From 3efdd200d47b68147265d7f6161aa07245f86516 Mon Sep 17 00:00:00 2001 From: kexkey Date: Fri, 19 Jun 2020 17:47:56 -0400 Subject: [PATCH 01/22] Extended batching, first draft --- api_auth_docker/api-sample.properties | 7 + .../templates/gatekeeper/api.properties | 7 + proxy_docker/app/data/cyphernode.sql | 17 +- .../app/data/sqlmigrate20181213_0-0.1.sh | 0 .../app/data/sqlmigrate20190104_0.1-0.2.sh | 0 .../app/data/sqlmigrate20190130_0.1-0.2.sh | 0 .../data/sqlmigrate20191127_0.2.4-0.3.0.sh | 0 .../data/sqlmigrate20200610_0.4.0-0.5.0.sh | 14 + .../data/sqlmigrate20200610_0.4.0-0.5.0.sql | 16 + proxy_docker/app/script/batching.sh | 823 ++++++++++++++++++ proxy_docker/app/script/blockchainrpc.sh | 11 + proxy_docker/app/script/callbacks_job.sh | 2 +- proxy_docker/app/script/newblock.sh | 2 + proxy_docker/app/script/requesthandler.sh | 210 ++++- proxy_docker/app/script/test-batching.sh | 322 +++++++ proxy_docker/app/script/walletoperations.sh | 113 +-- 16 files changed, 1430 insertions(+), 114 deletions(-) mode change 100644 => 100755 proxy_docker/app/data/sqlmigrate20181213_0-0.1.sh mode change 100644 => 100755 proxy_docker/app/data/sqlmigrate20190104_0.1-0.2.sh mode change 100644 => 100755 proxy_docker/app/data/sqlmigrate20190130_0.1-0.2.sh mode change 100644 => 100755 proxy_docker/app/data/sqlmigrate20191127_0.2.4-0.3.0.sh create mode 100755 proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sh create mode 100644 proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql create mode 100644 proxy_docker/app/script/batching.sh create mode 100755 proxy_docker/app/script/test-batching.sh diff --git a/api_auth_docker/api-sample.properties b/api_auth_docker/api-sample.properties index 27ddfd6..74c2cc4 100644 --- a/api_auth_docker/api-sample.properties +++ b/api_auth_docker/api-sample.properties @@ -31,6 +31,7 @@ action_ln_decodebolt11=watcher action_ln_listpeers=watcher action_ln_getroute=watcher action_ln_listpays=watcher +action_bitcoin_estimatesmartfee=watcher # Spender can do what the watcher can do, plus: action_getxnslist=spender @@ -55,6 +56,12 @@ action_ln_decodebolt11=spender action_ln_connectfund=spender action_ln_listfunds=spender action_ln_withdraw=spender +action_createbatch=spender +action_updatebatch=spender +action_removefrombatch=spender +action_listbatches=spender +action_getbatch=spender +action_getbatchdetails=spender # Admin can do what the spender can do, plus: diff --git a/cyphernodeconf_docker/templates/gatekeeper/api.properties b/cyphernodeconf_docker/templates/gatekeeper/api.properties index d22c2f5..7350b0b 100644 --- a/cyphernodeconf_docker/templates/gatekeeper/api.properties +++ b/cyphernodeconf_docker/templates/gatekeeper/api.properties @@ -36,6 +36,7 @@ action_ln_decodebolt11=watcher action_ln_listpeers=watcher action_ln_getroute=watcher action_ln_listpays=watcher +action_bitcoin_estimatesmartfee=watcher # Spender can do what the watcher can do, plus: action_get_txns_spending=spender @@ -59,6 +60,12 @@ action_ln_decodebolt11=spender action_ln_connectfund=spender action_ln_listfunds=spender action_ln_withdraw=spender +action_createbatch=spender +action_updatebatch=spender +action_removefrombatch=spender +action_listbatches=spender +action_getbatch=spender +action_getbatchdetails=spender # Admin can do what the spender can do, plus: diff --git a/proxy_docker/app/data/cyphernode.sql b/proxy_docker/app/data/cyphernode.sql index 743ce18..7fb3621 100644 --- a/proxy_docker/app/data/cyphernode.sql +++ b/proxy_docker/app/data/cyphernode.sql @@ -64,9 +64,24 @@ CREATE TABLE recipient ( address TEXT, amount REAL, tx_id INTEGER REFERENCES tx, - inserted_ts INTEGER DEFAULT CURRENT_TIMESTAMP + inserted_ts INTEGER DEFAULT CURRENT_TIMESTAMP, + webhook_url TEXT, + calledback INTEGER DEFAULT FALSE, + calledback_ts INTEGER, + batch_id INTEGER REFERENCES batch, + label TEXT, ); CREATE INDEX idx_recipient_address ON recipient (address); +CREATE INDEX idx_recipient_label ON recipient (label); + +CREATE TABLE batch ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT UNIQUE, + conf_target INTEGER, + feerate REAL, + inserted_ts INTEGER DEFAULT CURRENT_TIMESTAMP +); +INSERT INTO batch (id, label, conf_target, feerate) VALUES (1, "default", 6, NULL); CREATE TABLE watching_by_txid ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/proxy_docker/app/data/sqlmigrate20181213_0-0.1.sh b/proxy_docker/app/data/sqlmigrate20181213_0-0.1.sh old mode 100644 new mode 100755 diff --git a/proxy_docker/app/data/sqlmigrate20190104_0.1-0.2.sh b/proxy_docker/app/data/sqlmigrate20190104_0.1-0.2.sh old mode 100644 new mode 100755 diff --git a/proxy_docker/app/data/sqlmigrate20190130_0.1-0.2.sh b/proxy_docker/app/data/sqlmigrate20190130_0.1-0.2.sh old mode 100644 new mode 100755 diff --git a/proxy_docker/app/data/sqlmigrate20191127_0.2.4-0.3.0.sh b/proxy_docker/app/data/sqlmigrate20191127_0.2.4-0.3.0.sh old mode 100644 new mode 100755 diff --git a/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sh b/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sh new file mode 100755 index 0000000..9cb8049 --- /dev/null +++ b/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +echo "Checking for extended batching support in DB..." +count=$(sqlite3 $DB_FILE "select count(*) from pragma_table_info('recipient') where name='batch_id'") +if [ "${count}" -eq "0" ]; then + # batch_id not there, we have to migrate + echo "Migrating database for extended batching support..." + echo "Backing up current DB..." + cp $DB_FILE $DB_FILE-sqlmigrate20200610_0.4.0-0.5.0 + echo "Altering DB..." + cat sqlmigrate20200610_0.4.0-0.5.0.sql | sqlite3 $DB_FILE +else + echo "Database extended batching support migration already done, skipping!" +fi diff --git a/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql b/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql new file mode 100644 index 0000000..bc8fd39 --- /dev/null +++ b/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql @@ -0,0 +1,16 @@ + +CREATE TABLE batch ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT UNIQUE, + conf_target INTEGER, + feerate REAL, + inserted_ts INTEGER DEFAULT CURRENT_TIMESTAMP +); +INSERT INTO batch (id, label, conf_target, feerate) VALUES (1, "default", 6, NULL); + +ALTER TABLE recipient ADD COLUMN webhook_url TEXT; +ALTER TABLE recipient ADD COLUMN batch_id INTEGER REFERENCES batch; +ALTER TABLE recipient ADD COLUMN label INTEGER REFERENCES batch; +ALTER TABLE recipient ADD COLUMN calledback INTEGER DEFAULT FALSE; +ALTER TABLE recipient ADD COLUMN calledback_ts INTEGER; +CREATE INDEX idx_recipient_label ON recipient (label); diff --git a/proxy_docker/app/script/batching.sh b/proxy_docker/app/script/batching.sh new file mode 100644 index 0000000..f95b88f --- /dev/null +++ b/proxy_docker/app/script/batching.sh @@ -0,0 +1,823 @@ +#!/bin/sh + +. ./trace.sh +. ./sendtobitcoinnode.sh + +createbatch() { + trace "Entering createbatch()..." + + # POST http://192.168.111.152:8080/createbatch + # + # args: + # - batchLabel, optional, id can be used to reference the batch + # - confTarget, optional, overriden by batchspend's confTarget, default Bitcoin Core conf_target will be used if not supplied + # NOTYET - feeRate, sat/vB, optional, overrides confTarget if supplied, overriden by batchspend's feeRate, default Bitcoin Core fee policy will be used if not supplied + # + # response: + # - id, the batch id + # + # BODY {"batchLabel":"lowfees","confTarget":32} + # NOTYET BODY {"batchLabel":"highfees","feeRate":231.8} + + local request=${1} + local response + local label=$(echo "${request}" | jq ".batchLabel") + trace "[createbatch] label=${label}" + local conf_target=$(echo "${request}" | jq ".confTarget") + trace "[createbatch] conf_target=${conf_target}" + local feerate=$(echo "${request}" | jq ".feeRate") + trace "[createbatch] feerate=${feerate}" + + # if [ "${feerate}" != "null" ]; then + # # If not null, let's nullify conf_target since feerate overrides it + # conf_target="null" + # trace "[createbatch] Overriding conf_target=${conf_target}" + # fi + + local batch_id + + batch_id=$(sql "INSERT OR IGNORE INTO batch (label, conf_target, feerate) VALUES (${label}, ${conf_target}, ${feerate}); SELECT LAST_INSERT_ROWID();") + + if ("${batch_id}" -eq "0"); then + trace "[createbatch] Could not insert" + response='{"result":null,"error":{"code":-32700,"message":"Could not create batch, label probably already exists","data":'${request}'}}' + else + trace "[createbatch] Inserted" + response='{"result":{"batchId":'${batch_id}'},"error":null}' + fi + + echo "${response}" +} + +updatebatch() { + trace "Entering updatebatch()..." + + # POST http://192.168.111.152:8080/updatebatch + # + # args: + # - batchId, optional, batch id to update, will update default batch if not supplied + # - batchLabel, optional, id can be used to reference the batch, will update default batch if not supplied, if id is present then change the label with supplied text + # - confTarget, optional, new confirmation target for the batch + # NOTYET - feeRate, sat/vB, optional, new feerate for the batch + # + # response: + # - batchId, the batch id + # - batchLabel, the batch label + # - confTarget, the batch default confirmation target + # NOTYET - feeRate, the batch default feerate + # + # BODY {"batchId":5,"confTarget":12} + # NOTYET BODY {"batchLabel":"highfees","feeRate":400} + # NOTYET BODY {"batchId":3,"batchLabel":"ultrahighfees","feeRate":800} + # BODY {"batchLabel":"fast","confTarget":2} + + local request=${1} + local response + local whereclause + local returncode + + local id=$(echo "${request}" | jq ".batchId") + trace "[updatebatch] id=${id}" + local label=$(echo "${request}" | jq ".batchLabel") + trace "[updatebatch] label=${label}" + local conf_target=$(echo "${request}" | jq ".confTarget") + trace "[updatebatch] conf_target=${conf_target}" + local feerate=$(echo "${request}" | jq ".feeRate") + trace "[updatebatch] feerate=${feerate}" + + if [ "${id}" = "null" ] && [ "${label}" = "null" ]; then + # If id and label are null, use default batch + trace "[updatebatch] Using default batch 1" + id=1 + fi + + # if [ "${feerate}" != "null" ]; then + # # If not null, let's nullify conf_target since feerate overrides it + # conf_target="null" + # trace "[updatebatch] Overriding conf_target=${conf_target}" + # fi + + if [ "${id}" = "null" ]; then + whereclause="label=${label}" + else + whereclause="id = ${id}" + fi + + sql "UPDATE batch set label=${label}, conf_target=${conf_target}, feerate=${feerate} WHERE ${whereclause}" + returncode=$? + trace_rc ${returncode} + if [ "${returncode}" -ne 0 ]; then + response='{"result":null,"error":{"code":-32700,"message":"Could not update batch","data":'${request}'}}' + else + response='{"result":{"batchId":'${id}'},"error":null}' + fi + + echo "${response}" +} + +addtobatch() { + trace "Entering addtobatch()..." + + # POST http://192.168.111.152:8080/addtobatch + # + # args: + # - address, required, desination address + # - amount, required, amount to send to the destination address + # - batchId, optional, the id of the batch to which the output will be added, default batch if not supplied, overrides batchLabel + # - batchLabel, optional, the label of the batch to which the output will be added, default batch if not supplied + # - webhookUrl, optional, the webhook to call when the batch is broadcast + # + # response: + # - id, the id of the added output + # - nbOutputs, the number of outputs currently in the batch + # - oldest, the timestamp of the oldest output in the batch + # - pendingTotal, the current sum of the batch's output amounts + # + # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233} + # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batchId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} + # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batchLabel":"lowfees","webhookUrl":"https://myCypherApp:3000/batchExecuted"} + # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batchId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} + + local request=${1} + local response + local returncode=0 + local inserted_id + local row + + local address=$(echo "${request}" | jq ".address") + trace "[addtobatch] address=${address}" + local amount=$(echo "${request}" | jq ".amount") + trace "[addtobatch] amount=${amount}" + local label=$(echo "${request}" | jq ".outputLabel") + trace "[addtobatch] label=${label}" + local batch_id=$(echo "${request}" | jq ".batchId") + trace "[addtobatch] batch_id=${batch_id}" + local batch_label=$(echo "${request}" | jq ".batchLabel") + trace "[addtobatch] batch_label=${batch_label}" + local webhook_url=$(echo "${request}" | jq ".webhookUrl") + trace "[addtobatch] webhook_url=${webhook_url}" + + if [ "${batch_id}" = "null" ] && [ "${batch_label}" = "null" ]; then + # If batch_id and batch_label are null, use default batch + trace "[addtobatch] Using default batch 1" + batch_id=1 + fi + + if [ "${batch_id}" = "null" ]; then + # Using batch_label + batch_id=$(sql "SELECT id FROM batch WHERE label=${batch_label}") + returncode=$? + trace_rc ${returncode} + fi + + if [ -z "${batch_id}" ]; then + # batchLabel not found + response='{"result":null,"error":{"code":-32700,"message":"batch not found","data":'${request}'}}' + else + inserted_id=$(sql "INSERT INTO recipient (address, amount, webhook_url, batch_id, label) VALUES (${address}, ${amount}, ${webhook_url}, ${batch_id}, ${label}); SELECT LAST_INSERT_ROWID();") + returncode=$? + trace_rc ${returncode} + + if [ "${returncode}" -ne 0 ]; then + response='{"result":null,"error":{"code":-32700,"message":"Could not add to batch","data":'${request}'}}' + else + row=$(sql "SELECT COUNT(id), MIN(inserted_ts), SUM(amount) FROM recipient WHERE tx_id IS NULL AND batch_id=${batch_id}") + returncode=$? + trace_rc ${returncode} + + local count=$(echo "${row}" | cut -d '|' -f1) + trace "[addtobatch] count=${count}" + local oldest=$(echo "${row}" | cut -d '|' -f2) + trace "[addtobatch] oldest=${oldest}" + local total=$(echo "${row}" | cut -d '|' -f3) + trace "[addtobatch] total=${total}" + + response='{"result":{"batchId":'${batch_id}',"outputId":'${inserted_id}',"nbOutputs":'${count}',"oldest":"'${oldest}'","pendingTotal":'${total}'},"error":null}' + fi + fi + + echo "${response}" +} + +removefrombatch() { + trace "Entering removefrombatch()..." + + # POST http://192.168.111.152:8080/removefrombatch + # + # args: + # - outputId, required, id of the output to remove + # + # response: + # - outputId, the id of the removed output if found + # - nbOutputs, the number of outputs currently in the batch + # - oldest, the timestamp of the oldest output in the batch + # - pendingTotal, the current sum of the batch's output amounts + # + # BODY {"id":72} + + local request=${1} + local response + local returncode=0 + local row + local batch_id + + local id=$(echo "${request}" | jq ".outputId") + trace "[removefrombatch] id=${id}" + + if [ "${id}" = "null" ]; then + # id is required + trace "[removefrombatch] id missing" + response='{"result":null,"error":{"code":-32700,"message":"outputId is required","data":'${request}'}}' + else + batch_id=$(sql "SELECT batch_id FROM recipient WHERE id=${id}") + returncode=$? + trace_rc ${returncode} + + if [ -n "${batch_id}" ]; then + sql "DELETE FROM recipient WHERE id=${id}" + returncode=$? + trace_rc ${returncode} + + if [ "${returncode}" -ne 0 ]; then + response='{"result":null,"error":{"code":-32700,"message":"Output was not removed","data":'${request}'}}' + else + row=$(sql "SELECT COUNT(id), COALESCE(MIN(inserted_ts), 0), COALESCE(SUM(amount), 0.00000000) FROM recipient WHERE tx_id IS NULL AND batch_id=${batch_id}") + returncode=$? + trace_rc ${returncode} + + local count=$(echo "${row}" | cut -d '|' -f1) + trace "[removefrombatch] count=${count}" + local oldest=$(echo "${row}" | cut -d '|' -f2) + trace "[removefrombatch] oldest=${oldest}" + local total=$(echo "${row}" | cut -d '|' -f3) + trace "[removefrombatch] total=${total}" + + response='{"result":{"batchId":'${batch_id}',"nbOutputs":'${count}',"oldest":"'${oldest}'","pendingTotal":'${total}'},"error":null}' + fi + else + response='{"result":null,"error":{"code":-32700,"message":"Output not found or already spent","data":'${request}'}}' + fi + fi + + echo "${response}" +} + +batchspend() { + trace "Entering batchspend()..." + + # POST http://192.168.111.152:8080/batchspend + # + # args: + # - batchId, optional, id of the batch to spend, overrides batchLabel, default batch will be spent if not supplied + # - batchLabel, optional, label of the batch to spend, default batch will be spent if not supplied + # - confTarget, optional, overrides default value of createbatch, default to value of createbatch, default Bitcoin Core conf_target will be used if not supplied + # NOTYET - feeRate, optional, overrides confTarget if supplied, overrides default value of createbatch, default to value of createbatch, default Bitcoin Core value will be used if not supplied + # + # response: + # - txid, the txid of the batch + # - nbOutputs, the number of outputs spent in the batch + # - oldest, the timestamp of the oldest output in the spent batch + # - total, the sum of the spent batch's output amounts + # - txid, the transaction txid + # - hash, the transaction hash + # - tx details: size, vsize, replaceable, fee + # - outputs + # + # BODY {} + # BODY {"batchId":"34","confTarget":12} + # NOTYET BODY {"batchLabel":"highfees","feeRate":233.7} + # BODY {"batchId":"411","confTarget":6} + + local request=${1} + local response + local returncode=0 + local row + local whereclause + + local batch_id=$(echo "${request}" | jq ".batchId") + trace "[batchspend] batch_id=${batch_id}" + local batch_label=$(echo "${request}" | jq ".batchLabel") + trace "[batchspend] batch_label=${batch_label}" + local conf_target=$(echo "${request}" | jq ".confTarget") + trace "[batchspend] conf_target=${conf_target}" + local feerate=$(echo "${request}" | jq ".feeRate") + trace "[batchspend] feerate=${feerate}" + + if [ "${batch_id}" = "null" ] && [ "${batch_label}" = "null" ]; then + # If batch_id and batch_label are null, use default batch + trace "[batchspend] Using default batch 1" + batch_id=1 + fi + + if [ "${batch_id}" = "null" ]; then + # Using batch_label + whereclause="label=${batch_label}" + else + whereclause="id=${batch_id}" + fi + + local batch=$(sql "SELECT id, conf_target, feerate FROM batch WHERE ${whereclause}") + returncode=$? + trace_rc ${returncode} + + if [ -z "${batch}" ]; then + # batchLabel not found + response='{"result":null,"error":{"code":-32700,"message":"batch not found","data":'${request}'}}' + else + # All good, let's try to batch spend! + + # NOTYET + # We'll use supplied feerate + # If not supplied, we'll use supplied conf_target + # If not supplied, we'll use batch default feerate + # If not set, we'll use batch default conf_target + # If not set, default Bitcoin Core fee policy will be used + + # We'll use the supplied conf_target + # If not supplied, we'll use the batch default conf_target + # If not set, default Bitcoin Core fee policy will be used + + # if [ "${feerate}" != "null" ]; then + # # If not null, let's nullify conf_target since feerate overrides it + # conf_target= + # trace "[batchspend] Overriding conf_target=${conf_target}" + # else + # if [ "${conf_target}" = "null" ]; then + # feerate=$(echo "${batch}" | cut -d '|' -f3) + # if [ -z "${feerate}" ]; then + # # If null, let's use batch conf_target + # conf_target=$(echo "${batch}" | cut -d '|' -f2) + # fi + # fi + # fi + + if [ "${conf_target}" = "null" ]; then + conf_target=$(echo "${batch}" | cut -d '|' -f2) + trace "[batchspend] Using batch default conf_target=${conf_target}" + fi + + batch_id=$(echo "${batch}" | cut -d '|' -f1) + + local batching=$(sql "SELECT address, amount, id, webhook_url FROM recipient WHERE tx_id IS NULL AND batch_id=${batch_id}") + trace "[batchspend] batching=${batching}" + + local data + local recipientsjson + local webhooks_data + local id_inserted + local tx_details + local tx_raw_details + local address + local amount + local IFS=$'\n' + for row in ${batching} + do + trace "[batchspend] row=${row}" + address=$(echo "${row}" | cut -d '|' -f1) + trace "[batchspend] address=${address}" + amount=$(echo "${row}" | cut -d '|' -f2) + trace "[batchspend] amount=${amount}" + recipient_id=$(echo "${row}" | cut -d '|' -f3) + trace "[batchspend] recipient_id=${recipient_id}" + webhook_url=$(echo "${row}" | cut -d '|' -f4) + trace "[batchspend] webhook_url=${webhook_url}" + + if [ -z "${recipientsjson}" ]; then + whereclause="\"${recipient_id}\"" + recipientsjson="\"${address}\":${amount}" + webhooks_data="{\"outputId\":${recipient_id},\"address\":\"${address}\",\"amount\":${amount},\"webhookUrl\":\"${webhook_url}\"}" + else + whereclause="${whereclause},\"${recipient_id}\"" + recipientsjson="${recipientsjson},\"${address}\":${amount}" + webhooks_data="${webhooks_data},{\"outputId\":${recipient_id},\"address\":\"${address}\",\"amount\":${amount},\"webhookUrl\":\"${webhook_url}\"}" + fi + done + + local bitcoincore_args="{\"method\":\"sendmany\",\"params\":[\"\", {${recipientsjson}}" + if [ -n "${conf_target}" ]; then + bitcoincore_args="${bitcoincore_args}, 1, \"\", null, null, ${conf_target}" + fi + bitcoincore_args="${bitcoincore_args}]}" + data=$(send_to_spender_node "${bitcoincore_args}") + returncode=$? + trace_rc ${returncode} + trace "[batchspend] data=${data}" + + if [ "${returncode}" -eq 0 ]; then + local txid=$(echo "${data}" | jq -r ".result") + trace "[batchspend] txid=${txid}" + + # Let's get transaction details on the spending wallet so that we have fee information + tx_details=$(get_transaction ${txid} "spender") + tx_raw_details=$(get_rawtransaction ${txid}) + + # Amounts and fees are negative when spending so we absolute those fields + local tx_hash=$(echo "${tx_raw_details}" | jq '.result.hash') + local tx_ts_firstseen=$(echo "${tx_details}" | jq '.result.timereceived') + local tx_amount=$(echo "${tx_details}" | jq '.result.amount | fabs' | awk '{ printf "%.8f", $0 }') + local tx_size=$(echo "${tx_raw_details}" | jq '.result.size') + local tx_vsize=$(echo "${tx_raw_details}" | jq '.result.vsize') + local tx_replaceable=$(echo "${tx_details}" | jq -r '.result."bip125-replaceable"') + trace "[batchspend] tx_replaceable=${tx_replaceable}" + tx_replaceable=$([ "${tx_replaceable}" = "yes" ] && echo "true" || echo "false") + trace "[batchspend] tx_replaceable=${tx_replaceable}" + local fees=$(echo "${tx_details}" | jq '.result.fee | fabs' | awk '{ printf "%.8f", $0 }') + # Sometimes raw tx are too long to be passed as paramater, so let's write + # it to a temp file for it to be read by sqlite3 and then delete the file + echo "${tx_raw_details}" > batchspend-rawtx-${txid}.blob + + # Get the info on the batch before setting it to done + row=$(sql "SELECT COUNT(id), COALESCE(MIN(inserted_ts), 0), COALESCE(SUM(amount), 0.00000000) FROM recipient WHERE tx_id IS NULL AND batch_id=${batch_id}") + + # Let's insert the txid in our little DB -- then we'll already have it when receiving confirmation + id_inserted=$(sql "INSERT OR IGNORE INTO tx (txid, hash, confirmations, timereceived, fee, size, vsize, is_replaceable, raw_tx) VALUES (\"${txid}\", ${tx_hash}, 0, ${tx_ts_firstseen}, ${fees}, ${tx_size}, ${tx_vsize}, ${tx_replaceable}, readfile('batchspend-rawtx-${txid}.blob')); SELECT LAST_INSERT_ROWID();") + returncode=$? + trace_rc ${returncode} + if [ "${returncode}" -eq 0 ]; then + if [ "${id_inserted}" -eq 0 ]; then + id_inserted=$(sql "SELECT id FROM tx WHERE txid=\"${txid}\"") + fi + trace "[batchspend] id_inserted: ${id_inserted}" + sql "UPDATE recipient SET tx_id=${id_inserted} WHERE id IN (${whereclause})" + trace_rc $? + fi + + # Use the selected row above (before the insert) + local count=$(echo "${row}" | cut -d '|' -f1) + trace "[batchspend] count=${count}" + local oldest=$(echo "${row}" | cut -d '|' -f2) + trace "[batchspend] oldest=${oldest}" + local total=$(echo "${row}" | cut -d '|' -f3) + trace "[batchspend] total=${total}" + + response='{"result":{"batchId":'${batch_id}',"nbOutputs":'${count}',"oldest":"'${oldest}'","total":'${total} + response="${response},\"txid\":\"${txid}\",\"hash\":${tx_hash},\"details\":{\"firstseen\":${tx_ts_firstseen},\"size\":${tx_size},\"vsize\":${tx_vsize},\"replaceable\":${tx_replaceable},\"fee\":${fees}},\"outputs\":{${recipientsjson}}}" + response="${response},\"error\":null}" + + # Delete the temp file containing the raw tx (see above) + rm batchspend-rawtx-${txid}.blob + + batch_webhooks "[${webhooks_data}]" '"batchId":'${batch_id}',"txid":"'${txid}'","hash":'${tx_hash}',"details":{"firstseen":'${tx_ts_firstseen}',"size":'${tx_size}',"vsize":'${tx_vsize}',"replaceable":'${tx_replaceable}',"fee":'${fees}'}' + + else + local message=$(echo "${data}" | jq -e ".error.message") + response='{"result":null,"error":{"code":-32700,"message":'${message}',"data":'${request}'}}' + fi + fi + + trace "[batchspend] responding=${response}" + echo "${response}" +} + +batch_check_webhooks() { + trace "Entering batch_check_webhooks()..." + + local webhooks_data + local address + local amount + local recipient_id + local webhook_url + local batch_id + local txid + local tx_hash + local tx_ts_firstseen + local tx_size + local tx_vsize + local tx_replaceable + local fees + + local batching=$(sql "SELECT address, amount, r.id, webhook_url, b.id, t.txid, t.hash, t.timereceived, t.fee, t.size, t.vsize, t.is_replaceable FROM recipient r, batch b, tx t WHERE r.batch_id=b.id AND r.tx_id=t.id AND NOT calledback AND tx_id IS NOT NULL AND webhook_url IS NOT NULL") + trace "[batch_check_webhooks] batching=${batching}" + + local IFS=$'\n' + for row in ${batching} + do + trace "[batch_check_webhooks] row=${row}" + address=$(echo "${row}" | cut -d '|' -f1) + trace "[batch_check_webhooks] address=${address}" + amount=$(echo "${row}" | cut -d '|' -f2) + trace "[batch_check_webhooks] amount=${amount}" + recipient_id=$(echo "${row}" | cut -d '|' -f3) + trace "[batch_check_webhooks] recipient_id=${recipient_id}" + webhook_url=$(echo "${row}" | cut -d '|' -f4) + trace "[batch_check_webhooks] webhook_url=${webhook_url}" + batch_id=$(echo "${row}" | cut -d '|' -f5) + trace "[batch_check_webhooks] batch_id=${batch_id}" + txid=$(echo "${row}" | cut -d '|' -f6) + trace "[batch_check_webhooks] txid=${txid}" + tx_hash=$(echo "${row}" | cut -d '|' -f7) + trace "[batch_check_webhooks] tx_hash=${tx_hash}" + tx_ts_firstseen=$(echo "${row}" | cut -d '|' -f8) + trace "[batch_check_webhooks] tx_ts_firstseen=${tx_ts_firstseen}" + tx_size=$(echo "${row}" | cut -d '|' -f9) + trace "[batch_check_webhooks] tx_size=${tx_size}" + tx_vsize=$(echo "${row}" | cut -d '|' -f10) + trace "[batch_check_webhooks] tx_vsize=${tx_vsize}" + tx_replaceable=$(echo "${row}" | cut -d '|' -f11) + trace "[batch_check_webhooks] tx_replaceable=${tx_replaceable}" + fees=$(echo "${row}" | cut -d '|' -f12) + trace "[batch_check_webhooks] fees=${fees}" + + webhooks_data="{\"outputId\":${recipient_id},\"address\":\"${address}\",\"amount\":${amount},\"webhookUrl\":\"${webhook_url}\"}" + + batch_webhooks "[${webhooks_data}]" '"batchId":'${batch_id}',"txid":"'${txid}'","hash":"'${tx_hash}'","details":{"firstseen":'${tx_ts_firstseen}',"size":'${tx_size}',"vsize":'${tx_vsize}',"replaceable":'${tx_replaceable}',"fee":'${fees}'}' + done +} + +batch_webhooks() { + trace "Entering batch_webhooks()..." + + # webhooks_data: + # {"outputId":1,"address":"1abc","amount":0.12,"webhookUrl":"https://bleah.com/batchwebhook"}" + local webhooks_data=${1} + trace "[batch_webhooks] webhooks_data=${webhooks_data}" + + # tx: + # {"batchId":1,"txid":"abc123","hash":"abc123","details":{"firstseen":123123,"size":200,"vsize":141,"replaceable":true,"fee":0.00001}}' + local tx=${2} + trace "[batch_webhooks] tx=${tx}" + + local outputs + local output_id + local address + local amount + local webhook_url + local body + local successful_recipient_ids + local returncode + + outputs=$(echo "${webhooks_data}" | jq -Mc ".[]") + + local output + local IFS=$'\n' + for output in ${outputs} + do + webhook_url=$(echo "${output}" | jq -r ".webhookUrl") + trace "[batch_webhooks] webhook_url=${webhook_url}" + + if [ -z "${webhook_url}" ] || [ "${webhook_url}" = "null" ]; then + trace "[batch_webhooks] Empty webhook_url, skipping" + continue + fi + + output_id=$(echo "${output}" | jq ".outputId") + trace "[batch_webhooks] output_id=${output_id}" + address=$(echo "${output}" | jq ".address") + trace "[batch_webhooks] address=${address}" + amount=$(echo "${output}" | jq ".amount") + trace "[batch_webhooks] amount=${amount}" + + body='{"outputId":'${output_id}',"address":'${address}',"amount":'${amount}','${tx}'}' + trace "[batch_webhooks] body=${body}" + + notify_web "${webhook_url}" "${body}" ${TOR_ADDR_WATCH_WEBHOOKS} + returncode=$? + trace_rc ${returncode} + + if [ "${returncode}" -eq 0 ]; then + if [ -n "${successful_recipient_ids}" ]; then + successful_recipient_ids="${successful_recipient_ids},${output_id}" + else + successful_recipient_ids="${output_id}" + fi + else + trace "[batch_webhooks] callback failed, won't set to true in DB" + fi + done + + sql "UPDATE recipient SET calledback=1, calledback_ts=CURRENT_TIMESTAMP WHERE id IN (${successful_recipient_ids})" + trace_rc $? +} + +listbatches() { + trace "Entering listbatches()..." + + # curl (GET) http://192.168.111.152:8080/listbatches + # + # {"result":[ + # {"batchId":1,"batchLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143}, + # {"batchId":2,"batchLabel":"lowfee","confTarget":32,"nbOutputs":44,"oldest":123123,"pendingTotal":0.49827387}, + # {"batchId":3,"batchLabel":"highfee","confTarget":2,"nbOutputs":7,"oldest":123123,"pendingTotal":4.16843782} + # ], + # "error":null} + + + local batches=$(sql "SELECT b.id, '{\"batchId\":' || b.id || ',\"batchLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"pendingTotal\":' ||COALESCE(SUM(amount), 0.00000000) || '}' FROM batch b LEFT JOIN recipient r ON r.batch_id=b.id AND r.tx_id IS NULL GROUP BY b.id") + trace "[listbatches] batches=${batches}" + + local returncode + local response + local batch + local jsonstring + local IFS=$'\n' + for batch in ${batches} + do + jsonstring=$(echo ${batch} | cut -d '|' -f2) + if [ -z "${response}" ]; then + response='{"result":['${jsonstring} + else + response="${response},${jsonstring}" + fi + done + + response=${response}'],"error":null}' + trace "[listbatches] responding=${response}" + echo "${response}" +} + +getbatch() { + trace "Entering getbatch()..." + + # POST (GET) http://192.168.111.152:8080/getbatch + # + # args: + # - batchId, optional, id of the batch to spend, overrides batchLabel, default batch will be spent if not supplied + # - batchLabel, optional, label of the batch to spend, default batch will be spent if not supplied + # + # response: + # {"result":{"batchId":1,"batchLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143},"error":null} + # + # BODY {} + # BODY {"batchId":34} + + local request=${1} + local response + local returncode=0 + local batch + local whereclause + + local batch_id=$(echo "${request}" | jq ".batchId") + trace "[getbatch] batch_id=${batch_id}" + local batch_label=$(echo "${request}" | jq ".batchLabel") + trace "[getbatch] batch_label=${batch_label}" + + if [ "${batch_id}" = "null" ] && [ "${batch_label}" = "null" ]; then + # If batch_id and batch_label are null, use default batch + trace "[getbatch] Using default batch 1" + batch_id=1 + fi + + if [ "${batch_id}" = "null" ]; then + # Using batch_label + whereclause="b.label=${batch_label}" + else + # Using batch_id + whereclause="b.id=${batch_id}" + fi + + batch=$(sql "SELECT b.id, '{\"batchId\":' || b.id || ',\"batchLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"pendingTotal\":' ||COALESCE(SUM(amount), 0.00000000) || '}' FROM batch b LEFT JOIN recipient r ON r.batch_id=b.id AND r.tx_id IS NULL WHERE ${whereclause} GROUP BY b.id") + trace "[getbatch] batch=${batch}" + + if [ -n "${batch}" ]; then + batch=$(echo "${batch}" | cut -d '|' -f2) + response='{"result":'${batch}',"error":null}' + else + response='{"result":null,"error":{"code":-32700,"message":"batch not found","data":'${request}'}}' + fi + + echo "${response}" +} + +getbatchdetails() { + trace "Entering getbatchdetails()..." + + # POST (GET) http://192.168.111.152:8080/getbatchdetails + # + # args: + # - batchId, optional, id of the batch to spend, overrides batchLabel, default batch will be spent if not supplied + # - batchLabel, optional, label of the batch to spend, default batch will be spent if not supplied + # - txid, optional, if you want the details of an executed batch, supply the batch txid, will return current pending batch + # if not supplied + # + # response: + # {"result":{ + # "batchId":34, + # "batchLabel":"Special batch for a special client", + # "confTarget":6, + # "nbOutputs":83, + # "oldest":123123, + # "total":10.86990143, + # "txid":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", + # "hash":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", + # "details":{ + # "firstseen":123123, + # "size":424, + # "vsize":371, + # "replaceable":yes, + # "fee":0.00004112, + # "outputs":[ + # {"label":"order 1234","address":"1abc","amount":0.12}, + # {"label":"order 2345","address":"3abc","amount":0.66}, + # "bc1abc":2.848, + # ... + # ] + # } + # },"error":null} + # + # BODY {} + # BODY {"batchId":34} + + local request=${1} + local response + local returncode=0 + local batch + local tx + local outputsjson + local whereclause + + local batch_id=$(echo "${request}" | jq ".batchId") + trace "[getbatchdetails] batch_id=${batch_id}" + local batch_label=$(echo "${request}" | jq ".batchLabel") + trace "[getbatchdetails] batch_label=${batch_label}" + local txid=$(echo "${request}" | jq ".txid") + trace "[getbatchdetails] txid=${txid}" + + if [ "${batch_id}" = "null" ] && [ "${batch_label}" = "null" ]; then + # If batch_id and batch_label are null, use default batch + trace "[getbatchdetails] Using default batch 1" + batch_id=1 + fi + + if [ "${batch_id}" = "null" ]; then + # Using batch_label + whereclause="b.label=${batch_label}" + else + # Using batch_id + whereclause="b.id=${batch_id}" + fi + + if [ "${txid}" != "null" ]; then + # Using txid + whereclause="${whereclause} AND t.txid=${txid}" + else + # null txid + whereclause="${whereclause} AND t.txid IS NULL" + outerclause="AND r.tx_id IS NULL" + fi + + # First get the batch summary + batch=$(sql "SELECT b.id, COALESCE(t.id, 0), '{\"batchId\":' || b.id || ',\"batchLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"total\":' ||COALESCE(SUM(amount), 0.00000000) FROM batch b LEFT JOIN recipient r ON r.batch_id=b.id ${outerclause} LEFT JOIN tx t ON t.id=r.tx_id WHERE ${whereclause} GROUP BY b.id") + trace "[getbatchdetails] batch=${batch}" + + if [ -n "${batch}" ]; then + local tx_id + local outputs + + tx_id=$(echo "${batch}" | cut -d '|' -f2) + trace "[getbatchdetails] tx_id=${tx_id}" + if [ -n "${tx_id}" ]; then + # Using txid + outerclause="AND r.tx_id=${tx_id}" + else + # null txid + outerclause="AND r.tx_id IS NULL" + fi + + tx=$(sql "SELECT '\"txid\":\"' || txid || '\",\"hash\":\"' || hash || '\",\"details\":{\"firstseen\":' || timereceived || ',\"size\":' || size || ',\"vsize\":' || vsize || ',\"replaceable\":' || is_replaceable || ',\"fee\":' || fee || '}' FROM tx WHERE id=${tx_id}") + + batch_id=$(echo "${batch}" | cut -d '|' -f1) + outputs=$(sql "SELECT '{\"outputId\":' || id || ',\"outputLabel\":\"' || COALESCE(label, '') || '\",\"address\":\"' || address || '\",\"amount\":' || amount || ',\"addedTimestamp\":\"' || inserted_ts || '\"}' FROM recipient r WHERE batch_id=${batch_id} ${outerclause}") + + local output + local IFS=$'\n' + for output in ${outputs} + do + if [ -n "${outputsjson}" ]; then + outputsjson="${outputsjson},${output}" + else + outputsjson="${output}" + fi + done + + batch=$(echo "${batch}" | cut -d '|' -f3) + + response='{"result":'${batch} + if [ -n "${tx}" ]; then + response=${response}','${tx} + else + response=${response}',"txid":null,"hash":null' + fi + response=${response}',"outputs":['${outputsjson}']},"error":null}' + else + response='{"result":null,"error":{"code":-32700,"message":"batch not found or no corresponding txid","data":'${request}'}}' + fi + + echo "${response}" + +} + +# curl localhost:8888/listbatches | jq +# curl -d '{}' localhost:8888/getbatch | jq +# curl -d '{}' localhost:8888/getbatchdetails | jq +# curl -d '{"outputLabel":"test002","address":"1abd","amount":0.0002}' localhost:8888/addtobatch | jq +# curl -d '{}' localhost:8888/batchspend | jq +# curl -d '{"outputId":1}' localhost:8888/removefrombatch | jq + +# curl -d '{"batchLabel":"lowfees","confTarget":32}' localhost:8888/createbatch | jq +# curl localhost:8888/listbatches | jq + +# curl -d '{"batchLabel":"lowfees"}' localhost:8888/getbatch | jq +# curl -d '{"batchLabel":"lowfees"}' localhost:8888/getbatchdetails | jq +# curl -d '{"batchLabel":"lowfees","outputLabel":"test002","address":"1abd","amount":0.0002}' localhost:8888/addtobatch | jq +# curl -d '{"batchLabel":"lowfees"}' localhost:8888/batchspend | jq +# curl -d '{"batchLabel":"lowfees","outputId":9}' localhost:8888/removefrombatch | jq diff --git a/proxy_docker/app/script/blockchainrpc.sh b/proxy_docker/app/script/blockchainrpc.sh index 7d85aec..31ed9b6 100644 --- a/proxy_docker/app/script/blockchainrpc.sh +++ b/proxy_docker/app/script/blockchainrpc.sh @@ -103,3 +103,14 @@ validateaddress() { send_to_watcher_node "${data}" return $? } + +bitcoin_estimatesmartfee() { + trace "Entering bitcoin_estimatesmartfee()..." + + local conf_target=${1} + trace "[bitcoin_estimatesmartfee] conf_target=${conf_target}" + local data="{\"method\":\"estimatesmartfee\",\"params\":[\"${conf_target}\"]}" + trace "[bitcoin_estimatesmartfee] data=${data}" + send_to_watcher_node "${data}" + return $? +} diff --git a/proxy_docker/app/script/callbacks_job.sh b/proxy_docker/app/script/callbacks_job.sh index 3bc7a06..6bc7219 100644 --- a/proxy_docker/app/script/callbacks_job.sh +++ b/proxy_docker/app/script/callbacks_job.sh @@ -45,7 +45,7 @@ do_callbacks() { done callbacks=$(sql "SELECT id, label, bolt11, callback_url, payment_hash, msatoshi, status, pay_index, msatoshi_received, paid_at, description, expires_at FROM ln_invoice WHERE NOT calledback AND callback_failed") - trace "[do_callbacks LN] ln_callbacks=${callbacks}" + trace "[do_callbacks] ln_callbacks=${callbacks}" for row in ${callbacks} do diff --git a/proxy_docker/app/script/newblock.sh b/proxy_docker/app/script/newblock.sh index 7ecfa09..eb4c49d 100644 --- a/proxy_docker/app/script/newblock.sh +++ b/proxy_docker/app/script/newblock.sh @@ -3,6 +3,7 @@ . ./trace.sh . ./callbacks_txid.sh . ./blockchainrpc.sh +. ./batching.sh newblock() { trace "Entering newblock()..." @@ -22,4 +23,5 @@ newblock() { trace_rc ${returncode} do_callbacks_txid + batch_check_webhooks } diff --git a/proxy_docker/app/script/requesthandler.sh b/proxy_docker/app/script/requesthandler.sh index 63f63a1..bbf3836 100644 --- a/proxy_docker/app/script/requesthandler.sh +++ b/proxy_docker/app/script/requesthandler.sh @@ -20,6 +20,7 @@ . ./call_lightningd.sh . ./ots.sh . ./newblock.sh +. ./batching.sh main() { trace "Entering main()..." @@ -300,21 +301,226 @@ main() { response_to_client "${response}" ${?} break ;; + createbatch) + # POST http://192.168.111.152:8080/createbatch + # + # args: + # - batchLabel, optional, id can be used to reference the batch + # - confTarget, optional, overriden by batchspend's confTarget, default Bitcoin Core conf_target will be used if not supplied + # NOTYET - feeRate, sat/vB, optional, overrides confTarget if supplied, overriden by batchspend's feeRate, default Bitcoin Core fee policy will be used if not supplied + # + # response: + # - batchId, the batch id + # + # BODY {"batchLabel":"lowfees","confTarget":32} + # NOTYET BODY {"batchLabel":"highfees","feeRate":231.8} + + response=$(createbatch "${line}") + response_to_client "${response}" ${?} + break + ;; + updatebatch) + # POST http://192.168.111.152:8080/updatebatch + # + # args: + # - batchId, optional, batch id to update, will update default batch if not supplied + # - batchLabel, optional, id can be used to reference the batch, will update default batch if not supplied, if id is present then change the label with supplied text + # - confTarget, optional, new confirmation target for the batch + # NOTYET - feeRate, sat/vB, optional, new feerate for the batch + # + # response: + # - batchId, the batch id + # - batchLabel, the batch label + # - confTarget, the batch default confirmation target + # NOTYET - feeRate, the batch default feerate + # + # BODY {"batchId":5,"confTarget":12} + # NOTYET BODY {"batchLabel":"highfees","feeRate":400} + # NOTYET BODY {"batchId":3,"label":"ultrahighfees","feeRate":800} + # BODY {"batchLabel":"fast","confTarget":2} + + response=$(updatebatch "${line}") + response_to_client "${response}" ${?} + break + ;; addtobatch) # POST http://192.168.111.152:8080/addtobatch + # + # args: + # - address, required, desination address + # - amount, required, amount to send to the destination address + # - outputLabel, optional, if you want to reference this output + # - batchId, optional, the id of the batch to which the output will be added, default batch if not supplied, overrides batchLabel + # - batchLabel, optional, the label of the batch to which the output will be added, default batch if not supplied + # - webhookUrl, optional, the webhook to call when the batch is broadcast + # + # response: + # - outputId, the id of the added output + # - nbOutputs, the number of outputs currently in the batch + # - oldest, the timestamp of the oldest output in the batch + # - pendingTotal, the current sum of the batch's output amounts + # # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233} + # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batchId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} + # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batchLabel":"lowfees","webhookUrl":"https://myCypherApp:3000/batchExecuted"} + # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batchId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} - response=$(addtobatching $(echo "${line}" | jq -r ".address") $(echo "${line}" | jq ".amount")) + response=$(addtobatch "${line}") + response_to_client "${response}" ${?} + break + ;; + removefrombatch) + # POST http://192.168.111.152:8080/removefrombatch + # + # args: + # - outputId, required, id of the output to remove + # + # response: + # - outputId, the id of the removed output if found + # - nbOutputs, the number of outputs currently in the batch + # - oldest, the timestamp of the oldest output in the batch + # - pendingTotal, the current sum of the batch's output amounts + # + # BODY {"outputId":72} + + response=$(removefrombatch "${line}") response_to_client "${response}" ${?} break ;; batchspend) - # GET http://192.168.111.152:8080/batchspend + # POST http://192.168.111.152:8080/batchspend + # + # args: + # - batchId, optional, id of the batch to spend, overrides batchLabel, default batch will be spent if not supplied + # - batchLabel, optional, label of the batch to spend, default batch will be spent if not supplied + # - confTarget, optional, overrides default value of createbatch, default to value of createbatch, default Bitcoin Core conf_target will be used if not supplied + # NOTYET - feeRate, optional, overrides confTarget if supplied, overrides default value of createbatch, default to value of createbatch, default Bitcoin Core value will be used if not supplied + # + # response: + # - txid, the transaction txid + # - hash, the transaction hash + # - nbOutputs, the number of outputs spent in the batch + # - oldest, the timestamp of the oldest output in the spent batch + # - total, the sum of the spent batch's output amounts + # - tx details: size, vsize, replaceable, fee + # - outputs + # + # {"result":{ + # "batchId":34, + # "batchLabel":"Special batch for a special client", + # "confTarget":6, + # "nbOutputs":83, + # "oldest":123123, + # "total":10.86990143, + # "txid":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", + # "hash":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", + # "details":{ + # "firstseen":123123, + # "size":424, + # "vsize":371, + # "replaceable":yes, + # "fee":0.00004112 + # }, + # "outputs":{ + # "1abc":0.12, + # "3abc":0.66, + # "bc1abc":2.848, + # ... + # } + # } + # },"error":null} + # + # BODY {} + # BODY {"batchId":34,"confTarget":12} + # NOTYET BODY {"batchLabel":"highfees","feeRate":233.7} + # BODY {"batchId":411,"confTarget":6} response=$(batchspend "${line}") response_to_client "${response}" ${?} break ;; + getbatch) + # POST (GET) http://192.168.111.152:8080/getbatch + # + # args: + # - batchId, optional, id of the batch to spend, overrides batchLabel, default batch will be spent if not supplied + # - batchLabel, optional, label of the batch to spend, default batch will be spent if not supplied + # + # response: + # {"result":{"batchId":1,"batchLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143},"error":null} + # + # BODY {} + # BODY {"batchId":34} + + response=$(getbatch "${line}") + response_to_client "${response}" ${?} + break + ;; + getbatchdetails) + # POST (GET) http://192.168.111.152:8080/getbatchdetails + # + # args: + # - batchId, optional, id of the batch to spend, overrides batchLabel, default batch will be spent if not supplied + # - batchLabel, optional, label of the batch to spend, default batch will be spent if not supplied + # - txid, optional, if you want the details of an executed batch, supply the batch txid, will return current pending batch + # if not supplied + # + # response: + # {"result":{ + # "batchId":34, + # "batchLabel":"Special batch for a special client", + # "confTarget":6, + # "nbOutputs":83, + # "oldest":123123, + # "total":10.86990143, + # "txid":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", + # "hash":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", + # "details":{ + # "firstseen":123123, + # "size":424, + # "vsize":371, + # "replaceable":yes, + # "fee":0.00004112 + # }, + # "outputs":[ + # "1abc":0.12, + # "3abc":0.66, + # "bc1abc":2.848, + # ... + # ] + # } + # },"error":null} + # + # BODY {} + # BODY {"batchId":34} + + response=$(getbatchdetails "${line}") + response_to_client "${response}" ${?} + break + ;; + listbatches) + # curl (GET) http://192.168.111.152:8080/listbatches + # + # response: + # {"result":[ + # {"batchId":1,"batchLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143}, + # {"batchId":2,"batchLabel":"lowfee","confTarget":32,"nbOutputs":44,"oldest":123123,"pendingTotal":0.49827387}, + # {"batchId":3,"batchLabel":"highfee","confTarget":2,"nbOutputs":7,"oldest":123123,"pendingTotal":4.16843782} + # ], + # "error":null} + + response=$(listbatches) + response_to_client "${response}" ${?} + break + ;; + bitcoin_estimatesmartfee) + # POST http://192.168.111.152:8080/bitcoin_estimatesmartfee + # BODY {"confTarget":2} + + response=$(bitcoin_estimatesmartfee $(echo "${line}" | jq -r ".confTarget")) + response_to_client "${response}" ${?} + break + ;; deriveindex) # curl GET http://192.168.111.152:8080/deriveindex/25-30 # curl GET http://192.168.111.152:8080/deriveindex/34 diff --git a/proxy_docker/app/script/test-batching.sh b/proxy_docker/app/script/test-batching.sh new file mode 100755 index 0000000..eb2bab9 --- /dev/null +++ b/proxy_docker/app/script/test-batching.sh @@ -0,0 +1,322 @@ +#!/bin/sh + +# curl localhost:8888/listbatches | jq +# curl -d '{}' localhost:8888/getbatch | jq +# curl -d '{}' localhost:8888/getbatchdetails | jq +# curl -d '{"outputLabel":"test002","address":"1abd","amount":0.0002}' localhost:8888/addtobatch | jq +# curl -d '{}' localhost:8888/batchspend | jq +# curl -d '{"outputId":1}' localhost:8888/removefrombatch | jq + +# curl -d '{"batchLabel":"lowfees","confTarget":32}' localhost:8888/createbatch | jq +# curl localhost:8888/listbatches | jq + +# curl -d '{"batchLabel":"lowfees"}' localhost:8888/getbatch | jq +# curl -d '{"batchLabel":"lowfees"}' localhost:8888/getbatchdetails | jq +# curl -d '{"batchLabel":"lowfees","outputLabel":"test002","address":"1abd","amount":0.0002}' localhost:8888/addtobatch | jq +# curl -d '{"batchLabel":"lowfees"}' localhost:8888/batchspend | jq +# curl -d '{"batchLabel":"lowfees","outputId":9}' localhost:8888/removefrombatch | jq + +testbatching() { + local response + local id + local id2 + local data + local data2 + local address1 + local address2 + local amount1 + local amount2 + + local url1="$(hostname):1111/callback" + echo "url1=${url1}" + local url2="$(hostname):1112/callback" + echo "url2=${url2}" + + # List batches (should show at least empty default batch) + echo "Testing listbatches..." + response=$(curl -s proxy:8888/listbatches) + echo "response=${response}" + id=$(echo "${response}" | jq ".result[0].batchId") + echo "batchId=${id}" + if [ "${id}" -ne "1" ]; then + exit 10 + fi + echo "Tested listbatches." + + # getbatch the default batch + echo "Testing getbatch..." + response=$(curl -sd '{}' localhost:8888/getbatch) + echo "response=${response}" + data=$(echo "${response}" | jq -r ".result.batchLabel") + echo "batchLabel=${data}" + if [ "${data}" != "default" ]; then + exit 20 + fi + + response=$(curl -sd '{"batchId":1}' localhost:8888/getbatch) + echo "response=${response}" + data=$(echo "${response}" | jq -r ".result.batchLabel") + echo "batchLabel=${data}" + if [ "${data}" != "default" ]; then + exit 25 + fi + echo "Tested getbatch." + + # getbatchdetails the default batch + echo "Testing getbatchdetails..." + response=$(curl -sd '{}' localhost:8888/getbatchdetails) + echo "response=${response}" + data=$(echo "${response}" | jq -r ".result.batchLabel") + echo "batchLabel=${data}" + if [ "${data}" != "default" ]; then + exit 30 + fi + echo "${response}" | jq -e ".result.outputs" + if [ "$?" -ne 0 ]; then + exit 32 + fi + + response=$(curl -sd '{"batchId":1}' localhost:8888/getbatchdetails) + echo "response=${response}" + data=$(echo "${response}" | jq -r ".result.batchLabel") + echo "batchLabel=${data}" + if [ "${data}" != "default" ]; then + exit 35 + fi + echo "${response}" | jq -e ".result.outputs" + if [ "$?" -ne 0 ]; then + exit 37 + fi + echo "Tested getbatchdetails." + + # addtobatch to default batch + echo "Testing addtobatch..." + response=$(curl -sd '{"outputLabel":"test001","address":"test001","amount":0.001}' localhost:8888/addtobatch) + echo "response=${response}" + id=$(echo "${response}" | jq ".result.batchId") + echo "batchId=${id}" + if [ "${id}" -ne "1" ]; then + exit 40 + fi + id=$(echo "${response}" | jq -e ".result.outputId") + if [ "$?" -ne 0 ]; then + exit 42 + fi + echo "outputId=${id}" + + response=$(curl -sd '{"batchId":1,"outputLabel":"test002","address":"test002","amount":0.002}' localhost:8888/addtobatch) + echo "response=${response}" + id2=$(echo "${response}" | jq ".result.batchId") + echo "batchId=${id2}" + if [ "${id2}" -ne "1" ]; then + exit 40 + fi + id2=$(echo "${response}" | jq -e ".result.outputId") + if [ "$?" -ne 0 ]; then + exit 42 + fi + echo "outputId=${id2}" + echo "Tested addtobatch." + + # batchspend default batch + echo "Testing batchspend..." + response=$(curl -sd '{}' localhost:8888/batchspend) + echo "response=${response}" + echo "${response}" | jq -e ".error" + if [ "$?" -ne 0 ]; then + exit 44 + fi + echo "Tested batchspend." + + # getbatchdetails the default batch + echo "Testing getbatchdetails..." + response=$(curl -sd '{}' localhost:8888/getbatchdetails) + echo "response=${response}" + data=$(echo "${response}" | jq ".result.nbOutputs") + echo "nbOutputs=${data}" + echo "Tested getbatchdetails." + + # removefrombatch from default batch + echo "Testing removefrombatch..." + response=$(curl -sd '{"outputId":'${id}'}' localhost:8888/removefrombatch) + echo "response=${response}" + id=$(echo "${response}" | jq ".result.batchId") + echo "batchId=${id}" + if [ "${id}" -ne "1" ]; then + exit 50 + fi + + response=$(curl -sd '{"outputId":'${id2}'}' localhost:8888/removefrombatch) + echo "response=${response}" + id=$(echo "${response}" | jq ".result.batchId") + echo "batchId=${id}" + if [ "${id}" -ne "1" ]; then + exit 54 + fi + echo "Tested removefrombatch." + + # getbatchdetails the default batch + echo "Testing getbatchdetails..." + response=$(curl -sd '{"batchId":1}' localhost:8888/getbatchdetails) + echo "response=${response}" + data2=$(echo "${response}" | jq ".result.nbOutputs") + echo "nbOutputs=${data2}" + if [ "${data2}" -ne "$((${data}-2))" ]; then + exit 58 + fi + echo "Tested getbatchdetails." + + + + + + + + + + + + + + + # Create a batch + echo "Testing createbatch..." + response=$(curl -s -H 'Content-Type: application/json' -d '{"batchLabel":"testbatch","confTarget":32}' proxy:8888/createbatch) + echo "response=${response}" + id=$(echo "${response}" | jq -e ".result.batchId") + if [ "$?" -ne "0" ]; then + exit 60 + fi + + # List batches (should show at least default and testbatch batches) + echo "Testing listbatches..." + response=$(curl -s proxy:8888/listbatches) + echo "response=${response}" + id=$(echo "${response}" | jq '.result[] | select(.batchLabel == "testbatch") | .batchId') + echo "batchId=${id}" + if [ -z "${id}" ]; then + exit 70 + fi + echo "Tested listbatches." + + # getbatch the testbatch batch + echo "Testing getbatch..." + response=$(curl -sd '{"batchId":'${id}'}' localhost:8888/getbatch) + echo "response=${response}" + data=$(echo "${response}" | jq -r ".result.batchLabel") + echo "batchLabel=${data}" + if [ "${data}" != "testbatch" ]; then + exit 80 + fi + + response=$(curl -sd '{"batchLabel":"testbatch"}' localhost:8888/getbatch) + echo "response=${response}" + data=$(echo "${response}" | jq -r ".result.batchId") + echo "batchId=${data}" + if [ "${data}" != "${id}" ]; then + exit 90 + fi + echo "Tested getbatch." + + # getbatchdetails the testbatch batch + echo "Testing getbatchdetails..." + response=$(curl -sd '{"batchLabel":"testbatch"}' localhost:8888/getbatchdetails) + echo "response=${response}" + data=$(echo "${response}" | jq -r ".result.batchId") + echo "batchId=${data}" + if [ "${data}" != "${id}" ]; then + exit 100 + fi + echo "${response}" | jq -e ".result.outputs" + if [ "$?" -ne 0 ]; then + exit 32 + fi + + response=$(curl -sd '{"batchId":'${id}'}' localhost:8888/getbatchdetails) + echo "response=${response}" + data=$(echo "${response}" | jq -r ".result.batchLabel") + echo "batchLabel=${data}" + if [ "${data}" != "testbatch" ]; then + exit 35 + fi + echo "${response}" | jq -e ".result.outputs" + if [ "$?" -ne 0 ]; then + exit 37 + fi + echo "Tested getbatchdetails." + + # addtobatch to testbatch batch + echo "Testing addtobatch..." + address1=$(curl -s localhost:8888/getnewaddress | jq -r ".address") + echo "address1=${address1}" + response=$(curl -sd '{"batchId":'${id}',"outputLabel":"test001","address":"'${address1}'","amount":0.001,"webhookUrl":"'${url1}'/'${address1}'"}' localhost:8888/addtobatch) + echo "response=${response}" + data=$(echo "${response}" | jq ".result.batchId") + echo "batchId=${data}" + if [ "${data}" -ne "${id}" ]; then + exit 40 + fi + id2=$(echo "${response}" | jq -e ".result.outputId") + if [ "$?" -ne 0 ]; then + exit 42 + fi + echo "outputId=${id2}" + + address2=$(curl -s localhost:8888/getnewaddress | jq -r ".address") + echo "address2=${address2}" + response=$(curl -sd '{"batchLabel":"testbatch","outputLabel":"test002","address":"'${address2}'","amount":0.002,"webhookUrl":"'${url2}'/'${address2}'"}' localhost:8888/addtobatch) + echo "response=${response}" + data=$(echo "${response}" | jq ".result.batchId") + echo "batchId=${data}" + if [ "${data}" -ne "${id}" ]; then + exit 40 + fi + id2=$(echo "${response}" | jq -e ".result.outputId") + if [ "$?" -ne 0 ]; then + exit 42 + fi + echo "outputId=${id2}" + echo "Tested addtobatch." + + # batchspend testbatch batch + echo "Testing batchspend..." + response=$(curl -sd '{"batchLabel":"testbatch"}' localhost:8888/batchspend) + echo "response=${response}" + data2=$(echo "${response}" | jq -e ".result.txid") + if [ "$?" -ne 0 ]; then + exit 44 + fi + echo "txid=${data2}" + data=$(echo "${response}" | jq ".result.outputs | length") + if [ "${data}" -ne "2" ]; then + exit 42 + fi + echo "Tested batchspend." + + # getbatchdetails the testbatch batch + echo "Testing getbatchdetails..." + echo "txid=${data2}" + response=$(curl -sd '{"batchLabel":"testbatch","txid":'${data2}'}' localhost:8888/getbatchdetails) + echo "response=${response}" + data=$(echo "${response}" | jq ".result.nbOutputs") + echo "nbOutputs=${data}" + if [ "${data}" -ne "2" ]; then + exit 42 + fi + echo "Tested getbatchdetails." + + # List batches + # Add to batch + # List batches + # Remove from batch + # List batches +} + +wait_for_callbacks() { + nc -vlp1111 -e ./tests-cb.sh & + nc -vlp1112 -e ./tests-cb.sh & +} + +wait_for_callbacks +testbatching +wait diff --git a/proxy_docker/app/script/walletoperations.sh b/proxy_docker/app/script/walletoperations.sh index 067937b..a7e623a 100644 --- a/proxy_docker/app/script/walletoperations.sh +++ b/proxy_docker/app/script/walletoperations.sh @@ -44,7 +44,7 @@ spend() { local tx_size=$(echo "${tx_raw_details}" | jq '.result.size') local tx_vsize=$(echo "${tx_raw_details}" | jq '.result.vsize') local tx_replaceable=$(echo "${tx_details}" | jq '.result."bip125-replaceable"') - tx_replaceable=$([ ${tx_replaceable} = "yes" ] && echo 1 || echo 0) + tx_replaceable=$([ ${tx_replaceable} = "yes" ] && echo "true" || echo "false") local fees=$(echo "${tx_details}" | jq '.result.fee | fabs' | awk '{ printf "%.8f", $0 }') # Sometimes raw tx are too long to be passed as paramater, so let's write # it to a temp file for it to be read by sqlite3 and then delete the file @@ -77,7 +77,7 @@ spend() { trace_rc $? data="{\"status\":\"accepted\"" - data="${data},\"hash\":\"${txid}\",\"details\":{\"address\":\"${address}\",\"amount\":${amount},\"firstseen\":${tx_ts_firstseen},\"size\":${tx_size},\"vsize\":${tx_vsize},\"replaceable\":${replaceable},\"fee\":${fees},\"subtractfeefromamount\":${subtractfeefromamount}}}" + data="${data},\"hash\":\"${txid}\",\"details\":{\"address\":\"${address}\",\"amount\":${amount},\"firstseen\":${tx_ts_firstseen},\"size\":${tx_size},\"vsize\":${tx_vsize},\"replaceable\":${tx_replaceable},\"fee\":${fees},\"subtractfeefromamount\":${subtractfeefromamount}}}" # Delete the temp file containing the raw tx (see above) rm spend-rawtx-${txid}-$$.blob @@ -156,6 +156,7 @@ get_txns_spending() { return ${returncode} } + getbalance() { trace "Entering getbalance()..." @@ -293,113 +294,6 @@ getnewaddress() { return ${returncode} } -addtobatching() { - trace "Entering addtobatching()..." - - local address=${1} - trace "[addtobatching] address=${address}" - local amount=${2} - trace "[addtobatching] amount=${amount}" - - sql "INSERT OR IGNORE INTO recipient (address, amount) VALUES (\"${address}\", ${amount})" - returncode=$? - trace_rc ${returncode} - - return ${returncode} -} - -batchspend() { - trace "Entering batchspend()..." - - local data - local response - local recipientswhere - local recipientsjson - local id_inserted - local tx_details - local tx_raw_details - - # We will batch all the addresses in DB without a TXID - local batching=$(sql 'SELECT address, amount FROM recipient WHERE tx_id IS NULL') - trace "[batchspend] batching=${batching}" - - local returncode - local address - local amount - local notfirst=false - local IFS=$'\n' - for row in ${batching} - do - trace "[batchspend] row=${row}" - address=$(echo "${row}" | cut -d '|' -f1) - trace "[batchspend] address=${address}" - amount=$(echo "${row}" | cut -d '|' -f2) - trace "[batchspend] amount=${amount}" - - if ${notfirst}; then - recipientswhere="${recipientswhere}," - recipientsjson="${recipientsjson}," - else - notfirst=true - fi - - recipientswhere="${recipientswhere}\"${address}\"" - recipientsjson="${recipientsjson}\"${address}\":${amount}" - done - - response=$(send_to_spender_node "{\"method\":\"sendmany\",\"params\":[\"\", {${recipientsjson}}]}") - returncode=$? - trace_rc ${returncode} - trace "[batchspend] response=${response}" - - if [ "${returncode}" -eq 0 ]; then - local txid=$(echo "${response}" | jq -r ".result") - trace "[batchspend] txid=${txid}" - - # Let's get transaction details on the spending wallet so that we have fee information - tx_details=$(get_transaction ${txid} "spender") - tx_raw_details=$(get_rawtransaction ${txid}) - - # Amounts and fees are negative when spending so we absolute those fields - local tx_hash=$(echo "${tx_raw_details}" | jq '.result.hash') - local tx_ts_firstseen=$(echo "${tx_details}" | jq '.result.timereceived') - local tx_amount=$(echo "${tx_details}" | jq '.result.amount | fabs' | awk '{ printf "%.8f", $0 }') - local tx_size=$(echo "${tx_raw_details}" | jq '.result.size') - local tx_vsize=$(echo "${tx_raw_details}" | jq '.result.vsize') - local tx_replaceable=$(echo "${tx_details}" | jq '.result."bip125-replaceable"') - tx_replaceable=$([ ${tx_replaceable} = "yes" ] && echo 1 || echo 0) - local fees=$(echo "${tx_details}" | jq '.result.fee | fabs' | awk '{ printf "%.8f", $0 }') - # Sometimes raw tx are too long to be passed as paramater, so let's write - # it to a temp file for it to be read by sqlite3 and then delete the file - echo "${tx_raw_details}" > batchspend-rawtx-${txid}-$$.blob - - # Let's insert the txid in our little DB -- then we'll already have it when receiving confirmation - sql "INSERT OR IGNORE INTO tx (txid, hash, confirmations, timereceived, fee, size, vsize, is_replaceable, raw_tx) VALUES (\"${txid}\", ${tx_hash}, 0, ${tx_ts_firstseen}, ${fees}, ${tx_size}, ${tx_vsize}, ${tx_replaceable}, readfile('batchspend-rawtx-${txid}-$$.blob'))" - returncode=$? - trace_rc ${returncode} - if [ "${returncode}" -eq 0 ]; then - id_inserted=$(sql "SELECT id FROM tx WHERE txid=\"${txid}\"") - trace "[batchspend] id_inserted: ${id_inserted}" - sql "UPDATE recipient SET tx_id=${id_inserted} WHERE address IN (${recipientswhere})" - trace_rc $? - fi - - data="{\"status\":\"accepted\"" - data="${data},\"hash\":\"${txid}\"}" - - # Delete the temp file containing the raw tx (see above) - rm batchspend-rawtx-${txid}-$$.blob - else - local message=$(echo "${response}" | jq -e ".error.message") - data="{\"message\":${message}}" - fi - - trace "[batchspend] responding=${data}" - echo "${data}" - - return ${returncode} -} - create_wallet() { trace "[Entering create_wallet()]" @@ -416,4 +310,3 @@ create_wallet() { return ${returncode} } - From 38819f169d885f77c605103aaa199d23eaea4ca8 Mon Sep 17 00:00:00 2001 From: kexkey Date: Fri, 3 Jul 2020 13:42:32 -0400 Subject: [PATCH 02/22] Batchers manage batches --- proxy_docker/app/data/cyphernode.sql | 8 +- .../data/sqlmigrate20200610_0.4.0-0.5.0.sh | 4 +- .../data/sqlmigrate20200610_0.4.0-0.5.0.sql | 8 +- proxy_docker/app/script/batching.sh | 370 +++++++++--------- proxy_docker/app/script/requesthandler.sh | 108 ++--- proxy_docker/app/script/test-batching.sh | 172 ++++---- proxy_docker/app/script/walletoperations.sh | 2 +- 7 files changed, 336 insertions(+), 336 deletions(-) diff --git a/proxy_docker/app/data/cyphernode.sql b/proxy_docker/app/data/cyphernode.sql index 7fb3621..a843cd6 100644 --- a/proxy_docker/app/data/cyphernode.sql +++ b/proxy_docker/app/data/cyphernode.sql @@ -68,20 +68,20 @@ CREATE TABLE recipient ( webhook_url TEXT, calledback INTEGER DEFAULT FALSE, calledback_ts INTEGER, - batch_id INTEGER REFERENCES batch, - label TEXT, + batcher_id INTEGER REFERENCES batcher, + label TEXT ); CREATE INDEX idx_recipient_address ON recipient (address); CREATE INDEX idx_recipient_label ON recipient (label); -CREATE TABLE batch ( +CREATE TABLE batcher ( id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT UNIQUE, conf_target INTEGER, feerate REAL, inserted_ts INTEGER DEFAULT CURRENT_TIMESTAMP ); -INSERT INTO batch (id, label, conf_target, feerate) VALUES (1, "default", 6, NULL); +INSERT INTO batcher (id, label, conf_target, feerate) VALUES (1, "default", 6, NULL); CREATE TABLE watching_by_txid ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sh b/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sh index 9cb8049..5e9d7cb 100755 --- a/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sh +++ b/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sh @@ -1,9 +1,9 @@ #!/bin/sh echo "Checking for extended batching support in DB..." -count=$(sqlite3 $DB_FILE "select count(*) from pragma_table_info('recipient') where name='batch_id'") +count=$(sqlite3 $DB_FILE "select count(*) from pragma_table_info('recipient') where name='batcher_id'") if [ "${count}" -eq "0" ]; then - # batch_id not there, we have to migrate + # batcher_id not there, we have to migrate echo "Migrating database for extended batching support..." echo "Backing up current DB..." cp $DB_FILE $DB_FILE-sqlmigrate20200610_0.4.0-0.5.0 diff --git a/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql b/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql index bc8fd39..2e8ddc4 100644 --- a/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql +++ b/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql @@ -1,16 +1,16 @@ -CREATE TABLE batch ( +CREATE TABLE batcher ( id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT UNIQUE, conf_target INTEGER, feerate REAL, inserted_ts INTEGER DEFAULT CURRENT_TIMESTAMP ); -INSERT INTO batch (id, label, conf_target, feerate) VALUES (1, "default", 6, NULL); +INSERT INTO batcher (id, label, conf_target, feerate) VALUES (1, "default", 6, NULL); ALTER TABLE recipient ADD COLUMN webhook_url TEXT; -ALTER TABLE recipient ADD COLUMN batch_id INTEGER REFERENCES batch; -ALTER TABLE recipient ADD COLUMN label INTEGER REFERENCES batch; +ALTER TABLE recipient ADD COLUMN batcher_id INTEGER REFERENCES batcher; +ALTER TABLE recipient ADD COLUMN label INTEGER REFERENCES batcher; ALTER TABLE recipient ADD COLUMN calledback INTEGER DEFAULT FALSE; ALTER TABLE recipient ADD COLUMN calledback_ts INTEGER; CREATE INDEX idx_recipient_label ON recipient (label); diff --git a/proxy_docker/app/script/batching.sh b/proxy_docker/app/script/batching.sh index f95b88f..fe7786e 100644 --- a/proxy_docker/app/script/batching.sh +++ b/proxy_docker/app/script/batching.sh @@ -3,98 +3,98 @@ . ./trace.sh . ./sendtobitcoinnode.sh -createbatch() { - trace "Entering createbatch()..." +createbatcher() { + trace "Entering createbatcher()..." - # POST http://192.168.111.152:8080/createbatch + # POST http://192.168.111.152:8080/createbatcher # # args: - # - batchLabel, optional, id can be used to reference the batch + # - batcherLabel, optional, id can be used to reference the batcher # - confTarget, optional, overriden by batchspend's confTarget, default Bitcoin Core conf_target will be used if not supplied # NOTYET - feeRate, sat/vB, optional, overrides confTarget if supplied, overriden by batchspend's feeRate, default Bitcoin Core fee policy will be used if not supplied # # response: - # - id, the batch id + # - batcherId, the batcher id # - # BODY {"batchLabel":"lowfees","confTarget":32} - # NOTYET BODY {"batchLabel":"highfees","feeRate":231.8} + # BODY {"batcherLabel":"lowfees","confTarget":32} + # NOTYET BODY {"batcherLabel":"highfees","feeRate":231.8} local request=${1} local response - local label=$(echo "${request}" | jq ".batchLabel") - trace "[createbatch] label=${label}" + local label=$(echo "${request}" | jq ".batcherLabel") + trace "[createbatcher] label=${label}" local conf_target=$(echo "${request}" | jq ".confTarget") - trace "[createbatch] conf_target=${conf_target}" + trace "[createbatcher] conf_target=${conf_target}" local feerate=$(echo "${request}" | jq ".feeRate") - trace "[createbatch] feerate=${feerate}" + trace "[createbatcher] feerate=${feerate}" # if [ "${feerate}" != "null" ]; then # # If not null, let's nullify conf_target since feerate overrides it # conf_target="null" - # trace "[createbatch] Overriding conf_target=${conf_target}" + # trace "[createbatcher] Overriding conf_target=${conf_target}" # fi - local batch_id + local batcher_id - batch_id=$(sql "INSERT OR IGNORE INTO batch (label, conf_target, feerate) VALUES (${label}, ${conf_target}, ${feerate}); SELECT LAST_INSERT_ROWID();") + batcher_id=$(sql "INSERT OR IGNORE INTO batcher (label, conf_target, feerate) VALUES (${label}, ${conf_target}, ${feerate}); SELECT LAST_INSERT_ROWID();") - if ("${batch_id}" -eq "0"); then - trace "[createbatch] Could not insert" - response='{"result":null,"error":{"code":-32700,"message":"Could not create batch, label probably already exists","data":'${request}'}}' + if ("${batcher_id}" -eq "0"); then + trace "[createbatcher] Could not insert" + response='{"result":null,"error":{"code":-32700,"message":"Could not create batcher, label probably already exists","data":'${request}'}}' else - trace "[createbatch] Inserted" - response='{"result":{"batchId":'${batch_id}'},"error":null}' + trace "[createbatcher] Inserted" + response='{"result":{"batcherId":'${batcher_id}'},"error":null}' fi echo "${response}" } -updatebatch() { - trace "Entering updatebatch()..." +updatebatcher() { + trace "Entering updatebatcher()..." - # POST http://192.168.111.152:8080/updatebatch + # POST http://192.168.111.152:8080/updatebatcher # # args: - # - batchId, optional, batch id to update, will update default batch if not supplied - # - batchLabel, optional, id can be used to reference the batch, will update default batch if not supplied, if id is present then change the label with supplied text - # - confTarget, optional, new confirmation target for the batch - # NOTYET - feeRate, sat/vB, optional, new feerate for the batch + # - batcherId, optional, batcher id to update, will update default batcher if not supplied + # - batcherLabel, optional, id can be used to reference the batcher, will update default batcher if not supplied, if id is present then change the label with supplied text + # - confTarget, optional, new confirmation target for the batcher + # NOTYET - feeRate, sat/vB, optional, new feerate for the batcher # # response: - # - batchId, the batch id - # - batchLabel, the batch label - # - confTarget, the batch default confirmation target - # NOTYET - feeRate, the batch default feerate + # - batcherId, the batcher id + # - batcherLabel, the batcher label + # - confTarget, the batcher default confirmation target + # NOTYET - feeRate, the batcher default feerate # - # BODY {"batchId":5,"confTarget":12} - # NOTYET BODY {"batchLabel":"highfees","feeRate":400} - # NOTYET BODY {"batchId":3,"batchLabel":"ultrahighfees","feeRate":800} - # BODY {"batchLabel":"fast","confTarget":2} + # BODY {"batcherId":5,"confTarget":12} + # NOTYET BODY {"batcherLabel":"highfees","feeRate":400} + # NOTYET BODY {"batcherId":3,"batcherLabel":"ultrahighfees","feeRate":800} + # BODY {"batcherLabel":"fast","confTarget":2} local request=${1} local response local whereclause local returncode - local id=$(echo "${request}" | jq ".batchId") - trace "[updatebatch] id=${id}" - local label=$(echo "${request}" | jq ".batchLabel") - trace "[updatebatch] label=${label}" + local id=$(echo "${request}" | jq ".batcherId") + trace "[updatebatcher] id=${id}" + local label=$(echo "${request}" | jq ".batcherLabel") + trace "[updatebatcher] label=${label}" local conf_target=$(echo "${request}" | jq ".confTarget") - trace "[updatebatch] conf_target=${conf_target}" + trace "[updatebatcher] conf_target=${conf_target}" local feerate=$(echo "${request}" | jq ".feeRate") - trace "[updatebatch] feerate=${feerate}" + trace "[updatebatcher] feerate=${feerate}" if [ "${id}" = "null" ] && [ "${label}" = "null" ]; then - # If id and label are null, use default batch - trace "[updatebatch] Using default batch 1" + # If id and label are null, use default batcher + trace "[updatebatcher] Using default batcher 1" id=1 fi # if [ "${feerate}" != "null" ]; then # # If not null, let's nullify conf_target since feerate overrides it # conf_target="null" - # trace "[updatebatch] Overriding conf_target=${conf_target}" + # trace "[updatebatcher] Overriding conf_target=${conf_target}" # fi if [ "${id}" = "null" ]; then @@ -103,13 +103,13 @@ updatebatch() { whereclause="id = ${id}" fi - sql "UPDATE batch set label=${label}, conf_target=${conf_target}, feerate=${feerate} WHERE ${whereclause}" + sql "UPDATE batcher set label=${label}, conf_target=${conf_target}, feerate=${feerate} WHERE ${whereclause}" returncode=$? trace_rc ${returncode} if [ "${returncode}" -ne 0 ]; then - response='{"result":null,"error":{"code":-32700,"message":"Could not update batch","data":'${request}'}}' + response='{"result":null,"error":{"code":-32700,"message":"Could not update batcher","data":'${request}'}}' else - response='{"result":{"batchId":'${id}'},"error":null}' + response='{"result":{"batcherId":'${id}'},"error":null}' fi echo "${response}" @@ -123,20 +123,20 @@ addtobatch() { # args: # - address, required, desination address # - amount, required, amount to send to the destination address - # - batchId, optional, the id of the batch to which the output will be added, default batch if not supplied, overrides batchLabel - # - batchLabel, optional, the label of the batch to which the output will be added, default batch if not supplied + # - batcherId, optional, the id of the batcher to which the output will be added, default batcher if not supplied, overrides batcherLabel + # - batcherLabel, optional, the label of the batcher to which the output will be added, default batcher if not supplied # - webhookUrl, optional, the webhook to call when the batch is broadcast # # response: - # - id, the id of the added output + # - outputId, the id of the added output # - nbOutputs, the number of outputs currently in the batch # - oldest, the timestamp of the oldest output in the batch # - pendingTotal, the current sum of the batch's output amounts # # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233} - # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batchId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} - # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batchLabel":"lowfees","webhookUrl":"https://myCypherApp:3000/batchExecuted"} - # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batchId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} + # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batcherId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} + # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batcherLabel":"lowfees","webhookUrl":"https://myCypherApp:3000/batchExecuted"} + # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batcherId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} local request=${1} local response @@ -150,38 +150,38 @@ addtobatch() { trace "[addtobatch] amount=${amount}" local label=$(echo "${request}" | jq ".outputLabel") trace "[addtobatch] label=${label}" - local batch_id=$(echo "${request}" | jq ".batchId") - trace "[addtobatch] batch_id=${batch_id}" - local batch_label=$(echo "${request}" | jq ".batchLabel") - trace "[addtobatch] batch_label=${batch_label}" + local batcher_id=$(echo "${request}" | jq ".batcherId") + trace "[addtobatch] batcher_id=${batcher_id}" + local batcher_label=$(echo "${request}" | jq ".batcherLabel") + trace "[addtobatch] batcher_label=${batcher_label}" local webhook_url=$(echo "${request}" | jq ".webhookUrl") trace "[addtobatch] webhook_url=${webhook_url}" - if [ "${batch_id}" = "null" ] && [ "${batch_label}" = "null" ]; then - # If batch_id and batch_label are null, use default batch - trace "[addtobatch] Using default batch 1" - batch_id=1 + if [ "${batcher_id}" = "null" ] && [ "${batcher_label}" = "null" ]; then + # If batcher_id and batcher_label are null, use default batcher + trace "[addtobatch] Using default batcher 1" + batcher_id=1 fi - if [ "${batch_id}" = "null" ]; then - # Using batch_label - batch_id=$(sql "SELECT id FROM batch WHERE label=${batch_label}") + if [ "${batcher_id}" = "null" ]; then + # Using batcher_label + batcher_id=$(sql "SELECT id FROM batcher WHERE label=${batcher_label}") returncode=$? trace_rc ${returncode} fi - if [ -z "${batch_id}" ]; then - # batchLabel not found - response='{"result":null,"error":{"code":-32700,"message":"batch not found","data":'${request}'}}' + if [ -z "${batcher_id}" ]; then + # batcherLabel not found + response='{"result":null,"error":{"code":-32700,"message":"batcher not found","data":'${request}'}}' else - inserted_id=$(sql "INSERT INTO recipient (address, amount, webhook_url, batch_id, label) VALUES (${address}, ${amount}, ${webhook_url}, ${batch_id}, ${label}); SELECT LAST_INSERT_ROWID();") + inserted_id=$(sql "INSERT INTO recipient (address, amount, webhook_url, batcher_id, label) VALUES (${address}, ${amount}, ${webhook_url}, ${batcher_id}, ${label}); SELECT LAST_INSERT_ROWID();") returncode=$? trace_rc ${returncode} if [ "${returncode}" -ne 0 ]; then response='{"result":null,"error":{"code":-32700,"message":"Could not add to batch","data":'${request}'}}' else - row=$(sql "SELECT COUNT(id), MIN(inserted_ts), SUM(amount) FROM recipient WHERE tx_id IS NULL AND batch_id=${batch_id}") + row=$(sql "SELECT COUNT(id), MIN(inserted_ts), SUM(amount) FROM recipient WHERE tx_id IS NULL AND batcher_id=${batcher_id}") returncode=$? trace_rc ${returncode} @@ -192,7 +192,7 @@ addtobatch() { local total=$(echo "${row}" | cut -d '|' -f3) trace "[addtobatch] total=${total}" - response='{"result":{"batchId":'${batch_id}',"outputId":'${inserted_id}',"nbOutputs":'${count}',"oldest":"'${oldest}'","pendingTotal":'${total}'},"error":null}' + response='{"result":{"batcherId":'${batcher_id}',"outputId":'${inserted_id}',"nbOutputs":'${count}',"oldest":"'${oldest}'","pendingTotal":'${total}'},"error":null}' fi fi @@ -219,7 +219,7 @@ removefrombatch() { local response local returncode=0 local row - local batch_id + local batcher_id local id=$(echo "${request}" | jq ".outputId") trace "[removefrombatch] id=${id}" @@ -229,11 +229,11 @@ removefrombatch() { trace "[removefrombatch] id missing" response='{"result":null,"error":{"code":-32700,"message":"outputId is required","data":'${request}'}}' else - batch_id=$(sql "SELECT batch_id FROM recipient WHERE id=${id}") + batcher_id=$(sql "SELECT batcher_id FROM recipient WHERE id=${id}") returncode=$? trace_rc ${returncode} - if [ -n "${batch_id}" ]; then + if [ -n "${batcher_id}" ]; then sql "DELETE FROM recipient WHERE id=${id}" returncode=$? trace_rc ${returncode} @@ -241,7 +241,7 @@ removefrombatch() { if [ "${returncode}" -ne 0 ]; then response='{"result":null,"error":{"code":-32700,"message":"Output was not removed","data":'${request}'}}' else - row=$(sql "SELECT COUNT(id), COALESCE(MIN(inserted_ts), 0), COALESCE(SUM(amount), 0.00000000) FROM recipient WHERE tx_id IS NULL AND batch_id=${batch_id}") + row=$(sql "SELECT COUNT(id), COALESCE(MIN(inserted_ts), 0), COALESCE(SUM(amount), 0.00000000) FROM recipient WHERE tx_id IS NULL AND batcher_id=${batcher_id}") returncode=$? trace_rc ${returncode} @@ -252,7 +252,7 @@ removefrombatch() { local total=$(echo "${row}" | cut -d '|' -f3) trace "[removefrombatch] total=${total}" - response='{"result":{"batchId":'${batch_id}',"nbOutputs":'${count}',"oldest":"'${oldest}'","pendingTotal":'${total}'},"error":null}' + response='{"result":{"batcherId":'${batcher_id}',"nbOutputs":'${count}',"oldest":"'${oldest}'","pendingTotal":'${total}'},"error":null}' fi else response='{"result":null,"error":{"code":-32700,"message":"Output not found or already spent","data":'${request}'}}' @@ -268,10 +268,10 @@ batchspend() { # POST http://192.168.111.152:8080/batchspend # # args: - # - batchId, optional, id of the batch to spend, overrides batchLabel, default batch will be spent if not supplied - # - batchLabel, optional, label of the batch to spend, default batch will be spent if not supplied - # - confTarget, optional, overrides default value of createbatch, default to value of createbatch, default Bitcoin Core conf_target will be used if not supplied - # NOTYET - feeRate, optional, overrides confTarget if supplied, overrides default value of createbatch, default to value of createbatch, default Bitcoin Core value will be used if not supplied + # - batcherId, optional, id of the batcher to execute, overrides batcherLabel, default batcher will be spent if not supplied + # - batcherLabel, optional, label of the batcher to execute, default batcher will be executed if not supplied + # - confTarget, optional, overrides default value of createbatcher, default to value of createbatcher, default Bitcoin Core conf_target will be used if not supplied + # NOTYET - feeRate, optional, overrides confTarget if supplied, overrides default value of createbatcher, default to value of createbatcher, default Bitcoin Core value will be used if not supplied # # response: # - txid, the txid of the batch @@ -284,9 +284,9 @@ batchspend() { # - outputs # # BODY {} - # BODY {"batchId":"34","confTarget":12} - # NOTYET BODY {"batchLabel":"highfees","feeRate":233.7} - # BODY {"batchId":"411","confTarget":6} + # BODY {"batcherId":"34","confTarget":12} + # NOTYET BODY {"batcherLabel":"highfees","feeRate":233.7} + # BODY {"batcherId":"411","confTarget":6} local request=${1} local response @@ -294,47 +294,47 @@ batchspend() { local row local whereclause - local batch_id=$(echo "${request}" | jq ".batchId") - trace "[batchspend] batch_id=${batch_id}" - local batch_label=$(echo "${request}" | jq ".batchLabel") - trace "[batchspend] batch_label=${batch_label}" + local batcher_id=$(echo "${request}" | jq ".batcherId") + trace "[batchspend] batcher_id=${batcher_id}" + local batcher_label=$(echo "${request}" | jq ".batcherLabel") + trace "[batchspend] batcher_label=${batcher_label}" local conf_target=$(echo "${request}" | jq ".confTarget") trace "[batchspend] conf_target=${conf_target}" local feerate=$(echo "${request}" | jq ".feeRate") trace "[batchspend] feerate=${feerate}" - if [ "${batch_id}" = "null" ] && [ "${batch_label}" = "null" ]; then - # If batch_id and batch_label are null, use default batch - trace "[batchspend] Using default batch 1" - batch_id=1 + if [ "${batcher_id}" = "null" ] && [ "${batcher_label}" = "null" ]; then + # If batcher_id and batcher_label are null, use default batcher + trace "[batchspend] Using default batcher 1" + batcher_id=1 fi - if [ "${batch_id}" = "null" ]; then - # Using batch_label - whereclause="label=${batch_label}" + if [ "${batcher_id}" = "null" ]; then + # Using batcher_label + whereclause="label=${batcher_label}" else - whereclause="id=${batch_id}" + whereclause="id=${batcher_id}" fi - local batch=$(sql "SELECT id, conf_target, feerate FROM batch WHERE ${whereclause}") + local batcher=$(sql "SELECT id, conf_target, feerate FROM batcher WHERE ${whereclause}") returncode=$? trace_rc ${returncode} - if [ -z "${batch}" ]; then - # batchLabel not found - response='{"result":null,"error":{"code":-32700,"message":"batch not found","data":'${request}'}}' + if [ -z "${batcher}" ]; then + # batcherLabel not found + response='{"result":null,"error":{"code":-32700,"message":"batcher not found","data":'${request}'}}' else # All good, let's try to batch spend! # NOTYET # We'll use supplied feerate # If not supplied, we'll use supplied conf_target - # If not supplied, we'll use batch default feerate - # If not set, we'll use batch default conf_target + # If not supplied, we'll use batcher default feerate + # If not set, we'll use batcher default conf_target # If not set, default Bitcoin Core fee policy will be used # We'll use the supplied conf_target - # If not supplied, we'll use the batch default conf_target + # If not supplied, we'll use the batcher default conf_target # If not set, default Bitcoin Core fee policy will be used # if [ "${feerate}" != "null" ]; then @@ -343,22 +343,22 @@ batchspend() { # trace "[batchspend] Overriding conf_target=${conf_target}" # else # if [ "${conf_target}" = "null" ]; then - # feerate=$(echo "${batch}" | cut -d '|' -f3) + # feerate=$(echo "${batcher}" | cut -d '|' -f3) # if [ -z "${feerate}" ]; then - # # If null, let's use batch conf_target - # conf_target=$(echo "${batch}" | cut -d '|' -f2) + # # If null, let's use batcher conf_target + # conf_target=$(echo "${batcher}" | cut -d '|' -f2) # fi # fi # fi if [ "${conf_target}" = "null" ]; then - conf_target=$(echo "${batch}" | cut -d '|' -f2) - trace "[batchspend] Using batch default conf_target=${conf_target}" + conf_target=$(echo "${batcher}" | cut -d '|' -f2) + trace "[batchspend] Using batcher default conf_target=${conf_target}" fi - batch_id=$(echo "${batch}" | cut -d '|' -f1) + batcher_id=$(echo "${batcher}" | cut -d '|' -f1) - local batching=$(sql "SELECT address, amount, id, webhook_url FROM recipient WHERE tx_id IS NULL AND batch_id=${batch_id}") + local batching=$(sql "SELECT address, amount, id, webhook_url FROM recipient WHERE tx_id IS NULL AND batcher_id=${batcher_id}") trace "[batchspend] batching=${batching}" local data @@ -427,7 +427,7 @@ batchspend() { echo "${tx_raw_details}" > batchspend-rawtx-${txid}.blob # Get the info on the batch before setting it to done - row=$(sql "SELECT COUNT(id), COALESCE(MIN(inserted_ts), 0), COALESCE(SUM(amount), 0.00000000) FROM recipient WHERE tx_id IS NULL AND batch_id=${batch_id}") + row=$(sql "SELECT COUNT(id), COALESCE(MIN(inserted_ts), 0), COALESCE(SUM(amount), 0.00000000) FROM recipient WHERE tx_id IS NULL AND batcher_id=${batcher_id}") # Let's insert the txid in our little DB -- then we'll already have it when receiving confirmation id_inserted=$(sql "INSERT OR IGNORE INTO tx (txid, hash, confirmations, timereceived, fee, size, vsize, is_replaceable, raw_tx) VALUES (\"${txid}\", ${tx_hash}, 0, ${tx_ts_firstseen}, ${fees}, ${tx_size}, ${tx_vsize}, ${tx_replaceable}, readfile('batchspend-rawtx-${txid}.blob')); SELECT LAST_INSERT_ROWID();") @@ -450,14 +450,14 @@ batchspend() { local total=$(echo "${row}" | cut -d '|' -f3) trace "[batchspend] total=${total}" - response='{"result":{"batchId":'${batch_id}',"nbOutputs":'${count}',"oldest":"'${oldest}'","total":'${total} + response='{"result":{"batcherId":'${batcher_id}',"nbOutputs":'${count}',"oldest":"'${oldest}'","total":'${total} response="${response},\"txid\":\"${txid}\",\"hash\":${tx_hash},\"details\":{\"firstseen\":${tx_ts_firstseen},\"size\":${tx_size},\"vsize\":${tx_vsize},\"replaceable\":${tx_replaceable},\"fee\":${fees}},\"outputs\":{${recipientsjson}}}" response="${response},\"error\":null}" # Delete the temp file containing the raw tx (see above) rm batchspend-rawtx-${txid}.blob - batch_webhooks "[${webhooks_data}]" '"batchId":'${batch_id}',"txid":"'${txid}'","hash":'${tx_hash}',"details":{"firstseen":'${tx_ts_firstseen}',"size":'${tx_size}',"vsize":'${tx_vsize}',"replaceable":'${tx_replaceable}',"fee":'${fees}'}' + batch_webhooks "[${webhooks_data}]" '"batcherId":'${batcher_id}',"txid":"'${txid}'","hash":'${tx_hash}',"details":{"firstseen":'${tx_ts_firstseen}',"size":'${tx_size}',"vsize":'${tx_vsize}',"replaceable":'${tx_replaceable}',"fee":'${fees}'}' else local message=$(echo "${data}" | jq -e ".error.message") @@ -477,7 +477,7 @@ batch_check_webhooks() { local amount local recipient_id local webhook_url - local batch_id + local batcher_id local txid local tx_hash local tx_ts_firstseen @@ -486,7 +486,7 @@ batch_check_webhooks() { local tx_replaceable local fees - local batching=$(sql "SELECT address, amount, r.id, webhook_url, b.id, t.txid, t.hash, t.timereceived, t.fee, t.size, t.vsize, t.is_replaceable FROM recipient r, batch b, tx t WHERE r.batch_id=b.id AND r.tx_id=t.id AND NOT calledback AND tx_id IS NOT NULL AND webhook_url IS NOT NULL") + local batching=$(sql "SELECT address, amount, r.id, webhook_url, b.id, t.txid, t.hash, t.timereceived, t.fee, t.size, t.vsize, t.is_replaceable FROM recipient r, batcher b, tx t WHERE r.batcher_id=b.id AND r.tx_id=t.id AND NOT calledback AND tx_id IS NOT NULL AND webhook_url IS NOT NULL") trace "[batch_check_webhooks] batching=${batching}" local IFS=$'\n' @@ -501,8 +501,8 @@ batch_check_webhooks() { trace "[batch_check_webhooks] recipient_id=${recipient_id}" webhook_url=$(echo "${row}" | cut -d '|' -f4) trace "[batch_check_webhooks] webhook_url=${webhook_url}" - batch_id=$(echo "${row}" | cut -d '|' -f5) - trace "[batch_check_webhooks] batch_id=${batch_id}" + batcher_id=$(echo "${row}" | cut -d '|' -f5) + trace "[batch_check_webhooks] batcher_id=${batcher_id}" txid=$(echo "${row}" | cut -d '|' -f6) trace "[batch_check_webhooks] txid=${txid}" tx_hash=$(echo "${row}" | cut -d '|' -f7) @@ -520,7 +520,7 @@ batch_check_webhooks() { webhooks_data="{\"outputId\":${recipient_id},\"address\":\"${address}\",\"amount\":${amount},\"webhookUrl\":\"${webhook_url}\"}" - batch_webhooks "[${webhooks_data}]" '"batchId":'${batch_id}',"txid":"'${txid}'","hash":"'${tx_hash}'","details":{"firstseen":'${tx_ts_firstseen}',"size":'${tx_size}',"vsize":'${tx_vsize}',"replaceable":'${tx_replaceable}',"fee":'${fees}'}' + batch_webhooks "[${webhooks_data}]" '"batcherId":'${batcher_id}',"txid":"'${txid}'","hash":"'${tx_hash}'","details":{"firstseen":'${tx_ts_firstseen}',"size":'${tx_size}',"vsize":'${tx_vsize}',"replaceable":'${tx_replaceable}',"fee":'${fees}'}' done } @@ -533,7 +533,7 @@ batch_webhooks() { trace "[batch_webhooks] webhooks_data=${webhooks_data}" # tx: - # {"batchId":1,"txid":"abc123","hash":"abc123","details":{"firstseen":123123,"size":200,"vsize":141,"replaceable":true,"fee":0.00001}}' + # {"batcherId":1,"txid":"abc123","hash":"abc123","details":{"firstseen":123123,"size":200,"vsize":141,"replaceable":true,"fee":0.00001}}' local tx=${2} trace "[batch_webhooks] tx=${tx}" @@ -589,30 +589,30 @@ batch_webhooks() { trace_rc $? } -listbatches() { - trace "Entering listbatches()..." +listbatchers() { + trace "Entering listbatchers()..." - # curl (GET) http://192.168.111.152:8080/listbatches + # curl (GET) http://192.168.111.152:8080/listbatchers # # {"result":[ - # {"batchId":1,"batchLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143}, - # {"batchId":2,"batchLabel":"lowfee","confTarget":32,"nbOutputs":44,"oldest":123123,"pendingTotal":0.49827387}, - # {"batchId":3,"batchLabel":"highfee","confTarget":2,"nbOutputs":7,"oldest":123123,"pendingTotal":4.16843782} + # {"batcherId":1,"batcherLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143}, + # {"batcherId":2,"batcherLabel":"lowfee","confTarget":32,"nbOutputs":44,"oldest":123123,"pendingTotal":0.49827387}, + # {"batcherId":3,"batcherLabel":"highfee","confTarget":2,"nbOutputs":7,"oldest":123123,"pendingTotal":4.16843782} # ], # "error":null} - local batches=$(sql "SELECT b.id, '{\"batchId\":' || b.id || ',\"batchLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"pendingTotal\":' ||COALESCE(SUM(amount), 0.00000000) || '}' FROM batch b LEFT JOIN recipient r ON r.batch_id=b.id AND r.tx_id IS NULL GROUP BY b.id") - trace "[listbatches] batches=${batches}" + local batchers=$(sql "SELECT b.id, '{\"batcherId\":' || b.id || ',\"batcherLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"pendingTotal\":' ||COALESCE(SUM(amount), 0.00000000) || '}' FROM batcher b LEFT JOIN recipient r ON r.batcher_id=b.id AND r.tx_id IS NULL GROUP BY b.id") + trace "[listbatchers] batchers=${batchers}" local returncode local response - local batch + local batcher local jsonstring local IFS=$'\n' - for batch in ${batches} + for batcher in ${batchers} do - jsonstring=$(echo ${batch} | cut -d '|' -f2) + jsonstring=$(echo ${batcher} | cut -d '|' -f2) if [ -z "${response}" ]; then response='{"result":['${jsonstring} else @@ -621,58 +621,58 @@ listbatches() { done response=${response}'],"error":null}' - trace "[listbatches] responding=${response}" + trace "[listbatchers] responding=${response}" echo "${response}" } -getbatch() { - trace "Entering getbatch()..." +getbatcher() { + trace "Entering getbatcher()..." - # POST (GET) http://192.168.111.152:8080/getbatch + # POST (GET) http://192.168.111.152:8080/getbatcher # # args: - # - batchId, optional, id of the batch to spend, overrides batchLabel, default batch will be spent if not supplied - # - batchLabel, optional, label of the batch to spend, default batch will be spent if not supplied + # - batcherId, optional, id of the batcher, overrides batcerhLabel, default batcher will be used if not supplied + # - batcherLabel, optional, label of the batcher, default batcher will be used if not supplied # # response: - # {"result":{"batchId":1,"batchLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143},"error":null} + # {"result":{"batcherId":1,"batcherLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143},"error":null} # # BODY {} - # BODY {"batchId":34} + # BODY {"batcherId":34} local request=${1} local response local returncode=0 - local batch + local batcher local whereclause - local batch_id=$(echo "${request}" | jq ".batchId") - trace "[getbatch] batch_id=${batch_id}" - local batch_label=$(echo "${request}" | jq ".batchLabel") - trace "[getbatch] batch_label=${batch_label}" + local batcher_id=$(echo "${request}" | jq ".batcherId") + trace "[getbatcher] batcher_id=${batcher_id}" + local batcher_label=$(echo "${request}" | jq ".batcherLabel") + trace "[getbatcher] batcher_label=${batcher_label}" - if [ "${batch_id}" = "null" ] && [ "${batch_label}" = "null" ]; then - # If batch_id and batch_label are null, use default batch - trace "[getbatch] Using default batch 1" - batch_id=1 + if [ "${batcher_id}" = "null" ] && [ "${batcher_label}" = "null" ]; then + # If batcher_id and batcher_label are null, use default batcher + trace "[getbatcher] Using default batcher 1" + batcher_id=1 fi - if [ "${batch_id}" = "null" ]; then - # Using batch_label - whereclause="b.label=${batch_label}" + if [ "${batcher_id}" = "null" ]; then + # Using batcher_label + whereclause="b.label=${batcher_label}" else - # Using batch_id - whereclause="b.id=${batch_id}" + # Using batcher_id + whereclause="b.id=${batcher_id}" fi - batch=$(sql "SELECT b.id, '{\"batchId\":' || b.id || ',\"batchLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"pendingTotal\":' ||COALESCE(SUM(amount), 0.00000000) || '}' FROM batch b LEFT JOIN recipient r ON r.batch_id=b.id AND r.tx_id IS NULL WHERE ${whereclause} GROUP BY b.id") - trace "[getbatch] batch=${batch}" + batcher=$(sql "SELECT b.id, '{\"batcherId\":' || b.id || ',\"batcherLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"pendingTotal\":' ||COALESCE(SUM(amount), 0.00000000) || '}' FROM batcher b LEFT JOIN recipient r ON r.batcher_id=b.id AND r.tx_id IS NULL WHERE ${whereclause} GROUP BY b.id") + trace "[getbatcher] batcher=${batcher}" - if [ -n "${batch}" ]; then - batch=$(echo "${batch}" | cut -d '|' -f2) - response='{"result":'${batch}',"error":null}' + if [ -n "${batcher}" ]; then + batcher=$(echo "${batcher}" | cut -d '|' -f2) + response='{"result":'${batcher}',"error":null}' else - response='{"result":null,"error":{"code":-32700,"message":"batch not found","data":'${request}'}}' + response='{"result":null,"error":{"code":-32700,"message":"batcher not found","data":'${request}'}}' fi echo "${response}" @@ -684,15 +684,15 @@ getbatchdetails() { # POST (GET) http://192.168.111.152:8080/getbatchdetails # # args: - # - batchId, optional, id of the batch to spend, overrides batchLabel, default batch will be spent if not supplied - # - batchLabel, optional, label of the batch to spend, default batch will be spent if not supplied + # - batcherId, optional, id of the batcher, overrides batcherLabel, default batcher will be used if not supplied + # - batcherLabel, optional, label of the batcher, default batcher will be used if not supplied # - txid, optional, if you want the details of an executed batch, supply the batch txid, will return current pending batch # if not supplied # # response: # {"result":{ - # "batchId":34, - # "batchLabel":"Special batch for a special client", + # "batcherId":34, + # "batcherLabel":"Special batcher for a special client", # "confTarget":6, # "nbOutputs":83, # "oldest":123123, @@ -715,7 +715,7 @@ getbatchdetails() { # },"error":null} # # BODY {} - # BODY {"batchId":34} + # BODY {"batcherId":34} local request=${1} local response @@ -725,25 +725,25 @@ getbatchdetails() { local outputsjson local whereclause - local batch_id=$(echo "${request}" | jq ".batchId") - trace "[getbatchdetails] batch_id=${batch_id}" - local batch_label=$(echo "${request}" | jq ".batchLabel") - trace "[getbatchdetails] batch_label=${batch_label}" + local batcher_id=$(echo "${request}" | jq ".batcherId") + trace "[getbatchdetails] batcher_id=${batcher_id}" + local batcher_label=$(echo "${request}" | jq ".batcherLabel") + trace "[getbatchdetails] batcher_label=${batcher_label}" local txid=$(echo "${request}" | jq ".txid") trace "[getbatchdetails] txid=${txid}" - if [ "${batch_id}" = "null" ] && [ "${batch_label}" = "null" ]; then - # If batch_id and batch_label are null, use default batch - trace "[getbatchdetails] Using default batch 1" - batch_id=1 + if [ "${batcher_id}" = "null" ] && [ "${batcher_label}" = "null" ]; then + # If batcher_id and batcher_label are null, use default batcher + trace "[getbatchdetails] Using default batcher 1" + batcher_id=1 fi - if [ "${batch_id}" = "null" ]; then - # Using batch_label - whereclause="b.label=${batch_label}" + if [ "${batcher_id}" = "null" ]; then + # Using batcher_label + whereclause="b.label=${batcher_label}" else - # Using batch_id - whereclause="b.id=${batch_id}" + # Using batcher_id + whereclause="b.id=${batcher_id}" fi if [ "${txid}" != "null" ]; then @@ -756,7 +756,7 @@ getbatchdetails() { fi # First get the batch summary - batch=$(sql "SELECT b.id, COALESCE(t.id, 0), '{\"batchId\":' || b.id || ',\"batchLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"total\":' ||COALESCE(SUM(amount), 0.00000000) FROM batch b LEFT JOIN recipient r ON r.batch_id=b.id ${outerclause} LEFT JOIN tx t ON t.id=r.tx_id WHERE ${whereclause} GROUP BY b.id") + batch=$(sql "SELECT b.id, COALESCE(t.id, 0), '{\"batcherId\":' || b.id || ',\"batcherLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"total\":' ||COALESCE(SUM(amount), 0.00000000) FROM batcher b LEFT JOIN recipient r ON r.batcher_id=b.id ${outerclause} LEFT JOIN tx t ON t.id=r.tx_id WHERE ${whereclause} GROUP BY b.id") trace "[getbatchdetails] batch=${batch}" if [ -n "${batch}" ]; then @@ -775,8 +775,8 @@ getbatchdetails() { tx=$(sql "SELECT '\"txid\":\"' || txid || '\",\"hash\":\"' || hash || '\",\"details\":{\"firstseen\":' || timereceived || ',\"size\":' || size || ',\"vsize\":' || vsize || ',\"replaceable\":' || is_replaceable || ',\"fee\":' || fee || '}' FROM tx WHERE id=${tx_id}") - batch_id=$(echo "${batch}" | cut -d '|' -f1) - outputs=$(sql "SELECT '{\"outputId\":' || id || ',\"outputLabel\":\"' || COALESCE(label, '') || '\",\"address\":\"' || address || '\",\"amount\":' || amount || ',\"addedTimestamp\":\"' || inserted_ts || '\"}' FROM recipient r WHERE batch_id=${batch_id} ${outerclause}") + batcher_id=$(echo "${batch}" | cut -d '|' -f1) + outputs=$(sql "SELECT '{\"outputId\":' || id || ',\"outputLabel\":\"' || COALESCE(label, '') || '\",\"address\":\"' || address || '\",\"amount\":' || amount || ',\"addedTimestamp\":\"' || inserted_ts || '\"}' FROM recipient r WHERE batcher_id=${batcher_id} ${outerclause}") local output local IFS=$'\n' @@ -806,18 +806,18 @@ getbatchdetails() { } -# curl localhost:8888/listbatches | jq -# curl -d '{}' localhost:8888/getbatch | jq +# curl localhost:8888/listbatchers | jq +# curl -d '{}' localhost:8888/getbatcher | jq # curl -d '{}' localhost:8888/getbatchdetails | jq # curl -d '{"outputLabel":"test002","address":"1abd","amount":0.0002}' localhost:8888/addtobatch | jq # curl -d '{}' localhost:8888/batchspend | jq # curl -d '{"outputId":1}' localhost:8888/removefrombatch | jq -# curl -d '{"batchLabel":"lowfees","confTarget":32}' localhost:8888/createbatch | jq -# curl localhost:8888/listbatches | jq +# curl -d '{"batcherLabel":"lowfees","confTarget":32}' localhost:8888/createbatcher | jq +# curl localhost:8888/listbatchers | jq -# curl -d '{"batchLabel":"lowfees"}' localhost:8888/getbatch | jq -# curl -d '{"batchLabel":"lowfees"}' localhost:8888/getbatchdetails | jq -# curl -d '{"batchLabel":"lowfees","outputLabel":"test002","address":"1abd","amount":0.0002}' localhost:8888/addtobatch | jq -# curl -d '{"batchLabel":"lowfees"}' localhost:8888/batchspend | jq -# curl -d '{"batchLabel":"lowfees","outputId":9}' localhost:8888/removefrombatch | jq +# curl -d '{"batcherLabel":"lowfees"}' localhost:8888/getbatcher | jq +# curl -d '{"batcherLabel":"lowfees"}' localhost:8888/getbatchdetails | jq +# curl -d '{"batcherLabel":"lowfees","outputLabel":"test002","address":"1abd","amount":0.0002}' localhost:8888/addtobatch | jq +# curl -d '{"batcherLabel":"lowfees"}' localhost:8888/batchspend | jq +# curl -d '{"batcherLabel":"lowfees","outputId":9}' localhost:8888/removefrombatch | jq diff --git a/proxy_docker/app/script/requesthandler.sh b/proxy_docker/app/script/requesthandler.sh index bbf3836..59bb748 100644 --- a/proxy_docker/app/script/requesthandler.sh +++ b/proxy_docker/app/script/requesthandler.sh @@ -301,45 +301,45 @@ main() { response_to_client "${response}" ${?} break ;; - createbatch) - # POST http://192.168.111.152:8080/createbatch + createbatcher) + # POST http://192.168.111.152:8080/createbatcher # # args: - # - batchLabel, optional, id can be used to reference the batch + # - batcherLabel, optional, id can be used to reference the batcher # - confTarget, optional, overriden by batchspend's confTarget, default Bitcoin Core conf_target will be used if not supplied # NOTYET - feeRate, sat/vB, optional, overrides confTarget if supplied, overriden by batchspend's feeRate, default Bitcoin Core fee policy will be used if not supplied # # response: - # - batchId, the batch id + # - batcherId, the batcher id # - # BODY {"batchLabel":"lowfees","confTarget":32} - # NOTYET BODY {"batchLabel":"highfees","feeRate":231.8} + # BODY {"batcherLabel":"lowfees","confTarget":32} + # NOTYET BODY {"batcherLabel":"highfees","feeRate":231.8} - response=$(createbatch "${line}") + response=$(createbatcher "${line}") response_to_client "${response}" ${?} break ;; - updatebatch) - # POST http://192.168.111.152:8080/updatebatch + updatebatcher) + # POST http://192.168.111.152:8080/updatebatcher # # args: - # - batchId, optional, batch id to update, will update default batch if not supplied - # - batchLabel, optional, id can be used to reference the batch, will update default batch if not supplied, if id is present then change the label with supplied text - # - confTarget, optional, new confirmation target for the batch - # NOTYET - feeRate, sat/vB, optional, new feerate for the batch + # - batcherId, optional, batcher id to update, will update default batcher if not supplied + # - batcherLabel, optional, id can be used to reference the batcher, will update default batcher if not supplied, if id is present then change the label with supplied text + # - confTarget, optional, new confirmation target for the batcher + # NOTYET - feeRate, sat/vB, optional, new feerate for the batcher # # response: - # - batchId, the batch id - # - batchLabel, the batch label - # - confTarget, the batch default confirmation target - # NOTYET - feeRate, the batch default feerate + # - batcherId, the batcher id + # - batcherLabel, the batcher label + # - confTarget, the batcher default confirmation target + # NOTYET - feeRate, the batcher default feerate # - # BODY {"batchId":5,"confTarget":12} - # NOTYET BODY {"batchLabel":"highfees","feeRate":400} - # NOTYET BODY {"batchId":3,"label":"ultrahighfees","feeRate":800} - # BODY {"batchLabel":"fast","confTarget":2} + # BODY {"batcherId":5,"confTarget":12} + # NOTYET BODY {"batcherLabel":"highfees","feeRate":400} + # NOTYET BODY {"batcherId":3,"label":"ultrahighfees","feeRate":800} + # BODY {"batcherLabel":"fast","confTarget":2} - response=$(updatebatch "${line}") + response=$(updatebatcher "${line}") response_to_client "${response}" ${?} break ;; @@ -350,8 +350,8 @@ main() { # - address, required, desination address # - amount, required, amount to send to the destination address # - outputLabel, optional, if you want to reference this output - # - batchId, optional, the id of the batch to which the output will be added, default batch if not supplied, overrides batchLabel - # - batchLabel, optional, the label of the batch to which the output will be added, default batch if not supplied + # - batcherId, optional, the id of the batcher to which the output will be added, default batcher if not supplied, overrides batcherLabel + # - batcherLabel, optional, the label of the batcher to which the output will be added, default batcher if not supplied # - webhookUrl, optional, the webhook to call when the batch is broadcast # # response: @@ -361,9 +361,9 @@ main() { # - pendingTotal, the current sum of the batch's output amounts # # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233} - # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batchId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} - # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batchLabel":"lowfees","webhookUrl":"https://myCypherApp:3000/batchExecuted"} - # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batchId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} + # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batcherId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} + # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batcherLabel":"lowfees","webhookUrl":"https://myCypherApp:3000/batchExecuted"} + # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batcherId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} response=$(addtobatch "${line}") response_to_client "${response}" ${?} @@ -391,10 +391,10 @@ main() { # POST http://192.168.111.152:8080/batchspend # # args: - # - batchId, optional, id of the batch to spend, overrides batchLabel, default batch will be spent if not supplied - # - batchLabel, optional, label of the batch to spend, default batch will be spent if not supplied - # - confTarget, optional, overrides default value of createbatch, default to value of createbatch, default Bitcoin Core conf_target will be used if not supplied - # NOTYET - feeRate, optional, overrides confTarget if supplied, overrides default value of createbatch, default to value of createbatch, default Bitcoin Core value will be used if not supplied + # - batcherId, optional, id of the batcher to execute, overrides batcherLabel, default batcher will be spent if not supplied + # - batcherLabel, optional, label of the batcher to execute, default batcher will be executed if not supplied + # - confTarget, optional, overrides default value of createbatcher, default to value of createbatcher, default Bitcoin Core conf_target will be used if not supplied + # NOTYET - feeRate, optional, overrides confTarget if supplied, overrides default value of createbatcher, default to value of createbatcher, default Bitcoin Core value will be used if not supplied # # response: # - txid, the transaction txid @@ -406,8 +406,8 @@ main() { # - outputs # # {"result":{ - # "batchId":34, - # "batchLabel":"Special batch for a special client", + # "batcherId":34, + # "batcherLabel":"Special batcher for a special client", # "confTarget":6, # "nbOutputs":83, # "oldest":123123, @@ -431,28 +431,28 @@ main() { # },"error":null} # # BODY {} - # BODY {"batchId":34,"confTarget":12} - # NOTYET BODY {"batchLabel":"highfees","feeRate":233.7} - # BODY {"batchId":411,"confTarget":6} + # BODY {"batcherId":34,"confTarget":12} + # NOTYET BODY {"batcherLabel":"highfees","feeRate":233.7} + # BODY {"batcherId":411,"confTarget":6} response=$(batchspend "${line}") response_to_client "${response}" ${?} break ;; - getbatch) - # POST (GET) http://192.168.111.152:8080/getbatch + getbatcher) + # POST (GET) http://192.168.111.152:8080/getbatcher # # args: - # - batchId, optional, id of the batch to spend, overrides batchLabel, default batch will be spent if not supplied - # - batchLabel, optional, label of the batch to spend, default batch will be spent if not supplied + # - batcherId, optional, id of the batcher, overrides batcherLabel, default batcher will be used if not supplied + # - batcherLabel, optional, label of the batcher, default batcher will be used if not supplied # # response: - # {"result":{"batchId":1,"batchLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143},"error":null} + # {"result":{"batcherId":1,"batcherLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143},"error":null} # # BODY {} - # BODY {"batchId":34} + # BODY {"batcherId":34} - response=$(getbatch "${line}") + response=$(getbatcher "${line}") response_to_client "${response}" ${?} break ;; @@ -460,15 +460,15 @@ main() { # POST (GET) http://192.168.111.152:8080/getbatchdetails # # args: - # - batchId, optional, id of the batch to spend, overrides batchLabel, default batch will be spent if not supplied - # - batchLabel, optional, label of the batch to spend, default batch will be spent if not supplied + # - batcherId, optional, id of the batcher, overrides batcherLabel, default batcher will be spent if not supplied + # - batcherLabel, optional, label of the batcher, default batcher will be used if not supplied # - txid, optional, if you want the details of an executed batch, supply the batch txid, will return current pending batch # if not supplied # # response: # {"result":{ - # "batchId":34, - # "batchLabel":"Special batch for a special client", + # "batcherId":34, + # "batcherLabel":"Special batcher for a special client", # "confTarget":6, # "nbOutputs":83, # "oldest":123123, @@ -492,24 +492,24 @@ main() { # },"error":null} # # BODY {} - # BODY {"batchId":34} + # BODY {"batcherId":34} response=$(getbatchdetails "${line}") response_to_client "${response}" ${?} break ;; - listbatches) - # curl (GET) http://192.168.111.152:8080/listbatches + listbatchers) + # curl (GET) http://192.168.111.152:8080/listbatchers # # response: # {"result":[ - # {"batchId":1,"batchLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143}, - # {"batchId":2,"batchLabel":"lowfee","confTarget":32,"nbOutputs":44,"oldest":123123,"pendingTotal":0.49827387}, - # {"batchId":3,"batchLabel":"highfee","confTarget":2,"nbOutputs":7,"oldest":123123,"pendingTotal":4.16843782} + # {"batcherId":1,"batcherLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143}, + # {"batcherId":2,"batcherLabel":"lowfee","confTarget":32,"nbOutputs":44,"oldest":123123,"pendingTotal":0.49827387}, + # {"batcherId":3,"batcherLabel":"highfee","confTarget":2,"nbOutputs":7,"oldest":123123,"pendingTotal":4.16843782} # ], # "error":null} - response=$(listbatches) + response=$(listbatchers) response_to_client "${response}" ${?} break ;; diff --git a/proxy_docker/app/script/test-batching.sh b/proxy_docker/app/script/test-batching.sh index eb2bab9..b693680 100755 --- a/proxy_docker/app/script/test-batching.sh +++ b/proxy_docker/app/script/test-batching.sh @@ -1,20 +1,20 @@ #!/bin/sh -# curl localhost:8888/listbatches | jq -# curl -d '{}' localhost:8888/getbatch | jq +# curl localhost:8888/listbatchers | jq +# curl -d '{}' localhost:8888/getbatcher | jq # curl -d '{}' localhost:8888/getbatchdetails | jq # curl -d '{"outputLabel":"test002","address":"1abd","amount":0.0002}' localhost:8888/addtobatch | jq # curl -d '{}' localhost:8888/batchspend | jq # curl -d '{"outputId":1}' localhost:8888/removefrombatch | jq -# curl -d '{"batchLabel":"lowfees","confTarget":32}' localhost:8888/createbatch | jq -# curl localhost:8888/listbatches | jq +# curl -d '{"batcherLabel":"lowfees","confTarget":32}' localhost:8888/createbatcher | jq +# curl localhost:8888/listbatchers | jq -# curl -d '{"batchLabel":"lowfees"}' localhost:8888/getbatch | jq -# curl -d '{"batchLabel":"lowfees"}' localhost:8888/getbatchdetails | jq -# curl -d '{"batchLabel":"lowfees","outputLabel":"test002","address":"1abd","amount":0.0002}' localhost:8888/addtobatch | jq -# curl -d '{"batchLabel":"lowfees"}' localhost:8888/batchspend | jq -# curl -d '{"batchLabel":"lowfees","outputId":9}' localhost:8888/removefrombatch | jq +# curl -d '{"batcherLabel":"lowfees"}' localhost:8888/getbatcher | jq +# curl -d '{"batcherLabel":"lowfees"}' localhost:8888/getbatchdetails | jq +# curl -d '{"batcherLabel":"lowfees","outputLabel":"test002","address":"1abd","amount":0.0002}' localhost:8888/addtobatch | jq +# curl -d '{"batcherLabel":"lowfees"}' localhost:8888/batchspend | jq +# curl -d '{"batcherLabel":"lowfees","outputId":9}' localhost:8888/removefrombatch | jq testbatching() { local response @@ -32,42 +32,42 @@ testbatching() { local url2="$(hostname):1112/callback" echo "url2=${url2}" - # List batches (should show at least empty default batch) - echo "Testing listbatches..." - response=$(curl -s proxy:8888/listbatches) + # List batchers (should show at least empty default batcher) + echo "Testing listbatchers..." + response=$(curl -s proxy:8888/listbatchers) echo "response=${response}" - id=$(echo "${response}" | jq ".result[0].batchId") - echo "batchId=${id}" + id=$(echo "${response}" | jq ".result[0].batcherId") + echo "batcherId=${id}" if [ "${id}" -ne "1" ]; then exit 10 fi - echo "Tested listbatches." + echo "Tested listbatchers." - # getbatch the default batch - echo "Testing getbatch..." - response=$(curl -sd '{}' localhost:8888/getbatch) + # getbatcher the default batcher + echo "Testing getbatcher..." + response=$(curl -sd '{}' localhost:8888/getbatcher) echo "response=${response}" - data=$(echo "${response}" | jq -r ".result.batchLabel") - echo "batchLabel=${data}" + data=$(echo "${response}" | jq -r ".result.batcherLabel") + echo "batcherLabel=${data}" if [ "${data}" != "default" ]; then exit 20 fi - response=$(curl -sd '{"batchId":1}' localhost:8888/getbatch) + response=$(curl -sd '{"batcherId":1}' localhost:8888/getbatcher) echo "response=${response}" - data=$(echo "${response}" | jq -r ".result.batchLabel") - echo "batchLabel=${data}" + data=$(echo "${response}" | jq -r ".result.batcherLabel") + echo "batcherLabel=${data}" if [ "${data}" != "default" ]; then exit 25 fi - echo "Tested getbatch." + echo "Tested getbatcher." - # getbatchdetails the default batch + # getbatchdetails the default batcher echo "Testing getbatchdetails..." response=$(curl -sd '{}' localhost:8888/getbatchdetails) echo "response=${response}" - data=$(echo "${response}" | jq -r ".result.batchLabel") - echo "batchLabel=${data}" + data=$(echo "${response}" | jq -r ".result.batcherLabel") + echo "batcherLabel=${data}" if [ "${data}" != "default" ]; then exit 30 fi @@ -76,10 +76,10 @@ testbatching() { exit 32 fi - response=$(curl -sd '{"batchId":1}' localhost:8888/getbatchdetails) + response=$(curl -sd '{"batcherId":1}' localhost:8888/getbatchdetails) echo "response=${response}" - data=$(echo "${response}" | jq -r ".result.batchLabel") - echo "batchLabel=${data}" + data=$(echo "${response}" | jq -r ".result.batcherLabel") + echo "batcherLabel=${data}" if [ "${data}" != "default" ]; then exit 35 fi @@ -89,12 +89,12 @@ testbatching() { fi echo "Tested getbatchdetails." - # addtobatch to default batch + # addtobatch to default batcher echo "Testing addtobatch..." response=$(curl -sd '{"outputLabel":"test001","address":"test001","amount":0.001}' localhost:8888/addtobatch) echo "response=${response}" - id=$(echo "${response}" | jq ".result.batchId") - echo "batchId=${id}" + id=$(echo "${response}" | jq ".result.batcherId") + echo "batcherId=${id}" if [ "${id}" -ne "1" ]; then exit 40 fi @@ -104,10 +104,10 @@ testbatching() { fi echo "outputId=${id}" - response=$(curl -sd '{"batchId":1,"outputLabel":"test002","address":"test002","amount":0.002}' localhost:8888/addtobatch) + response=$(curl -sd '{"batcherId":1,"outputLabel":"test002","address":"test002","amount":0.002}' localhost:8888/addtobatch) echo "response=${response}" - id2=$(echo "${response}" | jq ".result.batchId") - echo "batchId=${id2}" + id2=$(echo "${response}" | jq ".result.batcherId") + echo "batcherId=${id2}" if [ "${id2}" -ne "1" ]; then exit 40 fi @@ -118,7 +118,7 @@ testbatching() { echo "outputId=${id2}" echo "Tested addtobatch." - # batchspend default batch + # batchspend default batcher echo "Testing batchspend..." response=$(curl -sd '{}' localhost:8888/batchspend) echo "response=${response}" @@ -128,7 +128,7 @@ testbatching() { fi echo "Tested batchspend." - # getbatchdetails the default batch + # getbatchdetails the default batcher echo "Testing getbatchdetails..." response=$(curl -sd '{}' localhost:8888/getbatchdetails) echo "response=${response}" @@ -136,28 +136,28 @@ testbatching() { echo "nbOutputs=${data}" echo "Tested getbatchdetails." - # removefrombatch from default batch + # removefrombatch from default batcher echo "Testing removefrombatch..." response=$(curl -sd '{"outputId":'${id}'}' localhost:8888/removefrombatch) echo "response=${response}" - id=$(echo "${response}" | jq ".result.batchId") - echo "batchId=${id}" + id=$(echo "${response}" | jq ".result.batcherId") + echo "batcherId=${id}" if [ "${id}" -ne "1" ]; then exit 50 fi response=$(curl -sd '{"outputId":'${id2}'}' localhost:8888/removefrombatch) echo "response=${response}" - id=$(echo "${response}" | jq ".result.batchId") - echo "batchId=${id}" + id=$(echo "${response}" | jq ".result.batcherId") + echo "batcherId=${id}" if [ "${id}" -ne "1" ]; then exit 54 fi echo "Tested removefrombatch." - # getbatchdetails the default batch + # getbatchdetails the default batcher echo "Testing getbatchdetails..." - response=$(curl -sd '{"batchId":1}' localhost:8888/getbatchdetails) + response=$(curl -sd '{"batcherId":1}' localhost:8888/getbatchdetails) echo "response=${response}" data2=$(echo "${response}" | jq ".result.nbOutputs") echo "nbOutputs=${data2}" @@ -179,51 +179,51 @@ testbatching() { - # Create a batch - echo "Testing createbatch..." - response=$(curl -s -H 'Content-Type: application/json' -d '{"batchLabel":"testbatch","confTarget":32}' proxy:8888/createbatch) + # Create a batcher + echo "Testing createbatcher..." + response=$(curl -s -H 'Content-Type: application/json' -d '{"batcherLabel":"testbatcher","confTarget":32}' proxy:8888/createbatcher) echo "response=${response}" - id=$(echo "${response}" | jq -e ".result.batchId") + id=$(echo "${response}" | jq -e ".result.batcherId") if [ "$?" -ne "0" ]; then exit 60 fi - # List batches (should show at least default and testbatch batches) + # List batchers (should show at least default and testbatcher batchers) echo "Testing listbatches..." - response=$(curl -s proxy:8888/listbatches) + response=$(curl -s proxy:8888/listbatchers) echo "response=${response}" - id=$(echo "${response}" | jq '.result[] | select(.batchLabel == "testbatch") | .batchId') - echo "batchId=${id}" + id=$(echo "${response}" | jq '.result[] | select(.batcherLabel == "testbatcher") | .batcherId') + echo "batcherId=${id}" if [ -z "${id}" ]; then exit 70 fi - echo "Tested listbatches." + echo "Tested listbatchers." - # getbatch the testbatch batch - echo "Testing getbatch..." - response=$(curl -sd '{"batchId":'${id}'}' localhost:8888/getbatch) + # getbatcher the testbatcher batcher + echo "Testing getbatcher..." + response=$(curl -sd '{"batcherId":'${id}'}' localhost:8888/getbatcher) echo "response=${response}" - data=$(echo "${response}" | jq -r ".result.batchLabel") - echo "batchLabel=${data}" - if [ "${data}" != "testbatch" ]; then + data=$(echo "${response}" | jq -r ".result.batcherLabel") + echo "batcherLabel=${data}" + if [ "${data}" != "testbatcher" ]; then exit 80 fi - response=$(curl -sd '{"batchLabel":"testbatch"}' localhost:8888/getbatch) + response=$(curl -sd '{"batcherLabel":"testbatcher"}' localhost:8888/getbatcher) echo "response=${response}" - data=$(echo "${response}" | jq -r ".result.batchId") - echo "batchId=${data}" + data=$(echo "${response}" | jq -r ".result.batcherId") + echo "batcherId=${data}" if [ "${data}" != "${id}" ]; then exit 90 fi - echo "Tested getbatch." + echo "Tested getbatcher." - # getbatchdetails the testbatch batch + # getbatchdetails the testbatcher batcher echo "Testing getbatchdetails..." - response=$(curl -sd '{"batchLabel":"testbatch"}' localhost:8888/getbatchdetails) + response=$(curl -sd '{"batcherLabel":"testbatcher"}' localhost:8888/getbatchdetails) echo "response=${response}" - data=$(echo "${response}" | jq -r ".result.batchId") - echo "batchId=${data}" + data=$(echo "${response}" | jq -r ".result.batcherId") + echo "batcherId=${data}" if [ "${data}" != "${id}" ]; then exit 100 fi @@ -232,11 +232,11 @@ testbatching() { exit 32 fi - response=$(curl -sd '{"batchId":'${id}'}' localhost:8888/getbatchdetails) + response=$(curl -sd '{"batcherId":'${id}'}' localhost:8888/getbatchdetails) echo "response=${response}" - data=$(echo "${response}" | jq -r ".result.batchLabel") - echo "batchLabel=${data}" - if [ "${data}" != "testbatch" ]; then + data=$(echo "${response}" | jq -r ".result.batcherLabel") + echo "batcherLabel=${data}" + if [ "${data}" != "testbatcher" ]; then exit 35 fi echo "${response}" | jq -e ".result.outputs" @@ -245,14 +245,14 @@ testbatching() { fi echo "Tested getbatchdetails." - # addtobatch to testbatch batch + # addtobatch to testbatcher batcher echo "Testing addtobatch..." address1=$(curl -s localhost:8888/getnewaddress | jq -r ".address") echo "address1=${address1}" - response=$(curl -sd '{"batchId":'${id}',"outputLabel":"test001","address":"'${address1}'","amount":0.001,"webhookUrl":"'${url1}'/'${address1}'"}' localhost:8888/addtobatch) + response=$(curl -sd '{"batcherId":'${id}',"outputLabel":"test001","address":"'${address1}'","amount":0.001,"webhookUrl":"'${url1}'/'${address1}'"}' localhost:8888/addtobatch) echo "response=${response}" - data=$(echo "${response}" | jq ".result.batchId") - echo "batchId=${data}" + data=$(echo "${response}" | jq ".result.batcherId") + echo "batcherId=${data}" if [ "${data}" -ne "${id}" ]; then exit 40 fi @@ -264,10 +264,10 @@ testbatching() { address2=$(curl -s localhost:8888/getnewaddress | jq -r ".address") echo "address2=${address2}" - response=$(curl -sd '{"batchLabel":"testbatch","outputLabel":"test002","address":"'${address2}'","amount":0.002,"webhookUrl":"'${url2}'/'${address2}'"}' localhost:8888/addtobatch) + response=$(curl -sd '{"batcherLabel":"testbatcher","outputLabel":"test002","address":"'${address2}'","amount":0.002,"webhookUrl":"'${url2}'/'${address2}'"}' localhost:8888/addtobatch) echo "response=${response}" - data=$(echo "${response}" | jq ".result.batchId") - echo "batchId=${data}" + data=$(echo "${response}" | jq ".result.batcherId") + echo "batcherId=${data}" if [ "${data}" -ne "${id}" ]; then exit 40 fi @@ -278,9 +278,9 @@ testbatching() { echo "outputId=${id2}" echo "Tested addtobatch." - # batchspend testbatch batch + # batchspend testbatcher batcher echo "Testing batchspend..." - response=$(curl -sd '{"batchLabel":"testbatch"}' localhost:8888/batchspend) + response=$(curl -sd '{"batcherLabel":"testbatcher"}' localhost:8888/batchspend) echo "response=${response}" data2=$(echo "${response}" | jq -e ".result.txid") if [ "$?" -ne 0 ]; then @@ -293,10 +293,10 @@ testbatching() { fi echo "Tested batchspend." - # getbatchdetails the testbatch batch + # getbatchdetails the testbatcher batcher echo "Testing getbatchdetails..." echo "txid=${data2}" - response=$(curl -sd '{"batchLabel":"testbatch","txid":'${data2}'}' localhost:8888/getbatchdetails) + response=$(curl -sd '{"batcherLabel":"testbatcher","txid":'${data2}'}' localhost:8888/getbatchdetails) echo "response=${response}" data=$(echo "${response}" | jq ".result.nbOutputs") echo "nbOutputs=${data}" @@ -305,11 +305,11 @@ testbatching() { fi echo "Tested getbatchdetails." - # List batches + # List batchers # Add to batch - # List batches + # List batchers # Remove from batch - # List batches + # List batchers } wait_for_callbacks() { diff --git a/proxy_docker/app/script/walletoperations.sh b/proxy_docker/app/script/walletoperations.sh index a7e623a..3c7fcbd 100644 --- a/proxy_docker/app/script/walletoperations.sh +++ b/proxy_docker/app/script/walletoperations.sh @@ -43,7 +43,7 @@ spend() { local tx_amount=$(echo "${tx_details}" | jq '.result.amount | fabs' | awk '{ printf "%.8f", $0 }') local tx_size=$(echo "${tx_raw_details}" | jq '.result.size') local tx_vsize=$(echo "${tx_raw_details}" | jq '.result.vsize') - local tx_replaceable=$(echo "${tx_details}" | jq '.result."bip125-replaceable"') + local tx_replaceable=$(echo "${tx_details}" | jq -r '.result."bip125-replaceable"') tx_replaceable=$([ ${tx_replaceable} = "yes" ] && echo "true" || echo "false") local fees=$(echo "${tx_details}" | jq '.result.fee | fabs' | awk '{ printf "%.8f", $0 }') # Sometimes raw tx are too long to be passed as paramater, so let's write From c55b8b22d97c0675be0fb49541476a58242be496 Mon Sep 17 00:00:00 2001 From: kexkey Date: Fri, 3 Jul 2020 13:50:27 -0400 Subject: [PATCH 03/22] Old batches are actually batchers and they create batches --- api_auth_docker/api-sample.properties | 8 ++++---- cyphernodeconf_docker/templates/gatekeeper/api.properties | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api_auth_docker/api-sample.properties b/api_auth_docker/api-sample.properties index 74c2cc4..4b295af 100644 --- a/api_auth_docker/api-sample.properties +++ b/api_auth_docker/api-sample.properties @@ -56,11 +56,11 @@ action_ln_decodebolt11=spender action_ln_connectfund=spender action_ln_listfunds=spender action_ln_withdraw=spender -action_createbatch=spender -action_updatebatch=spender +action_createbatcher=spender +action_updatebatcher=spender action_removefrombatch=spender -action_listbatches=spender -action_getbatch=spender +action_listbatchers=spender +action_getbatcher=spender action_getbatchdetails=spender # Admin can do what the spender can do, plus: diff --git a/cyphernodeconf_docker/templates/gatekeeper/api.properties b/cyphernodeconf_docker/templates/gatekeeper/api.properties index 7350b0b..a2f4019 100644 --- a/cyphernodeconf_docker/templates/gatekeeper/api.properties +++ b/cyphernodeconf_docker/templates/gatekeeper/api.properties @@ -60,11 +60,11 @@ action_ln_decodebolt11=spender action_ln_connectfund=spender action_ln_listfunds=spender action_ln_withdraw=spender -action_createbatch=spender -action_updatebatch=spender +action_createbatcher=spender +action_updatebatcher=spender action_removefrombatch=spender -action_listbatches=spender -action_getbatch=spender +action_listbatchers=spender +action_getbatcher=spender action_getbatchdetails=spender # Admin can do what the spender can do, plus: From c561ddccc63d52cc975adbf0c48265cc72f29706 Mon Sep 17 00:00:00 2001 From: kexkey Date: Wed, 22 Jul 2020 15:11:14 -0400 Subject: [PATCH 04/22] Norm. responses, validate addresses, several small fixes --- proxy_docker/app/script/batching.sh | 66 ++++++++++++++--------- proxy_docker/app/script/requesthandler.sh | 14 ++--- 2 files changed, 50 insertions(+), 30 deletions(-) diff --git a/proxy_docker/app/script/batching.sh b/proxy_docker/app/script/batching.sh index fe7786e..94fd3b2 100644 --- a/proxy_docker/app/script/batching.sh +++ b/proxy_docker/app/script/batching.sh @@ -123,15 +123,17 @@ addtobatch() { # args: # - address, required, desination address # - amount, required, amount to send to the destination address + # - outputLabel, optional, if you want to reference this output # - batcherId, optional, the id of the batcher to which the output will be added, default batcher if not supplied, overrides batcherLabel # - batcherLabel, optional, the label of the batcher to which the output will be added, default batcher if not supplied # - webhookUrl, optional, the webhook to call when the batch is broadcast # # response: + # - batcherId, the id of the batcher # - outputId, the id of the added output # - nbOutputs, the number of outputs currently in the batch # - oldest, the timestamp of the oldest output in the batch - # - pendingTotal, the current sum of the batch's output amounts + # - total, the current sum of the batch's output amounts # # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233} # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batcherId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} @@ -144,7 +146,7 @@ addtobatch() { local inserted_id local row - local address=$(echo "${request}" | jq ".address") + local address=$(echo "${request}" | jq -r ".address") trace "[addtobatch] address=${address}" local amount=$(echo "${request}" | jq ".amount") trace "[addtobatch] amount=${amount}" @@ -157,6 +159,20 @@ addtobatch() { local webhook_url=$(echo "${request}" | jq ".webhookUrl") trace "[addtobatch] webhook_url=${webhook_url}" + local isvalid + isvalid=$(validateaddress "${address}" | jq ".result.isvalid") + if [ "${isvalid}" != "true" ]; then + + response='{"result":null,"error":{"code":-32700,"message":"Invalid address","data":'${request}'}}' + + trace "[addtobatch] Invalid address" + trace "[addtobatch] responding=${response}" + + echo "${response}" + + return 1 + fi + if [ "${batcher_id}" = "null" ] && [ "${batcher_label}" = "null" ]; then # If batcher_id and batcher_label are null, use default batcher trace "[addtobatch] Using default batcher 1" @@ -174,7 +190,7 @@ addtobatch() { # batcherLabel not found response='{"result":null,"error":{"code":-32700,"message":"batcher not found","data":'${request}'}}' else - inserted_id=$(sql "INSERT INTO recipient (address, amount, webhook_url, batcher_id, label) VALUES (${address}, ${amount}, ${webhook_url}, ${batcher_id}, ${label}); SELECT LAST_INSERT_ROWID();") + inserted_id=$(sql "INSERT INTO recipient (address, amount, webhook_url, batcher_id, label) VALUES (\"${address}\", ${amount}, ${webhook_url}, ${batcher_id}, ${label}); SELECT LAST_INSERT_ROWID();") returncode=$? trace_rc ${returncode} @@ -192,7 +208,7 @@ addtobatch() { local total=$(echo "${row}" | cut -d '|' -f3) trace "[addtobatch] total=${total}" - response='{"result":{"batcherId":'${batcher_id}',"outputId":'${inserted_id}',"nbOutputs":'${count}',"oldest":"'${oldest}'","pendingTotal":'${total}'},"error":null}' + response='{"result":{"batcherId":'${batcher_id}',"outputId":'${inserted_id}',"nbOutputs":'${count}',"oldest":"'${oldest}'","total":'${total}'},"error":null}' fi fi @@ -208,10 +224,11 @@ removefrombatch() { # - outputId, required, id of the output to remove # # response: + # - batcherId, the id of the batcher # - outputId, the id of the removed output if found # - nbOutputs, the number of outputs currently in the batch # - oldest, the timestamp of the oldest output in the batch - # - pendingTotal, the current sum of the batch's output amounts + # - total, the current sum of the batch's output amounts # # BODY {"id":72} @@ -252,7 +269,7 @@ removefrombatch() { local total=$(echo "${row}" | cut -d '|' -f3) trace "[removefrombatch] total=${total}" - response='{"result":{"batcherId":'${batcher_id}',"nbOutputs":'${count}',"oldest":"'${oldest}'","pendingTotal":'${total}'},"error":null}' + response='{"result":{"batcherId":'${batcher_id}',"outputId":'${id}',"nbOutputs":'${count}',"oldest":"'${oldest}'","total":'${total}'},"error":null}' fi else response='{"result":null,"error":{"code":-32700,"message":"Output not found or already spent","data":'${request}'}}' @@ -450,7 +467,7 @@ batchspend() { local total=$(echo "${row}" | cut -d '|' -f3) trace "[batchspend] total=${total}" - response='{"result":{"batcherId":'${batcher_id}',"nbOutputs":'${count}',"oldest":"'${oldest}'","total":'${total} + response='{"result":{"batcherId":'${batcher_id}',"confTarget":'${conf_target}',"nbOutputs":'${count}',"oldest":"'${oldest}'","total":'${total} response="${response},\"txid\":\"${txid}\",\"hash\":${tx_hash},\"details\":{\"firstseen\":${tx_ts_firstseen},\"size\":${tx_size},\"vsize\":${tx_vsize},\"replaceable\":${tx_replaceable},\"fee\":${fees}},\"outputs\":{${recipientsjson}}}" response="${response},\"error\":null}" @@ -595,14 +612,14 @@ listbatchers() { # curl (GET) http://192.168.111.152:8080/listbatchers # # {"result":[ - # {"batcherId":1,"batcherLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143}, - # {"batcherId":2,"batcherLabel":"lowfee","confTarget":32,"nbOutputs":44,"oldest":123123,"pendingTotal":0.49827387}, - # {"batcherId":3,"batcherLabel":"highfee","confTarget":2,"nbOutputs":7,"oldest":123123,"pendingTotal":4.16843782} + # {"batcherId":1,"batcherLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"total":0.86990143}, + # {"batcherId":2,"batcherLabel":"lowfee","confTarget":32,"nbOutputs":44,"oldest":123123,"total":0.49827387}, + # {"batcherId":3,"batcherLabel":"highfee","confTarget":2,"nbOutputs":7,"oldest":123123,"total":4.16843782} # ], # "error":null} - local batchers=$(sql "SELECT b.id, '{\"batcherId\":' || b.id || ',\"batcherLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"pendingTotal\":' ||COALESCE(SUM(amount), 0.00000000) || '}' FROM batcher b LEFT JOIN recipient r ON r.batcher_id=b.id AND r.tx_id IS NULL GROUP BY b.id") + local batchers=$(sql "SELECT b.id, '{\"batcherId\":' || b.id || ',\"batcherLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"total\":' ||COALESCE(SUM(amount), 0.00000000) || '}' FROM batcher b LEFT JOIN recipient r ON r.batcher_id=b.id AND r.tx_id IS NULL GROUP BY b.id") trace "[listbatchers] batchers=${batchers}" local returncode @@ -635,7 +652,7 @@ getbatcher() { # - batcherLabel, optional, label of the batcher, default batcher will be used if not supplied # # response: - # {"result":{"batcherId":1,"batcherLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143},"error":null} + # {"result":{"batcherId":1,"batcherLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"total":0.86990143},"error":null} # # BODY {} # BODY {"batcherId":34} @@ -665,7 +682,7 @@ getbatcher() { whereclause="b.id=${batcher_id}" fi - batcher=$(sql "SELECT b.id, '{\"batcherId\":' || b.id || ',\"batcherLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"pendingTotal\":' ||COALESCE(SUM(amount), 0.00000000) || '}' FROM batcher b LEFT JOIN recipient r ON r.batcher_id=b.id AND r.tx_id IS NULL WHERE ${whereclause} GROUP BY b.id") + batcher=$(sql "SELECT b.id, '{\"batcherId\":' || b.id || ',\"batcherLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"total\":' ||COALESCE(SUM(amount), 0.00000000) || '}' FROM batcher b LEFT JOIN recipient r ON r.batcher_id=b.id AND r.tx_id IS NULL WHERE ${whereclause} GROUP BY b.id") trace "[getbatcher] batcher=${batcher}" if [ -n "${batcher}" ]; then @@ -704,14 +721,15 @@ getbatchdetails() { # "size":424, # "vsize":371, # "replaceable":yes, - # "fee":0.00004112, - # "outputs":[ - # {"label":"order 1234","address":"1abc","amount":0.12}, - # {"label":"order 2345","address":"3abc","amount":0.66}, - # "bc1abc":2.848, - # ... - # ] - # } + # "fee":0.00004112 + # }, + # "outputs":[ + # "1abc":0.12, + # "3abc":0.66, + # "bc1abc":2.848, + # ... + # ] + # } # },"error":null} # # BODY {} @@ -756,7 +774,7 @@ getbatchdetails() { fi # First get the batch summary - batch=$(sql "SELECT b.id, COALESCE(t.id, 0), '{\"batcherId\":' || b.id || ',\"batcherLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"total\":' ||COALESCE(SUM(amount), 0.00000000) FROM batcher b LEFT JOIN recipient r ON r.batcher_id=b.id ${outerclause} LEFT JOIN tx t ON t.id=r.tx_id WHERE ${whereclause} GROUP BY b.id") + batch=$(sql "SELECT b.id, COALESCE(t.id, NULL), '{\"batcherId\":' || b.id || ',\"batcherLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"total\":' ||COALESCE(SUM(amount), 0.00000000) FROM batcher b LEFT JOIN recipient r ON r.batcher_id=b.id ${outerclause} LEFT JOIN tx t ON t.id=r.tx_id WHERE ${whereclause} GROUP BY b.id") trace "[getbatchdetails] batch=${batch}" if [ -n "${batch}" ]; then @@ -768,13 +786,13 @@ getbatchdetails() { if [ -n "${tx_id}" ]; then # Using txid outerclause="AND r.tx_id=${tx_id}" + + tx=$(sql "SELECT '\"txid\":\"' || txid || '\",\"hash\":\"' || hash || '\",\"details\":{\"firstseen\":' || timereceived || ',\"size\":' || size || ',\"vsize\":' || vsize || ',\"replaceable\":' || is_replaceable || ',\"fee\":' || fee || '}' FROM tx WHERE id=${tx_id}") else # null txid outerclause="AND r.tx_id IS NULL" fi - tx=$(sql "SELECT '\"txid\":\"' || txid || '\",\"hash\":\"' || hash || '\",\"details\":{\"firstseen\":' || timereceived || ',\"size\":' || size || ',\"vsize\":' || vsize || ',\"replaceable\":' || is_replaceable || ',\"fee\":' || fee || '}' FROM tx WHERE id=${tx_id}") - batcher_id=$(echo "${batch}" | cut -d '|' -f1) outputs=$(sql "SELECT '{\"outputId\":' || id || ',\"outputLabel\":\"' || COALESCE(label, '') || '\",\"address\":\"' || address || '\",\"amount\":' || amount || ',\"addedTimestamp\":\"' || inserted_ts || '\"}' FROM recipient r WHERE batcher_id=${batcher_id} ${outerclause}") diff --git a/proxy_docker/app/script/requesthandler.sh b/proxy_docker/app/script/requesthandler.sh index 59bb748..e99f718 100644 --- a/proxy_docker/app/script/requesthandler.sh +++ b/proxy_docker/app/script/requesthandler.sh @@ -355,10 +355,11 @@ main() { # - webhookUrl, optional, the webhook to call when the batch is broadcast # # response: + # - batcherId, the id of the batcher # - outputId, the id of the added output # - nbOutputs, the number of outputs currently in the batch # - oldest, the timestamp of the oldest output in the batch - # - pendingTotal, the current sum of the batch's output amounts + # - total, the current sum of the batch's output amounts # # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233} # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batcherId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} @@ -376,10 +377,11 @@ main() { # - outputId, required, id of the output to remove # # response: + # - batcherId, the id of the batcher # - outputId, the id of the removed output if found # - nbOutputs, the number of outputs currently in the batch # - oldest, the timestamp of the oldest output in the batch - # - pendingTotal, the current sum of the batch's output amounts + # - total, the current sum of the batch's output amounts # # BODY {"outputId":72} @@ -447,7 +449,7 @@ main() { # - batcherLabel, optional, label of the batcher, default batcher will be used if not supplied # # response: - # {"result":{"batcherId":1,"batcherLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143},"error":null} + # {"result":{"batcherId":1,"batcherLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"total":0.86990143},"error":null} # # BODY {} # BODY {"batcherId":34} @@ -503,9 +505,9 @@ main() { # # response: # {"result":[ - # {"batcherId":1,"batcherLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"pendingTotal":0.86990143}, - # {"batcherId":2,"batcherLabel":"lowfee","confTarget":32,"nbOutputs":44,"oldest":123123,"pendingTotal":0.49827387}, - # {"batcherId":3,"batcherLabel":"highfee","confTarget":2,"nbOutputs":7,"oldest":123123,"pendingTotal":4.16843782} + # {"batcherId":1,"batcherLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"total":0.86990143}, + # {"batcherId":2,"batcherLabel":"lowfee","confTarget":32,"nbOutputs":44,"oldest":123123,"total":0.49827387}, + # {"batcherId":3,"batcherLabel":"highfee","confTarget":2,"nbOutputs":7,"oldest":123123,"total":4.16843782} # ], # "error":null} From ebb3f4f7f9d9ff5b2e1475dd763c2a859c55f05c Mon Sep 17 00:00:00 2001 From: kexkey Date: Tue, 28 Jul 2020 17:44:01 -0400 Subject: [PATCH 05/22] Better response on batchspend --- proxy_docker/app/script/batching.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proxy_docker/app/script/batching.sh b/proxy_docker/app/script/batching.sh index 94fd3b2..eb6a616 100644 --- a/proxy_docker/app/script/batching.sh +++ b/proxy_docker/app/script/batching.sh @@ -468,7 +468,7 @@ batchspend() { trace "[batchspend] total=${total}" response='{"result":{"batcherId":'${batcher_id}',"confTarget":'${conf_target}',"nbOutputs":'${count}',"oldest":"'${oldest}'","total":'${total} - response="${response},\"txid\":\"${txid}\",\"hash\":${tx_hash},\"details\":{\"firstseen\":${tx_ts_firstseen},\"size\":${tx_size},\"vsize\":${tx_vsize},\"replaceable\":${tx_replaceable},\"fee\":${fees}},\"outputs\":{${recipientsjson}}}" + response="${response},\"txid\":\"${txid}\",\"hash\":${tx_hash},\"details\":{\"firstseen\":${tx_ts_firstseen},\"size\":${tx_size},\"vsize\":${tx_vsize},\"replaceable\":${tx_replaceable},\"fee\":${fees}},\"outputs\":[${webhooks_data}]}" response="${response},\"error\":null}" # Delete the temp file containing the raw tx (see above) From 9af47b9940ffdc92615c4e37b649d7f215c15d5f Mon Sep 17 00:00:00 2001 From: kexkey Date: Thu, 30 Jul 2020 17:03:54 -0400 Subject: [PATCH 06/22] Small fixes on batchspend response --- proxy_docker/app/script/batching.sh | 7 ++++--- proxy_docker/app/script/requesthandler.sh | 9 +++++---- proxy_docker/app/script/walletoperations.sh | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/proxy_docker/app/script/batching.sh b/proxy_docker/app/script/batching.sh index eb6a616..ff225f2 100644 --- a/proxy_docker/app/script/batching.sh +++ b/proxy_docker/app/script/batching.sh @@ -291,13 +291,14 @@ batchspend() { # NOTYET - feeRate, optional, overrides confTarget if supplied, overrides default value of createbatcher, default to value of createbatcher, default Bitcoin Core value will be used if not supplied # # response: - # - txid, the txid of the batch + # - batcherId, id of the executed batcher + # - confTarget, conf_target used for the spend # - nbOutputs, the number of outputs spent in the batch # - oldest, the timestamp of the oldest output in the spent batch # - total, the sum of the spent batch's output amounts - # - txid, the transaction txid + # - txid, the batch transaction id # - hash, the transaction hash - # - tx details: size, vsize, replaceable, fee + # - tx details: firstseen, size, vsize, replaceable, fee # - outputs # # BODY {} diff --git a/proxy_docker/app/script/requesthandler.sh b/proxy_docker/app/script/requesthandler.sh index e99f718..88dbd13 100644 --- a/proxy_docker/app/script/requesthandler.sh +++ b/proxy_docker/app/script/requesthandler.sh @@ -399,17 +399,18 @@ main() { # NOTYET - feeRate, optional, overrides confTarget if supplied, overrides default value of createbatcher, default to value of createbatcher, default Bitcoin Core value will be used if not supplied # # response: - # - txid, the transaction txid - # - hash, the transaction hash + # - batcherId, id of the executed batcher + # - confTarget, conf_target used for the spend # - nbOutputs, the number of outputs spent in the batch # - oldest, the timestamp of the oldest output in the spent batch # - total, the sum of the spent batch's output amounts - # - tx details: size, vsize, replaceable, fee + # - txid, the batch transaction id + # - hash, the transaction hash + # - tx details: firstseen, size, vsize, replaceable, fee # - outputs # # {"result":{ # "batcherId":34, - # "batcherLabel":"Special batcher for a special client", # "confTarget":6, # "nbOutputs":83, # "oldest":123123, diff --git a/proxy_docker/app/script/walletoperations.sh b/proxy_docker/app/script/walletoperations.sh index 3c7fcbd..867fafa 100644 --- a/proxy_docker/app/script/walletoperations.sh +++ b/proxy_docker/app/script/walletoperations.sh @@ -77,7 +77,7 @@ spend() { trace_rc $? data="{\"status\":\"accepted\"" - data="${data},\"hash\":\"${txid}\",\"details\":{\"address\":\"${address}\",\"amount\":${amount},\"firstseen\":${tx_ts_firstseen},\"size\":${tx_size},\"vsize\":${tx_vsize},\"replaceable\":${tx_replaceable},\"fee\":${fees},\"subtractfeefromamount\":${subtractfeefromamount}}}" + data="${data},\"txid\":\"${txid}\",\"hash\":\"${tx_hash}\",\"details\":{\"address\":\"${address}\",\"amount\":${amount},\"firstseen\":${tx_ts_firstseen},\"size\":${tx_size},\"vsize\":${tx_vsize},\"replaceable\":${tx_replaceable},\"fee\":${fees},\"subtractfeefromamount\":${subtractfeefromamount}}}" # Delete the temp file containing the raw tx (see above) rm spend-rawtx-${txid}-$$.blob From d91f1af52f18a8dc6c8f0a160b6518c7c0a813e8 Mon Sep 17 00:00:00 2001 From: kexkey Date: Fri, 7 Aug 2020 17:51:34 -0400 Subject: [PATCH 07/22] Added info in batch webhooks --- proxy_docker/app/data/cyphernode.sql | 1 + .../data/sqlmigrate20200610_0.4.0-0.5.0.sql | 2 + proxy_docker/app/script/batching.sh | 47 ++++++++++++++----- proxy_docker/app/script/walletoperations.sh | 4 +- 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/proxy_docker/app/data/cyphernode.sql b/proxy_docker/app/data/cyphernode.sql index a843cd6..90cbd67 100644 --- a/proxy_docker/app/data/cyphernode.sql +++ b/proxy_docker/app/data/cyphernode.sql @@ -48,6 +48,7 @@ CREATE TABLE tx ( blockhash TEXT, blockheight INTEGER, blocktime INTEGER, + conf_target INTEGER, raw_tx TEXT, inserted_ts INTEGER DEFAULT CURRENT_TIMESTAMP ); diff --git a/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql b/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql index 2e8ddc4..ae2fb4e 100644 --- a/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql +++ b/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql @@ -14,3 +14,5 @@ ALTER TABLE recipient ADD COLUMN label INTEGER REFERENCES batcher; ALTER TABLE recipient ADD COLUMN calledback INTEGER DEFAULT FALSE; ALTER TABLE recipient ADD COLUMN calledback_ts INTEGER; CREATE INDEX idx_recipient_label ON recipient (label); + +ALTER TABLE tx ADD COLUMN conf_target INTEGER DEFAULT NULL; diff --git a/proxy_docker/app/script/batching.sh b/proxy_docker/app/script/batching.sh index ff225f2..16553c8 100644 --- a/proxy_docker/app/script/batching.sh +++ b/proxy_docker/app/script/batching.sh @@ -448,7 +448,7 @@ batchspend() { row=$(sql "SELECT COUNT(id), COALESCE(MIN(inserted_ts), 0), COALESCE(SUM(amount), 0.00000000) FROM recipient WHERE tx_id IS NULL AND batcher_id=${batcher_id}") # Let's insert the txid in our little DB -- then we'll already have it when receiving confirmation - id_inserted=$(sql "INSERT OR IGNORE INTO tx (txid, hash, confirmations, timereceived, fee, size, vsize, is_replaceable, raw_tx) VALUES (\"${txid}\", ${tx_hash}, 0, ${tx_ts_firstseen}, ${fees}, ${tx_size}, ${tx_vsize}, ${tx_replaceable}, readfile('batchspend-rawtx-${txid}.blob')); SELECT LAST_INSERT_ROWID();") + id_inserted=$(sql "INSERT OR IGNORE INTO tx (txid, hash, confirmations, timereceived, fee, size, vsize, is_replaceable, conf_target, raw_tx) VALUES (\"${txid}\", ${tx_hash}, 0, ${tx_ts_firstseen}, ${fees}, ${tx_size}, ${tx_vsize}, ${tx_replaceable}, ${conf_target}, readfile('batchspend-rawtx-${txid}.blob')); SELECT LAST_INSERT_ROWID();") returncode=$? trace_rc ${returncode} if [ "${returncode}" -eq 0 ]; then @@ -469,13 +469,13 @@ batchspend() { trace "[batchspend] total=${total}" response='{"result":{"batcherId":'${batcher_id}',"confTarget":'${conf_target}',"nbOutputs":'${count}',"oldest":"'${oldest}'","total":'${total} - response="${response},\"txid\":\"${txid}\",\"hash\":${tx_hash},\"details\":{\"firstseen\":${tx_ts_firstseen},\"size\":${tx_size},\"vsize\":${tx_vsize},\"replaceable\":${tx_replaceable},\"fee\":${fees}},\"outputs\":[${webhooks_data}]}" + response="${response},\"status\":\"accepted\",\"txid\":\"${txid}\",\"hash\":${tx_hash},\"details\":{\"firstseen\":${tx_ts_firstseen},\"size\":${tx_size},\"vsize\":${tx_vsize},\"replaceable\":${tx_replaceable},\"fee\":${fees}},\"outputs\":[${webhooks_data}]}" response="${response},\"error\":null}" # Delete the temp file containing the raw tx (see above) rm batchspend-rawtx-${txid}.blob - batch_webhooks "[${webhooks_data}]" '"batcherId":'${batcher_id}',"txid":"'${txid}'","hash":'${tx_hash}',"details":{"firstseen":'${tx_ts_firstseen}',"size":'${tx_size}',"vsize":'${tx_vsize}',"replaceable":'${tx_replaceable}',"fee":'${fees}'}' + batch_webhooks "[${webhooks_data}]" '"batcherId":'${batcher_id}',"confTarget":'${conf_target}',"nbOutputs":'${count}',"oldest":"'${oldest}'","total":'${total}',"status":"accepted","txid":"'${txid}'","hash":'${tx_hash}',"details":{"firstseen":'${tx_ts_firstseen}',"size":'${tx_size}',"vsize":'${tx_vsize}',"replaceable":'${tx_replaceable}',"fee":'${fees}'}' else local message=$(echo "${data}" | jq -e ".error.message") @@ -503,8 +503,14 @@ batch_check_webhooks() { local tx_vsize local tx_replaceable local fees + local conf_target + local row + local count + local oldest + local total + local tx_id - local batching=$(sql "SELECT address, amount, r.id, webhook_url, b.id, t.txid, t.hash, t.timereceived, t.fee, t.size, t.vsize, t.is_replaceable FROM recipient r, batcher b, tx t WHERE r.batcher_id=b.id AND r.tx_id=t.id AND NOT calledback AND tx_id IS NOT NULL AND webhook_url IS NOT NULL") + local batching=$(sql "SELECT address, amount, r.id, webhook_url, b.id, t.txid, t.hash, t.timereceived, t.fee, t.size, t.vsize, t.is_replaceable, t.conf_target, t.id FROM recipient r, batcher b, tx t WHERE r.batcher_id=b.id AND r.tx_id=t.id AND NOT calledback AND tx_id IS NOT NULL AND webhook_url IS NOT NULL") trace "[batch_check_webhooks] batching=${batching}" local IFS=$'\n' @@ -527,18 +533,35 @@ batch_check_webhooks() { trace "[batch_check_webhooks] tx_hash=${tx_hash}" tx_ts_firstseen=$(echo "${row}" | cut -d '|' -f8) trace "[batch_check_webhooks] tx_ts_firstseen=${tx_ts_firstseen}" - tx_size=$(echo "${row}" | cut -d '|' -f9) - trace "[batch_check_webhooks] tx_size=${tx_size}" - tx_vsize=$(echo "${row}" | cut -d '|' -f10) - trace "[batch_check_webhooks] tx_vsize=${tx_vsize}" - tx_replaceable=$(echo "${row}" | cut -d '|' -f11) - trace "[batch_check_webhooks] tx_replaceable=${tx_replaceable}" - fees=$(echo "${row}" | cut -d '|' -f12) + fees=$(echo "${row}" | cut -d '|' -f9) trace "[batch_check_webhooks] fees=${fees}" + tx_size=$(echo "${row}" | cut -d '|' -f10) + trace "[batch_check_webhooks] tx_size=${tx_size}" + tx_vsize=$(echo "${row}" | cut -d '|' -f11) + trace "[batch_check_webhooks] tx_vsize=${tx_vsize}" + tx_replaceable=$(echo "${row}" | cut -d '|' -f12) + trace "[batch_check_webhooks] tx_replaceable=${tx_replaceable}" + conf_target=$(echo "${row}" | cut -d '|' -f13) + trace "[batch_check_webhooks] conf_target=${conf_target}" + tx_id=$(echo "${row}" | cut -d '|' -f14) + trace "[batch_check_webhooks] tx_id=${tx_id}" webhooks_data="{\"outputId\":${recipient_id},\"address\":\"${address}\",\"amount\":${amount},\"webhookUrl\":\"${webhook_url}\"}" - batch_webhooks "[${webhooks_data}]" '"batcherId":'${batcher_id}',"txid":"'${txid}'","hash":"'${tx_hash}'","details":{"firstseen":'${tx_ts_firstseen}',"size":'${tx_size}',"vsize":'${tx_vsize}',"replaceable":'${tx_replaceable}',"fee":'${fees}'}' + # I know this query for each output is not very efficient, but this function should not execute often, only in case of + # failed callbacks on batches... + # Get the info on the batch + row=$(sql "SELECT COUNT(id), COALESCE(MIN(inserted_ts), 0), COALESCE(SUM(amount), 0.00000000) FROM recipient r WHERE tx_id=\"${tx_id}\"") + + # Use the selected row above + count=$(echo "${row}" | cut -d '|' -f1) + trace "[batchspend] count=${count}" + oldest=$(echo "${row}" | cut -d '|' -f2) + trace "[batchspend] oldest=${oldest}" + total=$(echo "${row}" | cut -d '|' -f3) + trace "[batchspend] total=${total}" + + batch_webhooks "[${webhooks_data}]" '"batcherId":'${batcher_id}',"confTarget":'${conf_target}',"nbOutputs":'${count}',"oldest":"'${oldest}'","total":'${total}',"status":"accepted","txid":"'${txid}'","hash":"'${tx_hash}'","details":{"firstseen":'${tx_ts_firstseen}',"size":'${tx_size}',"vsize":'${tx_vsize}',"replaceable":'${tx_replaceable}',"fee":'${fees}'}' done } diff --git a/proxy_docker/app/script/walletoperations.sh b/proxy_docker/app/script/walletoperations.sh index 867fafa..013c4a5 100644 --- a/proxy_docker/app/script/walletoperations.sh +++ b/proxy_docker/app/script/walletoperations.sh @@ -69,7 +69,7 @@ spend() { ######################################################################################################## # Let's insert the txid in our little DB -- then we'll already have it when receiving confirmation - sql "INSERT OR IGNORE INTO tx (txid, hash, confirmations, timereceived, fee, size, vsize, is_replaceable, raw_tx) VALUES (\"${txid}\", ${tx_hash}, 0, ${tx_ts_firstseen}, ${fees}, ${tx_size}, ${tx_vsize}, ${tx_replaceable}, readfile('spend-rawtx-${txid}-$$.blob'))" + sql "INSERT OR IGNORE INTO tx (txid, hash, confirmations, timereceived, fee, size, vsize, is_replaceable, conf_target, raw_tx) VALUES (\"${txid}\", ${tx_hash}, 0, ${tx_ts_firstseen}, ${fees}, ${tx_size}, ${tx_vsize}, ${tx_replaceable}, ${conf_target}, readfile('spend-rawtx-${txid}-$$.blob'))" trace_rc $? id_inserted=$(sql "SELECT id FROM tx WHERE txid=\"${txid}\"") trace_rc $? @@ -77,7 +77,7 @@ spend() { trace_rc $? data="{\"status\":\"accepted\"" - data="${data},\"txid\":\"${txid}\",\"hash\":\"${tx_hash}\",\"details\":{\"address\":\"${address}\",\"amount\":${amount},\"firstseen\":${tx_ts_firstseen},\"size\":${tx_size},\"vsize\":${tx_vsize},\"replaceable\":${tx_replaceable},\"fee\":${fees},\"subtractfeefromamount\":${subtractfeefromamount}}}" + data="${data},\"txid\":\"${txid}\",\"hash\":${tx_hash},\"details\":{\"address\":\"${address}\",\"amount\":${amount},\"firstseen\":${tx_ts_firstseen},\"size\":${tx_size},\"vsize\":${tx_vsize},\"replaceable\":${tx_replaceable},\"fee\":${fees},\"subtractfeefromamount\":${subtractfeefromamount}}}" # Delete the temp file containing the raw tx (see above) rm spend-rawtx-${txid}-$$.blob From f250698da9c2edac40f62981a634b7b988323a10 Mon Sep 17 00:00:00 2001 From: kexkey Date: Tue, 11 Aug 2020 14:57:55 -0400 Subject: [PATCH 08/22] Check for duplicated addresses in batch outputs --- proxy_docker/app/script/batching.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/proxy_docker/app/script/batching.sh b/proxy_docker/app/script/batching.sh index 16553c8..d99b77b 100644 --- a/proxy_docker/app/script/batching.sh +++ b/proxy_docker/app/script/batching.sh @@ -190,6 +190,20 @@ addtobatch() { # batcherLabel not found response='{"result":null,"error":{"code":-32700,"message":"batcher not found","data":'${request}'}}' else + # Check if address already pending for this batcher... + inserted_id=$(sql "SELECT id FROM recipient WHERE address=\"${address}\" AND tx_id IS NULL AND batcher_id=${batcher_id}") + if [ -n "${inserted_id}" ]; then + response='{"result":null,"error":{"code":-32700,"message":"Duplicated address","data":'${request}'}}' + + trace "[addtobatch] Duplicated address" + trace "[addtobatch] responding=${response}" + + echo "${response}" + + return 1 + fi + + # Insert the new destination inserted_id=$(sql "INSERT INTO recipient (address, amount, webhook_url, batcher_id, label) VALUES (\"${address}\", ${amount}, ${webhook_url}, ${batcher_id}, ${label}); SELECT LAST_INSERT_ROWID();") returncode=$? trace_rc ${returncode} From e7983c232903ab1050bffff030bb8cd9c7023aad Mon Sep 17 00:00:00 2001 From: kexkey Date: Thu, 20 Aug 2020 18:04:55 -0400 Subject: [PATCH 09/22] Fixed replaceable json, batching tests and several small glitches --- proxy_docker/app/script/batching.sh | 5 +++-- proxy_docker/app/script/callbacks_job.sh | 11 ++++++++--- proxy_docker/app/script/confirmation.sh | 8 ++++---- proxy_docker/app/script/test-batching.sh | 8 ++++++-- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/proxy_docker/app/script/batching.sh b/proxy_docker/app/script/batching.sh index d99b77b..496a783 100644 --- a/proxy_docker/app/script/batching.sh +++ b/proxy_docker/app/script/batching.sh @@ -554,6 +554,7 @@ batch_check_webhooks() { tx_vsize=$(echo "${row}" | cut -d '|' -f11) trace "[batch_check_webhooks] tx_vsize=${tx_vsize}" tx_replaceable=$(echo "${row}" | cut -d '|' -f12) + tx_replaceable=$([ "${tx_replaceable}" -eq "1" ] && echo "true" || echo "false") trace "[batch_check_webhooks] tx_replaceable=${tx_replaceable}" conf_target=$(echo "${row}" | cut -d '|' -f13) trace "[batch_check_webhooks] conf_target=${conf_target}" @@ -812,7 +813,7 @@ getbatchdetails() { fi # First get the batch summary - batch=$(sql "SELECT b.id, COALESCE(t.id, NULL), '{\"batcherId\":' || b.id || ',\"batcherLabel\":\"' || b.label || '\",\"confTarget\":' || conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"total\":' ||COALESCE(SUM(amount), 0.00000000) FROM batcher b LEFT JOIN recipient r ON r.batcher_id=b.id ${outerclause} LEFT JOIN tx t ON t.id=r.tx_id WHERE ${whereclause} GROUP BY b.id") + batch=$(sql "SELECT b.id, COALESCE(t.id, NULL), '{\"batcherId\":' || b.id || ',\"batcherLabel\":\"' || b.label || '\",\"confTarget\":' || b.conf_target || ',\"nbOutputs\":' || COUNT(r.id) || ',\"oldest\":\"' ||COALESCE(MIN(r.inserted_ts), 0) || '\",\"total\":' ||COALESCE(SUM(amount), 0.00000000) FROM batcher b LEFT JOIN recipient r ON r.batcher_id=b.id ${outerclause} LEFT JOIN tx t ON t.id=r.tx_id WHERE ${whereclause} GROUP BY b.id") trace "[getbatchdetails] batch=${batch}" if [ -n "${batch}" ]; then @@ -825,7 +826,7 @@ getbatchdetails() { # Using txid outerclause="AND r.tx_id=${tx_id}" - tx=$(sql "SELECT '\"txid\":\"' || txid || '\",\"hash\":\"' || hash || '\",\"details\":{\"firstseen\":' || timereceived || ',\"size\":' || size || ',\"vsize\":' || vsize || ',\"replaceable\":' || is_replaceable || ',\"fee\":' || fee || '}' FROM tx WHERE id=${tx_id}") + tx=$(sql "SELECT '\"txid\":\"' || txid || '\",\"hash\":\"' || hash || '\",\"details\":{\"firstseen\":' || timereceived || ',\"size\":' || size || ',\"vsize\":' || vsize || ',\"replaceable\":' || (CASE WHEN is_replaceable>0 THEN \"\\\"true\\\"\") || ',\"fee\":' || fee || '}' FROM tx WHERE id=${tx_id}") else # null txid outerclause="AND r.tx_id IS NULL" diff --git a/proxy_docker/app/script/callbacks_job.sh b/proxy_docker/app/script/callbacks_job.sh index 6bc7219..405d6c8 100644 --- a/proxy_docker/app/script/callbacks_job.sh +++ b/proxy_docker/app/script/callbacks_job.sh @@ -11,7 +11,7 @@ do_callbacks() { trace "Entering do_callbacks()..." # Let's fetch all the watching addresses still being watched but not called back - local callbacks=$(sql 'SELECT DISTINCT w.callback0conf, address, txid, vout, amount, confirmations, timereceived, fee, size, vsize, blockhash, blockheight, blocktime, w.id, is_replaceable, pub32_index, pub32, label, derivation_path, event_message FROM watching w LEFT JOIN watching_tx ON w.id = watching_id LEFT JOIN tx ON tx.id = tx_id LEFT JOIN watching_by_pub32 w32 ON watching_by_pub32_id = w32.id WHERE NOT calledback0conf AND watching_id NOT NULL AND w.callback0conf NOT NULL AND w.watching') + local callbacks=$(sql 'SELECT DISTINCT w.callback0conf, address, txid, vout, amount, confirmations, timereceived, fee, size, vsize, blockhash, blockheight, blocktime, w.id, is_replaceable, pub32_index, pub32, label, derivation_path, event_message, hash FROM watching w LEFT JOIN watching_tx ON w.id = watching_id LEFT JOIN tx ON tx.id = tx_id LEFT JOIN watching_by_pub32 w32 ON watching_by_pub32_id = w32.id WHERE NOT calledback0conf AND watching_id NOT NULL AND w.callback0conf NOT NULL AND w.watching') trace "[do_callbacks] callbacks0conf=${callbacks}" local returncode @@ -30,7 +30,7 @@ do_callbacks() { fi done - callbacks=$(sql 'SELECT DISTINCT w.callback1conf, address, txid, vout, amount, confirmations, timereceived, fee, size, vsize, blockhash, blockheight, blocktime, w.id, is_replaceable, pub32_index, pub32, label, derivation_path, event_message FROM watching w, watching_tx wt, tx t LEFT JOIN watching_by_pub32 w32 ON watching_by_pub32_id = w32.id WHERE w.id = watching_id AND tx_id = t.id AND NOT calledback1conf AND confirmations>0 AND w.callback1conf NOT NULL AND w.watching') + callbacks=$(sql 'SELECT DISTINCT w.callback1conf, address, txid, vout, amount, confirmations, timereceived, fee, size, vsize, blockhash, blockheight, blocktime, w.id, is_replaceable, pub32_index, pub32, label, derivation_path, event_message, hash FROM watching w, watching_tx wt, tx t LEFT JOIN watching_by_pub32 w32 ON watching_by_pub32_id = w32.id WHERE w.id = watching_id AND tx_id = t.id AND NOT calledback1conf AND confirmations>0 AND w.callback1conf NOT NULL AND w.watching') trace "[do_callbacks] callbacks1conf=${callbacks}" for row in ${callbacks} @@ -152,6 +152,7 @@ build_callback() { local derivation_path local event_message + local hash # w.callback0conf, address, txid, vout, amount, confirmations, timereceived, fee, size, vsize, blockhash, blockheight, blocktime, # w.id, is_replaceable, pub32_index, pub32, label, derivation_path, event_message @@ -171,6 +172,8 @@ build_callback() { trace "[build_callback] address=${address}" txid=$(echo "${row}" | cut -d '|' -f3) trace "[build_callback] txid=${txid}" + hash=$(echo "${row}" | cut -d '|' -f21) + trace "[build_callback] hash=${hash}" vout_n=$(echo "${row}" | cut -d '|' -f4) trace "[build_callback] vout_n=${vout_n}" sent_amount=$(echo "${row}" | cut -d '|' -f5 | awk '{ printf "%.8f", $0 }') @@ -192,6 +195,7 @@ build_callback() { vsize=$(echo "${row}" | cut -d '|' -f10) trace "[build_callback] vsize=${vsize}" is_replaceable=$(echo "${row}" | cut -d '|' -f15) + is_replaceable=$([ "${is_replaceable}" -eq "1" ] && echo "true" || echo "false") trace "[build_callback] is_replaceable=${is_replaceable}" blockhash=$(echo "${row}" | cut -d '|' -f11) trace "[build_callback] blockhash=${blockhash}" @@ -215,7 +219,8 @@ build_callback() { data="{\"id\":\"${id}\"," data="${data}\"address\":\"${address}\"," - data="${data}\"hash\":\"${txid}\"," + data="${data}\"txid\":\"${txid}\"," + data="${data}\"hash\":\"${hash}\"," data="${data}\"vout_n\":${vout_n}," data="${data}\"sent_amount\":${sent_amount}," data="${data}\"confirmations\":${confirmations}," diff --git a/proxy_docker/app/script/confirmation.sh b/proxy_docker/app/script/confirmation.sh index 996511f..5e3a39e 100644 --- a/proxy_docker/app/script/confirmation.sh +++ b/proxy_docker/app/script/confirmation.sh @@ -86,8 +86,8 @@ confirmation() { local tx_size=$(echo "${tx_raw_details}" | jq '.result.size') local tx_vsize=$(echo "${tx_raw_details}" | jq '.result.vsize') - local tx_replaceable=$(echo "${tx_details}" | jq '.result."bip125-replaceable"') - tx_replaceable=$([ ${tx_replaceable} = "yes" ] && echo 1 || echo 0) + local tx_replaceable=$(echo "${tx_details}" | jq -r '.result."bip125-replaceable"') + tx_replaceable=$([ ${tx_replaceable} = "yes" ] && echo "true" || echo "false") local fees=$(compute_fees "${txid}") trace "[confirmation] fees=${fees}" @@ -184,8 +184,8 @@ confirmation() { if [ -n "${event_message}" ]; then # There's an event message, let's publish it! - trace "[confirmation] mosquitto_pub -h broker -t tx_confirmation -m \"{\"txid\":\"${txid}\",\"address\":\"${address}\",\"vout_n\":${tx_vout_n},\"amount\":${tx_vout_amount},\"confirmations\":${tx_nb_conf},\"eventMessage\":\"${event_message}\"}\"" - response=$(mosquitto_pub -h broker -t tx_confirmation -m "{\"txid\":\"${txid}\",\"address\":\"${address}\",\"vout_n\":${tx_vout_n},\"amount\":${tx_vout_amount},\"confirmations\":${tx_nb_conf},\"eventMessage\":\"${event_message}\"}") + trace "[confirmation] mosquitto_pub -h broker -t tx_confirmation -m \"{\"txid\":\"${txid}\",\"hash\":\"${tx_hash}\",\"address\":\"${address}\",\"vout_n\":${tx_vout_n},\"amount\":${tx_vout_amount},\"confirmations\":${tx_nb_conf},\"eventMessage\":\"${event_message}\"}\"" + response=$(mosquitto_pub -h broker -t tx_confirmation -m "{\"txid\":\"${txid}\",\"hash\":\"${tx_hash}\",\"address\":\"${address}\",\"vout_n\":${tx_vout_n},\"amount\":${tx_vout_amount},\"confirmations\":${tx_nb_conf},\"eventMessage\":\"${event_message}\"}") returncode=$? trace_rc ${returncode} fi diff --git a/proxy_docker/app/script/test-batching.sh b/proxy_docker/app/script/test-batching.sh index b693680..66b5e7d 100755 --- a/proxy_docker/app/script/test-batching.sh +++ b/proxy_docker/app/script/test-batching.sh @@ -91,7 +91,9 @@ testbatching() { # addtobatch to default batcher echo "Testing addtobatch..." - response=$(curl -sd '{"outputLabel":"test001","address":"test001","amount":0.001}' localhost:8888/addtobatch) + address1=$(curl -s localhost:8888/getnewaddress | jq -r ".address") + echo "address1=${address1}" + response=$(curl -sd '{"outputLabel":"test001","address":"'${address1}'","amount":0.001}' localhost:8888/addtobatch) echo "response=${response}" id=$(echo "${response}" | jq ".result.batcherId") echo "batcherId=${id}" @@ -104,7 +106,9 @@ testbatching() { fi echo "outputId=${id}" - response=$(curl -sd '{"batcherId":1,"outputLabel":"test002","address":"test002","amount":0.002}' localhost:8888/addtobatch) + address2=$(curl -s localhost:8888/getnewaddress | jq -r ".address") + echo "address2=${address2}" + response=$(curl -sd '{"batcherId":1,"outputLabel":"test002","address":"'${address2}'","amount":22000000}' localhost:8888/addtobatch) echo "response=${response}" id2=$(echo "${response}" | jq ".result.batcherId") echo "batcherId=${id2}" From b24c2bd36e0ebe736e58f3b46d419f14cd2155bb Mon Sep 17 00:00:00 2001 From: kexkey Date: Fri, 21 Aug 2020 09:22:40 -0400 Subject: [PATCH 10/22] Standardized replaceable instead of is_replaceable in json --- doc/API.v0.md | 4 ++-- doc/openapi/v0/cyphernode-callbacks.yaml | 2 +- proxy_docker/app/script/callbacks_job.sh | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/API.v0.md b/doc/API.v0.md index 133e5ff..da9ba8a 100644 --- a/doc/API.v0.md +++ b/doc/API.v0.md @@ -321,7 +321,7 @@ When cyphernode receives a transaction confirmation (/conf endpoint) on a watche "size":371, "vsize":166, "fees":0.00002992, - "is_replaceable":0, + "replaceable":false, "blockhash":"", "blocktime":"", "blockheight":"" @@ -340,7 +340,7 @@ When cyphernode receives a transaction confirmation (/conf endpoint) on a watche "size":371, "vsize":166, "fees":0.00002992, - "is_replaceable":0, + "replaceable":false, "blockhash":"00000000000000000011bb83bb9bed0f6e131d0d0c903ec3a063e00b3aa00bf6", "blocktime":"2018-10-18T16:58:49+0000", "blockheight":"" diff --git a/doc/openapi/v0/cyphernode-callbacks.yaml b/doc/openapi/v0/cyphernode-callbacks.yaml index 79bded3..36ef2b0 100644 --- a/doc/openapi/v0/cyphernode-callbacks.yaml +++ b/doc/openapi/v0/cyphernode-callbacks.yaml @@ -116,7 +116,7 @@ components: type: "integer" fees: type: "number" - is_replaceable: + replaceable: type: "integer" blockhash: $ref: '#/components/schemas/TypeHashString' diff --git a/proxy_docker/app/script/callbacks_job.sh b/proxy_docker/app/script/callbacks_job.sh index 405d6c8..1a6508a 100644 --- a/proxy_docker/app/script/callbacks_job.sh +++ b/proxy_docker/app/script/callbacks_job.sh @@ -230,7 +230,7 @@ build_callback() { if [ -n "${fee}" ]; then data="${data}\"fees\":${fee}," fi - data="${data}\"is_replaceable\":${is_replaceable}," + data="${data}\"replaceable\":${is_replaceable}," if [ -n "${blocktime}" ]; then data="${data}\"blockhash\":\"${blockhash}\"," data="${data}\"blocktime\":\"$(date -Is -d @${blocktime})\"," From 86095a765dd162e044ffc7bc47385da2e1bc90ce Mon Sep 17 00:00:00 2001 From: kexkey Date: Fri, 21 Aug 2020 11:05:12 -0400 Subject: [PATCH 11/22] Fixed sqlite3 replaceable bool and rawtx with LF in DB --- proxy_docker/app/script/batching.sh | 10 +++--- proxy_docker/app/script/computefees.sh | 2 +- proxy_docker/app/script/confirmation.sh | 2 +- proxy_docker/app/script/test-batching.sh | 36 ++++++++++----------- proxy_docker/app/script/walletoperations.sh | 2 +- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/proxy_docker/app/script/batching.sh b/proxy_docker/app/script/batching.sh index 496a783..6982a6a 100644 --- a/proxy_docker/app/script/batching.sh +++ b/proxy_docker/app/script/batching.sh @@ -441,7 +441,7 @@ batchspend() { # Let's get transaction details on the spending wallet so that we have fee information tx_details=$(get_transaction ${txid} "spender") - tx_raw_details=$(get_rawtransaction ${txid}) + tx_raw_details=$(get_rawtransaction ${txid} | tr -d '\n') # Amounts and fees are negative when spending so we absolute those fields local tx_hash=$(echo "${tx_raw_details}" | jq '.result.hash') @@ -456,13 +456,13 @@ batchspend() { local fees=$(echo "${tx_details}" | jq '.result.fee | fabs' | awk '{ printf "%.8f", $0 }') # Sometimes raw tx are too long to be passed as paramater, so let's write # it to a temp file for it to be read by sqlite3 and then delete the file - echo "${tx_raw_details}" > batchspend-rawtx-${txid}.blob + echo "${tx_raw_details}" > batchspend-rawtx-${txid}-$$.blob # Get the info on the batch before setting it to done row=$(sql "SELECT COUNT(id), COALESCE(MIN(inserted_ts), 0), COALESCE(SUM(amount), 0.00000000) FROM recipient WHERE tx_id IS NULL AND batcher_id=${batcher_id}") # Let's insert the txid in our little DB -- then we'll already have it when receiving confirmation - id_inserted=$(sql "INSERT OR IGNORE INTO tx (txid, hash, confirmations, timereceived, fee, size, vsize, is_replaceable, conf_target, raw_tx) VALUES (\"${txid}\", ${tx_hash}, 0, ${tx_ts_firstseen}, ${fees}, ${tx_size}, ${tx_vsize}, ${tx_replaceable}, ${conf_target}, readfile('batchspend-rawtx-${txid}.blob')); SELECT LAST_INSERT_ROWID();") + id_inserted=$(sql "INSERT OR IGNORE INTO tx (txid, hash, confirmations, timereceived, fee, size, vsize, is_replaceable, conf_target, raw_tx) VALUES (\"${txid}\", ${tx_hash}, 0, ${tx_ts_firstseen}, ${fees}, ${tx_size}, ${tx_vsize}, ${tx_replaceable}, ${conf_target}, readfile('batchspend-rawtx-${txid}-$$.blob')); SELECT LAST_INSERT_ROWID();") returncode=$? trace_rc ${returncode} if [ "${returncode}" -eq 0 ]; then @@ -487,7 +487,7 @@ batchspend() { response="${response},\"error\":null}" # Delete the temp file containing the raw tx (see above) - rm batchspend-rawtx-${txid}.blob + rm batchspend-rawtx-${txid}-$$.blob batch_webhooks "[${webhooks_data}]" '"batcherId":'${batcher_id}',"confTarget":'${conf_target}',"nbOutputs":'${count}',"oldest":"'${oldest}'","total":'${total}',"status":"accepted","txid":"'${txid}'","hash":'${tx_hash}',"details":{"firstseen":'${tx_ts_firstseen}',"size":'${tx_size}',"vsize":'${tx_vsize}',"replaceable":'${tx_replaceable}',"fee":'${fees}'}' @@ -826,7 +826,7 @@ getbatchdetails() { # Using txid outerclause="AND r.tx_id=${tx_id}" - tx=$(sql "SELECT '\"txid\":\"' || txid || '\",\"hash\":\"' || hash || '\",\"details\":{\"firstseen\":' || timereceived || ',\"size\":' || size || ',\"vsize\":' || vsize || ',\"replaceable\":' || (CASE WHEN is_replaceable>0 THEN \"\\\"true\\\"\") || ',\"fee\":' || fee || '}' FROM tx WHERE id=${tx_id}") + tx=$(sql "SELECT '\"txid\":\"' || txid || '\",\"hash\":\"' || hash || '\",\"details\":{\"firstseen\":' || timereceived || ',\"size\":' || size || ',\"vsize\":' || vsize || ',\"replaceable\":' || CASE is_replaceable WHEN 1 THEN 'true' ELSE 'false' END || ',\"fee\":' || fee || '}' FROM tx WHERE id=${tx_id}") else # null txid outerclause="AND r.tx_id IS NULL" diff --git a/proxy_docker/app/script/computefees.sh b/proxy_docker/app/script/computefees.sh index 01f01c0..be35197 100644 --- a/proxy_docker/app/script/computefees.sh +++ b/proxy_docker/app/script/computefees.sh @@ -68,7 +68,7 @@ compute_vin_total_amount() vin_raw_tx=$(sql "SELECT raw_tx FROM tx WHERE txid=\"${vin_txid}\"") if [ -z "${vin_raw_tx}" ]; then txid_already_inserted=false - vin_raw_tx=$(get_rawtransaction "${vin_txid}") + vin_raw_tx=$(get_rawtransaction "${vin_txid}" | tr -d '\n') returncode=$? if [ "${returncode}" -ne 0 ]; then return ${returncode} diff --git a/proxy_docker/app/script/confirmation.sh b/proxy_docker/app/script/confirmation.sh index 5e3a39e..e953ff4 100644 --- a/proxy_docker/app/script/confirmation.sh +++ b/proxy_docker/app/script/confirmation.sh @@ -66,7 +66,7 @@ confirmation() { local tx=$(sql "SELECT id FROM tx WHERE txid=\"${txid}\"") local id_inserted - local tx_raw_details=$(get_rawtransaction ${txid}) + local tx_raw_details=$(get_rawtransaction ${txid} | tr -d '\n') local tx_nb_conf=$(echo "${tx_details}" | jq -r '.result.confirmations // 0') # Sometimes raw tx are too long to be passed as paramater, so let's write diff --git a/proxy_docker/app/script/test-batching.sh b/proxy_docker/app/script/test-batching.sh index 66b5e7d..92aee7e 100755 --- a/proxy_docker/app/script/test-batching.sh +++ b/proxy_docker/app/script/test-batching.sh @@ -113,11 +113,11 @@ testbatching() { id2=$(echo "${response}" | jq ".result.batcherId") echo "batcherId=${id2}" if [ "${id2}" -ne "1" ]; then - exit 40 + exit 47 fi id2=$(echo "${response}" | jq -e ".result.outputId") if [ "$?" -ne 0 ]; then - exit 42 + exit 50 fi echo "outputId=${id2}" echo "Tested addtobatch." @@ -128,7 +128,7 @@ testbatching() { echo "response=${response}" echo "${response}" | jq -e ".error" if [ "$?" -ne 0 ]; then - exit 44 + exit 55 fi echo "Tested batchspend." @@ -147,7 +147,7 @@ testbatching() { id=$(echo "${response}" | jq ".result.batcherId") echo "batcherId=${id}" if [ "${id}" -ne "1" ]; then - exit 50 + exit 60 fi response=$(curl -sd '{"outputId":'${id2}'}' localhost:8888/removefrombatch) @@ -155,7 +155,7 @@ testbatching() { id=$(echo "${response}" | jq ".result.batcherId") echo "batcherId=${id}" if [ "${id}" -ne "1" ]; then - exit 54 + exit 64 fi echo "Tested removefrombatch." @@ -166,7 +166,7 @@ testbatching() { data2=$(echo "${response}" | jq ".result.nbOutputs") echo "nbOutputs=${data2}" if [ "${data2}" -ne "$((${data}-2))" ]; then - exit 58 + exit 68 fi echo "Tested getbatchdetails." @@ -189,7 +189,7 @@ testbatching() { echo "response=${response}" id=$(echo "${response}" | jq -e ".result.batcherId") if [ "$?" -ne "0" ]; then - exit 60 + exit 70 fi # List batchers (should show at least default and testbatcher batchers) @@ -199,7 +199,7 @@ testbatching() { id=$(echo "${response}" | jq '.result[] | select(.batcherLabel == "testbatcher") | .batcherId') echo "batcherId=${id}" if [ -z "${id}" ]; then - exit 70 + exit 75 fi echo "Tested listbatchers." @@ -233,7 +233,7 @@ testbatching() { fi echo "${response}" | jq -e ".result.outputs" if [ "$?" -ne 0 ]; then - exit 32 + exit 110 fi response=$(curl -sd '{"batcherId":'${id}'}' localhost:8888/getbatchdetails) @@ -241,11 +241,11 @@ testbatching() { data=$(echo "${response}" | jq -r ".result.batcherLabel") echo "batcherLabel=${data}" if [ "${data}" != "testbatcher" ]; then - exit 35 + exit 120 fi echo "${response}" | jq -e ".result.outputs" if [ "$?" -ne 0 ]; then - exit 37 + exit 130 fi echo "Tested getbatchdetails." @@ -258,11 +258,11 @@ testbatching() { data=$(echo "${response}" | jq ".result.batcherId") echo "batcherId=${data}" if [ "${data}" -ne "${id}" ]; then - exit 40 + exit 140 fi id2=$(echo "${response}" | jq -e ".result.outputId") if [ "$?" -ne 0 ]; then - exit 42 + exit 142 fi echo "outputId=${id2}" @@ -273,11 +273,11 @@ testbatching() { data=$(echo "${response}" | jq ".result.batcherId") echo "batcherId=${data}" if [ "${data}" -ne "${id}" ]; then - exit 40 + exit 150 fi id2=$(echo "${response}" | jq -e ".result.outputId") if [ "$?" -ne 0 ]; then - exit 42 + exit 152 fi echo "outputId=${id2}" echo "Tested addtobatch." @@ -288,12 +288,12 @@ testbatching() { echo "response=${response}" data2=$(echo "${response}" | jq -e ".result.txid") if [ "$?" -ne 0 ]; then - exit 44 + exit 160 fi echo "txid=${data2}" data=$(echo "${response}" | jq ".result.outputs | length") if [ "${data}" -ne "2" ]; then - exit 42 + exit 162 fi echo "Tested batchspend." @@ -305,7 +305,7 @@ testbatching() { data=$(echo "${response}" | jq ".result.nbOutputs") echo "nbOutputs=${data}" if [ "${data}" -ne "2" ]; then - exit 42 + exit 170 fi echo "Tested getbatchdetails." diff --git a/proxy_docker/app/script/walletoperations.sh b/proxy_docker/app/script/walletoperations.sh index 013c4a5..b436fe1 100644 --- a/proxy_docker/app/script/walletoperations.sh +++ b/proxy_docker/app/script/walletoperations.sh @@ -35,7 +35,7 @@ spend() { # Let's get transaction details on the spending wallet so that we have fee information tx_details=$(get_transaction ${txid} "spender") - tx_raw_details=$(get_rawtransaction ${txid}) + tx_raw_details=$(get_rawtransaction ${txid} | tr -d '\n') # Amounts and fees are negative when spending so we absolute those fields local tx_hash=$(echo "${tx_raw_details}" | jq '.result.hash') From bc9f36fb119b86571412b77655074af742b1873d Mon Sep 17 00:00:00 2001 From: kexkey Date: Fri, 21 Aug 2020 13:38:33 -0400 Subject: [PATCH 12/22] Better test-batching --- proxy_docker/app/script/test-batching.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/proxy_docker/app/script/test-batching.sh b/proxy_docker/app/script/test-batching.sh index 92aee7e..5eea4ff 100755 --- a/proxy_docker/app/script/test-batching.sh +++ b/proxy_docker/app/script/test-batching.sh @@ -317,8 +317,8 @@ testbatching() { } wait_for_callbacks() { - nc -vlp1111 -e ./tests-cb.sh & - nc -vlp1112 -e ./tests-cb.sh & + nc -vlp1111 -e sh -c 'echo -en "HTTP/1.1 200 OK\r\n\r\n" ; timeout 1 tee /dev/tty | cat ; echo 1>&2' & + nc -vlp1112 -e sh -c 'echo -en "HTTP/1.1 200 OK\r\n\r\n" ; timeout 1 tee /dev/tty | cat ; echo 1>&2' & } wait_for_callbacks From 22eab0c1097b0f81878eb9a98589659968470953 Mon Sep 17 00:00:00 2001 From: kexkey Date: Tue, 25 Aug 2020 14:25:38 -0400 Subject: [PATCH 13/22] Now possible to watch same entity from multiple clients, unwatchtxid --- api_auth_docker/api-sample.properties | 1 + .../templates/gatekeeper/api.properties | 1 + proxy_docker/app/data/cyphernode.sql | 7 ++- .../data/sqlmigrate20200610_0.4.0-0.5.0.sql | 57 +++++++++++++++++++ proxy_docker/app/script/callbacks_txid.sh | 8 ++- proxy_docker/app/script/requesthandler.sh | 51 ++++++++++++++++- proxy_docker/app/script/unwatchrequest.sh | 56 +++++++++++++++--- proxy_docker/app/script/watchrequest.sh | 36 ++++++++---- 8 files changed, 193 insertions(+), 24 deletions(-) diff --git a/api_auth_docker/api-sample.properties b/api_auth_docker/api-sample.properties index 4b295af..3987819 100644 --- a/api_auth_docker/api-sample.properties +++ b/api_auth_docker/api-sample.properties @@ -17,6 +17,7 @@ action_getactivewatchesbyxpub=watcher action_getactivewatchesbylabel=watcher action_getactivexpubwatches=watcher action_watchtxid=watcher +action_unwatchtxid=watcher action_getactivewatches=watcher action_get_txns_by_watchlabel=watcher action_get_unused_addresses_by_watchlabel=watcher diff --git a/cyphernodeconf_docker/templates/gatekeeper/api.properties b/cyphernodeconf_docker/templates/gatekeeper/api.properties index a2f4019..1e1b3e2 100644 --- a/cyphernodeconf_docker/templates/gatekeeper/api.properties +++ b/cyphernodeconf_docker/templates/gatekeeper/api.properties @@ -22,6 +22,7 @@ action_getactivexpubwatches=watcher action_get_txns_by_watchlabel=watcher action_get_unused_addresses_by_watchlabel=watcher action_watchtxid=watcher +action_unwatchtxid=watcher action_getactivewatches=watcher action_getbestblockhash=watcher action_getbestblockinfo=watcher diff --git a/proxy_docker/app/data/cyphernode.sql b/proxy_docker/app/data/cyphernode.sql index 90cbd67..b3ba8c1 100644 --- a/proxy_docker/app/data/cyphernode.sql +++ b/proxy_docker/app/data/cyphernode.sql @@ -14,7 +14,7 @@ CREATE TABLE watching_by_pub32 ( CREATE TABLE watching ( id INTEGER PRIMARY KEY AUTOINCREMENT, - address TEXT UNIQUE, + address TEXT, watching INTEGER DEFAULT FALSE, callback0conf TEXT, calledback0conf INTEGER DEFAULT FALSE, @@ -26,6 +26,8 @@ CREATE TABLE watching ( event_message TEXT, inserted_ts INTEGER DEFAULT CURRENT_TIMESTAMP ); +CREATE INDEX idx_watching_address ON watching (address); +CREATE UNIQUE INDEX idx_watching_01 ON watching (address, callback0conf, callback1conf); CREATE TABLE watching_tx ( watching_id INTEGER REFERENCES watching, @@ -86,7 +88,7 @@ INSERT INTO batcher (id, label, conf_target, feerate) VALUES (1, "default", 6, N CREATE TABLE watching_by_txid ( id INTEGER PRIMARY KEY AUTOINCREMENT, - txid TEXT UNIQUE, + txid TEXT, watching INTEGER DEFAULT FALSE, callback1conf TEXT, calledback1conf INTEGER DEFAULT FALSE, @@ -96,6 +98,7 @@ CREATE TABLE watching_by_txid ( inserted_ts INTEGER DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_watching_by_txid_txid ON watching_by_txid (txid); +CREATE UNIQUE INDEX idx_watching_by_txid_1x ON watching_by_txid (txid, callback1conf, callbackxconf); CREATE TABLE stamp ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql b/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql index ae2fb4e..580ef70 100644 --- a/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql +++ b/proxy_docker/app/data/sqlmigrate20200610_0.4.0-0.5.0.sql @@ -1,3 +1,6 @@ +PRAGMA foreign_keys=off; + +BEGIN TRANSACTION; CREATE TABLE batcher ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -16,3 +19,57 @@ ALTER TABLE recipient ADD COLUMN calledback_ts INTEGER; CREATE INDEX idx_recipient_label ON recipient (label); ALTER TABLE tx ADD COLUMN conf_target INTEGER DEFAULT NULL; + + +ALTER TABLE watching RENAME TO watching_20200610; + +CREATE TABLE watching ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + address TEXT, + watching INTEGER DEFAULT FALSE, + callback0conf TEXT, + calledback0conf INTEGER DEFAULT FALSE, + callback1conf TEXT, + calledback1conf INTEGER DEFAULT FALSE, + imported INTEGER DEFAULT FALSE, + watching_by_pub32_id INTEGER REFERENCES watching_by_pub32, + pub32_index INTEGER, + event_message TEXT, + inserted_ts INTEGER DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO watching SELECT * FROM watching_20200610; + +DROP INDEX IF EXISTS idx_watching_address; +CREATE INDEX idx_watching_address ON watching (address); +DROP INDEX IF EXISTS idx_watching_01; +CREATE UNIQUE INDEX idx_watching_01 ON watching (address, callback0conf, callback1conf); + +--DROP TABLE watching20200610; + +ALTER TABLE watching_by_txid RENAME TO watching_by_txid_20200610; + +CREATE TABLE watching_by_txid ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + txid TEXT, + watching INTEGER DEFAULT FALSE, + callback1conf TEXT, + calledback1conf INTEGER DEFAULT FALSE, + callbackxconf TEXT, + calledbackxconf INTEGER DEFAULT FALSE, + nbxconf INTEGER, + inserted_ts INTEGER DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO watching_by_txid SELECT * FROM watching_by_txid_20200610; + +DROP INDEX IF EXISTS idx_watching_by_txid_txid; +CREATE INDEX idx_watching_by_txid_txid ON watching_by_txid (txid); +DROP INDEX IF EXISTS idx_watching_by_txid_1x; +CREATE UNIQUE INDEX idx_watching_by_txid_1x ON watching_by_txid (txid, callback1conf, callbackxconf); + +--DROP TABLE watching_by_txid_20200610; + +COMMIT; + +PRAGMA foreign_keys=on; diff --git a/proxy_docker/app/script/callbacks_txid.sh b/proxy_docker/app/script/callbacks_txid.sh index 94f6546..0358057 100644 --- a/proxy_docker/app/script/callbacks_txid.sh +++ b/proxy_docker/app/script/callbacks_txid.sh @@ -23,7 +23,7 @@ do_callbacks_txid() { build_callback_txid ${row} returncode=$? trace_rc ${returncode} - if [ "${returncode}" -eq 0 ]; then + if [ "${returncode}" -eq "0" ]; then id=$(echo "${row}" | cut -d '|' -f1) sql "UPDATE watching_by_txid SET calledback1conf=1 WHERE id=\"${id}\"" trace_rc $? @@ -39,7 +39,8 @@ do_callbacks_txid() { do build_callback_txid ${row} returncode=$? - if [ "${returncode}" -eq 0 ]; then + trace_rc ${returncode} + if [ "${returncode}" -eq "0" ]; then id=$(echo "${row}" | cut -d '|' -f1) sql "UPDATE watching_by_txid SET calledbackxconf=1, watching=0 WHERE id=\"${id}\"" trace_rc $? @@ -136,6 +137,9 @@ build_callback_txid() { trace "[build_callback_txid] Number of confirmations for tx is not enough to call back." return 1 fi + else + trace "[build_callback_txid] Couldn't get tx from the Bitcoin node." + return 1 fi } diff --git a/proxy_docker/app/script/requesthandler.sh b/proxy_docker/app/script/requesthandler.sh index 88dbd13..2e4bdda 100644 --- a/proxy_docker/app/script/requesthandler.sh +++ b/proxy_docker/app/script/requesthandler.sh @@ -100,8 +100,35 @@ main() { ;; unwatch) # curl (GET) 192.168.111.152:8080/unwatch/2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp + # or + # POST http://192.168.111.152:8080/unwatch + # BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","unconfirmedCallbackURL":"192.168.111.233:1111/callback0conf","confirmedCallbackURL":"192.168.111.233:1111/callback1conf"} + # or + # BODY {"id":3124} - response=$(unwatchrequest "${line}") + # args: + # - address: string, required + # - unconfirmedCallbackURL: string, optional + # - confirmedCallbackURL: string, optional + # or + # - id: the id returned by the watch + + local address + local unconfirmedCallbackURL + local confirmedCallbackURL + local watchid + + # Let's make it work even for a GET request (equivalent to a POST with empty json object body) + if [ "$http_method" = "POST" ]; then + address=$(echo "${line}" | jq -r ".address") + unconfirmedCallbackURL=$(echo "${line}" | jq ".unconfirmedCallbackURL") + confirmedCallbackURL=$(echo "${line}" | jq ".confirmedCallbackURL") + watchid=$(echo "${line}" | jq ".id") + else + address=$(echo "${line}" | cut -d ' ' -f2 | cut -d '/' -f3) + fi + + response=$(unwatchrequest "${watchid}" "${address}" "${unconfirmedCallbackURL}" "${confirmedCallbackURL}") response_to_client "${response}" ${?} break ;; @@ -158,6 +185,28 @@ main() { response_to_client "${response}" ${?} break ;; + unwatchtxid) + # POST http://192.168.111.152:8080/unwatchtxid + # BODY {"txid":"b081ca7724386f549cf0c16f71db6affeb52ff7a0d9b606fb2e5c43faffd3387","unconfirmedCallbackURL":"192.168.111.233:1111/callback0conf","confirmedCallbackURL":"192.168.111.233:1111/callback1conf"} + # or + # BODY {"id":3124} + + # args: + # - txid: string, required + # - unconfirmedCallbackURL: string, optional + # - confirmedCallbackURL: string, optional + # or + # - id: the id returned by watchtxid + + local txid=$(echo "${line}" | jq -r ".txid") + local unconfirmedCallbackURL=$(echo "${line}" | jq ".unconfirmedCallbackURL") + local confirmedCallbackURL=$(echo "${line}" | jq ".confirmedCallbackURL") + local watchid=$(echo "${line}" | jq ".id") + + response=$(unwatchtxidrequest "${watchid}" "${txid}" "${unconfirmedCallbackURL}" "${confirmedCallbackURL}") + response_to_client "${response}" ${?} + break + ;; getactivewatches) # curl (GET) 192.168.111.152:8080/getactivewatches diff --git a/proxy_docker/app/script/unwatchrequest.sh b/proxy_docker/app/script/unwatchrequest.sh index 8c0a334..ad9784d 100644 --- a/proxy_docker/app/script/unwatchrequest.sh +++ b/proxy_docker/app/script/unwatchrequest.sh @@ -6,16 +6,27 @@ unwatchrequest() { trace "Entering unwatchrequest()..." - local request=${1} - local address=$(echo "${request}" | cut -d ' ' -f2 | cut -d '/' -f3) + local watchid=${1} + local address=${2} + local unconfirmedCallbackURL=${3} + local confirmedCallbackURL=${4} local returncode - trace "[unwatchrequest] Unwatch request on address ${address}" + trace "[unwatchrequest] Unwatch request id ${watchid} on address ${address} with url0conf ${unconfirmedCallbackURL} and url1conf ${confirmedCallbackURL}" - sql "UPDATE watching SET watching=0 WHERE address=\"${address}\"" - returncode=$? - trace_rc ${returncode} + if [ "${watchid}" != "null" ]; then + sql "UPDATE watching SET watching=0 WHERE id=${watchid}" + returncode=$? + trace_rc ${returncode} + + data="{\"event\":\"unwatch\",\"id\":${watchid}}" + else + sql "UPDATE watching SET watching=0 WHERE address='${address}' AND callback0conf=${unconfirmedCallbackURL} AND callback1conf=${confirmedCallbackURL}" + returncode=$? + trace_rc ${returncode} + + data="{\"event\":\"unwatch\",\"address\":\"${address}\",\"unconfirmedCallbackURL\":${unconfirmedCallbackURL},\"confirmedCallbackURL\":${confirmedCallbackURL}}" + fi - data="{\"event\":\"unwatch\",\"address\":\"${address}\"}" trace "[unwatchrequest] responding=${data}" echo "${data}" @@ -80,3 +91,34 @@ unwatchpub32labelrequest() { return ${returncode} } + +unwatchtxidrequest() { + trace "Entering unwatchtxidrequest()..." + + local watchid=${1} + local txid=${2} + local unconfirmedCallbackURL=${3} + local confirmedCallbackURL=${4} + local returncode + trace "[unwatchtxidrequest] Unwatch request id ${watchid} on txid ${txid} with url0conf ${unconfirmedCallbackURL} and url1conf ${confirmedCallbackURL}" + + if [ "${watchid}" != "null" ]; then + sql "UPDATE watching_by_txid SET watching=0 WHERE id=${watchid}" + returncode=$? + trace_rc ${returncode} + + data="{\"event\":\"unwatchtxid\",\"id\":${watchid}}" + else + sql "UPDATE watching_by_txid SET watching=0 WHERE txid='${txid}' AND callback0conf=${unconfirmedCallbackURL} AND callback1conf=${confirmedCallbackURL}" + returncode=$? + trace_rc ${returncode} + + data="{\"event\":\"unwatchtxid\",\"txid\":\"${txid}\",\"unconfirmedCallbackURL\":${unconfirmedCallbackURL},\"confirmedCallbackURL\":${confirmedCallbackURL}}" + fi + + trace "[unwatchtxidrequest] responding=${data}" + + echo "${data}" + + return ${returncode} +} diff --git a/proxy_docker/app/script/watchrequest.sh b/proxy_docker/app/script/watchrequest.sh index cd07df7..2089eb1 100644 --- a/proxy_docker/app/script/watchrequest.sh +++ b/proxy_docker/app/script/watchrequest.sh @@ -52,12 +52,14 @@ watchrequest() { imported=0 fi - sql "INSERT INTO watching (address, watching, callback0conf, callback1conf, imported, event_message) VALUES (\"${address}\", 1, ${cb0conf_url}, ${cb1conf_url}, ${imported}, ${event_message}) ON CONFLICT(address) DO UPDATE SET watching=1, callback0conf=excluded.callback0conf, calledback0conf=0, callback1conf=excluded.callback1conf, calledback1conf=0, event_message=excluded.event_message" +# sql "INSERT INTO watching (address, watching, callback0conf, callback1conf, imported, event_message) VALUES (\"${address}\", 1, ${cb0conf_url}, ${cb1conf_url}, ${imported}, ${event_message}) ON CONFLICT(address) DO UPDATE SET watching=1, callback0conf=excluded.callback0conf, calledback0conf=0, callback1conf=excluded.callback1conf, calledback1conf=0, event_message=excluded.event_message" + sql "INSERT INTO watching (address, watching, callback0conf, callback1conf, imported, event_message) VALUES (\"${address}\", 1, ${cb0conf_url}, ${cb1conf_url}, ${imported}, ${event_message}) ON CONFLICT DO UPDATE watching SET watching=1, event_message=${event_message}, calledback0conf=0, calledback1conf=0" returncode=$? trace_rc ${returncode} + if [ "${returncode}" -eq 0 ]; then inserted=1 - id_inserted=$(sql "SELECT id FROM watching WHERE address='${address}'") + id_inserted=$(sql "SELECT id FROM watching WHERE address='${address}' AND callback0conf=${cb0conf_url} AND callback1conf=${cb1conf_url}") trace "[watchrequest] id_inserted: ${id_inserted}" else inserted=0 @@ -78,15 +80,15 @@ watchrequest() { result="{\"id\":\"${id_inserted}\", \"event\":\"watch\", - \"imported\":\"${imported}\", - \"inserted\":\"${inserted}\", + \"imported\":${imported}, + \"inserted\":${inserted}, \"address\":\"${address}\", \"unconfirmedCallbackURL\":${cb0conf_url}, \"confirmedCallbackURL\":${cb1conf_url}, - \"estimatesmartfee2blocks\":\"${fees2blocks}\", - \"estimatesmartfee6blocks\":\"${fees6blocks}\", - \"estimatesmartfee36blocks\":\"${fees36blocks}\", - \"estimatesmartfee144blocks\":\"${fees144blocks}\", + \"estimatesmartfee2blocks\":${fees2blocks}, + \"estimatesmartfee6blocks\":${fees6blocks}, + \"estimatesmartfee36blocks\":${fees36blocks}, + \"estimatesmartfee144blocks\":${fees144blocks}, \"eventMessage\":${event_message}}" trace "[watchrequest] responding=${result}" @@ -270,7 +272,8 @@ insert_watches() { inserted_values="${inserted_values})" done - sql "INSERT INTO watching (address, watching, callback0conf, callback1conf, imported, watching_by_pub32_id, pub32_index) VALUES ${inserted_values} ON CONFLICT(address) DO UPDATE SET watching=1, callback0conf=excluded.callback0conf, calledback0conf=0, callback1conf=excluded.callback1conf, calledback1conf=0" +# sql "INSERT INTO watching (address, watching, callback0conf, callback1conf, imported, watching_by_pub32_id, pub32_index) VALUES ${inserted_values} ON CONFLICT(address) DO UPDATE SET watching=1, callback0conf=excluded.callback0conf, calledback0conf=0, callback1conf=excluded.callback1conf, calledback1conf=0" + sql "INSERT INTO watching (address, watching, callback0conf, callback1conf, imported, watching_by_pub32_id, pub32_index) VALUES ${inserted_values} ON CONFLICT DO UPDATE watching SET watching=1, event_message=${event_message}, calledback0conf=0, calledback1conf=0" returncode=$? trace_rc ${returncode} @@ -313,7 +316,7 @@ extend_watchers() { # we want to extend the watched addresses to 166 if our gap is 100 (default). trace "[extend_watchers] We have addresses to add to watchers!" - watchpub32 "${label}" "${pub32}" "${derivation_path}" $((${last_imported_n} + 1)) "${callback0conf}" "${callback1conf}" ${upgrade_to_n} > /dev/null + watchpub32 "${label}" "${pub32}" "${derivation_path}" "$((${last_imported_n} + 1))" "${callback0conf}" "${callback1conf}" "${upgrade_to_n}" > /dev/null returncode=$? trace_rc ${returncode} else @@ -342,12 +345,21 @@ watchtxidrequest() { local result trace "[watchtxidrequest] Watch request on txid (${txid}), cb 1-conf (${cb1conf_url}) and cb x-conf (${cbxconf_url}) on ${nbxconf} confirmations." - sql "INSERT OR IGNORE INTO watching_by_txid (txid, watching, callback1conf, callbackxconf, nbxconf) VALUES (${txid}, 1, ${cb1conf_url}, ${cbxconf_url}, ${nbxconf})" +# sql "INSERT OR IGNORE INTO watching_by_txid (txid, watching, callback1conf, callbackxconf, nbxconf) VALUES (${txid}, 1, ${cb1conf_url}, ${cbxconf_url}, ${nbxconf})" + sql "INSERT INTO watching_by_txid (txid, watching, callback1conf, callbackxconf, nbxconf) VALUES (${txid}, 1, ${cb1conf_url}, ${cbxconf_url}, ${nbxconf})" returncode=$? trace_rc ${returncode} + + if [ "${returncode}" -ne "0" ]; then + trace "[watchtxidrequest] txid with urls already being watched, updating with new values based on supplied txid, 1confurl and xconfurl..." + sql "UPDATE watching_by_txid SET watching=1, nbxconf=${nbxconf}, calledback1conf=0, calledbackxconf=0 WHERE txid=${txid} AND callback1conf=${cb1conf_url} AND callbackxconf=${cbxconf_url}" + returncode=$? + trace_rc ${returncode} + fi + if [ "${returncode}" -eq 0 ]; then inserted=1 - id_inserted=$(sql "SELECT id FROM watching_by_txid WHERE txid=${txid}") + id_inserted=$(sql "SELECT id FROM watching_by_txid WHERE txid=${txid} AND callback1conf=${cb1conf_url} AND callbackxconf=${cbxconf_url}") trace "[watchtxidrequest] id_inserted: ${id_inserted}" else inserted=0 From be6d1eafe51f2831d64c65f43f5aea179d11b21b Mon Sep 17 00:00:00 2001 From: kexkey Date: Tue, 25 Aug 2020 16:39:41 -0400 Subject: [PATCH 14/22] Fix for multicallbacks on watch address --- proxy_docker/app/script/watchrequest.sh | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/proxy_docker/app/script/watchrequest.sh b/proxy_docker/app/script/watchrequest.sh index 2089eb1..4901eaa 100644 --- a/proxy_docker/app/script/watchrequest.sh +++ b/proxy_docker/app/script/watchrequest.sh @@ -52,8 +52,7 @@ watchrequest() { imported=0 fi -# sql "INSERT INTO watching (address, watching, callback0conf, callback1conf, imported, event_message) VALUES (\"${address}\", 1, ${cb0conf_url}, ${cb1conf_url}, ${imported}, ${event_message}) ON CONFLICT(address) DO UPDATE SET watching=1, callback0conf=excluded.callback0conf, calledback0conf=0, callback1conf=excluded.callback1conf, calledback1conf=0, event_message=excluded.event_message" - sql "INSERT INTO watching (address, watching, callback0conf, callback1conf, imported, event_message) VALUES (\"${address}\", 1, ${cb0conf_url}, ${cb1conf_url}, ${imported}, ${event_message}) ON CONFLICT DO UPDATE watching SET watching=1, event_message=${event_message}, calledback0conf=0, calledback1conf=0" + sql "INSERT INTO watching (address, watching, callback0conf, callback1conf, imported, event_message) VALUES (\"${address}\", 1, ${cb0conf_url}, ${cb1conf_url}, ${imported}, ${event_message}) ON CONFLICT(address,callback0conf,callback1conf) DO UPDATE SET watching=1, event_message=${event_message}, calledback0conf=0, calledback1conf=0" returncode=$? trace_rc ${returncode} @@ -272,8 +271,7 @@ insert_watches() { inserted_values="${inserted_values})" done -# sql "INSERT INTO watching (address, watching, callback0conf, callback1conf, imported, watching_by_pub32_id, pub32_index) VALUES ${inserted_values} ON CONFLICT(address) DO UPDATE SET watching=1, callback0conf=excluded.callback0conf, calledback0conf=0, callback1conf=excluded.callback1conf, calledback1conf=0" - sql "INSERT INTO watching (address, watching, callback0conf, callback1conf, imported, watching_by_pub32_id, pub32_index) VALUES ${inserted_values} ON CONFLICT DO UPDATE watching SET watching=1, event_message=${event_message}, calledback0conf=0, calledback1conf=0" + sql "INSERT INTO watching (address, watching, callback0conf, callback1conf, imported, watching_by_pub32_id, pub32_index) VALUES ${inserted_values} ON CONFLICT(address,callback0conf,callback1conf) DO UPDATE SET watching=1, event_message=${event_message}, calledback0conf=0, calledback1conf=0" returncode=$? trace_rc ${returncode} @@ -345,18 +343,10 @@ watchtxidrequest() { local result trace "[watchtxidrequest] Watch request on txid (${txid}), cb 1-conf (${cb1conf_url}) and cb x-conf (${cbxconf_url}) on ${nbxconf} confirmations." -# sql "INSERT OR IGNORE INTO watching_by_txid (txid, watching, callback1conf, callbackxconf, nbxconf) VALUES (${txid}, 1, ${cb1conf_url}, ${cbxconf_url}, ${nbxconf})" - sql "INSERT INTO watching_by_txid (txid, watching, callback1conf, callbackxconf, nbxconf) VALUES (${txid}, 1, ${cb1conf_url}, ${cbxconf_url}, ${nbxconf})" + sql "INSERT INTO watching_by_txid (txid, watching, callback1conf, callbackxconf, nbxconf) VALUES (${txid}, 1, ${cb1conf_url}, ${cbxconf_url}, ${nbxconf}) ON CONFLICT(txid, callback1conf, callbackxconf) DO UPDATE SET watching=1, nbxconf=${nbxconf}, calledback1conf=0, calledbackxconf=0" returncode=$? trace_rc ${returncode} - if [ "${returncode}" -ne "0" ]; then - trace "[watchtxidrequest] txid with urls already being watched, updating with new values based on supplied txid, 1confurl and xconfurl..." - sql "UPDATE watching_by_txid SET watching=1, nbxconf=${nbxconf}, calledback1conf=0, calledbackxconf=0 WHERE txid=${txid} AND callback1conf=${cb1conf_url} AND callbackxconf=${cbxconf_url}" - returncode=$? - trace_rc ${returncode} - fi - if [ "${returncode}" -eq 0 ]; then inserted=1 id_inserted=$(sql "SELECT id FROM watching_by_txid WHERE txid=${txid} AND callback1conf=${cb1conf_url} AND callbackxconf=${cbxconf_url}") From 36454d2eea52289a662af2e13957e594cc95dabf Mon Sep 17 00:00:00 2001 From: kexkey Date: Thu, 27 Aug 2020 11:01:43 -0400 Subject: [PATCH 15/22] Wrong var init for unwatch address --- proxy_docker/app/script/requesthandler.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/proxy_docker/app/script/requesthandler.sh b/proxy_docker/app/script/requesthandler.sh index 2e4bdda..b033128 100644 --- a/proxy_docker/app/script/requesthandler.sh +++ b/proxy_docker/app/script/requesthandler.sh @@ -113,10 +113,10 @@ main() { # or # - id: the id returned by the watch - local address - local unconfirmedCallbackURL - local confirmedCallbackURL - local watchid + local address="null" + local unconfirmedCallbackURL="null" + local confirmedCallbackURL="null" + local watchid="null" # Let's make it work even for a GET request (equivalent to a POST with empty json object body) if [ "$http_method" = "POST" ]; then From 1f899778b1adeb087806c480852f85cba99e950e Mon Sep 17 00:00:00 2001 From: kexkey Date: Wed, 2 Sep 2020 10:58:38 -0400 Subject: [PATCH 16/22] Double-double-quotes in JSON in mqtt msg for new tx --- proxy_docker/app/script/confirmation.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/proxy_docker/app/script/confirmation.sh b/proxy_docker/app/script/confirmation.sh index e953ff4..0179c70 100644 --- a/proxy_docker/app/script/confirmation.sh +++ b/proxy_docker/app/script/confirmation.sh @@ -184,8 +184,8 @@ confirmation() { if [ -n "${event_message}" ]; then # There's an event message, let's publish it! - trace "[confirmation] mosquitto_pub -h broker -t tx_confirmation -m \"{\"txid\":\"${txid}\",\"hash\":\"${tx_hash}\",\"address\":\"${address}\",\"vout_n\":${tx_vout_n},\"amount\":${tx_vout_amount},\"confirmations\":${tx_nb_conf},\"eventMessage\":\"${event_message}\"}\"" - response=$(mosquitto_pub -h broker -t tx_confirmation -m "{\"txid\":\"${txid}\",\"hash\":\"${tx_hash}\",\"address\":\"${address}\",\"vout_n\":${tx_vout_n},\"amount\":${tx_vout_amount},\"confirmations\":${tx_nb_conf},\"eventMessage\":\"${event_message}\"}") + trace "[confirmation] mosquitto_pub -h broker -t tx_confirmation -m \"{\"txid\":\"${txid}\",\"hash\":${tx_hash},\"address\":\"${address}\",\"vout_n\":${tx_vout_n},\"amount\":${tx_vout_amount},\"confirmations\":${tx_nb_conf},\"eventMessage\":\"${event_message}\"}\"" + response=$(mosquitto_pub -h broker -t tx_confirmation -m "{\"txid\":\"${txid}\",\"hash\":${tx_hash},\"address\":\"${address}\",\"vout_n\":${tx_vout_n},\"amount\":${tx_vout_amount},\"confirmations\":${tx_nb_conf},\"eventMessage\":\"${event_message}\"}") returncode=$? trace_rc ${returncode} fi From b8c5a45731de2aa3ed611bd240042908c4363f9a Mon Sep 17 00:00:00 2001 From: kexkey Date: Wed, 2 Sep 2020 11:32:51 -0400 Subject: [PATCH 17/22] tx_hash must be initialized also when tx is not new --- proxy_docker/app/script/confirmation.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proxy_docker/app/script/confirmation.sh b/proxy_docker/app/script/confirmation.sh index 0179c70..4218d15 100644 --- a/proxy_docker/app/script/confirmation.sh +++ b/proxy_docker/app/script/confirmation.sh @@ -68,6 +68,7 @@ confirmation() { local id_inserted local tx_raw_details=$(get_rawtransaction ${txid} | tr -d '\n') local tx_nb_conf=$(echo "${tx_details}" | jq -r '.result.confirmations // 0') + local tx_hash=$(echo "${tx_raw_details}" | jq '.result.hash') # Sometimes raw tx are too long to be passed as paramater, so let's write # it to a temp file for it to be read by sqlite3 and then delete the file @@ -80,7 +81,6 @@ confirmation() { # Let's first insert the tx in our DB - local tx_hash=$(echo "${tx_raw_details}" | jq '.result.hash') local tx_ts_firstseen=$(echo "${tx_details}" | jq '.result.timereceived') local tx_amount=$(echo "${tx_details}" | jq '.result.amount') From 95a0a846665631a1e2094ed62ed848a280512818 Mon Sep 17 00:00:00 2001 From: kexkey Date: Tue, 8 Sep 2020 14:14:31 -0400 Subject: [PATCH 18/22] Cypherapps can now use the log path to log logs --- cyphernodeconf_docker/templates/installer/start.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/cyphernodeconf_docker/templates/installer/start.sh b/cyphernodeconf_docker/templates/installer/start.sh index a3d4a2b..0ec0163 100644 --- a/cyphernodeconf_docker/templates/installer/start.sh +++ b/cyphernodeconf_docker/templates/installer/start.sh @@ -27,6 +27,7 @@ start_apps() { export TOR_DATAPATH export LIGHTNING_DATAPATH export BITCOIN_DATAPATH + export LOGS_DATAPATH export APP_SCRIPT_PATH export APP_ID export DOCKER_MODE From 84f6b9b3d7c0203b4a75bf523592870068af8561 Mon Sep 17 00:00:00 2001 From: kexkey Date: Wed, 9 Sep 2020 10:56:46 -0400 Subject: [PATCH 19/22] Added API docs for batching features --- doc/API.v0.md | 267 +++++++++++++++++++++++ proxy_docker/app/script/blockchainrpc.sh | 2 +- 2 files changed, 268 insertions(+), 1 deletion(-) diff --git a/doc/API.v0.md b/doc/API.v0.md index da9ba8a..3806ded 100644 --- a/doc/API.v0.md +++ b/doc/API.v0.md @@ -1496,3 +1496,270 @@ Proxy response: "message": "Base64 string of the information text" } ``` + +### Create a batcher + +Used to create a batching template, by setting a label and a default confTarget. + +```http +POST http://cyphernode:8888/createbatcher +with body... +{"batcherLabel":"lowfees","confTarget":32} +``` + +Proxy response: + +```json +{ + "result": { + "batcherId": 1 + }, + "error": null +} +``` + +### Update a batcher + +Used to change batching template settings. + +```http +POST http://cyphernode:8888/updatebatcher +with body... +{"batcherId":5,"confTarget":12} +or +{"batcherLabel":"fast","confTarget":2} +``` + +Proxy response: + +```json +{ + "result": { + "batcherId": 1, + "batcherLabel": "default", + "confTarget": 6 + }, + "error": null +} +``` + +### Add an output to the next batched transaction (called by application) + +Inserts output information in the DB. Used when batchspend is called later. + +```http +POST http://cyphernode:8888/addtobatch +with body... +{"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233} +or +{"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batcherId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} +or +{"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batcherLabel":"lowfees","webhookUrl":"https://myCypherApp:3000/batchExecuted"} +or +{"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batcherId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} +``` + +Proxy response: + +```json +{ + "result": { + "batcherId": 1, + "outputId": 34, + "nbOutputs": 7, + "oldest": "2020-09-09 14:00:01", + "total": 0.04016971 + }, + "error": null +} +``` + +### Remove an output from the next batched transaction (called by application) + +Removes a previously added output scheduled for the next batch. + +```http +POST http://cyphernode:8888/removefrombatch +with body... +{"outputId":72} +``` + +Proxy response: + +```json +{ + "result": { + "batcherId": 1, + "outputId": 72, + "nbOutputs": 6, + "oldest": "2020-09-09 14:00:01", + "total": 0.03783971 + }, + "error": null +} +``` + +### Spend a batched transaction with outputs previously added with addtobatch (called by application) + +Calls the sendmany RPC on spending wallet with the unspent "addtobatch" inserted outputs. Will execute default batcher if no batcherId/batcherLabel supplied and default confTarget if no confTarget supplied. + +```http +POST http://cyphernode:8888/batchspend +with body... +{} +or +{"batcherId":34} +or +{"batcherId":34,"confTarget":12} +or +{"batcherLabel":"fastest","confTarget":2} +``` + +Proxy response: + +```json +{ + "result": { + "batcherId":34, + "confTarget":6, + "nbOutputs":83, + "oldest":123123, + "total":10.86990143, + "txid":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", + "hash":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", + "details":{ + "firstseen":123123, + "size":424, + "vsize":371, + "replaceable":true, + "fee":0.00004112 + }, + "outputs":{ + "1abc":0.12, + "3abc":0.66, + "bc1abc":2.848, + ... + } + }, + "error":null +} +``` + +### Get batcher (called by application) + +Will return current state/summary of the requested batching template. + +```http +POST http://cyphernode:8888/getbatcher +with body... +{} +or +{"batcherId":34} +or +{"batcherLabel":"fastest"} +``` + +Proxy response: + +```json +{ + "result": { + "batcherId": 1, + "batcherLabel": "default", + "confTarget": 6, + "nbOutputs": 12, + "oldest": 123123, + "total": 0.86990143 + }, + "error": null +} +``` + +### Get batch details (called by application) + +Will return current state and details of the requested batch, including all outputs. A batch is the combinatio of a batcher and an optional txid. If no txid is supplied, will return current non-yet-executed batch. + +```http +POST http://cyphernode:8888/getbatchdetails +with body... +{} +or +{"batcherId":34} +or +{"batcherLabel":"fastest","txid":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648"} +``` + +Proxy response: + +```json +{ + "result": { + "batcherId": 34, + "batcherLabel": "Special batcher for a special client", + "confTarget": 6, + "nbOutputs": 83, + "oldest": 123123, + "total": 10.86990143, + "txid": "af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", + "hash": "af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", + "details": { + "firstseen": 123123, + "size": 424, + "vsize": 371, + "replaceable":true, + "fee": 0.00004112 + }, + "outputs": { + "1abc": 0.12, + "3abc": 0.66, + "bc1abc": 2.848, + ... + } + }, + "error": null +} +``` + +### Get a list of existing batch templates (called by application) + +Will return a list of batch templates. batcherId 1 is a default batcher created at installation time. + +```http +GET http://cyphernode:8888/listbatchers +``` + +Proxy response: + +```json +{ + "result": [ + {"batcherId":1,"batcherLabel":"default","confTarget":6,"nbOutputs":12,"oldest":123123,"total":0.86990143}, + {"batcherId":2,"batcherLabel":"lowfee","confTarget":32,"nbOutputs":44,"oldest":123123,"total":0.49827387}, + {"batcherId":3,"batcherLabel":"highfee","confTarget":2,"nbOutputs":7,"oldest":123123,"total":4.16843782} + ], + "error": null +} +``` + +### Get an estimation of current Bitcoin fees + +This will call the Bitcoin Core estimatesmartfee RPC call and return the result as is. + +```http +POST http://cyphernode:8888/bitcoin_estimatesmartfee +with body... +{"confTarget":2} +``` + +Proxy response: + +```json +{ + "result": { + "feerate": 0.00001000, + "blocks": 4 + }, + "error": null, + "id": null +} +``` diff --git a/proxy_docker/app/script/blockchainrpc.sh b/proxy_docker/app/script/blockchainrpc.sh index 31ed9b6..9cf452d 100644 --- a/proxy_docker/app/script/blockchainrpc.sh +++ b/proxy_docker/app/script/blockchainrpc.sh @@ -109,7 +109,7 @@ bitcoin_estimatesmartfee() { local conf_target=${1} trace "[bitcoin_estimatesmartfee] conf_target=${conf_target}" - local data="{\"method\":\"estimatesmartfee\",\"params\":[\"${conf_target}\"]}" + local data="{\"method\":\"estimatesmartfee\",\"params\":[${conf_target}]}" trace "[bitcoin_estimatesmartfee] data=${data}" send_to_watcher_node "${data}" return $? From 655e0e4f1e2a8fb9f85b21eafe6b707050ffe5e4 Mon Sep 17 00:00:00 2001 From: kexkey Date: Thu, 10 Sep 2020 15:07:26 -0400 Subject: [PATCH 20/22] OpenAPI stuff for batching --- doc/API.v0.md | 108 ++++--- doc/openapi/v0/cyphernode-api.yaml | 490 ++++++++++++++++++++++++++++- 2 files changed, 536 insertions(+), 62 deletions(-) diff --git a/doc/API.v0.md b/doc/API.v0.md index 3806ded..ef937b0 100644 --- a/doc/API.v0.md +++ b/doc/API.v0.md @@ -2,9 +2,9 @@ ## Current API -### Watch a Bitcoin Address (called by application) +### Watch a Bitcoin Address (called by your application) -Inserts the address and callbacks in the DB and imports the address to the Watching wallet. The callback URLs and event message are optional. If eventMessage is not supplied, tx_confirmation for that watch will not be published. Event message should be in base64 format to avoid dealing with escaping special characters. +Inserts the address, webhook URLs and eventMessage in the DB and imports the address to the Watching wallet. The webhook URLs (callbackURLs) and event message are optional. If eventMessage is not supplied, the event will not be published to the tx_confirmation topic on confirmations. Event message should be in base64 format to avoid dealing with escaping special characters. The same address can be watched by different requests with different webhook URLs. ```http POST http://cyphernode:8888/watch @@ -31,12 +31,18 @@ Proxy response: } ``` -### Un-watch a previously watched Bitcoin Address (called by application) +### Un-watch a previously watched Bitcoin Address (called by your application) -Updates the watched address row in DB so that callbacks won't be called on tx confirmations for that address. +Updates the watched address row in DB so that webhooks won't be called on tx confirmations for that address. You can POST the URLs to make sure you unwatch the good watcher, since there may be multiple watchers on the same address with different webhook URLs. You can also, more conveniently, supply the watch id to unwatch. ```http GET http://cyphernode:8888/unwatch/2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp +or +POST http://192.168.111.152:8080/unwatch +with body... +{"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","unconfirmedCallbackURL":"192.168.111.233:1111/callback0conf","confirmedCallbackURL":"192.168.111.233:1111/callback1conf"} +or +{"id":3124} ``` Proxy response: @@ -44,11 +50,13 @@ Proxy response: ```json { "event": "unwatch", - "address": "2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp" + "address": "2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp", + "unconfirmedCallbackURL": "192.168.133.233:1111/callback0conf", + "confirmedCallbackURL": "192.168.133.233:1111/callback1conf" } ``` -### Get a list of Bitcoin addresses being watched (called by application) +### Get a list of Bitcoin addresses being watched (called by your application) Returns the list of currently watched addresses and callback information. @@ -61,17 +69,19 @@ Proxy response: ```json { "watches": [ - { - "id":"291", - "address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp", - "imported":"1", - "unconfirmedCallbackURL":"192.168.133.233:1111/callback0conf", - "confirmedCallbackURL":"192.168.133.233:1111/callback1conf", - "watching_since":"2018-09-06 21:14:03", - "eventMessage":"eyJib3VuY2VfYWRkcmVzcyI6IjJNdkEzeHIzOHIxNXRRZWhGblBKMVhBdXJDUFR2ZTZOamNGIiwibmJfY29uZiI6MH0K"} + { + "id":"291", + "address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp", + "imported":"1", + "unconfirmedCallbackURL":"192.168.133.233:1111/callback0conf", + "confirmedCallbackURL":"192.168.133.233:1111/callback1conf", + "watching_since":"2018-09-06 21:14:03", + "eventMessage":"eyJib3VuY2VfYWRkcmVzcyI6IjJNdkEzeHIzOHIxNXRRZWhGblBKMVhBdXJDUFR2ZTZOamNGIiwibmJfY29uZiI6MH0K" + } ] } ``` + ### Get a list of txns from a watched label Returns the list of transactions not spend(txns) from watched label. @@ -126,7 +136,7 @@ Proxy response: } ``` -### Watch a Bitcoin xpub/ypub/zpub/tpub/upub/vpub extended public key (called by application) +### Watch a Bitcoin xpub/ypub/zpub/tpub/upub/vpub extended public key (called by your application) Used to watch the transactions related to an xpub. It will first derive 100 addresses using the provided xpub, derivation path and index information. It will add those addresses to the watching DB table and add those addresses to the Watching-by-xpub wallet. The watching process will take care of calling the provided callbacks when a transaction occurs. When a transaction is seen, Cyphernode will derive and start watching new addresses related to the xpub, keeping a 100 address gap between the last used address in a transaction and the last watched address of that xpub. The label can be used later, instead of the whole xpub, with unwatchxpub* and and getactivewatchesby*. @@ -151,7 +161,7 @@ Proxy response: } ``` -### Un-watch a previously watched Bitcoin xpub by providing the xpub (called by application) +### Un-watch a previously watched Bitcoin xpub by providing the xpub (called by your application) Updates the watched address rows in DB so that callbacks won't be called on tx confirmations for the provided xpub and related addresses. @@ -168,7 +178,7 @@ Proxy response: } ``` -### Un-watch a previously watched Bitcoin xpub by providing the label (called by application) +### Un-watch a previously watched Bitcoin xpub by providing the label (called by your application) Updates the watched address rows in DB so that callbacks won't be called on tx confirmations for the provided xpub and related addresses. @@ -185,7 +195,7 @@ Proxy response: } ``` -### Watch a TXID (called by application) +### Watch a TXID (called by your application) Used to watch a transaction. Will call the 1-conf callback url after the transaction has been mined. Will call the x-conf callback url after the transaction has x confirmations. @@ -209,7 +219,7 @@ Proxy response: } ``` -### Get a list of Bitcoin xpub being watched (called by application) +### Get a list of Bitcoin xpub being watched (called by your application) Returns the list of currently watched xpub and callback information. @@ -235,7 +245,7 @@ Proxy response: } ``` -### Get a list of Bitcoin addresses being watched by provided xpub (called by application) +### Get a list of Bitcoin addresses being watched by provided xpub (called by your application) Returns the list of currently watched addresses related to the provided xpub and callback information. @@ -262,7 +272,7 @@ Proxy response: } ``` -### Get a list of Bitcoin addresses being watched by provided xpub label (called by application) +### Get a list of Bitcoin addresses being watched by provided xpub label (called by your application) Returns the list of currently watched addresses related to the provided xpub label and callback information. @@ -367,7 +377,7 @@ Proxy response: } ``` -### Get the blockchain information (called by application) +### Get the blockchain information (called by your application) Returns the blockchain information of the Bitcoin node. Used for example by the welcome app to get syncing progression. @@ -431,7 +441,7 @@ Proxy response: } ``` -### Get the Block Hash from Height (called by application) +### Get the Block Hash from Height (called by your application) Returns the best block hash matching height provided. @@ -449,7 +459,7 @@ Proxy response: } ``` -### Get the Best Block Hash (called by application) +### Get the Best Block Hash (called by your application) Returns the best block hash of the watching Bitcoin node. @@ -467,7 +477,7 @@ Proxy response: } ``` -### Get Block Info (called by application) +### Get Block Info (called by your application) Returns block info for the supplied block hash. @@ -506,7 +516,7 @@ Proxy response: } ``` -### Get the Best Block Info (called by application) +### Get the Best Block Info (called by your application) Returns best block info: calls getblockinfo with bestblockhash. @@ -545,7 +555,7 @@ Proxy response: } ``` -### Get a transaction details (node's getrawtransaction) (called by application) +### Get a transaction details (node's getrawtransaction) (called by your application) Calls getrawtransaction RPC for the supplied txid. @@ -662,7 +672,7 @@ Proxy response: } ``` -### Get spending wallet's balance (called by application) +### Get spending wallet's balance (called by your application) Calls getbalance RPC on the spending wallet. @@ -678,7 +688,7 @@ Proxy response: } ``` -### Get spending wallet's extended balances (called by application) +### Get spending wallet's extended balances (called by your application) Calls getbalances RPC on the spending wallet. @@ -700,7 +710,7 @@ Proxy response: } ``` -### Get a new Bitcoin address from spending wallet (called by application) +### Get a new Bitcoin address from spending wallet (called by your application) Calls getnewaddress RPC on the spending wallet. Used to refill the spending wallet from cold wallet (ie Trezor). Will derive the default address type (set in your bitcoin.conf file, p2sh-segwit if not specified) or you can supply the address type like the following examples. @@ -725,7 +735,7 @@ Proxy response: } ``` -### Spend coins from spending wallet (called by application) +### Spend coins from spending wallet (called by your application) Calls sendtoaddress RPC on the spending wallet with supplied info. Can supply an eventMessage to be published on successful spending. eventMessage should be base64 encoded to avoid dealing with escaping special characters. @@ -757,7 +767,7 @@ Proxy response: } ``` -### Bump transaction's fees (called by application) +### Bump transaction's fees (called by your application) Calls bumpfee RPC on the spending wallet with supplied info. @@ -780,7 +790,7 @@ Proxy response: } ``` -### Add an output to the next batched transaction (called by application) +### Add an output to the next batched transaction (called by your application) Inserts output information in the DB. Used when batchspend is called later. @@ -792,7 +802,7 @@ with body... Proxy response: EMPTY -### Spend a batched transaction with outputs added with addtobatch (called by application) +### Spend a batched transaction with outputs added with addtobatch (called by your application) Calls sendmany RPC on spending wallet with the unspent "addtobatch" inserted outputs. Will be useful during next bull run. @@ -809,7 +819,7 @@ Proxy response: } ``` -### Get derived address(es) using path in config and provided index (called by application) +### Get derived address(es) using path in config and provided index (called by your application) Derives addresses for supplied index. Must be used with derivation.pub32 and derivation.path properties in config.properties. @@ -833,7 +843,7 @@ Proxy response: } ``` -### Get derived address(es) using provided path and index (called by application) +### Get derived address(es) using provided path and index (called by your application) Derives addresses for supplied pub32 and path. config.properties' derivation.pub32 and derivation.path are not used. @@ -866,7 +876,7 @@ Proxy response: } ``` -### Get info from Lightning Network node (called by application) +### Get info from Lightning Network node (called by your application) Calls getinfo from lightningd. Useful to let your users know where to connect to. @@ -901,7 +911,7 @@ Proxy response: } ``` -### Create a Lightning Network invoice (called by application) +### Create a Lightning Network invoice (called by your application) Returns a LN invoice. Label must be unique. Description will be used by your user for payment. Expiry is in seconds and optional. If msatoshi is not supplied, will use "any" (ie donation invoice). callbackUrl is optional. @@ -923,7 +933,7 @@ Proxy response: } ``` -### Pay a Lightning Network invoice (called by application) +### Pay a Lightning Network invoice (called by your application) Make a LN payment. expected_msatoshi and expected_description are respectively the amount and description you gave your user for her to create the invoice; they must match the given bolt11 invoice supplied by your user. If the bolt11 invoice doesn't contain an amount, then the expected_msatoshi supplied here will be used as the paid amount. @@ -962,7 +972,7 @@ Proxy response: ``` -### Get a new Bitcoin address from the Lightning Network node (to fund it) (called by application) +### Get a new Bitcoin address from the Lightning Network node (to fund it) (called by your application) Returns a Bitcoin bech32 address to fund your LN wallet. @@ -1098,7 +1108,7 @@ Proxy response: } ``` -### Get the list of peers, with channels, from Lightning Network node (called by application) +### Get the list of peers, with channels, from Lightning Network node (called by your application) Calls listpeers from lightningd. Returns the list of peers and the channels opened with them, even for currently offline peers. @@ -1389,7 +1399,7 @@ Proxy response: "txid": "6b38....b0c3b" } ``` -### Stamp a hash on the Bitcoin blockchain using OTS (called by application) +### Stamp a hash on the Bitcoin blockchain using OTS (called by your application) Will stamp the supplied hash to the Bitcoin blockchain using OTS. Cyphernode will curl the callback when the OTS stamping is complete. @@ -1543,7 +1553,7 @@ Proxy response: } ``` -### Add an output to the next batched transaction (called by application) +### Add an output to the next batched transaction (called by your application) Inserts output information in the DB. Used when batchspend is called later. @@ -1574,7 +1584,7 @@ Proxy response: } ``` -### Remove an output from the next batched transaction (called by application) +### Remove an output from the next batched transaction (called by your application) Removes a previously added output scheduled for the next batch. @@ -1599,7 +1609,7 @@ Proxy response: } ``` -### Spend a batched transaction with outputs previously added with addtobatch (called by application) +### Spend a batched transaction with outputs previously added with addtobatch (called by your application) Calls the sendmany RPC on spending wallet with the unspent "addtobatch" inserted outputs. Will execute default batcher if no batcherId/batcherLabel supplied and default confTarget if no confTarget supplied. @@ -1645,7 +1655,7 @@ Proxy response: } ``` -### Get batcher (called by application) +### Get batcher (called by your application) Will return current state/summary of the requested batching template. @@ -1675,9 +1685,9 @@ Proxy response: } ``` -### Get batch details (called by application) +### Get batch details (called by your application) -Will return current state and details of the requested batch, including all outputs. A batch is the combinatio of a batcher and an optional txid. If no txid is supplied, will return current non-yet-executed batch. +Will return current state and details of the requested batch, including all outputs. A batch is the combination of a batcher and an optional txid. If no txid is supplied, will return current non-yet-executed batch. ```http POST http://cyphernode:8888/getbatchdetails @@ -1720,7 +1730,7 @@ Proxy response: } ``` -### Get a list of existing batch templates (called by application) +### Get a list of existing batch templates (called by your application) Will return a list of batch templates. batcherId 1 is a default batcher created at installation time. diff --git a/doc/openapi/v0/cyphernode-api.yaml b/doc/openapi/v0/cyphernode-api.yaml index 408a9bc..68db867 100644 --- a/doc/openapi/v0/cyphernode-api.yaml +++ b/doc/openapi/v0/cyphernode-api.yaml @@ -146,6 +146,61 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponseTemporarilyUnavailable' + /unwatch: + post: + tags: + - "watching addresses" + - "core features" + summary: "Stop watching a Bitcoin address" + description: "Updates the watched Bitcoin address row in DB so that callbacks won't be called on tx confirmations for that address for the specified URLs or id." + operationId: "deleteWatchedAddress" + requestBody: + description: "Bitcoin address that needs to be watched" + required: true + content: + application/json: + schema: + type: "object" + properties: + address: + $ref: '#/components/schemas/TypeAddressString' + unconfirmedCallbackURL: + type: "string" + format: "url" + confirmedCallbackURL: + type: "string" + format: "url" + id: + description: "id returned by the corresponding watch" + type: "string" + responses: + '200': + description: "successfully unwatched" + content: + application/json: + schema: + type: "object" + properties: + event: + type: "string" + address: + $ref: '#/components/schemas/TypeAddressString' + unconfirmedCallbackURL: + type: "string" + format: "url" + confirmedCallbackURL: + type: "string" + format: "url" + '400': + $ref: '#/components/schemas/ApiResponseInvalidInput' + '403': + $ref: '#/components/schemas/ApiResponseNotAllowed' + '503': + description: "Resource temporarily unavailable" + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponseTemporarilyUnavailable' /watchxpub: post: tags: @@ -1181,16 +1236,115 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponseTemporarilyUnavailable' + /createbatcher: + post: + tags: + - "spending wallet" + - "core features" + - "batching" + summary: "Create a batching template, by setting a label and a default confTarget" + description: "Inserts batcher information to the DB." + operationId: "createbatcher" + requestBody: + description: "Batcher label and conf target" + required: true + content: + application/json: + schema: + type: "object" + properties: + batcherLabel: + type: "string" + confTarget: + type: "number" + responses: + '200': + description: "operation successful" + content: + application/json: + schema: + type: "object" + properties: + result: + type: "object" + properties: + batcherId: + type: "number" + error: + type: "object" + '403': + $ref: '#/components/schemas/ApiResponseNotAllowed' + '405': + $ref: '#/components/schemas/ApiResponseInvalidInput' + '503': + description: "Resource temporarily unavailable" + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponseTemporarilyUnavailable' + /updatebatcher: + post: + tags: + - "spending wallet" + - "core features" + - "batching" + summary: "Update a batching template, by changing the label or the default confTarget" + description: "Updates batcher information to the DB." + operationId: "updatebatcher" + requestBody: + description: "Batcher id, batcher label and conf target" + required: true + content: + application/json: + schema: + type: "object" + properties: + batcherId: + type: "number" + batcherLabel: + type: "string" + confTarget: + type: "number" + responses: + '200': + description: "operation successful" + content: + application/json: + schema: + type: "object" + properties: + result: + type: "object" + properties: + batcherId: + type: "number" + batcherLabel: + type: "string" + confTarget: + type: "number" + error: + type: "object" + '403': + $ref: '#/components/schemas/ApiResponseNotAllowed' + '405': + $ref: '#/components/schemas/ApiResponseInvalidInput' + '503': + description: "Resource temporarily unavailable" + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponseTemporarilyUnavailable' /addtobatch: post: tags: - "spending wallet" - "core features" + - "batching" summary: "Adds spending of some amount to some address to the next batch" description: "Inserts output information in the DB. Used when batchspend is called later." operationId: "spendInNextBatch" requestBody: - description: "Address and amount" + description: "Address, amount, batcherId, batcherLabel and webhookUrl" required: true content: application/json: @@ -1204,9 +1358,90 @@ paths: $ref: '#/components/schemas/TypeAddressString' amount: type: "number" + batcherId: + type: "number" + batcherLabel: + type: "string" + webhookUrl: + type: "string" + format: "url" responses: '200': description: "operation successful" + content: + application/json: + schema: + type: "object" + properties: + result: + type: "object" + properties: + batcherId: + type: "number" + outputId: + type: "number" + nbOutputs: + type: "number" + oldest: + type: "string" + total: + type: "number" + error: + type: "object" + '403': + $ref: '#/components/schemas/ApiResponseNotAllowed' + '405': + $ref: '#/components/schemas/ApiResponseInvalidInput' + '503': + description: "Resource temporarily unavailable" + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponseTemporarilyUnavailable' + /removefrombatch: + post: + tags: + - "spending wallet" + - "core features" + - "batching" + summary: "Removes a previously added output from the next batch" + description: "Deletes output from the DB." + operationId: "removeFromNextBatch" + requestBody: + description: "outputId returned by corresponding addtobatch" + required: true + content: + application/json: + schema: + type: "object" + required: + - "outputId" + properties: + outputId: + type: "number" + responses: + '200': + description: "operation successful" + content: + application/json: + schema: + type: "object" + properties: + result: + type: "object" + properties: + batcherId: + type: "number" + outputId: + type: "number" + nbOutputs: + type: "number" + oldest: + type: "string" + total: + type: "number" + error: + type: "object" '403': $ref: '#/components/schemas/ApiResponseNotAllowed' '405': @@ -1218,13 +1453,28 @@ paths: schema: $ref: '#/components/schemas/ApiResponseTemporarilyUnavailable' /batchspend: - get: + post: tags: - "spending wallet" - "core features" - summary: "Spend previously amounts/addresses added with addtobatch" - description: "Creates a batched transaction whose outputs are the previously unspent addtobatch calls." + - "batching" + summary: "Spend previously added amounts/addresses in a batch" + description: "Creates a batched transaction whose outputs are the previously unspent addtobatch calls for the batcher." operationId: "batchSpend" + requestBody: + description: "batcherId or batcherLabel with an optional confTarget to override the batcher's default" + required: true + content: + application/json: + schema: + type: "object" + properties: + batcherId: + type: "number" + batcherLabel: + type: "string" + confTarget: + type: "number" responses: '200': description: "operation successful" @@ -1232,18 +1482,232 @@ paths: application/json: schema: type: "object" - required: - - "status" - - "hash" properties: - status: - type: "string" - hash: - $ref: '#/components/schemas/TypeHashString' - '400': - $ref: '#/components/schemas/ApiResponseInvalidInput' + result: + type: "object" + properties: + batcherId: + type: "number" + confTarget: + type: "number" + nbOutputs: + type: "number" + oldest: + type: "string" + total: + type: "number" + txid: + type: "string" + hash: + type: "string" + details: + type: "object" + outputs: + type: "object" + error: + type: "object" '403': $ref: '#/components/schemas/ApiResponseNotAllowed' + '405': + $ref: '#/components/schemas/ApiResponseInvalidInput' + '503': + description: "Resource temporarily unavailable" + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponseTemporarilyUnavailable' + /getbatcher: + post: + tags: + - "spending wallet" + - "core features" + - "batching" + summary: "Returns current state/summary of the requested batching template" + description: "Get information from the batcher and recipient DB tables." + operationId: "getBatcher" + requestBody: + description: "Optional batcherId or batcherLabel, default batcher if not supplied" + required: true + content: + application/json: + schema: + type: "object" + properties: + batcherId: + type: "number" + batcherLabel: + type: "string" + responses: + '200': + description: "operation successful" + content: + application/json: + schema: + type: "object" + properties: + result: + type: "object" + properties: + batcherId: + type: "number" + batcherLabel: + type: "string" + confTarget: + type: "number" + nbOutputs: + type: "number" + oldest: + type: "string" + total: + type: "number" + error: + type: "object" + '403': + $ref: '#/components/schemas/ApiResponseNotAllowed' + '405': + $ref: '#/components/schemas/ApiResponseInvalidInput' + '503': + description: "Resource temporarily unavailable" + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponseTemporarilyUnavailable' + /getbatchdetails: + post: + tags: + - "spending wallet" + - "core features" + - "batching" + summary: "Returns current state and details of the requested batch, including all outputs" + description: "Get detailed information from the batcher and recipient DB tables." + operationId: "getBatcherDetails" + requestBody: + description: "Optional batcherId or batcherLabel and txid, default batcher if not supplied" + required: true + content: + application/json: + schema: + type: "object" + properties: + batcherId: + type: "number" + batcherLabel: + type: "string" + txid: + type: "string" + responses: + '200': + description: "operation successful" + content: + application/json: + schema: + type: "object" + properties: + result: + type: "object" + properties: + batcherId: + type: "number" + batcherLabel: + type: "string" + confTarget: + type: "number" + nbOutputs: + type: "number" + oldest: + type: "string" + total: + type: "number" + txid: + type: "string" + hash: + type: "string" + details: + type: "object" + outputs: + type: "object" + error: + type: "object" + '403': + $ref: '#/components/schemas/ApiResponseNotAllowed' + '405': + $ref: '#/components/schemas/ApiResponseInvalidInput' + '503': + description: "Resource temporarily unavailable" + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponseTemporarilyUnavailable' + /listbatchers: + get: + tags: + - "spending wallet" + - "core features" + - "batching" + summary: "Get list of batchers, including the default batcher" + description: "Returns the list of batch templates." + operationId: "listBatchers" + responses: + '200': + description: "successful operation" + content: + application/json: + schema: + type: "object" + properties: + result: + type: "array" + error: + type: "object" + '403': + $ref: '#/components/schemas/ApiResponseNotAllowed' + '503': + description: "Resource temporarily unavailable" + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponseTemporarilyUnavailable' + /bitcoin_estimatesmartfee: + post: + tags: + - "core features" + - "bitcoin" + summary: "Returns current fee estimation computed by Bitcoin Core's estimatesmartfee" + description: "Returns current fee estimation computed by Bitcoin Core's estimatesmartfee" + operationId: "estimateSmartFee" + requestBody: + description: "Conf Target" + required: true + content: + application/json: + schema: + type: "object" + properties: + confTarget: + type: "number" + responses: + '200': + description: "operation successful" + content: + application/json: + schema: + type: "object" + properties: + result: + type: "object" + properties: + feerate: + type: "number" + blocks: + type: "number" + error: + type: "object" + id: + type: "number" + '403': + $ref: '#/components/schemas/ApiResponseNotAllowed' + '405': + $ref: '#/components/schemas/ApiResponseInvalidInput' '503': description: "Resource temporarily unavailable" content: From 877bcbbee525264e913ec31ef8c9800dc42786f8 Mon Sep 17 00:00:00 2001 From: kexkey Date: Fri, 11 Sep 2020 13:21:18 -0400 Subject: [PATCH 21/22] Added design docs about the batching --- doc/BATCHING.md | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 doc/BATCHING.md diff --git a/doc/BATCHING.md b/doc/BATCHING.md new file mode 100644 index 0000000..b1f2c13 --- /dev/null +++ b/doc/BATCHING.md @@ -0,0 +1,50 @@ +# How Batching works in Cyphernode + +Details on how batching was implemented in Cyphernode. + +## Glossary + +A Batcher is a batching template with corresponding past batched transactions and a queue of outputs waiting to be batched in the next batch transaction. + +A batched transaction is a transaction that combines multiple recipients in one transaction with multiple outputs, instead of using multiple individual transactions. + +An ongoing batch is a batcher with its queued outputs waiting to be part of the next batch transaction. There's no associated txid yet. + +## Entities + +### Database + +See [Cyphernode's Entity-Relation Model](../proxy_docker/app/data/cyphernode.sql). + +- `batcher`: batching template. The conf_target is the default confTarget that will be used when creating the batch transaction if no confTarget is supplied to batchspend that would override it. + - id: autoincrementing primary key + - label: optional unique label to be used on subsequent calls instead of using the id + - conf_target: optional default confTarget to be used when creating the batched transaction +- `recipient`: a batch output. Minimally requires the destination address and the amount. + - id: autoincrementing primary key + - address: destination Bitcoin address + - amount: amount to be sent, in BTC + - tx_id: foreign key on the tx table, the actual transaction if created + - webhook_url: optional URL that you want Cyphernode to call back when the batch transaction is broadcast + - batcher_id: foreign key on the batcher table, the corresponding batching template for this recipient + - label: an optional label for this output. +- `tx`: a transaction. The information about a broadcast Bitcoin transaction. + +### Good to know + +- There is a default batcher created on installation time, with id 1, label "default" and conf_target 6. +- When a recipient has no tx_id, it means it is waiting for the next batch. +- When a recipian has a batcher_id, it means it is part of a past or ongoing batch. +- When a batch transaction is broadcast, the webhook_url of each included recipient will be called by Cyphernode, if present, with information about the batched transaction in the POSTed body. +- Cyphernode knows when a callback webhook didn't work. It will retry the callback when a new blocks is mined, until it works. + +## 2nd layer: the Batcher cypherapp + +The Cyphernode's base functionalities for batching is pretty basic. Instead of adding complex batching features to the Cyphernode API, we decided to develop a [CypherApp](CYPHERAPPS.md). + +The [Batcher](https://github.com/SatoshiPortal/batcher) cypherapp will take care of the following tasks: + +- Merging same destination outputs into one, adding the amounts and calling the different webhook URLs on batch execution. +- Scheduling the batches. +- Executing the batches when an amount threshold has been reached. +- Hiding Cyphernode complexity by dealing only with a `batchRequestId` and a `batchId`. From 474f6475dab5f1e2c8b1d5839f219b7625e397ab Mon Sep 17 00:00:00 2001 From: kexkey Date: Fri, 11 Sep 2020 13:34:37 -0400 Subject: [PATCH 22/22] Added the Batcher to the CN Arch schema --- doc/CN-Arch.jpg | Bin 85319 -> 86625 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/doc/CN-Arch.jpg b/doc/CN-Arch.jpg index b87ad2f3e1a183240013ff8f357cf6f0bb27e98f..5bada13ff12fad4919d8a34e618f3137649bb3fd 100644 GIT binary patch delta 78438 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 z zwPq0+3q!ok9nX9(87k!>258i9B^MvdjI9@k^HlyW&Z)T4WPUYs{Fue&Sv89`Rw9y>yKdXyjeKY1qa3QVbwMsFIl8w2C@OoW9W6~alY2(ds8Y2c$}Zke*J?Y z>nrX>IJibJqmpAd%}!4{1q&Iz{R%XDL8uB}u|($e%EOXg%|5#}KF ze18h26=RK~zz`B_k$tdfgOOqdXsNdeE-fA12|BGg6VHC9o*~q;MMQK0TYPdT0D>3* zJ4DJNSUYy-;w7=A#!EHXTo#_GwacG~>m!K`X^`)Ud}I+!bdQFb){(HT-}Q@1q{upn zE4WOn?_?X8Pt*>NcRqRKyNM?tiTm)7 z?^S<``tN!k0tL+8KDlO`#RFwKP5+ps*Rs;n3DpTLFX4Er`Z&Yhlp6kiRb8!ig(6SA zhuNM=x*;I2NIO&FC9QIzAS+p$si#{DJqlZeabU7DIw}X4C`smTwBeQsKh1{NMBOQSh9 zJ~uw@hV4hm#!w)RRIXRl^V0q^l%*t_)HYZ7nH9l&y3ML4Xy8|9VAmC@7m-V+&qN+0 z{7G(>5!1`uy!$;@68fr)7(R3tjEwruGiVN!d>UUyxn3}yvFN>5cwPh$( z4%1}`pOAgTLmUfev+&1HlhsCVzPOzw9kwv0QKou9;m}{8lwp0WTU;oq#SoV(MH?C# z&Hd_UQ5H{3O)XuhtU?X#&~skWkIOglee%WzNj4itNtJ6a4)zj=Iit*p#a_Xf%Y9VX zqCcGyyzW-E_y>Di|k3lrOt!N4pz!8o0g>(ro&sSOHV9{Sl zJ@xYVB_I^eA0?@4=u|FoNgC;6Zn5GY=`}uxCJBLHLvZy9B{Hq;2@^#uuM3<2g-@da z{GN53+<7&k3}zk?H!jeRX?%q}5#~&z`>%^!?#Nv4M0=_!63o?oBM_E2Nj*rgNj+f#QxzBM z7sDQkNGTs?6y#e3va4duqRysPW3{*2n?ifaGf_54;hd*laLMrLep}^T#l-l%{@u-`Iwh>KAUkOXzfts?k<52zQ>g+mp?0MkbzgPhqhoU?xaq}P#G85q>3LGG z&()@9x_Km%%~-Mmpaz;(J%^0ht0+hi=PsK7&8H=9-*rg-VxVU;Yvk=^p;Cht;a(`k z{H(ss_r~-Ld0V@cEQ)VzfmCKOR4=56e+toPM=PB4V+tHv)B#v2m0`9HjyE+o$)Z3* zeQw0$BAd>U#;**zde zaJ6s{Xr0l$JFedzRVs~wOy64Rc|3Wy+B7B2l(O@&pNb@p>0$B7{6kxPFAVl*8|c&? z@${=@D68Tc=21)FvbFG!elIN`NbDtG=MtuKi+EZG0e_!E# zk&SwOJBI@C(}uYdIL1>$W4w`%B_ZQHm(& zb#KiljH-1RUuV=-EA?G1v`3;K`&U)JE@>@<^U5+n~xdw*DbJNd5D8tuQzw~OZ zx~1UU|FzfeK|AhNvnam@?`0Ko;4C@Y(mE&Z6(1O&rgzc^eHmWwoWzHoy&mRpGM3V^ zH9uD&cZ#zUFJ~IH^ztC_!xsw979bE))^P&+fytp;jR#+^9N*-FhWey648~za+LzDJ zhzq_EX!>n<4p1%ge-ne|+yu(eDORN@M!8Ld2_GYVp%(_;<4WV;4s{Gy(hHKT47R2D zWY4i@&lZ})Y?9iT0zYsAM3Y2&T#w``UGSpCf zr~-VruiqvRQ9|l=T9PPcQgDlDo}$47tPTPz=2*4v(W_L}UmK7|NlOgZWzxt<_y^Y+ z#IX*?)CQd%39oB?aaR={JG2MN?k=~SH>>11ARi`!&T2|2nE7g0Us*s%yIRS^Umrii zxS=ZW&W5cZbZQn6O^H9Yd>HEM$M|wnrD`j!))>h?{miBO(M6Y0*;K^|Y+!&cFqo|m?1c?cQmQx8aTuXPd)k9nMt|`^AuQQ&bL-1^M{|4gFbhvN`p5VsDi3K379IWPk$+fK+XLWS(_EfhLqRI-n>O@E z0kG7P`gKy@S+lT&W4z8X(2Cm|fms+V(7nC~_J#dX_xG0u ze(P%ok^ix+!qtVPkQ8o(f^e?M@&U0O{zeVDJbZY^I|5)AFf{)m5CuU7^nq67w}ine zd~?d(@zSvl$Ew!L*lfBEYy^WKSa{B11H0J20E0%i?|^0Lcu5gRQ6ztpv^F1*a17=1 zqAi-<-Hc7$!8_b>1LcJAUOSy5eFYc5G2<(BAByXWdCHxGj*w=@F|vjaSq+AEOu_$0 z;eV+lCZXvB4P+LbKu67WgTf-h&eX}J zH!h03=OcmOx7H)!PGGn$X|=RzCDZyZ6Pf;y>FLz~b=FfBeCuKn^k4gw`41iY$Rd8f zk!LN!l;$7*?2}($Fo&ZCD6wqKxqDx7M+{D=>r$g2S08`VmXfcTKN}W)Rx60jP!9nG zD(BLod|gdrkFPV5b$IEqjYjEc@|J^j$%;pI+464BKlvdQ-iE!stG2K(;7qop`}y7H z-DgDuUmJf-;`w)Cl#1J`iFzz0S!>)ozH1P(!Nrm|#P;(Q7o)}zfXKmnKoj$o`4r<( zW$2>0YgtCD3v*?zCuV&Mrf+s~taH`p+nkY|7}ybY= zfu@m>=Ubc)&2-eXC`WVOf&21yNx)oL`lCHu-j{|k@D}K%)!Xo0u?g^xnbpA@0 zxU1S+XgJ57^ei$f2Hvx?HK|V3@pOgGoACLyX46iHoiEmBIe_8#-Oz)DIvVHHT&P0y z9iHjg7E>)O;#HouqjmO)VhJ0!nX7O6cvO8Q29(87rkW{Fn4P|LvxJ3J1sHjXUu&FE z5F2dS@QdAi|k!(y3@+^O4~1X0dY;$|Khw;tkT96nob zS>OBl(XqDYYqveY_W43{LX2n+m!0S9pSX%m+Q{s*)v=zKEIG5x>wPKJQqVbH^EFKt zTK4$};rEm$g57etCqa6Jh^Axp5#mhq?^Ucnc*{-Wd1N+n#(l(g>oIGK9bUM|JuNM+ zDAA$m(|bxC*BvWM(!%}ePM3^Q*t;87b9G;9n+&>rvL9nfOAadj(0W|lkP=>suEM1YBC16NP`U=bFYkroCk-G2pqo zwVb|?RueQ*yMXP8UO^j{VSczljW2bB_M?Gl%v`c>bBpqa&{6}9JrwSSD8F);?6^vo z808w8scc|-v8W_SnN48-VP)lmzn+d?pa#!n1pSW827Mi|lN-PdDGVG;3uul>S}oU{ z51f&S# z)(R2&7vBu7l$_bw$g9}7n)?9HXL4M4b)%Za97T9G35x?q_P0iJh9q;3FL0;-*gkZx z&;KhAfc{AY{=EMf7wcn4eP-m|qS$6r_JO_HzLZ5Wlk}_;f$Y|+lj&BC8sh#<>w9Yw zbR$Y>lfQ(oOn_{-6?LmZZHPTBK+k&7_~nfUJvVsgrinL;Eid#3It!vlA7_)v21TUW zx4}Y#8mcktrC%uC9IekBm%TPw;R>-9;L7L|0$N!hPrb^7;-QeATjjJt<<^D5Yo4_Z zzJZ^s`sT*xd%{pv(*U=8H)-C(l0&m+mr5dl;lY1#`Rhwl>O@~G#$px2Z;S`EU~{7& z>KSXV?du{w^`aC|DuA+uU4&R>bvw+}e%&(&B<*K~msOtuFn8v9rJQK?|H# z=v}l_rN!}{;*8wLfiyAqy#9MhLX863ME8s}jlC0zX0n2*32Ba_uR4$KDJPN4MoW0t z&x9hI4uz{RvfPH2iuNtgBm>)9D#~F>{KSPfb?hF$;(mu~U)n7}g-s%`s-unYcGR*e zW@B#O0QY|(FUxeXEL}fWr%j~P+blxgPL{ioy4KrzcYki+eQv2ob*u^a+LMP)S6#LV3TiKYrR~Nq$6Swdx?0f+mK5>N1ZcsNjnavjL z9$M(NwV~K+cXp;*wtcK{JAX{n=bC)_iP`f39yut1W|&@1E{rL}*%-Yq{KLx^^XCf$~Sadj%NkRWz8Cl0gC)RD*HO0inBB6o)0!>sl) z5m?_;M=8z)NVW7T`8B@^d|h<2m?xrMSdX(s>=Rlb_ck1Jl{lVTZz3Z;H<~=lsdXfeAx!F>VRh?gXqKUHk1pT6 zFsZ1ZDr=78Fq!0qedr_ZM8Pw)Tz1nqkDY$9j8LTnECaMSFUN?5xZ2gNa(}` zz{b{2qaa7*cdN-TXGJ63kuS@pyV$rFCF-5b@Q`_+nOXd%^TF=&0 zvtKBDj?1^!7lYpyD*GypT5+^3D*8X9vR`+$Qk5S?B{OD?(Q(2v#Q~JQcmRfmW zrX~sfv0PGWksbd9e#Q1$W7Soq_|l6H40GFvQbp%{Q=pG#={XTCqEjow}d~6Bz20|KI9mIJCbo{Z~KyXJzq2 z?drfpzoq}$ZwAPXI^d^!Hh;C#a~*8dZ;X!sxt-kYR-8Z- z8)J#jzx?&DI*Y&67Jpwcqj#Zg)O*(77#p-U0O9+s+3ThUK?^s{KybSaUEI-7(IY@( zi5jdt_2S>7jOCV3D)0RB>)n693I^SR+H9I(M|Xc?{35Q1A7PqRJjFRlMmNCDX+~7^ zNb=@TQ462{#>eC&*h#z&xI#jZb7fWp5L$w7dK}umlJh@6Ws3d7&NHn9N#Xs=AeV_bvq-NwJCv41q8STag z7uw&QHePIYc<|a-t(`(uE0?=JgXf;LfIhdrdyr8`((5|DfM!;bb73-LovKV)NaU^( zU022nqu7`BiPZHYBEleL2eU@Oh|=B1@olQh-*`JtkTRRRdRJw2nNv1;T9bkzi>}wE z@S~b?eTUY~r4;ZBwohX_*jo4uhPpRF(%b8VTA75Qj%<2<)RsB~o!uM*Sfm5~pT*u# zsH1Hm%IG^mYY34Xo-0%p78Ui;4^jbtm7RArN^}8LorBN&w7<^O z7}Gk|!ugzce!-k`F&sxzky7(5>Lmr!Q%h@>G7jr<#on|AmE2|5+#>}@u>-FQ#g6nl zwhZQNzhQ@X&cBjZN(M>=no3QCT94Ib(q=uPEn&pe_gip;5lm{I^iTEheLZJq@Eh9+ z*}s{7$Ef~@g#>;$>ZW=u#_cz@b*S^a%2N1tQBg(JH9n~>{Eq?SpQ#L&F@xM*I-I19 za5DtZ1g>^Z@;zOuDA}bC%=xy4zi?2`0A_i8hn*qnh~Z_5j2^xUUVGi@RZs`@B@K}5ms$rT zUnQHI%_#IqQ1lVdB@WeDrp66i&J`kujU%pAG8qe!HtwDcDSe9KwoGP@H_mhHOYnN# z{g9~ZCA)d3JOxXIv>GLsyih@L?;$5tW$~TL%rFVLp?~g~hdK`0Pu@JL-3MAkR$AEW zwiMrX((}RXjjxK*Ad+O|Dt2W*&Zj*(H#psfElZDYLdzf})j+ou9YE)6LH`<-Q4Hq{ zJFsSC@cyRxKQ@|uiHAX3sYB@_$#mf3o>tXHL|=}D{+DC5uLYHYd!rZVI6`96mY@;Y zJZi1>UmH`qHWg!qlM_m9)xpJOo_c+Q=UcaGi1Fi<`tSpqgxK#XP@5=F)`!-aa==9dgdfbi%>OI}xG`h%lCv^{aXzDpa`hhV*Ni&8LdHEskm53b_ zUdvYSjtIxF)xh>GR_0>6iT!=5c3xW_SIyPBk?~croB!E}fq~qoj-9d`R{8t?$s^tr3c|zq!?>M_wXdDK})0~NvP_3O)}5gdZgs1 zx3c;dddH|c!(6%JPWj7ruS$1W`%^jmJ!801iaezFCz4w&zb)~ z#(&nkV#GXIYDwix|J4)Y`Wy!%)u$hVsLc7su}o6pPal778|)%4LJYPUZ8ExrpfARn z(pVAOL(@9St_3=SFHaz0e{Gz(Yb`!RpcQWGDmUkS^Cn{w=C`Z#EK$quO z2^h0?tR^}on?^kgeKuA*mxs9;iVHDA!0(c|pnVTM{m<^+xc&Pm*PQ_&hoRX~Y6Xq8 zCbTC_jn7O-E@BSQQJ__Uav@!;(pE$V)zr@Gozv7#y%;?tO;=+5dSfQEdU)#`jgcQ= zJzg9n93@#(iZSt&rgyRvd@hB(*w?GEo2a2z6D5MT9(f89Eyv{D=WY1eP^OZWu<0~D zttU30hokJs!6bU^k`JkhqkOUimBV~;G#ZtIp~XFeC6-*I#B43kaSHD=0 z*XN$$E+Q$}w$;-Sn-}^}Ye4G-=z^yr68Pb(M=!v04|0shi$m)AfRD)FksAZYB_we9 z0|}^+Y2}c?EyPKJ3*r(YP$ScM`~1)h?Orl|5Z?4@R8dTF=@50K`k0@BV*2%pdgN*f z)Y=aw7zPvzAfL(Y8h;L#={BU1iG;{bmlH8cC0Bx|z1hu=_KHHE!e^zAmf}DM8#w@u z5j#i%9@7dyl7rcb83lFquD#vr**=Ht<-unRP*aWKi85b#8m|1mw@+C6&sec^+>Nc@ z7^OVZr)LJ|3qVGPjmO%cE;ugspoTKw#Dm7wK!+SJ_!3OT*U4EPx%$p{5(%G#&w?+o zK|B@eo%?&xpRHB>(zSX;@?ZtEuzj-n`a{U<`~NY7N%1pJwg2=RUB-4{9TZE?N~D^c zU_E*sVrE({UZjiHm`58$-nyy3u{qFucAEl6lH32H1vmz5cdb8NVR-Qzs+gYD=d=+j zyy|jIid&_V+0|+vH?eq2#Q29JBC}u#|`{b8+KAkf>``-HczMu+LuI+3+sg z#eSfZ=<6I4QL!Q(^nOs`7s}hZp)yrTySwxtnLM9FlPG&6yo7OCaG#PIqHt)}fLx70)XCykk@zka-^S10S~CsOD8`9sv6B<Pe<#l{Y?<<`u(ja9d8n3i1Af?>RK(F5!9J0lH6DymvR<@iY zXC`y&%;je0_aCGLu~@F!{|r1FGV|lsA&d8J{pg4z$q|^2q}!V`S22H;RJb=pw0qug zJH9lJn(7g@C&c8z^Kg)%;#`T!I+|y*#y~-HdmTNZI9zHkzW$gZWQQxx{2TfwylP-$ z!+zo5T)5DB9i@{WIJ&L#!`u)L-@u~ANq(pZ-__k~JG?)BS!B8ULUdiCn(dLGa1h37 zloQ$K3YmvP943*kGRaN$zdH%~e;1^Ec?%ofxUDr@Lk0~V3fJQ6OiZ7WIksekD&8#; zPQOjaz*(~$p^3!9`8o4ZZvbO<5*uSD@rG!NtiMpnKW3-D8)UN-k4Ucfp zsj4Pb9UtjS5W5WYy+#l0wcMu9qHCvk)3w}2&Y;VsRQQ+scwfkD5t9r*l?$^Tn7z!p zF3W(SVg%YsBE;2-$zM5}FS_~#RBOrkXHtK-LfJ~hM1T0uS|RF%Ojf(dbIOuT4QvL| zy5nZK*v7!!vAJOmcE$u7GA554BBMp(Hi0wuT|YTNB+w)ExF=)pQdn7|O{U}OT9!EJ!}p}rd+=Lct*MQ_P7`p=YYbg|L_4c+n(cc;ZBNuh zWptzG&$)jzno3ID+9-oNBnr&{TX1(INW1O~=yWwphTE}Jyqk6{a5EBsJs*@paFvC# z2Yv*}w@7F6XQGN2Wy_{Ou0^d6Mv3uef)~u_IJz{sHoAyZe8qgN-FQOvWvYdrtH1PT zW=-rRz*?I~YrUN1bBW3cT^>bja#fs(EB+rI!L-#&NDm*DOW$N&@|Da`Zos3MIOCrA(Yd1{nK}qwpTq6S?8c?Zg`zPW${gIned#p zBE-p#L1uP&mXIRKg=27NjA8dtR8w=eec~-ZCeZyQ6Eq}sw?6=j&P|335BMID?H_7E z=>*ocvM*)MnDYiQbt=*joHM}35f<`L*SN27pLkDTFMp4bgl^${B0S~pHNjixr9$QFZ{Si zEaI02!h>@V$t%FVTs~mUN;ei0_{R8fb$b75_4S%$_5tYjjlHok^E|y?zS>>#>MfA( zI>lTg)CiT&u)rUeOmB6xt!+5Ie?Q91m0l((!lT%F+2Nkxa)qDyjl&9--c85s;=rS$ zab=n1AztC2g5+OvF4`ovcx@p+n9JpxD?HFCD&KY^vz<-8uif*Ek{{T%qK(waw4zId z3&vgN>R5*saidM!g!9L-e&}vzAh{r-N$I(4OHFpGtsK)@J}w%~XNf9UhLD$L@*|V_ z7jPx58j`_7Jr0Gwt~@jPIM2+9U!Rrf@3lM+!#_WMxmShkd*`;>{<3_fNFDZZOKfCT z?j|bbt6To4_h%UAYFXH6>_p0?KZ%>AiQ_lMy;A5SMfcvv)ektXt1}_1`o415JG*!#I`=qFEYfqTmhlfrLZcZ>>55=x*tAVL2g2bmDicH$;g%(22um>txM4 zrt1+h!`{PkAIX$p>+*P%eZ5z(pU^E%KCkAXF!@7^nfmRGLbF=}^+$4oZ(4pG8-T z+^bSf_0I{VB{C6Kqz?2cuXoQlaT?bcau^pr=)OSz1)A zF_Js)4&yBIc`O}Iw?O(H~D<9ET+^0gdj5liw0FQMIQE!mE2&DBeJ(eLob-zAB;&D?P} zt$8A-=c?@JP?~p8OEByb&19D_?F}vP&OzrlppCD3lCDd>*%n9qvBiUl#Di_s<@*tj zU#MDPV$->)SDy5^n?E>r_AMsYG>I_WH~!_#`c`;w?$OXg0bI$QU!b^3>x7>tHq1asg?+g_HKn;^eJ?6!4EADhzuFE#+R@d*W^7_v=MiX z9~MtQT$2Pf=Je%Lw=(93V|ad%6MTZ@ipPq)bHnJlGDD!g9Ps2jrTEp1rTBhsBUS|_ zYIZ_A$KF@5UUfMqTY_2S<4&8cVnw7Bp>kAi`wFx6v`ryd7Gu}@hMksoh<*erHR2|f zU${oEosrN5GraMNsEqATZ-lL9+MgI*II{E3Kuits16HcSk)o1n-GX}IiwZbqUJJUB z#<#_Oy72NgSvv8W(SZV4MsrVKD#Ax%73Ea-%K6ikqO6Ck|mIGrEubfbP>iH!zQCg z`SUQmFpyfvK=c6n+MiTDeu8OXi&uBKBNqYcfyMBSy5Q?fo=%>@o~C+1fKm9=33@CN z`tI560IdI?Kl1SLx=E8kw)JtHYr`Ev(d&n4yrYCJR{}ifr`ASbV)?TccnSp!4`4)P zSbJ?l6^L*_Aosd|c`P8MeeMA|5Q+ain0<(1jV~7Qnb@6a_;r={rS(-rgEN*}YpAIs z#Z{Z$F@7lePl*6;mm$#jL7)qLpAL-_0tzUl+g_t~5577L9d1Bj!w39P$vl@*f_yCZ z3!U5E{$Hb{JzK9`zb>rwhMky;U61mZf{=t{Q*-ny0juAF_seJT6jVNij)+SF$kL}; zUbafksl-RUd>{8YDN!Gt_Mj8Ub}bYM{88EdWoIF+`^Zw?7e(bEwFQfX<%3d?Q?0lx z52vn_5fn=of~V$)1*_{Ly%@W=1}ZWKmq{Xbq&|n#ljWE(5ll@5DMzi;#U61bKx4Gg| zBA&RZp5|P<1?_%A_tE9CMT>$g1p{`W<#z6t?ojG&TP(W_!K+X2TtQi|bP|@Aa;CM7 zFOvQWeiH9Z>z`LzZ6XIb4R<(mb+b)J(7p72Dld@k2zq{W`p_i;mv9=lOW zMFj|A_UoqC(NuPt7VE1zD|H+E0Asa=yPzZDsuEKwOQfyR$LqOuKhxa5^Qa$-JZOG6 z?Oa4mJE_Hw;2~cI`Aw{QqIeAZ8Ax`%Mg0AV!N@&qfBUv|HLs6l>#u2xO8lmX#wld( zN`J#7lG9c?x=CKcsZErS!)Zwx;W}2%a~1?;N&wqd5Ybs|iSdis+Ze6E-0ev1rQRlc z^ZJAK;QFvVY45Oo_)ouvpN7DHJWFkiYgl!+WZo_%-gc16(k6SKLCn~E!#W=6CT5wq z^9q#Uvi^lbYC*J3u>Ero(YzE-h9GPvd7^qjG56(zoI$H++S4|JU$yb{TOo-*Wk0%0 zlIsVu9-gnr{gYSsS@2$Hs!#evfo`XZqrV!ht5HQ5h|D#Jtho}t`?hHNY&Qxx=7{=D zt8VRuexgZ?ugw-4zNuFW-xEUIsFdb*HFA!A99Hv_`{Slws%;GreDqw=YmoMa8d0_VjZ3;v9jRi8EyTIwh&HOEPEhr$bZC;5PA)$A$b|_R&y}AvNaf!f$PxU-vyG zk*+`E7(H0m{GNE@;AD*4{#sW^Ma$?!YQ%f$P15$YD=9YDSGp~&Oz*9-=FV$I_OUN& zY7vHFQorG6s8yE6s|d_T>2 zO$^@aq z+vB<|eb2u8g{HjR6H>}FA08|I`b+}U4{gND#*nRRI!W)i4&|?QswWD6(O)9} z+?RB3T%nIss-F2-NJubV=^HiYoj|``9s^_MG?JG=mOu8CX)8EMd)s}MY0F5VRh{q` z*n(**1GWAfRpRcJM3r$Sg{6{V?p!{s{rn`?YQzJ}6g;t|FL4t1QN3TgJ8SzJLpSb} zEb$wdQiE;>oh?ka4XU2NzRg#Nto9j8Nx$f5yryYH+eniJ<+ zV66jq7>3X5ZjXE6q2{8!wou9;ad{9W)J8mjZXgEXhy)qO+vTzXCac(g-pTz@+Y*#X zG zmT)9Q7{?vk9Fw0Yrn+rnr#`hKjP)A0+zx-Bl?Ox5RVLamaxH6*1gZV_Ip^uC4d%>H zUm<)zZuUbfZ2HehGTTH(^xV99oXYndU$wK~)>De7P#k2hJP$VUrBhYUFW@HDbyN39 z(aiNZfJcznNSvxi5!xz-vzj`rYmVF(N@ywMO$h(-G`I=*JS37q5b3jlv#wR5NyRY; zt&hRhHRNqpREd}Qvn8^T>8YT?ZA^j_+Vg9AtkPwl26T;R2UMz9_Z;jLRRpR{*?{5HETGHc z7xkU^TkY~k2DTKvXVQq1z!7aLy4dvFOHh1)%jrdm0!UQz0WVT zX^?$3*6yz|T%e)doILO8y&Hf>N6ufGAtp!kZoP=$8IwBrejjU0Fk}V~_hX1T7-_Sc zDt2XOpCV36@uC!hc9D^OpY%$%Gjkm2)^Jo0P1Q1aAZIRmRI(k!W_WYegtn{w-D~^i&dFC z;|8wBv_8$44@nOU{jxL{e{hDaS;94cW2oIfZz*CC)&NVe^A;qc379Niw_IWyg>EUt z+Q^yoi(aqaO_ayLp_>P*7cn-N-c3ks7A5!n>hqoKioo`-Z3tULzxbsAEG9>UP z=C>_|L|i8-S^NU~cAUNu>wd{>;F)e&RpHA}h;aT0#yAhB99>0d=kX;L+<2wP%px-? z=uOiyhbt{uM+&uCbhuY`20c(W(=sXXc{-}5%1uyKVqqYLmVcr|fd3%DXsqY^vzKPH zq;v~27fe}pdQwRvDw)LrPq5eI*_vDGixs<3+0pl%6Q5g`?z4!ZBF`V|-Z*}Au->Ze zr&C#UbwZAIX)|g6_BF%r{8vIbY|Uvm9+wuGwVJKm9o zOI#^Glq>B(eL%dbla=V?QDm=(x4%86KTflrw1_jMg=g6Ig(dpQ!oHAkthvI^=~Ri# ziq`b*dwb7NExClRUp3sxU$Ye3n{g~SU>w+DW`0bfY2) zo-Nj&xOd3U!=^0CW*qi*V!=jY@crs3G(3%>A4cr2^DnXZ1@cD;6P5@KB1{lySF+9Y z=($(VcvdCpD%nt7`c!dY;R#!g%Pn^+Z{Bs15(4L~KH$kHWSx>`SfTNLIDeHlTJNNvzb{vuf?H?lobsh#BmGZsRt7UI-({$&OV z)TDw6dO;0a06}+ts4U~yl+LIB`-=aI^`3$qj{nac{*U#b0H&qUUv%MCHf#+7+On0k zP3yh>w5IoHHvJs~^2;umrg!4Bq28S7Xr%DpQ&W&*8r>(o8o#gmW>7gGcd<Hh}gt1;~TNlG*=85A)48Y1k~J_1W1m^SVYRmNsc< z!$62E)OveYh)tr&*22lIE=7VIdBZlcS9e!8&3w~_i^&J4-ERy|J$=it6W8AuG!ux!V~%~I5ygwK z^c+Zq3AzV||6I5K(?=nAPE6Bisfb;7V$#ArKO(De%VNTe0Fic z<*)%iFlT-m?nHYc;h>--FR_Ms%{8=DPU^h8EZF^G2zYX8g|@0hGXo4B7u57<%d`N5 z(28^RQEw>!q{BtqaeLPweex{2!5bn~4Hu>YaQ;6h`FJ-KobQAeV*c`2K-%`_u*cUN zDk;ZKTAC{)_*c<9+b42X@a0d_=_e!CV%0*TzVpfnH#=Hx-5*>{SVlc8LF_l!44^E8 zg9yjEtyrJjeDA<~v(@$x$!U#e8|&8NJHD=Vk^ecg7#f-2$Wqa}35>)s*x!?FDW&IF zdW5h+0VO52&>dwuWf__(AL+Z9hJ^eKS1U;AHD_HFnf)~9#|oniO6js2MXU+SrEUjb z?|mYTDl!-@{SKOkHtRPjbZdcdwv%WM+wXh<8s!Lr=TFA93d!L=1<~TC+K3Dnw{32cen}_cUGZocqDDHV^#Y)$2WrCrEs2xvD6#xaDC4C6626sK|YS z6m8NdrHDQ()JFR~Dd1#&R2Q)tY`>{tGQyi$UEDt)pN}3a^V10pa%y1w{v?a5{wBNJ z`*)o6f~z4Y0(0AIfaDAO?gSp$`0C!X_6idFgv?Kvf-Q2_88GRKc=c{NvQ)Z{+8}c{ ztor8VzUq1V+k9w~$Y#RIN{qp>RTQTP03veTFpth)p!ma!PdgInF+b=k37X<$SL^Zi zjnX`A)ejXv)5;I0|2XHZ`p_fuszOQFHK+d?LxFr@>?V->7+^CB0ZLR+x!?SZ323K zko=fDY2A6OB|U>W--n!>C5G*Jnw@dTVBnL@I$f$|!Oe+nstD=l{ond)el3Uu995=Q zcrH+I8&SW+707N)mFdBoOIfv=C5)9HzqfdEEH!A-x}nBl(8G$wRQQHSKCrkvB!$0Q zfm4k1O%v2EX!xRQ!T>_F*mdt_Zh-*QGA|C4f;ao~F9{;G`_suvW6SH6U#N*L+OssG zANk=tLM9!n=#r9b-7GWuaoK_-)@52=aFXcf*%J#Zz8+ELuCa0MQ^~Kd0^J6dE8P`Y z(STn@wv*nwskV2sPiNI^Y${S z-ciUS?2I1y+Yt;VR>Dw_oajxDr?yBJ55(z-%>s*)Ty!0+px97{i_Zgh&(u~HF)~w9 z^?DeG(FtgIAEXVUt9vi%hDD4(nbh`KIr~J`(%AF9WphlP09ww|=-6g(l5Qht_bF&b zLisnwOC6}Uc^i$zYt{+y!+YW&d;RbQ#~f&b96CJ(=_6FE5qwWh8jgAuidQ1me#)w-3uk)V zYPp^}SESQ%=il zWXen;#0gP~Qv_Q^59L@}Z=hULByM+mI`U||ND3yCiDe9a87jo1Aas;qhbI_jCh2NKGrlttXppi|;xeC}Kwkw|J}VaL5%Z%TDQI$9N-ho8Iva zTYWpGHm0&29VqJ7wBSE0&iK@NfpB2!wC;_G4t)+C%x}+A93X|&e+(6mS3B*o(X34g z2XGCKVa0!QHjb6H#-w;Du&&4V(a7FB$0gK!j)`*kTR~k@P{Oq;ImG#@rWfIjOg#Ug zt%lAN14-ktkE`wO74-fc1w6VLo-@R{n5AXe0dw8VW3M07R+bjU&aGL#R@=hg=8VKz zpt!RMt+-;nld=pf`N@kl7+#;e#^e>dF=AjDJHwk*H^ScZx$Bd0%P7g?UxIB5!`^98 znX4m0p5vBxIZ9xM*B;q0+5l>oPJ@*vXg znmUBP+HdYWGUmCMdIq?b59Ap;H?U@-6S4=O?7O-}+6pOC_eVqoFA_wwy+Oal3{;|O zn!1w|Tjg6iuFQE_s*k+a^HbNf7t`|C62FK7k@2N`dTLdTc1-FULbb7`SC1YuU@p&B zUlq0tuz6NgE@zpm&~w|@maLJjfhCd}{~JN*TWPAfucGSY)gUKQ9NnGJ;<8b1?F!_A zkfMPko~k1Zxh`&dTW|^Vx|37o z=XRToXQC@Nwtq%Al+*1$A_&kG?GggWp{<}x*kzNcf8Up##7d(l!z}_+QrEA)XH%o>Kf&nGw#a<0)4tmw1rw@rDwqBGzIZ`6&@NKD*eW&R>S*3gwu@$uQd3U zZkmBiwkEvV`hHab-CugyYqiGQPOQG8Q%qH}H4@qE~_s_z>Vz9PkgdZKVI?pwGf5v$cL{Ic^NpGQJmHdxIu6NL;SaSdO zfA;bE-wT&(x#v<^Q|aG_3@AJINJelWV^70AHBv z{OL?F%>(^=X%w{u%JeS2TwqL-1>oyV+6p+Osk@fLd zGAT~-EjcbyadsQ^H57nMK&GncrZ-NX-oNyRW?pis&?T1&oyBziAyaiE`TQ1mD8Vbx z{(y0QQ04VEY$fn`XaSqgXI4>19`~os6pLjYDXci`%BnFZ?gGRl;%xbD z#v^RIF!W<$#Nh>O&tik6rf6ZOXuwV1;Jg35qXMsw8Q{9V-UwbFSH}k zT(;67t}2+-Z>!y{zt@to;7ziezK7vO1XZR@<_3Pp>C7~FB8!+Thh*Te?A49mt?2GD~|$0%HzY?TcsgPrKh`@d*98fa&K z2NSgnD}Fgt+_1r!Hn`rH@>CjqItwW?e~+F^e@J-f^?2u?zf#&g9R-OG@CqB*n%~JI z37Kc0&C16<^-?oInOJ1W7RT)q?dLTl*tZR`^SB}w%BbE5!dtLucPYTcXE_8oadk@% zYi=UntXWT?*Je@?LLmyR`W`l~IniI>OH83ITivwId||=g5|hf(XE^%5l>xTc9GZZ4 zlo`btmWS5P1WXY_2_^KqW&yb((#fsn*HU&L#TZX47-8HE6f15vjZI&KJ+os!zJ;fK zxF+ADVb-Cbps>L$AIi8mzC>-6{-B~m_Nb#J^%t}Nc~E0@fz2Xx`eiuyu> z_4s4>N^ePQ{WNo@)FyDTs|9FXS))%&4P(m=X?z66UPTWsK>H!Xg*m{rVm<_4pMo!0 zzOtHPy8ETA+g?6%d=IgqINaWx1+|&^Pxu0CJ$^-o&rQkeXRy| z%f!l76oWbpXsbtVE8Mz=Q}9_q0G;!|r%^aO=LETW$Isg@eXomZCJIS5})Ut``l zkFeod{soFhmi_vUO>fqUUoo~{yT68&H-XZA|eB9 z1-je}W8oI@KD%e=G}_IIe7NVNpo%_}M5`=AD?hgo9MSFu?eu95%ko}QJItl2L- zIHJisb@N3XTu44+byU7GB?vl>D0Q5BBkX2e{{IJqvF3phLFsJcH912h{n6i;>p0OAM$ z2uPm`Je>1M1?vd^KX2)SmB2g9J1lulRETV`4)V^yzW&~B5HBJvx#i$Iwc^@ZNEE|P zh_}I)%jQzC+iSJxX#40o+8OEhQ_}rEtvFZiJKFVd`w6Rh2S^ZEXi}HfzNx23n41 z8e3&p>vQ_5jR(pu=PA{J`*ri?uLV|G8_v?;&S3IEmF9z-y(rBEEs9EP-GJ7zgIv|j zO+z-Cr$4^rrXac=K6q$AIezli6Yt=dh$q&=R_C7aRmlK@C|wF@W}eaw)}PI`km2{W zi24rS{Kv%d3-|+x$*SPblSYOZFBZMt;C|#8DU^G8?5p2QRJBCXnXlqw+5*3o7wb?^ z)L#@70K~rM8Da7(ygz{5AY9cy&tsF)ADLg|pB?oB{ObxL`D)!=ByqaE*C@eZv-eNcleNMN7z0J1L-Jl0UhJW|`Jt3V)cp}RIzGSVzT4=X-HwzfthejH$s-K0~Gu5vN zwjO}ySJT&_HeAKCvpC@1;whhJ6&WWM;+mWw?rzin~vFKKMvFC>~8@#9KsbCG#c=8bR}f?mhq~ zABAZ7`ra3NP?a~CBttMT*YulYM~f%(o8e&fCx(C8at?jg9g>$&C>&y|>GiY@Yb@hy zT@6t$l!DCD1}X?NCgw!&zM0FX-<~(RKbBM(PFJ<~q#W}AIc#ufb%3;~o4)K6Tgw1k zDcufsP_|8~jemHmx`y+F9}{CX(E=Kls`2mWmLrdXJWzm<7VqHdt*RS@Jo(t6lbDzoxp;D@(P+V^wQ zuX3e$_Qif-Xj*zAz$-Ov&`&5^fxeQ6;1v>ur18D^k4}4!vpnO^Z-?rADUN&1D{N~n zl<)t@@JfrVWIzcbUE-^bz#QaZ^j2SS@Gz&^zL|LVD9ws*Yf7t;as0SIc*qLgQo@f< zVnfTcKf{?xB)z|FHp|RkHvrL@~&KbS``Hd8>i6>8I2P`qXA8eZ$fTR+s_4>T&J73#>di zC~^pZ{nxS+Y$vN2c6MV7sH$ve0N}C)p%q#H;yg-El)%b(X3z@|O#gd_|NFfsj}C88 z^?U~zpp`}K!98FHtlb2f<$wLbJNkd_5u9{3$n?Gigi5(^K_ED z^&S*d9_QB+$QOe@5nF&Ea^sP(io@B1GBVP%lrPL%0Uz0A+5}0lu@7@(DAU6fT&*Dz)PBZG z6yGql{q*~jftgqxpAsM0DS?R|ITkBcwVmr;F>r5&XVW8jr#I`yv15l+_V7~e@I{*k zBW>GlX_A{+h$TK!YpALBmI=-eS5VTJGxS@fM0{;ixfbL>VTxd2v>UCB&CEocME0UL zwYyaZ0p6wLKQ9WV<(F4JT7iYJ9|>7VT(qKmPe#MRkgb+^e4nn8cqI!lcK5Qq<8O>-@FH6D zIy&6l&9gC7@2i8F;^qxd`u-au74~Nc7$I6MHrvkLT)~`v^a(`m{-A$GP)#A;E<_pX zSv?c^zWNz&h?BP5Uko2vR^21A7_N{V4&q zE#L)uFQKWSj$1?MCL^?*u9H2C0KRk+oI3hoW6~E@bC0Q`CJ_ZqV&FKtum5-$0=HU5 zwxwS^ci;`>RUUvKjEzXznVBDH3Yy+T0HN?^nkO&iH9#?5<*K$a#KuCtA1adUy2Al2 zt_?>8w|M3YdKBbuh+I)`vrQ7396g^nXwgc6EJ;y0db;AdqBm=eYI)xg?A$rb0luK8 z^5=7_ogVozkHRG#$f0)ZY2xYm<#Bs#^v;&U&}^d>8Jo=M;~pBoJScKrSk(xWG^2Lr zqz%n22`68{ox=1^dg~-avA5DN{C4+j$ zVwvBGAEW1TluUzcDKAzs)g!Fzg)O=1#k=aV0xWZXSXC8(Oqc1R zDS|4*DWrq%tR@DBr5~aBzZn7j$Xtvo@|AqG#pi6>5YipDypzbv;#8fT(b`QIgo{B& z)K78kDflKpx!mkS6V_T0A5jf?gS@bMA9^hZQjuIvgJ<`K6v(HRLp6D#N;u!^)v6@x{Gq@~}Cqk?ZCIs(8b_($k>n(Y63PakrBX`LkVU z(#9y2goRc0NY%zN!_3Fmc^xXD=UrP)DyP4D<=_(glszK|pNM08&XtokY3nu#-Pn85 z22=PK{aBvj4k@>_>%(rF*ZHAS?OdE7W4r^dj~IU8uxJd9vHdp75P6bKVxIgQT^*sS zi&ydnUYU}+*52{fLQcOa9vPuMU=|q1TK+RTGwazJ^wMOv@s#}d$LBDiipx3Qv#~^Z zw*`=1CcE|h&6Pw;^U9SvsYo>@ywDAmUA(C{AB=Vqp&`8uD-np$f;gdK;CFLTOX+w% zTc^vXhn7!B1wTBZ*bfyT!#G;xdg0{V;iIOsNor7&^PLu7G~zta(;LFbf!bntch0Y}X#qF!8+QKR-#q81`Sx0JUMy=& zv8KZO#&c1ebWBXluSXgFgA++&%g-?>FL6TcW+^urq&=qUbCNT3`0bcT9YSla*|iC% zCQf~3Evn@8Hqo$Bker3NB8I)9QHigivAYt@ZdHDhwbe(zDGyEEhXUrZBte&gQBCDUTtiwB&kqBxK2wdHal85SttE|*n3*W?}6$?2y{Kv_)d-84=guApkz#Jj8=fM8#4O1=j74KW(C1LO&s0$9XzOwPF|E{6oZ!7GfjMP#H&7Ua^bhj2 zq|Y-iaE5yHo5J!R3q_Vp(moRhc2kVTZkY@@nMUPHnCU3s!J z7=jV63bo78K@5Xs&vZs|ne5BBKGw!`9YF2P7b^XKF!HoM04Ct#Uhd)ocad44X8L$! zEgY-uwJ%NB`6U8=I#HC*-Rny8UxF)!d?4~rKR3}i;XDBV3|lu6v9j!BS;YA%3T+R{ zErb6ig&w>{5@$emjffbFVILp;T&?N&65!yQCdlM%7&`J-S542Jy(%p{{-!@UJ zU01{6{Es}&n%?I{IlzCZ%R!6XBVT^biFYE{TWq!Sevu*6pxo%LfJsRu?gJx^n$HddmtBG$C}wSLy%RK_ z!NF_10+Ht)p61f@Q00U1)#aAOU$4{iM!d`?Tr-XglqET$qu<-}EtEWCUdb~LEL^Ze zeTPj;j%`>B%tfd;p3t<^eZYdPt)nc~kB^urr7H_Xw1a|NDr0COAI|U=NzxDwSMTEy z#6EO(6+--m?dN;+qS~X9M%LLLcKr4|y!#}#UW2fLfB#l34K$}a`4(}$1hr*C+r!HD z8p|KqwJ;zp4wzFEs00ITyv#i$Cp??`mK4G+omsXU%2!7ppt;(@bWB)mSUI8f2w!EER0w2^Ec{-)(z>^39}zdR)DB9sEMg^O=j+KEc`gde zOsOPHUi504rXkRJ$4x>ClEDnApfJ-<0sAn1$7v z?&AtW0+F*Ax|1gCM?^&-|L*<$ic4k3k7d0EknV=O5;-YR+ZkBWDLveTLpw!t4MP=+ zuA0{O89&hZ7)#N=kRhnf7H#HM))*~*HN#j#wlo^K9;=|MrJc^Gv`z4ih=z^STgT%f z;yG0aA%}JwjXzh&HCiS{OxmSA;ZClLLt!JW;rsf;x_3-GER&x+iXCj#blx>nta?hr z0+Alnobs(SvPv`ECgEFhSGYdr|NgCEJiM{9t2mgXKaPGUPK5 z?zsTq-@n^m8)IrVz2Cz|sd!BKe%(j!Q5Pyx)^56(51H@K35>DTgCR5uwqHn17%t-@ z`9e}gXN+*;P;i1`FxSO{H03qG-{DX!;&csI)Fyel>_J=T2^M?lr7Aqj55c_PJ#{*x zM4hm`_z7qXK*AGwxm~A2=@fJx7vww(PQgI|692|ySu<#K)Hs?>+bX;1470Lws5JVZ_b_tN zS|hfb!nXnMTG)QhZg$G8VVQdcr%yA2$;}jubPhxLzxOq&motagg#V;+7AIR;nt$Gz z&nZdS^6v7X9Z5LBsh>k1*S;8bilNan&INw|Evt^3p#lyUs)>ZjnLfAKGHABiB*)KZ zrUIbX9x4YS(31Um!oW`g>9-t@qr&2I`lybdKGk7}-uGh=`Y?SA*IM1S(cQh*PIIpO znKO)j!ER`|z59H?fT*R+ynd(1&cXU^)Sck)ha^XrzjKx>bI!H6TbSS7z{ZgJu8LVg zMAZ2QLn3Njas1UOamjhuHU13P6oPN~g<`UTwFx=K2 zJo&V`6l?{76fdvT^aE0d!#zJkO2U4f_#|6!QU;)S}lPEi0*i-5FqgCEf- z{GLOk4VuYLkpvBGc@q_LQTR_?L2V& zS2J#SEly463F$&ZK#TspN-GiPssKq#`mIwj+DmUflvI&LyxUk#`rWpOTnJFGkFenD za6b)YKcbZs!hOABC(4#F9`%I8FMMV%ynQJ-S{)Z#>ipqL8pD4ufNP@2d~QQIF{pYb zgvhLu-m$jGp&1KJ`yUko$d@0oIZ%IjF3_kDi^{GNqbq$cCR{K@nfq8$w2dA;yZz`IPg`s$Nr8)%8CL!8O0CZ0vNJ^! z8nPLgZl|K@km4^>NA7UqT~!GY!~=Y18OD;UB2>V+5q;(ZEr?%%DnKmL7bR}T&K~aL zqfVF;_ZRZlV!OWIv!S^GYpa0I6ZS)E53!ak8=*WK{JYMlv9s)4hP90ug-aHmWm9Oc z#{gPxg}ti+1fox%7?~!t7ib1Q!u37i!oR&nJ7;d|B~h~c^ArM0gL7A;`{JAo@js1~ zTkXu!J*Iyi%`kHaQO+Of4w^fIwM_OI$vl7Zjy}p7tAFIcV6?CaM6VS?yaQxCo;Fx2usMItHSaE`Y*KS9>DS zGzFOUE3nHm#!IJcB=RTFCvLuleo)TOUfh^MON5L&ix3Ho(WQ=St$*VhDJ)3UFu3GmEfp}Oud7QaQHY5ek~|INtQ&p{ZNBAZ zv!K1)JA7rnRKLJ-yvju!rkFx`CilC2|5Tsuz^7 z-Z~T`(D%jKR7OTYjf*};Wa8nkrj0PjPMRfmsj0Wg@wDIx%p*zxL|C7X6zc!PxKvIZ-D!)}=O0?G=&an;b>7Z^yL*yOe70Oji?H3K?WjgmN-H zChjir-XDz|Ra00jA`gVYv_BYW)68(lwGR)D(=sZ= zLSHgr%=+_}jvd!hkzk5NWxxj|9!++3l9JVT6mPC*Ro~UR2y40B6BvKSIn1I-!_J~- z0ICcuy03xTo2~B!7ZRIr*AKKN#R+rT?JRwM{pJ|swm}p3mIHo_8tk+i93H9}%s)7! z6(T*(u+e z{Pqtuo0OjWgP}~* zk_l{5$B2kCCD}~#LtVOGRm5dFH0Ph0e`Jo7+!AdQ{f;T}18Qi)4|Hz`?RvK*Az+~Y z2O}`o_$;yD1|7?mG-Y5DFlr@7AHl624Bbi2wO^UM-zLgIcgy8N1@GlJhH24Fi8{TM zxNWako^JP_cWfUkKBn?3O*q6xGg;oxz25tsZ@cmwB+|&E+1J;?y4Cg_Al={%na2r+ zCj-K2tFMN3Ayes{0;}v;Zl*}t`{j(n&uMM%n2OH_5fsf>#NBJQJ*<$!>Aa38kJhbK za`16o4~$QgP z0+%;;vl-Sn-BoiTnkh5>H48rcZidTBHn}zWnPwY2nX;#!G4J1gEFoP;Kw(*J!8p!S zgeAHLXG*<>8bXPinS8|l%$gbs@!w!~);! zl}BN#oml=Xt`LT+dA{ZK%E}7&a`W=33OC_oejo)GY7*79^X%IF!Tvj)tcifweBX0X z4hlE;R|X^J2XhrZ`M%>06m=iqyY!!$zIjzMEF4!HI7BeSU4URH^JKohF*tc~%Vz_7 z^>bZDAc}ko zwH(PC;oJCl$~-NB)!P)J-lH3B{n<~U$CTSd>7PPj+&8ZX6)OOt!s1?x0%(wj{sl7V zVoW|^8EFeG$p6)1Ag%RbVm?gzx;*x24K1ditv$6!=}r@-PWmV&+% zK_EC#17w}P%!kG@;J?Nswa3ol(vYA0`$j$?+|U)sVawUg*9G@koXsebu0_ce3O}TB z%tj$Lb@_zgqs(nF+YU@4!budDggo$5@`H4vzZu4lRQe6ysvROCQheerud|;6&t!P9 z=_PRh5(S;%BOioq#=xWYg;tIJKvxOLv-8$xC*4&?f>K-dIp2nU48@M0#cW0m|j+gKo6VgQ|N8g z6p-?a2N~eSHs@rau9-XsUfbw(^n-Q1N|f(IS8ws&JRsICfRCCh4sjpD9gAtnfaXBlw`IL1c)pC^k--YGYfJsxcq1 zsrGDFVb$tG4caD^OqYf9fgJtk;7|HV5GI z{kTh!A+#$UF24UE5_4PxS`Fx&+cq$nB~-llgHb@Z-T=$%O8v+I?8G9U#`;TvtH!+l z+yhhlagM&WMLG}d`EBYSw!tqi7=dEWt_4;L2Vj#7=xw49X3(;a67+W@naA%yr>L4! z9)ctjdVsAW1a?PLTG;;%Xa@oP(|vSb1$fA9$&r$8khjMzSAFDT_c7#t7#bbeH+-bM zP1Ak(R`lhUNmYP*%3M77r$26zEpLd)pYvT;N5^p6RtQID$I;vmiq{{ zN1m=(a;)>=Uub@MJ~;?@Vi;KbV1~Hs1vyn(fMw5_nQeOsVXatnsro~Z?209*zxmb6 z2T;GEf9&zY?zfMe+MXQvg*d;wgMm|nMH3n@n&zRARrj;FtDx+SEos23i$mWtHIKgc zJjAbzQd?OZ^vmO3PPpn%ftOCd53;wZICP-cPAiJJ^yor`~Rp zEL2L+9$fqthD8;F%l$J~DG6r5UDHkMb`WQyv#*7gsml$&{$0&F!A}ztk4nx`aai2V ziZJ&{bCJi3MR}WxZ3gs5%iJPuaUdN)-J%8{NAs#{(Bhu4<^ytR<+b)uB(gt}5_($Kt=f|nU53Z;T#-uz2 z6}`{YDvUn+EGr^!CuSYjLQ_3C&E?LpFbgSeL1(OMK$b4_1s|nhbIbxy!Rs(IVaI!; z%(QT$Z<|1kFGdp;U&^6j-@|@SIW(dNdBzy}@135A#f* zO$zQJZ$Nu_-x$sz4AiVc?f-QP^#6BH+8JUQiFxYTyLu^sFNw_g_s;pId9FbKZ6<@E zz|VkcdOHiKuR^;`djtS@2wH&R_V9n73L>^igkcBie{aqA2b*+A7VIyV*)wc#1c8X< z1byToNK5fuQX0$-`)9QNceu;+4)k{9Yc zhGhKDcq(eX1!_#DGFii;)*iXDZ!y)Xz0WKx1<2V&yS*{9wmc_V`^lkZKu>UkS|Ae|?=aK8va| z4T10-N7IPFwDpn_;e5ukF&T^~%00|n9`MQ2DTjGSJ6)`&Qe!aFjYw~m7fRTwN0uri zSaj_pe+ueE@*>W%nUq1kSi*{;$IgTdHZ%I`6t4 z;8*l4y(n!NeAb~F&xmuomniQ@e*@&G({yi(<4N^QlYby_{|9oqiy|rJfG!NTU8keb z?mPDpzJuSrFyeLA>~_~;+nKl9@4{KjzXazecP=kFKUb0`!7$AfWe&44J0IewDILtJJY+;HLn4*vWU_ zbTgM*lg3&LXLd9Q7Hg8aupIeH+j{QcWL?LXd2a8; znL_RBpkS6l?xt0xfW9=>QaPEW$*50B4u0v`=@?kPys zV1yN)ezPeQx12{&sXQ(S2iCNCQ(G)6?u!J?bZz_uYNGNQ{GTb7p+fm8<8kbw>sBTi zux(sx>EFFjeSa)1ELy>;;Dtmk^p2;6ntxJs&c`mLVFbbrvbi%kEyQU)7$@di#gC2L zSQ4P}dg)|G&(fPDbn;5!+dk}nJ;ZYgbHQW0osp$Fny;7u~LDx*1+Pg^dtj|2#eVH$Q zX$DbceXVGyY)tI@*bBRX+>3#I$uu8eQ@c*?ab1$aZdWWgHki!P9vEf$PB~&GU1gtd z8$^`iLQLOBkm78|fgKe_q^56w!VD5}l%i++B$6_r(xYPUrX?Z(VCvu>7L5Y=Gw(R4 z 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 delta 77267 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
  • dLA-@>}F*5pblV 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 U7qFd=5PqxX1i5}ZLm#{U4HRy|h5!Hn