basicswap/scripts/createoffers.py
2024-11-28 10:29:41 +02:00

824 lines
34 KiB
Python
Executable file

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright (c) 2023-2024 tecnovert
# Copyright (c) 2024 The Basicswap developers
# Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php.
"""
Create offers
{
"min_seconds_between_offers": Add a random delay between creating offers between min and max, default 60.
"max_seconds_between_offers": ^, default "min_seconds_between_offers" * 4
"min_seconds_between_bids": Add a random delay between creating bids between min and max, default 60.
"max_seconds_between_bids": ^, default "min_seconds_between_bids" * 4
"wallet_port_override": Used for testing.
"offers": [
{
"name": Offer template name, eg "Offer 0", will be automatically renamed if not unique.
"coin_from": Coin you send.
"coin_to": Coin you receive.
"amount": Amount to create the offer for.
"minrate": Rate below which the offer won't drop.
"ratetweakpercent": modify the offer rate from the fetched value, can be negative.
"amount_variable": bool, bidder can set a different amount
"address": Address offer is sent from, default will generate a new address per offer.
"min_coin_from_amt": Won't generate offers if the wallet would drop below min_coin_from_amt.
"offer_valid_seconds": Seconds that the generated offers will be valid for.
# Optional
"enabled": Set to false to ignore offer template.
"swap_type": Type of swap, defaults to "adaptor_sig"
"min_swap_amount": Sets "amt_bid_min" on the offer, minimum valid bid when offer amount is variable.
"amount_step": If set offers will be created for amount values between "amount" and "min_coin_from_amt" in decrements of "amount_step".
},
...
],
"bids": [
{
"name": Bid template name, must be unique, eg "Bid 0", will be automatically renamed if not unique.
"coin_from": Coin you receive.
"coin_to": Coin you send.
"amount": amount to bid.
"max_rate": Maximum rate for bids.
"min_coin_to_balance": Won't send bids if wallet amount of "coin_to" would drop below.
# Optional
"enabled": Set to false to ignore bid template.
"max_concurrent": Maximum number of bids to have active at once, default 1.
"amount_variable": Can send bids below the set "amount" where possible if true.
"max_coin_from_balance": Won't send bids if wallet amount of "coin_from" would be above.
"address": Address offer is sent from, default will generate a new address per bid.
},
...
]
}
"""
__version__ = "0.2"
import os
import json
import time
import random
import shutil
import signal
import urllib
import logging
import argparse
import threading
from urllib.request import urlopen
delay_event = threading.Event()
DEFAULT_CONFIG_FILE: str = "createoffers.json"
DEFAULT_STATE_FILE: str = "createoffers_state.json"
def post_req(url: str, json_data=None):
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
if json_data:
req.add_header("Content-Type", "application/json; charset=utf-8")
post_bytes = json.dumps(json_data).encode("utf-8")
req.add_header("Content-Length", len(post_bytes))
else:
post_bytes = None
return urlopen(req, data=post_bytes, timeout=300).read()
def make_json_api_func(host: str, port: int):
host = host
port = port
def api_func(path=None, json_data=None, timeout=300):
nonlocal host, port
url = f"http://{host}:{port}/json"
if path is not None:
url += "/" + path
if json_data is not None:
return json.loads(post_req(url, json_data))
response = urlopen(url, timeout=300).read()
return json.loads(response)
return api_func
def signal_handler(sig, frame) -> None:
logging.info("Signal {} detected.".format(sig))
delay_event.set()
def findCoin(coin: str, known_coins) -> str:
for known_coin in known_coins:
if (
known_coin["name"].lower() == coin.lower()
or known_coin["ticker"].lower() == coin.lower()
):
if known_coin["active"] is False:
raise ValueError(f"Inactive coin {coin}")
return known_coin["name"]
raise ValueError(f"Unknown coin {coin}")
def readConfig(args, known_coins):
config_path: str = args.configfile
num_changes: int = 0
with open(config_path) as fs:
config = json.load(fs)
if "offers" not in config:
config["offers"] = []
if "bids" not in config:
config["bids"] = []
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 "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 "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 "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
offer_templates = config["offers"]
offer_templates_map = {}
num_enabled = 0
for i, offer_template in enumerate(offer_templates):
num_enabled += 1 if offer_template.get("enabled", True) else 0
if "name" not in offer_template:
print("Naming offer template", i)
offer_template["name"] = f"Offer {i}"
num_changes += 1
if offer_template["name"] in offer_templates_map:
print("Renaming offer template", offer_template["name"])
original_name = offer_template["name"]
offset = 2
while f"{original_name}_{offset}" in offer_templates_map:
offset += 1
offer_template["name"] = f"{original_name}_{offset}"
num_changes += 1
offer_templates_map[offer_template["name"]] = offer_template
if "amount_step" not in offer_template:
if offer_template.get("min_coin_from_amt", 0) < offer_template["amount"]:
print("Setting min_coin_from_amt for", offer_template["name"])
offer_template["min_coin_from_amt"] = offer_template["amount"]
num_changes += 1
else:
if "min_coin_from_amt" not in offer_template:
print("Setting min_coin_from_amt for", offer_template["name"])
offer_template["min_coin_from_amt"] = 0
num_changes += 1
if "address" not in offer_template:
print("Setting address to auto for offer", offer_template["name"])
offer_template["address"] = "auto"
num_changes += 1
if "ratetweakpercent" not in offer_template:
print("Setting ratetweakpercent to 0 for offer", offer_template["name"])
offer_template["ratetweakpercent"] = 0
num_changes += 1
if "amount_variable" not in offer_template:
print("Setting amount_variable to True for offer", offer_template["name"])
offer_template["amount_variable"] = True
num_changes += 1
if offer_template.get("enabled", True) is False:
continue
offer_template["coin_from"] = findCoin(offer_template["coin_from"], known_coins)
offer_template["coin_to"] = findCoin(offer_template["coin_to"], known_coins)
config["num_enabled_offers"] = num_enabled
bid_templates = config["bids"]
bid_templates_map = {}
num_enabled = 0
for i, bid_template in enumerate(bid_templates):
num_enabled += 1 if bid_template.get("enabled", True) else 0
if "name" not in bid_template:
print("Naming bid template", i)
bid_template["name"] = f"Bid {i}"
num_changes += 1
if bid_template["name"] in bid_templates_map:
print("Renaming bid template", bid_template["name"])
original_name = bid_template["name"]
offset = 2
while f"{original_name}_{offset}" in bid_templates_map:
offset += 1
bid_template["name"] = f"{original_name}_{offset}"
num_changes += 1
bid_templates_map[bid_template["name"]] = bid_template
if bid_template.get("min_swap_amount", 0.0) < 0.001:
print("Setting min_swap_amount for bid template", bid_template["name"])
bid_template["min_swap_amount"] = 0.001
if "address" not in bid_template:
print("Setting address to auto for bid", bid_template["name"])
bid_template["address"] = "auto"
num_changes += 1
if bid_template.get("enabled", True) is False:
continue
bid_template["coin_from"] = findCoin(bid_template["coin_from"], known_coins)
bid_template["coin_to"] = findCoin(bid_template["coin_to"], known_coins)
config["num_enabled_bids"] = num_enabled
if num_changes > 0:
shutil.copyfile(config_path, config_path + ".last")
with open(config_path, "w") as fp:
json.dump(config, fp, indent=4)
return config
def write_state(statefile, script_state):
if os.path.exists(statefile):
shutil.copyfile(statefile, statefile + ".last")
with open(statefile, "w") as fp:
json.dump(script_state, fp, indent=4)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"-v",
"--version",
action="version",
version="%(prog)s {version}".format(version=__version__),
)
parser.add_argument(
"--host",
dest="host",
help="RPC host (default=127.0.0.1)",
type=str,
default="127.0.0.1",
required=False,
)
parser.add_argument(
"--port",
dest="port",
help="RPC port (default=12700)",
type=int,
default=12700,
required=False,
)
parser.add_argument(
"--oneshot",
dest="oneshot",
help="Exit after one iteration (default=false)",
required=False,
action="store_true",
)
parser.add_argument(
"--debug",
dest="debug",
help="Print extra debug messages (default=false)",
required=False,
action="store_true",
)
parser.add_argument(
"--configfile",
dest="configfile",
help=f"config file path (default={DEFAULT_CONFIG_FILE})",
type=str,
default=DEFAULT_CONFIG_FILE,
required=False,
)
parser.add_argument(
"--statefile",
dest="statefile",
help=f"state file path (default={DEFAULT_STATE_FILE})",
type=str,
default=DEFAULT_STATE_FILE,
required=False,
)
args = parser.parse_args()
read_json_api = make_json_api_func(args.host, args.port)
if not os.path.exists(args.configfile):
raise ValueError(f'Config file "{args.configfile}" not found.')
known_coins = read_json_api("coins")
coins_map = {}
for known_coin in known_coins:
coins_map[known_coin["name"]] = known_coin
script_state = {}
if os.path.exists(args.statefile):
with open(args.statefile) as fs:
script_state = json.load(fs)
signal.signal(signal.SIGINT, signal_handler)
while not delay_event.is_set():
# Read config each iteration so they can be modified without restarting
config = readConfig(args, known_coins)
offer_templates = config["offers"]
random.shuffle(offer_templates)
bid_templates = config["bids"]
random.shuffle(bid_templates)
# override wallet api calls for testing
if "wallet_port_override" in config:
wallet_api_port = int(config["wallet_port_override"])
print(f"Overriding wallet api port: {wallet_api_port}")
read_json_api_wallet = make_json_api_func(args.host, wallet_api_port)
else:
read_json_api_wallet = read_json_api
try:
sent_offers = read_json_api("sentoffers", {"active": "active"})
if args.debug and len(offer_templates) > 0:
print(
"Processing {} offer template{}".format(
config["num_enabled_offers"],
"s" if config["num_enabled_offers"] != 1 else "",
)
)
for offer_template in offer_templates:
if offer_template.get("enabled", True) is False:
continue
offers_found = 0
coin_from_data = coins_map[offer_template["coin_from"]]
coin_to_data = coins_map[offer_template["coin_to"]]
wallet_from = read_json_api_wallet(
"wallets/{}".format(coin_from_data["ticker"])
)
coin_ticker = coin_from_data["ticker"]
if coin_ticker == "PART" and "variant" in coin_from_data:
coin_variant = coin_from_data["variant"]
if coin_variant == "Anon":
coin_from_data_name = "PART_ANON"
wallet_balance: float = float(wallet_from["anon_balance"])
elif coin_variant == "Blind":
coin_from_data_name = "PART_BLIND"
wallet_balance: float = float(wallet_from["blind_balance"])
else:
raise ValueError(
f"{coin_ticker} variant {coin_variant} not handled"
)
else:
coin_from_data_name = coin_ticker
wallet_balance: float = float(wallet_from["balance"])
for offer in sent_offers:
created_offers = script_state.get("offers", {})
prev_template_offers = created_offers.get(
offer_template["name"], {}
)
if next(
(
x
for x in prev_template_offers
if x["offer_id"] == offer["offer_id"]
),
None,
):
offers_found += 1
if wallet_balance <= float(offer_template["min_coin_from_amt"]):
offer_id = offer["offer_id"]
print(
"Revoking offer {}, wallet from balance below minimum".format(
offer_id
)
)
result = read_json_api(f"revokeoffer/{offer_id}")
print("revokeoffer", result)
if offers_found > 0:
continue
max_offer_amount: float = offer_template["amount"]
min_offer_amount: float = offer_template.get(
"amount_step", max_offer_amount
)
min_wallet_from_amount: float = float(
offer_template["min_coin_from_amt"]
)
if wallet_balance - min_offer_amount <= min_wallet_from_amount:
print(
"Skipping template {}, wallet from balance below minimum".format(
offer_template["name"]
)
)
continue
offer_amount: float = max_offer_amount
if wallet_balance - max_offer_amount <= min_wallet_from_amount:
available_balance: float = wallet_balance - min_wallet_from_amount
min_steps: int = available_balance // min_offer_amount
assert min_steps > 0 # Should not be possible, checked above
offer_amount = min_offer_amount * min_steps
delay_next_offer_before = script_state.get("delay_next_offer_before", 0)
if delay_next_offer_before > int(time.time()):
print("Delaying offers until {}".format(delay_next_offer_before))
break
"""
received_offers = read_json_api(args.port, 'offers', {'active': 'active', 'include_sent': False, 'coin_from': coin_from_data['id'], 'coin_to': coin_to_data['id']})
print('received_offers', received_offers)
TODO - adjust rates based on existing offers
"""
rates = read_json_api(
"rates",
{"coin_from": coin_from_data["id"], "coin_to": coin_to_data["id"]},
)
print("Rates", rates)
coingecko_rate = float(rates["coingecko"]["rate_inferred"])
use_rate = coingecko_rate
if offer_template["ratetweakpercent"] != 0:
print(
"Adjusting rate {} by {}%.".format(
use_rate, offer_template["ratetweakpercent"]
)
)
tweak = offer_template["ratetweakpercent"] / 100.0
use_rate += use_rate * tweak
if use_rate < offer_template["minrate"]:
print("Warning: Clamping rate to minimum.")
use_rate = offer_template["minrate"]
print(
"Creating offer for: {} at rate: {}".format(
offer_template, use_rate
)
)
template_from_addr = offer_template["address"]
offer_data = {
"addr_from": (
-1 if template_from_addr == "auto" else template_from_addr
),
"coin_from": coin_from_data_name,
"coin_to": coin_to_data["ticker"],
"amt_from": offer_amount,
"amt_var": offer_template["amount_variable"],
"valid_for_seconds": offer_template.get(
"offer_valid_seconds", config.get("offer_valid_seconds", 3600)
),
"rate": use_rate,
"swap_type": offer_template.get("swap_type", "adaptor_sig"),
"lockhrs": "24",
"automation_strat_id": 1,
}
if "min_swap_amount" in offer_template:
offer_data["amt_bid_min"] = offer_template["min_swap_amount"]
if args.debug:
print("offer data {}".format(offer_data))
new_offer = read_json_api("offers/new", offer_data)
if "error" in new_offer:
raise ValueError(
"Server failed to create offer: {}".format(new_offer["error"])
)
print("New offer: {}".format(new_offer["offer_id"]))
if "offers" not in script_state:
script_state["offers"] = {}
template_name = offer_template["name"]
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"]
min_seconds_between_offers = config["min_seconds_between_offers"]
time_between_offers = min_seconds_between_offers
if max_seconds_between_offers > min_seconds_between_offers:
time_between_offers = random.randint(
min_seconds_between_offers, max_seconds_between_offers
)
script_state["delay_next_offer_before"] = (
int(time.time()) + time_between_offers
)
write_state(args.statefile, script_state)
if args.debug and len(bid_templates) > 0:
print(
"Processing {} bid template{}".format(
config["num_enabled_bids"],
"s" if config["num_enabled_bids"] != 1 else "",
)
)
for bid_template in bid_templates:
if bid_template.get("enabled", True) is False:
continue
delay_next_bid_before = script_state.get("delay_next_bid_before", 0)
if delay_next_bid_before > int(time.time()):
print("Delaying bids until {}".format(delay_next_bid_before))
break
# Check bids in progress
max_concurrent = bid_template.get("max_concurrent", 1)
if "bids" not in script_state:
script_state["bids"] = {}
template_name = bid_template["name"]
if template_name not in script_state["bids"]:
script_state["bids"][template_name] = []
previous_bids = script_state["bids"][template_name]
bids_in_progress: int = 0
for previous_bid in previous_bids:
if not previous_bid["active"]:
continue
previous_bid_id = previous_bid["bid_id"]
previous_bid_info = read_json_api(f"bids/{previous_bid_id}")
bid_state = previous_bid_info["bid_state"]
if bid_state in (
"Completed",
"Timed-out",
"Abandoned",
"Error",
"Rejected",
):
print(
f"Marking bid inactive {previous_bid_id}, state {bid_state}"
)
previous_bid["active"] = False
write_state(args.statefile, script_state)
continue
if bid_state in ("Sent", "Received") and previous_bid_info[
"expired_at"
] < int(time.time()):
print(f"Marking bid inactive {previous_bid_id}, expired")
previous_bid["active"] = False
write_state(args.statefile, script_state)
continue
bids_in_progress += 1
if bids_in_progress >= max_concurrent:
print("Max concurrent bids reached for template")
continue
# Bidder sends coin_to and receives coin_from
coin_from_data = coins_map[bid_template["coin_from"]]
coin_to_data = coins_map[bid_template["coin_to"]]
page_limit: int = 25
offers_options = {
"active": "active",
"include_sent": False,
"coin_from": coin_from_data["id"],
"coin_to": coin_to_data["id"],
"with_extra_info": True,
"sort_by": "rate",
"sort_dir": "asc",
"offset": 0,
"limit": page_limit,
}
received_offers = []
for i in range(1000000): # for i in itertools.count()
page_offers = read_json_api("offers", offers_options)
if len(page_offers) < 1:
break
received_offers += page_offers
offers_options["offset"] = offers_options["offset"] + page_limit
if i > 100:
print(f"Warning: Broke offers loop at: {i}")
break
if args.debug:
print("Received Offers", received_offers)
for offer in received_offers:
offer_id = offer["offer_id"]
offer_amount = float(offer["amount_from"])
offer_rate = float(offer["rate"])
bid_amount = bid_template["amount"]
min_swap_amount = bid_template.get(
"min_swap_amount", 0.01
) # TODO: Make default vary per coin
can_adjust_offer_amount: bool = offer["amount_negotiable"]
can_adjust_bid_amount: bool = bid_template.get(
"amount_variable", True
)
can_adjust_amount: bool = (
can_adjust_offer_amount and can_adjust_bid_amount
)
if offer_amount < min_swap_amount:
if args.debug:
print(f"Offer amount below min swap amount bid {offer_id}")
continue
if can_adjust_offer_amount is False and offer_amount > bid_amount:
if args.debug:
print(f"Bid amount too low for offer {offer_id}")
continue
if bid_amount > offer_amount:
if can_adjust_bid_amount:
bid_amount = offer_amount
else:
if args.debug:
print(f"Bid amount too high for offer {offer_id}")
continue
if offer_rate > bid_template["maxrate"]:
if args.debug:
print(f"Bid rate too low for offer {offer_id}")
continue
sent_bids = read_json_api(
"sentbids",
{
"offer_id": offer["offer_id"],
"with_available_or_active": True,
},
)
if len(sent_bids) > 0:
if args.debug:
print(f"Already bidding on offer {offer_id}")
continue
offer_identity = read_json_api(
"identities/{}".format(offer["addr_from"])
)
if len(offer_identity) > 0:
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})."
)
continue
validateamount: bool = False
max_coin_from_balance = bid_template.get(
"max_coin_from_balance", -1
)
if max_coin_from_balance > 0:
wallet_from = read_json_api_wallet(
"wallets/{}".format(coin_from_data["ticker"])
)
total_balance_from = float(wallet_from["balance"]) + float(
wallet_from["unconfirmed"]
)
if args.debug:
print(f"Total coin from balance {total_balance_from}")
if total_balance_from + bid_amount > max_coin_from_balance:
if (
can_adjust_amount
and max_coin_from_balance - total_balance_from
> min_swap_amount
):
bid_amount = max_coin_from_balance - total_balance_from
validateamount = True
print(f"Reduced bid amount to {bid_amount}")
else:
if args.debug:
print(
f"Bid amount would exceed maximum wallet total for offer {offer_id}"
)
continue
min_coin_to_balance = bid_template["min_coin_to_balance"]
if min_coin_to_balance > 0:
wallet_to = read_json_api_wallet(
"wallets/{}".format(coin_to_data["ticker"])
)
total_balance_to = float(wallet_to["balance"]) + float(
wallet_to["unconfirmed"]
)
if args.debug:
print(f"Total coin to balance {total_balance_to}")
swap_amount_to = bid_amount * offer_rate
if total_balance_to - swap_amount_to < min_coin_to_balance:
if can_adjust_amount:
adjusted_swap_amount_to = (
total_balance_to - min_coin_to_balance
)
adjusted_bid_amount = (
adjusted_swap_amount_to / offer_rate
)
if adjusted_bid_amount > min_swap_amount:
bid_amount = adjusted_bid_amount
validateamount = True
print(f"Reduced bid amount to {bid_amount}")
swap_amount_to = adjusted_bid_amount * offer_rate
if total_balance_to - swap_amount_to < min_coin_to_balance:
if args.debug:
print(
f"Bid amount would exceed minimum coin to wallet total for offer {offer_id}"
)
continue
if validateamount:
bid_amount = read_json_api(
"validateamount",
{
"coin": coin_from_data["ticker"],
"amount": bid_amount,
"method": "rounddown",
},
)
bid_data = {
"offer_id": offer["offer_id"],
"amount_from": bid_amount,
}
if "address" in bid_template:
addr_from = bid_template["address"]
if addr_from != -1 and addr_from != "auto":
bid_data["addr_from"] = addr_from
if config.get("test_mode", False):
print("Would create bid: {}".format(bid_data))
bid_id = "simulated"
else:
if args.debug:
print("Creating bid: {}".format(bid_data))
new_bid = read_json_api("bids/new", bid_data)
if "error" in new_bid:
raise ValueError(
"Server failed to create bid: {}".format(
new_bid["error"]
)
)
print(
"New bid: {} on offer {}".format(
new_bid["bid_id"], offer["offer_id"]
)
)
bid_id = new_bid["bid_id"]
script_state["bids"][template_name].append(
{"bid_id": bid_id, "time": int(time.time()), "active": True}
)
max_seconds_between_bids = config["max_seconds_between_bids"]
min_seconds_between_bids = config["min_seconds_between_bids"]
if max_seconds_between_bids > min_seconds_between_bids:
time_between_bids = random.randint(
min_seconds_between_bids, max_seconds_between_bids
)
else:
time_between_bids = min_seconds_between_bids
script_state["delay_next_bid_before"] = (
int(time.time()) + time_between_bids
)
write_state(args.statefile, script_state)
break # Create max one bid per iteration
except Exception as e:
print(f"Error: {e}.")
if args.oneshot:
break
print("Looping indefinitely, ctrl+c to exit.")
delay_event.wait(60)
print("Done.")
if __name__ == "__main__":
main()