mirror of
https://github.com/cryptosharks131/lndg.git
synced 2026-08-13 12:33:04 +02:00
1061 lines
No EOL
44 KiB
Python
1061 lines
No EOL
44 KiB
Python
import django, base64, secrets, re, asyncio, json
|
|
from django.db.models import Sum, IntegerField, Count, F, Q
|
|
from django.db.models.functions import Round
|
|
from time import time
|
|
from bech32 import bech32_decode, bech32_encode, convertbits
|
|
from datetime import datetime, timedelta
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
from cryptography.hazmat.backends import default_backend
|
|
from decimal import Decimal
|
|
from lndg import settings
|
|
from gui.lnd_deps import lightning_pb2 as ln
|
|
from gui.lnd_deps import lightning_pb2_grpc as lnrpc
|
|
from gui.lnd_deps import signer_pb2 as lns
|
|
from gui.lnd_deps import signer_pb2_grpc as lnsigner
|
|
from gui.lnd_deps import router_pb2 as lnr
|
|
from gui.lnd_deps import router_pb2_grpc as lnrouter
|
|
from gui.lnd_deps.lnd_connect import lnd_connect, async_lnd_connect
|
|
from os import environ
|
|
environ['DJANGO_SETTINGS_MODULE'] = 'lndg.settings'
|
|
django.setup()
|
|
from gui.models import TradeSales, Payments, PaymentHops, Forwards, Peers
|
|
import logging
|
|
logger = logging.getLogger('[P2P]')
|
|
|
|
def is_hex(n):
|
|
return len(n) % 2 == 0 and all(c in '0123456789ABCDEFabcdef' for c in n)
|
|
|
|
def hex_as_utf8(hex_str):
|
|
return bytes.fromhex(hex_str).decode('utf-8')
|
|
|
|
def utf8_as_hex(utf8_str):
|
|
return bytes(utf8_str, 'utf-8').hex()
|
|
|
|
def decode_basic_trade(records):
|
|
if not isinstance(records, list):
|
|
raise ValueError('ExpectedArrayOfRecordsToDecodeBasicTrade')
|
|
|
|
description_record = next((record for record in records if record['type'] == '2'), None)
|
|
|
|
if not description_record:
|
|
raise ValueError('ExpectedDescriptionRecordToDecodeBasicTrade')
|
|
|
|
id_record = next((record for record in records if record['type'] == '1'), None)
|
|
|
|
if not id_record:
|
|
raise ValueError('ExpectedIdRecordToDecodeBasicTradeDetails')
|
|
|
|
return {
|
|
'description': hex_as_utf8(description_record['value']),
|
|
'id': id_record['value']
|
|
}
|
|
|
|
def encode_as_bigsize(number: int):
|
|
max_8_bit_number = 252
|
|
max_16_bit_number = 65535
|
|
max_32_bit_number = 4294967295
|
|
|
|
def tag_as_uint8(n):
|
|
return format(n, '02x')
|
|
|
|
def tag_as_uint16(n):
|
|
return 'fd' + format(n, '04x')
|
|
|
|
def tag_as_uint32(n):
|
|
return 'fe' + format(n, '08x')
|
|
|
|
def tag_as_uint64(n):
|
|
return 'ff' + format(n, '016x')
|
|
|
|
number = int(number)
|
|
|
|
if number <= max_8_bit_number:
|
|
return tag_as_uint8(number)
|
|
elif number <= max_16_bit_number:
|
|
return tag_as_uint16(number)
|
|
elif number <= max_32_bit_number:
|
|
return tag_as_uint32(number)
|
|
else:
|
|
return tag_as_uint64(number)
|
|
|
|
def decode_as_bigsize(encoded_string: str):
|
|
def read_uint8(encoded_string):
|
|
return int(encoded_string[:2], 16), encoded_string[2:]
|
|
|
|
def read_uint16(encoded_string):
|
|
return int(encoded_string[2:6], 16), encoded_string[6:]
|
|
|
|
def read_uint32(encoded_string):
|
|
return int(encoded_string[2:10], 16), encoded_string[10:]
|
|
|
|
def read_uint64(encoded_string):
|
|
return int(encoded_string[2:18], 16), encoded_string[18:]
|
|
|
|
if encoded_string.startswith('ff'):
|
|
value, remaining = read_uint64(encoded_string)
|
|
elif encoded_string.startswith('fe'):
|
|
value, remaining = read_uint32(encoded_string)
|
|
elif encoded_string.startswith('fd'):
|
|
value, remaining = read_uint16(encoded_string)
|
|
else:
|
|
value, remaining = read_uint8(encoded_string)
|
|
|
|
return value
|
|
|
|
def decode_big_size(encoded):
|
|
max_8bit_number = 0xfc
|
|
max_16bit_number = 0xffff
|
|
max_32bit_number = 0xffffffff
|
|
uint8_length = 1
|
|
uint16 = 0xfd
|
|
uint16_length = 3
|
|
uint32 = 0xfe
|
|
uint32_length = 5
|
|
uint64_length = 9
|
|
|
|
if not bool(encoded) and is_hex(encoded):
|
|
raise ValueError('ExpectedHexEncodedBigSizeValueToDecode')
|
|
|
|
bytes_data = bytes.fromhex(encoded)
|
|
|
|
size = bytes_data[0]
|
|
|
|
if size <= max_8bit_number:
|
|
return {'decoded': str(size), 'length': uint8_length}
|
|
|
|
byte_length = len(bytes_data)
|
|
|
|
if size == uint16:
|
|
if byte_length < uint16_length:
|
|
raise ValueError('ExpectedMoreBytesToDecodeUint16BigSize')
|
|
|
|
uint16_number = int.from_bytes(bytes_data[1:3], 'big')
|
|
|
|
if uint16_number <= max_8bit_number:
|
|
raise ValueError('ExpectedLargerNumberToDecodeUint16BigSize')
|
|
|
|
return {'decoded': str(uint16_number), 'length': uint16_length}
|
|
elif size == uint32:
|
|
if byte_length < uint32_length:
|
|
raise ValueError('ExpectedMoreBytesToDecodeUint32BigSize')
|
|
|
|
uint32_number = int.from_bytes(bytes_data[1:5], 'big')
|
|
|
|
if uint32_number <= max_16bit_number:
|
|
raise ValueError('ExpectedLargerNumberToDecodeUint32BigSize')
|
|
|
|
return {'decoded': str(uint32_number), 'length': uint32_length}
|
|
else:
|
|
if byte_length < uint64_length:
|
|
raise ValueError('ExpectedMoreBytesToDecodeUint64BigSize')
|
|
|
|
uint64_number = int.from_bytes(bytes_data[1:9], 'big')
|
|
|
|
if uint64_number <= max_32bit_number:
|
|
raise ValueError('ExpectedLargerNumberToDecodeUint64BigSize')
|
|
|
|
return {'decoded': str(uint64_number), 'length': uint64_length}
|
|
|
|
def decode_tlv_record(data):
|
|
def hex_len(byte_length):
|
|
return 0 if not byte_length else byte_length * 2
|
|
|
|
def read(from_pos, hex_str, to_pos=None):
|
|
if to_pos is None:
|
|
return hex_str[from_pos:]
|
|
else:
|
|
return hex_str[from_pos:to_pos + from_pos]
|
|
encoded = data['encoded']
|
|
offset = data.get('offset', 0)
|
|
|
|
start = hex_len(offset)
|
|
|
|
type_record = decode_big_size(read(start, encoded))
|
|
|
|
size_start = start + hex_len(type_record['length'])
|
|
|
|
bytes_record = decode_big_size(read(size_start, encoded))
|
|
|
|
meta_bytes = type_record['length'] + bytes_record['length']
|
|
|
|
if not int(bytes_record['decoded']):
|
|
return {'length': meta_bytes, 'type': type_record['decoded'], 'value': ''}
|
|
|
|
value_start = start + hex_len(meta_bytes)
|
|
total_bytes = meta_bytes + int(bytes_record['decoded'])
|
|
|
|
if start + hex_len(total_bytes) > len(encoded):
|
|
raise ValueError('ExpectedAdditionalValueBytesInTlvRecord')
|
|
|
|
return {
|
|
'length': total_bytes,
|
|
'type': type_record['decoded'],
|
|
'value': read(value_start, encoded, hex_len(int(bytes_record['decoded'])))
|
|
}
|
|
|
|
def decode_tlv_stream(encoded):
|
|
|
|
if not is_hex(encoded):
|
|
raise ValueError('ExpectedHexEncodedTlvStreamToDecode')
|
|
|
|
if not encoded:
|
|
return []
|
|
|
|
total_bytes = len(encoded) // 2
|
|
stream = {'offset': 0, 'records': []}
|
|
|
|
while stream['offset'] < total_bytes:
|
|
stream['record'] = decode_tlv_record({'encoded': encoded, 'offset': stream['offset']})
|
|
|
|
stream['offset'] += stream['record']['length']
|
|
stream['records'].append(stream['record'])
|
|
|
|
return stream['records']
|
|
|
|
def parse_response_code(data):
|
|
encoded = data['encoded']
|
|
|
|
if not encoded:
|
|
raise ValueError('ExpectedResponseCodeValueToParseResponseCode')
|
|
|
|
records = decode_tlv_stream(encoded)
|
|
code_record = next((record for record in records if record['type'] == '0'), None)
|
|
|
|
if not code_record:
|
|
raise ValueError('ExpectedCodeRecordToParseResponseCode')
|
|
|
|
code = int(decode_big_size(code_record['value'])['decoded'])
|
|
if code > 2 ** 53 - 1:
|
|
raise ValueError('UnexpectedlyLargeResponseCodeInResponse')
|
|
|
|
if code < 100:
|
|
raise ValueError('UnexpectedlySmallResponseCodeInResponse')
|
|
|
|
if not code > 400:
|
|
return {}
|
|
|
|
message_record = next((record for record in records if record['type'] == '1'), None) or {'value': ''}
|
|
|
|
return {'failure': [code, hex_as_utf8(message_record['value'])]}
|
|
|
|
def parse_peer_request_message(message):
|
|
records = decode_tlv_stream(message[len('626f73ff'):])
|
|
version = next((record for record in records if record['type'] == '0'), None)
|
|
|
|
if version is not None:
|
|
raise ValueError('UnexpectedVersionNumberOfRequestMessage')
|
|
|
|
id_record = next((record for record in records if record['type'] == '1'), None)
|
|
|
|
if id_record is None or len(id_record['value']) != 64:
|
|
raise ValueError('ExpectedRequestIdInRequestMessage')
|
|
|
|
records_record = next((record for record in records if record['type'] == '5'), None) or {'value':''}
|
|
|
|
response_code_record = next((record for record in records if record['type'] == '2'), None)
|
|
type_record = next((record for record in records if record['type'] == '3'), None)
|
|
|
|
if not type_record and not response_code_record:
|
|
raise ValueError('ExpectedEitherRequestParametersOrResponseCode')
|
|
|
|
if response_code_record is not None:
|
|
response_code = parse_response_code({'encoded': response_code_record['value']})
|
|
failure = response_code['failure'] if 'failure' in response_code else None
|
|
|
|
return {
|
|
'response': {
|
|
'failure': failure,
|
|
'id': id_record['value'],
|
|
'records': [{'type': record['type'], 'value': record['value']} for record in decode_tlv_stream(records_record['value'])],
|
|
},
|
|
}
|
|
else:
|
|
return {
|
|
'request': {
|
|
'id': id_record['value'],
|
|
'records': [{'type': record['type'], 'value': record['value']} for record in decode_tlv_stream(records_record['value'])],
|
|
'type': decode_big_size(type_record['value'])['decoded'],
|
|
},
|
|
}
|
|
|
|
def decode_basic_trade(records):
|
|
if not isinstance(records, list):
|
|
raise ValueError('ExpectedArrayOfRecordsToDecodeBasicTrade')
|
|
|
|
description_record = next((record for record in records if record['type'] == '2'), None)
|
|
|
|
if not description_record:
|
|
raise ValueError('ExpectedDescriptionRecordToDecodeBasicTrade')
|
|
|
|
id_record = next((record for record in records if record['type'] == '1'), None)
|
|
|
|
if not id_record:
|
|
raise ValueError('ExpectedIdRecordToDecodeBasicTradeDetails')
|
|
|
|
return {
|
|
'description': hex_as_utf8(description_record['value']),
|
|
'id': id_record['value']
|
|
}
|
|
|
|
def decode_anchored_trade_data(encoded):
|
|
anchor_prefix = 'anchor-trade-secret:'
|
|
|
|
if not encoded.startswith(anchor_prefix):
|
|
return {}
|
|
|
|
encoded_data = encoded[len(anchor_prefix):]
|
|
|
|
try:
|
|
decoded_data = decode_tlv_stream(base64.b64decode(encoded_data).hex())
|
|
except Exception as e:
|
|
return {}
|
|
|
|
records = decoded_data
|
|
channel_record = next((record for record in records if record['type'] == '3'), None)
|
|
description_record = next((record for record in records if record['type'] == '1'), None)
|
|
secret_record = next((record for record in records if record['type'] == '0'), None)
|
|
price_record = next((record for record in records if record['type'] == '2'), None)
|
|
|
|
if channel_record:
|
|
try:
|
|
channel_value = int(decode_big_size({'encoded': channel_record['value']})['decoded'])
|
|
except ValueError:
|
|
return {}
|
|
|
|
return {
|
|
'channel': channel_value,
|
|
'price': hex_as_utf8(price_record['value']) if price_record else None,
|
|
}
|
|
|
|
if description_record and secret_record:
|
|
return {
|
|
'description': hex_as_utf8(description_record['value']),
|
|
'price': hex_as_utf8(price_record['value']) if price_record else None,
|
|
'secret': hex_as_utf8(secret_record['value']),
|
|
}
|
|
return None
|
|
|
|
def encode_tlv_record(data):
|
|
def byte_length_of(hex_string):
|
|
return len(hex_string) // 2
|
|
|
|
def encode(type, length, val):
|
|
return f"{type}{length}{val}"
|
|
|
|
type_number = data["type"]
|
|
value_hex = data["value"]
|
|
|
|
data_length = encode_as_bigsize(byte_length_of(value_hex))
|
|
encoded_tlv_record = encode(encode_as_bigsize(type_number), data_length, value_hex)
|
|
|
|
return encoded_tlv_record
|
|
|
|
def encode_peer_request(data):
|
|
id = data['id']
|
|
records = data.get('records')
|
|
request_type = data['type']
|
|
if not id:
|
|
raise ValueError('ExpectedRequestIdHexStringToEncodePeerRequest')
|
|
|
|
if records is not None and not isinstance(records, list):
|
|
raise ValueError('ExpectedRecordsArrayToEncodePeerRequest')
|
|
|
|
if not request_type:
|
|
raise ValueError('ExpectedRequestTypeToEncodePeerRequest')
|
|
|
|
peer_response = [{'type': '1', 'value': id},{'type': '3', 'value': encode_as_bigsize(request_type)},{'type': '5', 'value': records}]
|
|
|
|
peer_response = [
|
|
{'type': item['type'], 'value': item['value']} if not isinstance(item['value'], list) else {'type': item['type'], 'value': ''.join([encode_tlv_record(record) for record in item['value']])}
|
|
for item in peer_response
|
|
]
|
|
|
|
peer_response = [{'type': item['type'], 'value': str(item['value'])} for item in peer_response]
|
|
|
|
return '626f73ff' + ''.join([encode_tlv_record(record) for record in peer_response])
|
|
|
|
def encode_response_code(data):
|
|
code_for_success = 200
|
|
failure = data.get('failure')
|
|
|
|
if not failure:
|
|
return ''.join([encode_tlv_record(record) for record in [{'type': '0', 'value': encode_as_bigsize(code_for_success)}]])
|
|
|
|
if not isinstance(failure, list):
|
|
raise ValueError('ExpectedFailureArrayToEncodeResponseCode')
|
|
|
|
code, message = failure
|
|
|
|
if not code:
|
|
raise ValueError('ExpectedErrorCodeToEncodeResponseCode')
|
|
|
|
records = [{'value': encode_as_bigsize(code), 'type': '0'}, {'value': utf8_as_hex(message), 'type': '1'}]
|
|
|
|
return ''.join([encode_tlv_record(record) for record in records])
|
|
|
|
def encode_peer_response(data):
|
|
failure = data.get('failure')
|
|
id = data['id']
|
|
records = data.get('records')
|
|
|
|
code = encode_response_code({'failure': failure})
|
|
encoded = ''.join([encode_tlv_record(record) for record in records]) if records is not None else None
|
|
|
|
peer_response = [{'type': '1', 'value': id}, {'type': '2', 'value': code}, {'type': '5', 'value': encoded}]
|
|
|
|
return '626f73ff' + ''.join([encode_tlv_record(record) for record in peer_response])
|
|
|
|
def get_legacy_trades(stub):
|
|
trades = []
|
|
for invoice in stub.ListInvoices(ln.ListInvoiceRequest(pending_only=True)).invoices:
|
|
if invoice.is_keysend == False:
|
|
trade = decode_anchored_trade_data(invoice.memo)
|
|
if trade:
|
|
trade['price'] = invoice.value
|
|
trade['id'] = invoice.r_hash.hex()
|
|
trade['expiry'] = invoice.expiry
|
|
trade['creation_date'] = invoice.creation_date
|
|
trades.append(trade)
|
|
return trades
|
|
|
|
def get_trades(trade_id=None):
|
|
trades = TradeSales.objects.filter(Q(expiry__isnull=True) | Q(expiry__gt=datetime.now())).filter(Q(sale_limit__isnull=True) | Q(sale_count__lt=F('sale_limit')))
|
|
if trade_id:
|
|
trades = trades.filter(id=trade_id)
|
|
return trades
|
|
|
|
def decodePrefix(prefix):
|
|
bech32CurrencyCodes={"bc": "bitcoin","bcrt": "regtest","ltc": "litecoin","tb": "testnet","tbs": "signet","sb": "simnet"}
|
|
matches = re.compile(r'^ln(\S+?)(\d*)([a-zA-Z]?)$').match(prefix)
|
|
|
|
if not matches or not matches.groups():
|
|
raise ValueError('InvalidPaymentRequestPrefix')
|
|
|
|
_, _, type = matches.groups()
|
|
|
|
prefixElements = re.compile(r'^ln(\S+)$').match(prefix) if not type else matches
|
|
|
|
currency, amount, units = prefixElements.groups()
|
|
|
|
network = bech32CurrencyCodes.get(currency)
|
|
|
|
if not network:
|
|
raise ValueError('UnknownCurrencyCodeInPaymentRequest')
|
|
|
|
return amount, network, units
|
|
|
|
def parseHumanReadableValue(data):
|
|
amountMultiplierPattern = r'^[^munp0-9]$'
|
|
divisibilityMarkerLen = 1
|
|
divisibilityPattern = r'^[munp]$'
|
|
|
|
amount = data.get('amount')
|
|
units = data.get('units')
|
|
|
|
hrp = f"{amount}{units}"
|
|
|
|
if re.match(divisibilityPattern, hrp[-divisibilityMarkerLen:]):
|
|
return {
|
|
'divisor': hrp[-divisibilityMarkerLen:],
|
|
'value': hrp[:-(divisibilityMarkerLen)],
|
|
}
|
|
|
|
if re.match(amountMultiplierPattern, hrp[-divisibilityMarkerLen:]):
|
|
raise ValueError('InvalidAmountMultiplier')
|
|
|
|
return {'value': hrp}
|
|
|
|
def hrpAsMtokens(amount, units):
|
|
divisors ={"m": "1000","n": "1000000000","p": "1000000000000","u": "1000000"}
|
|
|
|
if not amount:
|
|
return {}
|
|
|
|
result = parseHumanReadableValue({'amount': amount, 'units': units})
|
|
divisor = result['divisor']
|
|
value = result['value']
|
|
|
|
if not bool(re.match(r'^\d+$', value)):
|
|
raise ValueError('ExpectedValidNumericAmountToParseHrpAsMtokens')
|
|
|
|
val = Decimal(value)
|
|
|
|
if not divisor:
|
|
return {'mtokens': str(int(val * Decimal(1e11)))}
|
|
|
|
div = Decimal(divisors.get(divisor, 1))
|
|
|
|
return str(int(val * Decimal(1e11) / div))
|
|
|
|
def mtokensAsHrp(mtokens):
|
|
amount = int(mtokens)
|
|
hrp = None
|
|
|
|
multipliers = {
|
|
'n': 100,
|
|
'u': 100000,
|
|
'm': 100000000,
|
|
'': 100000000000,
|
|
}
|
|
|
|
for letter, value in multipliers.items():
|
|
value = int(value)
|
|
if amount % value == 0:
|
|
if letter == 'u':
|
|
hrp = f"{amount // value}u"
|
|
elif letter == 'm':
|
|
hrp = f"{amount // value}m"
|
|
elif letter == 'n':
|
|
hrp = f"{amount // value}n"
|
|
elif letter == '':
|
|
hrp = f"{amount // value}"
|
|
|
|
if not hrp:
|
|
return str(amount * 10) + 'p'
|
|
return hrp
|
|
|
|
def decodeBech32Words(words):
|
|
inBits = 5
|
|
outBits = 8
|
|
|
|
bits = 0
|
|
maxV = (1 << outBits) - 1
|
|
result = []
|
|
value = 0
|
|
|
|
for word in words:
|
|
value = (value << inBits) | word
|
|
bits += inBits
|
|
while bits >= outBits:
|
|
bits -= outBits
|
|
result.append((value >> bits) & maxV)
|
|
|
|
if bits:
|
|
result.append((value << (outBits - bits)) & maxV)
|
|
|
|
return bytes(result)
|
|
|
|
def byteEncodeRequest(request):
|
|
if not request:
|
|
raise ValueError('ExpectedPaymentRequestToByteEncode')
|
|
|
|
if request[:2].lower() != 'ln':
|
|
raise ValueError('ExpectedLnPrefixToByteEncodePaymentRequest')
|
|
|
|
(prefix, words) = bech32_decode(request)
|
|
|
|
(amount, network, units) = decodePrefix(prefix)
|
|
|
|
mtokens = hrpAsMtokens(amount, units)
|
|
|
|
encoded = decodeBech32Words(words).hex()
|
|
|
|
return {'encoded': encoded, 'network': network, 'mtokens': mtokens, 'words': len(words)}
|
|
|
|
def byteDecodeRequest(encoded, mtokens, network, words):
|
|
if not is_hex(encoded):
|
|
raise ValueError('ExpectedHexEncodedPaymentRequestDataToDecodeRequest')
|
|
if not network:
|
|
raise ValueError('ExpectedNetworkToDecodeByteEncodedRequest')
|
|
if not words:
|
|
raise ValueError('ExpectedWordsCountToDecodeByteEncodedRequest')
|
|
|
|
if network == 'bitcoin':
|
|
prefix = 'bc'
|
|
elif network == 'testnet':
|
|
prefix = 'tb'
|
|
elif network == 'regtest':
|
|
prefix = 'bcrt'
|
|
elif network == 'signet':
|
|
prefix = 'tbs'
|
|
else:
|
|
raise ValueError('ExpectedKnownNetworkToDecodeByteEncodedRequest')
|
|
|
|
prefix = 'ln' + prefix + mtokensAsHrp(mtokens)
|
|
five_bit = convertbits(bytes.fromhex(encoded), 8, 5)[:words]
|
|
return bech32_encode(prefix, five_bit)
|
|
|
|
def encode_request_as_records(request):
|
|
if not request:
|
|
raise ValueError('ExpectedRequestToEncodeAsRequestRecords')
|
|
|
|
records = []
|
|
|
|
result = byteEncodeRequest(request)
|
|
encoded = result['encoded']
|
|
mtokens = result['mtokens']
|
|
words = result['words']
|
|
|
|
records.append({'type': '1', 'value': encoded})
|
|
records.append({'type': '0', 'value': encode_as_bigsize(words)})
|
|
|
|
if mtokens:
|
|
records.append({'type': '2', 'value': encode_as_bigsize(mtokens)})
|
|
|
|
return ''.join([encode_tlv_record(record) for record in records]), result['network']
|
|
|
|
def decode_records_as_request(encoded, network):
|
|
if not encoded:
|
|
raise ValueError('ExpectectedEncodedPaymentRequestRecordsToDecode')
|
|
|
|
if not network:
|
|
raise ValueError('ExpectedNetworkNameToDeriveRequestFromRequestRecords')
|
|
|
|
records = decode_tlv_stream(encoded)
|
|
word_count = next((record for record in records if record['type'] == '0'), None)
|
|
if not word_count:
|
|
raise ValueError('ExpectedWordCountRecordInPaymentTlvRecord')
|
|
try:
|
|
words = int(decode_as_bigsize(word_count['value']))
|
|
except:
|
|
raise ValueError('ExpectedPaymentRequestWordCountInRequestRecords')
|
|
|
|
details = next((record for record in records if record['type'] == '1'), None)
|
|
if not details:
|
|
raise ValueError('ExpectedEncodedPaymentDetailsInPaymentTlvRecord')
|
|
|
|
amount = next((record for record in records if record['type'] == '2'), None)
|
|
if not amount:
|
|
raise ValueError('ExpectedPaymentRequestTokensInPaymentRecords')
|
|
mtokens = decode_as_bigsize(amount['value'])
|
|
|
|
try:
|
|
request = byteDecodeRequest(details['value'], mtokens, network, words)
|
|
except:
|
|
raise ValueError('ExpectedValidPaymentRequestDetailsToDecodeRecords')
|
|
|
|
return request
|
|
|
|
def encode_final_trade(auth, payload, request):
|
|
if not request:
|
|
raise ValueError('ExpectedPaymentRequestToDeriveNetworkRecord')
|
|
|
|
encoded_request, network = encode_request_as_records(request)
|
|
trade_records = [{'type': '2', 'value': encoded_request}]
|
|
|
|
if settings.LND_NETWORK != 'mainnet':
|
|
network_value = '02' if network == 'regtest' else '01'
|
|
trade_records.append({'type': '1','value': network_value})
|
|
|
|
encryption_records = ''.join([encode_tlv_record(record) for record in [{'type': '0', 'value': payload}, {'type': '1', 'value': auth}]])
|
|
details_records = ''.join([encode_tlv_record(record) for record in [{'type': '0', 'value': encryption_records}]])
|
|
trade_records.append({'type': '3', 'value': details_records})
|
|
|
|
return '626f73ff' + ''.join([encode_tlv_record(record) for record in trade_records])
|
|
|
|
def decode_final_trade(network, request, details):
|
|
details = decode_tlv_stream(details['value'])
|
|
|
|
encrypted = next((record for record in details if record['type'] == '0'), None)
|
|
if not encrypted:
|
|
raise ValueError('ExpectedEncryptedRecordToDecodeTrade')
|
|
encrypted_records = decode_tlv_stream(encrypted['value'])
|
|
|
|
encrypted_data = next((record for record in encrypted_records if record['type'] == '0'), None)
|
|
if not encrypted_data:
|
|
raise ValueError('ExpectedEncryptedDataRecordToDecodeTrade')
|
|
|
|
auth = next((record for record in encrypted_records if record['type'] == '1'), None)
|
|
if not auth:
|
|
raise ValueError('ExpectedAuthDataRecordToDecodeTrade')
|
|
|
|
return decode_records_as_request(request['value'], network), auth['value'], encrypted_data['value']
|
|
|
|
def getSecret(stub, sale_type):
|
|
if sale_type == 1: # routing data
|
|
try:
|
|
filter_30day = datetime.now() - timedelta(days=30)
|
|
incoming_nodes = Forwards.objects.filter(forward_date__gte=filter_30day).values('chan_id_in').annotate(ppm=Round((Sum('fee')/Sum('amt_in_msat'))*1000000000, output_field=IntegerField()), score=Round((Round(Count('id')/1, output_field=IntegerField())+Round(Sum('amt_in_msat')/100000, output_field=IntegerField()))/10, output_field=IntegerField())).exclude(score=0).order_by('-score', '-ppm')[:5]
|
|
outgoing_nodes = Forwards.objects.filter(forward_date__gte=filter_30day).values('chan_id_out').annotate(ppm=Round((Sum('fee')/Sum('amt_out_msat'))*1000000000, output_field=IntegerField()), score=Round((Round(Count('id')/1, output_field=IntegerField())+Round(Sum('amt_out_msat')/100000, output_field=IntegerField()))/10, output_field=IntegerField())).exclude(score=0).order_by('-score', '-ppm')[:5]
|
|
secret = json.dumps({"incoming_nodes":list(incoming_nodes.values('chan_id_in', 'score', 'ppm')), "outgoing_nodes":list(outgoing_nodes.values('chan_id_out', 'score', 'ppm'))})
|
|
except Exception as e:
|
|
logger.error(f'Error getting secret: {str(e)}')
|
|
secret = None
|
|
finally:
|
|
return secret
|
|
elif sale_type == 2: # payment data
|
|
try:
|
|
self_pubkey = stub.GetInfo(ln.GetInfoRequest()).identity_pubkey
|
|
filter_30day = datetime.now() - timedelta(days=30)
|
|
# exlcude_list = AvoidNodes.objects.values_list('pubkey')
|
|
payments_30day = Payments.objects.filter(creation_date__gte=filter_30day, status=2).values_list('payment_hash')
|
|
payment_nodes = PaymentHops.objects.filter(payment_hash__in=payments_30day).exclude(node_pubkey=self_pubkey).values('node_pubkey').annotate(ppm=Round((Sum('fee')/Sum('amt'))*1000000, output_field=IntegerField()), score=Round((Round(Count('id')/1, output_field=IntegerField())+Round(Sum('amt')/100000, output_field=IntegerField()))/10, output_field=IntegerField())).exclude(score=0).order_by('-score', 'ppm')[:10]
|
|
secret = json.dumps({"payment_nodes": list(payment_nodes.values('node_pubkey', 'score', 'ppm'))})
|
|
except Exception as e:
|
|
logger.error(f'Error getting secret: {str(e)}')
|
|
secret = None
|
|
finally:
|
|
return secret
|
|
else:
|
|
return None
|
|
|
|
def serve_trades(stub):
|
|
logger.info('Serving trades...')
|
|
for trade in get_trades():
|
|
logger.info(f'Serving trade: {trade.id}')
|
|
for response in stub.SubscribeCustomMessages(ln.SubscribeCustomMessagesRequest()):
|
|
if response.type == 32768:
|
|
from_peer = response.peer
|
|
msg_type = response.type
|
|
message = response.data.hex()
|
|
if msg_type == 32768 and message.lower().startswith('626f73ff'):
|
|
msg_response = parse_peer_request_message(message)
|
|
if 'request' in msg_response:
|
|
request = msg_response['request']
|
|
if 'type' in request:
|
|
req_type = request['type']
|
|
if req_type == '8050005': # request a seller to finalize a trade or give all open trades
|
|
logger.info(f'SELLER ACTION | ID: {request["id"]} | Records: {request["records"]}')
|
|
select_trade = next((record for record in request['records'] if record['type'] == '0'), None)
|
|
request_trade = next((record for record in request['records'] if record['type'] == '1'), None)
|
|
if request_trade:
|
|
trades = get_trades()
|
|
for trade in trades:
|
|
trade_data = encode_peer_request({'id':secrets.token_bytes(32).hex(), 'type':'8050006', 'records':[{'type': '1', 'value': trade.id}, {'type': '2', 'value': utf8_as_hex(trade.description)}, {'type': '0', 'value': request_trade['value']}]})
|
|
stub.SendCustomMessage(ln.SendCustomMessageRequest(peer=from_peer, type=32768, data=bytes.fromhex(trade_data)))
|
|
ack_data = encode_peer_response({'failure':None, 'id':request['id'], 'records':[]})
|
|
for trade in trades:
|
|
stub.SendCustomMessage(ln.SendCustomMessageRequest(peer=from_peer, type=32768, data=bytes.fromhex(ack_data)))
|
|
if select_trade:
|
|
selected_trade = get_trades(trade.id)
|
|
if selected_trade:
|
|
trade_details = selected_trade[0]
|
|
if not trade_details.secret:
|
|
secret = getSecret(stub, trade_details.sale_type)
|
|
else:
|
|
secret = trade_details.secret
|
|
if not secret:
|
|
logger.error(f'Failed to get secret for: {trade_details.id}')
|
|
continue
|
|
signerstub = lnsigner.SignerStub(lnd_connect())
|
|
shared_key = signerstub.DeriveSharedKey(lns.SharedKeyRequest(ephemeral_pubkey=from_peer)).shared_key
|
|
preimage = secrets.token_bytes(32)
|
|
shared_secret = bytes(x ^ y for x, y in zip(shared_key, preimage))
|
|
cipher = Cipher(algorithms.AES(shared_secret), modes.GCM(bytes(16)), backend=default_backend())
|
|
encryptor = cipher.encryptor()
|
|
ciphertext = (encryptor.update(secret.encode('utf-8')) + encryptor.finalize()).hex()
|
|
auth_tag = encryptor.tag.hex()
|
|
time_to_expiry = (trade_details.expiry-datetime.now()).seconds if trade_details.expiry else None
|
|
default_expiry = 30*60
|
|
inv_expiry = default_expiry if time_to_expiry is None or time_to_expiry > default_expiry else time_to_expiry
|
|
if inv_expiry > 0:
|
|
final_invoice = stub.AddInvoice(ln.Invoice(memo=trade_details.description, value=trade_details.price, expiry=inv_expiry, r_preimage=preimage))
|
|
trade_data = encode_peer_response({'failure': None, 'id':request['id'], 'records': [{'type':'1', 'value':encode_final_trade(auth_tag, ciphertext, final_invoice.payment_request)}]})
|
|
trade_details.sale_count = F('sale_count') + 1
|
|
trade_details.save()
|
|
stub.SendCustomMessage(ln.SendCustomMessageRequest(peer=from_peer, type=32768, data=bytes.fromhex(trade_data)))
|
|
else:
|
|
logger.error(f'Expected request type in message: {request["id"]}')
|
|
if 'response' in msg_response:
|
|
request = msg_response['response']
|
|
if 'failure' in request and request['failure'] != None:
|
|
# failure message returned
|
|
logger.error(f'Failure: {request["failure"]}')
|
|
else:
|
|
if len(request['records']) == 0:
|
|
# message acknowledgements
|
|
logger.info(f'ACK | ID: {request["id"]}')
|
|
|
|
async def get_open_trades(astub, results):
|
|
try:
|
|
async for response in astub.SubscribeCustomMessages(ln.SubscribeCustomMessagesRequest()):
|
|
if response.type == 32768:
|
|
# from_peer = response.peer
|
|
msg_type = response.type
|
|
message = response.data.hex()
|
|
if msg_type == 32768 and message.lower().startswith('626f73ff'):
|
|
msg_response = parse_peer_request_message(message)
|
|
if 'request' in msg_response:
|
|
request = msg_response['request']
|
|
if 'type' in request:
|
|
req_type = request['type']
|
|
if req_type == '8050006': # request a buyer to select the trade provided
|
|
# select a trade from the records
|
|
trade = decode_basic_trade(request['records'])
|
|
print('BUYER ACTION', '|', 'ID:', request['id'], '|', 'Trade:', trade)
|
|
results.append({'id': request['id'], 'trade': trade})
|
|
else:
|
|
raise ValueError('ExpectedRequestTypeInRequestMessage')
|
|
if 'response' in msg_response:
|
|
request = msg_response['response']
|
|
if 'failure' in request and request['failure'] != None:
|
|
# failure message returned
|
|
print('Failure:', request['failure'])
|
|
return
|
|
else:
|
|
if len(request['records']) == 0:
|
|
# message acknowledgements
|
|
print('ACK', '|', 'ID:', request['id'])
|
|
else:
|
|
# buyer to pay invoice and get secret their secret from preimage
|
|
print('BUYER FINALIZE', '|', 'ID:', request['id'], '|', 'Records:', request['records'])
|
|
return request['records']
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception as e:
|
|
print('Error runnig task:', str(e))
|
|
|
|
def encode_nodes_data(data, network_value):
|
|
records = [{'type': '0', 'value': '01'}]
|
|
if network_value:
|
|
records.append({'type': '1', 'value': network_value})
|
|
|
|
node_records = []
|
|
for idx, node in enumerate(data):
|
|
node_record = [{ 'type': '2', 'value': node['id'] }]
|
|
encoded_node_record = ''.join([encode_tlv_record(record) for record in node_record])
|
|
node_records.append({'type': str(idx), 'value': encoded_node_record})
|
|
records.append({'type': '4', 'value': ''.join([encode_tlv_record(record) for record in node_records])})
|
|
nodes_encoded = '626f73ff' + ''.join([encode_tlv_record(record) for record in records])
|
|
return nodes_encoded
|
|
|
|
def decode_node_record(encoded):
|
|
channel_hex_length = 16
|
|
if not encoded:
|
|
raise ValueError('ExpectedEncodedNodeRecordToGetNodePointer')
|
|
|
|
records = decode_tlv_stream(encoded['value'])
|
|
high_key_record = next((n for n in records if n['type'] == '1'), None)
|
|
|
|
if high_key_record and len(high_key_record['value']) != channel_hex_length:
|
|
raise ValueError('ExpectedChannelIdInHighKeyRecord')
|
|
if high_key_record:
|
|
return {'high_channel': high_key_record['value']}
|
|
|
|
low_key_record = next((n for n in records if n['type'] == '0'), None)
|
|
if low_key_record and len(low_key_record['value']) != channel_hex_length:
|
|
raise ValueError('ExpectedChannelIdInLowKeyRecord')
|
|
if low_key_record:
|
|
return {'low_channel': low_key_record['value']}
|
|
|
|
id_record = next((n for n in records if n['type'] == '2'), None)
|
|
if not id_record:
|
|
raise ValueError('ExpectedNodeIdRecordToMapNodeRecordToNodePointer')
|
|
|
|
if not (bool(id_record['value']) and re.match(r'^0[2-3][0-9A-F]{64}$', id_record['value'], re.I)):
|
|
raise ValueError('ExpectedNodeIdPublicKeyToMapNodeRecordToNodePointer')
|
|
|
|
return {'node': {'id': id_record['value']}}
|
|
|
|
def decode_open_trade(network, records):
|
|
if not network:
|
|
raise ValueError('ExpectedNetworkNameToDecodeOpenTrade')
|
|
if not isinstance(records, list):
|
|
raise ValueError('ExpectedArrayOfRecordsToDecodeOpenTrade')
|
|
|
|
nodes_record = next((n for n in records if n['type'] == '4'), None)
|
|
id_record = next((n for n in records if n['type'] == '5'), None)
|
|
if not nodes_record:
|
|
raise ValueError('ExpectedNodesRecordToDecodeOpenTradeDetails')
|
|
|
|
try:
|
|
decode_tlv_stream(nodes_record['value'])
|
|
except:
|
|
raise ValueError('ExpectedValidNodesTlvStreamToDecodeOpenTradeDetails')
|
|
|
|
node_records = decode_tlv_stream(nodes_record['value'])
|
|
if not node_records:
|
|
raise ValueError('ExpectedNodeRecordsForOpenTrade')
|
|
|
|
return network, id_record['value'] if id_record else None, [decode_node_record(value) for value in node_records]
|
|
|
|
def decode_trade_data(encoded):
|
|
if not encoded.lower().startswith('626f73ff'):
|
|
raise ValueError('UnexpectedFormatOfTradeToDecode')
|
|
try:
|
|
decoded_trade = decode_tlv_stream(encoded[8:])
|
|
except:
|
|
raise ValueError('ExpectedValidTlvStreamForTradeData')
|
|
records = decoded_trade
|
|
|
|
network_value = next((record for record in records if record['type'] == '1'), None)
|
|
if network_value:
|
|
if network_value['value'] == '01':
|
|
if settings.LND_NETWORK == 'testnet':
|
|
network = 'testnet'
|
|
else:
|
|
raise ValueError('TradeRequestForAnotherNetwork')
|
|
elif network_value['value'] =='02':
|
|
if settings.LND_NETWORK == 'regtest':
|
|
network = 'regtest'
|
|
else:
|
|
raise ValueError('TradeRequestForAnotherNetwork')
|
|
else:
|
|
raise ValueError('UnknownNetworkNameToDeriveNetworkRecordFor')
|
|
else:
|
|
if settings.LND_NETWORK == 'mainnet':
|
|
network = 'bitcoin'
|
|
else:
|
|
raise ValueError('TradeRequestForAnotherNetwork')
|
|
|
|
request = next((record for record in records if record['type'] == '2'), None)
|
|
details = next((record for record in records if record['type'] == '3'), None)
|
|
swap = next((record for record in records if record['type'] == '6'), None)
|
|
|
|
if request and details:
|
|
return {'secret': decode_final_trade(network, request, details)}
|
|
elif request:
|
|
pass # just a payment
|
|
elif swap:
|
|
pass # swap offer
|
|
else:
|
|
return {'connect': decode_open_trade(network, records)}
|
|
|
|
def encode_trade(description, price, secret):
|
|
anchorPrefix = 'anchor-trade-secret:'
|
|
elements = [
|
|
{'value': secret, 'type': '0'},
|
|
{'value': description, 'type': '1'},
|
|
{'value': price, 'type': '2'},
|
|
]
|
|
|
|
records = [
|
|
{'type': record['type'], 'value': bytes(record['value'], 'utf-8').hex()}
|
|
for record in elements if record
|
|
]
|
|
encoded_data = ''.join([encode_tlv_record(record) for record in records])
|
|
encoded = anchorPrefix + base64.b64encode(bytes.fromhex(encoded_data)).decode()
|
|
|
|
return encoded
|
|
|
|
def create_trade_details(stub):
|
|
nodes = [{'id':stub.GetInfo(ln.GetInfoRequest()).identity_pubkey}]
|
|
lnd_network = settings.LND_NETWORK
|
|
if lnd_network == 'mainnet':
|
|
network_value = None
|
|
elif lnd_network == 'testnet':
|
|
network_value = '01'
|
|
elif lnd_network == 'regtest':
|
|
network_value = '02'
|
|
else:
|
|
raise ValueError('UnsupportedNetworkForTrades')
|
|
return encode_nodes_data(nodes, network_value)
|
|
|
|
def create_trade_anchor(stub, description, price, secret, expiry):
|
|
try:
|
|
encoded_trade = encode_trade(description, price, secret)
|
|
stub.AddInvoice(ln.Invoice(value=int(price), expiry=int(expiry)*60*60*24, memo=encoded_trade))
|
|
except Exception as e:
|
|
print('Error creating trade:', str(e))
|
|
print('Trade Anchor:', encoded_trade)
|
|
|
|
async def request_trades(to_peer):
|
|
results = []
|
|
start_time = time()
|
|
astub = lnrpc.LightningStub(async_lnd_connect())
|
|
task = asyncio.create_task(get_open_trades(astub, results))
|
|
asyncio.gather(task)
|
|
trade_data = encode_peer_request({'id':secrets.token_bytes(32).hex(), 'type':'8050005', 'records':[{'type': '1', 'value': secrets.token_bytes(32).hex()}]})
|
|
astub.SendCustomMessage(ln.SendCustomMessageRequest(peer=bytes.fromhex(to_peer), type=32768, data=bytes.fromhex(trade_data)))
|
|
while len(results) == 0:
|
|
if (time() - start_time) < 30:
|
|
await asyncio.sleep(1)
|
|
else:
|
|
print('Timeout waiting for trade records from peer.')
|
|
task.cancel()
|
|
return None
|
|
await asyncio.sleep(1)
|
|
if len(results) == 1:
|
|
ack_data = encode_peer_response({'failure':None, 'id': results[0]['id'], 'records':[]})
|
|
astub.SendCustomMessage(ln.SendCustomMessageRequest(peer=bytes.fromhex(to_peer), type=32768, data=bytes.fromhex(ack_data)))
|
|
choice = 1
|
|
else:
|
|
print('Select a trade to buy:')
|
|
for idx, trade in enumerate(results, start=1):
|
|
ack_data = encode_peer_response({'failure':None, 'id': trade['id'], 'records':[]})
|
|
astub.SendCustomMessage(ln.SendCustomMessageRequest(peer=bytes.fromhex(to_peer), type=32768, data=bytes.fromhex(ack_data)))
|
|
print(f"{idx}. {trade['trade']['description']}")
|
|
while True:
|
|
try:
|
|
choice = int(input("Enter the number of your choice: "))
|
|
if 1 <= choice <= len(results):
|
|
break
|
|
else:
|
|
print("Invalid input. Please enter a valid option.")
|
|
except ValueError:
|
|
print("Invalid input. Please enter a number for your selection.")
|
|
selected_trade = results[choice - 1]
|
|
trade_data = encode_peer_request({'id':secrets.token_bytes(32).hex(), 'type':'8050005', 'records':[{'type': '0', 'value': selected_trade['trade']['id']}]})
|
|
astub.SendCustomMessage(ln.SendCustomMessageRequest(peer=bytes.fromhex(to_peer), type=32768, data=bytes.fromhex(trade_data)))
|
|
return await task
|
|
|
|
def decrypt_secret(stub, decoded_trade):
|
|
invoice, auth, payload = decoded_trade['secret']
|
|
decoded_invoice = stub.DecodePayReq(ln.PayReqString(pay_req=invoice))
|
|
print('Invoice for secret decoded.')
|
|
print('Destination:', decoded_invoice.destination)
|
|
print('Amount:', decoded_invoice.num_satoshis)
|
|
print('Description:', decoded_invoice.description)
|
|
ask_pay = input('Pay the invoice and decrypt the secret? [y/N]: ')
|
|
if ask_pay.lower() == 'y':
|
|
routerstub = lnrouter.RouterStub(lnd_connect())
|
|
for response in routerstub.SendPaymentV2(lnr.SendPaymentRequest(payment_request=invoice, timeout_seconds=60)):
|
|
if response.status == 2:
|
|
print('Payment paid!')
|
|
preimage = bytes.fromhex(response.payment_preimage)
|
|
if response.status > 2:
|
|
print('Payment failed. Please try again.')
|
|
return
|
|
signerstub = lnsigner.SignerStub(lnd_connect())
|
|
shared_key = signerstub.DeriveSharedKey(lns.SharedKeyRequest(ephemeral_pubkey=bytes.fromhex(decoded_invoice.destination))).shared_key
|
|
shared_secret = bytes(x ^ y for x, y in zip(shared_key, preimage))
|
|
cipher = Cipher(algorithms.AES(shared_secret), modes.GCM(bytes(16), bytes.fromhex(auth)), backend=default_backend())
|
|
decryptor = cipher.decryptor()
|
|
decrypted = decryptor.update(bytes.fromhex(payload)) + decryptor.finalize()
|
|
print('Successfully decrypted the secret:', decrypted.decode('utf-8'))
|
|
|
|
def main():
|
|
options = ["Buy A Trade", "Setup A Sale", "Serve Trades"]
|
|
print("Select an option:")
|
|
for idx, option in enumerate(options, start=1):
|
|
print(f"{idx}. {option}")
|
|
while True:
|
|
try:
|
|
choice = int(input("Enter the number of your choice: "))
|
|
if 1 <= choice <= len(options):
|
|
break
|
|
else:
|
|
print("Invalid input. Please enter a valid option.")
|
|
except ValueError:
|
|
print("Invalid input. Please enter a number for your selection.")
|
|
selected_option = options[choice - 1]
|
|
stub = lnrpc.LightningStub(lnd_connect())
|
|
if selected_option == 'Setup A Sale':
|
|
description = input('Enter a description for the trade: ')
|
|
price = input('Enter the price to charge in sats: ')
|
|
expiry = input('Enter desired days until trade expiry: ')
|
|
secret = input('Enter the secret you want to sell: ')
|
|
create_trade_anchor(stub, description, price, secret, expiry)
|
|
ask_serve = input('Start serving trades? y/N: ')
|
|
if ask_serve.lower() == 'y':
|
|
print('Generic Trade Link:', create_trade_details(stub))
|
|
serve_trades(stub)
|
|
if selected_option == 'Serve Trades':
|
|
print('Generic Trade Link:', create_trade_details(stub))
|
|
serve_trades(stub)
|
|
if selected_option == 'Buy A Trade':
|
|
trade = input('Enter an encoded trade: ')
|
|
decoded_trade = decode_trade_data(trade)
|
|
if 'secret' in decoded_trade:
|
|
decrypt_secret(stub, decoded_trade)
|
|
if 'connect' in decoded_trade:
|
|
network, id, connection = decoded_trade['connect']
|
|
if 'node' in connection[0]:
|
|
try:
|
|
to_peer = connection[0]['node']['id']
|
|
if not (Peers.objects.filter(pubkey=to_peer).exists() and Peers.objects.filter(pubkey=to_peer)[0].connected == True):
|
|
host = stub.GetNodeInfo(ln.NodeInfoRequest(pub_key=to_peer, include_channels=False)).node.addresses[0].addr
|
|
stub.ConnectPeer(ln.ConnectPeerRequest(addr=ln.LightningAddress(pubkey=to_peer, host=host), timeout=60))
|
|
except:
|
|
raise ValueError('PeerConnectionError')
|
|
else:
|
|
raise ValueError('NoPeerFoundInConnectionData')
|
|
trade = asyncio.run(request_trades(to_peer))
|
|
if trade:
|
|
trade_data = next((record for record in trade if record['type'] == '1'), None)
|
|
if trade_data:
|
|
decoded_trade = decode_trade_data(trade_data['value'])
|
|
decrypt_secret(stub, decoded_trade)
|
|
|
|
if __name__ == '__main__':
|
|
main() |