├── LastSaleDatetime ├── Procfile ├── requirements.txt ├── .env ├── README.md ├── config.py ├── address.py └── main.py /LastSaleDatetime: -------------------------------------------------------------------------------- 1 | 0 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: python main.py 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-dotenv==0.20.0 2 | pytz==2021.3 3 | requests==2.27.1 4 | schedule==1.1.0 5 | telepot==12.7 6 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | COLLECTION_ADDRESS="EQAo92DYMokxghKcq-CkCGSk_MgXY5Fo1SPW20gkvZl75iCN" 2 | ROYALTY_ADDRESS="EQABh4JBalyRKN42tZB1jevT3BheWqHYjkhSv3zoHldqqRJs" 3 | BOT_TOKEN="" 4 | TONCENTER_API="" 5 | CHATS="-1001536482073;-1001627286419" 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TON NFT Resales Notificator 2 | :gem: Bot notifying about resales of any TON NFT collection 3 | 4 | ## How to run bot 5 | 6 | ### .env variables 7 | 8 | You need to specify these env variables to run this bot. If you run it locally, you can also write them in `.env` text file. 9 | 10 | ``` bash 11 | CHATS= # Chats to send resell notifications to (separated by semicolons) // Example: "-1001536482073;-1001627286419" 12 | BOT_TOKEN= # Telegram bot API token from @BotFather // Example: "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ" 13 | ROYALTY_ADDRESS= # TON address to which royalties from resales are accrued // Example: "EQABh4JBalyRKN42tZB1jevT3BheWqHYjkhSv3zoHldqqRJs" 14 | COLLECTION_ADDRESS= # TON address of NFT collection // Example: "EQAo92DYMokxghKcq-CkCGSk_MgXY5Fo1SPW20gkvZl75iCN" 15 | TONCENTER_API_KEY= # toncenter.com API key from @tonapibot 16 | 17 | ``` 18 | 19 | ### Run bot locally 20 | 21 | First, you need to install all dependencies: 22 | 23 | ```bash 24 | pip install -r requirements.txt 25 | ``` 26 | 27 | Then you can run the bot. Don't forget to fill in the `.env` file in the root folder with all required params (read above). 28 | 29 | ``` bash 30 | python main.py 31 | ``` 32 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | 4 | import telepot 5 | from dotenv import load_dotenv 6 | from address import * 7 | 8 | load_dotenv() 9 | 10 | TONCENTER_API = os.getenv('TONCENTER_API') 11 | BOT_TOKEN = os.getenv('BOT_TOKEN') 12 | ROYALTY_ADDRESS = detect_address(os.getenv('ROYALTY_ADDRESS'))['bounceable']['b64url'] 13 | COLLECTION_ADDRESS = os.getenv('COLLECTION_ADDRESS') 14 | chats = list(map(int, os.getenv('CHATS').split(';'))) 15 | 16 | bot = telepot.Bot(BOT_TOKEN) 17 | 18 | current_path = pathlib.Path(__file__).parent.resolve() 19 | 20 | disintar = { 21 | 'headers': { 22 | 'cookie': 'csrftoken=vO44viQbEDlbcKiLz56aaE8tLmjzc5XtVqUTZo6zgItxRNehF7VRGlCVdQR3dul9', 23 | 'x-csrftoken': 'vO44viQbEDlbcKiLz56aaE8tLmjzc5XtVqUTZo6zgItxRNehF7VRGlCVdQR3dul9', 24 | 'referer': 'https://beta.disintar.io/object/', 25 | 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0'}, 26 | 'get_floor': {'entity_name': 'Collection', 27 | 'order_by': '["creation_date"]', 28 | 'filter_by': '[{"name":"address","value":' + 29 | detect_address(COLLECTION_ADDRESS)['bounceable']['b64url'] + '}]', 30 | 'limit': 'null', 31 | 'group_by': 'null', 32 | 'page': '0', 'request_time': 'undefined'} 33 | } 34 | 35 | getgems_query = "query nftSearch($count: Int!, $cursor: String, $query: String, $sort: String) {\n alphaNftItemSearch(first: $count, after: $cursor, query: $query, sort: $sort) {\n edges {\n node {\n ...nftPreview\n __typename\n }\n cursor\n __typename\n }\n info {\n hasNextPage\n __typename\n }\n __typename\n }\n}\n\nfragment nftPreview on NftItem {\n name\n previewImage: content {\n ... on NftContentImage {\n image {\n sized(width: 500, height: 500)\n __typename\n }\n __typename\n }\n ... on NftContentLottie {\n lottie\n fallbackImage: image {\n sized(width: 500, height: 500)\n __typename\n }\n __typename\n }\n __typename\n }\n address\n collection {\n name\n address\n __typename\n }\n sale {\n ... on NftSaleFixPrice {\n fullPrice\n __typename\n }\n __typename\n }\n __typename\n}" 36 | 37 | getgems_data = {'operationName': 'nftSearch', 38 | 'query': getgems_query, 39 | 'variables': { 40 | 'count': 30, 41 | 'query': '{"$and":[{"collectionAddress":"' + detect_address(COLLECTION_ADDRESS)['bounceable']['b64url'] + '"}]}', 42 | 'sort': '[{"isOnSale":{"order":"desc"}},{"price":{"order":"asc"}},{"index":{"order":"asc"}}]' 43 | }} 44 | 45 | main_params = {'address': ROYALTY_ADDRESS, 'limit': '100', 'to_lt': '0', 46 | 'archival': 'false', 'api_key': TONCENTER_API} 47 | 48 | second_params = {'limit': '15', 'to_lt': '0', 'archival': 'false', 49 | 'api_key': TONCENTER_API} 50 | 51 | marketplaces = {'EQDrLq-X6jKZNHAScgghh0h1iog3StK71zn8dcmrOj8jPWRA': 'Disintar', 52 | 'EQCjk1hh952vWaE9bRguFkAhDAL5jj3xj9p0uPWrFBq_GEMS': 'GetGems'} 53 | -------------------------------------------------------------------------------- /address.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | """ 4 | https://github.com/toncenter/pytonlib/blob/e2093be2ccf03a0dc9dd424df999c5b9f77beac8/pytonlib/utils/address.py 5 | """ 6 | 7 | bounceable_tag, non_bounceable_tag = b'\x11', b'\x51' 8 | b64_abc = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890+/') 9 | b64_abc_urlsafe = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890_-') 10 | 11 | 12 | def is_int(x): 13 | try: 14 | int(x) 15 | return True 16 | except: 17 | return False 18 | 19 | 20 | def is_hex(x): 21 | try: 22 | int(x, 16) 23 | return True 24 | except: 25 | return False 26 | 27 | 28 | def calcCRC(message): 29 | poly = 0x1021 30 | reg = 0 31 | message += b'\x00\x00' 32 | for byte in message: 33 | mask = 0x80 34 | while(mask > 0): 35 | reg <<= 1 36 | if byte & mask: 37 | reg += 1 38 | mask >>= 1 39 | if reg > 0xffff: 40 | reg &= 0xffff 41 | reg ^= poly 42 | return reg.to_bytes(2, "big") 43 | 44 | 45 | def account_forms(raw_form, test_only=False): 46 | workchain, address = raw_form.split(":") 47 | workchain, address = int(workchain), int(address, 16) 48 | address = address.to_bytes(32, "big") 49 | workchain_tag = b'\xff' if workchain == -1 else workchain.to_bytes(1, "big") 50 | btag = bounceable_tag 51 | nbtag = non_bounceable_tag 52 | # if test_only: 53 | # btag = (btag[0] | 0x80).to_bytes(1,'big') 54 | # nbtag = (nbtag[0] | 0x80).to_bytes(1,'big') 55 | preaddr_b = btag + workchain_tag + address 56 | preaddr_u = nbtag + workchain_tag + address 57 | b64_b = base64.b64encode(preaddr_b+calcCRC(preaddr_b)).decode('utf8') 58 | b64_u = base64.b64encode(preaddr_u+calcCRC(preaddr_u)).decode('utf8') 59 | b64_b_us = base64.urlsafe_b64encode(preaddr_b+calcCRC(preaddr_b)).decode('utf8') 60 | b64_u_us = base64.urlsafe_b64encode(preaddr_u+calcCRC(preaddr_u)).decode('utf8') 61 | return {'raw_form': raw_form, 62 | 'bounceable': {'b64': b64_b, 'b64url': b64_b_us}, 63 | 'non_bounceable': {'b64': b64_u, 'b64url': b64_u_us}, 64 | 'given_type': 'raw_form', 65 | 'test_only': test_only} 66 | 67 | 68 | def read_friendly_address(address): 69 | urlsafe = False 70 | if set(address).issubset(b64_abc): 71 | address_bytes = base64.b64decode(address.encode('utf8')) 72 | elif set(address).issubset(b64_abc_urlsafe): 73 | urlsafe = True 74 | address_bytes = base64.urlsafe_b64decode(address.encode('utf8')) 75 | else: 76 | raise Exception("Not an address") 77 | if not calcCRC(address_bytes[:-2]) == address_bytes[-2:]: 78 | raise Exception("Wrong checksum") 79 | tag = address_bytes[0] 80 | if tag & 0x80: 81 | test_only = True 82 | tag = tag ^ 0x80 83 | else: 84 | test_only = False 85 | tag = tag.to_bytes(1, 'big') 86 | if tag == bounceable_tag: 87 | bounceable = True 88 | elif tag == non_bounceable_tag: 89 | bounceable = False 90 | else: 91 | raise Exception("Unknown tag") 92 | if address_bytes[1:2] == b'\xff': 93 | workchain = -1 94 | else: 95 | workchain = address_bytes[1] 96 | hx = hex(int.from_bytes(address_bytes[2:-2], "big"))[2:] 97 | hx = (64-len(hx))*"0"+hx 98 | raw_form = str(workchain)+":"+hx 99 | account = account_forms(raw_form, test_only) 100 | account['given_type'] = "friendly_"+("bounceable" if bounceable else "non_bounceable") 101 | return account 102 | 103 | 104 | def detect_address(unknown_form): 105 | if is_hex(unknown_form): 106 | return account_forms("-1:"+unknown_form) 107 | elif (":" in unknown_form) and is_int(unknown_form.split(":")[0]) and is_hex(unknown_form.split(":")[1]): 108 | return account_forms(unknown_form) 109 | else: 110 | return read_friendly_address(unknown_form) 111 | 112 | 113 | def prepare_address(unknown_form): 114 | address = detect_address(unknown_form) 115 | if 'non_bounceable' in address['given_type']: 116 | return address["non_bounceable"]["b64"] 117 | return address["bounceable"]["b64"] 118 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import requests 3 | import time 4 | import json 5 | from datetime import datetime 6 | import pytz 7 | from config import * 8 | import schedule 9 | 10 | 11 | def main(): 12 | old_utime = open(f'{current_path}/LastSaleDatetime', 'r').read() 13 | try: 14 | last_utime = int(open(f'{current_path}/LastSaleDatetime', 'r').read()) 15 | success = False 16 | while not success: 17 | try: 18 | response = json.loads(requests.get( 19 | "https://toncenter.com/api/v2/getTransactions", params=main_params).text) 20 | response = response['result'] 21 | success = True 22 | except: 23 | print('Get Request Failed') 24 | for transaction in response[::-1]: 25 | if transaction['utime'] <= last_utime: 26 | continue 27 | transaction = transaction['in_msg'] 28 | if transaction['@type'] == 'raw.message' and transaction['message'] == '': 29 | address = transaction['source'] 30 | second_params['address'] = address 31 | transactions = json.loads(requests.get( 32 | "https://toncenter.com/api/v2/getTransactions", params=second_params).text) 33 | prices, timings = [], [] 34 | isnft = False 35 | market = 'Unknown Marketplace' 36 | for i in transactions['result'][::-1]: 37 | try: 38 | for out in i['out_msgs']: 39 | try: 40 | market = marketplaces[out['destination']] 41 | break 42 | except: 43 | pass 44 | except: 45 | pass 46 | msg = i['in_msg'] 47 | time.sleep(1) 48 | try: 49 | nft = json.loads( 50 | requests.get(f'https://tonapi.io/v1/nft/getItem?account={msg["source"]}').text) 51 | if nft['collection_address'] == detect_address(COLLECTION_ADDRESS)['raw_form']: 52 | data = nft['metadata'] 53 | name = data['name'] 54 | image = data['image'] 55 | nft_address = msg['source'] 56 | isnft = True 57 | except: 58 | if i['utime'] <= last_utime: 59 | continue 60 | else: 61 | open(f'{current_path}/LastSaleDatetime', 'w').write(str(i['utime'])) 62 | price = int(msg['value']) 63 | if price >= 1000000000: 64 | prices += [price / (10 ** 9)] 65 | timings += [str(datetime.fromtimestamp(i['utime'], tz=pytz.utc).strftime('%d.%m.%Y %H:%M:%S'))] 66 | if isnft: 67 | print(f'New deal on {market}: {name}') 68 | price = round(float(max(prices)), 3) 69 | emoji = '💎' 70 | floor = {'disintar': [10**20, ''], 71 | 'getgems': [10**20, '']} 72 | try: 73 | getgems_floor = json.loads(requests.post('https://api.getgems.io/graphql', json=getgems_data).text)['data']['alphaNftItemSearch']['edges'] 74 | number = 0 75 | error = True 76 | while error: 77 | try: 78 | gg_floor_num = getgems_floor[number]['node'] 79 | floor['getgems'] = [round(float(gg_floor_num['sale']['fullPrice']) / (10 ** 9), 3), 80 | f"https://getgems.io/collection/{detect_address(COLLECTION_ADDRESS)['bounceable']['b64url']}/{gg_floor_num['address']}"] 81 | error = False 82 | except: 83 | pass 84 | number += 1 85 | except: 86 | print('Get GetGems Floor Failed') 87 | try: 88 | disintar_floor = json.loads(requests.post('https://beta.disintar.io/api/get_entities/', 89 | headers=disintar['headers'], 90 | data=disintar['get_floor']).text)['data'][0] 91 | floor['disintar'] = [round(float(disintar_floor['price']), 3), 92 | f"https://beta.disintar.io/object/{disintar_floor['address']}"] 93 | except: 94 | print('Get Disintar Floor Failed') 95 | try: 96 | floor_market = 'disintar' if floor['disintar'][0] < floor['getgems'][0] else 'getgems' 97 | floor_price, floor_link = floor[floor_market] 98 | floor_text = f'🔽 Current floor: {floor_price} TON' 99 | if price <= floor_price * 1.1: 100 | emoji = '🍣' 101 | except: 102 | floor_text = '' 103 | print('Get Floor Failed') 104 | buy_time = timings[prices.index(max(prices))] 105 | try: 106 | dollars = json.loads(requests.get('https://ru.ton.org/getpriceg/').text)['the-open-network']['usd'] 107 | dollars = str(round(float(price) * float(dollars), 2)) 108 | dollars_text = f'({dollars}$)' 109 | except: 110 | dollars_text = '' 111 | print('Get Dollars Failed') 112 | message_text = f'👾 {name}\n\n' \ 113 | f'' \ 114 | f'{emoji} #Purchased for {price} TON {dollars_text} on {market}\n\n' \ 115 | '' \ 116 | f'⌚ Date and time of buy: {buy_time} UTC\n' \ 117 | f'{floor_text}' 118 | for chat in chats: 119 | try: 120 | bot.sendPhoto(chat, photo=image, caption=message_text, parse_mode='HTML') 121 | except Exception as e: 122 | print(f'Photo Send ({chat}) Failed: {e}') 123 | try: 124 | bot.sendMessage(chat, message_text, parse_mode='HTML', disable_web_page_preview=True) 125 | except Exception as e: 126 | print(f'Message Send ({chat}) Failed: {e}') 127 | except Exception as e: 128 | print(e) 129 | 130 | 131 | schedule.every(15).seconds.do(main) 132 | 133 | while True: 134 | schedule.run_pending() 135 | time.sleep(1) 136 | --------------------------------------------------------------------------------