Add automation override option.

2024-05-20_merge
tecnovert 2 years ago
parent 3241616d68
commit ac16fc07a4
No known key found for this signature in database
GPG Key ID: 8ED6D8750C4E3F93
  1. 115
      basicswap/basicswap.py
  2. 48
      basicswap/basicswap_util.py
  3. 6
      basicswap/db.py
  4. 7
      basicswap/db_upgrades.py
  5. 22
      basicswap/http_server.py
  6. 6
      basicswap/js_server.py
  7. 27
      basicswap/templates/identity.html
  8. 12
      basicswap/util/__init__.py
  9. 45
      scripts/createoffers.py
  10. 6
      tests/basicswap/extended/test_scripts.py
  11. 16
      tests/basicswap/test_run.py

@ -52,6 +52,7 @@ from .util import (
format_amount,
format_timestamp,
DeserialiseNum,
zeroIfNone,
make_int,
ensure,
)
@ -143,6 +144,8 @@ from .basicswap_util import (
getLastBidState,
isActiveBidState,
NotificationTypes as NT,
AutomationOverrideOptions,
VisibilityOverrideOptions,
)
@ -157,12 +160,6 @@ def validOfferStateToReceiveBid(offer_state):
return False
def zeroIfNone(value):
if value is None:
return 0
return value
def threadPollXMRChainState(swap_client, coin_type):
ci = swap_client.ci(coin_type)
cc = swap_client.coin_clients[coin_type]
@ -1189,17 +1186,57 @@ class BasicSwap(BaseApp):
try:
now = int(time.time())
session = self.openSession()
q = session.execute(f'SELECT COUNT(*) FROM knownidentities WHERE address = "{address}"').first()
q = session.execute('SELECT COUNT(*) FROM knownidentities WHERE address = :address', {'address': address}).first()
if q[0] < 1:
q = session.execute(f'INSERT INTO knownidentities (active_ind, address, created_at) VALUES (1, "{address}", {now})')
session.execute('INSERT INTO knownidentities (active_ind, address, created_at) VALUES (1, :address, :now)', {'address': address, 'now': now})
values = []
pattern = ''
if 'label' in data:
pattern += (', ' if pattern != '' else '')
pattern += 'label = "{}"'.format(data['label'])
values.append(address)
q = session.execute(f'UPDATE knownidentities SET {pattern} WHERE address = "{address}"')
session.execute('UPDATE knownidentities SET label = :label WHERE address = :address', {'address': address, 'label': data['label']})
if 'automation_override' in data:
new_value: int = 0
data_value = data['automation_override']
if isinstance(data_value, int):
new_value = data_value
elif isinstance(data_value, str):
if data_value.isdigit():
new_value = int(data_value)
elif data_value == 'default':
new_value = 0
elif data_value == 'always_accept':
new_value = int(AutomationOverrideOptions.ALWAYS_ACCEPT)
elif data_value == 'never_accept':
new_value = int(AutomationOverrideOptions.NEVER_ACCEPT)
else:
raise ValueError('Unknown automation_override value')
else:
raise ValueError('Unknown automation_override type')
session.execute('UPDATE knownidentities SET automation_override = :new_value WHERE address = :address', {'address': address, 'new_value': new_value})
if 'visibility_override' in data:
new_value: int = 0
data_value = data['visibility_override']
if isinstance(data_value, int):
new_value = data_value
elif isinstance(data_value, str):
if data_value.isdigit():
new_value = int(data_value)
elif data_value == 'default':
new_value = 0
elif data_value == 'hide':
new_value = int(VisibilityOverrideOptions.HIDE)
elif data_value == 'block':
new_value = int(VisibilityOverrideOptions.BLOCK)
else:
raise ValueError('Unknown visibility_override value')
else:
raise ValueError('Unknown visibility_override type')
session.execute('UPDATE knownidentities SET visibility_override = :new_value WHERE address = :address', {'address': address, 'new_value': new_value})
if 'note' in data:
session.execute('UPDATE knownidentities SET note = :note WHERE address = :address', {'address': address, 'note': data['note']})
finally:
self.closeSession(session)
@ -1209,7 +1246,8 @@ class BasicSwap(BaseApp):
session = self.openSession()
query_str = 'SELECT address, label, num_sent_bids_successful, num_recv_bids_successful, ' + \
' num_sent_bids_rejected, num_recv_bids_rejected, num_sent_bids_failed, num_recv_bids_failed ' + \
' num_sent_bids_rejected, num_recv_bids_rejected, num_sent_bids_failed, num_recv_bids_failed, ' + \
' automation_override, visibility_override, note ' + \
' FROM knownidentities ' + \
' WHERE active_ind = 1 '
@ -1240,6 +1278,9 @@ class BasicSwap(BaseApp):
'num_recv_bids_rejected': zeroIfNone(row[5]),
'num_sent_bids_failed': zeroIfNone(row[6]),
'num_recv_bids_failed': zeroIfNone(row[7]),
'automation_override': zeroIfNone(row[8]),
'visibility_override': zeroIfNone(row[9]),
'note': row[10],
}
rv.append(identity)
return rv
@ -2186,22 +2227,6 @@ class BasicSwap(BaseApp):
session.remove()
self.mxDB.release()
def updateIdentity(self, address, label):
self.mxDB.acquire()
try:
session = scoped_session(self.session_factory)
identity = session.query(KnownIdentity).filter_by(address=address).first()
if identity is None:
identity = KnownIdentity(active_ind=1, address=address)
identity.label = label
identity.updated_at = int(time.time())
session.add(identity)
session.commit()
finally:
session.close()
session.remove()
self.mxDB.release()
def list_bid_events(self, bid_id, session):
query_str = 'SELECT created_at, event_type, event_msg FROM eventlog ' + \
'WHERE active_ind = 1 AND linked_type = {} AND linked_id = x\'{}\' '.format(Concepts.BID, bid_id.hex())
@ -4158,6 +4183,24 @@ class BasicSwap(BaseApp):
total_value += amount
return bids, total_value
def evaluateKnownIdentityForAutoAccept(self, strategy, identity_stats) -> bool:
if identity_stats:
if identity_stats.automation_override == AutomationOverrideOptions.NEVER_ACCEPT:
raise AutomationConstraint('From address is marked never accept')
if identity_stats.automation_override == AutomationOverrideOptions.ALWAYS_ACCEPT:
return True
if strategy.only_known_identities:
if not identity_stats:
raise AutomationConstraint('Unknown bidder')
# TODO: More options
if identity_stats.num_recv_bids_successful < 1:
raise AutomationConstraint('Bidder has too few successful swaps')
if identity_stats.num_recv_bids_successful <= identity_stats.num_recv_bids_failed:
raise AutomationConstraint('Bidder has too many failed swaps')
return True
def shouldAutoAcceptBid(self, offer, bid, session=None):
try:
use_session = self.openSession(session)
@ -4195,16 +4238,8 @@ class BasicSwap(BaseApp):
if num_not_completed >= max_concurrent_bids:
raise AutomationConstraint('Already have {} bids to complete'.format(num_not_completed))
if strategy.only_known_identities:
identity_stats = use_session.query(KnownIdentity).filter_by(address=bid.bid_addr).first()
if not identity_stats:
raise AutomationConstraint('Unknown bidder')
# TODO: More options
if identity_stats.num_recv_bids_successful < 1:
raise AutomationConstraint('Bidder has too few successful swaps')
if identity_stats.num_recv_bids_successful <= identity_stats.num_recv_bids_failed:
raise AutomationConstraint('Bidder has too many failed swaps')
self.evaluateKnownIdentityForAutoAccept(strategy, identity_stats)
self.logEvent(Concepts.BID,
bid.bid_id,

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021-2022 tecnovert
# Copyright (c) 2021-2023 tecnovert
# Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php.
@ -192,6 +192,45 @@ class DebugTypes(IntEnum):
B_LOCK_TX_MISSED_SEND = auto()
class NotificationTypes(IntEnum):
NONE = 0
OFFER_RECEIVED = auto()
BID_RECEIVED = auto()
BID_ACCEPTED = auto()
class AutomationOverrideOptions(IntEnum):
DEFAULT = 0
ALWAYS_ACCEPT = 1
NEVER_ACCEPT = auto()
def strAutomationOverrideOption(option):
if option == AutomationOverrideOptions.DEFAULT:
return 'Default'
if option == AutomationOverrideOptions.ALWAYS_ACCEPT:
return 'Always Accept'
if option == AutomationOverrideOptions.NEVER_ACCEPT:
return 'Never Accept'
return 'Unknown'
class VisibilityOverrideOptions(IntEnum):
DEFAULT = 0
HIDE = 1
BLOCK = auto()
def strVisibilityOverrideOption(option):
if option == VisibilityOverrideOptions.DEFAULT:
return 'Default'
if option == VisibilityOverrideOptions.HIDE:
return 'Hide'
if option == VisibilityOverrideOptions.BLOCK:
return 'Block'
return 'Unknown'
def strOfferState(state):
if state == OfferStates.OFFER_SENT:
return 'Sent'
@ -202,13 +241,6 @@ def strOfferState(state):
return 'Unknown'
class NotificationTypes(IntEnum):
NONE = 0
OFFER_RECEIVED = auto()
BID_RECEIVED = auto()
BID_ACCEPTED = auto()
def strBidState(state):
if state == BidStates.BID_SENT:
return 'Sent'

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019-2022 tecnovert
# Copyright (c) 2019-2023 tecnovert
# Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php.
@ -12,7 +12,7 @@ from enum import IntEnum, auto
from sqlalchemy.ext.declarative import declarative_base
CURRENT_DB_VERSION = 17
CURRENT_DB_VERSION = 18
CURRENT_DB_DATA_VERSION = 2
Base = declarative_base()
@ -421,6 +421,8 @@ class KnownIdentity(Base):
num_recv_bids_rejected = sa.Column(sa.Integer)
num_sent_bids_failed = sa.Column(sa.Integer)
num_recv_bids_failed = sa.Column(sa.Integer)
automation_override = sa.Column(sa.Integer) # AutomationOverrideOptions
visibility_override = sa.Column(sa.Integer) # VisibilityOverrideOptions
note = sa.Column(sa.String)
updated_at = sa.Column(sa.BigInteger)
created_at = sa.Column(sa.BigInteger)

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2022 tecnovert
# Copyright (c) 2022-2023 tecnovert
# Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php.
@ -238,6 +238,11 @@ def upgradeDatabase(self, db_version):
tx_data BLOB,
used_by BLOB,
PRIMARY KEY (record_id))''')
elif current_version == 16:
db_version += 1
session.execute('ALTER TABLE knownidentities ADD COLUMN automation_override INTEGER')
session.execute('ALTER TABLE knownidentities ADD COLUMN visibility_override INTEGER')
session.execute('UPDATE knownidentities SET active_ind = 1')
if current_version != db_version:
self.db_version = db_version

@ -17,6 +17,7 @@ from . import __version__
from .util import (
dumpj,
ensure,
zeroIfNone,
LockedCoinError,
format_timestamp,
)
@ -25,9 +26,11 @@ from .chainparams import (
chainparams,
)
from .basicswap_util import (
strBidState,
strTxState,
strBidState,
strAddressType,
AutomationOverrideOptions,
strAutomationOverrideOption,
)
from .js_server import (
@ -37,6 +40,7 @@ from .js_server import (
from .ui.util import (
getCoinName,
get_data_entry,
get_data_entry_or,
have_data_entry,
listAvailableCoins,
)
@ -447,10 +451,13 @@ class HttpHandler(BaseHTTPRequestHandler):
if have_data_entry(form_data, 'edit'):
page_data['show_edit_form'] = True
if have_data_entry(form_data, 'apply'):
new_label = get_data_entry(form_data, 'label')
try:
swap_client.updateIdentity(identity_address, new_label)
data = {
'label': get_data_entry_or(form_data, 'label', ''),
'note': get_data_entry_or(form_data, 'note', ''),
'automation_override': get_data_entry(form_data, 'automation_override'),
}
swap_client.setIdentityData({'address': identity_address}, data)
messages.append('Updated')
except Exception as e:
err_messages.append(str(e))
@ -466,14 +473,19 @@ class HttpHandler(BaseHTTPRequestHandler):
page_data['num_recv_bids_rejected'] = identity.num_recv_bids_rejected
page_data['num_sent_bids_failed'] = identity.num_sent_bids_failed
page_data['num_recv_bids_failed'] = identity.num_recv_bids_failed
automation_override = zeroIfNone(identity.automation_override)
page_data['automation_override'] = automation_override
page_data['str_automation_override'] = strAutomationOverrideOption(automation_override)
page_data['note'] = identity.note
except Exception as e:
messages.append(e)
err_messages.append(e)
template = env.get_template('identity.html')
return self.render_template(template, {
'messages': messages,
'err_messages': err_messages,
'data': page_data,
'automation_override_options': [(int(opt), strAutomationOverrideOption(opt)) for opt in AutomationOverrideOptions if opt > 0],
'summary': summary,
})

@ -517,6 +517,12 @@ def js_identities(self, url_split, post_string: str, is_json: bool) -> bytes:
set_data = {}
if have_data_entry(post_data, 'set_label'):
set_data['label'] = get_data_entry(post_data, 'set_label')
if have_data_entry(post_data, 'set_automation_override'):
set_data['automation_override'] = get_data_entry(post_data, 'set_automation_override')
if have_data_entry(post_data, 'set_visibility_override'):
set_data['visibility_override'] = get_data_entry(post_data, 'set_visibility_override')
if have_data_entry(post_data, 'set_note'):
set_data['note'] = get_data_entry(post_data, 'set_note')
if set_data:
ensure('address' in filters, 'Must provide an address to modify data')

@ -63,11 +63,38 @@
<input class="appearance-none bg-gray-50 border w-full border-gray-300 text-gray-900 text-sm rounded-lg outline-none focus:ring-blue-500 focus:border-blue-500 block p-2.5" type="text" id="label" name="label" value="{{ data.label }}">
</td>
</tr>
<tr>
<td class="py-4 px-6 bold">Automation Override</td>
<td class="py-4 pr-5">
<select name="automation_override">
<option{% if data.automation_override=="0" %} selected{% endif %} value="0">-- Default --</option>
{% for a in automation_override_options %}
<option{% if data.automation_override==a[0] %} selected{% endif %} value="{{ a[0] }}">{{ a[1] }}</option>
{% endfor %}
</select>
</td>
</tr>
<tr>
<td class="py-4 px-6 bold">Notes</td>
<td class="py-4 pr-5">
<textarea rows="5" class="w-full" id="note" name="note">{{ data.note }}</textarea>
</td>
</tr>
{% else %}
<tr>
<td class="py-4 px-6 bold">Label</td>
<td class="py-4">{{ data.label }}</td>
</tr>
<tr>
<td class="py-4 px-6 bold">Automation Override</td>
<td class="py-4">{{ data.str_automation_override }}</td>
</tr>
<tr>
<td class="py-4 px-6 bold">Notes</td>
<td class="py-4">
<textarea rows="5" class="w-full" readonly>{{ data.note }}</textarea></td>
</td>
</tr>
{% endif %}
<tr class="bg-white border-t hover:bg-gray-50">
<td class="py-4 px-6 bold w-96">Successful Sent Bids</td>

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018-2022 tecnovert
# Copyright (c) 2018-2023 tecnovert
# Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php.
@ -177,7 +177,7 @@ def format_amount(i, display_scale, scale=None):
return rv
def format_timestamp(value, with_seconds=False):
def format_timestamp(value: int, with_seconds=False) -> str:
str_format = '%Y-%m-%d %H:%M'
if with_seconds:
str_format += ':%S'
@ -205,5 +205,11 @@ def h2b(h: str) -> bytes:
return bytes.fromhex(h)
def i2h(x):
def i2h(x: int) -> str:
return b2h(i2b(x))
def zeroIfNone(value) -> int:
if value is None:
return 0
return value

@ -76,27 +76,27 @@ def readConfig(args, known_coins):
with open(config_path) as fs:
config = json.load(fs)
if not 'offers' in config:
if 'offers' not in config:
config['offers'] = []
if not 'bids' in config:
if 'bids' not in config:
config['bids'] = []
if not 'stealthex' in config:
if 'stealthex' not in config:
config['stealthex'] = []
if not 'min_seconds_between_offers' in config:
if 'min_seconds_between_offers' not in config:
config['min_seconds_between_offers'] = 60
print('Set min_seconds_between_offers', config['min_seconds_between_offers'])
num_changes += 1
if not 'max_seconds_between_offers' in config:
if 'max_seconds_between_offers' not in config:
config['max_seconds_between_offers'] = config['min_seconds_between_offers'] * 4
print('Set max_seconds_between_offers', config['max_seconds_between_offers'])
num_changes += 1
if not 'min_seconds_between_bids' in config:
if 'min_seconds_between_bids' not in config:
config['min_seconds_between_bids'] = 60
print('Set min_seconds_between_bids', config['min_seconds_between_bids'])
num_changes += 1
if not 'max_seconds_between_bids' in config:
if 'max_seconds_between_bids' not in config:
config['max_seconds_between_bids'] = config['min_seconds_between_bids'] * 4
print('Set max_seconds_between_bids', config['max_seconds_between_bids'])
num_changes += 1
@ -152,7 +152,7 @@ def readConfig(args, known_coins):
bid_template['coin_to'] = findCoin(bid_template['coin_to'], known_coins)
if bid_template['name'] in bid_templates_map:
print('renaming bid template', offer_templates_map_template['name'])
print('renaming bid template', bid_template['name'])
original_name = bid_template['name']
offset = 2
while f'{original_name}_{offset}' in bid_templates_map:
@ -167,7 +167,6 @@ def readConfig(args, known_coins):
for i, swap in enumerate(stealthex_swaps):
num_enabled += 1 if swap.get('enabled', True) else 0
swap['coin_from'] = findCoin(swap['coin_from'], known_coins)
#bid_template['coin_to'] = findCoin(bid_template['coin_to'], known_coins)
config['num_enabled_swaps'] = num_enabled
if num_changes > 0:
@ -303,10 +302,10 @@ def main():
new_offer = read_json_api('offers/new', offer_data)
print('New offer: {}'.format(new_offer['offer_id']))
num_state_changes += 1
if not 'offers' in script_state:
if 'offers' not in script_state:
script_state['offers'] = {}
template_name = offer_template['name']
if not template_name in script_state['offers']:
if template_name not in script_state['offers']:
script_state['offers'][template_name] = []
script_state['offers'][template_name].append({'offer_id': new_offer['offer_id'], 'time': int(time.time())})
max_seconds_between_offers = config['max_seconds_between_offers']
@ -328,10 +327,10 @@ def main():
# Check bids in progress
max_concurrent = bid_template.get('max_concurrent', 1)
if not 'bids' in script_state:
if 'bids' not in script_state:
script_state['bids'] = {}
template_name = bid_template['name']
if not template_name in script_state['bids']:
if template_name not in script_state['bids']:
script_state['bids'][template_name] = []
previous_bids = script_state['bids'][template_name]
@ -405,8 +404,18 @@ def main():
offer_identity = read_json_api('identities/{}'.format(offer['addr_from']))
if len(offer_identity) > 0:
successful_sent_bids = offer_identity[0]['num_sent_bids_successful']
failed_sent_bids = offer_identity[0]['num_sent_bids_failed']
id_offer_from = offer_identity[0]
automation_override = id_offer_from['automation_override']
if automation_override == 2:
if args.debug:
print(f'Not bidding on offer {offer_id}, automation_override ({automation_override}).')
continue
if automation_override == 1:
if args.debug:
print('Offer address from {}, set to always accept.'.format(offer['addr_from']))
else:
successful_sent_bids = id_offer_from['num_sent_bids_successful']
failed_sent_bids = id_offer_from['num_sent_bids_failed']
if failed_sent_bids > 3 and failed_sent_bids > successful_sent_bids:
if args.debug:
print(f'Not bidding on offer {offer_id}, too many failed bids ({failed_sent_bids}).')
@ -533,10 +542,10 @@ def main():
'address_to': address_to,
'amount_from': swap_amount,
'fixed': True,
#'extra_id_to':
#'referral':
# 'extra_id_to':
# 'referral':
'refund_address': address_refund,
#'refund_extra_id':
# 'refund_extra_id':
'rate_id': estimate_response['rate_id'],
}

@ -197,7 +197,6 @@ class Test(unittest.TestCase):
cls.node1_statefile = os.path.join(datadir, 'node1_state.json')
cls.node1_args = [script_path, '--port', str(UI_PORT + 1), '--configfile', cls.node1_configfile, '--statefile', cls.node1_statefile, '--oneshot', '--debug']
@classmethod
def tearDownClass(cls):
logging.info('Stopping test')
@ -333,7 +332,7 @@ class Test(unittest.TestCase):
assert (count_lines_with(rv_stdout, 'Bid rate too low for offer') == 3)
assert (count_lines_with(rv_stdout, 'Already bidding on offer') == 1)
logging.info(f'Modifying node1 config')
logging.info('Modifying node1 config')
node1_test1_config['bids'][0]['maxrate'] = 0.07
node1_test1_config['bids'][0]['max_coin_from_balance'] = 100
node1_test1_config['bids'][0]['min_coin_to_balance'] = 100
@ -418,7 +417,7 @@ class Test(unittest.TestCase):
assert (len(possible_bids) == 1)
assert (float(possible_bids[0]['amount_from'] < 20.0))
logging.info(f'Adding mock data to node1 db for tests')
logging.info('Adding mock data to node1 db for tests')
rows = []
offers = read_json_api(UI_PORT, 'offers')
@ -523,7 +522,6 @@ class Test(unittest.TestCase):
offer_ids = []
logging.info('Create three offers')
for i in range(3):
if i > 0:
with open(self.node0_statefile) as fs:

@ -1,7 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (c) 2019-2022 tecnovert
# Copyright (c) 2019-2023 tecnovert
# Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php.
@ -145,6 +145,20 @@ class Test(BaseTest):
rv = read_json_api(1800, 'identities/pPCsRro5po7Yu6kyu5XjSyr3A1PPdk9j1F', {'set_label': 'test 3'})
assert (rv['error'] == 'Invalid identity address')
rv = read_json_api(1800, 'identities/ppCsRro5po7Yu6kyu5XjSyr3A1PPdk9j1F', {'set_note': 'note 1'})
assert (len(rv) == 1)
assert (rv[0]['address'] == 'ppCsRro5po7Yu6kyu5XjSyr3A1PPdk9j1F')
assert (rv[0]['label'] == 'test 2')
assert (rv[0]['note'] == 'note 1')
rv = read_json_api(1800, 'identities/ppCsRro5po7Yu6kyu5XjSyr3A1PPdk9j1F', {'set_automation_override': 1})
assert (len(rv) == 1)
assert (rv[0]['automation_override'] == 1)
rv = read_json_api(1800, 'identities/ppCsRro5po7Yu6kyu5XjSyr3A1PPdk9j1F', {'set_visibility_override': 'hide'})
assert (len(rv) == 1)
assert (rv[0]['visibility_override'] == 1)
def test_01_verifyrawtransaction(self):
txn = '0200000001eb6e5c4ebba4efa32f40c7314cad456a64008e91ee30b2dd0235ab9bb67fbdbb01000000ee47304402200956933242dde94f6cf8f195a470f8d02aef21ec5c9b66c5d3871594bdb74c9d02201d7e1b440de8f4da672d689f9e37e98815fb63dbc1706353290887eb6e8f7235012103dc1b24feb32841bc2f4375da91fa97834e5983668c2a39a6b7eadb60e7033f9d205a803b28fe2f86c17db91fa99d7ed2598f79b5677ffe869de2e478c0d1c02cc7514c606382012088a8201fe90717abb84b481c2a59112414ae56ec8acc72273642ca26cc7a5812fdc8f68876a914225fbfa4cb725b75e511810ac4d6f74069bdded26703520140b27576a914207eb66b2fd6ed9924d6217efc7fa7b38dfabe666888acffffffff01e0167118020000001976a9140044e188928710cecba8311f1cf412135b98145c88ac00000000'
prevout = {

Loading…
Cancel
Save