├── .github └── workflows │ └── main.yml ├── .gitignore ├── README.md ├── bot ├── .gitignore ├── backends.py ├── cli │ ├── about.py │ ├── assets │ │ ├── contract-job.boc │ │ └── contract-offer.boc │ ├── bcutils.py │ ├── colors.py │ ├── contracts.py │ ├── dnsresolver.py │ ├── jobs.py │ ├── keyring.py │ ├── offers.py │ ├── polyfills.py │ ├── signing.py │ └── tslice.py ├── install_libs.py ├── keyutils.py ├── main.py ├── persistence.py ├── stateful.py ├── states.py ├── textutils.py └── tg.py ├── cli ├── about.py ├── assets │ ├── contract-job.boc │ └── contract-offer.boc ├── bcutils.py ├── colors.py ├── contracts.py ├── jobs.py ├── keyring.py ├── offers.py ├── polyfills.py ├── signing.py └── tslice.py ├── cli_main.py ├── contracts ├── build.py ├── build │ ├── boc │ │ ├── contract-job.boc │ │ ├── contract-job.hex │ │ ├── contract-offer.boc │ │ └── contract-offer.hex │ ├── contract-job.fif │ ├── contract-job_tests.fif │ ├── contract-offer.fif │ └── contract-offer_tests.fif ├── fift │ ├── config-validators.boc │ ├── exotic.fif │ └── usage.fif ├── func │ ├── job-contract.fc │ ├── offer-contract.fc │ ├── opcodes.fc │ ├── stdlib-ext.fc │ ├── stdlib.fc │ └── tlb.fc ├── project.yaml ├── show-log.py ├── tests │ ├── tests-job.fc │ └── tests-offer.fc ├── toncli.err └── toncli.log ├── freelance-highlevel.idr ├── interaction.tlb ├── main.js └── web ├── assets ├── index-GKpznQ_X.css └── index-d_kZnM7Q.js ├── global.css ├── images ├── cube.png ├── favicon.ico └── ratelance.png ├── index.html └── vite.svg /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | 4 | jobs: 5 | compile_idris: 6 | name: Formal verification 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v3.3.0 12 | 13 | - name: Cache LinuxBrew 14 | id: cache-linuxbrew 15 | uses: actions/cache@v3 16 | with: 17 | path: /home/linuxbrew/ 18 | key: ${{ runner.os }}-${{ matrix.ghc }} 19 | 20 | - name: Install Brew 21 | if: steps.cache-linuxbrew.outputs.cache-hit != 'true' 22 | run: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 23 | 24 | - name: Install Idris 25 | if: steps.cache-linuxbrew.outputs.cache-hit != 'true' 26 | run: /home/linuxbrew/.linuxbrew/bin/brew install idris2 27 | 28 | - name: Search for Idris (to speed up trial-and-failure loop) 29 | run: | 30 | echo $PATH 31 | find /home/linuxbrew/ -name "*idris2*" 32 | 33 | - name: Compile formal description 34 | run: /home/linuxbrew/.linuxbrew/bin/idris2 ./freelance-highlevel.idr -o ./contracts-fd 35 | 36 | - name: Show directory to locate executable (to speed up trial-and-failure loop) 37 | run: tree -D 38 | 39 | - name: Dump .TTC file 40 | run: cat ./build/ttc/freelance-highlevel.ttc 41 | 42 | - name: Dump .TTM file 43 | run: cat ./build/ttc/freelance-highlevel.ttm 44 | 45 | - name: Test executable 46 | run: /home/linuxbrew/.linuxbrew/bin/scheme-script ./build/exec/contracts-fd_app/contracts-fd.ss 47 | 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # due to GPL license instead of MIT 2 | Disasm.fif 3 | 4 | seed.pk 5 | __pycache__ 6 | *.pyc 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ratelance 2 | 3 | Ratelance platform poster 4 | 5 | Ratelance is freelance platform that seeks to remove barriers between potential employers and workers. 6 | Our goal is to create a secure, reliable and formally verified platform that makes it easy to delegate tasks. 7 | 8 | We are focused on creating an environment of collaboration and DeTrust :handshake:, while helping individuals and businesses to 9 | - :mag: locate the right freelancer for their project, 10 | - :file_folder: verify his previous works, 11 | - :white_check_mark: sign a contract with selectible cost and stakes, 12 | - :lock: provide an encrypted communication channel and 13 | - :rocket: make it easy to complete the tasks! 14 | 15 | ## Contact us 16 | 17 | - Read latest news: https://t.me/ratelance 18 | - Vote for us in different hackatons by DoraHacks: https://dorahacks.io/buidl/4227 19 | - Propose something, report a bug, etc: [issues](https://github.com/ProgramCrafter/ratelance/issues) 20 | 21 | ## Install 22 | 23 | ``` 24 | $ git clone https://github.com/ProgramCrafter/ratelance.git 25 | $ pip3 install --upgrade tonsdk pynacl requests bitarray bitstring==3.1.9 26 | $ cd ratelance 27 | $ python3 cli_main.py 28 | ``` 29 | 30 | ## Pros of this system 31 | 32 | 1. There are no TODOs in contracts; their code is hopefully final 33 | 1. Contracts' states are simple, which makes it easier to verify their correctness 34 | 1. Distribution of reward is flexible, which allows worker and job poster to negotiate half-payment or something more interesting 35 | 1. System is symmetric, which allows workers to post announcements that they are ready to take some jobs 36 | 1. Contracts are written in FunC; that ensures less errors while converting to TVM assembly than high-level languages like Tact 37 | 1. System utilizes newest TON features, such as wallet v4 plugins 38 | 1. CLI is almost ready to be used, it covers whole contract lifetime 39 | 40 | ## Current state 41 | 42 | - `contracts` :white_check_mark: 43 | - platform contracts with unit tests 44 | - tests are based on toncli; they can be run as `cd contracts; python3 build.py` 45 | - test results from the build system are located in `contracts/toncli.log`; all tests pass successfully 46 | - compiled contract codes are located in `contracts/build/boc` directory 47 | - `cli` 48 | - [x] Getting information about jobs, posting and revoking 49 | - [x] Applying for jobs, revoking offers 50 | - [x] Listing offers, delegating job to someone 51 | - [ ] Automatically verifying job/offer existence and validity 52 | - [ ] Encrypted communication channel on top of job contract 53 | - [x] Negotiating payments 54 | - [x] Working with keyring 55 | - [ ] Integration with system keys storage/using password for keyring encryption 56 | - **used contract codes are located in `cli/assets`, they are not synchronized automatically on recompilation** 57 | - `freelance-highlevel.idr` 58 | - [x] High-level contracts representation 59 | - [x] Transaction loop implementation 60 | - Further work on formal contract description is suspended, as it means simulating something at least closely similar to TON, and as such it should be funded separately. 61 | 62 | ## Algorithm described in detail 63 | 64 | ### Common user story 65 | 66 | 1. Poster creates an job contract sending his stake (order value + fee + safety deposit) to it. 67 | 2. An analytic message is sent by poster to pre-specified address in parallel, for job contracts to be found easier. 68 | --- 69 | 3. Worker creates an offer contract in response to specified job, stores hash of order. 70 | 4. An analytic message is sent by worker to job contract in parallel, for offer contracts to be found easier. 71 | --- 72 | 5. Poster chooses an offer and sends its address (and proof that it's really an offer == StateInit) to job contract. 73 | 6. Job contract calculates hash of current order state and sends a "collapse" request to offer contract. 74 | Job contract locks. 75 | 7. Offer contract checks the incoming message and requests money from worker's wallet, locking meanwhile. 76 | 8. Worker's wallet accepts message from plugin and responds with wanted amount of money. 77 | 9. Offer contract forwards this money to job contract, destroying itself and unplugging from wallet. 78 | --- 79 | 10. Job contract transforms into multisig wallet (2/4: poster, worker, Ratelance platform and TON validators) 80 | - poster+worker – an agreement was established, everything is OK 81 | - poster+Ratelance – worker has not accomplished the work, poster gets a refund 82 | - worker+Ratelance – poster does not accept provided work, worker gets the money 83 | - poster+TON validators – something done by worker is deemed so inacceptible by TON that even voting is conducted 84 | - worker+TON validators – heavy disagreement between poster and worker, so that Ratelance cannot be a referee 85 | - Ratelance+TON – order is deemed so inacceptible by TON that even voting is conducted 86 | --- 87 | 11. Single-party-signed messages go to job or other pre-established address as analytic messages with minimal value. 88 | --- 89 | 12. Upon receiving message with required signatures, job contract sends out TON and self-destroys. 90 | 91 | ### Reverts 92 | 93 | - `1,2.` Poster sends a "job revoke" message, job contract performs a full refund and self-destroys. 94 | - `3,4.` Worker revokes a plugin via his wallet, offer contract self-destroys. 95 | - `5,6,7.` No way back. 96 | - `8.` If worker's wallet responds "not enough funds" (bounce), offer contract unlocks itself and job contract, returning to step 5. 97 | - `9,10.` No way back. 98 | - `11.` No way to revoke a message once signed. Open another job contract to change conditions. 99 | - `12.` No way back. Money is sent in unbounceable mode. 100 | 101 | ### Possible failures 102 | 103 | - `12.` Invalid message signed by parties just won't let the transaction execute. 104 | - `11.`  – 105 | - `10.` Insufficient funds for transformation. Taking 0.2 TON (total 0.2). 106 | - `9.` - Insufficient value to forward. Taking +0.1 TON. 107 | - Insufficient money to unplug. Taking +0.2 TON (total 0.5). 108 | - `8.` Insufficient value to process message. Taking +0.2 TON (total 0.7). 109 | - `7.` - Insufficient money to lock the contract. Taking +0.1 TON (total 0.8). 110 | - Contract does not exist, message bounces. 111 | - `6.` Insufficient funds to lock the contract. Taking +0.2 TON (total 1.0). 112 | - `5.`  – 113 | - `4.`  – 114 | - `3.`  – 115 | - `2.`  – 116 | - `1.`  – 117 | 118 | ### TL-B schemes 119 | 120 | Moved to `interaction.tlb`. 121 | -------------------------------------------------------------------------------- /bot/.gitignore: -------------------------------------------------------------------------------- 1 | utils.py 2 | examples 3 | __pycache__ 4 | bot.json 5 | bot.log 6 | .lock 7 | update_id.txt 8 | -------------------------------------------------------------------------------- /bot/backends.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import abc 3 | 4 | import requests 5 | 6 | from tg import check_bot_ok, send, respond_inline_query, yield_messages 7 | 8 | 9 | class IBackend(abc.ABC): 10 | @abc.abstractmethod 11 | def __init__(self): pass 12 | 13 | @abc.abstractmethod 14 | def receive_all_new_messages(self): 15 | ''' 16 | Receives all new messages from current backend and marks them read. 17 | 18 | typeof result = Array[ 19 | {'inline_query': {'id': str, 'from': User, 'query': str, 'offset': str}} | 20 | {'message': {'message_id': int, 'from': User?, 'date': int, 'chat': Chat, 21 | 'reply_to_message': Message, 'text': str?}} 22 | ]; 23 | ''' 24 | pass 25 | 26 | @abc.abstractmethod 27 | def send_message(self, chat, text, **kw): 28 | pass 29 | 30 | 31 | class TelegramBackend(IBackend): 32 | ''' 33 | Standard bot backend: Telegram. 34 | ''' 35 | def __init__(self): 36 | check_bot_ok() 37 | 38 | def receive_all_new_messages(self): 39 | yield from yield_messages() 40 | 41 | def send_message(self, chat, text, reply=None, 42 | keyboard=None, parse_mode='html', custom={}): 43 | return send(chat, text, reply, keyboard, parse_mode, custom) 44 | 45 | def respond_inline_query(self, query_id, results, button): 46 | return respond_inline_query(query_id, results, button) 47 | -------------------------------------------------------------------------------- /bot/cli/about.py: -------------------------------------------------------------------------------- 1 | from .colors import nh, h, nb, b, ns, s 2 | 3 | PROMPT_SINGLE = f'''{b}{"="*80}{ns} 4 | Jobs {h}jl{nh}: list {h}jp{nh}: post {h}ji{nh}: info {h}jv{nh}: load+verify {h}jr{nh}: revoke {h}jd{nh}: delegate 5 | Offers {h}ol{nh}: list {h}op{nh}: post {h}oi{nh}: info {h}ov{nh}: load+verify {h}or{nh}: revoke 6 | Contracts {s}cu: unseal{ns}{h}{nh} {h}ct{nh}: talk {h}cn{nh}: negotiate 7 | General {h} h{nh}: help {h} u{nh}: update {h} q{nh}: quit {h} d{nh}: donate 8 | Keys {h}kl{nh}: list {h}ke{nh}: export {h}kn{nh}: new {h}ki{nh}: import 9 | {b}{"="*80}{ns} 10 | ''' 11 | 12 | PROMPT = f'{b}ratelance> {nb}' 13 | 14 | ABOUT = f''' 15 | Repository: {h}https://github.com/ProgramCrafter/ratelance/{nh} 16 | TG channel: {h}https://t.me/ratelance{nh} 17 | '''.strip() 18 | -------------------------------------------------------------------------------- /bot/cli/assets/contract-job.boc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgramCrafter/ratelance/2de56f4f96acf0f9e1e5b0e60ae0ad846ba50170/bot/cli/assets/contract-job.boc -------------------------------------------------------------------------------- /bot/cli/assets/contract-offer.boc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgramCrafter/ratelance/2de56f4f96acf0f9e1e5b0e60ae0ad846ba50170/bot/cli/assets/contract-offer.boc -------------------------------------------------------------------------------- /bot/cli/bcutils.py: -------------------------------------------------------------------------------- 1 | from tonsdk.boc import Builder, Cell 2 | from tonsdk.utils import Address 3 | import requests 4 | 5 | 6 | 7 | def input_address(prompt): 8 | while True: 9 | try: 10 | return Address(input(prompt)) 11 | except KeyboardInterrupt: 12 | raise 13 | except Exception: 14 | pass 15 | 16 | 17 | def input_long(prompt): 18 | s = '' 19 | while True: 20 | t = input(prompt) 21 | if not t: return s 22 | 23 | prompt = '> ' 24 | s += t + '\n' 25 | 26 | 27 | def load_transactions(address, start_lt): 28 | return requests.get( 29 | f'https://tonapi.io/v1/blockchain/getTransactions?account={address}&limit=100' 30 | ).json()['transactions'] 31 | 32 | 33 | def load_account(address): 34 | return requests.get( 35 | f'https://tonapi.io/v1/blockchain/getAccount?account={address}' 36 | ).json() 37 | 38 | 39 | def decode_text(cell: Cell) -> str: 40 | a = '' 41 | s = cell.begin_parse() 42 | if s.is_empty(): return '' 43 | if s.preload_uint(32) == 0: s.skip_bits(32) 44 | a += s.load_bytes(len(s) // 8).decode('utf-8') 45 | while s.refs: 46 | s = s.load_ref().begin_parse() 47 | a += s.load_bytes(len(s) // 8).decode('utf-8') 48 | return a 49 | 50 | 51 | def encode_text(a) -> Cell: 52 | if isinstance(a, str): 53 | s = a.encode('utf-8') 54 | else: 55 | s = a 56 | 57 | b = Builder() 58 | b.store_bytes(s[:127]) 59 | if len(s) > 127: 60 | b.store_ref(encode_text(s[127:])) 61 | return b.end_cell() 62 | 63 | 64 | def shorten_escape(s: str, indent=2) -> str: 65 | s = s.replace('\x1b', '').replace('\r', '') 66 | lines = s.split('\n') 67 | if len(lines) > 4: 68 | lines = lines[:3] + ['...'] 69 | if lines: 70 | lines = [lines[0]] + [' ' * indent + line for line in lines[1:]] 71 | return '\n'.join(lines) 72 | -------------------------------------------------------------------------------- /bot/cli/colors.py: -------------------------------------------------------------------------------- 1 | # ANSI escape sequences 2 | 3 | # highlight (green) 4 | nh = '\x1b[37m' 5 | h = '\x1b[32m' 6 | 7 | # strikethrough (or invisible) 8 | ns = '\x1b[37m' 9 | s = '\x1b[30m' 10 | 11 | # blue 12 | nb = '\x1b[37m' 13 | b = '\x1b[36m' 14 | -------------------------------------------------------------------------------- /bot/cli/contracts.py: -------------------------------------------------------------------------------- 1 | from .bcutils import input_address, load_account, load_transactions 2 | from .colors import h, nh, b, nb 3 | from .signing import sign_send 4 | 5 | from tonsdk.boc import Builder, Cell 6 | from tonsdk.utils import Address 7 | import nacl.signing 8 | 9 | from base64 import b16decode, b16encode, b64decode 10 | import hashlib 11 | 12 | 13 | 14 | def serialize_signed_data(job: Address, bottom: int, upper: int) -> bytes: 15 | b = Builder() 16 | b.store_bytes(b16decode('FFFF726C3A3A6A6F623A3A7630')) 17 | b.store_uint(0, 5) 18 | b.store_address(job) 19 | b.store_uint(bottom, 64) 20 | b.store_uint(upper, 64) 21 | return b.end_cell().bytes_hash() 22 | 23 | 24 | def sign_pay_proposal(job: Address, bottom: int, upper: int, role: int, key_id: str, keyring) -> Cell: 25 | key = keyring.keys_info[key_id] 26 | secret_key_obj = nacl.signing.SigningKey(key['secret']) 27 | to_sign = serialize_signed_data(job, bottom, upper) 28 | signature = secret_key_obj.sign(to_sign)[:512//8] 29 | # print(signature, len(signature)) 30 | 31 | b = Builder() 32 | b.store_uint(role, 2) 33 | b.store_bytes(signature) 34 | b.store_uint(bottom, 64) 35 | b.store_uint(upper, 64) 36 | return b.end_cell() 37 | 38 | 39 | def double_sign_proposal(job: Address, bottom: int, upper: int, key_id1: str, key_id2: str, keyring, role1=0, role2=1) -> Cell: 40 | b = Builder() 41 | b.store_uint(0x000000BB, 32) 42 | b.store_ref(sign_pay_proposal(job, bottom, upper, role1, key_id1, keyring)) 43 | b.store_ref(sign_pay_proposal(job, bottom, upper, role2, key_id2, keyring)) 44 | return b.end_cell() 45 | 46 | 47 | def upsign_proposal(job: Address, bottom: int, upper: int, key_id: str, keyring, role: int, proposal: Cell) -> Cell: 48 | b = Builder() 49 | b.store_uint(0x000000BB, 32) 50 | b.store_ref(proposal) 51 | b.store_ref(sign_pay_proposal(job, bottom, upper, role, key_id, keyring)) 52 | return b.end_cell() 53 | 54 | 55 | def close_job_with(job: Address, proposal: Cell): 56 | sign_send([ 57 | (job, None, proposal, 5*10**7) 58 | ], 'closing job ' + job.to_string(True, True, True)) 59 | 60 | 61 | def load_job_keys_triple(job: Address) -> (bytes, bytes, bytes): 62 | acc = load_account(job.to_string(True, True, True)) 63 | if acc['status'] != 'active': 64 | raise Exception('contract ' + acc['status']) 65 | 66 | d = Cell.one_from_boc(b16decode(acc['data'].upper())).begin_parse() 67 | flag = d.load_uint(2) 68 | if flag != 2: 69 | raise Exception('job is not delegated') 70 | 71 | d.load_msg_addr() 72 | d.load_msg_addr() 73 | d.skip_bits(64) 74 | d.load_ref() 75 | d.load_ref() 76 | keys = d.load_ref().begin_parse() 77 | d.end_parse() 78 | 79 | p = keys.load_bytes(32) 80 | w = keys.load_bytes(32) 81 | r = keys.load_bytes(32) 82 | keys.end_parse() 83 | return (p, w, r) 84 | 85 | 86 | def check_intersect(limits_a: tuple[int, int], proposal_plus_limits: tuple[Cell, int, int]) -> bool: 87 | return max(limits_a[0], proposal_plus_limits[1]) <= min(limits_a[1], proposal_plus_limits[2]) 88 | 89 | 90 | def check_negotiate_suggestions(job: Address, p: bytes, w: bytes, r: bytes, skip_role: int): 91 | for tx in load_transactions(job.to_string(True, True, True), 0): 92 | try: 93 | body = Cell.one_from_boc(b64decode(tx['in_msg']['msg_data'])).begin_parse() 94 | if body.preload_uint(32) != 0x4bed4ee8: continue 95 | 96 | proposal_cell = body.load_ref() 97 | proposal_part = proposal_cell.begin_parse() 98 | role = proposal_part.load_uint(2) 99 | if role == 3: 100 | print('* someone claims that TON validators have voted; check TON config param 1652841508') 101 | continue 102 | if role == skip_role: 103 | # this can be our own negotiation message 104 | continue 105 | 106 | check_key_bytes = ((p, w, r))[role] 107 | check_key_obj = nacl.signing.VerifyKey(check_key_bytes) 108 | 109 | signature = proposal_part.load_bytes(512 // 8) 110 | pay_bottom = proposal_part.load_uint(64) 111 | pay_upper = proposal_part.load_uint(64) 112 | 113 | check_key_obj.verify(serialize_signed_data(job, pay_bottom, pay_upper), 114 | signature) 115 | 116 | yield [proposal_cell, pay_bottom, pay_upper] 117 | except Exception as exc: 118 | print(f'{b}Error while processing contract payment negotiation:{nb}', repr(exc)) 119 | 120 | 121 | def is_key_available(i: int, key: bytes, keyring) -> bool: 122 | key_hex = b16encode(key).decode('ascii') 123 | key_armored = 'pub:ed25519:vk:' + key_hex 124 | key_id = hashlib.sha256(key_armored.encode('ascii')).hexdigest()[::8] 125 | if key_id in keyring.keys_info: 126 | return (i, key_id) 127 | return None 128 | 129 | 130 | def check_available_roles_keyids(p: bytes, w: bytes, r: bytes, keyring): 131 | return list(filter(None, [is_key_available(i, k, keyring) for i,k in enumerate((p,w,r))])) 132 | 133 | 134 | def process_contract_cmd(command: str, keyring): 135 | if command == 'ct': 136 | print(f'{h}not implemented:{nh} \'ct\'') 137 | elif command == 'cn': 138 | job = input_address(f'{b}Job address:{nb} ') 139 | (poster_key, worker_key, ratelance_key) = load_job_keys_triple(job) 140 | available_keys = check_available_roles_keyids(poster_key, worker_key, ratelance_key, keyring) 141 | 142 | if len(available_keys) > 1: 143 | print('We have keys of two sides. Proceeding to contract closing.') 144 | upper = int(float(input(f'{b}TON to give to freelancer (upper limit):{nb} ')) * 1e9) 145 | bottom = int(float(input(f'{b}TON to give to freelancer (bottom limit):{nb} ')) * 1e9) 146 | 147 | role1, key1 = available_keys[0] 148 | role2, key2 = available_keys[1] 149 | 150 | close_job_with(job, double_sign_proposal( 151 | job, bottom, upper, key1, key2, keyring, role1, role2 152 | )) 153 | else: 154 | print('We have key of single party. Looking for negotiation requests.') 155 | skip_role = available_keys[0][0] 156 | 157 | # (proposal_cell, pay_bottom, pay_upper) 158 | negotiations = list(check_negotiate_suggestions(job, poster_key, worker_key, ratelance_key, skip_role)) 159 | 160 | if negotiations: 161 | min_fl = min(n[1] for n in negotiations) / 1e9 162 | max_fl = max(n[2] for n in negotiations) / 1e9 163 | print(f'There are suggestions to give {min_fl}..{max_fl} TON to freelancer.') 164 | upper = int(float(input(f'{b}TON you want to give to freelancer (upper limit):{nb} ')) * 1e9) 165 | bottom = int(float(input(f'{b}TON you want to give to freelancer (bottom limit):{nb} ')) * 1e9) 166 | 167 | for n in negotiations: 168 | if check_intersect((bottom, upper), n): 169 | print('Found matching suggestion. Building message with two signatures...') 170 | p = upsign_proposal(job, bottom, upper, available_keys[0][1], keyring, skip_role, n[0]) 171 | close_job_with(job, p) 172 | return 173 | else: 174 | print('No matching suggestion. Proceeding to sending negotiation request to other party.') 175 | else: 176 | upper = int(float(input(f'{b}TON you want to give to freelancer (upper limit):{nb} ')) * 1e9) 177 | bottom = int(float(input(f'{b}TON you want to give to freelancer (bottom limit):{nb} ')) * 1e9) 178 | 179 | p = sign_pay_proposal(job, bottom, upper, skip_role, available_keys[0][1], keyring) 180 | msg = Builder() 181 | msg.store_uint(0x4bed4ee8, 32) 182 | msg.store_ref(p) 183 | sign_send([ 184 | (job, None, msg.end_cell(), 2) 185 | ], 'negotiating payment for job ' + job.to_string(True, True, True)) 186 | 187 | else: 188 | print(f'{b}not implemented:{nb} {repr(command)}') 189 | -------------------------------------------------------------------------------- /bot/cli/dnsresolver.py: -------------------------------------------------------------------------------- 1 | from tonsdk.utils import Address 2 | import requests 3 | 4 | 5 | class TONDNSResolutionError(Exception): pass 6 | 7 | def resolve_to_userfriendly(addr): 8 | try: 9 | return Address(addr).to_string(True, True, True) 10 | except: 11 | # resolving TON DNS 12 | if not set('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.').issuperset(addr): 13 | raise TONDNSResolutionError('Invalid characters') 14 | if len(addr) < 3 or len(addr) > 127: 15 | raise TONDNSResolutionError('Invalid length') 16 | if not addr.endswith('.ton') and not addr.endswith('.t.me'): 17 | raise TONDNSResolutionError('Unsupported top-level domain') 18 | 19 | resolved = requests.get(f'https://tonapi.io/v2/dns/{addr}/resolve').json() 20 | if 'wallet' not in resolved: 21 | raise TONDNSResolutionError('Domain not pointing at wallet') 22 | 23 | return Address(resolved['wallet']['address']).to_string(True, True, True) 24 | -------------------------------------------------------------------------------- /bot/cli/jobs.py: -------------------------------------------------------------------------------- 1 | from .bcutils import decode_text, encode_text, load_transactions, shorten_escape, input_address, load_account, input_long 2 | from .signing import retrieve_auth_wallet, sign_send 3 | from .colors import h, nh, b, nb 4 | from .keyring import Keyring 5 | 6 | from base64 import b64decode, b16decode, b16encode 7 | import traceback 8 | import hashlib 9 | 10 | from tonsdk.boc import Builder, Cell 11 | from tonsdk.utils import Address 12 | from .tslice import Slice 13 | 14 | 15 | 16 | JOB_NOTIFICATIONS = 'EQA__RATELANCE_______________________________JvN' 17 | 18 | 19 | def job_data_init(poster: str, value: int, desc: Cell, key: int) -> Cell: 20 | di = Builder() 21 | di.store_uint(0, 2) 22 | di.store_address(Address(poster)) 23 | di.store_uint(value, 64) 24 | di.store_ref(desc) 25 | di.store_uint(key, 256) 26 | return di.end_cell() 27 | 28 | 29 | def job_state_init(poster: str, value: int, desc: Cell, key: int) -> Cell: 30 | with open(__file__ + '/../assets/contract-job.boc', 'rb') as f: 31 | code = Cell.one_from_boc(f.read()) 32 | 33 | si = Builder() 34 | si.store_uint(6, 5) 35 | si.store_ref(code) 36 | si.store_ref(job_data_init(poster, value, desc, key)) 37 | return si.end_cell() 38 | 39 | 40 | def analytic_msg(job: Address, value: int, desc: Cell, key: int) -> Cell: 41 | am = Builder() 42 | am.store_uint(0x130850fc, 32) 43 | am.store_address(job) 44 | am.store_uint(value, 64) 45 | am.store_ref(desc) 46 | am.store_uint(key, 256) 47 | return am.end_cell() 48 | 49 | 50 | def load_jobs(start_lt=None, custom_notif_addr=None): 51 | if start_lt: raise Exception('loading specific jobs not supported') 52 | 53 | notif = custom_notif_addr or JOB_NOTIFICATIONS 54 | for tx in load_transactions(notif, start_lt=start_lt): 55 | try: 56 | body = Cell.one_from_boc(b64decode(tx['in_msg']['msg_data'])).begin_parse() 57 | 58 | if body.preload_uint(32) != 0x130850fc: 59 | print(f'\n{b}* legacy job notification [without TL-B tag]{nb}') 60 | assert tx['hash'] in ( 61 | '155ccfdc282660413ada2c1b71ecbd5935db7afd40c82d7b36b8502fea064b8a', 62 | 'f2c76d3ea82e6147887320a71b359553f99cb176a521d63081facfb80a183dbf', 63 | ) 64 | else: 65 | body.load_uint(32) 66 | 67 | job = body.load_msg_addr() 68 | poster = Address(tx['in_msg']['source']['address']).to_string(True, True, True) 69 | value = body.load_uint(64) 70 | desc = body.load_ref() 71 | desc_text = decode_text(desc) 72 | poster_key = body.load_uint(256) 73 | 74 | # TODO: skip notifications with value < 0.05 TON 75 | 76 | if job.hash_part != job_state_init(poster, value, desc, poster_key).bytes_hash(): 77 | print(f'{b}Found notification with invalid job address:{nb}', job.to_string()) 78 | print(f'* {h}poster: {nh}{poster}') 79 | print(f'* {h}description: {nh}{shorten_escape(desc_text)}') 80 | else: 81 | yield (job.to_string(True, True, True), poster, value, desc_text) 82 | except Exception as exc: 83 | print(f'{b}Failure while processing notification:{nb}', repr(exc)) 84 | 85 | 86 | def show_jobs(start_lt=None, custom_notif_addr=None, validate_jobs=False): 87 | if validate_jobs: raise Exception('validating jobs not supported') 88 | 89 | for (job, poster, value, desc) in load_jobs(start_lt, custom_notif_addr): 90 | jid = job[40:] 91 | print(f'Order [{h}{jid}{nh}] {job}') 92 | print(f'- {h}posted by{nh} {poster}') 93 | print(f'- {h}promising{nh} {value/1e9} TON, {h}staked{nh} ') 94 | print('-', shorten_escape(desc)) 95 | 96 | 97 | def post_job(value: int, stake: int, desc_text: str, keyring: Keyring, key_id: str): 98 | print(f'\n{h}Creating new job{nh}', repr(desc_text)) 99 | 100 | if not key_id.strip() and len(keyring.keys_info) == 1: 101 | key_id = list(keyring.keys_info.keys())[0] 102 | 103 | key_info = keyring.keys_info[key_id] 104 | assert key_info['key_id'] == key_id 105 | public_key = int.from_bytes(key_info['public'], 'big') 106 | 107 | WAY_PROMPT = f'Send via mnemonic [{h}m{nh}]/wallet seed [{h}s{nh}]/ton link [{h}t{nh}]? ' 108 | while (auth_way := input(WAY_PROMPT).lower()) not in ('m', 's', 't'): pass 109 | 110 | if auth_way == 't': 111 | wallet = None 112 | poster = input_address(f'{b}Your address: {nb}') 113 | else: 114 | wallet = retrieve_auth_wallet(auth_way) 115 | poster = wallet.address.to_string(True, True, True) 116 | 117 | desc = encode_text(desc_text) 118 | si = job_state_init(poster, value, desc, public_key) 119 | addr = Address('0:' + b16encode(si.bytes_hash()).decode('ascii')) 120 | am = analytic_msg(addr, value, desc, public_key) 121 | 122 | jobs = Address(JOB_NOTIFICATIONS) 123 | jobs.is_bounceable = False 124 | 125 | print() 126 | sign_send([ 127 | (addr, si, Cell(), stake), 128 | (jobs, None, am, 5*10**7), 129 | ], 'creating job', auth_way, wallet) 130 | 131 | 132 | def delegate_job(job: str, offer_addr: str): 133 | msg = Builder() 134 | msg.store_uint(0x000000AC, 32) 135 | msg.store_ref(Cell()) 136 | msg.store_address(Address(offer_addr)) 137 | 138 | sign_send([(Address(job), None, msg.end_cell(), 10**9)], 'delegating job') 139 | 140 | 141 | def public_key_desc(key: bytes, keyring) -> str: 142 | key_hex = b16encode(key).decode('ascii') 143 | key_armored = 'pub:ed25519:vk:' + key_hex 144 | key_id = hashlib.sha256(key_armored.encode('ascii')).hexdigest()[::8] 145 | not_present = 'not ' if key_id not in keyring.keys_info else '' 146 | return f'{key_hex} ({key_id}, {h}{not_present}present{nh} in local keyring)' 147 | 148 | 149 | def show_job(job: str, keyring): 150 | acc = load_account(job) 151 | 152 | if acc['status'] != 'active': 153 | print('* contract', acc['status']) 154 | return 155 | 156 | d = Cell.one_from_boc(b16decode(acc['data'].upper())).begin_parse() 157 | flag = d.load_uint(2) 158 | 159 | if flag == 0: 160 | print(f'* job {h}waiting for offers{nh}') 161 | print(f'- {h}posted by {nh}', d.load_msg_addr().to_string(True, True, True)) 162 | print(f'- {h}promising {nh}', d.load_uint(64) / 1e9, 'TON') 163 | print(f'- {h}public key{nh}', public_key_desc(d.load_bytes(32), keyring)) 164 | 165 | j_desc_text = decode_text(d.load_ref()) 166 | print(f'- {h}job descr {nh}', shorten_escape(j_desc_text, indent=12)) 167 | 168 | d.end_parse() 169 | elif flag == 1: 170 | print(f'* job {h}locked on offer{nh}', d.load_msg_addr().to_string(True, True, True)) 171 | print(f'- {h}posted by {nh}', d.load_msg_addr().to_string(True, True, True)) 172 | print(f'- {h}promising {nh}', d.load_uint(64) / 1e9, 'TON') 173 | print(f'- {h}public key{nh}', public_key_desc(d.load_bytes(32), keyring)) 174 | 175 | j_desc_text = decode_text(d.load_ref()) 176 | print(f'- {h}job descr {nh}', shorten_escape(j_desc_text, indent=12)) 177 | 178 | d.end_parse() 179 | elif flag == 2: 180 | print(f'* {h}taken{nh} job') 181 | print(f'- {h}posted by {nh}', d.load_msg_addr().to_string(True, True, True)) 182 | print(f'- {h}worker {nh}', d.load_msg_addr().to_string(True, True, True)) 183 | print(f'- {h}promising {nh}', d.load_uint(64) / 1e9, 'TON') 184 | 185 | j_desc_text = decode_text(d.load_ref()) 186 | print(f'- {h}job descr {nh}', shorten_escape(j_desc_text, indent=13)) 187 | o_desc_text = decode_text(d.load_ref()) 188 | print(f'- {h}offer descr{nh}', shorten_escape(o_desc_text, indent=13)) 189 | 190 | keys = d.load_ref().begin_parse() 191 | print(f'- {h} poster key{nh}', public_key_desc(keys.load_bytes(32), keyring)) 192 | print(f'- {h} worker key{nh}', public_key_desc(keys.load_bytes(32), keyring)) 193 | print(f'- {h}Ratelance key{nh}', public_key_desc(keys.load_bytes(32), keyring)) 194 | 195 | keys.end_parse() 196 | d.end_parse() 197 | else: 198 | print('* broken job contract') 199 | 200 | 201 | def process_jobs_cmd(command, keyring): 202 | if command == 'jl': 203 | show_jobs() 204 | elif command == 'jp': 205 | possible_keys = list(keyring.keys_info.keys()) 206 | if len(possible_keys) > 3: 207 | possible_keys_s = '/'.join(possible_keys[:3]) + '/...' 208 | else: 209 | possible_keys_s = '/'.join(possible_keys) or '' 210 | 211 | key_id = input(f'Used key ({possible_keys_s}): ') 212 | value = int(1e9 * float(input('Promised job value (TON): '))) 213 | stake = int(1e9 * float(input('Send stake (TON): '))) 214 | desc_text = input_long('Job description: ') 215 | 216 | post_job(value, stake, desc_text, keyring, key_id) 217 | elif command == 'jd': 218 | job = input_address('Job address: ') 219 | offer = input_address('Offer address: ') 220 | 221 | delegate_job(job, offer) 222 | elif command == 'ji': 223 | try: 224 | show_job(input_address('Job address: ').to_string(True, True, True), keyring) 225 | except Exception as exc: 226 | print(f'{b}Invalid job:{nb}', repr(exc)) 227 | else: 228 | print(f'{b}not implemented:{nb} {repr(command)}') -------------------------------------------------------------------------------- /bot/cli/keyring.py: -------------------------------------------------------------------------------- 1 | from .colors import nh, h, nb, b, ns, s 2 | 3 | from base64 import b16encode as hex_encode_b2b 4 | from base64 import b16decode 5 | import traceback 6 | import hashlib 7 | import time 8 | import os 9 | 10 | import nacl.signing 11 | import nacl.secret 12 | 13 | 14 | 15 | def b16encode(v): 16 | return hex_encode_b2b(v).decode('ascii') 17 | 18 | class Keyring: 19 | def __init__(self, path=None): 20 | self.path = path or self.generate_keyring_path() 21 | self.keys_info = {} 22 | 23 | def __enter__(self): 24 | try: 25 | with open(self.path, 'r') as f: 26 | self.parse_keys_from(f) 27 | except IOError: 28 | open(self.path, 'x').close() 29 | return self 30 | 31 | def __exit__(self, exc_type, exc_val, exc_trace): 32 | try: 33 | with open(self.path, 'r') as f: 34 | self.parse_keys_from(f) 35 | except IOError: 36 | pass 37 | 38 | self.flush_keys() 39 | 40 | if exc_val: 41 | raise 42 | 43 | def generate_keyring_path(self): 44 | appdata = os.environ['LOCALAPPDATA'] 45 | return os.path.abspath(appdata + '/ratelance-private-keys.dat') 46 | 47 | def flush_keys(self): 48 | with open(self.path, 'w') as f: 49 | self.write_keys_to(f) 50 | 51 | def parse_keys_from(self, file): 52 | for line in file: 53 | version, public_key, secret_key, name = line.strip().split(' ', 3) 54 | assert version == 'v0.0.2' 55 | assert public_key.startswith('pub:ed25519:vk:') 56 | assert secret_key.startswith('prv:ed25519:sk:') 57 | 58 | key_id = hashlib.sha256(public_key.encode('ascii')).hexdigest()[::8] 59 | 60 | self.keys_info[key_id] = { 61 | 'public': b16decode(public_key.removeprefix('pub:ed25519:vk:')), 62 | 'secret': b16decode(secret_key.removeprefix('prv:ed25519:sk:')), 63 | 'key_id': key_id, 64 | 'name': name 65 | } 66 | 67 | def write_keys_to(self, file): 68 | for (key_id, key_info) in self.keys_info.items(): 69 | public_key = 'pub:ed25519:vk:' + b16encode(key_info['public']) 70 | secret_key = 'prv:ed25519:sk:' + b16encode(key_info['secret']) 71 | 72 | print('v0.0.2', public_key, secret_key, key_info['name'], 73 | file=file, flush=True) 74 | 75 | def add_key(self, secret_bytes, name): 76 | secret_key_obj = nacl.signing.SigningKey(secret_bytes) 77 | public_bytes = secret_key_obj.verify_key.encode() 78 | 79 | public_key_armored = 'pub:ed25519:vk:' + b16encode(public_bytes) 80 | key_id = hashlib.sha256(public_key_armored.encode('ascii')).hexdigest()[::8] 81 | 82 | self.keys_info[key_id] = { 83 | 'public': public_bytes, 84 | 'secret': secret_bytes, 85 | 'key_id': key_id, 86 | 'name': name 87 | } 88 | self.flush_keys() 89 | return self.keys_info[key_id] 90 | 91 | def generate_new_key(self): 92 | return self.add_key( 93 | nacl.secret.random(32), 94 | f'''key@[{time.strftime('%Y.%m.%d %H:%M:%S')}]''' 95 | ) 96 | 97 | 98 | def process_keyring_cmd(command, keyring): 99 | if command == 'kl': 100 | print('Keys list:') 101 | 102 | for (key_id, key_info) in keyring.keys_info.items(): 103 | print(f'- {b}{key_id}{nb} -', repr(key_info['name'])) 104 | 105 | elif command == 'ke': 106 | key_id = input(f' {h}Key ID:{nh} ') 107 | 108 | try: 109 | key_info = keyring.keys_info[key_id] 110 | assert key_info['key_id'] == key_id 111 | print('- ID: ', key_id) 112 | print('- Name: ', key_info['name']) 113 | print('- Public key:', b16encode(key_info['public'])) 114 | print('- Secret key:', b16encode(key_info['secret'])) 115 | except: 116 | traceback.print_exc() 117 | 118 | elif command == 'ki': 119 | secret = input(f' {h}HEX secret key:{nh} ') 120 | name = input(f' {h}Key name:{nh} ') 121 | 122 | try: 123 | secret_bytes = b16decode(secret.upper()) 124 | assert len(secret_bytes) == 32 125 | keyring.add_key(secret_bytes, name) 126 | except: 127 | traceback.print_exc() 128 | 129 | elif command == 'kn': 130 | key_info = keyring.generate_new_key() 131 | print(f'{h}Created key{nh}', key_info['key_id'], repr(key_info['name'])) 132 | 133 | else: 134 | print(f'{b}not implemented:{nb} {repr(command)}') 135 | -------------------------------------------------------------------------------- /bot/cli/offers.py: -------------------------------------------------------------------------------- 1 | from .bcutils import decode_text, encode_text, load_transactions, shorten_escape, input_address, load_account 2 | from .signing import retrieve_auth_wallet, sign_send, sign_install_plugin, sign_uninstall_plugin 3 | from .colors import h, nh, b, nb 4 | from .keyring import Keyring 5 | 6 | from base64 import b64decode, b16decode, b16encode 7 | import traceback 8 | import time 9 | 10 | from tonsdk.boc import Builder, Cell 11 | from tonsdk.utils import Address 12 | from .tslice import Slice 13 | 14 | 15 | 16 | def offer_data_init(job: str, worker: Address, stake: int, desc: Cell, 17 | key: int, short_job_hash: int) -> Cell: 18 | di = Builder() 19 | di.store_uint(0, 2) 20 | di.store_address(Address(job)) 21 | di.store_address(worker) 22 | di.store_uint(stake, 64) 23 | di.store_ref(desc) 24 | di.store_uint(key, 256) 25 | di.store_uint(short_job_hash, 160) 26 | return di.end_cell() 27 | 28 | 29 | def offer_state_init(job: str, worker: Address, stake: int, desc: Cell, 30 | key: int, short_job_hash: int) -> Cell: 31 | with open(__file__ + '/../assets/contract-offer.boc', 'rb') as f: 32 | code = Cell.one_from_boc(f.read()) 33 | 34 | si = Builder() 35 | si.store_uint(6, 5) 36 | si.store_ref(code) 37 | si.store_ref(offer_data_init(job, worker, stake, desc, key, short_job_hash)) 38 | return si.end_cell() 39 | 40 | 41 | def analytic_msg(offer: Address, stake: int, desc: Cell, 42 | key: int, short_job_hash: int) -> Cell: 43 | am = Builder() 44 | am.store_uint(0x18ceb1bf, 32) 45 | am.store_address(offer) 46 | am.store_uint(stake, 64) 47 | am.store_ref(desc) 48 | am.store_uint(key, 256) 49 | am.store_uint(short_job_hash, 160) 50 | return am.end_cell() 51 | 52 | 53 | def load_offers(job: str, start_lt=None): 54 | if start_lt: raise Exception('loading specific offers not supported') 55 | 56 | job_data = Cell.one_from_boc(b16decode(load_account(job)['data'].upper())) 57 | job_hash = int.from_bytes(job_data.bytes_hash(), 'big') & ((1 << 160) - 1) 58 | 59 | if job_data.begin_parse().load_uint(2) != 0: 60 | print(f'{b}[job not in unlocked state]{nb}') 61 | 62 | for tx in load_transactions(job, start_lt=start_lt): 63 | try: 64 | if tx['in_msg']['value'] != 1: continue 65 | 66 | worker = Address(tx['in_msg']['source']['address']) 67 | body = Cell.one_from_boc(b64decode(tx['in_msg']['msg_data'])).begin_parse() 68 | 69 | if body.preload_uint(32) not in (0x18ceb1bf, 0x4bed4ee8): 70 | print(f'\n{b}* legacy offer notification [without TL-B tag]{nb}') 71 | assert tx['hash'] in ( 72 | '4109a9bf38f7376acdb013ff95e33ebb5112c00ebd9d93a348361522400b5783', 73 | '8fb9cb7532a8d6a710106d1c346f99bdd22a2d74480858956ecc19a02e1dfd8d', 74 | 'a598c47a792ceccb9e26b2cd5cc73c7a6024dae12f24ae20747182966b407b65', 75 | ) 76 | else: 77 | body.skip_bits(32) 78 | 79 | offer = body.load_msg_addr() 80 | stake = body.load_uint(64) 81 | desc = body.load_ref() 82 | desc_text = decode_text(desc) 83 | key = body.load_uint(256) 84 | shjh = body.load_uint(160) 85 | 86 | hash_up_to_date = shjh == job_hash 87 | 88 | if offer.hash_part != offer_state_init(job, worker, stake, desc, key, shjh).bytes_hash(): 89 | print(f'{b}Found notification with invalid offer address:{nb}', offer.to_string()) 90 | print(f'* {h}worker: {nh}{worker.to_string()}') 91 | print(f'* {h}description: {nh}{repr(desc_text)}') 92 | else: 93 | yield (offer.to_string(True, True, True), worker.to_string(True, True, True), 94 | stake, desc_text, hash_up_to_date) 95 | except Exception as exc: 96 | print(f'{b}Failure while processing notification:{nb}', repr(exc)) 97 | 98 | 99 | def show_offers(job: str, start_lt=None, validate_offers=False): 100 | if validate_offers: raise Exception('validating offers not supported') 101 | # requires checking whether contract exists and is attached as plugin to worker's wallet 102 | 103 | for (offer, worker, stake, desc, hash_up_to_date) in load_offers(job, start_lt): 104 | oid = offer[40:] 105 | print(f'\nOffer [{h}{oid}{nh}] {offer}') 106 | print(f'- {h}posted by{nh} {worker}') 107 | print(f'- {h}staked{nh} {stake/1e9} TON, {h}really available{nh} ') 108 | print(f'- {h}hash{nh} {"" if hash_up_to_date else "not "}up to date') 109 | print('-', shorten_escape(desc)) 110 | 111 | 112 | def post_offer(job: str, stake: int, desc_text: str, shjh: int, keyring: Keyring, key_id: str): 113 | print(f'\n{h}Creating new offer{nh}', repr(desc_text)) 114 | print('You will have to use v4 wallet, because offer is a plugin.') 115 | 116 | if not key_id.strip() and len(keyring.keys_info) == 1: 117 | key_id = list(keyring.keys_info.keys())[0] 118 | 119 | key_info = keyring.keys_info[key_id] 120 | assert key_info['key_id'] == key_id 121 | public_key = int.from_bytes(key_info['public'], 'big') 122 | 123 | WAY_PROMPT = f'Send via mnemonic [{h}m{nh}]/wallet seed [{h}s{nh}]? ' 124 | while (auth_way := input(WAY_PROMPT).lower()) not in ('m', 's'): pass 125 | 126 | wallet = retrieve_auth_wallet(auth_way, plugin_only=True) 127 | worker = wallet.address.to_string(True, True, True) 128 | 129 | desc = encode_text(desc_text) 130 | si = offer_state_init(job, wallet.address, stake, desc, public_key, shjh) 131 | addr = Address('0:' + b16encode(si.bytes_hash()).decode('ascii')) 132 | am = analytic_msg(addr, stake, desc, public_key, shjh) 133 | 134 | print() 135 | sign_install_plugin(si, 5*10**7, 'creating offer', wallet) 136 | print('... please, wait for blockchain update (30s) ...') 137 | time.sleep(30) 138 | print() 139 | sign_send([(job, None, am, 1)], 'notifying job poster', auth_way, wallet) 140 | 141 | 142 | def revoke_offer(offer: str): 143 | sign_uninstall_plugin(offer, 'revoking offer') 144 | 145 | 146 | def process_offers_cmd(command, keyring): 147 | if command == 'ol': 148 | # TODO: support job IDs instead of addresses 149 | job = input_address(f'{b}Job address: {nb}') 150 | 151 | show_offers(job.to_string()) 152 | elif command == 'op': 153 | possible_keys = list(keyring.keys_info.keys()) 154 | if len(possible_keys) > 3: 155 | possible_keys_s = '/'.join(possible_keys[:3]) + '/...' 156 | else: 157 | possible_keys_s = '/'.join(possible_keys) or '' 158 | 159 | key_id = input(f'Used key ({possible_keys_s}): ') 160 | job = input_address(f'{b}Job address: {nb}') 161 | stake = int(1e9 * float(input('Staked value (TON): '))) 162 | desc_text = input('Offer description: ') 163 | 164 | job_data = Cell.one_from_boc(b16decode( 165 | load_account(job.to_string())['data'].upper() 166 | )) 167 | assert job_data.begin_parse().load_uint(2) == 0 168 | job_hash = int.from_bytes(job_data.bytes_hash(), 'big') & ((1 << 160) - 1) 169 | 170 | post_offer(job, stake, desc_text, job_hash, keyring, key_id) 171 | elif command == 'or': 172 | revoke_offer(input_address(f'{b}Offer address: {nb}').to_string(True, True, True)) 173 | else: 174 | print(f'{b}not implemented:{nb} {repr(command)}') -------------------------------------------------------------------------------- /bot/cli/polyfills.py: -------------------------------------------------------------------------------- 1 | from tonsdk.boc import Cell 2 | from .tslice import Slice 3 | 4 | Cell.begin_parse = lambda self: Slice(self) 5 | -------------------------------------------------------------------------------- /bot/cli/signing.py: -------------------------------------------------------------------------------- 1 | from .colors import nh, h, nb, b, ns, s 2 | 3 | from base64 import b16decode, b64encode, urlsafe_b64encode 4 | from getpass import getpass 5 | import time 6 | 7 | from tonsdk.contract.wallet import Wallets 8 | from tonsdk.contract import Contract 9 | from tonsdk.utils import Address 10 | from tonsdk.boc import Cell 11 | import tonsdk.crypto 12 | import nacl.signing 13 | # TODO: move `requests` out of functions/modules where secret keys are accessed 14 | import requests 15 | 16 | 17 | 18 | def retrieve_keypair(auth_way: str): 19 | if auth_way == 'm': 20 | while True: 21 | mnemonic = getpass(f'{b}Your wallet mnemonic (not echoed):{nb} ').split() 22 | if tonsdk.crypto.mnemonic_is_valid(mnemonic): break 23 | 24 | use_anyway = input(f'{b}Entered mnemonic is invalid. Use it anyway?{nb} [y/n] ').lower() 25 | if use_anyway == 'y': break 26 | 27 | _, secret_key = tonsdk.crypto.mnemonic_to_wallet_key(mnemonic) 28 | secret_key = secret_key[:32] 29 | del mnemonic 30 | elif auth_way == 's': 31 | while True: 32 | secret_hex = getpass(f'{b}Your secret key in HEX (not echoed):{nb} ').upper() 33 | if not set('0123456789ABCDEF').issuperset(secret_hex): 34 | print('Invalid characters met') 35 | elif len(secret_hex) != 64: 36 | print('Invalid key length') 37 | else: 38 | break 39 | 40 | secret_key = b16decode(secret_hex) 41 | del secret_hex 42 | else: 43 | raise Exception('unsupported auth way for retrieving keypair') 44 | 45 | secret_key_obj = nacl.signing.SigningKey(secret_key) 46 | public_key = secret_key_obj.verify_key.encode() 47 | secret_key = secret_key_obj.encode() + public_key # making secret key 64-byte 48 | return public_key, secret_key 49 | 50 | 51 | def retrieve_auth_wallet(auth_way: str, plugin_only=False): 52 | public_key, secret_key = retrieve_keypair(auth_way) 53 | 54 | WALLET_V = ['v4r2'] if plugin_only else ['v3r1', 'v3r2', 'v4r2'] 55 | WALLET_PROMPT = 'Enter wallet version (' + b + '/'.join(WALLET_V) + nb + '): ' 56 | while (wallet_ver := input(WALLET_PROMPT).lower()) not in WALLET_V: pass 57 | 58 | wallet_class = Wallets.ALL[wallet_ver] 59 | return wallet_class(public_key=public_key, private_key=secret_key) 60 | 61 | 62 | # orders: list[tuple[to_addr, state_init, payload, amount]] 63 | def sign_multitransfer_body(wallet: Contract, seqno: int, 64 | orders: list[tuple[Address,Cell,Cell,int]]) -> Cell: 65 | assert len(orders) <= 4 66 | 67 | send_mode = 3 68 | signing_message = wallet.create_signing_message(seqno) 69 | 70 | for (to_addr, state_init, payload, amount) in orders: 71 | order_header = Contract.create_internal_message_header(to_addr, amount) 72 | order = Contract.create_common_msg_info(order_header, state_init, payload) 73 | signing_message.bits.write_uint8(send_mode) 74 | signing_message.refs.append(order) 75 | 76 | return wallet.create_external_message(signing_message, seqno)['message'] 77 | 78 | 79 | def sign_for_sending(orders: list[tuple[Address,Cell,Cell,int]], 80 | description: str, auth_way=None, wallet=None) -> Cell: 81 | print(f'{h}Sending messages for purpose of{nh}', repr(description)) 82 | 83 | sum_value = 0 84 | for (dest, state_init, message, value_nton) in orders: 85 | init_flag = f'{b}[deploy]{nb}' if state_init else '' 86 | 87 | print('===') 88 | print(f'{h}Destination:{nh}', dest.to_string(True, True, True), init_flag) 89 | print(f'{h}TON amount: {nh}', value_nton / 1e9) 90 | print(f'{h}Message BOC:{nh}', b64encode(message.to_boc(False)).decode('ascii')) 91 | sum_value += value_nton 92 | 93 | print('===') 94 | print(f'{h}Total TON: {nh} {sum_value / 1e9}') 95 | print() 96 | 97 | if not auth_way: 98 | WAY_PROMPT = f'Send via mnemonic [{h}m{nh}]/wallet seed [{h}s{nh}]/ton link [{h}t{nh}]? ' 99 | while (auth_way := input(WAY_PROMPT).lower()) not in ('m', 's', 't'): pass 100 | 101 | if auth_way == 't': 102 | print('\nTransfer links:') 103 | 104 | for (dest, state_init, message, value_nton) in orders: 105 | addr = dest.to_string(True, True, True) 106 | boc = urlsafe_b64encode(message.to_boc(False)).decode('ascii') 107 | link = f'ton://transfer/{addr}?bin={boc}&amount={value_nton}' 108 | if state_init: 109 | link += '&init=' 110 | link += urlsafe_b64encode(state_init.to_boc(False)).decode('ascii') 111 | 112 | print(f'{b}{link}{nb}') 113 | 114 | return None 115 | 116 | if not wallet: 117 | wallet = retrieve_auth_wallet(auth_way) 118 | addr = wallet.address.to_string(True, True, True) 119 | 120 | print('Ready to do transfer from', addr) 121 | 122 | while (confirm := input(f'{h}Confirm? [y/n] {nh}').lower()) not in ('y', 'n'): 123 | pass 124 | 125 | if confirm == 'n': return None 126 | 127 | link = f'https://tonapi.io/v1/wallet/getSeqno?account={addr}' 128 | seqno = requests.get(link).json().get('seqno', 0) 129 | 130 | return sign_multitransfer_body(wallet, seqno, orders) 131 | 132 | 133 | def sign_send(orders: list[tuple[Address,Cell,Cell,int]], 134 | description: str, auth_way=None, wallet=None): 135 | signed_msg = sign_for_sending(orders, description, auth_way, wallet) 136 | if signed_msg: 137 | requests.post('https://tonapi.io/v1/send/boc', json={ 138 | 'boc': b64encode(signed_msg.to_boc(False)).decode('ascii') 139 | }) 140 | 141 | 142 | def sign_plugin(plugin_init: Cell, value_nton: int, 143 | description: str, wallet=None) -> Cell: 144 | print(f'{h}Attempting to install plugin{nh}', repr(description)) 145 | print(f'{h}Init BOC: {nh}', b64encode(plugin_init.to_boc(False)).decode('ascii')) 146 | print(f'{h}TON amount: {nh}', value_nton / 1e9) 147 | print() 148 | 149 | if not wallet: 150 | WAY_PROMPT = f'Install via mnemonic [{h}m{nh}]/wallet seed [{h}s{nh}]? ' 151 | while (auth_way := input(WAY_PROMPT).lower()) not in ('m', 's'): pass 152 | wallet = retrieve_auth_wallet(auth_way, plugin_only=True) 153 | addr = wallet.address.to_string(True, True, True) 154 | 155 | print('Ready to install plugin to', addr) 156 | 157 | while (confirm := input(f'{h}Confirm? [y/n] {nh}').lower()) not in ('y', 'n'): 158 | pass 159 | 160 | if confirm == 'n': return None 161 | 162 | link = f'https://tonapi.io/v1/wallet/getSeqno?account={addr}' 163 | seqno = requests.get(link).json().get('seqno', 0) 164 | 165 | msg_body = wallet.create_signing_message(seqno, without_op=True) 166 | msg_body.bits.write_uint(1, 8) # deploy + install plugin 167 | msg_body.bits.write_int(0, 8) # workchain 0 168 | msg_body.bits.write_coins(value_nton) # initial plugin balance 169 | msg_body.refs.append(plugin_init) 170 | msg_body.refs.append(Cell()) 171 | 172 | return wallet.create_external_message(msg_body, seqno)['message'] 173 | 174 | 175 | def sign_install_plugin(plugin_init: Cell, value_nton: int, 176 | description: str, wallet=None) -> Cell: 177 | signed_msg = sign_plugin(plugin_init, value_nton, description, wallet) 178 | if signed_msg: 179 | requests.post('https://tonapi.io/v1/send/boc', json={ 180 | 'boc': b64encode(signed_msg.to_boc(False)).decode('ascii') 181 | }) 182 | 183 | 184 | def sign_unplug(plugin: str, description: str, wallet=None) -> Cell: 185 | print(f'{h}Attempting to remove plugin{nh}', repr(description)) 186 | print(f'{h}Address:{nh}', plugin) 187 | print() 188 | 189 | if not wallet: 190 | WAY_PROMPT = f'Remove via mnemonic [{h}m{nh}]/wallet seed [{h}s{nh}]? ' 191 | while (auth_way := input(WAY_PROMPT).lower()) not in ('m', 's'): pass 192 | wallet = retrieve_auth_wallet(auth_way, plugin_only=True) 193 | addr = wallet.address.to_string(True, True, True) 194 | 195 | print('Ready to remove plugin from', addr) 196 | 197 | while (confirm := input(f'{h}Confirm? [y/n] {nh}').lower()) not in ('y', 'n'): 198 | pass 199 | 200 | if confirm == 'n': return None 201 | 202 | link = f'https://tonapi.io/v1/wallet/getSeqno?account={addr}' 203 | seqno = requests.get(link).json().get('seqno', 0) 204 | 205 | plugin_addr = Address(plugin) 206 | 207 | msg_body = wallet.create_signing_message(seqno, without_op=True) 208 | msg_body.bits.write_uint(3, 8) # remove plugin 209 | msg_body.bits.write_int(plugin_addr.wc, 8) 210 | msg_body.bits.write_bytes(plugin_addr.hash_part) 211 | msg_body.bits.write_coins(5*10**7) # value to send for destroying 212 | msg_body.bits.write_uint(int(time.time() * 1000), 64) 213 | 214 | return wallet.create_external_message(msg_body, seqno)['message'] 215 | 216 | 217 | def sign_uninstall_plugin(plugin: str, description: str, wallet=None) -> Cell: 218 | signed_msg = sign_unplug(plugin, description, wallet) 219 | if signed_msg: 220 | requests.post('https://tonapi.io/v1/send/boc', json={ 221 | 'boc': b64encode(signed_msg.to_boc(False)).decode('ascii') 222 | }) 223 | 224 | -------------------------------------------------------------------------------- /bot/cli/tslice.py: -------------------------------------------------------------------------------- 1 | # Modified version of _slice.py from fork of tonsdk library: 2 | # https://github.com/devdaoteam/tonsdk/blob/e3d6451e50a46d984e7fec1c52d1d32290781da5/tonsdk/boc/_slice.py 3 | # Original code is licensed under Apache-2.0 License. 4 | 5 | import bitarray 6 | 7 | from tonsdk.boc import Cell 8 | from tonsdk.utils import Address 9 | 10 | 11 | 12 | class Slice: 13 | '''Slice like an analog of slice in FunC. Used only for reading.''' 14 | def __init__(self, cell: Cell): 15 | self.bits = bitarray.bitarray() 16 | self.bits.frombytes(cell.bits.array) 17 | self.bits = self.bits[:cell.bits.cursor] 18 | self.refs = cell.refs 19 | self.ref_offset = 0 20 | 21 | def __len__(self): 22 | return len(self.bits) 23 | 24 | def __repr__(self): 25 | return hex(int(self.bits.to01(), 2))[2:].upper() 26 | 27 | def is_empty(self) -> bool: 28 | return len(self.bits) == 0 29 | 30 | def end_parse(self): 31 | '''Throws an exception if the slice is not empty.''' 32 | if not self.is_empty() or self.ref_offset != len(self.refs): 33 | raise Exception('Upon .end_parse(), slice is not empty.') 34 | 35 | def load_bit(self) -> int: 36 | '''Loads single bit from the slice.''' 37 | bit = self.bits[0] 38 | del self.bits[0] 39 | return bit 40 | 41 | def preload_bit(self) -> int: 42 | return self.bits[0] 43 | 44 | def load_bits(self, bit_count: int) -> bitarray.bitarray: 45 | bits = self.bits[:bit_count] 46 | del self.bits[:bit_count] 47 | return bits 48 | 49 | def preload_bits(self, bit_count: int) -> bitarray.bitarray: 50 | return self.bits[:bit_count] 51 | 52 | def skip_bits(self, bit_count: int): 53 | del self.bits[:bit_count] 54 | 55 | def load_uint(self, bit_length: int) -> int: 56 | value = self.bits[:bit_length] 57 | del self.bits[:bit_length] 58 | return int(value.to01(), 2) 59 | 60 | def preload_uint(self, bit_length: int) -> int: 61 | value = self.bits[:bit_length] 62 | return int(value.to01(), 2) 63 | 64 | def load_bytes(self, bytes_count: int) -> bytes: 65 | length = bytes_count * 8 66 | value = self.bits[:length] 67 | del self.bits[:length] 68 | return value.tobytes() 69 | 70 | def load_int(self, bit_length: int) -> int: 71 | if bit_length == 1: 72 | # if num is -1 then bit is 1. if 0 then 0. see _bit_string.py 73 | return - self.load_bit() 74 | else: 75 | is_negative = self.load_bit() 76 | value = self.load_uint(bit_length - 1) 77 | if is_negative == 1: 78 | # ones complement 79 | return - (2 ** (bit_length - 1) - value) 80 | else: 81 | return value 82 | 83 | def preload_int(self, bit_length: int) -> int: 84 | tmp = self.bits 85 | value = self.load_int(bit_length) 86 | self.bits = tmp 87 | return value 88 | 89 | def load_msg_addr(self) -> Address: 90 | '''Loads contract address from the slice. 91 | May return None if there is a zero-address.''' 92 | # TODO: support for external addresses 93 | if self.load_uint(2) == 0: 94 | return None 95 | self.load_bit() # anycast 96 | workchain_id = hex(self.load_int(8))[2:] 97 | hashpart = hex(self.load_uint(256))[2:].zfill(64) 98 | return Address(workchain_id + ':' + hashpart) 99 | 100 | def load_coins(self) -> int: 101 | '''Loads an amount of coins from the slice. Returns nanocoins.''' 102 | length = self.load_uint(4) 103 | if length == 0: # 0 in length means 0 coins 104 | return 0 105 | else: 106 | return self.load_uint(length * 8) 107 | 108 | def load_grams(self) -> int: 109 | '''Loads an amount of coins from the slice. Returns nanocoins.''' 110 | return self.load_coins() 111 | 112 | def load_string(self, length: int = 0) -> str: 113 | '''Loads string from the slice. 114 | If length is 0, then loads string until the end of the slice.''' 115 | if length == 0: 116 | length = len(self.bits) // 8 117 | return self.load_bytes(length).decode('utf-8') 118 | 119 | def load_ref(self) -> Cell: 120 | '''Loads next reference cell from the slice.''' 121 | ref = self.refs[self.ref_offset] 122 | self.ref_offset += 1 123 | return ref 124 | 125 | def preload_ref(self) -> Cell: 126 | return self.refs[self.ref_offset] 127 | 128 | def load_dict(self) -> Cell: 129 | '''Loads dictionary like a Cell from the slice. 130 | Returns None if the dictionary was null().''' 131 | not_null = self.load_bit() 132 | if not_null: 133 | return self.load_ref() 134 | else: 135 | return None 136 | 137 | def preload_dict(self) -> Cell: 138 | not_null = self.preload_bit() 139 | if not_null: 140 | return self.preload_ref() 141 | else: 142 | return None 143 | 144 | def skip_dict(self): 145 | self.load_dict() 146 | -------------------------------------------------------------------------------- /bot/install_libs.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | import sys 3 | import os 4 | 5 | interpreter = sys.executable.replace('pythonw', 'python') 6 | pip_prefix = shlex.quote(interpreter).replace("'", '"') + ' -m pip install ' 7 | 8 | try: 9 | import portalocker 10 | except: 11 | os.system(pip_prefix + 'portalocker') 12 | 13 | try: 14 | import requests 15 | except: 16 | os.system(pip_prefix + 'requests') 17 | 18 | try: 19 | import tonsdk 20 | except: 21 | os.system(pip_prefix + 'tonsdk bitstring==3.1.9') 22 | 23 | try: 24 | import nacl 25 | except: 26 | os.system(pip_prefix + 'pynacl') 27 | -------------------------------------------------------------------------------- /bot/keyutils.py: -------------------------------------------------------------------------------- 1 | from utils import KEY_GENERATOR_SALT 2 | from base64 import b16encode 3 | import nacl.signing 4 | import hashlib 5 | 6 | class KeyCustodialUtils: 7 | @staticmethod 8 | def get_keypair_for_user(chat_id): 9 | user_secret_uid = (KEY_GENERATOR_SALT + str(chat_id) + KEY_GENERATOR_SALT).encode('utf-8') 10 | secret_bytes = hashlib.sha256(user_secret_uid).digest() 11 | 12 | secret_key_obj = nacl.signing.SigningKey(secret_bytes) 13 | public_bytes = secret_key_obj.verify_key.encode() 14 | 15 | public_key_armored = 'pub:ed25519:vk:' + b16encode(public_bytes).decode('ascii') 16 | secret_key_armored = 'prv:ed25519:sk:' + b16encode(secret_bytes).decode('ascii') 17 | key_id = hashlib.sha256(public_key_armored.encode('ascii')).hexdigest()[::8] 18 | 19 | return { 20 | 'public': public_bytes, 21 | 'secret': secret_bytes, 22 | 'key_id': key_id, 23 | 'public_armored': public_key_armored, 24 | 'secret_armored': secret_key_armored 25 | } 26 | -------------------------------------------------------------------------------- /bot/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # encoding: utf-8 3 | 4 | from collections import deque 5 | import traceback 6 | import logging 7 | import html 8 | import time 9 | import os 10 | 11 | 12 | # Checking all third-party libraries are installed before proceeding. 13 | import install_libs 14 | 15 | import portalocker 16 | 17 | from states import StartState, SentinelState, donation_middleware 18 | from stateful import MultiuserStateMachine 19 | from backends import TelegramBackend 20 | 21 | 22 | # Setting logging configuration before `test.py` is launched. 23 | logging.basicConfig(format='%(asctime)s [%(levelname)s] %(message)s', 24 | datefmt='%d.%m.%Y %H:%M:%S', 25 | filename=os.path.abspath(__file__+'/../bot.log'), 26 | level=logging.DEBUG) 27 | 28 | 29 | # import test 30 | 31 | 32 | LOCK_PATH = os.path.abspath(__file__ + '/../.lock') 33 | 34 | 35 | def load_multiuser_machine(): 36 | try: 37 | with open(os.path.abspath(__file__ + '/../bot.json')) as f: 38 | return MultiuserStateMachine.load(f.read(), StartState) 39 | except FileNotFoundError: 40 | return MultiuserStateMachine(StartState) 41 | 42 | 43 | try: 44 | # Waiting for old bot instance to close. 45 | 46 | logging.info('Bot is waiting for older instance to close.') 47 | time.sleep(1) 48 | 49 | messages = deque() 50 | 51 | with portalocker.Lock(LOCK_PATH, 'w') as _: 52 | logging.info('Bot started.') 53 | backend = TelegramBackend() 54 | machine = load_multiuser_machine() 55 | machine.interceptors.append(donation_middleware) 56 | 57 | while not machine.state_is(SentinelState): 58 | if not len(messages) and machine.needs_message(): 59 | for e in backend.receive_all_new_messages(): 60 | logging.info(f'Putting message into queue: {str(e)[:40]}') 61 | messages.append(e) 62 | 63 | if not machine.needs_message(): 64 | machine.next(backend, None) 65 | elif len(messages): 66 | msg = messages.popleft() 67 | 68 | logging.info(f'Processing message from queue: {msg}') 69 | machine.next(backend, msg) 70 | 71 | time.sleep(2) 72 | 73 | except KeyboardInterrupt: 74 | logging.warning(backend.send_message(1463706336, 'Stopped by KeyboardInterrupt')) 75 | except: 76 | logging.error('Started sending crash log') 77 | 78 | remaining_messages = list(messages) 79 | 80 | if 'machine' not in globals(): machine = None 81 | if 'remaining_messages' not in globals(): remaining_messages = None 82 | 83 | crash_log = f''' 84 | BOT CRASHED 85 | Unparsed messages: {html.escape(repr(remaining_messages))} 86 | 87 | Machine state: {html.escape(repr(machine))} 88 | 89 | {html.escape(traceback.format_exc())} 90 | '''.strip() 91 | 92 | logging.error(crash_log) 93 | logging.warning(backend.send_message(1463706336, crash_log)) 94 | finally: 95 | logging.info('Bot has stopped.') 96 | 97 | with open(os.path.abspath(__file__ + '/../bot.json'), 'w') as f: 98 | f.write(repr(machine)) 99 | -------------------------------------------------------------------------------- /bot/persistence.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | 5 | class PersistentValue: 6 | def __init__(self, filename, default): 7 | path_s = os.path.sep 8 | 9 | self_dir = __file__ + path_s + '..' + path_s 10 | self.path = os.path.abspath(self_dir + filename) 11 | 12 | try: 13 | with open(self.path, 'r') as f: 14 | self.value = json.load(f) 15 | except: 16 | self.value = default 17 | 18 | def get(self): 19 | return self.value 20 | 21 | def set(self, value): 22 | self.value = value 23 | self.flush() 24 | 25 | def flush(self): 26 | with open(self.path, 'w') as f: 27 | json.dump(self.value, f) 28 | 29 | def set_max(self, value): 30 | if value > self.value: 31 | self.set(value) 32 | -------------------------------------------------------------------------------- /bot/stateful.py: -------------------------------------------------------------------------------- 1 | import json 2 | import abc 3 | 4 | 5 | class IState(abc.ABC): 6 | registered_states = {} 7 | 8 | @abc.abstractmethod 9 | def needs_message(self): pass 10 | 11 | @abc.abstractmethod 12 | def enter_state(self, message_info, reply, send_callback): pass 13 | 14 | @abc.abstractmethod 15 | def run(self, message_info, reply, send_callback): pass 16 | 17 | @staticmethod 18 | def load(state_repr): 19 | state_name, state_value = state_repr.split(':', 1) 20 | return IState.registered_states[state_name].load(state_value) 21 | 22 | 23 | def RegisterState(state_class): 24 | IState.registered_states[state_class.__name__] = state_class 25 | return state_class 26 | 27 | 28 | def load_chat_id(message_info): 29 | if 'inline_query' in message_info: 30 | return message_info['inline_query']['from']['id'] 31 | elif 'chosen_inline_result' in message_info: 32 | return message_info['chosen_inline_result']['from']['id'] 33 | elif 'message' in message_info: 34 | return message_info['message'].get('chat', {}).get('id', -1) 35 | else: 36 | # unreachable 37 | return -2 38 | 39 | 40 | class UserStateMachine: 41 | def __init__(self, start_state): 42 | self.state = start_state 43 | 44 | def __repr__(self): 45 | return self.state.__class__.__name__ + ':' + repr(self.state) 46 | 47 | def next(self, backend, message_info): 48 | ''' 49 | typeof message_info = 50 | {'inline_query': {'id': str, 'from': User, 'query': str, 'offset': str}} | 51 | {'message': {'message_id': int, 'from': User?, 'date': int, 'chat': Chat, 52 | 'reply_to_message': Message, 'text': str?}}; 53 | ''' 54 | 55 | if self.state.needs_message() and not message_info: 56 | return 57 | 58 | chat_id = load_chat_id(message_info) 59 | 60 | if 'message' in message_info: 61 | incoming_id = message_info['message']['message_id'] 62 | def reply(reply_text, **kw): 63 | backend.send_message(chat_id, reply_text, reply=incoming_id, **kw) 64 | self.state = self.state.run(message_info, reply, backend.send_message) 65 | elif 'chosen_inline_result' in message_info: 66 | def reply(reply_text, **kw): 67 | backend.send_message(chat_id, reply_text, **kw) 68 | self.state = self.state.run(message_info, reply, backend.send_message) 69 | elif 'inline_query' in message_info: 70 | incoming_id = message_info['inline_query']['id'] 71 | def reply_inline(results, button, **kw): 72 | backend.respond_inline_query(incoming_id, results, button, **kw) 73 | self.state = self.state.run(message_info, reply_inline, backend.send_message) 74 | 75 | 76 | def state_is(self, state_class): 77 | return isinstance(self.state, state_class) 78 | 79 | @staticmethod 80 | def load(machine_repr): 81 | self = UserStateMachine(None) 82 | self.state = IState.load(machine_repr) 83 | return self 84 | 85 | 86 | class MultiuserStateMachine: 87 | def __init__(self, start_state_class): 88 | self.start_state_class = start_state_class 89 | self.interceptors = [] 90 | self.users = {} 91 | 92 | def __repr__(self): 93 | return json.dumps({str(chat_id): repr(machine) 94 | for (chat_id, machine) in self.users.items()}) 95 | 96 | @staticmethod 97 | def load(users_repr, start_state_class): 98 | self = MultiuserStateMachine(start_state_class) 99 | self.users = {int(chat_id): UserStateMachine.load(machine_repr) 100 | for (chat_id, machine_repr) in json.loads(users_repr).items()} 101 | return self 102 | 103 | def next(self, backend, message_info): 104 | ''' 105 | typeof message_info = 106 | {'inline_query': {'id': str, 'from': User, 'query': str, 'offset': str}} | 107 | {'message': {'message_id': int, 'from': User?, 'date': int, 'chat': Chat, 108 | 'reply_to_message': Message, 'text': str?}}; 109 | ''' 110 | 111 | if not message_info: 112 | for chat_id, machine in self.users.items(): 113 | machine.next(backend, message_info) 114 | else: 115 | if any(intercept(backend, message_info) for intercept in self.interceptors): 116 | return 117 | 118 | chat_id = load_chat_id(message_info) 119 | 120 | if chat_id not in self.users: 121 | self.users[chat_id] = UserStateMachine(self.start_state_class()) 122 | 123 | self.users[chat_id].next(backend, message_info) 124 | 125 | def needs_message(self): 126 | return all(machine.state.needs_message() for machine in self.users.values()) 127 | 128 | def state_is(self, state_class): 129 | return any(machine.state_is(state_class) for machine in self.users.values()) 130 | -------------------------------------------------------------------------------- /bot/states.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import logging 3 | import secrets 4 | import html 5 | import json 6 | import time 7 | import os 8 | 9 | from stateful import IState, MultiuserStateMachine, RegisterState, load_chat_id 10 | from keyutils import KeyCustodialUtils 11 | from textutils import JobPostUtils 12 | 13 | import cli.polyfills 14 | from cli.dnsresolver import resolve_to_userfriendly, TONDNSResolutionError 15 | 16 | 17 | def flatten(arr): 18 | for row in arr: 19 | yield from row 20 | 21 | 22 | #VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV 23 | ''' 24 | typeof message_info = 25 | {'inline_query': {'id': str, 'from': User, 'query': str, 'offset': str}} | 26 | {'message': {'message_id': int, 'from': User?, 'date': int, 'chat': Chat, 27 | 'reply_to_message': Message, 'text': str?}}; 28 | ''' 29 | #^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 30 | 31 | 32 | @RegisterState 33 | class StartState(IState): 34 | ''' 35 | Welcome state, where everyone not yet known to bot starts. 36 | ''' 37 | 38 | def __init__(self, settings=None): self.settings = settings or {} 39 | def needs_message(self): return True 40 | def enter_state(self, message_info, reply, send_callback): pass 41 | 42 | @staticmethod 43 | def load(state_repr): return StartState(json.loads(state_repr)) 44 | def __repr__(self): return json.dumps(self.settings) 45 | 46 | def run(self, message_info, reply, send_callback): 47 | chat_id = load_chat_id(message_info) 48 | keypair = KeyCustodialUtils.get_keypair_for_user(chat_id) 49 | 50 | if 'message' in message_info: 51 | text = message_info['message'].get('text', '') 52 | if text == '/setwallet' or text == '/start set-wallet': 53 | new_state = SetWalletState(self.settings) 54 | new_state.enter_state(message_info, reply, send_callback) 55 | return new_state 56 | if text == '/me' or text == '/start me': 57 | wallet_text = f'Wallet:
{self.settings.get("address","")}
\n' 58 | pkey_text = f'Public key - ID {keypair["key_id"]}:
{keypair["public_armored"]}
' 59 | reply(wallet_text + pkey_text, keyboard=[['Show secret key'], ['/setwallet']]) 60 | if text == 'Show secret key' or text == '/showsecret': 61 | chat_id = message_info['message']['chat']['id'] 62 | keypair = KeyCustodialUtils.get_keypair_for_user(chat_id) 63 | reply('
%s
' % keypair['secret_armored']) 64 | 65 | return self 66 | elif 'chosen_inline_result' in message_info: 67 | reply(JobPostUtils.format_deploy_links( 68 | message_info['chosen_inline_result']['query'], 69 | self.settings['address'], 70 | keypair['public'] 71 | )) 72 | return self 73 | 74 | if 'address' not in self.settings: 75 | reply([], {'text': 'Set wallet address', 'start_parameter': 'set-wallet'}) 76 | elif message_info['inline_query']['chat_type'] in ('group', 'supergroup'): 77 | reply([], {'text': 'Usage in groups is locked', 'start_parameter': 'me'}) 78 | else: 79 | reply(JobPostUtils.format_article_list( 80 | message_info['inline_query']['query'], 81 | self.settings['address'], 82 | keypair['public'] 83 | ), None) 84 | 85 | chat_id = message_info['inline_query']['from']['id'] 86 | # send_callback(chat_id, 'You need to deploy the job.') 87 | 88 | return self 89 | 90 | 91 | @RegisterState 92 | class SetWalletState(IState): 93 | def __init__(self, settings=None): self.settings = settings or {} 94 | def needs_message(self): return True 95 | def enter_state(self, message_info, reply, send_callback): 96 | reply('Provide your TON wallet address in any format.') 97 | 98 | @staticmethod 99 | def load(state_repr): return StartState(json.loads(state_repr)) 100 | def __repr__(self): return json.dumps(self.settings) 101 | 102 | def run(self, message_info, reply, send_callback): 103 | if 'message' in message_info: 104 | text = message_info['message'].get('text', '') 105 | try: 106 | self.settings['address'] = resolve_to_userfriendly(text) 107 | reply(f'Wallet address set to {self.settings["address"]}.', custom={ 108 | 'reply_markup': {'inline_keyboard': [[{ 109 | 'text': 'Back to original chat', 110 | 'switch_inline_query': '' 111 | }]]} 112 | }) 113 | return StartState(self.settings) 114 | except TONDNSResolutionError as e: 115 | reply('Error when resolving address: ' + str(e) + '. Please, try again.') 116 | except Exception as e: 117 | reply(repr(e)) 118 | else: 119 | reply([], {'text': 'Set wallet address', 'start_parameter': 'set-wallet'}) 120 | return self 121 | 122 | 123 | @RegisterState 124 | class SentinelState(IState): 125 | ''' 126 | State where bot needs to shutdown. 127 | ''' 128 | def __init__(self, previous_state=None): 129 | logging.debug(f'State just before bot shutdown: {previous_state}') 130 | self.previous_state = previous_state 131 | 132 | def needs_message(self): 133 | return False 134 | 135 | def enter_state(self, message_info, reply, send_callback): 136 | reply('Stopping.') 137 | 138 | def run(self, message_info, reply, send_callback): 139 | return self 140 | 141 | @staticmethod 142 | def load(state_repr): 143 | logging.info(f'Loading SentinelState: {state_repr}') 144 | if state_repr != 'None': return IState.load(state_repr) # state just before SentinelState 145 | return SentinelState() 146 | def __repr__(self): 147 | if not self.previous_state: return '' 148 | return self.previous_state.__class__.__name__ + ':' + repr(self.previous_state) 149 | 150 | 151 | def format_donation_msg(): 152 | return ''' 153 | TON, mainnet:
EQCyoez1VF4HbNNq5Rbqfr3zKuoAjKorhK-YZr7LIIiVrSD7
154 | ton://transfer/EQCyoez1VF4HbNNq5Rbqfr3zKuoAjKorhK-YZr7LIIiVrSD7 155 | '''.strip() 156 | 157 | 158 | def donation_middleware(backend, in_msg_full): 159 | if 'message' not in in_msg_full: return 160 | in_msg_full = in_msg_full['message'] 161 | in_msg_body = in_msg_full.get('text', '') 162 | sender = in_msg_full['from']['id'] 163 | lt = in_msg_full['message_id'] 164 | 165 | if in_msg_body == '/donate': 166 | backend.send_message(sender, format_donation_msg(), reply=lt) 167 | return True 168 | elif in_msg_body == '/stopkb': 169 | raise KeyboardInterrupt 170 | elif in_msg_body == '//restart': 171 | os.startfile(__file__.replace('states.py', 'main.py')) 172 | raise KeyboardInterrupt 173 | else: 174 | return False 175 | -------------------------------------------------------------------------------- /bot/textutils.py: -------------------------------------------------------------------------------- 1 | from cli.jobs import analytic_msg, job_state_init, JOB_NOTIFICATIONS 2 | from cli.bcutils import encode_text 3 | from tonsdk.utils import Address 4 | from tonsdk.boc import Cell 5 | 6 | from base64 import urlsafe_b64encode, b16encode 7 | import secrets 8 | 9 | 10 | def part_escape_html(text: str) -> str: 11 | return text.replace('<', '<').replace('>', '>') 12 | 13 | 14 | def ton_link(destination: Address, init: Cell, body: Cell, value: int) -> str: 15 | addr = destination.to_string(True, True, True) 16 | boc = urlsafe_b64encode(body.to_boc(False)).decode('ascii') 17 | link = f'ton://transfer/{addr}?bin={boc}&amount={value}' 18 | if init: 19 | link += '&init=' 20 | link += urlsafe_b64encode(init.to_boc(False)).decode('ascii') 21 | return link 22 | 23 | 24 | class JobPostUtils: 25 | @staticmethod 26 | def parse_amount_title_description(message: str) -> tuple[int, str, str]: 27 | # message is like '20 [ton] the job. text' 28 | # or '20ton the job. text' 29 | 30 | words = message.strip().split(' ', 2) 31 | if len(words) < 2: raise Exception('insufficient words') 32 | 33 | price = words[0].lower() 34 | if price.endswith('ton'): 35 | price = price.removesuffix('ton') 36 | elif words[1].lower() == 'ton': # removing TON suffix 37 | words.pop(1) 38 | 39 | if '$' in price: raise Exception('$ denomination is not supported') 40 | 41 | amount = int(float(price) * 1e9) 42 | if amount < 10**8: raise Exception('too small job price') 43 | 44 | text = ' '.join(words[1:]) 45 | 46 | if '.' in text: 47 | title, description = text.split('.', 1) 48 | return amount, title.strip(), description.strip() 49 | else: 50 | return amount, 'Untitled', text.strip() 51 | 52 | @staticmethod 53 | def create_address_deploylinks(value: int, text: str, my_address: str, 54 | public_key: bytes) -> tuple[str, list[str]]: 55 | description_cell = encode_text(text) 56 | key_int = int.from_bytes(public_key, 'big') 57 | 58 | state_init = job_state_init(my_address, value, description_cell, key_int) 59 | addr = Address('0:' + b16encode(state_init.bytes_hash()).decode('ascii')) 60 | am = analytic_msg(addr, value, description_cell, key_int) 61 | 62 | jobs = Address(JOB_NOTIFICATIONS) 63 | jobs.is_bounceable = False 64 | 65 | return addr.to_string(True, True, True), [ 66 | ton_link(addr, state_init, Cell(), value), 67 | ton_link(jobs, None, am, 5*10**7) 68 | ] 69 | 70 | @staticmethod 71 | def format_article_list(message: str, my_address: str, public_key: bytes) -> list: 72 | try: 73 | amount, title, description = JobPostUtils.parse_amount_title_description(message) 74 | 75 | job_text = f'# {title}\n\n{description}' 76 | job_addr, _ = JobPostUtils.create_address_deploylinks(amount, job_text, my_address, public_key) 77 | 78 | article_text = f''' 79 | {part_escape_html(title)} 80 | worth {amount/1e9:.2f} TON, by
{my_address}
81 | 82 | {part_escape_html(description)} 83 | 84 | Job address:
{job_addr}
85 | '''.replace(' ' * 12, '').strip() 86 | 87 | return [{ 88 | 'type': 'article', 89 | 'id': secrets.token_urlsafe(), 90 | 'title': f'Create job "{part_escape_html(title)}" worth {amount/1e9:.2f} TON', 91 | 'input_message_content': { 92 | 'message_text': article_text, 'parse_mode': 'html' 93 | } 94 | }] 95 | except: 96 | return [] 97 | 98 | @staticmethod 99 | def format_deploy_links(message: str, my_address: str, public_key: bytes) -> str: 100 | try: 101 | amount, title, description = JobPostUtils.parse_amount_title_description(message) 102 | 103 | job_text = f'# {title}\n\n{description}' 104 | _, links = JobPostUtils.create_address_deploylinks(amount, job_text, my_address, public_key) 105 | 106 | deploy, notify = links 107 | 108 | return (f'Job "{part_escape_html(title)}" worth {amount/1e9:.2f} TON\n' + 109 | f'[Deploy] + ' + 110 | f'[notify others about job contract]') 111 | except Exception as e: 112 | return 'Error when creating deploy links: ' + repr(e) 113 | -------------------------------------------------------------------------------- /bot/tg.py: -------------------------------------------------------------------------------- 1 | __all__ = ['check_bot_ok', 'send', 'yield_messages'] 2 | 3 | 4 | import traceback 5 | import logging 6 | 7 | import requests 8 | 9 | from persistence import PersistentValue 10 | from utils import TOKEN 11 | 12 | 13 | url = 'https://api.telegram.org/bot%s/' % TOKEN 14 | update_id = PersistentValue('update_id.txt', default=0) 15 | 16 | 17 | def check_bot_ok(): 18 | assert requests.get(url + 'getMe').json()['ok'] 19 | 20 | 21 | def yield_messages(): 22 | ''' 23 | Generator yielding new messages sent from users to Telegram bot. 24 | ''' 25 | 26 | try: 27 | updates = requests.get(url + 'getUpdates', params={ 28 | 'offset': update_id.get() + 1, 29 | 'timeout': 15 30 | }, timeout=20).json() 31 | except requests.exceptions.ReadTimeout: 32 | return 33 | except Exception as e: 34 | logging.error(repr(e)) 35 | return 36 | 37 | if not updates['ok']: 38 | logging.error('Request failed: ' + updates['description']) 39 | return 40 | 41 | for update in updates['result']: 42 | update_id.set_max(update['update_id']) 43 | 44 | ''' 45 | typeof update = 46 | {'inline_query': {'id': str, 'from': User, 'query': str, 'offset': str}} | 47 | {'message': {'message_id': int, 'from': User?, 'date': int, 'chat': Chat, 48 | 'reply_to_message': Message, 'text': str?}}; 49 | ''' 50 | 51 | if 'message' in update or 'inline_query' in update or 'chosen_inline_result' in update: 52 | logging.debug(update) 53 | yield update 54 | 55 | 56 | def respond_inline_query(query_id, results, button): 57 | msg_params = { 58 | 'inline_query_id': query_id, 59 | 'results': results, 60 | 'is_personal': True, 61 | 'cache_time': 40 62 | } 63 | if button: msg_params['button'] = button 64 | 65 | logging.warning(msg_params) 66 | try: 67 | result = requests.post(url + 'answerInlineQuery', json=msg_params).json() 68 | except: 69 | result = {'ok': False, 'description': traceback.format_exc()} 70 | 71 | if not result.get('ok', False): 72 | logging.warning('Inline responding failed ' + str(result)) 73 | 74 | return result 75 | 76 | 77 | def send(chat, text, reply=None, keyboard=None, parse_mode='html', custom={}): 78 | ''' 79 | Function sending message `text` via Telegram bot to user specified by `chat`. 80 | If `reply` is specified, the sent message is reply to message with specified ID. 81 | `keyboard` must be either None or List[List[str]] - buttons' text. 82 | `parse_mode` is forwarded to Telegram as-is and changes text handling. 83 | ''' 84 | 85 | msg_params = {'chat_id': chat, 'text': text, 'parse_mode': parse_mode} 86 | 87 | if reply: 88 | msg_params['reply_to_message_id'] = reply 89 | 90 | if keyboard: 91 | msg_params['reply_markup'] = { 92 | 'keyboard': keyboard, 93 | 'one_time_keyboard': True 94 | } 95 | else: 96 | msg_params['reply_markup'] = {'remove_keyboard': True} 97 | 98 | msg_params.update(custom) 99 | 100 | try: 101 | result = requests.post(url + 'sendMessage', json=msg_params).json() 102 | except: 103 | result = {'ok': False, 'description': traceback.format_exc()} 104 | 105 | if not result.get('ok', False): 106 | logging.warning('Sending message failed ' + str(result)) 107 | 108 | return result 109 | -------------------------------------------------------------------------------- /cli/about.py: -------------------------------------------------------------------------------- 1 | from .colors import nh, h, nb, b, ns, s 2 | 3 | PROMPT_SINGLE = f'''{b}{"="*80}{ns} 4 | Jobs {h}jl{nh}: list {h}jp{nh}: post {h}ji{nh}: info {h}jv{nh}: load+verify {h}jr{nh}: revoke {h}jd{nh}: delegate 5 | Offers {h}ol{nh}: list {h}op{nh}: post {h}oi{nh}: info {h}ov{nh}: load+verify {h}or{nh}: revoke 6 | Contracts {s}cu: unseal{ns}{h}{nh} {h}ct{nh}: talk {h}cn{nh}: negotiate 7 | General {h} h{nh}: help {h} u{nh}: update {h} q{nh}: quit {h} d{nh}: donate 8 | Keys {h}kl{nh}: list {h}ke{nh}: export {h}kn{nh}: new {h}ki{nh}: import 9 | {b}{"="*80}{ns} 10 | ''' 11 | 12 | PROMPT = f'{b}ratelance> {nb}' 13 | 14 | ABOUT = f''' 15 | Repository: {h}https://github.com/ProgramCrafter/ratelance/{nh} 16 | TG channel: {h}https://t.me/ratelance{nh} 17 | '''.strip() 18 | -------------------------------------------------------------------------------- /cli/assets/contract-job.boc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgramCrafter/ratelance/2de56f4f96acf0f9e1e5b0e60ae0ad846ba50170/cli/assets/contract-job.boc -------------------------------------------------------------------------------- /cli/assets/contract-offer.boc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgramCrafter/ratelance/2de56f4f96acf0f9e1e5b0e60ae0ad846ba50170/cli/assets/contract-offer.boc -------------------------------------------------------------------------------- /cli/bcutils.py: -------------------------------------------------------------------------------- 1 | from tonsdk.boc import Builder, Cell 2 | from tonsdk.utils import Address 3 | import requests 4 | 5 | 6 | 7 | def input_address(prompt): 8 | while True: 9 | try: 10 | return Address(input(prompt)) 11 | except KeyboardInterrupt: 12 | raise 13 | except Exception: 14 | pass 15 | 16 | 17 | def input_long(prompt): 18 | s = '' 19 | while True: 20 | t = input(prompt) 21 | if not t: return s 22 | 23 | prompt = '> ' 24 | s += t + '\n' 25 | 26 | 27 | def load_transactions(address, start_lt): 28 | return requests.get( 29 | f'https://tonapi.io/v1/blockchain/getTransactions?account={address}&limit=100' 30 | ).json()['transactions'] 31 | 32 | 33 | def load_account(address): 34 | return requests.get( 35 | f'https://tonapi.io/v1/blockchain/getAccount?account={address}' 36 | ).json() 37 | 38 | 39 | def decode_text(cell: Cell) -> str: 40 | a = '' 41 | s = cell.begin_parse() 42 | if s.is_empty(): return '' 43 | if s.preload_uint(32) == 0: s.skip_bits(32) 44 | a += s.load_bytes(len(s) // 8).decode('utf-8') 45 | while s.refs: 46 | s = s.load_ref().begin_parse() 47 | a += s.load_bytes(len(s) // 8).decode('utf-8') 48 | return a 49 | 50 | 51 | def encode_text(a) -> Cell: 52 | if isinstance(a, str): 53 | s = a.encode('utf-8') 54 | else: 55 | s = a 56 | 57 | b = Builder() 58 | b.store_bytes(s[:127]) 59 | if len(s) > 127: 60 | b.store_ref(encode_text(s[127:])) 61 | return b.end_cell() 62 | 63 | 64 | def shorten_escape(s: str, indent=2) -> str: 65 | s = s.replace('\x1b', '').replace('\r', '') 66 | lines = s.split('\n') 67 | if len(lines) > 4: 68 | lines = lines[:3] + ['...'] 69 | if lines: 70 | lines = [lines[0]] + [' ' * indent + line for line in lines[1:]] 71 | return '\n'.join(lines) 72 | -------------------------------------------------------------------------------- /cli/colors.py: -------------------------------------------------------------------------------- 1 | # ANSI escape sequences 2 | 3 | # highlight (green) 4 | nh = '\x1b[37m' 5 | h = '\x1b[32m' 6 | 7 | # strikethrough (or invisible) 8 | ns = '\x1b[37m' 9 | s = '\x1b[30m' 10 | 11 | # blue 12 | nb = '\x1b[37m' 13 | b = '\x1b[36m' 14 | -------------------------------------------------------------------------------- /cli/contracts.py: -------------------------------------------------------------------------------- 1 | from .bcutils import input_address, load_account, load_transactions 2 | from .colors import h, nh, b, nb 3 | from .signing import sign_send 4 | 5 | from tonsdk.boc import Builder, Cell 6 | from tonsdk.utils import Address 7 | import nacl.signing 8 | 9 | from base64 import b16decode, b16encode, b64decode 10 | import hashlib 11 | 12 | 13 | 14 | def serialize_signed_data(job: Address, bottom: int, upper: int) -> bytes: 15 | b = Builder() 16 | b.store_bytes(b16decode('FFFF726C3A3A6A6F623A3A7630')) 17 | b.store_uint(0, 5) 18 | b.store_address(job) 19 | b.store_uint(bottom, 64) 20 | b.store_uint(upper, 64) 21 | return b.end_cell().bytes_hash() 22 | 23 | 24 | def sign_pay_proposal(job: Address, bottom: int, upper: int, role: int, key_id: str, keyring) -> Cell: 25 | key = keyring.keys_info[key_id] 26 | secret_key_obj = nacl.signing.SigningKey(key['secret']) 27 | to_sign = serialize_signed_data(job, bottom, upper) 28 | signature = secret_key_obj.sign(to_sign)[:512//8] 29 | # print(signature, len(signature)) 30 | 31 | b = Builder() 32 | b.store_uint(role, 2) 33 | b.store_bytes(signature) 34 | b.store_uint(bottom, 64) 35 | b.store_uint(upper, 64) 36 | return b.end_cell() 37 | 38 | 39 | def double_sign_proposal(job: Address, bottom: int, upper: int, key_id1: str, key_id2: str, keyring, role1=0, role2=1) -> Cell: 40 | b = Builder() 41 | b.store_uint(0x000000BB, 32) 42 | b.store_ref(sign_pay_proposal(job, bottom, upper, role1, key_id1, keyring)) 43 | b.store_ref(sign_pay_proposal(job, bottom, upper, role2, key_id2, keyring)) 44 | return b.end_cell() 45 | 46 | 47 | def upsign_proposal(job: Address, bottom: int, upper: int, key_id: str, keyring, role: int, proposal: Cell) -> Cell: 48 | b = Builder() 49 | b.store_uint(0x000000BB, 32) 50 | b.store_ref(proposal) 51 | b.store_ref(sign_pay_proposal(job, bottom, upper, role, key_id, keyring)) 52 | return b.end_cell() 53 | 54 | 55 | def close_job_with(job: Address, proposal: Cell): 56 | sign_send([ 57 | (job, None, proposal, 5*10**7) 58 | ], 'closing job ' + job.to_string(True, True, True)) 59 | 60 | 61 | def load_job_keys_triple(job: Address) -> (bytes, bytes, bytes): 62 | acc = load_account(job.to_string(True, True, True)) 63 | if acc['status'] != 'active': 64 | raise Exception('contract ' + acc['status']) 65 | 66 | d = Cell.one_from_boc(b16decode(acc['data'].upper())).begin_parse() 67 | flag = d.load_uint(2) 68 | if flag != 2: 69 | raise Exception('job is not delegated') 70 | 71 | d.load_msg_addr() 72 | d.load_msg_addr() 73 | d.skip_bits(64) 74 | d.load_ref() 75 | d.load_ref() 76 | keys = d.load_ref().begin_parse() 77 | d.end_parse() 78 | 79 | p = keys.load_bytes(32) 80 | w = keys.load_bytes(32) 81 | r = keys.load_bytes(32) 82 | keys.end_parse() 83 | return (p, w, r) 84 | 85 | 86 | def check_intersect(limits_a: tuple[int, int], proposal_plus_limits: tuple[Cell, int, int]) -> bool: 87 | return max(limits_a[0], proposal_plus_limits[1]) <= min(limits_a[1], proposal_plus_limits[2]) 88 | 89 | 90 | def check_negotiate_suggestions(job: Address, p: bytes, w: bytes, r: bytes, skip_role: int): 91 | for tx in load_transactions(job.to_string(True, True, True), 0): 92 | try: 93 | body = Cell.one_from_boc(b64decode(tx['in_msg']['msg_data'])).begin_parse() 94 | if body.preload_uint(32) != 0x4bed4ee8: continue 95 | 96 | proposal_cell = body.load_ref() 97 | proposal_part = proposal_cell.begin_parse() 98 | role = proposal_part.load_uint(2) 99 | if role == 3: 100 | print('* someone claims that TON validators have voted; check TON config param 1652841508') 101 | continue 102 | if role == skip_role: 103 | # this can be our own negotiation message 104 | continue 105 | 106 | check_key_bytes = ((p, w, r))[role] 107 | check_key_obj = nacl.signing.VerifyKey(check_key_bytes) 108 | 109 | signature = proposal_part.load_bytes(512 // 8) 110 | pay_bottom = proposal_part.load_uint(64) 111 | pay_upper = proposal_part.load_uint(64) 112 | 113 | check_key_obj.verify(serialize_signed_data(job, pay_bottom, pay_upper), 114 | signature) 115 | 116 | yield [proposal_cell, pay_bottom, pay_upper] 117 | except Exception as exc: 118 | print(f'{b}Error while processing contract payment negotiation:{nb}', repr(exc)) 119 | 120 | 121 | def is_key_available(i: int, key: bytes, keyring) -> bool: 122 | key_hex = b16encode(key).decode('ascii') 123 | key_armored = 'pub:ed25519:vk:' + key_hex 124 | key_id = hashlib.sha256(key_armored.encode('ascii')).hexdigest()[::8] 125 | if key_id in keyring.keys_info: 126 | return (i, key_id) 127 | return None 128 | 129 | 130 | def check_available_roles_keyids(p: bytes, w: bytes, r: bytes, keyring): 131 | return list(filter(None, [is_key_available(i, k, keyring) for i,k in enumerate((p,w,r))])) 132 | 133 | 134 | def process_contract_cmd(command: str, keyring): 135 | if command == 'ct': 136 | print(f'{h}not implemented:{nh} \'ct\'') 137 | elif command == 'cn': 138 | job = input_address(f'{b}Job address:{nb} ') 139 | (poster_key, worker_key, ratelance_key) = load_job_keys_triple(job) 140 | available_keys = check_available_roles_keyids(poster_key, worker_key, ratelance_key, keyring) 141 | 142 | if len(available_keys) > 1: 143 | print('We have keys of two sides. Proceeding to contract closing.') 144 | upper = int(float(input(f'{b}TON to give to freelancer (upper limit):{nb} ')) * 1e9) 145 | bottom = int(float(input(f'{b}TON to give to freelancer (bottom limit):{nb} ')) * 1e9) 146 | 147 | role1, key1 = available_keys[0] 148 | role2, key2 = available_keys[1] 149 | 150 | close_job_with(job, double_sign_proposal( 151 | job, bottom, upper, key1, key2, keyring, role1, role2 152 | )) 153 | else: 154 | print('We have key of single party. Looking for negotiation requests.') 155 | skip_role = available_keys[0][0] 156 | 157 | # (proposal_cell, pay_bottom, pay_upper) 158 | negotiations = list(check_negotiate_suggestions(job, poster_key, worker_key, ratelance_key, skip_role)) 159 | 160 | if negotiations: 161 | min_fl = min(n[1] for n in negotiations) / 1e9 162 | max_fl = max(n[2] for n in negotiations) / 1e9 163 | print(f'There are suggestions to give {min_fl}..{max_fl} TON to freelancer.') 164 | upper = int(float(input(f'{b}TON you want to give to freelancer (upper limit):{nb} ')) * 1e9) 165 | bottom = int(float(input(f'{b}TON you want to give to freelancer (bottom limit):{nb} ')) * 1e9) 166 | 167 | for n in negotiations: 168 | if check_intersect((bottom, upper), n): 169 | print('Found matching suggestion. Building message with two signatures...') 170 | p = upsign_proposal(job, bottom, upper, available_keys[0][1], keyring, skip_role, n[0]) 171 | close_job_with(job, p) 172 | return 173 | else: 174 | print('No matching suggestion. Proceeding to sending negotiation request to other party.') 175 | else: 176 | upper = int(float(input(f'{b}TON you want to give to freelancer (upper limit):{nb} ')) * 1e9) 177 | bottom = int(float(input(f'{b}TON you want to give to freelancer (bottom limit):{nb} ')) * 1e9) 178 | 179 | p = sign_pay_proposal(job, bottom, upper, skip_role, available_keys[0][1], keyring) 180 | msg = Builder() 181 | msg.store_uint(0x4bed4ee8, 32) 182 | msg.store_ref(p) 183 | sign_send([ 184 | (job, None, msg.end_cell(), 2) 185 | ], 'negotiating payment for job ' + job.to_string(True, True, True)) 186 | 187 | else: 188 | print(f'{b}not implemented:{nb} {repr(command)}') 189 | -------------------------------------------------------------------------------- /cli/jobs.py: -------------------------------------------------------------------------------- 1 | from .bcutils import decode_text, encode_text, load_transactions, shorten_escape, input_address, load_account, input_long 2 | from .signing import retrieve_auth_wallet, sign_send 3 | from .colors import h, nh, b, nb 4 | from .keyring import Keyring 5 | 6 | from base64 import b64decode, b16decode, b16encode 7 | import traceback 8 | import hashlib 9 | 10 | from tonsdk.boc import Builder, Cell 11 | from tonsdk.utils import Address 12 | from .tslice import Slice 13 | 14 | 15 | 16 | JOB_NOTIFICATIONS = 'EQA__RATELANCE_______________________________JvN' 17 | 18 | 19 | def job_data_init(poster: str, value: int, desc: Cell, key: int) -> Cell: 20 | di = Builder() 21 | di.store_uint(0, 2) 22 | di.store_address(Address(poster)) 23 | di.store_uint(value, 64) 24 | di.store_ref(desc) 25 | di.store_uint(key, 256) 26 | return di.end_cell() 27 | 28 | 29 | def job_state_init(poster: str, value: int, desc: Cell, key: int) -> Cell: 30 | with open(__file__ + '/../assets/contract-job.boc', 'rb') as f: 31 | code = Cell.one_from_boc(f.read()) 32 | 33 | si = Builder() 34 | si.store_uint(6, 5) 35 | si.store_ref(code) 36 | si.store_ref(job_data_init(poster, value, desc, key)) 37 | return si.end_cell() 38 | 39 | 40 | def analytic_msg(job: Address, value: int, desc: Cell, key: int) -> Cell: 41 | am = Builder() 42 | am.store_uint(0x130850fc, 32) 43 | am.store_address(job) 44 | am.store_uint(value, 64) 45 | am.store_ref(desc) 46 | am.store_uint(key, 256) 47 | return am.end_cell() 48 | 49 | 50 | def load_jobs(start_lt=None, custom_notif_addr=None): 51 | if start_lt: raise Exception('loading specific jobs not supported') 52 | 53 | notif = custom_notif_addr or JOB_NOTIFICATIONS 54 | for tx in load_transactions(notif, start_lt=start_lt): 55 | try: 56 | body = Cell.one_from_boc(b64decode(tx['in_msg']['msg_data'])).begin_parse() 57 | 58 | if body.preload_uint(32) != 0x130850fc: 59 | # legacy job notification [without TL-B tag] 60 | assert tx['hash'] in ( 61 | '155ccfdc282660413ada2c1b71ecbd5935db7afd40c82d7b36b8502fea064b8a', 62 | 'f2c76d3ea82e6147887320a71b359553f99cb176a521d63081facfb80a183dbf', 63 | ) 64 | else: 65 | body.load_uint(32) 66 | 67 | job = body.load_msg_addr() 68 | poster = Address(tx['in_msg']['source']['address']).to_string(True, True, True) 69 | value = body.load_uint(64) 70 | desc = body.load_ref() 71 | desc_text = decode_text(desc) 72 | poster_key = body.load_uint(256) 73 | 74 | # TODO: skip notifications with value < 0.05 TON 75 | 76 | if job.hash_part != job_state_init(poster, value, desc, poster_key).bytes_hash(): 77 | print(f'{b}Found notification with invalid job address:{nb}', job.to_string()) 78 | print(f'* {h}poster: {nh}{poster}') 79 | print(f'* {h}description: {nh}{shorten_escape(desc_text)}') 80 | else: 81 | yield (job.to_string(True, True, True), poster, value, desc_text) 82 | except Exception as exc: 83 | print(f'{b}Failure while processing notification:{nb}', repr(exc)) 84 | 85 | 86 | def show_jobs(start_lt=None, custom_notif_addr=None, validate_jobs=False): 87 | if validate_jobs: raise Exception('validating jobs not supported') 88 | 89 | for (job, poster, value, desc) in load_jobs(start_lt, custom_notif_addr): 90 | jid = job[40:] 91 | print(f'Order [{h}{jid}{nh}] {job}') 92 | print(f'- {h}posted by{nh} {poster}') 93 | print(f'- {h}promising{nh} {value/1e9} TON, {h}staked{nh} ') 94 | print('-', shorten_escape(desc)) 95 | 96 | 97 | def post_job(value: int, stake: int, desc_text: str, keyring: Keyring, key_id: str): 98 | print(f'\n{h}Creating new job{nh}', repr(desc_text)) 99 | 100 | if not key_id.strip() and len(keyring.keys_info) == 1: 101 | key_id = list(keyring.keys_info.keys())[0] 102 | 103 | key_info = keyring.keys_info[key_id] 104 | assert key_info['key_id'] == key_id 105 | public_key = int.from_bytes(key_info['public'], 'big') 106 | 107 | WAY_PROMPT = f'Send via mnemonic [{h}m{nh}]/wallet seed [{h}s{nh}]/ton link [{h}t{nh}]? ' 108 | while (auth_way := input(WAY_PROMPT).lower()) not in ('m', 's', 't'): pass 109 | 110 | if auth_way == 't': 111 | wallet = None 112 | poster = input_address(f'{b}Your address: {nb}') 113 | else: 114 | wallet = retrieve_auth_wallet(auth_way) 115 | poster = wallet.address.to_string(True, True, True) 116 | 117 | desc = encode_text(desc_text) 118 | si = job_state_init(poster, value, desc, public_key) 119 | addr = Address('0:' + b16encode(si.bytes_hash()).decode('ascii')) 120 | am = analytic_msg(addr, value, desc, public_key) 121 | 122 | jobs = Address(JOB_NOTIFICATIONS) 123 | jobs.is_bounceable = False 124 | 125 | print() 126 | sign_send([ 127 | (addr, si, Cell(), stake), 128 | (jobs, None, am, 5*10**7), 129 | ], 'creating job', auth_way, wallet) 130 | 131 | 132 | def delegate_job(job: str, offer_addr: str): 133 | msg = Builder() 134 | msg.store_uint(0x000000AC, 32) 135 | msg.store_ref(Cell()) 136 | msg.store_address(Address(offer_addr)) 137 | 138 | sign_send([(Address(job), None, msg.end_cell(), 10**9)], 'delegating job') 139 | 140 | 141 | def public_key_desc(key: bytes, keyring) -> str: 142 | key_hex = b16encode(key).decode('ascii') 143 | key_armored = 'pub:ed25519:vk:' + key_hex 144 | key_id = hashlib.sha256(key_armored.encode('ascii')).hexdigest()[::8] 145 | not_present = 'not ' if key_id not in keyring.keys_info else '' 146 | return f'{key_hex} ({key_id}, {h}{not_present}present{nh} in local keyring)' 147 | 148 | 149 | def show_job(job: str, keyring): 150 | acc = load_account(job) 151 | 152 | if acc['status'] != 'active': 153 | print('* contract', acc['status']) 154 | return 155 | 156 | d = Cell.one_from_boc(b16decode(acc['data'].upper())).begin_parse() 157 | flag = d.load_uint(2) 158 | 159 | if flag == 0: 160 | print(f'* job {h}waiting for offers{nh}') 161 | print(f'- {h}posted by {nh}', d.load_msg_addr().to_string(True, True, True)) 162 | print(f'- {h}promising {nh}', d.load_uint(64) / 1e9, 'TON') 163 | print(f'- {h}public key{nh}', public_key_desc(d.load_bytes(32), keyring)) 164 | 165 | j_desc_text = decode_text(d.load_ref()) 166 | print(f'- {h}job descr {nh}', shorten_escape(j_desc_text, indent=12)) 167 | 168 | d.end_parse() 169 | elif flag == 1: 170 | print(f'* job {h}locked on offer{nh}', d.load_msg_addr().to_string(True, True, True)) 171 | print(f'- {h}posted by {nh}', d.load_msg_addr().to_string(True, True, True)) 172 | print(f'- {h}promising {nh}', d.load_uint(64) / 1e9, 'TON') 173 | print(f'- {h}public key{nh}', public_key_desc(d.load_bytes(32), keyring)) 174 | 175 | j_desc_text = decode_text(d.load_ref()) 176 | print(f'- {h}job descr {nh}', shorten_escape(j_desc_text, indent=12)) 177 | 178 | d.end_parse() 179 | elif flag == 2: 180 | print(f'* {h}taken{nh} job') 181 | print(f'- {h}posted by {nh}', d.load_msg_addr().to_string(True, True, True)) 182 | print(f'- {h}worker {nh}', d.load_msg_addr().to_string(True, True, True)) 183 | print(f'- {h}promising {nh}', d.load_uint(64) / 1e9, 'TON') 184 | 185 | j_desc_text = decode_text(d.load_ref()) 186 | print(f'- {h}job descr {nh}', shorten_escape(j_desc_text, indent=13)) 187 | o_desc_text = decode_text(d.load_ref()) 188 | print(f'- {h}offer descr{nh}', shorten_escape(o_desc_text, indent=13)) 189 | 190 | keys = d.load_ref().begin_parse() 191 | print(f'- {h} poster key{nh}', public_key_desc(keys.load_bytes(32), keyring)) 192 | print(f'- {h} worker key{nh}', public_key_desc(keys.load_bytes(32), keyring)) 193 | print(f'- {h}Ratelance key{nh}', public_key_desc(keys.load_bytes(32), keyring)) 194 | 195 | keys.end_parse() 196 | d.end_parse() 197 | else: 198 | print('* broken job contract') 199 | 200 | 201 | def process_jobs_cmd(command, keyring): 202 | if command == 'jl': 203 | show_jobs() 204 | elif command == 'jp': 205 | possible_keys = list(keyring.keys_info.keys()) 206 | if len(possible_keys) > 3: 207 | possible_keys_s = '/'.join(possible_keys[:3]) + '/...' 208 | else: 209 | possible_keys_s = '/'.join(possible_keys) or '' 210 | 211 | key_id = input(f'Used key ({possible_keys_s}): ') 212 | value = int(1e9 * float(input('Promised job value (TON): '))) 213 | stake = int(1e9 * float(input('Send stake (TON): '))) 214 | desc_text = input_long('Job description: ') 215 | 216 | post_job(value, stake, desc_text, keyring, key_id) 217 | elif command == 'jd': 218 | job = input_address('Job address: ') 219 | offer = input_address('Offer address: ') 220 | 221 | delegate_job(job, offer) 222 | elif command == 'ji': 223 | try: 224 | show_job(input_address('Job address: ').to_string(True, True, True), keyring) 225 | except Exception as exc: 226 | print(f'{b}Invalid job:{nb}', repr(exc)) 227 | else: 228 | print(f'{b}not implemented:{nb} {repr(command)}') -------------------------------------------------------------------------------- /cli/keyring.py: -------------------------------------------------------------------------------- 1 | from .colors import nh, h, nb, b, ns, s 2 | 3 | from base64 import b16encode as hex_encode_b2b 4 | from base64 import b16decode 5 | import traceback 6 | import hashlib 7 | import time 8 | import os 9 | 10 | import nacl.signing 11 | import nacl.secret 12 | 13 | 14 | 15 | def b16encode(v): 16 | return hex_encode_b2b(v).decode('ascii') 17 | 18 | class Keyring: 19 | def __init__(self, path=None): 20 | self.path = path or self.generate_keyring_path() 21 | self.keys_info = {} 22 | 23 | def __enter__(self): 24 | try: 25 | with open(self.path, 'r') as f: 26 | self.parse_keys_from(f) 27 | except IOError: 28 | open(self.path, 'x').close() 29 | return self 30 | 31 | def __exit__(self, exc_type, exc_val, exc_trace): 32 | try: 33 | with open(self.path, 'r') as f: 34 | self.parse_keys_from(f) 35 | except IOError: 36 | pass 37 | 38 | self.flush_keys() 39 | 40 | if exc_val: 41 | raise 42 | 43 | def generate_keyring_path(self): 44 | appdata = os.environ['LOCALAPPDATA'] 45 | return os.path.abspath(appdata + '/ratelance-private-keys.dat') 46 | 47 | def flush_keys(self): 48 | with open(self.path, 'w') as f: 49 | self.write_keys_to(f) 50 | 51 | def parse_keys_from(self, file): 52 | for line in file: 53 | version, public_key, secret_key, name = line.strip().split(' ', 3) 54 | assert version == 'v0.0.2' 55 | assert public_key.startswith('pub:ed25519:vk:') 56 | assert secret_key.startswith('prv:ed25519:sk:') 57 | 58 | key_id = hashlib.sha256(public_key.encode('ascii')).hexdigest()[::8] 59 | 60 | self.keys_info[key_id] = { 61 | 'public': b16decode(public_key.removeprefix('pub:ed25519:vk:')), 62 | 'secret': b16decode(secret_key.removeprefix('prv:ed25519:sk:')), 63 | 'key_id': key_id, 64 | 'name': name 65 | } 66 | 67 | def write_keys_to(self, file): 68 | for (key_id, key_info) in self.keys_info.items(): 69 | public_key = 'pub:ed25519:vk:' + b16encode(key_info['public']) 70 | secret_key = 'prv:ed25519:sk:' + b16encode(key_info['secret']) 71 | 72 | print('v0.0.2', public_key, secret_key, key_info['name'], 73 | file=file, flush=True) 74 | 75 | def add_key(self, secret_bytes, name): 76 | secret_key_obj = nacl.signing.SigningKey(secret_bytes) 77 | public_bytes = secret_key_obj.verify_key.encode() 78 | 79 | public_key_armored = 'pub:ed25519:vk:' + b16encode(public_bytes) 80 | key_id = hashlib.sha256(public_key_armored.encode('ascii')).hexdigest()[::8] 81 | 82 | self.keys_info[key_id] = { 83 | 'public': public_bytes, 84 | 'secret': secret_bytes, 85 | 'key_id': key_id, 86 | 'name': name 87 | } 88 | self.flush_keys() 89 | return self.keys_info[key_id] 90 | 91 | def generate_new_key(self): 92 | return self.add_key( 93 | nacl.secret.random(32), 94 | f'''key@[{time.strftime('%Y.%m.%d %H:%M:%S')}]''' 95 | ) 96 | 97 | 98 | def process_keyring_cmd(command, keyring): 99 | if command == 'kl': 100 | print('Keys list:') 101 | 102 | for (key_id, key_info) in keyring.keys_info.items(): 103 | print(f'- {b}{key_id}{nb} -', repr(key_info['name'])) 104 | 105 | elif command == 'ke': 106 | key_id = input(f' {h}Key ID:{nh} ') 107 | 108 | try: 109 | key_info = keyring.keys_info[key_id] 110 | assert key_info['key_id'] == key_id 111 | print('- ID: ', key_id) 112 | print('- Name: ', key_info['name']) 113 | print('- Public key:', b16encode(key_info['public'])) 114 | print('- Secret key:', b16encode(key_info['secret'])) 115 | except: 116 | traceback.print_exc() 117 | 118 | elif command == 'ki': 119 | secret = input(f' {h}HEX secret key:{nh} ') 120 | name = input(f' {h}Key name:{nh} ') 121 | 122 | try: 123 | secret_bytes = b16decode(secret.upper()) 124 | assert len(secret_bytes) == 32 125 | keyring.add_key(secret_bytes, name) 126 | except: 127 | traceback.print_exc() 128 | 129 | elif command == 'kn': 130 | key_info = keyring.generate_new_key() 131 | print(f'{h}Created key{nh}', key_info['key_id'], repr(key_info['name'])) 132 | 133 | else: 134 | print(f'{b}not implemented:{nb} {repr(command)}') 135 | -------------------------------------------------------------------------------- /cli/offers.py: -------------------------------------------------------------------------------- 1 | from .bcutils import decode_text, encode_text, load_transactions, shorten_escape, input_address, load_account 2 | from .signing import retrieve_auth_wallet, sign_send, sign_install_plugin, sign_uninstall_plugin 3 | from .colors import h, nh, b, nb 4 | from .keyring import Keyring 5 | 6 | from base64 import b64decode, b16decode, b16encode 7 | import traceback 8 | import time 9 | 10 | from tonsdk.boc import Builder, Cell 11 | from tonsdk.utils import Address 12 | from .tslice import Slice 13 | 14 | 15 | 16 | def offer_data_init(job: str, worker: Address, stake: int, desc: Cell, 17 | key: int, short_job_hash: int) -> Cell: 18 | di = Builder() 19 | di.store_uint(0, 2) 20 | di.store_address(Address(job)) 21 | di.store_address(worker) 22 | di.store_uint(stake, 64) 23 | di.store_ref(desc) 24 | di.store_uint(key, 256) 25 | di.store_uint(short_job_hash, 160) 26 | return di.end_cell() 27 | 28 | 29 | def offer_state_init(job: str, worker: Address, stake: int, desc: Cell, 30 | key: int, short_job_hash: int) -> Cell: 31 | with open(__file__ + '/../assets/contract-offer.boc', 'rb') as f: 32 | code = Cell.one_from_boc(f.read()) 33 | 34 | si = Builder() 35 | si.store_uint(6, 5) 36 | si.store_ref(code) 37 | si.store_ref(offer_data_init(job, worker, stake, desc, key, short_job_hash)) 38 | return si.end_cell() 39 | 40 | 41 | def analytic_msg(offer: Address, stake: int, desc: Cell, 42 | key: int, short_job_hash: int) -> Cell: 43 | am = Builder() 44 | am.store_uint(0x18ceb1bf, 32) 45 | am.store_address(offer) 46 | am.store_uint(stake, 64) 47 | am.store_ref(desc) 48 | am.store_uint(key, 256) 49 | am.store_uint(short_job_hash, 160) 50 | return am.end_cell() 51 | 52 | 53 | def load_offers(job: str, start_lt=None): 54 | if start_lt: raise Exception('loading specific offers not supported') 55 | 56 | job_data = Cell.one_from_boc(b16decode(load_account(job)['data'].upper())) 57 | job_hash = int.from_bytes(job_data.bytes_hash(), 'big') & ((1 << 160) - 1) 58 | 59 | if job_data.begin_parse().load_uint(2) != 0: 60 | print(f'{b}[job not in unlocked state]{nb}') 61 | 62 | for tx in load_transactions(job, start_lt=start_lt): 63 | try: 64 | if tx['in_msg']['value'] != 1: continue 65 | 66 | worker = Address(tx['in_msg']['source']['address']) 67 | body = Cell.one_from_boc(b64decode(tx['in_msg']['msg_data'])).begin_parse() 68 | 69 | if body.preload_uint(32) not in (0x18ceb1bf, 0x4bed4ee8): 70 | print(f'\n{b}* legacy offer notification [without TL-B tag]{nb}') 71 | assert tx['hash'] in ( 72 | '4109a9bf38f7376acdb013ff95e33ebb5112c00ebd9d93a348361522400b5783', 73 | '8fb9cb7532a8d6a710106d1c346f99bdd22a2d74480858956ecc19a02e1dfd8d', 74 | 'a598c47a792ceccb9e26b2cd5cc73c7a6024dae12f24ae20747182966b407b65', 75 | ) 76 | else: 77 | body.skip_bits(32) 78 | 79 | offer = body.load_msg_addr() 80 | stake = body.load_uint(64) 81 | desc = body.load_ref() 82 | desc_text = decode_text(desc) 83 | key = body.load_uint(256) 84 | shjh = body.load_uint(160) 85 | 86 | hash_up_to_date = shjh == job_hash 87 | 88 | if offer.hash_part != offer_state_init(job, worker, stake, desc, key, shjh).bytes_hash(): 89 | print(f'{b}Found notification with invalid offer address:{nb}', offer.to_string()) 90 | print(f'* {h}worker: {nh}{worker.to_string()}') 91 | print(f'* {h}description: {nh}{repr(desc_text)}') 92 | else: 93 | yield (offer.to_string(True, True, True), worker.to_string(True, True, True), 94 | stake, desc_text, hash_up_to_date) 95 | except Exception as exc: 96 | print(f'{b}Failure while processing notification:{nb}', repr(exc)) 97 | 98 | 99 | def show_offers(job: str, start_lt=None, validate_offers=False): 100 | if validate_offers: raise Exception('validating offers not supported') 101 | # requires checking whether contract exists and is attached as plugin to worker's wallet 102 | 103 | for (offer, worker, stake, desc, hash_up_to_date) in load_offers(job, start_lt): 104 | oid = offer[40:] 105 | print(f'\nOffer [{h}{oid}{nh}] {offer}') 106 | print(f'- {h}posted by{nh} {worker}') 107 | print(f'- {h}staked{nh} {stake/1e9} TON, {h}really available{nh} ') 108 | print(f'- {h}hash{nh} {"" if hash_up_to_date else "not "}up to date') 109 | print('-', shorten_escape(desc)) 110 | 111 | 112 | def post_offer(job: str, stake: int, desc_text: str, shjh: int, keyring: Keyring, key_id: str): 113 | print(f'\n{h}Creating new offer{nh}', repr(desc_text)) 114 | print('You will have to use v4 wallet, because offer is a plugin.') 115 | 116 | if not key_id.strip() and len(keyring.keys_info) == 1: 117 | key_id = list(keyring.keys_info.keys())[0] 118 | 119 | key_info = keyring.keys_info[key_id] 120 | assert key_info['key_id'] == key_id 121 | public_key = int.from_bytes(key_info['public'], 'big') 122 | 123 | WAY_PROMPT = f'Send via mnemonic [{h}m{nh}]/wallet seed [{h}s{nh}]? ' 124 | while (auth_way := input(WAY_PROMPT).lower()) not in ('m', 's'): pass 125 | 126 | wallet = retrieve_auth_wallet(auth_way, plugin_only=True) 127 | worker = wallet.address.to_string(True, True, True) 128 | 129 | desc = encode_text(desc_text) 130 | si = offer_state_init(job, wallet.address, stake, desc, public_key, shjh) 131 | addr = Address('0:' + b16encode(si.bytes_hash()).decode('ascii')) 132 | am = analytic_msg(addr, stake, desc, public_key, shjh) 133 | 134 | print() 135 | sign_install_plugin(si, 5*10**7, 'creating offer', wallet) 136 | print('... please, wait for blockchain update (30s) ...') 137 | time.sleep(30) 138 | print() 139 | sign_send([(job, None, am, 1)], 'notifying job poster', auth_way, wallet) 140 | 141 | 142 | def revoke_offer(offer: str): 143 | sign_uninstall_plugin(offer, 'revoking offer') 144 | 145 | 146 | def process_offers_cmd(command, keyring): 147 | if command == 'ol': 148 | # TODO: support job IDs instead of addresses 149 | job = input_address(f'{b}Job address: {nb}') 150 | 151 | show_offers(job.to_string()) 152 | elif command == 'op': 153 | possible_keys = list(keyring.keys_info.keys()) 154 | if len(possible_keys) > 3: 155 | possible_keys_s = '/'.join(possible_keys[:3]) + '/...' 156 | else: 157 | possible_keys_s = '/'.join(possible_keys) or '' 158 | 159 | key_id = input(f'Used key ({possible_keys_s}): ') 160 | job = input_address(f'{b}Job address: {nb}') 161 | stake = int(1e9 * float(input('Staked value (TON): '))) 162 | desc_text = input('Offer description: ') 163 | 164 | job_data = Cell.one_from_boc(b16decode( 165 | load_account(job.to_string())['data'].upper() 166 | )) 167 | assert job_data.begin_parse().load_uint(2) == 0 168 | job_hash = int.from_bytes(job_data.bytes_hash(), 'big') & ((1 << 160) - 1) 169 | 170 | post_offer(job, stake, desc_text, job_hash, keyring, key_id) 171 | elif command == 'or': 172 | revoke_offer(input_address(f'{b}Offer address: {nb}').to_string(True, True, True)) 173 | else: 174 | print(f'{b}not implemented:{nb} {repr(command)}') -------------------------------------------------------------------------------- /cli/polyfills.py: -------------------------------------------------------------------------------- 1 | from tonsdk.boc import Cell 2 | from .tslice import Slice 3 | 4 | Cell.begin_parse = lambda self: Slice(self) 5 | -------------------------------------------------------------------------------- /cli/signing.py: -------------------------------------------------------------------------------- 1 | from .colors import nh, h, nb, b, ns, s 2 | 3 | from base64 import b16decode, b64encode, urlsafe_b64encode 4 | from getpass import getpass 5 | import time 6 | 7 | from tonsdk.contract.wallet import Wallets 8 | from tonsdk.contract import Contract 9 | from tonsdk.utils import Address 10 | from tonsdk.boc import Cell 11 | import tonsdk.crypto 12 | import nacl.signing 13 | # TODO: move `requests` out of functions/modules where secret keys are accessed 14 | import requests 15 | 16 | 17 | 18 | def retrieve_keypair(auth_way: str): 19 | if auth_way == 'm': 20 | while True: 21 | mnemonic = getpass(f'{b}Your wallet mnemonic (not echoed):{nb} ').split() 22 | if tonsdk.crypto.mnemonic_is_valid(mnemonic): break 23 | 24 | use_anyway = input(f'{b}Entered mnemonic is invalid. Use it anyway?{nb} [y/n] ').lower() 25 | if use_anyway == 'y': break 26 | 27 | _, secret_key = tonsdk.crypto.mnemonic_to_wallet_key(mnemonic) 28 | secret_key = secret_key[:32] 29 | del mnemonic 30 | elif auth_way == 's': 31 | while True: 32 | secret_hex = getpass(f'{b}Your secret key in HEX (not echoed):{nb} ').upper() 33 | if not set('0123456789ABCDEF').issuperset(secret_hex): 34 | print('Invalid characters met') 35 | elif len(secret_hex) != 64: 36 | print('Invalid key length') 37 | else: 38 | break 39 | 40 | secret_key = b16decode(secret_hex) 41 | del secret_hex 42 | else: 43 | raise Exception('unsupported auth way for retrieving keypair') 44 | 45 | secret_key_obj = nacl.signing.SigningKey(secret_key) 46 | public_key = secret_key_obj.verify_key.encode() 47 | secret_key = secret_key_obj.encode() + public_key # making secret key 64-byte 48 | return public_key, secret_key 49 | 50 | 51 | def retrieve_auth_wallet(auth_way: str, plugin_only=False): 52 | public_key, secret_key = retrieve_keypair(auth_way) 53 | 54 | WALLET_V = ['v4r2'] if plugin_only else ['v3r1', 'v3r2', 'v4r2'] 55 | WALLET_PROMPT = 'Enter wallet version (' + b + '/'.join(WALLET_V) + nb + '): ' 56 | while (wallet_ver := input(WALLET_PROMPT).lower()) not in WALLET_V: pass 57 | 58 | wallet_class = Wallets.ALL[wallet_ver] 59 | return wallet_class(public_key=public_key, private_key=secret_key) 60 | 61 | 62 | # orders: list[tuple[to_addr, state_init, payload, amount]] 63 | def sign_multitransfer_body(wallet: Contract, seqno: int, 64 | orders: list[tuple[Address,Cell,Cell,int]]) -> Cell: 65 | assert len(orders) <= 4 66 | 67 | send_mode = 3 68 | signing_message = wallet.create_signing_message(seqno) 69 | 70 | for (to_addr, state_init, payload, amount) in orders: 71 | order_header = Contract.create_internal_message_header(to_addr, amount) 72 | order = Contract.create_common_msg_info(order_header, state_init, payload) 73 | signing_message.bits.write_uint8(send_mode) 74 | signing_message.refs.append(order) 75 | 76 | return wallet.create_external_message(signing_message, seqno)['message'] 77 | 78 | 79 | def sign_for_sending(orders: list[tuple[Address,Cell,Cell,int]], 80 | description: str, auth_way=None, wallet=None) -> Cell: 81 | print(f'{h}Sending messages for purpose of{nh}', repr(description)) 82 | 83 | sum_value = 0 84 | for (dest, state_init, message, value_nton) in orders: 85 | init_flag = f'{b}[deploy]{nb}' if state_init else '' 86 | 87 | print('===') 88 | print(f'{h}Destination:{nh}', dest.to_string(True, True, True), init_flag) 89 | print(f'{h}TON amount: {nh}', value_nton / 1e9) 90 | print(f'{h}Message BOC:{nh}', b64encode(message.to_boc(False)).decode('ascii')) 91 | sum_value += value_nton 92 | 93 | print('===') 94 | print(f'{h}Total TON: {nh} {sum_value / 1e9}') 95 | print() 96 | 97 | if not auth_way: 98 | WAY_PROMPT = f'Send via mnemonic [{h}m{nh}]/wallet seed [{h}s{nh}]/ton link [{h}t{nh}]? ' 99 | while (auth_way := input(WAY_PROMPT).lower()) not in ('m', 's', 't'): pass 100 | 101 | if auth_way == 't': 102 | print('\nTransfer links:') 103 | 104 | for (dest, state_init, message, value_nton) in orders: 105 | addr = dest.to_string(True, True, True) 106 | boc = urlsafe_b64encode(message.to_boc(False)).decode('ascii') 107 | link = f'ton://transfer/{addr}?bin={boc}&amount={value_nton}' 108 | if state_init: 109 | link += '&init=' 110 | link += urlsafe_b64encode(state_init.to_boc(False)).decode('ascii') 111 | 112 | print(f'{b}{link}{nb}') 113 | 114 | return None 115 | 116 | if not wallet: 117 | wallet = retrieve_auth_wallet(auth_way) 118 | addr = wallet.address.to_string(True, True, True) 119 | 120 | print('Ready to do transfer from', addr) 121 | 122 | while (confirm := input(f'{h}Confirm? [y/n] {nh}').lower()) not in ('y', 'n'): 123 | pass 124 | 125 | if confirm == 'n': return None 126 | 127 | link = f'https://tonapi.io/v1/wallet/getSeqno?account={addr}' 128 | seqno = requests.get(link).json().get('seqno', 0) 129 | 130 | return sign_multitransfer_body(wallet, seqno, orders) 131 | 132 | 133 | def sign_send(orders: list[tuple[Address,Cell,Cell,int]], 134 | description: str, auth_way=None, wallet=None): 135 | signed_msg = sign_for_sending(orders, description, auth_way, wallet) 136 | if signed_msg: 137 | requests.post('https://tonapi.io/v1/send/boc', json={ 138 | 'boc': b64encode(signed_msg.to_boc(False)).decode('ascii') 139 | }) 140 | 141 | 142 | def sign_plugin(plugin_init: Cell, value_nton: int, 143 | description: str, wallet=None) -> Cell: 144 | print(f'{h}Attempting to install plugin{nh}', repr(description)) 145 | print(f'{h}Init BOC: {nh}', b64encode(plugin_init.to_boc(False)).decode('ascii')) 146 | print(f'{h}TON amount: {nh}', value_nton / 1e9) 147 | print() 148 | 149 | if not wallet: 150 | WAY_PROMPT = f'Install via mnemonic [{h}m{nh}]/wallet seed [{h}s{nh}]? ' 151 | while (auth_way := input(WAY_PROMPT).lower()) not in ('m', 's'): pass 152 | wallet = retrieve_auth_wallet(auth_way, plugin_only=True) 153 | addr = wallet.address.to_string(True, True, True) 154 | 155 | print('Ready to install plugin to', addr) 156 | 157 | while (confirm := input(f'{h}Confirm? [y/n] {nh}').lower()) not in ('y', 'n'): 158 | pass 159 | 160 | if confirm == 'n': return None 161 | 162 | link = f'https://tonapi.io/v1/wallet/getSeqno?account={addr}' 163 | seqno = requests.get(link).json().get('seqno', 0) 164 | 165 | msg_body = wallet.create_signing_message(seqno, without_op=True) 166 | msg_body.bits.write_uint(1, 8) # deploy + install plugin 167 | msg_body.bits.write_int(0, 8) # workchain 0 168 | msg_body.bits.write_coins(value_nton) # initial plugin balance 169 | msg_body.refs.append(plugin_init) 170 | msg_body.refs.append(Cell()) 171 | 172 | return wallet.create_external_message(msg_body, seqno)['message'] 173 | 174 | 175 | def sign_install_plugin(plugin_init: Cell, value_nton: int, 176 | description: str, wallet=None) -> Cell: 177 | signed_msg = sign_plugin(plugin_init, value_nton, description, wallet) 178 | if signed_msg: 179 | requests.post('https://tonapi.io/v1/send/boc', json={ 180 | 'boc': b64encode(signed_msg.to_boc(False)).decode('ascii') 181 | }) 182 | 183 | 184 | def sign_unplug(plugin: str, description: str, wallet=None) -> Cell: 185 | print(f'{h}Attempting to remove plugin{nh}', repr(description)) 186 | print(f'{h}Address:{nh}', plugin) 187 | print() 188 | 189 | if not wallet: 190 | WAY_PROMPT = f'Remove via mnemonic [{h}m{nh}]/wallet seed [{h}s{nh}]? ' 191 | while (auth_way := input(WAY_PROMPT).lower()) not in ('m', 's'): pass 192 | wallet = retrieve_auth_wallet(auth_way, plugin_only=True) 193 | addr = wallet.address.to_string(True, True, True) 194 | 195 | print('Ready to remove plugin from', addr) 196 | 197 | while (confirm := input(f'{h}Confirm? [y/n] {nh}').lower()) not in ('y', 'n'): 198 | pass 199 | 200 | if confirm == 'n': return None 201 | 202 | link = f'https://tonapi.io/v1/wallet/getSeqno?account={addr}' 203 | seqno = requests.get(link).json().get('seqno', 0) 204 | 205 | plugin_addr = Address(plugin) 206 | 207 | msg_body = wallet.create_signing_message(seqno, without_op=True) 208 | msg_body.bits.write_uint(3, 8) # remove plugin 209 | msg_body.bits.write_int(plugin_addr.wc, 8) 210 | msg_body.bits.write_bytes(plugin_addr.hash_part) 211 | msg_body.bits.write_coins(5*10**7) # value to send for destroying 212 | msg_body.bits.write_uint(int(time.time() * 1000), 64) 213 | 214 | return wallet.create_external_message(msg_body, seqno)['message'] 215 | 216 | 217 | def sign_uninstall_plugin(plugin: str, description: str, wallet=None) -> Cell: 218 | signed_msg = sign_unplug(plugin, description, wallet) 219 | if signed_msg: 220 | requests.post('https://tonapi.io/v1/send/boc', json={ 221 | 'boc': b64encode(signed_msg.to_boc(False)).decode('ascii') 222 | }) 223 | 224 | -------------------------------------------------------------------------------- /cli/tslice.py: -------------------------------------------------------------------------------- 1 | # Modified version of _slice.py from fork of tonsdk library: 2 | # https://github.com/devdaoteam/tonsdk/blob/e3d6451e50a46d984e7fec1c52d1d32290781da5/tonsdk/boc/_slice.py 3 | # Original code is licensed under Apache-2.0 License. 4 | 5 | import bitarray 6 | 7 | from tonsdk.boc import Cell 8 | from tonsdk.utils import Address 9 | 10 | 11 | 12 | class Slice: 13 | '''Slice like an analog of slice in FunC. Used only for reading.''' 14 | def __init__(self, cell: Cell): 15 | self.bits = bitarray.bitarray() 16 | self.bits.frombytes(cell.bits.array) 17 | self.bits = self.bits[:cell.bits.cursor] 18 | self.refs = cell.refs 19 | self.ref_offset = 0 20 | 21 | def __len__(self): 22 | return len(self.bits) 23 | 24 | def __repr__(self): 25 | return hex(int(self.bits.to01(), 2))[2:].upper() 26 | 27 | def is_empty(self) -> bool: 28 | return len(self.bits) == 0 29 | 30 | def end_parse(self): 31 | '''Throws an exception if the slice is not empty.''' 32 | if not self.is_empty() or self.ref_offset != len(self.refs): 33 | raise Exception('Upon .end_parse(), slice is not empty.') 34 | 35 | def load_bit(self) -> int: 36 | '''Loads single bit from the slice.''' 37 | bit = self.bits[0] 38 | del self.bits[0] 39 | return bit 40 | 41 | def preload_bit(self) -> int: 42 | return self.bits[0] 43 | 44 | def load_bits(self, bit_count: int) -> bitarray.bitarray: 45 | bits = self.bits[:bit_count] 46 | del self.bits[:bit_count] 47 | return bits 48 | 49 | def preload_bits(self, bit_count: int) -> bitarray.bitarray: 50 | return self.bits[:bit_count] 51 | 52 | def skip_bits(self, bit_count: int): 53 | del self.bits[:bit_count] 54 | 55 | def load_uint(self, bit_length: int) -> int: 56 | value = self.bits[:bit_length] 57 | del self.bits[:bit_length] 58 | return int(value.to01(), 2) 59 | 60 | def preload_uint(self, bit_length: int) -> int: 61 | value = self.bits[:bit_length] 62 | return int(value.to01(), 2) 63 | 64 | def load_bytes(self, bytes_count: int) -> bytes: 65 | length = bytes_count * 8 66 | value = self.bits[:length] 67 | del self.bits[:length] 68 | return value.tobytes() 69 | 70 | def load_int(self, bit_length: int) -> int: 71 | if bit_length == 1: 72 | # if num is -1 then bit is 1. if 0 then 0. see _bit_string.py 73 | return - self.load_bit() 74 | else: 75 | is_negative = self.load_bit() 76 | value = self.load_uint(bit_length - 1) 77 | if is_negative == 1: 78 | # ones complement 79 | return - (2 ** (bit_length - 1) - value) 80 | else: 81 | return value 82 | 83 | def preload_int(self, bit_length: int) -> int: 84 | tmp = self.bits 85 | value = self.load_int(bit_length) 86 | self.bits = tmp 87 | return value 88 | 89 | def load_msg_addr(self) -> Address: 90 | '''Loads contract address from the slice. 91 | May return None if there is a zero-address.''' 92 | # TODO: support for external addresses 93 | if self.load_uint(2) == 0: 94 | return None 95 | self.load_bit() # anycast 96 | workchain_id = hex(self.load_int(8))[2:] 97 | hashpart = hex(self.load_uint(256))[2:].zfill(64) 98 | return Address(workchain_id + ':' + hashpart) 99 | 100 | def load_coins(self) -> int: 101 | '''Loads an amount of coins from the slice. Returns nanocoins.''' 102 | length = self.load_uint(4) 103 | if length == 0: # 0 in length means 0 coins 104 | return 0 105 | else: 106 | return self.load_uint(length * 8) 107 | 108 | def load_grams(self) -> int: 109 | '''Loads an amount of coins from the slice. Returns nanocoins.''' 110 | return self.load_coins() 111 | 112 | def load_string(self, length: int = 0) -> str: 113 | '''Loads string from the slice. 114 | If length is 0, then loads string until the end of the slice.''' 115 | if length == 0: 116 | length = len(self.bits) // 8 117 | return self.load_bytes(length).decode('utf-8') 118 | 119 | def load_ref(self) -> Cell: 120 | '''Loads next reference cell from the slice.''' 121 | ref = self.refs[self.ref_offset] 122 | self.ref_offset += 1 123 | return ref 124 | 125 | def preload_ref(self) -> Cell: 126 | return self.refs[self.ref_offset] 127 | 128 | def load_dict(self) -> Cell: 129 | '''Loads dictionary like a Cell from the slice. 130 | Returns None if the dictionary was null().''' 131 | not_null = self.load_bit() 132 | if not_null: 133 | return self.load_ref() 134 | else: 135 | return None 136 | 137 | def preload_dict(self) -> Cell: 138 | not_null = self.preload_bit() 139 | if not_null: 140 | return self.preload_ref() 141 | else: 142 | return None 143 | 144 | def skip_dict(self): 145 | self.load_dict() 146 | -------------------------------------------------------------------------------- /cli_main.py: -------------------------------------------------------------------------------- 1 | from cli.keyring import Keyring, process_keyring_cmd 2 | from cli.about import PROMPT_SINGLE, PROMPT, ABOUT 3 | from cli.contracts import process_contract_cmd 4 | from cli.offers import process_offers_cmd 5 | from cli.jobs import process_jobs_cmd 6 | from cli.signing import sign_send 7 | from cli.colors import b, nb 8 | import cli.polyfills 9 | 10 | import traceback 11 | import os 12 | 13 | from tonsdk.utils import Address 14 | from tonsdk.boc import Cell 15 | import requests 16 | 17 | 18 | 19 | def main(): 20 | keys = Keyring() 21 | 22 | while True: 23 | print(PROMPT_SINGLE) 24 | 25 | try: 26 | while not (command := input(PROMPT).lower().strip()): 27 | pass 28 | except KeyboardInterrupt: 29 | command = 'q' 30 | 31 | try: 32 | if not command: 33 | pass 34 | elif command == 'h': 35 | print(ABOUT) 36 | elif command == 'q': 37 | break 38 | elif command == 'd': 39 | donate_addr = 'EQCyoez1VF4HbNNq5Rbqfr3zKuoAjKorhK-YZr7LIIiVrSD7' 40 | sign_send([ 41 | (Address(donate_addr), None, Cell(), 5*10**7), 42 | ], 'donate') 43 | elif command[0] == 'k': 44 | with keys: process_keyring_cmd(command, keys) 45 | elif command[0] == 'j': 46 | with keys: process_jobs_cmd(command, keys) 47 | elif command[0] == 'o': 48 | with keys: process_offers_cmd(command, keys) 49 | elif command[0] == 'c': 50 | with keys: process_contract_cmd(command, keys) 51 | else: 52 | print(f'{b}not implemented:{nb} {repr(command)}') 53 | except KeyboardInterrupt: 54 | print(f'{nb}\noperation cancelled') 55 | 56 | print('\n') 57 | 58 | try: 59 | if __name__ == '__main__': 60 | os.system('') 61 | main() 62 | except: 63 | traceback.print_exc() 64 | input('...') 65 | -------------------------------------------------------------------------------- /contracts/build.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import base64 3 | import shutil 4 | import sys 5 | import os 6 | 7 | FIFT_LIBS_LIST = 'Fift Asm AsmTests TonUtil Lists Color'.split(' ') 8 | CONTRACTS_LIST = 'contract-job contract-offer'.split(' ') 9 | 10 | ap = os.path.abspath 11 | 12 | base_path = ap(__file__ + '/../') 13 | fift_path = os.environ['FIFTPATH'] 14 | 15 | print('====== Starting build ====================') 16 | 17 | os.system('cls') # clears screen + enables escape sequences 18 | 19 | for fift_lib in FIFT_LIBS_LIST: 20 | shutil.copy(ap(f'{fift_path}toncli/lib/fift-libs/{fift_lib}.fif'), 21 | ap(f'{base_path}/{fift_lib}.fif')) 22 | 23 | print('====== Loaded libs for toncli ============') 24 | 25 | with open(ap(base_path + '/fift/exotic.fif')) as f: 26 | exotic_patch = f.read() 27 | 28 | with open(ap(base_path + '/Asm.fif'), 'a') as f: f.write(exotic_patch) 29 | with open(ap(base_path + '/AsmTests.fif'), 'a') as f: f.write(exotic_patch) 30 | 31 | print('====== Patched Fift libraries ============') 32 | 33 | os.chdir(base_path) 34 | os.system('toncli run_tests >toncli.log 2>toncli.err') 35 | os.system('python show-log.py') 36 | 37 | print('====== Ran tests =========================') 38 | 39 | os.system('toncli build >nul 2>nul') 40 | 41 | print('====== Built contract in prod mode =======') 42 | 43 | for contract in CONTRACTS_LIST: 44 | with open(ap(base_path + f'/build/{contract}.fif'), 'a') as f: 45 | f.write(f'\nboc>B "build/boc/{contract}.boc" B>file') 46 | os.system(f'toncli fift run build/{contract}.fif') 47 | 48 | with open(ap(f'build/boc/{contract}.boc'), 'rb') as rf: 49 | boc = rf.read() 50 | print(f'====== BOC of {repr(contract)} is {len(boc)} B') 51 | with open(ap(f'build/boc/{contract}.hex'), 'wb') as wf: 52 | wf.write(base64.b16encode(boc)) 53 | 54 | print(f'====== Saved {repr(contract)} in BOC and HEX representation') 55 | 56 | if '--noclean' not in sys.argv: 57 | for fift_lib in FIFT_LIBS_LIST: 58 | os.remove(ap(f'{base_path}/{fift_lib}.fif')) 59 | 60 | print('====== Deleted Fift libs =================') 61 | -------------------------------------------------------------------------------- /contracts/build/boc/contract-job.boc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgramCrafter/ratelance/2de56f4f96acf0f9e1e5b0e60ae0ad846ba50170/contracts/build/boc/contract-job.boc -------------------------------------------------------------------------------- /contracts/build/boc/contract-job.hex: -------------------------------------------------------------------------------- 1 | B5EE9C7201021501000382000114FF00F4A413F4BCF2C80B0102016202030202CB04050201200D0E0247D0835D2704838C2007434C0C05C6C007E900C00B4C7FB513434C048700038C3E103FCBC206070023A11876A27C80424FD8408058AC658FE5CFC001D23133218100EBBA8E5E018100ACBA8E535213C709F2E0C80282103B9ACA00BEF2E0C7D4318040706D92F012245520705530C859DA21C97180100591789170E215B1C8CB055003CF1601FA0212CB6ACCC901FB0071C8CB010259CF1601CF16C9ED54DB31925F04E2E30D0802B63620C0018EAB30218100ADBA8E21308100D4BA01B18E1501FA4059C705F2E0C870C8CB0101CF16C9ED54DB31915BE2E30D8EA7333301C0028E9C8100BBBA8E9301FA40FA4072D748D003D401D001D74CD05114915BE2925F03E2E2090A006C5B6C1221C709F2E0C8830601708BE4A6F622064657374726F7965642E8708010C8CB055004CF1658FA0212CB8A01CF16C901FB00DB3100CC6C2102FA40FA40D33FD45045C705F2E0C804FA40D472C8CB01071046103550445076CF165004CF1612CB3F01CF1401CF14C85003CF1601CF168D0808CEB4C769ADE5E6E837239313EE5118E7BD11EDAB2B541C6FED19BA23FDBDBEE0CF16C9CF14C9ED54DB3101F601D30121C0038E255B821062845C24F833206EF2D0C8F8280181010BF40A6FA1F2E0C8736D02D33FD33F301023998308D718D33FD33F30E223C3038E3723AA07158307D724531470C88BDFFFF726C3A3A6A6F623A3A76308CF16CB04F828CF1612CB3FCB3FC9F90001D70BFF4130F910F2E0C602923233E21250360B01FE01D30121C0038E255B821062845C24F833206EF2D0C8F8280181010BF40A6FA1F2E0C8736D02D33FD33F301023998308D718D33FD33F30E223C3038E3723AA07158307D724531470C88BDFFFF726C3A3A6A6F623A3A76308CF16CB04F828CF1612CB3FCB3FC9F90001D70BFF4130F910F2E0C602923233E2125ABAF2D0C8590C00C4B6095044B6085133BCF2D0C87140138BE4A6F6220636F6D706C65746564218708010C8CB055004CF1658FA0212CB8A01CF16C901FB00830601708BD4A6F622066696E69736865642E8708010C8CB055004CF1658FA0212CB8A01CF16C901FB00DB310201580F1002012011120011B614BDA89A1AE160300021B6355DA89A1A6020327F48063BDF48061002016A13140017B946DED44D072D721FA403080027AEB876A268698080C9FD2018EF7D2018E99F9840000FAF4076A2686BA640 -------------------------------------------------------------------------------- /contracts/build/boc/contract-offer.boc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgramCrafter/ratelance/2de56f4f96acf0f9e1e5b0e60ae0ad846ba50170/contracts/build/boc/contract-offer.boc -------------------------------------------------------------------------------- /contracts/build/boc/contract-offer.hex: -------------------------------------------------------------------------------- 1 | B5EE9C7201020C010001D6000114FF00F4A413F4BCF2C80B010202CE02030247D0835D2704838C2007434C0C05C6C007E900C00B4C7FB513434C048700038C3E103FCBC20405020120080901E6313322FA40FA40D33FD4D6FFD70B9F6C2125821064737472BA8E56058100B1BA8E4B5026C705F2E0C801D70B9F58BAF2E0C88040717092F016103541401316C859DA21C97180100591789170E215B1C8CB055003CF1601FA0212CB6ACCC901FB0071C8CB0101CF16C9ED54DB31925F07E2E30D0601D032C0018EDF20FA40FA405217C705F2E0C8048E4E31018210F06C7567BA8E3F028040D72183067022D74C03D6FF3044306F0392F01A1034705530C859DA21C97180100591789170E215B1C8CB055003CF1601FA0212CB6ACCC901FB00DB31925F03E2E30D925F04E207006E10265F0621C705F2E0C8830601708BE4F66666572207265766F6B65642E8708010C8CB055004CF1658FA0212CB8A01CF16C901FB00DB31007E6C22328040706D92F01C10341035705530C859DA21C97180100591789170E215B1C8CB055003CF1601FA0212CB6ACCC901FB0070C8CB0101CF16C9ED54DB31002BBD0420E0D8EACEB1963FF04A03967E03F404E00396010201200A0B002569BC8E0402B540132C7D633C58073C50073C5A000FD1840806A00E58FC -------------------------------------------------------------------------------- /contracts/fift/config-validators.boc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgramCrafter/ratelance/2de56f4f96acf0f9e1e5b0e60ae0ad846ba50170/contracts/fift/config-validators.boc -------------------------------------------------------------------------------- /contracts/fift/exotic.fif: -------------------------------------------------------------------------------- 1 | 2 | x{CF23} @Defop ENDXC 3 | x{FEEF10} @Defop GASLIMITSTEMP 4 | x{FEEF13} @Defop RESETLOADEDCELLS 5 | 6 | B B>boc ref, b> [-x *] [-n|-b] [-t] [-B ] [-C ] []" +cr +tab 17 | +"Creates a request to advanced wallet created by new-wallet-v3.fif, with private key loaded from file .pk " 18 | +"and address from .addr, and saves it into .boc ('wallet-query.boc' by default)" 19 | disable-digit-options generic-help-setopt 20 | "n" "--no-bounce" { false =: allow-bounce } short-long-option 21 | "Clears bounce flag" option-help 22 | "b" "--force-bounce" { true =: force-bounce } short-long-option 23 | "Forces bounce flag" option-help 24 | "x" "--extra" { $>xcc extra-cc+! } short-long-option-arg 25 | "Indicates the amount of extra currencies to be transfered" option-help 26 | "t" "--timeout" { parse-int =: timeout } short-long-option-arg 27 | "Sets expiration timeout in seconds (" timeout (.) $+ +" by default)" option-help 28 | "B" "--body" { =: body-fift-file } short-long-option-arg 29 | "Sets the payload of the transfer message" option-help 30 | "C" "--comment" { =: comment } short-long-option-arg 31 | "Sets the comment to be sent in the transfer message" option-help 32 | "m" "--mode" { parse-int =: send-mode } short-long-option-arg 33 | "Sets transfer mode (0..255) for SENDRAWMSG (" send-mode (.) $+ +" by default)" 34 | option-help 35 | "h" "--help" { usage } short-long-option 36 | "Shows a help message" option-help 37 | parse-options 38 | 39 | $# dup 5 < swap 6 > or ' usage if 40 | 6 :$1..n 41 | 42 | true constant bounce 43 | $1 =: file-base 44 | $2 bounce parse-load-address force-bounce or allow-bounce and =: bounce 2=: dest_addr 45 | $3 parse-int =: subwallet_id 46 | $4 parse-int =: seqno 47 | $5 $>cc extra-cc+! extra-currencies @ 2=: amount 48 | $6 "wallet-query" replace-if-null =: savefile 49 | subwallet_id (.) 1 ' $+ does : +subwallet 50 | 51 | file-base +subwallet +".addr" dup file-exists? { drop file-base +".addr" } ifnot 52 | load-address 53 | 2dup 2constant wallet_addr 54 | ."INFO: 👀 Source wallet address = " 2dup 6 .Addr cr 55 | file-base +".pk" load-keypair nip constant wallet_pk 56 | 57 | def? body-fift-file { @' body-fift-file include } { comment simple-transfer-body } cond 58 | constant body-cell 59 | 60 | ."INFO: 👋 Send " dest_addr 2dup bounce 7 + .Addr ." = " 61 | ."subwallet_id=0x" subwallet_id x. 62 | ."seqno=0x" seqno x. ."bounce=" bounce . cr 63 | ."INFO: 🧟 Body of transfer message is " body-cell 69 | 70 | 71 | 72 | dup hashu wallet_pk ed25519_sign_uint 73 | 75 | 76 | 2 boc+>B 77 | 78 | saveboc // it is defined in build/cli.fif (or /tmp if in script mode) 79 | // it is project-specific lib from tlcli thats specify needed locations 80 | -------------------------------------------------------------------------------- /contracts/func/job-contract.fc: -------------------------------------------------------------------------------- 1 | #include "func/stdlib-ext.fc"; 2 | #include "func/opcodes.fc"; 3 | #include "func/tlb.fc"; 4 | 5 | (int, int, int) parse_proposal_check_sig(slice v, slice keys) impure inline { 6 | (int tag, slice sig, int lb, int ub) = job::parse_proposal_with_tag(v); 7 | 8 | if (tag != 3) { 9 | slice key = keys.extract_part(tag * 256, 256); 10 | throw_unless(198, 11 | check_signature(cell_hash(job::wrap_for_signing(lb, ub)), 12 | sig, 13 | key.preload_uint(256))); 14 | } 15 | 16 | return (tag, lb, ub); 17 | } 18 | 19 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 20 | 21 | int get_job_state() method_id { 22 | return get_data().begin_parse().preload_uint(2); 23 | } 24 | slice waiting_message_from() method_id { 25 | slice own_data = get_data().begin_parse(); 26 | own_data~skip_bits(2); 27 | return own_data~load_msg_addr(); 28 | } 29 | cell get_job_description() method_id { 30 | return get_data().begin_parse().preload_ref(); 31 | } 32 | int get_job_value() method_id { 33 | slice own_data = get_data().begin_parse(); 34 | int tag = own_data~load_uint(2); 35 | if (tag) { own_data~load_msg_addr(); } 36 | own_data~load_msg_addr(); 37 | return own_data~load_uint(64); 38 | } 39 | slice get_job_poster() method_id { 40 | slice own_data = get_data().begin_parse(); 41 | int tag = own_data~load_uint(2); 42 | if (tag) { own_data~load_msg_addr(); } 43 | return own_data~load_msg_addr(); 44 | } 45 | 46 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 47 | 48 | ;; Incoming messages: 49 | ;; CUS-1. poster [empty] 50 | ;; CUS-1,2-REV. poster op::cancel_job 51 | ;; CUS-4. offer [insufficient value to even notice it] 52 | ;; CUS-5. poster op::lock_on_offer 53 | ;; CUS-7-ERR. offer op::refuse_collapse [bounced] 54 | ;; CUS-9-OK. offer op::lock_success 55 | ;; CUS-9-ERR. offer op::lock_failed 56 | ;; CUS-12. anyone op::finish_job 57 | 58 | () recv_internal(int msg_value, cell in_msg, slice in_msg_body) { 59 | terminate_if(in_msg_body.slice_bits() < 32); 60 | 61 | (int bounced, slice sender) = in_msg.load_bounced_sender(); 62 | 63 | int op = in_msg_body~load_uint(32); 64 | 65 | slice own_data = get_data().begin_parse(); 66 | int tag = own_data~load_uint(2); 67 | 68 | if (tag == job::tag::unlocked) { 69 | if (op == op::cancel_job) { 70 | throw_unless(200, own_data.starts_with(sender)); 71 | send_text(mode::destroy_into(), sender, 0, "Job destroyed."); 72 | terminate(); 73 | } elseif (op == op::lock_on_offer) { 74 | throw_unless(200, own_data.starts_with(sender)); 75 | throw_unless(199, msg_value >= TON); ;; this will be returned eventually 76 | 77 | (_, slice offer_addr) = job::ld_msg_lock_on(in_msg_body); 78 | send_to(mode::forward_value(), offer_addr, 0, null(), 79 | job::st_msg_collapse); 80 | set_data(begin_cell() 81 | .store_uint(job::tag::locked_on_offer, 2) 82 | .job::st_locked(offer_addr, own_data) 83 | .end_cell()); 84 | terminate(); 85 | } 86 | } elseif (tag == job::tag::locked_on_offer) { 87 | if (op == op::lock_success) { 88 | ;; throw_unless(200, own_data.starts_with(sender)); 89 | (slice offer, slice poster, int value, cell poster_desc, slice poster_key) 90 | = job::ld_locked(own_data); 91 | throw_unless(200, equal_slices(offer, sender)); 92 | 93 | (slice worker, cell worker_desc, slice worker_key) 94 | = job::ld_msg_lock_success(in_msg_body); 95 | 96 | set_data(begin_cell() 97 | .store_uint(job::tag::locked_working, 2) 98 | .job::st_working(poster, worker, value, poster_desc, worker_desc, 99 | poster_key, worker_key) 100 | .end_cell()); 101 | terminate(); 102 | } elseif ((op == op::lock_failed) | bounced) { 103 | ;; throw_unless(200, own_data.starts_with(sender)); 104 | throw_unless(200, equal_slices(sender, own_data~load_msg_addr())); 105 | set_data(begin_cell() 106 | .store_uint(job::tag::unlocked, 2) 107 | .job::st_unlocked(own_data) 108 | .end_cell()); 109 | terminate(); 110 | } 111 | } elseif (tag == job::tag::locked_working) { 112 | if (op == op::finish_job) { 113 | (slice poster, slice worker, cell keys) = job::ld_working_main(own_data); 114 | slice keys = keys.begin_parse(); 115 | 116 | (slice a, slice b) = job::parse_finish_message(in_msg_body); 117 | (int tag_a, int al, int au) = parse_proposal_check_sig(a, keys); 118 | (int tag_b, int bl, int bu) = parse_proposal_check_sig(b, keys); 119 | throw_if(200, tag_a == tag_b); 120 | 121 | ;; we are not checking sender, as this code is guarded by signatures 122 | 123 | int nton_min = max(al, bl); 124 | int nton_max = min(au, bu); 125 | throw_if(200, nton_min > nton_max); 126 | 127 | send_text(mode::pay_transfer(), worker, nton_max, "Job completed!"); 128 | send_text(mode::destroy_into(), poster, 0, "Job finished."); 129 | terminate(); 130 | } 131 | } 132 | 133 | throw(0xFFFF); 134 | } 135 | -------------------------------------------------------------------------------- /contracts/func/offer-contract.fc: -------------------------------------------------------------------------------- 1 | #include "func/stdlib-ext.fc"; 2 | #include "func/opcodes.fc"; 3 | #include "func/tlb.fc"; 4 | 5 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 6 | 7 | ;; Incoming messages: 8 | ;; CUS-3. worker [empty] 9 | ;; CUS-3,4-REV. worker op::destruct 10 | ;; CUS-6. job op::collapse 11 | ;; CUS-8-OK. worker op::payment_ok 12 | ;; CUS-8-ERR. worker 0xFFFFFFFF 13 | 14 | () recv_internal(cell in_msg, slice in_msg_body) { 15 | terminate_if(in_msg_body.slice_bits() < 32); 16 | 17 | (int bounced, slice sender) = load_bounced_sender(in_msg); 18 | 19 | int op = in_msg_body~load_uint(32); 20 | 21 | slice own_data = get_data().begin_parse(); 22 | int tag = own_data~load_uint(2); 23 | 24 | if (tag == offer::tag::unlocked) { 25 | (slice job, slice worker, int stake, cell desc, slice worker_key, 26 | int job_hash) = offer::ld_unlocked(own_data); 27 | 28 | if (op == op::destruct) { 29 | throw_unless(200, equal_slices(worker, sender)); 30 | 31 | send_text(mode::destroy_into(), sender, 0, "Offer revoked."); 32 | terminate(); 33 | } elseif (op == op::collapse) { 34 | throw_unless(200, equal_slices(job, sender)); 35 | throw_unless(200, offer::ld_msg_collapse(in_msg_body) == job_hash); 36 | 37 | send_to_rbounceable(mode::forward_value(), 1, worker, 0, stake, 38 | offer::st_msg_take_stake); 39 | set_data(begin_cell() 40 | .store_uint(offer::tag::locked, 2) 41 | .offer::st_locked(own_data) 42 | .end_cell()); 43 | terminate(); 44 | } 45 | } elseif (tag == offer::tag::locked) { 46 | slice full_own_data = own_data; 47 | slice job = own_data~load_msg_addr(); 48 | slice worker = own_data~load_msg_addr(); 49 | 50 | throw_unless(200, equal_slices(worker, sender)); 51 | 52 | if (bounced) { 53 | send_to(mode::forward_value(), job, 0, null(), offer::st_msg_lock_failed); 54 | set_data(begin_cell() 55 | .store_uint(offer::tag::unlocked, 2) 56 | .store_slice(full_own_data) 57 | .end_cell()); 58 | terminate(); 59 | } elseif (op == op::payment_ok) { 60 | own_data~skip_bits(64); 61 | 62 | send_to(mode::destroy_into(), job, 0, triple( 63 | worker, 64 | own_data.preload_ref(), 65 | own_data~load_bits(256) 66 | ), offer::st_msg_lock_success); 67 | terminate(); 68 | } 69 | } 70 | 71 | throw(0xFFFF); 72 | } 73 | -------------------------------------------------------------------------------- /contracts/func/opcodes.fc: -------------------------------------------------------------------------------- 1 | ;; ---- Job contract states -------------------------------------------------- 2 | 3 | const int job::tag::unlocked = 0; 4 | const int job::tag::locked_on_offer = 1; 5 | const int job::tag::locked_working = 2; 6 | 7 | const int offer::tag::unlocked = 0; 8 | const int offer::tag::locked = 1; 9 | 10 | ;; ---- Offer contract opcodes ----------------------------------------------- 11 | 12 | const int op::destruct = 0x64737472; 13 | const int op::payment_request = 0x706c7567; 14 | const int op::payment_ok = 0x80000000 | op::payment_request; 15 | const int op::excesses = 0xd53276db; 16 | const int op::subscription = 0x73756273; 17 | 18 | ;; ---- Job contract opcodes ------------------------------------------------- 19 | 20 | const int op::update_job = 168; ;; 156 + zlib.crc32(b'op::update_job') % 100 21 | const int op::lock_on_offer = 172; ;; 156 + zlib.crc32(b'op::lock_on_offer') % 100 22 | const int op::collapse = 177; ;; 156 + zlib.crc32(b'op::collapse') % 100 23 | const int op::lock_success = 173; ;; 156 + zlib.crc32(b'op::lock_success') % 100 24 | const int op::lock_failed = 212; ;; 156 + zlib.crc32(b'op::lock_failed') % 100 25 | const int op::cancel_job = 235; ;; 156 + zlib.crc32(b'op::cancel_job') % 100 26 | const int op::finish_job = 187; ;; 156 + zlib.crc32(b'op::finish_job') % 100 27 | 28 | ;; ---- Errors (common) ------------------------------------------------------ 29 | 30 | const int err::invalid_sender = 171; ;; 100 + zlib.crc32(b'err::invalid_sender') % 100 31 | const int err::invariant_failed = 172; ;; 100 + zlib.crc32(b'err::invariant_failed') % 100 32 | const int err::insufficient_stake = 179; ;; 100 + zlib.crc32(b'err::insufficient_stake') % 100 33 | const int err::low_job_value = 127; ;; 100 + zlib.crc32(b'err::low_job_value') % 100 34 | 35 | ;; ---- TON utility constants ------------------------------------------------ 36 | 37 | const int TON = 1000 * 1000 * 1000; 38 | 39 | const int DEVELOPMENT = 1; 40 | 41 | (int, ()) mode::forward_value() inline { return (64, ()); } 42 | (int, ()) mode::pay_transfer() inline { return (1, ()); } 43 | (int, ()) mode::destroy_into() inline { return DEVELOPMENT ? (128, ()) : (160, ()); } 44 | 45 | ;; ---- Addresses (common) --------------------------------------------------- 46 | 47 | const slice job_analytic_address = "EQA__RATELANCE_______________________________JvN"a; 48 | 49 | const slice ratelance_public_key = "233AD31DA6B7979BA0DC8E4C4FB944639EF447B6ACAD5071BFB466E88FF6F6FB"s; 50 | -------------------------------------------------------------------------------- /contracts/func/stdlib-ext.fc: -------------------------------------------------------------------------------- 1 | (builder, ()) ~store_slice(builder, slice) asm "STSLICER"; 2 | (builder, ()) ~store_coins(builder a, int b) asm "STGRAMS"; 3 | (builder, ()) ~store_ref(builder, cell) asm "STREFR"; 4 | 5 | (slice, int) dict_get?(cell dict, int key_len, slice index) 6 | asm(index dict key_len) "DICTGET" "NULLSWAPIFNOT"; 7 | 8 | slice extract_part(slice v, int offset, int len) asm "SDSUBSTR"; 9 | cell load_nth_ref(slice v, int offset) asm "PLDREFVAR"; 10 | 11 | int starts_with(slice, slice) asm "SDPFXREV"; 12 | int equal_slices(slice, slice) asm "SDEQ"; 13 | int first_bit(slice) asm "SDFIRST"; 14 | int tuple_length(tuple) asm "TLEN"; 15 | 16 | forall X -> int is_null(X) asm "ISNULL"; 17 | 18 | (int, slice) load_bounced_sender(cell in_msg) inline { 19 | slice in_msg = in_msg.begin_parse(); 20 | int b = in_msg~load_uint(4) & 1; 21 | return (b, in_msg~load_msg_addr()); 22 | } 23 | 24 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 25 | 26 | ;; termination primitives not cleaning stack 27 | 28 | () terminate() impure asm "RETALT"; 29 | () terminate_if(int) impure asm "IFRETALT"; 30 | 31 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 32 | 33 | forall ADDARGS -> 34 | () send_to_rbounceable((int, ()) packed_mode, int bounce, slice dest, int value, 35 | ADDARGS a, ((builder, ADDARGS) -> builder) m) 36 | impure inline { 37 | (int mode, _) = packed_mode; 38 | 39 | send_raw_message(begin_cell() 40 | .store_uint(0x10 | (bounce ? 8 : 0), 6) 41 | .store_slice(dest) 42 | .store_coins(value) 43 | .store_uint(1, 107) 44 | .store_ref(m(begin_cell(), a).end_cell()) 45 | .end_cell(), mode); 46 | } 47 | forall ADDARGS -> 48 | () send_to((int, ()) packed_mode, slice dest, int value, ADDARGS a, 49 | ((builder, ADDARGS) -> builder) m) impure inline { 50 | return send_to_rbounceable(packed_mode, 0, dest, value, a, m); 51 | } 52 | () send_text((int, ()) packed_mode, slice dest, int value, slice body) 53 | impure inline { 54 | (int mode, _) = packed_mode; 55 | 56 | send_raw_message(begin_cell() 57 | .store_uint(0x10, 6) 58 | .store_slice(dest) 59 | .store_coins(value) 60 | .store_uint(0, 107 + 32) 61 | .store_slice(body) 62 | .end_cell(), mode); 63 | } 64 | -------------------------------------------------------------------------------- /contracts/func/tlb.fc: -------------------------------------------------------------------------------- 1 | #include "stdlib-ext.fc"; 2 | #include "opcodes.fc"; 3 | 4 | ;; CUS-1. Serialized by user. 5 | (slice, int) job::ld_tag(slice v) inline { 6 | return v.load_uint(2); 7 | } 8 | (slice, int, cell, slice) job::ld_unlocked(slice v) inline { 9 | return (v~load_msg_addr(), v~load_uint(64), v~load_ref(), v); 10 | } 11 | 12 | ;; CUS-2. Serialized by user. Deserialized by user. 13 | 14 | ;; CUS-1,2-REV. Serialized by user. No deserialization needed. 15 | 16 | ;; CUS-3. Serialized by user. 17 | (slice, int) offer::ld_tag(slice v) inline { 18 | return v.load_uint(2); 19 | } 20 | (slice, slice, int, cell, slice, int) offer::ld_unlocked(slice v) inline { 21 | return (v~load_msg_addr(), v~load_msg_addr(), v~load_uint(64), v~load_ref(), 22 | v~load_bits(256), v.preload_uint(160)); 23 | } 24 | 25 | ;; CUS-4. Serialized by user. Deserialized by user. 26 | 27 | ;; CUS-3,4-REV. Serialized by wallet. No deserialization needed. 28 | 29 | ;; CUS-5. Sent by user. 30 | (cell, slice) job::ld_msg_lock_on(slice v) inline { 31 | return (v~load_ref(), v); 32 | } 33 | 34 | ;; CUS-6. 35 | builder job::st_msg_collapse(builder v, cell add_args) inline { 36 | int order_hash = cell_hash(get_data()); 37 | order_hash &= (1 << 160) - 1; 38 | v~store_uint(op::collapse, 32); v~store_uint(order_hash, 160); 39 | return v; 40 | } 41 | builder job::st_locked(builder v, slice offer, slice unlocked) inline { 42 | v~store_slice(offer); v~store_slice(unlocked); 43 | return v; 44 | } 45 | (slice, slice, int, cell, slice) job::ld_locked(slice v) inline { 46 | return (v~load_msg_addr(), v~load_msg_addr(), v~load_uint(64), 47 | v~load_ref(), v); 48 | } 49 | int offer::ld_msg_collapse(slice v) inline { 50 | return v.preload_uint(160); 51 | } 52 | 53 | ;; CUS-7. 54 | builder offer::st_msg_take_stake(builder v, int stake) inline { 55 | v~store_uint(0x706c7567, 32); v~store_uint(cur_lt(), 64); 56 | v~store_coins(stake); v~store_uint(0, 1); 57 | return v; 58 | } 59 | builder offer::st_locked(builder v, slice unlocked) inline { 60 | v~store_slice(unlocked); 61 | return v; 62 | } 63 | (slice, slice, int, cell, slice, slice) offer::ld_locked(slice v) inline { 64 | return (v~load_msg_addr(), v~load_msg_addr(), v~load_uint(64), v~load_ref(), 65 | v~load_bits(256), v); 66 | } 67 | 68 | ;; CUS-8. Serialized by wallet. Needs no parsing other than opcode check. 69 | 70 | ;; CUS-9-OK. 71 | builder offer::st_msg_unplug(builder v, () add_args) inline { 72 | v~store_uint(0x64737472, 32); v~store_uint(cur_lt(), 64); 73 | return v; 74 | } 75 | builder offer::st_msg_lock_success(builder v, [slice, cell, slice] add_args) 76 | inline { 77 | (slice worker, cell desc, slice worker_key) = untriple(add_args); 78 | v~store_uint(op::lock_success, 32); v~store_slice(worker); 79 | v~store_ref(desc); v~store_slice(worker_key); 80 | return v; 81 | } 82 | (slice, cell, slice) job::ld_msg_lock_success(slice v) inline { 83 | return (v~load_msg_addr(), v~load_ref(), v); 84 | } 85 | 86 | ;; CUS-9-ERR. 87 | builder offer::st_msg_lock_failed(builder v, cell add_args) inline { 88 | v~store_uint(op::lock_failed, 32); return v; 89 | } 90 | builder offer::st_unlocked(builder v, slice job, slice worker, int stake, 91 | cell desc, slice worker_key, int short_job_hash) inline { 92 | v~store_slice(job); v~store_slice(worker); v~store_uint(stake, 64); 93 | v~store_ref(desc); v~store_slice(worker_key); 94 | v~store_uint(short_job_hash, 160); 95 | return v; 96 | } 97 | builder job::st_unlocked(builder v, slice locked_without_offer) inline { 98 | v~store_slice(locked_without_offer); 99 | return v; 100 | } 101 | 102 | ;; CUS-10. 103 | builder job::st_working(builder v, slice poster, slice worker, int value, 104 | cell poster_desc, cell worker_desc, slice poster_key, slice worker_key) 105 | inline { 106 | v~store_slice(poster); v~store_slice(worker); v~store_uint(value, 64); 107 | v~store_ref(poster_desc); v~store_ref(worker_desc); 108 | v~store_ref(begin_cell() 109 | .store_slice(poster_key) 110 | .store_slice(worker_key) 111 | .store_slice(ratelance_public_key) 112 | .end_cell()); 113 | return v; 114 | } 115 | (slice, slice, cell) job::ld_working_main(slice v) inline { 116 | return (v~load_msg_addr(), v~load_msg_addr(), v.load_nth_ref(2)); 117 | } 118 | 119 | ;; CUS-11. Serialized by user. 120 | cell job::wrap_for_signing(int worker_ton_min, int worker_ton_max) inline { 121 | return begin_cell() 122 | .store_slice("FFFF726C3A3A6A6F623A3A7630"s) 123 | .store_uint(0, 5) 124 | .store_slice(my_address()) 125 | .store_uint(worker_ton_min, 64) 126 | .store_uint(worker_ton_max, 64) 127 | .end_cell(); 128 | } 129 | (int, slice, int, int) job::parse_proposal_with_tag(slice v) impure inline { 130 | int tag = v~load_uint(2); 131 | if (tag == 3) { 132 | cell c = config_param(1652841508); 133 | throw_if(200, cell_null?(c)); 134 | (v, int success) = c.dict_get?(267, my_address()); 135 | throw_unless(200, success); 136 | return (3, null(), v~load_uint(64), v~load_uint(64)); 137 | } 138 | return (tag, v~load_bits(512), v~load_uint(64), v~load_uint(64)); 139 | } 140 | 141 | ;; CUS-12. Serialized by user. 142 | (slice, slice) job::parse_finish_message(slice v) impure inline { 143 | return (v~load_ref().begin_parse(), v.preload_ref().begin_parse()); 144 | } 145 | -------------------------------------------------------------------------------- /contracts/project.yaml: -------------------------------------------------------------------------------- 1 | contract-job: 2 | func: 3 | - func/job-contract.fc 4 | tests: 5 | - tests/tests-job.fc 6 | 7 | contract-offer: 8 | func: 9 | - func/offer-contract.fc 10 | tests: 11 | - tests/tests-offer.fc 12 | 13 | -------------------------------------------------------------------------------- /contracts/show-log.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | os.system('') 5 | 6 | ostream = sys.stderr.detach() 7 | ostream.write(b'\x1b[37m') 8 | 9 | need_load_err = True 10 | 11 | exit_codes = { 12 | '2': 'ERR_STACK_UNDERFLOW', 13 | '3': 'ERR_STACK_OVERFLOW', 14 | '4': 'ERR_INTEGER_OVERFLOW', 15 | '5': 'ERR_INTEGER_OUT_OF_RANGE', 16 | '6': 'ERR_INVALID_OPCODE', 17 | '7': 'ERR_TYPE_CHECK', 18 | '8': 'ERR_CELL_OVERFLOW', 19 | '9': 'ERR_CELL_UNDERFLOW', 20 | '10': 'ERR_DICT', 21 | '13': 'ERR_OUT_OF_GAS', 22 | '32': 'ERR_INVALID_ACTION_LIST', 23 | '34': 'ERR_ACTION_UNSUPPORTED', 24 | '37': 'ERR_NOT_ENOUGH_TON', 25 | '38': 'ERR_NOT_ENOUGH_CURRENCY' 26 | } 27 | 28 | with open(__file__ + '/../toncli.log', 'r', encoding='utf-8') as f: 29 | debug_line_tostr = False 30 | 31 | for line in f: 32 | if not line.strip(): continue 33 | if '_io.TextIO' in line: continue 34 | if 'detached' in line: continue 35 | 36 | need_load_err = False 37 | 38 | for d in (31, 32, 36, 0): 39 | line = line.replace('\x1b[%dm' % d, '') 40 | if '[ 3]' in line: 41 | continue 42 | line = ('\x1b[36m' + 43 | line.removeprefix('[ 3][t 0]') 44 | .replace('9223372036854775807', 'UMAX') 45 | .replace('[vm.cpp:558]', '[vm558]') + '\x1b[37m') 46 | 47 | line = line.removeprefix('INFO: ') 48 | 49 | for (code, desc) in exit_codes.items(): 50 | c2 = int(code) + 200 51 | line = line.replace(f'code: [{code}]', f'code: [{code} | {desc}]') 52 | line = line.replace(f'code: [{c2}]', f'code: [{c2} | CALLEE_{desc}]') 53 | 54 | if line.strip() == '#DEBUG#: s0 = 4445': 55 | debug_line_tostr = True 56 | continue 57 | elif line.startswith('#DEBUG#') and debug_line_tostr: 58 | debug_line_tostr = False 59 | try: 60 | n = int(line.removeprefix('#DEBUG#: s0 = ').rstrip()) 61 | s = '' 62 | while n: 63 | s = s + chr(n % 256) 64 | n //= 256 65 | line = '\x1b[36m DEBUG : ' + s[::-1] + '\x1b[37m\n' 66 | except: 67 | pass 68 | 69 | if 'Test' in line: 70 | color = '\x1b[37m' 71 | if 'SUCCESS' in line: color = '\x1b[32m' 72 | if 'FAIL' in line: color = '\x1b[33m' 73 | 74 | line = (line 75 | .replace('[SUCCESS] Test', 'OK,') 76 | .replace('[FAIL]', 'RE') 77 | .replace(' Total gas used (including testing code)', ', gas usage') 78 | ) 79 | 80 | gas_usage = int(line[line.rfind('[')+1:line.rfind(']')]) 81 | ton_usage = gas_usage * 10**-6 82 | if '_get]' in line: 83 | ton_s = ' (offchain request)' 84 | elif '_transfer]' in line: 85 | ton_s = ' (money transfer)' 86 | elif 'init_contract]' in line: 87 | ton_s = ' (one-time initialization)' 88 | elif ton_usage > 0.01: 89 | ton_s = '\x1b[33m (%.5f TON == %.2f req/TON)' % (ton_usage, 1/ton_usage) 90 | else: 91 | ton_s = '\x1b[32m (%.5f TON == %.2f req/TON)' % (ton_usage, 1/ton_usage) 92 | 93 | line = color + line.rstrip().replace('__test_', '', 1).replace('status: ', '', 1) + ton_s + '\x1b[37m\n' 94 | 95 | ostream.write(line.replace('\r', '').encode('utf-8')) 96 | 97 | red_border = '\n\x1b[41m\x1b[30m \x1b[40m\x1b[37m\n' 98 | 99 | with open(__file__ + '/../toncli.err', 'r', encoding='utf-8') as f: 100 | skipping_traceback = False 101 | 102 | for line in f: 103 | if '--- Logging error ---' in line: continue 104 | 105 | if line.startswith('Traceback'): 106 | skipping_traceback = True 107 | elif line.startswith('Arguments') or line.startswith('subprocess'): 108 | skipping_traceback = False 109 | continue 110 | 111 | if skipping_traceback: continue 112 | 113 | if red_border: 114 | ostream.write(red_border.encode('utf-8')) 115 | red_border = '' 116 | 117 | if 'func/' in line or 'func\\' in line: 118 | path, remainder = line.split(': ', 1) 119 | ostream.write(('\x1b[33m%s: \x1b[37m%s' % (path, remainder)).encode('utf-8')) 120 | else: 121 | ostream.write(line.encode('utf-8')) 122 | -------------------------------------------------------------------------------- /contracts/tests/tests-job.fc: -------------------------------------------------------------------------------- 1 | forall F, A, R -> (int, int, R) gas::invoke_nowrap(F fun, A args) impure asm 2 | "{" ;; <- this function saves gas-remaining to 11-th element of c7's config 3 | "c7 PUSH DUP FIRST" ;; (c7, config) 4 | "GASLIMITSTEMP SWAP DROP" ;; (c7, config, gas_remaining) 5 | "11 SETINDEX 0 SETINDEX c7 POP" ;; <- new c7 saved 6 | "} : save-gas-remaining" 7 | 8 | "{" ;; <- assumes 'save-gas-remaining' was called before 9 | "GASLIMITSTEMP SWAP DROP" 10 | "11 GETPARAM SWAP SUB" 11 | ;; we additionally executed [GASLIMITSTEMP, SWAP, DROP, SETINDEX, SETINDEX, POP], 12 | ;; before actually calling 'EXECUTE' 13 | ;; so we need to subtract ( 18 + 18 + 26 + (12+26) + (1+26) + 18) 14 | ;; from consumed gas value 15 | "145 PUSHINT SUB" 16 | "} : compute-gas-used" 17 | 18 | "c7 PUSH DUP FIRST c0 PUSH" ;; (fun, args, c7, config, c0) 19 | "12 SETINDEXQ 0 SETINDEX" ;; (fun, args, c7) 20 | "c7 POP" ;; (fun, args) 21 | 22 | "NEWC ENDC c5 POP" ;; clear actions cell 23 | "RESETLOADEDCELLS" ;; <- make sure first cell load cost 100 gas, not 25 24 | "255 PUSHINT EXPLODEVAR" ;; (fun, arg_1, arg_2, ..., arg_n, n) 25 | "DUP INC ROLLX" ;; (arg_1, arg_2, ..., arg_n, n, fun) 26 | "<{" 27 | "<{" ;; <- normal execution 28 | "<{" 29 | "compute-gas-used" ;; <- execution terminated via jump to c1 30 | "DEPTH DEC ROLLREVX" 31 | "DEPTH DEC TUPLEVAR" 32 | "ZERO ROTREV" ;; (exit_code, gas_used, [res...]) 33 | "c7 PUSH FIRST 12 INDEX" ;; (exit_code, gas_used, [res...], ret_to) 34 | "JMPX" 35 | "}> PUSHCONT SWAP c1 SETCONTCTR" 36 | "save-gas-remaining" 37 | "EXECUTE" 38 | "compute-gas-used" ;; <- it is important to call it just after EXECUTE, so we don't count additional commands 39 | "DEPTH DEC ROLLREVX" ;; (gas_used, res_1, res_2, ..., res_k) 40 | "DEPTH DEC TUPLEVAR" ;; (gas_used, [res_1, res_2, ..., res_k]) 41 | "ZERO ROTREV" ;; (exit_code = 0, gas_used, [res..]) 42 | "}> PUSHCONT" 43 | "<{" ;; <- exception handler 44 | "compute-gas-used" ;; (exception_info, exit_code, gas_used) 45 | "ROT DROP NIL" ;; (exit_code, gas_used, []) 46 | "}> PUSHCONT" 47 | "TRY" 48 | "}> PUSHCONT" ;; (args.., n, fun, func_with_exception_wrapper) 49 | "ROT INC -1 PUSHINT" ;; (args.., fun, func_with_exception_wrapper, n + 1, -1) 50 | "CALLXVARARGS" ;; (exit_code, gas_used, [res..]) 51 | ; 52 | 53 | forall F, A, R -> (int, int, R) gas::invoke_method_full(F fun, A args) impure { 54 | ;; no inlining 55 | return gas::invoke_nowrap(fun, args); 56 | } 57 | 58 | forall F, A, R -> (int, R) gas::invoke_method(F fun, A args) impure inline { 59 | (int exit_code, int gas_used, R return_values) = gas::invoke_method_full(fun, args); 60 | throw_if(exit_code, (exit_code != 0) & (exit_code != 1)); 61 | return (gas_used, return_values); 62 | } 63 | 64 | forall F, A -> int invoke_gas(F fun, A args) impure inline { 65 | (int exit_code, int gas_used, _) = gas::invoke_method_full(fun, args); 66 | 67 | ;; ~dump(4445); ~dump("gas::invoke_method_full exit"u); 68 | 69 | throw_if(exit_code, (exit_code != 0) & (exit_code != 1)); 70 | return gas_used; 71 | } 72 | 73 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 74 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 75 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 76 | 77 | ;; job_unlocked$00 poster:MsgAddressInt value:uint64 desc:^.. poster_key:uint256 78 | cell init_job() asm 79 | "b{00}" 80 | "b{100} x{003b819131c4c44a0caa0aefe707735ecca910875fe4c948a88c39be0edbf5d204}" 81 | "x{000000003b9aca00}" 82 | "x{0000000054657374206A6F62} |_" 83 | "B priv>pub B, b> c PUSHREF"; 85 | cell init_job_locked() asm 86 | "b{01}" 87 | "b{100} x{007da879d948524500877828304cfbdb4c43085f57c9fc49cc9fdfe6dc3bd1b490}" 88 | "b{100} x{003b819131c4c44a0caa0aefe707735ecca910875fe4c948a88c39be0edbf5d204}" 89 | "x{000000003b9aca00}" 90 | "x{0000000054657374206A6F62} |_" 91 | "B priv>pub B, b> c PUSHREF"; 93 | cell init_job_working() asm 94 | "b{10}" 95 | "b{100} x{003b819131c4c44a0caa0aefe707735ecca910875fe4c948a88c39be0edbf5d204}" 96 | "b{100} x{008be1e8c90f9d4f44895721ab629051a8ae6e345730d84ca9a7d403abbf563897}" 97 | "x{000000003b9aca00}" 98 | "x{0000000054657374206A6F62} |_" 99 | 100 | "x{0000000054657374206A6F62} |_" 101 | 102 | "B priv>pub B, b> B priv>pub B, b> c PUSHREF"; 106 | 107 | slice offer_addr() asm 108 | "b{100} x{007da879d948524500877828304cfbdb4c43085f57c9fc49cc9fdfe6dc3bd1b490}" 109 | "|+ PUSHSLICE"; 110 | slice worker_addr_desc_key() asm 111 | "b{100} x{008be1e8c90f9d4f44895721ab629051a8ae6e345730d84ca9a7d403abbf563897}" 112 | "x{0000000054657374206A6F62} |_" 113 | "B priv>pub B, b> B ed25519_sign B ed25519_sign 1); 223 | } 224 | 225 | () __test_job_unlock_on_failure() { 226 | set_data(init_job_locked()); 227 | invoke_gas(recv_internal, [1, build_offer_pfx().end_cell(), 228 | begin_cell().store_uint(op::lock_failed, 32).end_cell().begin_parse()]); 229 | 230 | throw_unless(140, is_null(parse_c5())); 231 | throw_unless(141, cell_hash(get_data()) == cell_hash(init_job())); 232 | } 233 | 234 | () __test_job_unlock_on_bounce() { 235 | set_data(init_job_locked()); 236 | invoke_gas(recv_internal, [1, build_offer_pfx_bounced().end_cell(), 237 | begin_cell().store_uint(0xFFFFFFFF, 32).end_cell().begin_parse()]); 238 | 239 | throw_unless(150, is_null(parse_c5())); 240 | throw_unless(151, cell_hash(get_data()) == cell_hash(init_job())); 241 | } 242 | 243 | () __test_job_lockup_worker_stake() { 244 | set_data(init_job_locked()); 245 | invoke_gas(recv_internal, [1, build_offer_pfx().end_cell(), 246 | begin_cell() 247 | .store_uint(op::lock_success, 32) 248 | .store_slice(worker_addr_desc_key()) 249 | .end_cell() 250 | .begin_parse()]); 251 | 252 | throw_unless(160, is_null(parse_c5())); 253 | throw_unless(161, cell_hash(get_data()) == cell_hash(init_job_working())); 254 | } 255 | 256 | () __test_job_work_complete() { 257 | set_data(init_job_working()); 258 | invoke_gas(recv_internal, [1, build_offer_pfx().end_cell(), 259 | begin_cell() 260 | .store_uint(op::finish_job, 32) 261 | .store_ref(proposal_poster()) 262 | .store_ref(proposal_worker()) 263 | .end_cell() 264 | .begin_parse()]); 265 | 266 | tuple actions = parse_c5(); 267 | throw_if(170, is_null(actions)); 268 | throw_unless(171, tuple_length(actions) == 2); 269 | } 270 | -------------------------------------------------------------------------------- /contracts/tests/tests-offer.fc: -------------------------------------------------------------------------------- 1 | forall F, A, R -> (int, int, R) gas::invoke_nowrap(F fun, A args) impure asm 2 | "{" ;; <- this function saves gas-remaining to 11-th element of c7's config 3 | "c7 PUSH DUP FIRST" ;; (c7, config) 4 | "GASLIMITSTEMP SWAP DROP" ;; (c7, config, gas_remaining) 5 | "11 SETINDEX 0 SETINDEX c7 POP" ;; <- new c7 saved 6 | "} : save-gas-remaining" 7 | 8 | "{" ;; <- assumes 'save-gas-remaining' was called before 9 | "GASLIMITSTEMP SWAP DROP" 10 | "11 GETPARAM SWAP SUB" 11 | ;; we additionally executed [GASLIMITSTEMP, SWAP, DROP, SETINDEX, SETINDEX, POP], 12 | ;; before actually calling 'EXECUTE' 13 | ;; so we need to subtract ( 18 + 18 + 26 + (12+26) + (1+26) + 18) 14 | ;; from consumed gas value 15 | "145 PUSHINT SUB" 16 | "} : compute-gas-used" 17 | 18 | "c7 PUSH DUP FIRST c0 PUSH" ;; (fun, args, c7, config, c0) 19 | "12 SETINDEXQ 0 SETINDEX" ;; (fun, args, c7) 20 | "c7 POP" ;; (fun, args) 21 | 22 | "NEWC ENDC c5 POP" ;; clear actions cell 23 | "RESETLOADEDCELLS" ;; <- make sure first cell load cost 100 gas, not 25 24 | "255 PUSHINT EXPLODEVAR" ;; (fun, arg_1, arg_2, ..., arg_n, n) 25 | "DUP INC ROLLX" ;; (arg_1, arg_2, ..., arg_n, n, fun) 26 | "<{" 27 | "<{" ;; <- normal execution 28 | "<{" 29 | "compute-gas-used" ;; <- execution terminated via jump to c1 30 | "DEPTH DEC ROLLREVX" 31 | "DEPTH DEC TUPLEVAR" 32 | "ZERO ROTREV" ;; (exit_code, gas_used, [res...]) 33 | "c7 PUSH FIRST 12 INDEX" ;; (exit_code, gas_used, [res...], ret_to) 34 | "JMPX" 35 | "}> PUSHCONT SWAP c1 SETCONTCTR" 36 | "save-gas-remaining" 37 | "EXECUTE" 38 | "compute-gas-used" ;; <- it is important to call it just after EXECUTE, so we don't count additional commands 39 | "DEPTH DEC ROLLREVX" ;; (gas_used, res_1, res_2, ..., res_k) 40 | "DEPTH DEC TUPLEVAR" ;; (gas_used, [res_1, res_2, ..., res_k]) 41 | "ZERO ROTREV" ;; (exit_code = 0, gas_used, [res..]) 42 | "}> PUSHCONT" 43 | "<{" ;; <- exception handler 44 | "compute-gas-used" ;; (exception_info, exit_code, gas_used) 45 | "ROT DROP NIL" ;; (exit_code, gas_used, []) 46 | "}> PUSHCONT" 47 | "TRY" 48 | "}> PUSHCONT" ;; (args.., n, fun, func_with_exception_wrapper) 49 | "ROT INC -1 PUSHINT" ;; (args.., fun, func_with_exception_wrapper, n + 1, -1) 50 | "CALLXVARARGS" ;; (exit_code, gas_used, [res..]) 51 | ; 52 | 53 | forall F, A, R -> (int, int, R) gas::invoke_method_full(F fun, A args) impure { 54 | ;; no inlining 55 | return gas::invoke_nowrap(fun, args); 56 | } 57 | 58 | forall F, A, R -> (int, R) gas::invoke_method(F fun, A args) impure method_id { 59 | (int exit_code, int gas_used, R return_values) = gas::invoke_method_full(fun, args); 60 | throw_if(exit_code, (exit_code != 0) & (exit_code != 1)); 61 | return (gas_used, return_values); 62 | } 63 | 64 | forall F, A -> int invoke_gas(F fun, A args) impure method_id { 65 | (int exit_code, int gas_used, _) = gas::invoke_method_full(fun, args); 66 | throw_if(exit_code, (exit_code != 0) & (exit_code != 1)); 67 | return gas_used; 68 | } 69 | 70 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 71 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 72 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 73 | 74 | () __fift_init() impure method_id asm 75 | "{" 76 | "b{00}" 77 | "b{100} x{003b819131c4c44a0caa0aefe707735ecca910875fe4c948a88c39be0edbf5d204}" 78 | "x{000000003b9aca00}" 79 | "x{0000000054657374206A6F62} |_" 80 | "B priv>pub B, b> c hash 1 160 << 1- and // job hash" 82 | "} : job-hash"; 83 | 84 | () __check_fift_init() impure method_id { 85 | return __fift_init(); 86 | } 87 | 88 | slice job_addr() asm 89 | "b{100} x{00227e0ff3501e47609d3cf76dfb6485252be873632f4b479fb86dc5d65dc3ffb0}" 90 | "|+ PUSHSLICE"; 91 | builder build_job_pfx() asm 92 | "x{6}" 93 | "b{100} x{00227e0ff3501e47609d3cf76dfb6485252be873632f4b479fb86dc5d65dc3ffb0}" 94 | "b{00}" 95 | "|+ |+ |+ PUSHSLICE NEWC STSLICE"; 96 | 97 | builder build_worker_pfx() asm 98 | "x{6}" 99 | "b{100} x{0077ebf11f623158315f29fdcc52617eb56c0d0567807ae5934de8bf08358fb980}" 100 | "b{00}" 101 | "|+ |+ |+ PUSHSLICE NEWC STSLICE"; 102 | builder build_worker_pfx_bounced() asm 103 | "x{7}" 104 | "b{100} x{0077ebf11f623158315f29fdcc52617eb56c0d0567807ae5934de8bf08358fb980}" 105 | "b{00}" 106 | "|+ |+ |+ PUSHSLICE NEWC STSLICE"; 107 | 108 | int job_hash() asm "job-hash PUSHINT"; 109 | cell init_offer() asm 110 | " ref," 118 | "20230216 256 u>B priv>pub B," 119 | "job-hash 160 u," 120 | "b> PUSHREF"; 121 | cell init_offer_locked() asm 122 | " ref," 130 | "20230216 256 u>B priv>pub B," 131 | "job-hash 160 u," 132 | "b> PUSHREF"; 133 | 134 | () __test_offer_init_success() { 135 | set_data(init_offer()); 136 | invoke_gas(recv_internal, [50000000, build_worker_pfx().end_cell(), 137 | begin_cell().end_cell().begin_parse()]); 138 | throw_unless(110, cell_hash(get_data()) == cell_hash(init_offer())); 139 | } 140 | 141 | () __test_offer_plugin_destroy() { 142 | set_data(init_offer()); 143 | invoke_gas(recv_internal, [1, build_worker_pfx().end_cell(), 144 | begin_cell().store_uint(op::destruct, 32).end_cell().begin_parse()]); 145 | 146 | tuple actions = parse_c5(); 147 | throw_if(120, is_null(actions)); 148 | throw_unless(121, tuple_length(actions) == 1); 149 | throw_unless(122, first(first(actions)) == 0); 150 | throw_unless(123, third(first(actions)) == 128); 151 | } 152 | 153 | () __test_offer_collapse_initiates() { 154 | set_data(init_offer()); 155 | invoke_gas(recv_internal, [1, build_job_pfx().end_cell(), 156 | begin_cell() 157 | .store_uint(op::collapse, 32) 158 | .store_uint(job_hash(), 160) 159 | .end_cell() 160 | .begin_parse()]); 161 | 162 | tuple actions = parse_c5(); 163 | throw_if(130, is_null(actions)); 164 | throw_unless(131, tuple_length(actions) == 1); 165 | throw_unless(132, first(first(actions)) == 0); 166 | throw_unless(133, third(first(actions)) == 64); 167 | throw_unless(134, cell_hash(get_data()) == cell_hash(init_offer_locked())); 168 | } 169 | 170 | () __test_offer_collapse_rejection_handled() { 171 | set_data(init_offer_locked()); 172 | invoke_gas(recv_internal, [1, build_worker_pfx_bounced().end_cell(), 173 | begin_cell().store_uint(0xFFFFFFFF, 32).end_cell().begin_parse()]); 174 | 175 | tuple actions = parse_c5(); 176 | throw_if(140, is_null(actions)); 177 | throw_unless(141, tuple_length(actions) == 1); 178 | throw_unless(142, first(first(actions)) == 0); 179 | throw_unless(143, third(first(actions)) == 64); 180 | throw_unless(144, cell_hash(get_data()) == cell_hash(init_offer())); 181 | } 182 | 183 | () __test_offer_collapse_completion() { 184 | set_data(init_offer_locked()); 185 | invoke_gas(recv_internal, [1, build_worker_pfx().end_cell(), 186 | begin_cell().store_uint(op::payment_ok, 32).end_cell().begin_parse()]); 187 | 188 | tuple actions = parse_c5(); 189 | throw_if(150, is_null(actions)); 190 | throw_unless(151, tuple_length(actions) == 1); 191 | throw_unless(152, first(first(actions)) == 0); 192 | throw_unless(153, third(first(actions)) == 128); 193 | throw_unless(154, cell_hash(get_data()) == cell_hash(init_offer_locked())); 194 | } 195 | -------------------------------------------------------------------------------- /contracts/toncli.err: -------------------------------------------------------------------------------- 1 | --- Logging error --- 2 | Traceback (most recent call last): 3 | File "C:\Program Files\Python39\lib\logging\__init__.py", line 1082, in emit 4 | stream.write(msg + self.terminator) 5 | File "C:\Program Files\Python39\lib\encodings\cp1251.py", line 19, in encode 6 | return codecs.charmap_encode(input,self.errors,encoding_table)[0] 7 | UnicodeEncodeError: 'charmap' codec can't encode character '\U0001f308' in position 6: character maps to 8 | Call stack: 9 | File "C:\Program Files\Python39\lib\runpy.py", line 197, in _run_module_as_main 10 | return _run_code(code, main_globals, None, 11 | File "C:\Program Files\Python39\lib\runpy.py", line 87, in _run_code 12 | exec(code, run_globals) 13 | File "C:\Program Files\Python39\Scripts\toncli.exe\__main__.py", line 7, in 14 | sys.exit(main()) 15 | File "C:\Users\Tigr\AppData\Roaming\Python\Python39\site-packages\toncli\main.py", line 67, in main 16 | CommandsExecuter(command, string_kwargs, parser) 17 | File "C:\Users\Tigr\AppData\Roaming\Python\Python39\site-packages\toncli\modules\utils\commands\commands_executer.py", line 39, in __init__ 18 | self.command_mapper[command](self) 19 | File "C:\Users\Tigr\AppData\Roaming\Python\Python39\site-packages\toncli\modules\utils\commands\commands_executer.py", line 49, in run_tests_command 20 | return RunTestsCommand(self.string_kwargs, self.parser) 21 | File "C:\Users\Tigr\AppData\Roaming\Python\Python39\site-packages\toncli\modules\utils\commands\command_classes\run_tests_command.py", line 12, in __init__ 22 | test_runner.run(args.contracts.split() if args.contracts else None, 23 | File "C:\Users\Tigr\AppData\Roaming\Python\Python39\site-packages\toncli\modules\utils\test\tests.py", line 26, in run 24 | logger.info(f"\U0001f308 Start tests") 25 | Message: '\U0001f308 Start tests' 26 | Arguments: () 27 | --- Logging error --- 28 | Traceback (most recent call last): 29 | File "C:\Program Files\Python39\lib\logging\__init__.py", line 1082, in emit 30 | stream.write(msg + self.terminator) 31 | File "C:\Program Files\Python39\lib\encodings\cp1251.py", line 19, in encode 32 | return codecs.charmap_encode(input,self.errors,encoding_table)[0] 33 | UnicodeEncodeError: 'charmap' codec can't encode character '\U0001f94c' in position 6: character maps to 34 | Call stack: 35 | File "C:\Program Files\Python39\lib\runpy.py", line 197, in _run_module_as_main 36 | return _run_code(code, main_globals, None, 37 | File "C:\Program Files\Python39\lib\runpy.py", line 87, in _run_code 38 | exec(code, run_globals) 39 | File "C:\Program Files\Python39\Scripts\toncli.exe\__main__.py", line 7, in 40 | sys.exit(main()) 41 | File "C:\Users\Tigr\AppData\Roaming\Python\Python39\site-packages\toncli\main.py", line 67, in main 42 | CommandsExecuter(command, string_kwargs, parser) 43 | File "C:\Users\Tigr\AppData\Roaming\Python\Python39\site-packages\toncli\modules\utils\commands\commands_executer.py", line 39, in __init__ 44 | self.command_mapper[command](self) 45 | File "C:\Users\Tigr\AppData\Roaming\Python\Python39\site-packages\toncli\modules\utils\commands\commands_executer.py", line 49, in run_tests_command 46 | return RunTestsCommand(self.string_kwargs, self.parser) 47 | File "C:\Users\Tigr\AppData\Roaming\Python\Python39\site-packages\toncli\modules\utils\commands\command_classes\run_tests_command.py", line 12, in __init__ 48 | test_runner.run(args.contracts.split() if args.contracts else None, 49 | File "C:\Users\Tigr\AppData\Roaming\Python\Python39\site-packages\toncli\modules\utils\test\tests.py", line 47, in run 50 | logger.info(f"\U0001f94c Build {gr}successfully{rs}, check out {gr}.{location}{rs}") 51 | Message: '\U0001f94c Build \x1b[32msuccessfully\x1b[0m, check out \x1b[32m.\\build\x1b[0m' 52 | Arguments: () 53 | -------------------------------------------------------------------------------- /contracts/toncli.log: -------------------------------------------------------------------------------- 1 | #DEBUG#: s0 = CS{Cell{004077ebf11f623158315f29fdcc52617eb56c0d0567807ae5934de8bf08358fb980} bits: 0..256; refs: 0..0} 2 | [ 3][t 0][2023-07-01 03:30:15.2123331][vm.cpp:586] steps: 14 gas: used=1711, max=1000000000, limit=1000000000, credit=0 3 | [ 3][t 0][2023-07-01 03:30:15.2128946][vm.cpp:586] steps: 100 gas: used=6222, max=1000000000, limit=1000000000, credit=0 4 | [ 3][t 0][2023-07-01 03:30:15.2129829][vm.cpp:586] steps: 221 gas: used=10618, max=1000000000, limit=1000000000, credit=0 5 | [ 3][t 0][2023-07-01 03:30:15.2130583][vm.cpp:586] steps: 281 gas: used=13982, max=1000000000, limit=1000000000, credit=0 6 | [ 3][t 0][2023-07-01 03:30:15.2131100][vm.cpp:586] steps: 126 gas: used=7646, max=1000000000, limit=1000000000, credit=0 7 | [ 3][t 0][2023-07-01 03:30:15.2131640][vm.cpp:586] steps: 169 gas: used=8611, max=1000000000, limit=1000000000, credit=0 8 | [ 3][t 0][2023-07-01 03:30:15.2132158][vm.cpp:586] steps: 169 gas: used=8603, max=1000000000, limit=1000000000, credit=0 9 | [ 3][t 0][2023-07-01 03:30:15.2132711][vm.cpp:586] steps: 191 gas: used=9763, max=1000000000, limit=1000000000, credit=0 10 | [ 3][t 0][2023-07-01 03:30:15.2134929][vm.cpp:586] steps: 412 gas: used=18346, max=1000000000, limit=1000000000, credit=0 11 | INFO: Test [__test_output_address] status: [SUCCESS] Test result: [[]] Total gas used (including testing code): [1711] 12 | INFO: Test [__test_job_init_success] status: [SUCCESS] Test result: [[]] Total gas used (including testing code): [6222] 13 | INFO: Test [__test_job_revoke_success] status: [SUCCESS] Test result: [[]] Total gas used (including testing code): [10618] 14 | INFO: Test [__test_job_lock_success] status: [SUCCESS] Test result: [[]] Total gas used (including testing code): [13982] 15 | INFO: Test [__test_job_lock_insufficient_funds] status: [SUCCESS] Test result: [[]] Total gas used (including testing code): [7646] 16 | INFO: Test [__test_job_unlock_on_failure] status: [SUCCESS] Test result: [[]] Total gas used (including testing code): [8611] 17 | INFO: Test [__test_job_unlock_on_bounce] status: [SUCCESS] Test result: [[]] Total gas used (including testing code): [8603] 18 | INFO: Test [__test_job_lockup_worker_stake] status: [SUCCESS] Test result: [[]] Total gas used (including testing code): [9763] 19 | INFO: Test [__test_job_work_complete] status: [SUCCESS] Test result: [[]] Total gas used (including testing code): [18346] 20 | 21 | [ 3][t 0][2023-07-01 03:30:15.2472293][vm.cpp:586] steps: 107 gas: used=6735, max=1000000000, limit=1000000000, credit=0 22 | [ 3][t 0][2023-07-01 03:30:15.2473602][vm.cpp:586] steps: 236 gas: used=11313, max=1000000000, limit=1000000000, credit=0 23 | [ 3][t 0][2023-07-01 03:30:15.2474368][vm.cpp:586] steps: 291 gas: used=14122, max=1000000000, limit=1000000000, credit=0 24 | [ 3][t 0][2023-07-01 03:30:15.2475034][vm.cpp:586] steps: 269 gas: used=13715, max=1000000000, limit=1000000000, credit=0 25 | [ 3][t 0][2023-07-01 03:30:15.2475700][vm.cpp:586] steps: 282 gas: used=13313, max=1000000000, limit=1000000000, credit=0 26 | INFO: Test [__test_offer_init_success] status: [SUCCESS] Test result: [[]] Total gas used (including testing code): [6735] 27 | INFO: Test [__test_offer_plugin_destroy] status: [SUCCESS] Test result: [[]] Total gas used (including testing code): [11313] 28 | INFO: Test [__test_offer_collapse_initiates] status: [SUCCESS] Test result: [[]] Total gas used (including testing code): [14122] 29 | INFO: Test [__test_offer_collapse_rejection_handled] status: [SUCCESS] Test result: [[]] Total gas used (including testing code): [13715] 30 | INFO: Test [__test_offer_collapse_completion] status: [SUCCESS] Test result: [[]] Total gas used (including testing code): [13313] 31 | 32 | -------------------------------------------------------------------------------- /freelance-highlevel.idr: -------------------------------------------------------------------------------- 1 | module Main 2 | 3 | import Data.Fin 4 | 5 | %default covering 6 | 7 | 8 | -------------------------------------------------------------------------------- 9 | ---- Std extensions 10 | -------------------------------------------------------------------------------- 11 | 12 | lookupBy : (a -> b -> Bool) -> a -> List (b, v) -> Maybe v 13 | lookupBy p e [] = Nothing 14 | lookupBy p e ((l, r) :: xs) = 15 | if p e l then 16 | Just r 17 | else 18 | lookupBy p e xs 19 | 20 | repeat : (n : Nat) -> List a -> List a 21 | repeat Z _ = [] 22 | repeat (S n) x = x ++ repeat n x 23 | 24 | -------------------------------------------------------------------------------- 25 | ---- Arithmetic types 26 | -------------------------------------------------------------------------------- 27 | 28 | data UintSeq : Nat -> Type where 29 | UintSeqVoid : UintSeq Z 30 | UintSeqLow : (bits : Nat) -> UintSeq bits -> UintSeq (S bits) 31 | UintSeqHigh : (bits : Nat) -> UintSeq bits -> UintSeq (S bits) 32 | 33 | using (n : Nat) 34 | Eq (UintSeq n) where 35 | (==) UintSeqVoid UintSeqVoid = True 36 | (==) (UintSeqLow _ a) (UintSeqLow _ b) = a == b 37 | (==) (UintSeqHigh _ _) (UintSeqLow _ _) = False 38 | (==) (UintSeqLow _ _) (UintSeqHigh _ _) = False 39 | (==) (UintSeqHigh _ a) (UintSeqHigh _ b) = a == b 40 | 41 | Ord (UintSeq n) where 42 | compare UintSeqVoid UintSeqVoid = EQ 43 | compare (UintSeqLow _ a) (UintSeqLow _ b) = compare a b 44 | compare (UintSeqHigh _ _) (UintSeqLow _ _) = GT 45 | compare (UintSeqLow _ _) (UintSeqHigh _ _) = LT 46 | compare (UintSeqHigh _ a) (UintSeqHigh _ b) = compare a b 47 | 48 | 49 | seq_to_bits : (n : Nat) -> UintSeq n -> List Nat 50 | seq_to_bits Z UintSeqVoid = Nil 51 | seq_to_bits (S n) (UintSeqLow n low) = 0 :: (seq_to_bits n low) 52 | seq_to_bits (S n) (UintSeqHigh n low) = 1 :: (seq_to_bits n low) 53 | 54 | bits_to_seq : (a : List (Fin 2)) -> UintSeq (length a) 55 | bits_to_seq Nil = UintSeqVoid 56 | bits_to_seq (0 :: next) = UintSeqLow _ $ bits_to_seq next 57 | bits_to_seq (1 :: next) = UintSeqHigh _ $ bits_to_seq next 58 | 59 | bits_to_nat : List Nat -> Nat 60 | bits_to_nat Nil = 0 61 | bits_to_nat (v :: old) = (bits_to_nat old) * 2 + v 62 | 63 | stn : (n : Nat) -> UintSeq n -> Nat 64 | stn n v = bits_to_nat (reverse (seq_to_bits n v)) 65 | 66 | build_zero_uint_seq : (n : Nat) -> UintSeq n 67 | build_zero_uint_seq Z = UintSeqVoid 68 | build_zero_uint_seq (S k) = UintSeqLow _ (build_zero_uint_seq k) 69 | 70 | build_high_uint_seq : (n : Nat) -> UintSeq n 71 | build_high_uint_seq Z = UintSeqVoid 72 | build_high_uint_seq (S k) = UintSeqHigh _ (build_high_uint_seq k) 73 | 74 | -------------------------------------------------------------------------------- 75 | ---- TON-specific types 76 | -------------------------------------------------------------------------------- 77 | 78 | data Anycast : Type where 79 | AnycastCreate : (depth : UintSeq 5) -> (pfx : UintSeq (stn 5 depth)) -> Anycast 80 | 81 | data MessageAddr : Type where 82 | MsgAddressIntStd : (Maybe Anycast) -> (workchain : UintSeq 8) -> (hash_part : UintSeq 256) -> MessageAddr 83 | -- MsgAddressIntVar : (Maybe Anycast) -> (addr_len : UintSeq 9) -> (workchain : UintSeq 32) -> (hash_part : UintSeq (stn 9 addr_len)) -> MessageAddr 84 | 85 | Eq MessageAddr where 86 | (==) (MsgAddressIntStd Nothing wc1 hp1) (MsgAddressIntStd Nothing wc2 hp2) = (wc1 == wc2) && (hp1 == hp2) 87 | (==) _ _ = False 88 | 89 | data TxMessage : Type where 90 | IntMsg : (bounce : Bool) -> (src : MessageAddr) -> (dest : MessageAddr) -> (coins : UintSeq 120) -> (init : Maybe ()) -> (body : JobMsgBody) -> TxMessage 91 | ExtInMsg : (src : MessageAddr) -> (dest : MessageAddr) -> (init : Maybe ()) -> (body : List Nat) -> TxMessage 92 | ExtOutMsg : (src : MessageAddr) -> (dest : MessageAddr) -> (init : Maybe ()) -> (body : List Nat) -> TxMessage 93 | 94 | -------------------------------------------------------------------------------- 95 | ---- High-level contracts representation 96 | -------------------------------------------------------------------------------- 97 | 98 | mutual 99 | data NextState : Type where 100 | MkNextState : ContractState ncs => ncs -> NextState 101 | 102 | interface ContractState cs where 103 | to_code : cs -> (cs -> TxMessage -> (NextState, List TxMessage)) 104 | to_data : cs -> Type 105 | 106 | data Contract : Type where 107 | Uninit : (addr : MessageAddr) -> Contract 108 | Init : ContractState cs => (addr : MessageAddr) -> (st : cs) -> (balance : Nat) -> Contract 109 | 110 | run_tvm : NextState -> TxMessage -> (NextState, List TxMessage) 111 | run_tvm (MkNextState wrapped_state) msg = (to_code wrapped_state) wrapped_state msg 112 | 113 | build_bounce : TxMessage -> List TxMessage 114 | build_bounce (IntMsg True src self coins _ body) = [IntMsg False self src coins Nothing $ Bounce body] 115 | build_bounce _ = Nil 116 | 117 | bounce_typical : ContractState cs => cs -> TxMessage -> (NextState, List TxMessage) 118 | bounce_typical state msg = (MkNextState state, build_bounce msg) 119 | 120 | ---- 121 | 122 | -- ContractStorage = (List (MessageAddr, NextState)) 123 | lookup_contract : (List (MessageAddr, NextState)) -> MessageAddr -> Maybe NextState 124 | lookup_contract contracts addr = lookupBy f () contracts where 125 | f : () -> MessageAddr -> Bool 126 | f _ some_addr = addr == some_addr 127 | 128 | extract_contract : (List (MessageAddr, NextState)) -> MessageAddr -> (Maybe NextState, (List (MessageAddr, NextState))) 129 | extract_contract Nil addr = (Nothing, Nil) 130 | extract_contract (x::xs) addr = let (gaddr, v) = x in 131 | if gaddr == addr then 132 | (Just v, xs) 133 | else 134 | let (loaded, storage) = extract_contract xs addr in 135 | (loaded, (gaddr,v) :: storage) 136 | 137 | extract_dest : TxMessage -> MessageAddr 138 | extract_dest (IntMsg _ _ dest _ _ _) = dest 139 | extract_dest (ExtInMsg _ dest _ _) = dest 140 | extract_dest (ExtOutMsg _ dest _ _) = dest 141 | 142 | main_loop : List TxMessage -> List (MessageAddr, NextState) -> (List TxMessage, List (MessageAddr, NextState)) 143 | main_loop Nil contracts = (Nil, contracts) 144 | main_loop (msg :: later) contracts = let (mb_contract, contracts_ext) = extract_contract contracts $ extract_dest msg in 145 | case mb_contract of 146 | Nothing => main_loop (later ++ (build_bounce msg)) contracts_ext 147 | Just contract => let (upd_contract, send) = run_tvm contract msg in 148 | (later ++ send, (extract_dest msg, upd_contract) :: contracts_ext) 149 | 150 | -------------------------------------------------------------------------------- 151 | ---- Job contract 152 | -------------------------------------------------------------------------------- 153 | 154 | data PayProposal : Type where 155 | ProposePay : (worker_lower : UintSeq 64) -> (worker_upper : UintSeq 64) -> PayProposal 156 | 157 | data PaySignature : PayProposal -> Type where 158 | SignPay : (proposal : PayProposal) -> (role : UintSeq 2) -> (sig : UintSeq 512) -> PaySignature proposal 159 | 160 | check_distinct_roles : PaySignature -> PaySignature -> Bool 161 | check_distinct_roles (SignPay _ r1 _) (SignPay _ r2 _) = (r1 != r2) 162 | 163 | data JobMsgBody : Type where 164 | JumLock : (offer : MessageAddr) -> JobMsgBody 165 | JlmConfirm : (worker_addr : MessageAddr) -> (worker_desc : Type) -> (worker_key : UintSeq 256) -> JobMsgBody 166 | JwmFinish : (sig_a : PaySignature) -> (sig_b: PaySignature) -> JobMsgBody 167 | Bounce : JobMsgBody -> JobMsgBody 168 | MsgRaw : List Nat -> JobMsgBody 169 | 170 | data JobContractStateDestroyed : Type where 171 | JcsDestroyed : JobContractStateDestroyed 172 | 173 | ContractState JobContractStateDestroyed where 174 | to_code _ = bounce_typical 175 | to_data _ = believe_me () 176 | 177 | ---- 178 | 179 | data JobContractDataUnlocked : Type where 180 | JcdUnlocked : (poster : MessageAddr) -> (desc : Type) -> (value : UintSeq 120) -> (poster_key : UintSeq 256) -> JobContractDataUnlocked 181 | 182 | data JobContractStateUnlocked : Type where 183 | JcsUnlocked : (jccode : (JobContractStateUnlocked -> TxMessage -> (NextState, List TxMessage))) -> (jcdata : JobContractDataUnlocked) -> JobContractStateUnlocked 184 | 185 | ContractState JobContractStateUnlocked where 186 | to_code cur_state = let JcsUnlocked jccode jcdata = cur_state in 187 | jccode 188 | to_data cur_state = let JcsUnlocked jccode jcdata = cur_state in 189 | believe_me jcdata 190 | 191 | ---- 192 | 193 | data JobContractDataLockedOn : Type where 194 | JcdLockedOn : (poster : MessageAddr) -> (desc : Type) -> (value : UintSeq 120) -> (poster_key : UintSeq 256) -> (offer : MessageAddr) -> JobContractDataLockedOn 195 | 196 | data JobContractStateLockedOn : Type where 197 | JcsLockedOn : (jccode : (JobContractStateLockedOn -> TxMessage -> (NextState, List TxMessage))) -> (jcdata : JobContractDataLockedOn) -> JobContractStateLockedOn 198 | 199 | ContractState JobContractStateLockedOn where 200 | to_code cur_state = let JcsLockedOn jccode jcdata = cur_state in 201 | jccode 202 | to_data cur_state = let JcsLockedOn jccode jcdata = cur_state in 203 | believe_me jcdata 204 | 205 | ---- 206 | 207 | data JobContractDataWorking : Type where 208 | JcdWorking : (poster : MessageAddr) -> (desc : Type) -> (value : UintSeq 120) -> (poster_key : UintSeq 256) -> (worker_key : UintSeq 256) -> JobContractDataWorking 209 | 210 | data JobContractStateWorking : Type where 211 | JcsWorking : (jccode : (JobContractStateWorking -> TxMessage -> (NextState, List TxMessage))) -> (jcdata : JobContractDataWorking) -> JobContractStateWorking 212 | 213 | ContractState JobContractStateWorking where 214 | to_code cur_state = let JcsWorking jccode jcdata = cur_state in 215 | jccode 216 | to_data cur_state = let JcsWorking jccode jcdata = cur_state in 217 | believe_me jcdata 218 | 219 | ---- 220 | 221 | job_working : JobContractStateWorking -> TxMessage -> (NextState, List TxMessage) 222 | job_working state (IntMsg bounce src self coins _ body) = case believe_me $ to_data state of 223 | (JcdWorking poster desc value poster_key worker_key) => case body of 224 | Bounce _ => (MkNextState state, Nil) 225 | JwmFinish worker poster_sig worker_sig => case True of -- TODO: check signatures 226 | True => (MkNextState JcsDestroyed, [IntMsg False self worker value Nothing $ MsgRaw []]) 227 | False => (MkNextState state, build_bounce (IntMsg bounce src self coins Nothing body)) 228 | _ => (MkNextState state, build_bounce (IntMsg bounce src self coins Nothing body)) 229 | _ => (MkNextState state, Nil) 230 | job_working state _ = (MkNextState state, Nil) 231 | 232 | 233 | job_locked_on : JobContractStateLockedOn -> TxMessage -> (NextState, List TxMessage) 234 | job_locked_on state (IntMsg bounce src self coins _ body) = case believe_me $ to_data state of 235 | (JcdLockedOn poster desc value poster_key offer) => case body of 236 | Bounce _ => (MkNextState state, Nil) 237 | (JlmConfirm worker_key) => case offer == src of 238 | True => ((MkNextState $ JcsWorking job_working $ believe_me $ JcdWorking poster desc value poster_key worker_key), Nil) 239 | False => (MkNextState state, build_bounce (IntMsg bounce src self coins Nothing body)) 240 | _ => (MkNextState state, build_bounce (IntMsg bounce src self coins Nothing body)) 241 | _ => (MkNextState state, Nil) 242 | job_locked_on state _ = (MkNextState state, Nil) 243 | 244 | 245 | job_unlocked : JobContractStateUnlocked -> TxMessage -> (NextState, List TxMessage) 246 | job_unlocked state (IntMsg bounce src self coins _ body) = case believe_me $ to_data state of 247 | (JcdUnlocked poster desc value poster_key) => case body of 248 | Bounce _ => (MkNextState state, Nil) 249 | (JumUpdate new_desc new_value new_poster_key) => case poster == src of 250 | True => ((MkNextState $ JcsUnlocked job_unlocked $ believe_me $ JcdUnlocked poster new_desc new_value new_poster_key), Nil) 251 | False => (MkNextState state, build_bounce (IntMsg bounce src self coins Nothing body)) 252 | (JumLock offer) => case poster == src of 253 | True => ((MkNextState $ JcsLockedOn job_locked_on $ believe_me $ JcdLockedOn poster desc value poster_key offer), Nil) 254 | False => (MkNextState state, build_bounce (IntMsg bounce src self coins Nothing body)) 255 | _ => (MkNextState state, build_bounce (IntMsg bounce src self coins Nothing body)) 256 | _ => (MkNextState state, Nil) 257 | job_unlocked state _ = (MkNextState state, Nil) 258 | 259 | ---- 260 | 261 | {- 262 | check_invariant : NextState -> TxMessage -> Bool 263 | check_invariant state msg = state == (fst $ run_tvm state msg) 264 | 265 | build_ju : MessageAddr -> Type -> UintSeq 120 -> UintSeq 256 -> NextState 266 | build_ju poster desc value poster_key = MkNextState $ JcsUnlocked job_unlocked $ believe_me $ JcdUnlocked poster desc value poster_key 267 | 268 | theorem_ju_no_extin_processing : (cp : MessageAddr) -> (cd : Type) -> (cv : UintSeq 120) -> (cpk : UintSeq 256) 269 | -> (ExtInMsg ma mb mc md) -> (check_invariant (build_ju cp cd cv cpk) $ ExtInMsg ma mb mc md) = True 270 | theorem_ju_no_extin_processing _ _ _ _ _ = Refl 271 | 272 | theorem_ju_invariant_nonposter : (poster : MessageAddr) -> (any : MessageAddr) 273 | -> (cd : Type) -> (cv : UintSeq 120) -> (cpk : UintSeq 256) 274 | -> ( 275 | -> Not (poster == any) 276 | -> (check_invariant (build_ju poster cd cv cpk) 277 | -} 278 | 279 | ---- 280 | 281 | main : IO () 282 | main = do putStrLn "Theorems proved" 283 | putStrLn "Starting transactions loop" 284 | putStrLn "Transactions loop finished" 285 | -------------------------------------------------------------------------------- /interaction.tlb: -------------------------------------------------------------------------------- 1 | // Common user story = CUS. 2 | // CUS-1. Poster creates a job contract. Message is empty. 3 | job_unlocked$00 poster:MsgAddressInt value:uint64 desc:^Cell poster_key:uint256 4 | = JobContractData; 5 | 6 | // CUS-2. Analytic message indicating address of newly created job contract. 7 | // zlib.crc32(b'notify::contract') & 0x7FFFFFFF 8 | _#130850fc job_contract:MsgAddressInt value:uint64 desc:^Cell poster_key:uint256 9 | = InternalMsgBody; 10 | 11 | // CUS-3. Worker deploys an offer contract as plugin. 12 | offer_unlocked$00 job:MsgAddressInt worker:MsgAddressInt stake:uint64 desc:^Cell 13 | worker_key:uint256 short_job_hash:uint160 = OfferContractData; 14 | _ sig:uint512 sw:uint32 until:uint32 seqno:uint32 [1]:uint8 [0]:uint8 [0.05]:TON 15 | state_init:^OfferContractState body_init:^(()) = InternalMsgBody; 16 | 17 | // CUS-4. Analytic message indicating address of newly created offer contract. 18 | // zlib.crc32(b'notify::offer') & 0x7FFFFFFF 19 | _#18ceb1bf offer_contract:MsgAddressInt stake:uint64 desc:^Cell 20 | worker_key:uint256 short_job_hash:uint160 = InternalMsgBody; 21 | 22 | // CUS-5. Poster chooses an offer. 23 | lock_on_offer#000000AC offer_data_init:^OfferContractData 24 | addr:MsgAddressInt = InternalMsgBody; 25 | 26 | // CUS-6. Job contract locks. 27 | collapse#000000B1 current_short_hash:uint160 = InternalMsgBody; 28 | job_locked$01 offer:MsgAddressInt poster:MsgAddressInt value:uint64 desc:^Cell 29 | poster_key:uint256 = JobContractData; 30 | 31 | // CUS-7. Offer contract requests money and locks. 32 | take_stake#706c7567 query_id:uint64 stake:(VarUInteger 16) ext:hme_empty 33 | = InternalMsgBody; 34 | offer_locked$01 job:MsgAddressInt worker:MsgAddressInt stake:uint64 desc:^Cell 35 | worker_key:uint256 short_job_hash:uint160 = OfferContractData; 36 | 37 | // CUS-7-ERR. Offer contract specified by job poster does not exist. 38 | refuse_collapse#ffffffff [000000B1]:uint32 ... = InternalMsgBody; 39 | 40 | // CUS-8-OK. Wallet returns the money. 41 | give_stake#f06c7567 query_id:uint64 = InternalMsgBody; 42 | 43 | // CUS-9-OK. Offer merges into job contract. 44 | lock_success#000000AD worker:MsgAddressInt desc:^Cell worker_key:uint256 45 | = InternalMsgBody; 46 | unplug#64737472 query_id:uint64 = InternalMsgBody; 47 | 48 | // CUS-8-ERR. Insufficient funds on worker's wallet. 49 | refuse_give#ffffffff [706c7567]:uint32 query_id:uint64 ... = InternalMsgBody; 50 | 51 | // CUS-9-ERR. Offer contract unlocks itself and job contract. 52 | // offer_unlocked 53 | lock_failed#000000D4 = InternalMsgBody; 54 | // job_unlocked 55 | 56 | // CUS-10. Job contract essentially becomes multisig wallet. 57 | job_working$10 poster:MsgAddressInt worker:MsgAddressInt value:uint64 58 | poster_desc:^Cell worker_desc:^Cell 59 | keys:^[poster:uint256 worker:uint256 platform:uint256] 60 | = JobContractData; 61 | 62 | // CUS-11. Single-signed messages. 63 | _ min_nton:uint64 max_nton:uint64 = PayLim; 64 | _ [FFFF726C3A3A6A6F623A3A7630]:bits $00000 job:MsgAddressInt ton_range:PayLim 65 | = Signed; 66 | poster_proposal$00 sig:bits512 worker_ton_range:PayLim = Proposal; 67 | worker_proposal$01 sig:bits512 worker_ton_range:PayLim = Proposal; 68 | ratelance_proposal$10 sig:bits512 worker_ton_range:PayLim = Proposal; 69 | ton_vals_proposal$11 = Proposal; 70 | 71 | // Config parameter ID: zlib.crc32(b'ratelance::decisions') & 0x7FFFFFFF 72 | ton_vals_proposal#_ dec_by_job:(HashmapE MsgAddressInt (uint64,uint64)) 73 | = ConfigParam 1652841508; 74 | 75 | // Messages to `multisig_negotiation` address (normally the job itself) 76 | // zlib.crc32(b'op::negotiate_reward') & 0x7FFFFFFF 77 | negotiate_reward#4bed4ee8 proposal:^Proposal = InternalMsgBody; 78 | 79 | // CUS-12. Finishing work. 80 | finish_job#000000BB first_sig:^Proposal second_sig:^Proposal 81 | {first_sig::tag != second_sig::tag} 82 | = InternalMsgBody; 83 | 84 | // CUS-1,2-REV. Cancelling job. 85 | cancel_job#000000EB = InternalMsgBody; 86 | 87 | // CUS-3,4-REV. Cancelling offer. 88 | destruct#64737472 = InternalMsgBody; 89 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | class JobNotifications { 4 | static const LOAD_URL = 'https://dton.io/graphql'; 5 | static const LOAD_REQUEST = ` 6 | { 7 | transactions(address_friendly: "EQA__RATELANCE_______________________________JvN") { 8 | gen_utime in_msg_src_addr_workchain_id in_msg_src_addr_address_hex in_msg_body 9 | } 10 | } 11 | `; 12 | 13 | static async load() { 14 | 15 | } 16 | } -------------------------------------------------------------------------------- /web/assets/index-GKpznQ_X.css: -------------------------------------------------------------------------------- 1 | div.svelte-3lybgc{height:1px;border-top:var(--color) 1px solid;margin:var(--margin) 0}.pages.svelte-1w3fjj0{display:flex;border:#cc880040 1px solid;border-radius:8px 8px 4px 4px;padding:0 16px;box-shadow:0 1px 2px #46321429;font-weight:600}input.svelte-1w3fjj0{flex-grow:.1;margin:8px 12px 6px;-webkit-appearance:none;-moz-appearance:none;appearance:none;border:none}input.svelte-1w3fjj0:after{content:var(--text)}input.svelte-1w3fjj0:not(:disabled):hover{color:#80002c}input.svelte-1w3fjj0:checked{color:#b0503c;text-shadow:#a0402c16 1px 1px}.root.svelte-u61idj{min-width:400px;width:50%;min-height:200px;padding:32px;background-color:#fff}input.svelte-u61idj{font-size:16px;width:100%}input[type=radio].svelte-u61idj{-webkit-appearance:none;-moz-appearance:none;appearance:none;font-size:18px}input[type=radio].svelte-u61idj:after{content:var(--text)}.method.svelte-u61idj{display:block;border:none;border-top:#666 1px solid}.method.svelte-u61idj:not(:disabled):not(:checked):hover{color:#80002c}.method.svelte-u61idj:checked{color:#b0503c;text-shadow:#a0402c16 1px 1px}.wallet-versions.svelte-u61idj{display:flex}.wallet-ver.svelte-u61idj:not(:last-child){margin-right:12px}.wallet-ver.svelte-u61idj:not(:checked):hover{color:#2c0080}.wallet-ver.svelte-u61idj:checked{color:#3c50b0;text-shadow:#2c40a016 1px 1px}.show-hover.svelte-u61idj:not(:hover){background-color:#888;color:#888}.show-hover.svelte-u61idj{margin:0 0 8px;font-size:13px}.popup.svelte-f0v46i{position:fixed;left:0;right:0;top:0;bottom:0;background-color:#3338;display:flex;align-items:center;justify-content:center}.popup.svelte-f0v46i:empty{display:none;visibility:hidden}header.svelte-1eyc10s.svelte-1eyc10s{display:flex;margin:0 8px 8px}.icon.svelte-1eyc10s.svelte-1eyc10s{width:40px;height:40px;margin-right:8px;display:inline-block;vertical-align:top}header.svelte-1eyc10s>.svelte-1eyc10s{margin:8px}#logo.svelte-1eyc10s.svelte-1eyc10s{display:flex;align-items:center;color:#232328;height:100%}.fill-space.svelte-1eyc10s.svelte-1eyc10s{flex-grow:1}.ton-connect-v2.svelte-1eyc10s.svelte-1eyc10s{border:#cf988c 2px solid;border-radius:10px;padding:7px 17px;font-size:16px;background-color:#fff;color:#b0503c}span.svelte-t1qxhd.svelte-t1qxhd{font-size:14px;color:#71717a}a.svelte-t1qxhd.svelte-t1qxhd{color:#6b6fb0}.myaddress.svelte-t1qxhd a.svelte-t1qxhd{color:#6bb03f}.monospace.svelte-t1qxhd.svelte-t1qxhd{font-family:monospace}.job-container.svelte-1pkob5b{border:#cc880040 1px solid;border-radius:4px 4px 8px 8px;box-shadow:0 1px 2px #46321429;padding:12px 16px;font-size:17px;margin-bottom:16px}.job-error.svelte-1pkob5b{border:#ff223340 1px solid;border-radius:4px 4px 8px 8px;box-shadow:0 1px 2px #460a1429;background-color:#ff223310;color:#602328;padding:24px;font-size:17px}.job-poster.svelte-1pkob5b{font-size:14px;color:#71717a}.price.svelte-1pkob5b{margin-left:auto;margin-right:24px;margin-top:8px;width:fit-content;font-weight:800;color:#4080c0;border-right:#4080c0 2px solid;padding-right:12px}.job-description.svelte-1pkob5b{border-left:#b0503c 2px solid;padding-left:12px;margin-top:8px}.job-description.svelte-1pkob5b p:first-child:first-line{font-weight:800;font-size:22px}.job-description.svelte-1pkob5b p{margin-top:0}.controls.svelte-1pkob5b{margin-top:4px;border-top:#444 1px solid;padding-top:8px}.article.svelte-1fepyk7{border:#cc880040 1px solid;border-radius:4px 4px 8px 8px;box-shadow:0 1px 2px #46321429;padding:24px;font-size:17px}.job-container.svelte-1tuh96e{border:#cc880040 1px solid;border-radius:4px 4px 8px 8px;box-shadow:0 1px 2px #46321429;padding:12px 16px;font-size:17px;margin-bottom:16px}.job-error.svelte-1tuh96e{border:#ff223340 1px solid;border-radius:4px 4px 8px 8px;box-shadow:0 1px 2px #460a1429;background-color:#ff223310;color:#602328;padding:24px;font-size:17px}.job-poster.svelte-1tuh96e{font-size:14px;color:#71717a}.price.svelte-1tuh96e{margin-left:auto;margin-right:24px;margin-top:8px;width:fit-content;font-weight:800;color:#4080c0;border-right:#4080c0 2px solid;padding-right:12px}.job-description.svelte-1tuh96e{border-left:#b0503c 2px solid;padding-left:12px;margin-top:8px}.job-description.svelte-1tuh96e p:first-child:first-line{font-weight:800;font-size:22px}.job-description.svelte-1tuh96e p{margin-top:0}.width-limit.svelte-1roawt3{max-width:1200px;margin:0 auto}.h-spacer.svelte-1roawt3{height:24px} 2 | -------------------------------------------------------------------------------- /web/global.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | position: relative; 3 | width: 100%; 4 | height: 100%; 5 | } 6 | 7 | body { 8 | color: #333; 9 | margin: 0; 10 | padding: 16px; 11 | box-sizing: border-box; 12 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; 13 | min-width: 320px; 14 | min-height: 100vh; 15 | } 16 | 17 | a { 18 | color: rgb(0,100,200); 19 | text-decoration: none; 20 | } 21 | 22 | a:hover { 23 | text-decoration: underline; 24 | } 25 | 26 | a:visited { 27 | color: rgb(0,80,160); 28 | } 29 | 30 | label { 31 | display: block; 32 | } 33 | 34 | input, button, select, textarea { 35 | font-family: inherit; 36 | font-size: inherit; 37 | -webkit-padding: 0.4em 0; 38 | padding: 0.4em; 39 | margin: 0 0 0.5em 0; 40 | box-sizing: border-box; 41 | border: 1px solid #ccc; 42 | border-radius: 2px; 43 | } 44 | 45 | input:disabled { 46 | color: #ccc; 47 | } 48 | :root { 49 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 50 | line-height: 1.5; 51 | font-weight: 400; 52 | 53 | color-scheme: light dark; 54 | color: rgba(255, 255, 255, 0.87); 55 | background-color: #242424; 56 | 57 | font-synthesis: none; 58 | text-rendering: optimizeLegibility; 59 | -webkit-font-smoothing: antialiased; 60 | -moz-osx-font-smoothing: grayscale; 61 | } 62 | 63 | a { 64 | font-weight: 500; 65 | color: #646cff; 66 | text-decoration: inherit; 67 | } 68 | a:hover { 69 | color: #535bf2; 70 | } 71 | 72 | h1 { 73 | font-size: 1.8em; 74 | line-height: 1.1; 75 | } 76 | 77 | button { 78 | border-radius: 8px; 79 | border: 1px solid transparent; 80 | padding: 0.6em 1.2em; 81 | font-size: 1em; 82 | font-weight: 500; 83 | font-family: inherit; 84 | background-color: #1a1a1a; 85 | cursor: pointer; 86 | transition: border-color 0.25s; 87 | } 88 | button:hover { 89 | border-color: #646cff; 90 | } 91 | button:focus, 92 | button:focus-visible { 93 | outline: 4px auto -webkit-focus-ring-color; 94 | } 95 | 96 | @media (prefers-color-scheme: light) { 97 | :root { 98 | color: #213547; 99 | background-color: #ffffff; 100 | } 101 | a:hover { 102 | color: #747bff; 103 | } 104 | button { 105 | background-color: #f9f9f9; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /web/images/cube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgramCrafter/ratelance/2de56f4f96acf0f9e1e5b0e60ae0ad846ba50170/web/images/cube.png -------------------------------------------------------------------------------- /web/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgramCrafter/ratelance/2de56f4f96acf0f9e1e5b0e60ae0ad846ba50170/web/images/favicon.ico -------------------------------------------------------------------------------- /web/images/ratelance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgramCrafter/ratelance/2de56f4f96acf0f9e1e5b0e60ae0ad846ba50170/web/images/ratelance.png -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Svelte app 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /web/vite.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------