# Merged Python Files #### bet.py #! /usr/bin/python3 """ Datastreams are identified by the address that publishes them, and referenced in transaction outputs. For CFD leverage, 1x = 5040, 2x = 10080, etc.: 5040 is a superior highly composite number and a colossally abundant number, and has 1-10, 12 as factors. All wagers are in XCP. Expiring a bet match doesn’t re‐open the constituent bets. (So all bets may be ‘filled’.) """ import struct import decimal import json D = decimal.Decimal import time import logging logger = logging.getLogger(__name__) from counterpartylib.lib import config from counterpartylib.lib import exceptions from counterpartylib.lib import util from counterpartylib.lib import log from counterpartylib.lib import message_type FORMAT = '>HIQQdII' LENGTH = 2 + 4 + 8 + 8 + 8 + 4 + 4 ID = 40 def initialise (db): cursor = db.cursor() # Bets. cursor.execute('''CREATE TABLE IF NOT EXISTS bets( tx_index INTEGER UNIQUE, tx_hash TEXT UNIQUE, block_index INTEGER, source TEXT, feed_address TEXT, bet_type INTEGER, deadline INTEGER, wager_quantity INTEGER, wager_remaining INTEGER, counterwager_quantity INTEGER, counterwager_remaining INTEGER, target_value REAL, leverage INTEGER, expiration INTEGER, expire_index INTEGER, fee_fraction_int INTEGER, status TEXT, FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index), PRIMARY KEY (tx_index, tx_hash)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS block_index_idx ON bets (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS index_hash_idx ON bets (tx_index, tx_hash) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS expire_idx ON bets (status, expire_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS feed_valid_bettype_idx ON bets (feed_address, status, bet_type) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS source_idx ON bets (source) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS status_idx ON bets (status) ''') # Bet Matches cursor.execute('''CREATE TABLE IF NOT EXISTS bet_matches( id TEXT PRIMARY KEY, tx0_index INTEGER, tx0_hash TEXT, tx0_address TEXT, tx1_index INTEGER, tx1_hash TEXT, tx1_address TEXT, tx0_bet_type INTEGER, tx1_bet_type INTEGER, feed_address TEXT, initial_value INTEGER, deadline INTEGER, target_value REAL, leverage INTEGER, forward_quantity INTEGER, backward_quantity INTEGER, tx0_block_index INTEGER, tx1_block_index INTEGER, block_index INTEGER, tx0_expiration INTEGER, tx1_expiration INTEGER, match_expire_index INTEGER, fee_fraction_int INTEGER, status TEXT, FOREIGN KEY (tx0_index, tx0_hash, tx0_block_index) REFERENCES transactions(tx_index, tx_hash, block_index), FOREIGN KEY (tx1_index, tx1_hash, tx1_block_index) REFERENCES transactions(tx_index, tx_hash, block_index)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS match_expire_idx ON bet_matches (status, match_expire_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS valid_feed_idx ON bet_matches (feed_address, status) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS id_idx ON bet_matches (id) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS tx0_address_idx ON bet_matches (tx0_address) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS tx1_address_idx ON bet_matches (tx1_address) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS status_idx ON bet_matches (status) ''') # Bet Expirations cursor.execute('''CREATE TABLE IF NOT EXISTS bet_expirations( bet_index INTEGER PRIMARY KEY, bet_hash TEXT UNIQUE, source TEXT, block_index INTEGER, FOREIGN KEY (block_index) REFERENCES blocks(block_index), FOREIGN KEY (bet_index, bet_hash) REFERENCES bets(tx_index, tx_hash)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS block_index_idx ON bet_expirations (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS source_idx ON bet_expirations (source) ''') # Bet Match Expirations cursor.execute('''CREATE TABLE IF NOT EXISTS bet_match_expirations( bet_match_id TEXT PRIMARY KEY, tx0_address TEXT, tx1_address TEXT, block_index INTEGER, FOREIGN KEY (bet_match_id) REFERENCES bet_matches(id), FOREIGN KEY (block_index) REFERENCES blocks(block_index)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS block_index_idx ON bet_match_expirations (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS tx0_address_idx ON bet_match_expirations (tx0_address) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS tx1_address_idx ON bet_match_expirations (tx1_address) ''') # Bet Match Resolutions cursor.execute('''CREATE TABLE IF NOT EXISTS bet_match_resolutions( bet_match_id TEXT PRIMARY KEY, bet_match_type_id INTEGER, block_index INTEGER, winner TEXT, settled BOOL, bull_credit INTEGER, bear_credit INTEGER, escrow_less_fee INTEGER, fee INTEGER, FOREIGN KEY (bet_match_id) REFERENCES bet_matches(id), FOREIGN KEY (block_index) REFERENCES blocks(block_index)) ''') def cancel_bet (db, bet, status, block_index): cursor = db.cursor() # Update status of bet. bindings = { 'status': status, 'tx_hash': bet['tx_hash'] } sql='update bets set status = :status where tx_hash = :tx_hash' cursor.execute(sql, bindings) log.message(db, block_index, 'update', 'bets', bindings) util.credit(db, bet['source'], config.XCP, bet['wager_remaining'], action='recredit wager remaining', event=bet['tx_hash']) cursor = db.cursor() def cancel_bet_match (db, bet_match, status, block_index): # Does not re‐open, re‐fill, etc. constituent bets. cursor = db.cursor() # Recredit tx0 address. util.credit(db, bet_match['tx0_address'], config.XCP, bet_match['forward_quantity'], action='recredit forward quantity', event=bet_match['id']) # Recredit tx1 address. util.credit(db, bet_match['tx1_address'], config.XCP, bet_match['backward_quantity'], action='recredit backward quantity', event=bet_match['id']) # Update status of bet match. bindings = { 'status': status, 'bet_match_id': bet_match['id'] } sql='update bet_matches set status = :status where id = :bet_match_id' cursor.execute(sql, bindings) log.message(db, block_index, 'update', 'bet_matches', bindings) cursor.close() def get_fee_fraction (db, feed_address): '''Get fee fraction from last broadcast from the feed_address address. ''' cursor = db.cursor() broadcasts = list(cursor.execute('''SELECT * FROM broadcasts WHERE (status = ? AND source = ?) ORDER BY tx_index ASC''', ('valid', feed_address))) cursor.close() if broadcasts: last_broadcast = broadcasts[-1] fee_fraction_int = last_broadcast['fee_fraction_int'] if fee_fraction_int: return fee_fraction_int / 1e8 else: return 0 else: return 0 def validate (db, source, feed_address, bet_type, deadline, wager_quantity, counterwager_quantity, target_value, leverage, expiration, block_index): problems = [] if leverage is None: leverage = 5040 # For SQLite3 if wager_quantity > config.MAX_INT or counterwager_quantity > config.MAX_INT or bet_type > config.MAX_INT \ or deadline > config.MAX_INT or leverage > config.MAX_INT or block_index + expiration > config.MAX_INT: problems.append('integer overflow') # Look at feed to be bet on. cursor = db.cursor() broadcasts = list(cursor.execute('''SELECT * FROM broadcasts WHERE (status = ? AND source = ?) ORDER BY tx_index ASC''', ('valid', feed_address))) cursor.close() if not broadcasts: problems.append('feed doesn’t exist') elif not broadcasts[-1]['text']: problems.append('feed is locked') elif broadcasts[-1]['timestamp'] >= deadline: problems.append('deadline in that feed’s past') if not bet_type in (0, 1, 2, 3): problems.append('unknown bet type') # Valid leverage level? if leverage != 5040 and bet_type in (2,3): # Equal, NotEqual problems.append('leverage used with Equal or NotEqual') if leverage < 5040 and not bet_type in (0,1): # BullCFD, BearCFD (fractional leverage makes sense precisely with CFDs) problems.append('leverage level too low') if bet_type in (0,1): # BullCFD, BearCFD if block_index >= 312350: # Protocol change. problems.append('CFDs temporarily disabled') if not isinstance(wager_quantity, int): problems.append('wager_quantity must be in satoshis') return problems, leverage if not isinstance(counterwager_quantity, int): problems.append('counterwager_quantity must be in satoshis') return problems, leverage if not isinstance(expiration, int): problems.append('expiration must be expressed as an integer block delta') return problems, leverage if wager_quantity <= 0: problems.append('non‐positive wager') if counterwager_quantity <= 0: problems.append('non‐positive counterwager') if deadline < 0: problems.append('negative deadline') if expiration < 0: problems.append('negative expiration') if expiration == 0 and not (block_index >= 317500 or config.TESTNET or config.REGTEST): # Protocol change. problems.append('zero expiration') if target_value: if bet_type in (0,1): # BullCFD, BearCFD problems.append('CFDs have no target value') if target_value < 0: problems.append('negative target value') if expiration > config.MAX_EXPIRATION: problems.append('expiration overflow') return problems, leverage def compose (db, source, feed_address, bet_type, deadline, wager_quantity, counterwager_quantity, target_value, leverage, expiration): if util.get_balance(db, source, config.XCP) < wager_quantity: raise exceptions.ComposeError('insufficient funds') problems, leverage = validate(db, source, feed_address, bet_type, deadline, wager_quantity, counterwager_quantity, target_value, leverage, expiration, util.CURRENT_BLOCK_INDEX) if util.date_passed(deadline): problems.append('deadline passed') if problems: raise exceptions.ComposeError(problems) data = message_type.pack(ID) data += struct.pack(FORMAT, bet_type, deadline, wager_quantity, counterwager_quantity, target_value, leverage, expiration) return (source, [(feed_address, None)], data) def parse (db, tx, message): bet_parse_cursor = db.cursor() # Unpack message. try: if len(message) != LENGTH: raise exceptions.UnpackError (bet_type, deadline, wager_quantity, counterwager_quantity, target_value, leverage, expiration) = struct.unpack(FORMAT, message) status = 'open' except (exceptions.UnpackError, struct.error): (bet_type, deadline, wager_quantity, counterwager_quantity, target_value, leverage, expiration, fee_fraction_int) = 0, 0, 0, 0, 0, 0, 0, 0 status = 'invalid: could not unpack' odds, fee_fraction = 0, 0 feed_address = tx['destination'] if status == 'open': try: odds = util.price(wager_quantity, counterwager_quantity) except ZeroDivisionError: odds = 0 fee_fraction = get_fee_fraction(db, feed_address) # Overbet bet_parse_cursor.execute('''SELECT * FROM balances \ WHERE (address = ? AND asset = ?)''', (tx['source'], config.XCP)) balances = list(bet_parse_cursor) if not balances: wager_quantity = 0 else: balance = balances[0]['quantity'] if balance < wager_quantity: wager_quantity = balance counterwager_quantity = int(util.price(wager_quantity, odds)) problems, leverage = validate(db, tx['source'], feed_address, bet_type, deadline, wager_quantity, counterwager_quantity, target_value, leverage, expiration, tx['block_index']) if problems: status = 'invalid: ' + '; '.join(problems) # Debit quantity wagered. (Escrow.) if status == 'open': util.debit(db, tx['source'], config.XCP, wager_quantity, action='bet', event=tx['tx_hash']) # Add parsed transaction to message-type–specific table. bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'source': tx['source'], 'feed_address': feed_address, 'bet_type': bet_type, 'deadline': deadline, 'wager_quantity': wager_quantity, 'wager_remaining': wager_quantity, 'counterwager_quantity': counterwager_quantity, 'counterwager_remaining': counterwager_quantity, 'target_value': target_value, 'leverage': leverage, 'expiration': expiration, 'expire_index': tx['block_index'] + expiration, 'fee_fraction_int': fee_fraction * 1e8, 'status': status, } if "integer overflow" not in status: sql = 'insert into bets values(:tx_index, :tx_hash, :block_index, :source, :feed_address, :bet_type, :deadline, :wager_quantity, :wager_remaining, :counterwager_quantity, :counterwager_remaining, :target_value, :leverage, :expiration, :expire_index, :fee_fraction_int, :status)' bet_parse_cursor.execute(sql, bindings) else: logger.warn("Not storing [bet] tx [%s]: %s" % (tx['tx_hash'], status)) logger.debug("Bindings: %s" % (json.dumps(bindings), )) # Match. if status == 'open' and tx['block_index'] != config.MEMPOOL_BLOCK_INDEX: match(db, tx) bet_parse_cursor.close() def match (db, tx): cursor = db.cursor() # Get bet in question. bets = list(cursor.execute('''SELECT * FROM bets\ WHERE (tx_index = ? AND status = ?)''', (tx['tx_index'], 'open'))) if not bets: cursor.close() return else: assert len(bets) == 1 tx1 = bets[0] # Get counterbet_type. if tx1['bet_type'] % 2: counterbet_type = tx1['bet_type'] - 1 else: counterbet_type = tx1['bet_type'] + 1 feed_address = tx1['feed_address'] cursor.execute('''SELECT * FROM bets\ WHERE (feed_address=? AND status=? AND bet_type=?)''', (tx1['feed_address'], 'open', counterbet_type)) tx1_wager_remaining = tx1['wager_remaining'] tx1_counterwager_remaining = tx1['counterwager_remaining'] bet_matches = cursor.fetchall() if tx['block_index'] > 284500 or config.TESTNET or config.REGTEST: # Protocol change. sorted(bet_matches, key=lambda x: x['tx_index']) # Sort by tx index second. sorted(bet_matches, key=lambda x: util.price(x['wager_quantity'], x['counterwager_quantity'])) # Sort by price first. tx1_status = tx1['status'] for tx0 in bet_matches: if tx1_status != 'open': break logger.debug('Considering: ' + tx0['tx_hash']) tx0_wager_remaining = tx0['wager_remaining'] tx0_counterwager_remaining = tx0['counterwager_remaining'] # Bet types must be opposite. if counterbet_type != tx0['bet_type']: logger.debug('Skipping: bet types disagree.') continue # Leverages must agree exactly if tx0['leverage'] != tx1['leverage']: logger.debug('Skipping: leverages disagree.') continue # Target values must agree exactly. if tx0['target_value'] != tx1['target_value']: logger.debug('Skipping: target values disagree.') continue # Fee fractions must agree exactly. if tx0['fee_fraction_int'] != tx1['fee_fraction_int']: logger.debug('Skipping: fee fractions disagree.') continue # Deadlines must agree exactly. if tx0['deadline'] != tx1['deadline']: logger.debug('Skipping: deadlines disagree.') continue # If the odds agree, make the trade. The found order sets the odds, # and they trade as much as they can. tx0_odds = util.price(tx0['wager_quantity'], tx0['counterwager_quantity']) tx0_inverse_odds = util.price(tx0['counterwager_quantity'], tx0['wager_quantity']) tx1_odds = util.price(tx1['wager_quantity'], tx1['counterwager_quantity']) if tx['block_index'] < 286000: tx0_inverse_odds = util.price(1, tx0_odds) # Protocol change. logger.debug('Tx0 Inverse Odds: {}; Tx1 Odds: {}'.format(float(tx0_inverse_odds), float(tx1_odds))) if tx0_inverse_odds > tx1_odds: logger.debug('Skipping: price mismatch.') else: logger.debug('Potential forward quantities: {}, {}'.format(tx0_wager_remaining, int(util.price(tx1_wager_remaining, tx1_odds)))) forward_quantity = int(min(tx0_wager_remaining, int(util.price(tx1_wager_remaining, tx1_odds)))) logger.debug('Forward Quantity: {}'.format(forward_quantity)) backward_quantity = round(forward_quantity / tx0_odds) logger.debug('Backward Quantity: {}'.format(backward_quantity)) if not forward_quantity: logger.debug('Skipping: zero forward quantity.') continue if tx1['block_index'] >= 286500 or config.TESTNET or config.REGTEST: # Protocol change. if not backward_quantity: logger.debug('Skipping: zero backward quantity.') continue bet_match_id = util.make_id(tx0['tx_hash'], tx1['tx_hash']) # Debit the order. # Counterwager remainings may be negative. tx0_wager_remaining = tx0_wager_remaining - forward_quantity tx0_counterwager_remaining = tx0_counterwager_remaining - backward_quantity tx1_wager_remaining = tx1_wager_remaining - backward_quantity tx1_counterwager_remaining = tx1_counterwager_remaining - forward_quantity # tx0 tx0_status = 'open' if tx0_wager_remaining <= 0 or tx0_counterwager_remaining <= 0: # Fill order, and recredit give_remaining. tx0_status = 'filled' util.credit(db, tx0['source'], config.XCP, tx0_wager_remaining, event=tx1['tx_hash'], action='filled') bindings = { 'wager_remaining': tx0_wager_remaining, 'counterwager_remaining': tx0_counterwager_remaining, 'status': tx0_status, 'tx_hash': tx0['tx_hash'] } sql='update bets set wager_remaining = :wager_remaining, counterwager_remaining = :counterwager_remaining, status = :status where tx_hash = :tx_hash' cursor.execute(sql, bindings) log.message(db, tx['block_index'], 'update', 'bets', bindings) if tx1['block_index'] >= 292000 or config.TESTNET or config.REGTEST: # Protocol change if tx1_wager_remaining <= 0 or tx1_counterwager_remaining <= 0: # Fill order, and recredit give_remaining. tx1_status = 'filled' util.credit(db, tx1['source'], config.XCP, tx1_wager_remaining, event=tx1['tx_hash'], action='filled') # tx1 bindings = { 'wager_remaining': tx1_wager_remaining, 'counterwager_remaining': tx1_counterwager_remaining, 'status': tx1_status, 'tx_hash': tx1['tx_hash'] } sql='update bets set wager_remaining = :wager_remaining, counterwager_remaining = :counterwager_remaining, status = :status where tx_hash = :tx_hash' cursor.execute(sql, bindings) log.message(db, tx['block_index'], 'update', 'bets', bindings) # Get last value of feed. broadcasts = list(cursor.execute('''SELECT * FROM broadcasts WHERE (status = ? AND source = ?) ORDER BY tx_index ASC''', ('valid', feed_address))) initial_value = broadcasts[-1]['value'] # Record bet fulfillment. bindings = { 'id': util.make_id(tx0['tx_hash'], tx['tx_hash']), 'tx0_index': tx0['tx_index'], 'tx0_hash': tx0['tx_hash'], 'tx0_address': tx0['source'], 'tx1_index': tx1['tx_index'], 'tx1_hash': tx1['tx_hash'], 'tx1_address': tx1['source'], 'tx0_bet_type': tx0['bet_type'], 'tx1_bet_type': tx1['bet_type'], 'feed_address': tx1['feed_address'], 'initial_value': initial_value, 'deadline': tx1['deadline'], 'target_value': tx1['target_value'], 'leverage': tx1['leverage'], 'forward_quantity': forward_quantity, 'backward_quantity': backward_quantity, 'tx0_block_index': tx0['block_index'], 'tx1_block_index': tx1['block_index'], 'block_index': max(tx0['block_index'], tx1['block_index']), 'tx0_expiration': tx0['expiration'], 'tx1_expiration': tx1['expiration'], 'match_expire_index': min(tx0['expire_index'], tx1['expire_index']), 'fee_fraction_int': tx1['fee_fraction_int'], 'status': 'pending', } sql='insert into bet_matches values(:id, :tx0_index, :tx0_hash, :tx0_address, :tx1_index, :tx1_hash, :tx1_address, :tx0_bet_type, :tx1_bet_type, :feed_address, :initial_value, :deadline, :target_value, :leverage, :forward_quantity, :backward_quantity, :tx0_block_index, :tx1_block_index, :block_index, :tx0_expiration, :tx1_expiration, :match_expire_index, :fee_fraction_int, :status)' cursor.execute(sql, bindings) cursor.close() return def expire (db, block_index, block_time): cursor = db.cursor() # Expire bets and give refunds for the quantity wager_remaining. cursor.execute('''SELECT * FROM bets \ WHERE (status = ? AND expire_index < ?)''', ('open', block_index)) for bet in cursor.fetchall(): cancel_bet(db, bet, 'expired', block_index) # Record bet expiration. bindings = { 'bet_index': bet['tx_index'], 'bet_hash': bet['tx_hash'], 'source': bet['source'], 'block_index': block_index } sql='insert into bet_expirations values(:bet_index, :bet_hash, :source, :block_index)' cursor.execute(sql, bindings) # Expire bet matches whose deadline is more than two weeks before the current block time. cursor.execute('''SELECT * FROM bet_matches \ WHERE (status = ? AND deadline < ?)''', ('pending', block_time - config.TWO_WEEKS)) for bet_match in cursor.fetchall(): cancel_bet_match(db, bet_match, 'expired', block_index) # Record bet match expiration. bindings = { 'bet_match_id': bet_match['id'], 'tx0_address': bet_match['tx0_address'], 'tx1_address': bet_match['tx1_address'], 'block_index': block_index } sql='insert into bet_match_expirations values(:bet_match_id, :tx0_address, :tx1_address, :block_index)' cursor.execute(sql, bindings) cursor.close() # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 #### broadcast.py #! /usr/bin/python3 """ Broadcast a message, with or without a price. Multiple messages per block are allowed. Bets are be made on the 'timestamp' field, and not the block index. An address is a feed of broadcasts. Feeds may be locked with a broadcast whose text field is identical to ‘lock’ (case insensitive). Bets on a feed reference the address that is the source of the feed in an output which includes the (latest) required fee. Broadcasts without a price may not be used for betting. Broadcasts about events with a small number of possible outcomes (e.g. sports games), should be written, for example, such that a price of 1 XCP means one outcome, 2 XCP means another, etc., which schema should be described in the 'text' field. fee_fraction: .05 XCP means 5%. It may be greater than 1, however; but because it is stored as a four‐byte integer, it may not be greater than about 42. """ import struct import decimal D = decimal.Decimal from fractions import Fraction import json import logging logger = logging.getLogger(__name__) from bitcoin.core import VarIntSerializer from counterpartylib.lib import exceptions from counterpartylib.lib import config from counterpartylib.lib import util from counterpartylib.lib import log from counterpartylib.lib import message_type from . import (bet) FORMAT = '>IdI' LENGTH = 4 + 8 + 4 ID = 30 # NOTE: Pascal strings are used for storing texts for backwards‐compatibility. def initialise(db): cursor = db.cursor() cursor.execute('''CREATE TABLE IF NOT EXISTS broadcasts( tx_index INTEGER PRIMARY KEY, tx_hash TEXT UNIQUE, block_index INTEGER, source TEXT, timestamp INTEGER, value REAL, fee_fraction_int INTEGER, text TEXT, locked BOOL, status TEXT, FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS block_index_idx ON broadcasts (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS status_source_idx ON broadcasts (status, source) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS status_source_index_idx ON broadcasts (status, source, tx_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS timestamp_idx ON broadcasts (timestamp) ''') def validate (db, source, timestamp, value, fee_fraction_int, text, block_index): problems = [] # For SQLite3 if timestamp > config.MAX_INT or value > config.MAX_INT or fee_fraction_int > config.MAX_INT: problems.append('integer overflow') if util.enabled('max_fee_fraction'): if fee_fraction_int >= config.UNIT: problems.append('fee fraction greater than or equal to 1') else: if fee_fraction_int > 4294967295: problems.append('fee fraction greater than 42.94967295') if timestamp < 0: problems.append('negative timestamp') if not source: problems.append('null source address') # Check previous broadcast in this feed. cursor = db.cursor() broadcasts = list(cursor.execute('''SELECT * FROM broadcasts WHERE (status = ? AND source = ?) ORDER BY tx_index ASC''', ('valid', source))) cursor.close() if broadcasts: last_broadcast = broadcasts[-1] if last_broadcast['locked']: problems.append('locked feed') elif timestamp <= last_broadcast['timestamp']: problems.append('feed timestamps not monotonically increasing') if not (block_index >= 317500 or config.TESTNET or config.REGTEST): # Protocol change. if len(text) > 52: problems.append('text too long') if util.enabled('options_require_memo') and text and text.lower().startswith('options'): try: # Check for options and if they are valid. options = util.parse_options_from_string(text) if options is not False: util.validate_address_options(options) except exceptions.OptionsError as e: problems.append(str(e)) return problems def compose (db, source, timestamp, value, fee_fraction, text): # Store the fee fraction as an integer. fee_fraction_int = int(fee_fraction * 1e8) problems = validate(db, source, timestamp, value, fee_fraction_int, text, util.CURRENT_BLOCK_INDEX) if problems: raise exceptions.ComposeError(problems) data = message_type.pack(ID) # always use custom length byte instead of problematic usage of 52p format and make sure to encode('utf-8') for length if util.enabled('broadcast_pack_text'): data += struct.pack(FORMAT, timestamp, value, fee_fraction_int) data += VarIntSerializer.serialize(len(text.encode('utf-8'))) data += text.encode('utf-8') else: if len(text) <= 52: curr_format = FORMAT + '{}p'.format(len(text) + 1) else: curr_format = FORMAT + '{}s'.format(len(text)) data += struct.pack(curr_format, timestamp, value, fee_fraction_int, text.encode('utf-8')) return (source, [], data) def parse (db, tx, message): cursor = db.cursor() # Unpack message. try: if util.enabled('broadcast_pack_text', tx['block_index']): timestamp, value, fee_fraction_int, rawtext = struct.unpack(FORMAT + '{}s'.format(len(message) - LENGTH), message) textlen = VarIntSerializer.deserialize(rawtext) if textlen == 0: text = b'' else: text = rawtext[-textlen:] assert len(text) == textlen else: if len(message) - LENGTH <= 52: curr_format = FORMAT + '{}p'.format(len(message) - LENGTH) else: curr_format = FORMAT + '{}s'.format(len(message) - LENGTH) timestamp, value, fee_fraction_int, text = struct.unpack(curr_format, message) try: text = text.decode('utf-8') except UnicodeDecodeError: text = '' status = 'valid' except (struct.error) as e: timestamp, value, fee_fraction_int, text = 0, None, 0, None status = 'invalid: could not unpack' if status == 'valid': # For SQLite3 timestamp = min(timestamp, config.MAX_INT) value = min(value, config.MAX_INT) problems = validate(db, tx['source'], timestamp, value, fee_fraction_int, text, tx['block_index']) if problems: status = 'invalid: ' + '; '.join(problems) # Lock? lock = False if text and text.lower() == 'lock': lock = True timestamp, value, fee_fraction_int, text = 0, None, None, None else: lock = False # Add parsed transaction to message-type–specific table. bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'source': tx['source'], 'timestamp': timestamp, 'value': value, 'fee_fraction_int': fee_fraction_int, 'text': text, 'locked': lock, 'status': status, } if "integer overflow" not in status: sql = 'insert into broadcasts values(:tx_index, :tx_hash, :block_index, :source, :timestamp, :value, :fee_fraction_int, :text, :locked, :status)' cursor.execute(sql, bindings) else: logger.warn("Not storing [broadcast] tx [%s]: %s" % (tx['tx_hash'], status)) logger.debug("Bindings: %s" % (json.dumps(bindings), )) # stop processing if broadcast is invalid for any reason if util.enabled('broadcast_invalid_check') and status != 'valid': return # Options? Should not fail to parse due to above checks. if util.enabled('options_require_memo') and text and text.lower().startswith('options'): options = util.parse_options_from_string(text) if options is not False: op_bindings = { 'block_index': tx['block_index'], 'address': tx['source'], 'options': options } sql = 'insert or replace into addresses(address, options, block_index) values(:address, :options, :block_index)' cursor = db.cursor() cursor.execute(sql, op_bindings) # Negative values (default to ignore). if value is None or value < 0: # Cancel Open Bets? if value == -2: cursor.execute('''SELECT * FROM bets \ WHERE (status = ? AND feed_address = ?)''', ('open', tx['source'])) for i in list(cursor): bet.cancel_bet(db, i, 'dropped', tx['block_index']) # Cancel Pending Bet Matches? if value == -3: cursor.execute('''SELECT * FROM bet_matches \ WHERE (status = ? AND feed_address = ?)''', ('pending', tx['source'])) for bet_match in list(cursor): bet.cancel_bet_match(db, bet_match, 'dropped', tx['block_index']) cursor.close() return # stop processing if broadcast is invalid for any reason # @TODO: remove this check once broadcast_invalid_check has been activated if util.enabled('max_fee_fraction') and status != 'valid': return # Handle bet matches that use this feed. cursor.execute('''SELECT * FROM bet_matches \ WHERE (status=? AND feed_address=?) ORDER BY tx1_index ASC, tx0_index ASC''', ('pending', tx['source'])) for bet_match in cursor.fetchall(): broadcast_bet_match_cursor = db.cursor() bet_match_id = util.make_id(bet_match['tx0_hash'], bet_match['tx1_hash']) bet_match_status = None # Calculate total funds held in escrow and total fee to be paid if # the bet match is settled. Escrow less fee is amount to be paid back # to betters. total_escrow = bet_match['forward_quantity'] + bet_match['backward_quantity'] if util.enabled('inmutable_fee_fraction'): fee_fraction = bet_match['fee_fraction_int'] / config.UNIT else: fee_fraction = fee_fraction_int / config.UNIT fee = int(fee_fraction * total_escrow) # Truncate. escrow_less_fee = total_escrow - fee # Get known bet match type IDs. cfd_type_id = util.BET_TYPE_ID['BullCFD'] + util.BET_TYPE_ID['BearCFD'] equal_type_id = util.BET_TYPE_ID['Equal'] + util.BET_TYPE_ID['NotEqual'] # Get the bet match type ID of this bet match. bet_match_type_id = bet_match['tx0_bet_type'] + bet_match['tx1_bet_type'] # Contract for difference, with determinate settlement date. if bet_match_type_id == cfd_type_id: # Recognise tx0, tx1 as the bull, bear (in the right direction). if bet_match['tx0_bet_type'] < bet_match['tx1_bet_type']: bull_address = bet_match['tx0_address'] bear_address = bet_match['tx1_address'] bull_escrow = bet_match['forward_quantity'] bear_escrow = bet_match['backward_quantity'] else: bull_address = bet_match['tx1_address'] bear_address = bet_match['tx0_address'] bull_escrow = bet_match['backward_quantity'] bear_escrow = bet_match['forward_quantity'] leverage = Fraction(bet_match['leverage'], 5040) initial_value = bet_match['initial_value'] bear_credit = bear_escrow - (value - initial_value) * leverage * config.UNIT bull_credit = escrow_less_fee - bear_credit bear_credit = round(bear_credit) bull_credit = round(bull_credit) # Liquidate, as necessary. if bull_credit >= escrow_less_fee or bull_credit <= 0: if bull_credit >= escrow_less_fee: bull_credit = escrow_less_fee bear_credit = 0 bet_match_status = 'settled: liquidated for bull' util.credit(db, bull_address, config.XCP, bull_credit, action='bet {}'.format(bet_match_status), event=tx['tx_hash']) elif bull_credit <= 0: bull_credit = 0 bear_credit = escrow_less_fee bet_match_status = 'settled: liquidated for bear' util.credit(db, bear_address, config.XCP, bear_credit, action='bet {}'.format(bet_match_status), event=tx['tx_hash']) # Pay fee to feed. util.credit(db, bet_match['feed_address'], config.XCP, fee, action='feed fee', event=tx['tx_hash']) # For logging purposes. bindings = { 'bet_match_id': bet_match_id, 'bet_match_type_id': bet_match_type_id, 'block_index': tx['block_index'], 'settled': False, 'bull_credit': bull_credit, 'bear_credit': bear_credit, 'winner': None, 'escrow_less_fee': None, 'fee': fee } sql='insert into bet_match_resolutions values(:bet_match_id, :bet_match_type_id, :block_index, :settled, :bull_credit, :bear_credit, :winner, :escrow_less_fee, :fee)' cursor.execute(sql, bindings) # Settle (if not liquidated). elif timestamp >= bet_match['deadline']: bet_match_status = 'settled' util.credit(db, bull_address, config.XCP, bull_credit, action='bet {}'.format(bet_match_status), event=tx['tx_hash']) util.credit(db, bear_address, config.XCP, bear_credit, action='bet {}'.format(bet_match_status), event=tx['tx_hash']) # Pay fee to feed. util.credit(db, bet_match['feed_address'], config.XCP, fee, action='feed fee', event=tx['tx_hash']) # For logging purposes. bindings = { 'bet_match_id': bet_match_id, 'bet_match_type_id': bet_match_type_id, 'block_index': tx['block_index'], 'settled': True, 'bull_credit': bull_credit, 'bear_credit': bear_credit, 'winner': None, 'escrow_less_fee': None, 'fee': fee } sql='insert into bet_match_resolutions values(:bet_match_id, :bet_match_type_id, :block_index, :settled, :bull_credit, :bear_credit, :winner, :escrow_less_fee, :fee)' cursor.execute(sql, bindings) # Equal[/NotEqual] bet. elif bet_match_type_id == equal_type_id and timestamp >= bet_match['deadline']: # Recognise tx0, tx1 as the bull, bear (in the right direction). if bet_match['tx0_bet_type'] < bet_match['tx1_bet_type']: equal_address = bet_match['tx0_address'] notequal_address = bet_match['tx1_address'] else: equal_address = bet_match['tx1_address'] notequal_address = bet_match['tx0_address'] # Decide who won, and credit appropriately. if value == bet_match['target_value']: winner = 'Equal' bet_match_status = 'settled: for equal' util.credit(db, equal_address, config.XCP, escrow_less_fee, action='bet {}'.format(bet_match_status), event=tx['tx_hash']) else: winner = 'NotEqual' bet_match_status = 'settled: for notequal' util.credit(db, notequal_address, config.XCP, escrow_less_fee, action='bet {}'.format(bet_match_status), event=tx['tx_hash']) # Pay fee to feed. util.credit(db, bet_match['feed_address'], config.XCP, fee, action='feed fee', event=tx['tx_hash']) # For logging purposes. bindings = { 'bet_match_id': bet_match_id, 'bet_match_type_id': bet_match_type_id, 'block_index': tx['block_index'], 'settled': None, 'bull_credit': None, 'bear_credit': None, 'winner': winner, 'escrow_less_fee': escrow_less_fee, 'fee': fee } sql='insert into bet_match_resolutions values(:bet_match_id, :bet_match_type_id, :block_index, :settled, :bull_credit, :bear_credit, :winner, :escrow_less_fee, :fee)' cursor.execute(sql, bindings) # Update the bet match’s status. if bet_match_status: bindings = { 'status': bet_match_status, 'bet_match_id': util.make_id(bet_match['tx0_hash'], bet_match['tx1_hash']) } sql='update bet_matches set status = :status where id = :bet_match_id' cursor.execute(sql, bindings) log.message(db, tx['block_index'], 'update', 'bet_matches', bindings) broadcast_bet_match_cursor.close() cursor.close() # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 #### btcpay.py #! /usr/bin/python3 import binascii import json import pprint import struct import logging logger = logging.getLogger(__name__) from counterpartylib.lib import config from counterpartylib.lib import exceptions from counterpartylib.lib import util from counterpartylib.lib import log from counterpartylib.lib import message_type FORMAT = '>32s32s' LENGTH = 32 + 32 ID = 11 def initialise(db): cursor = db.cursor() cursor.execute('''CREATE TABLE IF NOT EXISTS btcpays( tx_index INTEGER PRIMARY KEY, tx_hash TEXT UNIQUE, block_index INTEGER, source TEXT, destination TEXT, btc_amount INTEGER, order_match_id TEXT, status TEXT, FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index)) ''') # Disallows invalids: FOREIGN KEY (order_match_id) REFERENCES order_matches(id)) cursor.execute('''CREATE INDEX IF NOT EXISTS block_index_idx ON btcpays (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS source_idx ON btcpays (source) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS destination_idx ON btcpays (destination) ''') def validate (db, source, order_match_id, block_index): problems = [] order_match = None cursor = db.cursor() cursor.execute('''SELECT * FROM order_matches \ WHERE id = ?''', (order_match_id,)) order_matches = cursor.fetchall() cursor.close() if len(order_matches) == 0: problems.append('no such order match %s' % order_match_id) return None, None, None, None, order_match, problems elif len(order_matches) > 1: assert False else: order_match = order_matches[0] if order_match['status'] == 'expired': problems.append('order match expired') elif order_match['status'] == 'completed': problems.append('order match completed') elif order_match['status'].startswith('invalid'): problems.append('order match invalid') elif order_match['status'] != 'pending': raise exceptions.OrderError('unrecognised order match status') # Figure out to which address the BTC are being paid. # Check that source address is correct. if order_match['backward_asset'] == config.BTC: if source != order_match['tx1_address'] and not (block_index >= 313900 or config.TESTNET or config.REGTEST): # Protocol change. problems.append('incorrect source address') destination = order_match['tx0_address'] btc_quantity = order_match['backward_quantity'] escrowed_asset = order_match['forward_asset'] escrowed_quantity = order_match['forward_quantity'] elif order_match['forward_asset'] == config.BTC: if source != order_match['tx0_address'] and not (block_index >= 313900 or config.TESTNET or config.REGTEST): # Protocol change. problems.append('incorrect source address') destination = order_match['tx1_address'] btc_quantity = order_match['forward_quantity'] escrowed_asset = order_match['backward_asset'] escrowed_quantity = order_match['backward_quantity'] else: assert False return destination, btc_quantity, escrowed_asset, escrowed_quantity, order_match, problems def compose (db, source, order_match_id): tx0_hash, tx1_hash = util.parse_id(order_match_id) destination, btc_quantity, escrowed_asset, escrowed_quantity, order_match, problems = validate(db, source, order_match_id, util.CURRENT_BLOCK_INDEX) if problems: raise exceptions.ComposeError(problems) # Warn if down to the wire. time_left = order_match['match_expire_index'] - util.CURRENT_BLOCK_INDEX if time_left < 4: logger.warning('Only {} blocks until that order match expires. The payment might not make into the blockchain in time.'.format(time_left)) if 10 - time_left < 4: logger.warning('Order match has only {} confirmation(s).'.format(10 - time_left)) tx0_hash_bytes, tx1_hash_bytes = binascii.unhexlify(bytes(tx0_hash, 'utf-8')), binascii.unhexlify(bytes(tx1_hash, 'utf-8')) data = message_type.pack(ID) data += struct.pack(FORMAT, tx0_hash_bytes, tx1_hash_bytes) return (source, [(destination, btc_quantity)], data) def parse (db, tx, message): cursor = db.cursor() # Unpack message. try: if len(message) != LENGTH: raise exceptions.UnpackError tx0_hash_bytes, tx1_hash_bytes = struct.unpack(FORMAT, message) tx0_hash, tx1_hash = binascii.hexlify(tx0_hash_bytes).decode('utf-8'), binascii.hexlify(tx1_hash_bytes).decode('utf-8') order_match_id = util.make_id(tx0_hash, tx1_hash) status = 'valid' except (exceptions.UnpackError, struct.error) as e: tx0_hash, tx1_hash, order_match_id = None, None, None status = 'invalid: could not unpack' if status == 'valid': destination, btc_quantity, escrowed_asset, escrowed_quantity, order_match, problems = validate(db, tx['source'], order_match_id, tx['block_index']) if problems: order_match = None status = 'invalid: ' + '; '.join(problems) if status == 'valid': # BTC must be paid all at once. if tx['btc_amount'] >= btc_quantity: # Credit source address for the currency that he bought with the bitcoins. util.credit(db, tx['source'], escrowed_asset, escrowed_quantity, action='btcpay', event=tx['tx_hash']) status = 'valid' # Update order match. bindings = { 'status': 'completed', 'order_match_id': order_match_id } sql='update order_matches set status = :status where id = :order_match_id' cursor.execute(sql, bindings) log.message(db, tx['block_index'], 'update', 'order_matches', bindings) # Update give and get order status as filled if order_match is completed if util.enabled('btc_order_filled'): bindings = { 'status': 'pending', 'tx0_hash': tx0_hash, 'tx1_hash': tx1_hash } sql='select * from order_matches where status = :status and ((tx0_hash in (:tx0_hash, :tx1_hash)) or ((tx1_hash in (:tx0_hash, :tx1_hash))))' cursor.execute(sql, bindings) order_matches = cursor.fetchall() if len(order_matches) == 0: # mark both btc get and give orders as filled when order_match is completed and give or get remaining = 0 bindings = { 'status': 'filled', 'tx0_hash': tx0_hash, 'tx1_hash': tx1_hash } sql='update orders set status = :status where ((tx_hash in (:tx0_hash, :tx1_hash)) and ((give_remaining = 0) or (get_remaining = 0)))' cursor.execute(sql, bindings) else: # always mark btc get order as filled when order_match is completed and give or get remaining = 0 bindings = { 'status': 'filled', 'source': tx['destination'], 'tx0_hash': tx0_hash, 'tx1_hash': tx1_hash } sql='update orders set status = :status where ((tx_hash in (:tx0_hash, :tx1_hash)) and ((give_remaining = 0) or (get_remaining = 0)) and (source = :source))' cursor.execute(sql, bindings) # Add parsed transaction to message-type–specific table. bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'source': tx['source'], 'destination': tx['destination'], 'btc_amount': tx['btc_amount'], 'order_match_id': order_match_id, 'status': status, } if "integer overflow" not in status: sql = 'insert into btcpays values(:tx_index, :tx_hash, :block_index, :source, :destination, :btc_amount, :order_match_id, :status)' cursor.execute(sql, bindings) else: logger.warn("Not storing [btcpay] tx [%s]: %s" % (tx['tx_hash'], status)) logger.debug("Bindings: %s" % (json.dumps(bindings), )) cursor.close() # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 #### burn.py #! /usr/bin/python3 import json import struct import decimal import logging logger = logging.getLogger(__name__) D = decimal.Decimal from fractions import Fraction from counterpartylib.lib import (config, exceptions, util) """Burn {} to earn {} during a special period of time.""".format(config.BTC, config.XCP) ID = 60 def initialise (db): cursor = db.cursor() cursor.execute('''CREATE TABLE IF NOT EXISTS burns( tx_index INTEGER PRIMARY KEY, tx_hash TEXT UNIQUE, block_index INTEGER, source TEXT, burned INTEGER, earned INTEGER, status TEXT, FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS status_idx ON burns (status) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS address_idx ON burns (source) ''') def validate (db, source, destination, quantity, block_index, overburn=False): problems = [] # Check destination address. if destination != config.UNSPENDABLE: problems.append('wrong destination address') if not isinstance(quantity, int): problems.append('quantity must be in satoshis') return problems if quantity < 0: problems.append('negative quantity') # Try to make sure that the burned funds won't go to waste. if block_index < config.BURN_START - 1: problems.append('too early') elif block_index > config.BURN_END: problems.append('too late') return problems def compose (db, source, quantity, overburn=False): cursor = db.cursor() destination = config.UNSPENDABLE problems = validate(db, source, destination, quantity, util.CURRENT_BLOCK_INDEX, overburn=overburn) if problems: raise exceptions.ComposeError(problems) # Check that a maximum of 1 BTC total is burned per address. burns = list(cursor.execute('''SELECT * FROM burns WHERE (status = ? AND source = ?)''', ('valid', source))) already_burned = sum([burn['burned'] for burn in burns]) if quantity > (1 * config.UNIT - already_burned) and not overburn: raise exceptions.ComposeError('1 {} may be burned per address'.format(config.BTC)) cursor.close() return (source, [(destination, quantity)], None) def parse (db, tx, MAINNET_BURNS, message=None): burn_parse_cursor = db.cursor() if config.TESTNET or config.REGTEST: problems = [] status = 'valid' if status == 'valid': problems = validate(db, tx['source'], tx['destination'], tx['btc_amount'], tx['block_index'], overburn=False) if problems: status = 'invalid: ' + '; '.join(problems) if tx['btc_amount'] != None: sent = tx['btc_amount'] else: sent = 0 if status == 'valid': # Calculate quantity of XCP earned. (Maximum 1 BTC in total, ever.) cursor = db.cursor() cursor.execute('''SELECT * FROM burns WHERE (status = ? AND source = ?)''', ('valid', tx['source'])) burns = cursor.fetchall() already_burned = sum([burn['burned'] for burn in burns]) ONE = 1 * config.UNIT max_burn = ONE - already_burned if sent > max_burn: burned = max_burn # Exceeded maximum burn; earn what you can. else: burned = sent total_time = config.BURN_END - config.BURN_START partial_time = config.BURN_END - tx['block_index'] multiplier = (1000 + (500 * Fraction(partial_time, total_time))) earned = round(burned * multiplier) # Credit source address with earned XCP. util.credit(db, tx['source'], config.XCP, earned, action='burn', event=tx['tx_hash']) else: burned = 0 earned = 0 tx_index = tx['tx_index'] tx_hash = tx['tx_hash'] block_index = tx['block_index'] source = tx['source'] else: # Mainnet burns are hard‐coded. try: line = MAINNET_BURNS[tx['tx_hash']] except KeyError: return util.credit(db, line['source'], config.XCP, int(line['earned']), action='burn', event=line['tx_hash']) tx_index = tx['tx_index'] tx_hash = line['tx_hash'] block_index = line['block_index'] source = line['source'] burned = line['burned'] earned = line['earned'] status = 'valid' # Add parsed transaction to message-type–specific table. # TODO: store sent in table bindings = { 'tx_index': tx_index, 'tx_hash': tx_hash, 'block_index': block_index, 'source': source, 'burned': burned, 'earned': earned, 'status': status, } if "integer overflow" not in status: sql = 'insert into burns values(:tx_index, :tx_hash, :block_index, :source, :burned, :earned, :status)' burn_parse_cursor.execute(sql, bindings) else: logger.warn("Not storing [burn] tx [%s]: %s" % (tx['tx_hash'], status)) logger.debug("Bindings: %s" % (json.dumps(bindings), )) burn_parse_cursor.close() # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 #### cancel.py #! /usr/bin/python3 """ offer_hash is the hash of either a bet or an order. """ import binascii import struct import json import logging logger = logging.getLogger(__name__) from counterpartylib.lib import (config, exceptions, util, message_type) from . import (order, bet, rps) FORMAT = '>32s' LENGTH = 32 ID = 70 def initialise (db): cursor = db.cursor() cursor.execute('''CREATE TABLE IF NOT EXISTS cancels( tx_index INTEGER PRIMARY KEY, tx_hash TEXT UNIQUE, block_index INTEGER, source TEXT, offer_hash TEXT, status TEXT, FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index)) ''') # Offer hash is not a foreign key. (And it cannot be, because of some invalid cancels.) cursor.execute('''CREATE INDEX IF NOT EXISTS cancels_block_index_idx ON cancels (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS source_idx ON cancels (source) ''') def validate (db, source, offer_hash): problems = [] # TODO: make query only if necessary cursor = db.cursor() cursor.execute('''SELECT * from orders WHERE tx_hash = ?''', (offer_hash,)) orders = list(cursor) cursor.execute('''SELECT * from bets WHERE tx_hash = ?''', (offer_hash,)) bets = list(cursor) cursor.execute('''SELECT * from rps WHERE tx_hash = ?''', (offer_hash,)) rps = list(cursor) cursor.close() offer_type = None if orders: offer_type = 'order' elif bets: offer_type = 'bet' elif rps: offer_type = 'rps' else: problems = ['no open offer with that hash'] offer = None if offer_type: offers = orders + bets + rps offer = offers[0] if offer['source'] != source: problems.append('incorrect source address') if offer['status'] != 'open': problems.append('offer not open') return offer, offer_type, problems def compose (db, source, offer_hash): # Check that offer exists. offer, offer_type, problems = validate(db, source, offer_hash) if problems: raise exceptions.ComposeError(problems) offer_hash_bytes = binascii.unhexlify(bytes(offer_hash, 'utf-8')) data = message_type.pack(ID) data += struct.pack(FORMAT, offer_hash_bytes) return (source, [], data) def parse (db, tx, message): cursor = db.cursor() # Unpack message. try: if len(message) != LENGTH: raise exceptions.UnpackError offer_hash_bytes = struct.unpack(FORMAT, message)[0] offer_hash = binascii.hexlify(offer_hash_bytes).decode('utf-8') status = 'valid' except (exceptions.UnpackError, struct.error) as e: offer_hash = None status = 'invalid: could not unpack' if status == 'valid': offer, offer_type, problems = validate(db, tx['source'], offer_hash) if problems: status = 'invalid: ' + '; '.join(problems) if status == 'valid': # Cancel if order. if offer_type == 'order': order.cancel_order(db, offer, 'cancelled', tx['block_index']) # Cancel if bet. elif offer_type == 'bet': bet.cancel_bet(db, offer, 'cancelled', tx['block_index']) # Cancel if rps. elif offer_type == 'rps': rps.cancel_rps(db, offer, 'cancelled', tx['block_index']) # If neither order or bet, mark as invalid. else: assert False # Add parsed transaction to message-type–specific table. bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'source': tx['source'], 'offer_hash': offer_hash, 'status': status, } if "integer overflow" not in status: sql='INSERT INTO cancels VALUES (:tx_index, :tx_hash, :block_index, :source, :offer_hash, :status)' cursor.execute(sql, bindings) else: logger.warn("Not storing [cancel] tx [%s]: %s" % (tx['tx_hash'], status)) logger.debug("Bindings: %s" % (json.dumps(bindings), )) cursor.close() # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 #### destroy.py #! /usr/bin/python3 """Destroy a quantity of an asset.""" import struct import json import logging logger = logging.getLogger(__name__) from counterpartylib.lib import util from counterpartylib.lib import config from counterpartylib.lib import script from counterpartylib.lib import message_type from counterpartylib.lib.script import AddressError from counterpartylib.lib.exceptions import * FORMAT = '>QQ' LENGTH = 8 + 8 MAX_TAG_LENGTH = 34 ID = 110 def initialise(db): cursor = db.cursor() cursor.execute('''CREATE TABLE IF NOT EXISTS destructions( tx_index INTEGER PRIMARY KEY, tx_hash TEXT UNIQUE, block_index INTEGER, source TEXT, asset INTEGER, quantity INTEGER, tag TEXT, status TEXT, FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS status_idx ON destructions (status) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS address_idx ON destructions (source) ''') def pack(db, asset, quantity, tag): data = message_type.pack(ID) if isinstance(tag, str): tag = bytes(tag.encode('utf8'))[0:MAX_TAG_LENGTH] elif isinstance(tag, bytes): tag = tag[0:MAX_TAG_LENGTH] else: tag = b'' data += struct.pack(FORMAT, util.get_asset_id(db, asset, util.CURRENT_BLOCK_INDEX), quantity) data += tag return data def unpack(db, message): try: asset_id, quantity = struct.unpack(FORMAT, message[0:16]) tag = message[16:] asset = util.get_asset_name(db, asset_id, util.CURRENT_BLOCK_INDEX) except struct.error: raise UnpackError('could not unpack') except AssetIDError: raise UnpackError('asset id invalid') return asset, quantity, tag def validate (db, source, destination, asset, quantity): try: util.get_asset_id(db, asset, util.CURRENT_BLOCK_INDEX) except AssetError: raise ValidateError('asset invalid') try: script.validate(source) except AddressError: raise ValidateError('source address invalid') if destination: raise ValidateError('destination exists') if asset == config.BTC: raise ValidateError('cannot destroy {}'.format(config.BTC)) if type(quantity) != int: raise ValidateError('quantity not integer') if quantity > config.MAX_INT: raise ValidateError('integer overflow, quantity too large') if quantity < 0: raise ValidateError('quantity negative') if util.get_balance(db, source, asset) < quantity: raise BalanceError('balance insufficient') def compose (db, source, asset, quantity, tag): # resolve subassets asset = util.resolve_subasset_longname(db, asset) validate(db, source, None, asset, quantity) data = pack(db, asset, quantity, tag) return (source, [], data) def parse (db, tx, message): status = 'valid' asset, quantity, tag = None, None, None try: asset, quantity, tag = unpack(db, message) validate(db, tx['source'], tx['destination'], asset, quantity) util.debit(db, tx['source'], asset, quantity, 'destroy', tx['tx_hash']) except UnpackError as e: status = 'invalid: ' + ''.join(e.args) except (ValidateError, BalanceError) as e: status = 'invalid: ' + ''.join(e.args) bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'source': tx['source'], 'asset': asset, 'quantity': quantity, 'tag': tag, 'status': status, } if "integer overflow" not in status: sql = 'insert into destructions values(:tx_index, :tx_hash, :block_index, :source, :asset, :quantity, :tag, :status)' cursor = db.cursor() cursor.execute(sql, bindings) else: logger.warn("Not storing [destroy] tx [%s]: %s" % (tx['tx_hash'], status)) logger.debug("Bindings: %s" % (json.dumps(bindings), )) # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 #### dispenser.py #! /usr/bin/python3 # # What is a dispenser? # # A dispenser is a type of order where the holder address gives out a given amount # of units of an asset for a given amount of BTC satoshis received. # It's a very simple but powerful semantic to allow swaps to operate on-chain. # import binascii import json import pprint import struct import logging from math import floor logger = logging.getLogger(__name__) from counterpartylib.lib import config from counterpartylib.lib import exceptions from counterpartylib.lib import util from counterpartylib.lib import log from counterpartylib.lib import message_type from counterpartylib.lib import address FORMAT = '>QQQQB' LENGTH = 33 ID = 12 DISPENSE_ID = 13 STATUS_OPEN = 0 STATUS_OPEN_EMPTY_ADDRESS = 1 #STATUS_OPEN_ORACLE_PRICE = 20 #STATUS_OPEN_ORACLE_PRICE_EMPTY_ADDRESS = 21 STATUS_CLOSED = 10 def initialise(db): cursor = db.cursor() cursor.execute('''CREATE TABLE IF NOT EXISTS dispensers( tx_index INTEGER PRIMARY KEY, tx_hash TEXT UNIQUE, block_index INTEGER, source TEXT, asset TEXT, give_quantity INTEGER, escrow_quantity INTEGER, satoshirate INTEGER, status INTEGER, give_remaining INTEGER, FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index)) ''') # Disallows invalids: FOREIGN KEY (order_match_id) REFERENCES order_matches(id)) cursor.execute('''CREATE INDEX IF NOT EXISTS dispensers_source_idx ON dispensers (source) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS dispensers_asset_idx ON dispensers (asset) ''') cursor.execute('''CREATE TABLE IF NOT EXISTS dispenses( tx_index INTEGER, dispense_index INTEGER, tx_hash TEXT, block_index INTEGER, source TEXT, destination TEXT, asset TEXT, dispense_quantity INTEGER, dispenser_tx_hash TEXT, PRIMARY KEY (tx_index, dispense_index, source, destination), FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index)) ''') columns = [column['name'] for column in cursor.execute('''PRAGMA table_info(dispenses)''')] if 'dispenser_tx_hash' not in columns: cursor.execute('ALTER TABLE dispenses ADD COLUMN dispenser_tx_hash TEXT') columns = [column['name'] for column in cursor.execute('''PRAGMA table_info(dispensers)''')] if 'oracle_address' not in columns: cursor.execute('ALTER TABLE dispensers ADD COLUMN oracle_address TEXT') def validate (db, source, asset, give_quantity, escrow_quantity, mainchainrate, status, open_address, block_index, oracle_address): problems = [] order_match = None asset_id = None if asset == config.BTC: problems.append('cannot dispense %s' % config.BTC) return None, problems # resolve subassets asset = util.resolve_subasset_longname(db, asset) if status == STATUS_OPEN or status == STATUS_OPEN_EMPTY_ADDRESS: if give_quantity <= 0: problems.append('give_quantity must be positive') if mainchainrate <= 0: problems.append('mainchainrate must be positive') if escrow_quantity < give_quantity: problems.append('escrow_quantity must be greater or equal than give_quantity') elif not(status == STATUS_CLOSED): problems.append('invalid status %i' % status) cursor = db.cursor() cursor.execute('''SELECT quantity FROM balances \ WHERE address = ? and asset = ?''', (source,asset,)) available = cursor.fetchall() if len(available) == 0: problems.append('address doesn\'t has the asset %s' % asset) elif len(available) >= 1 and available[0]['quantity'] < escrow_quantity: problems.append('address doesn\'t has enough balance of %s (%i < %i)' % (asset, available[0]['quantity'], escrow_quantity)) else: if status == STATUS_OPEN_EMPTY_ADDRESS and not(open_address): open_address = source status = STATUS_OPEN query_address = open_address if status == STATUS_OPEN_EMPTY_ADDRESS else source cursor.execute('''SELECT * FROM dispensers WHERE source = ? AND asset = ? AND status=?''', (query_address, asset, STATUS_OPEN)) open_dispensers = cursor.fetchall() if status == STATUS_OPEN or status == STATUS_OPEN_EMPTY_ADDRESS: if len(open_dispensers) > 0 and open_dispensers[0]['satoshirate'] != mainchainrate: problems.append('address has a dispenser already opened for asset %s with a different mainchainrate' % asset) if len(open_dispensers) > 0 and open_dispensers[0]['give_quantity'] != give_quantity: problems.append('address has a dispenser already opened for asset %s with a different give_quantity' % asset) elif status == STATUS_CLOSED: if len(open_dispensers) == 0: problems.append('address doesnt has an open dispenser for asset %s' % asset) if status == STATUS_OPEN_EMPTY_ADDRESS: cursor.execute('''SELECT count(*) cnt FROM balances WHERE address = ?''', (query_address,)) existing_balances = cursor.fetchall() if existing_balances[0]['cnt'] > 0: problems.append('cannot open on another address if it has any balance history') if len(problems) == 0: asset_id = util.generate_asset_id(asset, block_index) if asset_id == 0: problems.append('cannot dispense %s' % asset) # How can we test this on a test vector? cursor.close() if oracle_address is not None and util.enabled('oracle_dispensers', block_index): last_price, last_fee, last_label, last_updated = util.get_oracle_last_price(db, oracle_address, block_index) if last_price is None: problems.append('The oracle address %s has not broadcasted any price yet' % oracle_address) if len(problems) > 0: return None, problems else: return asset_id, None def compose (db, source, asset, give_quantity, escrow_quantity, mainchainrate, status, open_address=None, oracle_address=None): assetid, problems = validate(db, source, asset, give_quantity, escrow_quantity, mainchainrate, status, open_address, util.CURRENT_BLOCK_INDEX, oracle_address) if problems: raise exceptions.ComposeError(problems) destination = [] data = message_type.pack(ID) data += struct.pack(FORMAT, assetid, give_quantity, escrow_quantity, mainchainrate, status) if status == STATUS_OPEN_EMPTY_ADDRESS and open_address: data += address.pack(open_address) if oracle_address is not None and util.enabled('oracle_dispensers'): oracle_fee = calculate_oracle_fee(db, escrow_quantity, give_quantity, mainchainrate, oracle_address, util.CURRENT_BLOCK_INDEX) if oracle_fee >= config.DEFAULT_REGULAR_DUST_SIZE: destination.append((oracle_address,oracle_fee)) data += address.pack(oracle_address) return (source, destination, data) def calculate_oracle_fee(db, escrow_quantity, give_quantity, mainchainrate, oracle_address, block_index): last_price, last_fee, last_fiat_label, last_updated = util.get_oracle_last_price(db, oracle_address, block_index) last_fee_multiplier = (last_fee / config.UNIT) #Format mainchainrate to ######.## oracle_mainchainrate = util.satoshirate_to_fiat(mainchainrate) oracle_mainchainrate_btc = oracle_mainchainrate/last_price #Calculate the total amount earned for dispenser and the fee remaining = int(floor(escrow_quantity / give_quantity)) total_quantity_btc = oracle_mainchainrate_btc * remaining oracle_fee_btc = int(total_quantity_btc * last_fee_multiplier *config.UNIT) return oracle_fee_btc def parse (db, tx, message): cursor = db.cursor() # Unpack message. try: action_address = tx['source'] oracle_address = None assetid, give_quantity, escrow_quantity, mainchainrate, dispenser_status = struct.unpack(FORMAT, message[0:LENGTH]) read = LENGTH if dispenser_status == STATUS_OPEN_EMPTY_ADDRESS: action_address = address.unpack(message[LENGTH:LENGTH+21]) read = LENGTH + 21 if len(message) > read: oracle_address = address.unpack(message[read:read+21]) asset = util.generate_asset_name(assetid, util.CURRENT_BLOCK_INDEX) status = 'valid' except (exceptions.UnpackError, struct.error) as e: assetid, give_quantity, mainchainrate, asset = None, None, None, None status = 'invalid: could not unpack' if status == 'valid': if util.enabled("dispenser_parsing_validation", util.CURRENT_BLOCK_INDEX): asset_id, problems = validate(db, tx['source'], asset, give_quantity, escrow_quantity, mainchainrate, dispenser_status, action_address if dispenser_status == STATUS_OPEN_EMPTY_ADDRESS else None, tx['block_index'], oracle_address) else: problems = None if problems: status = 'invalid: ' + '; '.join(problems) else: if dispenser_status == STATUS_OPEN or dispenser_status == STATUS_OPEN_EMPTY_ADDRESS: cursor.execute('SELECT * FROM dispensers WHERE source=:source AND asset=:asset AND status=:status', { 'source': action_address, 'asset': asset, 'status': STATUS_OPEN }) existing = cursor.fetchall() if len(existing) == 0: if (oracle_address != None) and util.enabled('oracle_dispensers', tx['block_index']): oracle_fee = calculate_oracle_fee(db, escrow_quantity, give_quantity, mainchainrate, oracle_address, tx['block_index']) if oracle_fee >= config.DEFAULT_REGULAR_DUST_SIZE: if tx["destination"] != oracle_address or tx["btc_amount"] < oracle_fee: status = 'invalid: insufficient or non-existent oracle fee' if status == 'valid': # Create the new dispenser try: if dispenser_status == STATUS_OPEN_EMPTY_ADDRESS: cursor.execute('SELECT count(*) cnt FROM balances WHERE address=:address AND quantity > 0', { 'address': action_address }) counts = cursor.fetchall()[0] if counts['cnt'] == 0: util.debit(db, tx['source'], asset, escrow_quantity, action='open dispenser empty addr', event=tx['tx_hash']) util.credit(db, action_address, asset, escrow_quantity, action='open dispenser empty addr', event=tx['tx_hash']) util.debit(db, action_address, asset, escrow_quantity, action='open dispenser empty addr', event=tx['tx_hash']) else: status = 'invalid: address not empty' else: util.debit(db, tx['source'], asset, escrow_quantity, action='open dispenser', event=tx['tx_hash']) except util.DebitError as e: status = 'invalid: insufficient funds' if status == 'valid': bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'source': action_address, 'asset': asset, 'give_quantity': give_quantity, 'escrow_quantity': escrow_quantity, 'satoshirate': mainchainrate, 'status': STATUS_OPEN, 'give_remaining': escrow_quantity, 'oracle_address': oracle_address } sql = 'insert into dispensers values(:tx_index, :tx_hash, :block_index, :source, :asset, :give_quantity, :escrow_quantity, :satoshirate, :status, :give_remaining, :oracle_address)' cursor.execute(sql, bindings) elif len(existing) == 1 and existing[0]['satoshirate'] == mainchainrate and existing[0]['give_quantity'] == give_quantity: if tx["source"]==action_address: if (oracle_address != None) and util.enabled('oracle_dispensers', tx['block_index']): oracle_fee = calculate_oracle_fee(db, escrow_quantity, give_quantity, mainchainrate, oracle_address, tx['block_index']) if oracle_fee >= config.DEFAULT_REGULAR_DUST_SIZE: if tx["destination"] != oracle_address or tx["btc_amount"] < oracle_fee: status = 'invalid: insufficient or non-existent oracle fee' if status == 'valid': # Refill the dispenser by the given amount bindings = { 'source': tx['source'], 'asset': asset, 'prev_status': dispenser_status, 'give_remaining': existing[0]['give_remaining'] + escrow_quantity, 'status': STATUS_OPEN, 'block_index': tx['block_index'], 'action':'refill dispenser', 'escrow_quantity':escrow_quantity } try: util.debit(db, tx['source'], asset, escrow_quantity, action='refill dispenser', event=tx['tx_hash']) sql = 'UPDATE dispensers SET give_remaining=:give_remaining \ WHERE source=:source AND asset=:asset AND status=:status' cursor.execute(sql, bindings) except (util.DebitError): status = 'insufficient funds' else: status = 'invalid: can only refill dispenser from source' else: status = 'can only have one open dispenser per asset per address' elif dispenser_status == STATUS_CLOSED: cursor.execute('SELECT tx_index, give_remaining FROM dispensers WHERE source=:source AND asset=:asset AND status=:status', { 'source': tx['source'], 'asset': asset, 'status': STATUS_OPEN }) existing = cursor.fetchall() if len(existing) == 1: util.credit(db, tx['source'], asset, existing[0]['give_remaining'], action='close dispenser', event=tx['tx_hash']) bindings = { 'source': tx['source'], 'asset': asset, 'status': STATUS_CLOSED, 'block_index': tx['block_index'], 'tx_index': existing[0]['tx_index'] } sql = 'UPDATE dispensers SET give_remaining=0, status=:status WHERE source=:source AND asset=:asset' cursor.execute(sql, bindings) else: status = 'dispenser inexistent' else: status = 'invalid: status must be one of OPEN or CLOSE' if status != 'valid': logger.warn("Not storing [dispenser] tx [%s]: %s" % (tx['tx_hash'], status)) cursor.close() def is_dispensable(db, address, amount): cursor = db.cursor() cursor.execute('SELECT * FROM dispensers WHERE source=:source AND status=:status', { 'source': address, 'status': STATUS_OPEN }) dispensers = cursor.fetchall() cursor.close() for next_dispenser in dispensers: if next_dispenser["oracle_address"] != None: last_price, last_fee, last_fiat_label, last_updated = util.get_oracle_last_price(db, next_dispenser['oracle_address'], util.CURRENT_BLOCK_INDEX) fiatrate = util.satoshirate_to_fiat(next_dispenser["satoshirate"]) if amount >= fiatrate/last_price: return True else: if amount >= next_dispenser["satoshirate"]: return True return False def dispense(db, tx): cursor = db.cursor() cursor.execute('SELECT * FROM dispensers WHERE source=:source AND status=:status ORDER BY asset', { 'source': tx['destination'], 'status': STATUS_OPEN }) dispensers = cursor.fetchall() dispense_index = 0 for dispenser in dispensers: satoshirate = dispenser['satoshirate'] give_quantity = dispenser['give_quantity'] if satoshirate > 0 and give_quantity > 0: if (dispenser['oracle_address'] != None) and util.enabled('oracle_dispensers', tx['block_index']): last_price, last_fee, last_fiat_label, last_updated = util.get_oracle_last_price(db, dispenser['oracle_address'], tx['block_index']) fiatrate = util.satoshirate_to_fiat(satoshirate) must_give = int(floor(((tx['btc_amount'] / config.UNIT) * last_price)/fiatrate)) else: must_give = int(floor(tx['btc_amount'] / satoshirate)) remaining = int(floor(dispenser['give_remaining'] / give_quantity)) actually_given = min(must_give, remaining) * give_quantity give_remaining = dispenser['give_remaining'] - actually_given assert give_remaining >= 0 # Skip dispense if quantity is 0 if util.enabled('zero_quantity_value_adjustment_1') and actually_given==0: continue util.credit(db, tx['source'], dispenser['asset'], actually_given, action='dispense', event=tx['tx_hash']) dispenser['give_remaining'] = give_remaining if give_remaining < dispenser['give_quantity']: # close the dispenser dispenser['give_remaining'] = 0 if give_remaining > 0: # return the remaining to the owner util.credit(db, dispenser['source'], dispenser['asset'], give_remaining, action='dispenser close', event=tx['tx_hash']) dispenser['status'] = STATUS_CLOSED dispenser['block_index'] = tx['block_index'] dispenser['prev_status'] = STATUS_OPEN cursor.execute('UPDATE DISPENSERS SET give_remaining=:give_remaining, status=:status \ WHERE source=:source AND asset=:asset AND satoshirate=:satoshirate AND give_quantity=:give_quantity AND status=:prev_status', dispenser) bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'dispense_index': dispense_index, 'block_index': tx['block_index'], 'source': tx['destination'], 'destination': tx['source'], 'asset': dispenser['asset'], 'dispense_quantity': actually_given, 'dispenser_tx_hash': dispenser['tx_hash'] } sql = 'INSERT INTO dispenses(tx_index, dispense_index, tx_hash, block_index, source, destination, asset, dispense_quantity, dispenser_tx_hash) \ VALUES(:tx_index, :dispense_index, :tx_hash, :block_index, :source, :destination, :asset, :dispense_quantity, :dispenser_tx_hash);' cursor.execute(sql, bindings) dispense_index += 1 cursor.close() #### dividend.py #! /usr/bin/python3 """Pay out dividends.""" import json import struct import decimal D = decimal.Decimal import logging logger = logging.getLogger(__name__) from counterpartylib.lib import (config, exceptions, util, message_type) FORMAT_1 = '>QQ' LENGTH_1 = 8 + 8 FORMAT_2 = '>QQQ' LENGTH_2 = 8 + 8 + 8 ID = 50 def initialise (db): cursor = db.cursor() cursor.execute('''CREATE TABLE IF NOT EXISTS dividends( tx_index INTEGER PRIMARY KEY, tx_hash TEXT UNIQUE, block_index INTEGER, source TEXT, asset TEXT, dividend_asset TEXT, quantity_per_unit INTEGER, fee_paid INTEGER, status TEXT, FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS block_index_idx ON dividends (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS source_idx ON dividends (source) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS asset_idx ON dividends (asset) ''') def validate (db, source, quantity_per_unit, asset, dividend_asset, block_index): cursor = db.cursor() problems = [] if asset == config.BTC: problems.append('cannot pay dividends to holders of {}'.format(config.BTC)) if asset == config.XCP: if (not block_index >= 317500) or block_index >= 320000 or config.TESTNET or config.REGTEST: # Protocol change. problems.append('cannot pay dividends to holders of {}'.format(config.XCP)) if quantity_per_unit <= 0: problems.append('non‐positive quantity per unit') # For SQLite3 if quantity_per_unit > config.MAX_INT: problems.append('integer overflow') # Examine asset. issuances = list(cursor.execute('''SELECT * FROM issuances WHERE (status = ? AND asset = ?) ORDER BY tx_index ASC''', ('valid', asset))) if not issuances: problems.append('no such asset, {}.'.format(asset)) return None, None, problems, 0 divisible = issuances[0]['divisible'] # Only issuer can pay dividends. if block_index >= 320000 or config.TESTNET or config.REGTEST: # Protocol change. if issuances[-1]['issuer'] != source: problems.append('only issuer can pay dividends') # Examine dividend asset. if dividend_asset in (config.BTC, config.XCP): dividend_divisible = True else: issuances = list(cursor.execute('''SELECT * FROM issuances WHERE (status = ? AND asset = ?)''', ('valid', dividend_asset))) if not issuances: problems.append('no such dividend asset, {}.'.format(dividend_asset)) return None, None, problems, 0 dividend_divisible = issuances[0]['divisible'] # Calculate dividend quantities. exclude_empty = False if util.enabled('zero_quantity_value_adjustment_1'): exclude_empty = True holders = util.holders(db, asset, exclude_empty) outputs = [] addresses = [] dividend_total = 0 for holder in holders: if block_index < 294500 and not (config.TESTNET or config.REGTEST): # Protocol change. if holder['escrow']: continue address = holder['address'] address_quantity = holder['address_quantity'] if block_index >= 296000 or config.TESTNET or config.REGTEST: # Protocol change. if address == source: continue dividend_quantity = address_quantity * quantity_per_unit if divisible: dividend_quantity /= config.UNIT if not util.enabled('nondivisible_dividend_fix') and not dividend_divisible: dividend_quantity /= config.UNIT # Pre-fix behaviour if dividend_asset == config.BTC and dividend_quantity < config.DEFAULT_MULTISIG_DUST_SIZE: continue # A bit hackish. dividend_quantity = int(dividend_quantity) outputs.append({'address': address, 'address_quantity': address_quantity, 'dividend_quantity': dividend_quantity}) addresses.append(address) dividend_total += dividend_quantity if not dividend_total: problems.append('zero dividend') if dividend_asset != config.BTC: dividend_balances = list(cursor.execute('''SELECT * FROM balances WHERE (address = ? AND asset = ?)''', (source, dividend_asset))) if not dividend_balances or dividend_balances[0]['quantity'] < dividend_total: problems.append('insufficient funds ({})'.format(dividend_asset)) fee = 0 if not problems and dividend_asset != config.BTC: holder_count = len(set(addresses)) if block_index >= 330000 or config.TESTNET or config.REGTEST: # Protocol change. fee = int(0.0002 * config.UNIT * holder_count) if fee: balances = list(cursor.execute('''SELECT * FROM balances WHERE (address = ? AND asset = ?)''', (source, config.XCP))) if not balances or balances[0]['quantity'] < fee: problems.append('insufficient funds ({})'.format(config.XCP)) if not problems and dividend_asset == config.XCP: total_cost = dividend_total + fee if not dividend_balances or dividend_balances[0]['quantity'] < total_cost: problems.append('insufficient funds ({})'.format(dividend_asset)) # For SQLite3 if fee > config.MAX_INT or dividend_total > config.MAX_INT: problems.append('integer overflow') cursor.close() return dividend_total, outputs, problems, fee def compose (db, source, quantity_per_unit, asset, dividend_asset): # resolve subassets asset = util.resolve_subasset_longname(db, asset) dividend_asset = util.resolve_subasset_longname(db, dividend_asset) dividend_total, outputs, problems, fee = validate(db, source, quantity_per_unit, asset, dividend_asset, util.CURRENT_BLOCK_INDEX) if problems: raise exceptions.ComposeError(problems) logger.info('Total quantity to be distributed in dividends: {} {}'.format(util.value_out(db, dividend_total, dividend_asset), dividend_asset)) if dividend_asset == config.BTC: return (source, [(output['address'], output['dividend_quantity']) for output in outputs], None) asset_id = util.get_asset_id(db, asset, util.CURRENT_BLOCK_INDEX) dividend_asset_id = util.get_asset_id(db, dividend_asset, util.CURRENT_BLOCK_INDEX) data = message_type.pack(ID) data += struct.pack(FORMAT_2, quantity_per_unit, asset_id, dividend_asset_id) return (source, [], data) def parse (db, tx, message): dividend_parse_cursor = db.cursor() # Unpack message. try: if (tx['block_index'] > 288150 or config.TESTNET or config.REGTEST) and len(message) == LENGTH_2: quantity_per_unit, asset_id, dividend_asset_id = struct.unpack(FORMAT_2, message) asset = util.get_asset_name(db, asset_id, tx['block_index']) dividend_asset = util.get_asset_name(db, dividend_asset_id, tx['block_index']) status = 'valid' elif len(message) == LENGTH_1: quantity_per_unit, asset_id = struct.unpack(FORMAT_1, message) asset = util.get_asset_name(db, asset_id, tx['block_index']) dividend_asset = config.XCP status = 'valid' else: raise exceptions.UnpackError except (exceptions.UnpackError, exceptions.AssetNameError, struct.error) as e: dividend_asset, quantity_per_unit, asset = None, None, None status = 'invalid: could not unpack' if dividend_asset == config.BTC: status = 'invalid: cannot pay {} dividends within protocol'.format(config.BTC) if status == 'valid': # For SQLite3 quantity_per_unit = min(quantity_per_unit, config.MAX_INT) dividend_total, outputs, problems, fee = validate(db, tx['source'], quantity_per_unit, asset, dividend_asset, block_index=tx['block_index']) if problems: status = 'invalid: ' + '; '.join(problems) if status == 'valid': # Debit. util.debit(db, tx['source'], dividend_asset, dividend_total, action='dividend', event=tx['tx_hash']) if tx['block_index'] >= 330000 or config.TESTNET or config.REGTEST: # Protocol change. util.debit(db, tx['source'], config.XCP, fee, action='dividend fee', event=tx['tx_hash']) # Credit. for output in outputs: if not util.enabled('dont_credit_zero_dividend') or output['dividend_quantity'] > 0: util.credit(db, output['address'], dividend_asset, output['dividend_quantity'], action='dividend', event=tx['tx_hash']) # Add parsed transaction to message-type–specific table. bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'source': tx['source'], 'asset': asset, 'dividend_asset': dividend_asset, 'quantity_per_unit': quantity_per_unit, 'fee_paid': fee, 'status': status, } if "integer overflow" not in status: sql = 'insert into dividends values(:tx_index, :tx_hash, :block_index, :source, :asset, :dividend_asset, :quantity_per_unit, :fee_paid, :status)' dividend_parse_cursor.execute(sql, bindings) else: logger.warn("Not storing [dividend] tx [%s]: %s" % (tx['tx_hash'], status)) logger.debug("Bindings: %s" % (json.dumps(bindings), )) dividend_parse_cursor.close() # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 #### issuance.py #! /usr/bin/python3 """ Allow simultaneous lock and transfer. """ import struct import decimal import json import logging logger = logging.getLogger(__name__) D = decimal.Decimal from counterpartylib.lib import (config, util, exceptions, util, message_type) FORMAT_1 = '>QQ?' LENGTH_1 = 8 + 8 + 1 FORMAT_2 = '>QQ??If' LENGTH_2 = 8 + 8 + 1 + 1 + 4 + 4 SUBASSET_FORMAT = '>QQ?B' SUBASSET_FORMAT_LENGTH = 8 + 8 + 1 + 1 ID = 20 SUBASSET_ID = 21 # NOTE: Pascal strings are used for storing descriptions for backwards‐compatibility. def initialise(db): cursor = db.cursor() cursor.execute('''CREATE TABLE IF NOT EXISTS issuances( tx_index INTEGER PRIMARY KEY, tx_hash TEXT UNIQUE, block_index INTEGER, asset TEXT, quantity INTEGER, divisible BOOL, source TEXT, issuer TEXT, transfer BOOL, callable BOOL, call_date INTEGER, call_price REAL, description TEXT, fee_paid INTEGER, locked BOOL, status TEXT, asset_longname TEXT, reset BOOL, FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index)) ''') # Add asset_longname for sub-assets # SQLite can’t do `ALTER TABLE IF COLUMN NOT EXISTS`. columns = [column['name'] for column in cursor.execute('''PRAGMA table_info(issuances)''')] if 'asset_longname' not in columns: cursor.execute('''ALTER TABLE issuances ADD COLUMN asset_longname TEXT''') if 'reset' not in columns: cursor.execute('''ALTER TABLE issuances ADD COLUMN reset BOOL''') # If sweep_hotfix activated, Create issuances copy, copy old data, drop old table, rename new table, recreate indexes # SQLite can’t do `ALTER TABLE IF COLUMN NOT EXISTS` nor can drop UNIQUE constraints if 'msg_index' not in columns: cursor.execute('''CREATE TABLE IF NOT EXISTS new_issuances( tx_index INTEGER, tx_hash TEXT, msg_index INTEGER DEFAULT 0, block_index INTEGER, asset TEXT, quantity INTEGER, divisible BOOL, source TEXT, issuer TEXT, transfer BOOL, callable BOOL, call_date INTEGER, call_price REAL, description TEXT, fee_paid INTEGER, locked BOOL, status TEXT, asset_longname TEXT, reset BOOL, PRIMARY KEY (tx_index, msg_index), FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index), UNIQUE (tx_hash, msg_index)) ''') cursor.execute('''INSERT INTO new_issuances(tx_index, tx_hash, msg_index, block_index, asset, quantity, divisible, source, issuer, transfer, callable, call_date, call_price, description, fee_paid, locked, status, asset_longname, reset) SELECT tx_index, tx_hash, 0, block_index, asset, quantity, divisible, source, issuer, transfer, callable, call_date, call_price, description, fee_paid, locked, status, asset_longname, reset FROM issuances''', {}) cursor.execute('DROP TABLE issuances') cursor.execute('ALTER TABLE new_issuances RENAME TO issuances') cursor.execute('''CREATE INDEX IF NOT EXISTS block_index_idx ON issuances (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS valid_asset_idx ON issuances (asset, status) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS status_idx ON issuances (status) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS source_idx ON issuances (source) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS asset_longname_idx ON issuances (asset_longname) ''') def validate (db, source, destination, asset, quantity, divisible, lock, reset, callable_, call_date, call_price, description, subasset_parent, subasset_longname, block_index): problems = [] fee = 0 if asset in (config.BTC, config.XCP): problems.append('cannot issue {} or {}'.format(config.BTC, config.XCP)) if call_date is None: call_date = 0 if call_price is None: call_price = 0.0 if description is None: description = "" if divisible is None: divisible = True if lock is None: lock = False if reset is None: reset = False if isinstance(call_price, int): call_price = float(call_price) #^ helps especially with calls from JS‐based clients, where parseFloat(15) returns 15 (not 15.0), which json takes as an int if not isinstance(quantity, int): problems.append('quantity must be in satoshis') return call_date, call_price, problems, fee, description, divisible, None, None if call_date and not isinstance(call_date, int): problems.append('call_date must be epoch integer') return call_date, call_price, problems, fee, description, divisible, None, None if call_price and not isinstance(call_price, float): problems.append('call_price must be a float') return call_date, call_price, problems, fee, description, divisible, None, None if quantity < 0: problems.append('negative quantity') if call_price < 0: problems.append('negative call price') if call_date < 0: problems.append('negative call date') # Callable, or not. if not callable_: if block_index >= 312500 or config.TESTNET or config.REGTEST: # Protocol change. call_date = 0 call_price = 0.0 elif block_index >= 310000: # Protocol change. if call_date: problems.append('call date for non‐callable asset') if call_price: problems.append('call price for non‐callable asset') # Valid re-issuance? cursor = db.cursor() cursor.execute('''SELECT * FROM issuances \ WHERE (status = ? AND asset = ?) ORDER BY tx_index ASC''', ('valid', asset)) issuances = cursor.fetchall() cursor.close() reissued_asset_longname = None if issuances: reissuance = True last_issuance = issuances[-1] reissued_asset_longname = last_issuance['asset_longname'] issuance_locked = False if util.enabled('issuance_lock_fix'): for issuance in issuances: if issuance['locked']: issuance_locked = True break elif last_issuance['locked']: # before the issuance_lock_fix, only the last issuance was checked issuance_locked = True if last_issuance['issuer'] != source: problems.append('issued by another address') if (bool(last_issuance['divisible']) != bool(divisible)) and ((not util.enabled("cip03", block_index)) or (not reset)): problems.append('cannot change divisibility') if bool(last_issuance['callable']) != bool(callable_): problems.append('cannot change callability') if last_issuance['call_date'] > call_date and (call_date != 0 or (block_index < 312500 and (not config.TESTNET or not config.REGTEST))): problems.append('cannot advance call date') if last_issuance['call_price'] > call_price: problems.append('cannot reduce call price') if issuance_locked and quantity: problems.append('locked asset and non‐zero quantity') if issuance_locked and reset: problems.append('cannot reset a locked asset') else: reissuance = False if description.lower() == 'lock': problems.append('cannot lock a non‐existent asset') #if destination: # problems.append('cannot transfer a non‐existent asset') if reset: problems.append('cannot reset a non existent asset') # validate parent ownership for subasset if subasset_longname is not None and not reissuance: cursor = db.cursor() cursor.execute('''SELECT * FROM issuances \ WHERE (status = ? AND asset = ?) ORDER BY tx_index ASC''', ('valid', subasset_parent)) parent_issuances = cursor.fetchall() cursor.close() if parent_issuances: last_parent_issuance = parent_issuances[-1] if last_parent_issuance['issuer'] != source: problems.append('parent asset owned by another address') else: problems.append('parent asset not found') # validate subasset issuance is not a duplicate if subasset_longname is not None and not reissuance: cursor = db.cursor() cursor.execute('''SELECT * FROM assets \ WHERE (asset_longname = ?)''', (subasset_longname,)) assets = cursor.fetchall() if len(assets) > 0: problems.append('subasset already exists') # validate that the actual asset is numeric if asset[0] != 'A': problems.append('a subasset must be a numeric asset') # Check for existence of fee funds. if quantity or (block_index >= 315000 or config.TESTNET or config.REGTEST): # Protocol change. if not reissuance or (block_index < 310000 and not config.TESTNET and not config.REGTEST): # Pay fee only upon first issuance. (Protocol change.) cursor = db.cursor() cursor.execute('''SELECT * FROM balances \ WHERE (address = ? AND asset = ?)''', (source, config.XCP)) balances = cursor.fetchall() cursor.close() if util.enabled('numeric_asset_names'): # Protocol change. if subasset_longname is not None and util.enabled('subassets'): # Protocol change. # subasset issuance is 0.25 fee = int(0.25 * config.UNIT) elif len(asset) >= 13: fee = 0 else: fee = int(0.5 * config.UNIT) elif block_index >= 291700 or config.TESTNET or config.REGTEST: # Protocol change. fee = int(0.5 * config.UNIT) elif block_index >= 286000 or config.TESTNET or config.REGTEST: # Protocol change. fee = 5 * config.UNIT elif block_index > 281236 or config.TESTNET or config.REGTEST: # Protocol change. fee = 5 if fee and (not balances or balances[0]['quantity'] < fee): problems.append('insufficient funds') if not (block_index >= 317500 or config.TESTNET or config.REGTEST): # Protocol change. if len(description) > 42: problems.append('description too long') # For SQLite3 call_date = min(call_date, config.MAX_INT) assert isinstance(quantity, int) if reset and util.enabled("cip03", block_index):#reset will overwrite the quantity if quantity > config.MAX_INT: problems.append('total quantity overflow') else: total = sum([issuance['quantity'] for issuance in issuances]) if total + quantity > config.MAX_INT: problems.append('total quantity overflow') if util.enabled("cip03", block_index) and reset and issuances: cursor = db.cursor() #Checking that all supply are held by the owner of the asset cursor.execute('''SELECT * FROM balances \ WHERE asset = ? AND quantity > 0''', (asset,)) balances = cursor.fetchall() cursor.close() if (len(balances) == 0): if util.asset_supply(db, asset) > 0: problems.append('Cannot reset an asset with no holder') elif (len(balances) > 1): problems.append('Cannot reset an asset with many holders') elif (len(balances) == 1): if (balances[0]['address'] != last_issuance["issuer"]): problems.append('Cannot reset an asset held by a different address than the owner') #if destination and quantity: # problems.append('cannot issue and transfer simultaneously') # For SQLite3 if util.enabled('integer_overflow_fix', block_index=block_index) and (fee > config.MAX_INT or quantity > config.MAX_INT): problems.append('integer overflow') return call_date, call_price, problems, fee, description, divisible, lock, reset, reissuance, reissued_asset_longname def compose (db, source, transfer_destination, asset, quantity, divisible, lock, reset, description): # Callability is deprecated, so for re‐issuances set relevant parameters # to old values; for first issuances, make uncallable. cursor = db.cursor() cursor.execute('''SELECT * FROM issuances \ WHERE (status = ? AND asset = ?) ORDER BY tx_index ASC''', ('valid', asset)) issuances = cursor.fetchall() if issuances: last_issuance = issuances[-1] callable_ = last_issuance['callable'] call_date = last_issuance['call_date'] call_price = last_issuance['call_price'] else: callable_ = False call_date = 0 call_price = 0.0 cursor.close() # check subasset subasset_parent = None subasset_longname = None if util.enabled('subassets'): # Protocol change. subasset_parent, subasset_longname = util.parse_subasset_from_asset_name(asset) if subasset_longname is not None: # try to find an existing subasset sa_cursor = db.cursor() sa_cursor.execute('''SELECT * FROM assets \ WHERE (asset_longname = ?)''', (subasset_longname,)) assets = sa_cursor.fetchall() sa_cursor.close() if len(assets) > 0: # this is a reissuance asset = assets[0]['asset_name'] else: # this is a new issuance # generate a random numeric asset id which will map to this subasset asset = util.generate_random_asset() call_date, call_price, problems, fee, description, divisible, lock, reset, reissuance, reissued_asset_longname = validate(db, source, transfer_destination, asset, quantity, divisible, lock, reset, callable_, call_date, call_price, description, subasset_parent, subasset_longname, util.CURRENT_BLOCK_INDEX) if problems: raise exceptions.ComposeError(problems) asset_id = util.generate_asset_id(asset, util.CURRENT_BLOCK_INDEX) if subasset_longname is None or reissuance: asset_format = util.get_value_by_block_index("issuance_asset_serialization_format") asset_format_length = util.get_value_by_block_index("issuance_asset_serialization_length") # Type 20 standard issuance FORMAT_2 >QQ??If # used for standard issuances and all reissuances data = message_type.pack(ID) if (len(description) <= 42) and not util.enabled('pascal_string_removed'): curr_format = FORMAT_2 + '{}p'.format(len(description) + 1) else: curr_format = asset_format + '{}s'.format(len(description)) if (asset_format_length <= 19):# callbacks parameters were removed data += struct.pack(curr_format, asset_id, quantity, 1 if divisible else 0, 1 if lock else 0, 1 if reset else 0, description.encode('utf-8')) elif (asset_format_length <= 26): data += struct.pack(curr_format, asset_id, quantity, 1 if divisible else 0, 1 if callable_ else 0, call_date or 0, call_price or 0.0, description.encode('utf-8')) elif (asset_format_length <= 27):# param reset was inserted data += struct.pack(curr_format, asset_id, quantity, 1 if divisible else 0, 1 if reset else 0, 1 if callable_ else 0, call_date or 0, call_price or 0.0, description.encode('utf-8')) elif (asset_format_length <= 28):# param lock was inserted data += struct.pack(curr_format, asset_id, quantity, 1 if divisible else 0, 1 if lock else 0, 1 if reset else 0, 1 if callable_ else 0, call_date or 0, call_price or 0.0, description.encode('utf-8')) else: subasset_format = util.get_value_by_block_index("issuance_subasset_serialization_format",util.CURRENT_BLOCK_INDEX) subasset_format_length = util.get_value_by_block_index("issuance_subasset_serialization_length",util.CURRENT_BLOCK_INDEX) # Type 21 subasset issuance SUBASSET_FORMAT >QQ?B # Used only for initial subasset issuance # compacts a subasset name to save space compacted_subasset_longname = util.compact_subasset_longname(subasset_longname) compacted_subasset_length = len(compacted_subasset_longname) data = message_type.pack(SUBASSET_ID) curr_format = subasset_format + '{}s'.format(compacted_subasset_length) + '{}s'.format(len(description)) if subasset_format_length <= 18: data += struct.pack(curr_format, asset_id, quantity, 1 if divisible else 0, compacted_subasset_length, compacted_subasset_longname, description.encode('utf-8')) elif subasset_format_length <= 19:# param reset was inserted data += struct.pack(curr_format, asset_id, quantity, 1 if divisible else 0, 1 if reset else 0, compacted_subasset_length, compacted_subasset_longname, description.encode('utf-8')) elif subasset_format_length <= 20:# param lock was inserted data += struct.pack(curr_format, asset_id, quantity, 1 if divisible else 0, 1 if lock else 0, 1 if reset else 0, compacted_subasset_length, compacted_subasset_longname, description.encode('utf-8')) if transfer_destination: destination_outputs = [(transfer_destination, None)] else: destination_outputs = [] return (source, destination_outputs, data) def parse (db, tx, message, message_type_id): issuance_parse_cursor = db.cursor() asset_format = util.get_value_by_block_index("issuance_asset_serialization_format",tx['block_index']) asset_format_length = util.get_value_by_block_index("issuance_asset_serialization_length",tx['block_index']) subasset_format = util.get_value_by_block_index("issuance_subasset_serialization_format",tx['block_index']) subasset_format_length = util.get_value_by_block_index("issuance_subasset_serialization_length",tx['block_index']) # Unpack message. try: subasset_longname = None if message_type_id == SUBASSET_ID: if not util.enabled('subassets', block_index=tx['block_index']): logger.warn("subassets are not enabled at block %s" % tx['block_index']) raise exceptions.UnpackError # parse a subasset original issuance message lock = None reset = None if subasset_format_length <= 18: asset_id, quantity, divisible, compacted_subasset_length = struct.unpack(subasset_format, message[0:subasset_format_length]) elif subasset_format_length <= 19:# param reset was inserted asset_id, quantity, divisible, reset, compacted_subasset_length = struct.unpack(subasset_format, message[0:subasset_format_length]) elif subasset_format_length <= 20:# param lock was inserted asset_id, quantity, divisible, lock, reset, compacted_subasset_length = struct.unpack(subasset_format, message[0:subasset_format_length]) description_length = len(message) - subasset_format_length - compacted_subasset_length if description_length < 0: logger.warn("invalid subasset length: [issuance] tx [%s]: %s" % (tx['tx_hash'], compacted_subasset_length)) raise exceptions.UnpackError messages_format = '>{}s{}s'.format(compacted_subasset_length, description_length) compacted_subasset_longname, description = struct.unpack(messages_format, message[subasset_format_length:]) subasset_longname = util.expand_subasset_longname(compacted_subasset_longname) callable_, call_date, call_price = False, 0, 0.0 try: description = description.decode('utf-8') except UnicodeDecodeError: description = '' elif (tx['block_index'] > 283271 or config.TESTNET or config.REGTEST) and len(message) >= asset_format_length: # Protocol change. if (len(message) - asset_format_length <= 42) and not util.enabled('pascal_string_removed'): curr_format = asset_format + '{}p'.format(len(message) - asset_format_length) else: curr_format = asset_format + '{}s'.format(len(message) - asset_format_length) lock = None reset = None if (asset_format_length <= 19):# callbacks parameters were removed asset_id, quantity, divisible, lock, reset, description = struct.unpack(curr_format, message) callable_, call_date, call_price = False, 0, 0.0 elif (asset_format_length <= 26):#the reset param didn't even exist asset_id, quantity, divisible, callable_, call_date, call_price, description = struct.unpack(curr_format, message) elif (asset_format_length <= 27):# param reset was inserted asset_id, quantity, divisible, reset, callable_, call_date, call_price, description = struct.unpack(curr_format, message) elif (asset_format_length <= 28):# param lock was inserted asset_id, quantity, divisible, lock, reset, callable_, call_date, call_price, description = struct.unpack(curr_format, message) call_price = round(call_price, 6) # TODO: arbitrary try: description = description.decode('utf-8') except UnicodeDecodeError: description = '' else: if len(message) != LENGTH_1: raise exceptions.UnpackError asset_id, quantity, divisible = struct.unpack(FORMAT_1, message) lock, reset, callable_, call_date, call_price, description = False, False, False, 0, 0.0, '' try: asset = util.generate_asset_name(asset_id, tx['block_index']) ##This is for backwards compatibility with assets names longer than 12 characters if asset.startswith('A'): namedAsset = util.get_asset_name(db, asset_id, tx['block_index']) if (namedAsset != 0): asset = namedAsset status = 'valid' except exceptions.AssetIDError: asset = None status = 'invalid: bad asset name' except exceptions.UnpackError as e: asset, quantity, divisible, lock, reset, callable_, call_date, call_price, description = None, None, None, None, None, None, None, None, None status = 'invalid: could not unpack' # parse and validate the subasset from the message subasset_parent = None if status == 'valid' and subasset_longname is not None: # Protocol change. try: # ensure the subasset_longname is valid util.validate_subasset_longname(subasset_longname) subasset_parent, subasset_longname = util.parse_subasset_from_asset_name(subasset_longname) except exceptions.AssetNameError as e: asset = None status = 'invalid: bad subasset name' reissuance = None fee = 0 if status == 'valid': call_date, call_price, problems, fee, description, divisible, lock, reset, reissuance, reissued_asset_longname = validate(db, tx['source'], tx['destination'], asset, quantity, divisible, lock, reset, callable_, call_date, call_price, description, subasset_parent, subasset_longname, block_index=tx['block_index']) if problems: status = 'invalid: ' + '; '.join(problems) if not util.enabled('integer_overflow_fix', block_index=tx['block_index']) and 'total quantity overflow' in problems: quantity = 0 # Reset? if (status == 'valid') and reset and util.enabled("cip03", tx['block_index']): balances_cursor = issuance_parse_cursor.execute('''SELECT * FROM balances WHERE asset = ? AND quantity > 0''', (asset,)) balances_result = balances_cursor.fetchall() if len(balances_result) <= 1: if len(balances_result) == 0: issuances_cursor = issuance_parse_cursor.execute('''SELECT * FROM issuances WHERE asset = ? ORDER BY tx_index DESC''', (asset,)) issuances_result = issuances_cursor.fetchall() owner_balance = 0 owner_address = issuances_result[0]['issuer'] else: owner_balance = balances_result[0]["quantity"] owner_address = balances_result[0]["address"] if owner_address == tx['source']: if owner_balance > 0: util.debit(db, tx['source'], asset, owner_balance, 'reset destroy', tx['tx_hash']) bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'source': tx['source'], 'asset': asset, 'quantity': owner_balance, 'tag': "reset", 'status': "valid", 'reset': True, } sql = 'insert into destructions values(:tx_index, :tx_hash, :block_index, :source, :asset, :quantity, :tag, :status)' issuance_parse_cursor.execute(sql, bindings) bindings= { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'asset': asset, 'quantity': quantity, 'divisible': divisible, 'source': tx['source'], 'issuer': tx['source'], 'transfer': False, 'callable': callable_, 'call_date': call_date, 'call_price': call_price, 'description': description, 'fee_paid': 0, 'locked': lock, 'status': status, 'reset': True, 'asset_longname': reissued_asset_longname, } sql='insert into issuances values(:tx_index, :tx_hash, 0, :block_index, :asset, :quantity, :divisible, :source, :issuer, :transfer, :callable, :call_date, :call_price, :description, :fee_paid, :locked, :status, :asset_longname, :reset)' issuance_parse_cursor.execute(sql, bindings) # Credit. if quantity: util.credit(db, tx['source'], asset, quantity, action="reset issuance", event=tx['tx_hash']) else: if tx['destination']: issuer = tx['destination'] transfer = True #quantity = 0 else: issuer = tx['source'] transfer = False # Debit fee. if status == 'valid': util.debit(db, tx['source'], config.XCP, fee, action="issuance fee", event=tx['tx_hash']) # Lock? if not isinstance(lock,bool): lock = False if status == 'valid': if (description and description.lower() == 'lock') or lock: lock = True cursor = db.cursor() issuances = list(cursor.execute('''SELECT * FROM issuances \ WHERE (status = ? AND asset = ?) ORDER BY tx_index ASC''', ('valid', asset))) cursor.close() if len(issuances) > 0: description = issuances[-1]['description'] # Use last description if not reissuance: # Add to table of assets. bindings= { 'asset_id': str(asset_id), 'asset_name': str(asset), 'block_index': tx['block_index'], 'asset_longname': subasset_longname, } sql='insert into assets values(:asset_id, :asset_name, :block_index, :asset_longname)' issuance_parse_cursor.execute(sql, bindings) if status == 'valid' and reissuance: # when reissuing, add the asset_longname to the issuances table for API lookups asset_longname = reissued_asset_longname else: asset_longname = subasset_longname # Add parsed transaction to message-type–specific table. bindings= { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'asset': asset, 'quantity': quantity, 'divisible': divisible, 'source': tx['source'], 'issuer': issuer, 'transfer': transfer, 'callable': callable_, 'call_date': call_date, 'call_price': call_price, 'description': description, 'fee_paid': fee, 'locked': lock, 'reset': reset, 'status': status, 'asset_longname': asset_longname, } if "integer overflow" not in status: sql='insert into issuances values(:tx_index, :tx_hash, 0, :block_index, :asset, :quantity, :divisible, :source, :issuer, :transfer, :callable, :call_date, :call_price, :description, :fee_paid, :locked, :status, :asset_longname, :reset)' issuance_parse_cursor.execute(sql, bindings) else: logger.warn("Not storing [issuance] tx [%s]: %s" % (tx['tx_hash'], status)) logger.debug("Bindings: %s" % (json.dumps(bindings), )) # Credit. if status == 'valid' and quantity: util.credit(db, tx['source'], asset, quantity, action="issuance", event=tx['tx_hash']) issuance_parse_cursor.close() # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 #### order.py #! /usr/bin/python3 # Filled orders may not be re‐opened, so only orders not involving BTC (and so # which cannot have expired order matches) may be filled. import json import struct import decimal D = decimal.Decimal import logging logger = logging.getLogger(__name__) from counterpartylib.lib import config from counterpartylib.lib import exceptions from counterpartylib.lib import util from counterpartylib.lib import backend from counterpartylib.lib import log from counterpartylib.lib import message_type FORMAT = '>QQQQHQ' LENGTH = 8 + 8 + 8 + 8 + 2 + 8 ID = 10 def initialise(db): cursor = db.cursor() # Orders cursor.execute('''CREATE TABLE IF NOT EXISTS orders( tx_index INTEGER UNIQUE, tx_hash TEXT UNIQUE, block_index INTEGER, source TEXT, give_asset TEXT, give_quantity INTEGER, give_remaining INTEGER, get_asset TEXT, get_quantity INTEGER, get_remaining INTEGER, expiration INTEGER, expire_index INTEGER, fee_required INTEGER, fee_required_remaining INTEGER, fee_provided INTEGER, fee_provided_remaining INTEGER, status TEXT, FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index), PRIMARY KEY (tx_index, tx_hash)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS block_index_idx ON orders (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS index_hash_idx ON orders (tx_index, tx_hash) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS expire_idx ON orders (expire_index, status) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS give_status_idx ON orders (give_asset, status) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS source_give_status_idx ON orders (source, give_asset, status) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS give_get_status_idx ON orders (get_asset, give_asset, status) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS source_idx ON orders (source) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS give_asset_idx ON orders (give_asset) ''') # Order Matches cursor.execute('''CREATE TABLE IF NOT EXISTS order_matches( id TEXT PRIMARY KEY, tx0_index INTEGER, tx0_hash TEXT, tx0_address TEXT, tx1_index INTEGER, tx1_hash TEXT, tx1_address TEXT, forward_asset TEXT, forward_quantity INTEGER, backward_asset TEXT, backward_quantity INTEGER, tx0_block_index INTEGER, tx1_block_index INTEGER, block_index INTEGER, tx0_expiration INTEGER, tx1_expiration INTEGER, match_expire_index INTEGER, fee_paid INTEGER, status TEXT, FOREIGN KEY (tx0_index, tx0_hash, tx0_block_index) REFERENCES transactions(tx_index, tx_hash, block_index), FOREIGN KEY (tx1_index, tx1_hash, tx1_block_index) REFERENCES transactions(tx_index, tx_hash, block_index)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS match_expire_idx ON order_matches (status, match_expire_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS forward_status_idx ON order_matches (forward_asset, status) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS backward_status_idx ON order_matches (backward_asset, status) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS id_idx ON order_matches (id) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS tx0_address_idx ON order_matches (tx0_address) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS tx1_address_idx ON order_matches (tx1_address) ''') # Order Expirations cursor.execute('''CREATE TABLE IF NOT EXISTS order_expirations( order_index INTEGER PRIMARY KEY, order_hash TEXT UNIQUE, source TEXT, block_index INTEGER, FOREIGN KEY (block_index) REFERENCES blocks(block_index), FOREIGN KEY (order_index, order_hash) REFERENCES orders(tx_index, tx_hash)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS block_index_idx ON order_expirations (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS source_idx ON order_expirations (source) ''') # Order Match Expirations cursor.execute('''CREATE TABLE IF NOT EXISTS order_match_expirations( order_match_id TEXT PRIMARY KEY, tx0_address TEXT, tx1_address TEXT, block_index INTEGER, FOREIGN KEY (order_match_id) REFERENCES order_matches(id), FOREIGN KEY (block_index) REFERENCES blocks(block_index)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS block_index_idx ON order_match_expirations (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS tx0_address_idx ON order_match_expirations (tx0_address) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS tx1_address_idx ON order_match_expirations (tx1_address) ''') def exact_penalty (db, address, block_index, order_match_id): # Penalize addresses that don’t make BTC payments. If an address lets an # order match expire, expire sell BTC orders from that address. cursor = db.cursor() # Orders. bad_orders = list(cursor.execute('''SELECT * FROM orders \ WHERE (source = ? AND give_asset = ? AND status = ?)''', (address, config.BTC, 'open'))) for bad_order in bad_orders: cancel_order(db, bad_order, 'expired', block_index) if not (block_index >= 314250 or config.TESTNET or config.REGTEST): # Protocol change. # Order matches. bad_order_matches = list(cursor.execute('''SELECT * FROM order_matches \ WHERE ((tx0_address = ? AND forward_asset = ?) OR (tx1_address = ? AND backward_asset = ?)) AND (status = ?)''', (address, config.BTC, address, config.BTC, 'pending'))) for bad_order_match in bad_order_matches: cancel_order_match(db, bad_order_match, 'expired', block_index) cursor.close() return def cancel_order (db, order, status, block_index): cursor = db.cursor() # Update status of order. bindings = { 'status': status, 'tx_hash': order['tx_hash'] } sql='update orders set status = :status where tx_hash = :tx_hash' cursor.execute(sql, bindings) log.message(db, block_index, 'update', 'orders', bindings) if order['give_asset'] != config.BTC: # Can’t credit BTC. util.credit(db, order['source'], order['give_asset'], order['give_remaining'], action='cancel order', event=order['tx_hash']) if status == 'expired': # Record offer expiration. bindings = { 'order_index': order['tx_index'], 'order_hash': order['tx_hash'], 'source': order['source'], 'block_index': block_index } sql='insert into order_expirations values(:order_index, :order_hash, :source, :block_index)' cursor.execute(sql, bindings) cursor.close() def cancel_order_match (db, order_match, status, block_index): '''The only cancelling is an expiration. ''' cursor = db.cursor() # Skip order matches just expired as a penalty. (Not very efficient.) if not (block_index >= 314250 or config.TESTNET or config.REGTEST): # Protocol change. order_matches = list(cursor.execute('''SELECT * FROM order_matches \ WHERE (id = ? AND status = ?)''', (order_match['id'], 'expired'))) if order_matches: cursor.close() return # Update status of order match. bindings = { 'status': status, 'order_match_id': order_match['id'] } sql='update order_matches set status = :status where id = :order_match_id' cursor.execute(sql, bindings) log.message(db, block_index, 'update', 'order_matches', bindings) order_match_id = util.make_id(order_match['tx0_hash'], order_match['tx1_hash']) # If tx0 is dead, credit address directly; if not, replenish give remaining, get remaining, and fee required remaining. orders = list(cursor.execute('''SELECT * FROM orders \ WHERE tx_index = ?''', (order_match['tx0_index'],))) assert len(orders) == 1 tx0_order = orders[0] if tx0_order['status'] in ('expired', 'cancelled'): tx0_order_status = tx0_order['status'] if order_match['forward_asset'] != config.BTC: util.credit(db, order_match['tx0_address'], order_match['forward_asset'], order_match['forward_quantity'], action='order {}'.format(tx0_order_status), event=order_match['id']) else: tx0_give_remaining = tx0_order['give_remaining'] + order_match['forward_quantity'] tx0_get_remaining = tx0_order['get_remaining'] + order_match['backward_quantity'] if tx0_order['get_asset'] == config.BTC and (block_index >= 297000 or config.TESTNET or config.REGTEST): # Protocol change. tx0_fee_required_remaining = tx0_order['fee_required_remaining'] + order_match['fee_paid'] else: tx0_fee_required_remaining = tx0_order['fee_required_remaining'] tx0_order_status = tx0_order['status'] if (tx0_order_status == 'filled' and util.enabled("reopen_order_when_btcpay_expires_fix", block_index)): #This case could happen if a BTCpay expires and before the expiration, the order was filled by a correct BTCpay tx0_order_status = 'open' # So, we have to open the order again bindings = { 'give_remaining': tx0_give_remaining, 'get_remaining': tx0_get_remaining, 'status': tx0_order_status, 'fee_required_remaining': tx0_fee_required_remaining, 'tx_hash': order_match['tx0_hash'] } sql='update orders set give_remaining = :give_remaining, get_remaining = :get_remaining, fee_required_remaining = :fee_required_remaining, status = :status where tx_hash = :tx_hash' cursor.execute(sql, bindings) log.message(db, block_index, 'update', 'orders', bindings) # If tx1 is dead, credit address directly; if not, replenish give remaining, get remaining, and fee required remaining. orders = list(cursor.execute('''SELECT * FROM orders \ WHERE tx_index = ?''', (order_match['tx1_index'],))) assert len(orders) == 1 tx1_order = orders[0] if tx1_order['status'] in ('expired', 'cancelled'): tx1_order_status = tx1_order['status'] if order_match['backward_asset'] != config.BTC: util.credit(db, order_match['tx1_address'], order_match['backward_asset'], order_match['backward_quantity'], action='order {}'.format(tx1_order_status), event=order_match['id']) else: tx1_give_remaining = tx1_order['give_remaining'] + order_match['backward_quantity'] tx1_get_remaining = tx1_order['get_remaining'] + order_match['forward_quantity'] if tx1_order['get_asset'] == config.BTC and (block_index >= 297000 or config.TESTNET or config.REGTEST): # Protocol change. tx1_fee_required_remaining = tx1_order['fee_required_remaining'] + order_match['fee_paid'] else: tx1_fee_required_remaining = tx1_order['fee_required_remaining'] tx1_order_status = tx1_order['status'] if (tx1_order_status == 'filled' and util.enabled("reopen_order_when_btcpay_expires_fix", block_index)): #This case could happen if a BTCpay expires and before the expiration, the order was filled by a correct BTCpay tx1_order_status = 'open' # So, we have to open the order again bindings = { 'give_remaining': tx1_give_remaining, 'get_remaining': tx1_get_remaining, 'status': tx1_order_status, 'fee_required_remaining': tx1_fee_required_remaining, 'tx_hash': order_match['tx1_hash'] } sql='update orders set give_remaining = :give_remaining, get_remaining = :get_remaining, fee_required_remaining = :fee_required_remaining, status = :status where tx_hash = :tx_hash' cursor.execute(sql, bindings) log.message(db, block_index, 'update', 'orders', bindings) if block_index < 286500: # Protocol change. # Sanity check: one of the two must have expired. tx0_order_time_left = tx0_order['expire_index'] - block_index tx1_order_time_left = tx1_order['expire_index'] - block_index assert tx0_order_time_left or tx1_order_time_left # Penalize tardiness. if block_index >= 313900 or config.TESTNET or config.REGTEST: # Protocol change. if tx0_order['status'] == 'expired' and order_match['forward_asset'] == config.BTC: exact_penalty(db, order_match['tx0_address'], block_index, order_match['id']) if tx1_order['status'] == 'expired' and order_match['backward_asset'] == config.BTC: exact_penalty(db, order_match['tx1_address'], block_index, order_match['id']) # Re‐match. if block_index >= 310000 or config.TESTNET or config.REGTEST: # Protocol change. if not (block_index >= 315000 or config.TESTNET or config.REGTEST): # Protocol change. cursor.execute('''SELECT * FROM transactions\ WHERE tx_hash = ?''', (tx0_order['tx_hash'],)) match(db, list(cursor)[0], block_index) cursor.execute('''SELECT * FROM transactions\ WHERE tx_hash = ?''', (tx1_order['tx_hash'],)) match(db, list(cursor)[0], block_index) if status == 'expired': # Record order match expiration. bindings = { 'order_match_id': order_match['id'], 'tx0_address': order_match['tx0_address'], 'tx1_address': order_match['tx1_address'], 'block_index': block_index } sql='insert into order_match_expirations values(:order_match_id, :tx0_address, :tx1_address, :block_index)' cursor.execute(sql, bindings) cursor.close() def validate (db, source, give_asset, give_quantity, get_asset, get_quantity, expiration, fee_required, block_index): problems = [] cursor = db.cursor() # For SQLite3 if give_quantity > config.MAX_INT or get_quantity > config.MAX_INT or fee_required > config.MAX_INT or block_index + expiration > config.MAX_INT: problems.append('integer overflow') if give_asset == config.BTC and get_asset == config.BTC: problems.append('cannot trade {} for itself'.format(config.BTC)) if not isinstance(give_quantity, int): problems.append('give_quantity must be in satoshis') return problems if not isinstance(get_quantity, int): problems.append('get_quantity must be in satoshis') return problems if not isinstance(fee_required, int): problems.append('fee_required must be in satoshis') return problems if not isinstance(expiration, int): problems.append('expiration must be expressed as an integer block delta') return problems if give_quantity <= 0: problems.append('non‐positive give quantity') if get_quantity <= 0: problems.append('non‐positive get quantity') if fee_required < 0: problems.append('negative fee_required') if expiration < 0: problems.append('negative expiration') if expiration == 0 and not (block_index >= 317500 or config.TESTNET or config.REGTEST): # Protocol change. problems.append('zero expiration') if not give_quantity or not get_quantity: problems.append('zero give or zero get') cursor.execute('select * from issuances where (status = ? and asset = ?)', ('valid', give_asset)) if give_asset not in (config.BTC, config.XCP) and not cursor.fetchall(): problems.append('no such asset to give ({})'.format(give_asset)) cursor.execute('select * from issuances where (status = ? and asset = ?)', ('valid', get_asset)) if get_asset not in (config.BTC, config.XCP) and not cursor.fetchall(): problems.append('no such asset to get ({})'.format(get_asset)) if expiration > config.MAX_EXPIRATION: problems.append('expiration overflow') cursor.close() return problems def compose (db, source, give_asset, give_quantity, get_asset, get_quantity, expiration, fee_required): cursor = db.cursor() # resolve subassets give_asset = util.resolve_subasset_longname(db, give_asset) get_asset = util.resolve_subasset_longname(db, get_asset) # Check balance. if give_asset != config.BTC: balances = list(cursor.execute('''SELECT * FROM balances WHERE (address = ? AND asset = ?)''', (source, give_asset))) if (not balances or balances[0]['quantity'] < give_quantity): raise exceptions.ComposeError('insufficient funds') problems = validate(db, source, give_asset, give_quantity, get_asset, get_quantity, expiration, fee_required, util.CURRENT_BLOCK_INDEX) if problems: raise exceptions.ComposeError(problems) give_id = util.get_asset_id(db, give_asset, util.CURRENT_BLOCK_INDEX) get_id = util.get_asset_id(db, get_asset, util.CURRENT_BLOCK_INDEX) data = message_type.pack(ID) data += struct.pack(FORMAT, give_id, give_quantity, get_id, get_quantity, expiration, fee_required) cursor.close() return (source, [], data) def parse (db, tx, message): order_parse_cursor = db.cursor() # Unpack message. try: if len(message) != LENGTH: raise exceptions.UnpackError give_id, give_quantity, get_id, get_quantity, expiration, fee_required = struct.unpack(FORMAT, message) give_asset = util.get_asset_name(db, give_id, tx['block_index']) get_asset = util.get_asset_name(db, get_id, tx['block_index']) status = 'open' except (exceptions.UnpackError, exceptions.AssetNameError, struct.error) as e: give_asset, give_quantity, get_asset, get_quantity, expiration, fee_required = 0, 0, 0, 0, 0, 0 status = 'invalid: could not unpack' price = 0 if status == 'open': try: price = util.price(get_quantity, give_quantity) except ZeroDivisionError: price = 0 # Overorder order_parse_cursor.execute('''SELECT * FROM balances \ WHERE (address = ? AND asset = ?)''', (tx['source'], give_asset)) balances = list(order_parse_cursor) if give_asset != config.BTC: if not balances: give_quantity = 0 else: balance = balances[0]['quantity'] if balance < give_quantity: give_quantity = balance get_quantity = int(price * give_quantity) problems = validate(db, tx['source'], give_asset, give_quantity, get_asset, get_quantity, expiration, fee_required, tx['block_index']) if problems: status = 'invalid: ' + '; '.join(problems) if util.enabled('btc_order_minimum'): min_btc_quantity = 0.001 * config.UNIT # 0.001 BTC if util.enabled('btc_order_minimum_adjustment_1'): min_btc_quantity = 0.00001 * config.UNIT # 0.00001 BTC if (give_asset == config.BTC and give_quantity < min_btc_quantity) or (get_asset == config.BTC and get_quantity < min_btc_quantity): if problems: status += '; btc order below minimum' else: status = 'invalid: btc order below minimum' # Debit give quantity. (Escrow.) if status == 'open': if give_asset != config.BTC: # No need (or way) to debit BTC. util.debit(db, tx['source'], give_asset, give_quantity, action='open order', event=tx['tx_hash']) # Add parsed transaction to message-type–specific table. bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'source': tx['source'], 'give_asset': give_asset, 'give_quantity': give_quantity, 'give_remaining': give_quantity, 'get_asset': get_asset, 'get_quantity': get_quantity, 'get_remaining': get_quantity, 'expiration': expiration, 'expire_index': tx['block_index'] + expiration, 'fee_required': fee_required, 'fee_required_remaining': fee_required, 'fee_provided': tx['fee'], 'fee_provided_remaining': tx['fee'], 'status': status, } if "integer overflow" not in status: sql = 'insert into orders values(:tx_index, :tx_hash, :block_index, :source, :give_asset, :give_quantity, :give_remaining, :get_asset, :get_quantity, :get_remaining, :expiration, :expire_index, :fee_required, :fee_required_remaining, :fee_provided, :fee_provided_remaining, :status)' order_parse_cursor.execute(sql, bindings) else: logger.warn("Not storing [order] tx [%s]: %s" % (tx['tx_hash'], status)) logger.debug("Bindings: %s" % (json.dumps(bindings), )) # Match. if status == 'open' and tx['block_index'] != config.MEMPOOL_BLOCK_INDEX: match(db, tx) order_parse_cursor.close() def match (db, tx, block_index=None): cursor = db.cursor() # Get order in question. orders = list(cursor.execute('''SELECT * FROM orders\ WHERE (tx_index = ? AND status = ?)''', (tx['tx_index'], 'open'))) if not orders: cursor.close() return else: assert len(orders) == 1 tx1 = orders[0] cursor.execute('''SELECT * FROM orders \ WHERE (give_asset=? AND get_asset=? AND status=? AND tx_hash != ?)''', (tx1['get_asset'], tx1['give_asset'], 'open', tx1['tx_hash'])) tx1_give_remaining = tx1['give_remaining'] tx1_get_remaining = tx1['get_remaining'] order_matches = cursor.fetchall() if tx['block_index'] > 284500 or config.TESTNET or config.REGTEST: # Protocol change. order_matches = sorted(order_matches, key=lambda x: x['tx_index']) # Sort by tx index second. order_matches = sorted(order_matches, key=lambda x: util.price(x['get_quantity'], x['give_quantity'])) # Sort by price first. # Get fee remaining. tx1_fee_required_remaining = tx1['fee_required_remaining'] tx1_fee_provided_remaining = tx1['fee_provided_remaining'] tx1_status = tx1['status'] for tx0 in order_matches: order_match_id = util.make_id(tx0['tx_hash'], tx1['tx_hash']) if not block_index: block_index = max(tx0['block_index'], tx1['block_index']) if tx1_status != 'open': break logger.debug('Considering: ' + tx0['tx_hash']) tx0_give_remaining = tx0['give_remaining'] tx0_get_remaining = tx0['get_remaining'] # Ignore previous matches. (Both directions, just to be sure.) cursor.execute('''SELECT * FROM order_matches WHERE id = ? ''', (util.make_id(tx0['tx_hash'], tx1['tx_hash']), )) if list(cursor): logger.debug('Skipping: previous match') continue cursor.execute('''SELECT * FROM order_matches WHERE id = ? ''', (util.make_id(tx1['tx_hash'], tx0['tx_hash']), )) if list(cursor): logger.debug('Skipping: previous match') continue # Get fee provided remaining. tx0_fee_required_remaining = tx0['fee_required_remaining'] tx0_fee_provided_remaining = tx0['fee_provided_remaining'] # Make sure that that both orders still have funds remaining (if order involves BTC, and so cannot be ‘filled’). if tx0['give_asset'] == config.BTC or tx0['get_asset'] == config.BTC: # Gratuitous if tx0_give_remaining <= 0 or tx1_give_remaining <= 0: logger.debug('Skipping: negative give quantity remaining') continue if block_index >= 292000 and block_index <= 310500 and not config.TESTNET or config.REGTEST: # Protocol changes if tx0_get_remaining <= 0 or tx1_get_remaining <= 0: logger.debug('Skipping: negative get quantity remaining') continue if block_index >= 294000 or config.TESTNET or config.REGTEST: # Protocol change. if tx0['fee_required_remaining'] < 0: logger.debug('Skipping: negative tx0 fee required remaining') continue if tx0['fee_provided_remaining'] < 0: logger.debug('Skipping: negative tx0 fee provided remaining') continue if tx1_fee_provided_remaining < 0: logger.debug('Skipping: negative tx1 fee provided remaining') continue if tx1_fee_required_remaining < 0: logger.debug('Skipping: negative tx1 fee required remaining') continue # If the prices agree, make the trade. The found order sets the price, # and they trade as much as they can. tx0_price = util.price(tx0['get_quantity'], tx0['give_quantity']) tx1_price = util.price(tx1['get_quantity'], tx1['give_quantity']) tx1_inverse_price = util.price(tx1['give_quantity'], tx1['get_quantity']) # Protocol change. if tx['block_index'] < 286000: tx1_inverse_price = util.price(1, tx1_price) logger.debug('Tx0 Price: {}; Tx1 Inverse Price: {}'.format(float(tx0_price), float(tx1_inverse_price))) if tx0_price > tx1_inverse_price: logger.debug('Skipping: price mismatch.') else: logger.debug('Potential forward quantities: {}, {}'.format(tx0_give_remaining, int(util.price(tx1_give_remaining, tx0_price)))) forward_quantity = int(min(tx0_give_remaining, int(util.price(tx1_give_remaining, tx0_price)))) logger.debug('Forward Quantity: {}'.format(forward_quantity)) backward_quantity = round(forward_quantity * tx0_price) logger.debug('Backward Quantity: {}'.format(backward_quantity)) if not forward_quantity: logger.debug('Skipping: zero forward quantity.') continue if block_index >= 286500 or config.TESTNET or config.REGTEST: # Protocol change. if not backward_quantity: logger.debug('Skipping: zero backward quantity.') continue forward_asset, backward_asset = tx1['get_asset'], tx1['give_asset'] if block_index >= 313900 or config.TESTNET or config.REGTEST: # Protocol change. min_btc_quantity = 0.001 * config.UNIT # 0.001 BTC if (forward_asset == config.BTC and forward_quantity <= min_btc_quantity) or (backward_asset == config.BTC and backward_quantity <= min_btc_quantity): logger.debug('Skipping: below minimum {} quantity'.format(config.BTC)) continue # Check and update fee remainings. fee = 0 if block_index >= 286500 or config.TESTNET or config.REGTEST: # Protocol change. Deduct fee_required from provided_remaining, etc., if possible (else don’t match). if tx1['get_asset'] == config.BTC: if block_index >= 310500 or config.TESTNET or config.REGTEST: # Protocol change. fee = int(tx1['fee_required'] * util.price(backward_quantity, tx1['give_quantity'])) else: fee = int(tx1['fee_required_remaining'] * util.price(forward_quantity, tx1_get_remaining)) logger.debug('Tx0 fee provided remaining: {}; required fee: {}'.format(tx0_fee_provided_remaining / config.UNIT, fee / config.UNIT)) if tx0_fee_provided_remaining < fee: logger.debug('Skipping: tx0 fee provided remaining is too low.') continue else: tx0_fee_provided_remaining -= fee if block_index >= 287800 or config.TESTNET or config.REGTEST: # Protocol change. tx1_fee_required_remaining -= fee elif tx1['give_asset'] == config.BTC: if block_index >= 310500 or config.TESTNET or config.REGTEST: # Protocol change. fee = int(tx0['fee_required'] * util.price(backward_quantity, tx0['give_quantity'])) else: fee = int(tx0['fee_required_remaining'] * util.price(backward_quantity, tx0_get_remaining)) logger.debug('Tx1 fee provided remaining: {}; required fee: {}'.format(tx1_fee_provided_remaining / config.UNIT, fee / config.UNIT)) if tx1_fee_provided_remaining < fee: logger.debug('Skipping: tx1 fee provided remaining is too low.') continue else: tx1_fee_provided_remaining -= fee if block_index >= 287800 or config.TESTNET or config.REGTEST: # Protocol change. tx0_fee_required_remaining -= fee else: # Don’t deduct. if tx1['get_asset'] == config.BTC: if tx0_fee_provided_remaining < tx1['fee_required']: continue elif tx1['give_asset'] == config.BTC: if tx1_fee_provided_remaining < tx0['fee_required']: continue if config.BTC in (tx1['give_asset'], tx1['get_asset']): status = 'pending' else: status = 'completed' # Credit. util.credit(db, tx1['source'], tx1['get_asset'], forward_quantity, action='order match', event=order_match_id) util.credit(db, tx0['source'], tx0['get_asset'], backward_quantity, action='order match', event=order_match_id) # Debit the order, even if it involves giving bitcoins, and so one # can't debit the sending account. # Get remainings may be negative. tx0_give_remaining -= forward_quantity tx0_get_remaining -= backward_quantity tx1_give_remaining -= backward_quantity tx1_get_remaining -= forward_quantity # Update give_remaining, get_remaining. # tx0 tx0_status = 'open' if tx0_give_remaining <= 0 or (tx0_get_remaining <= 0 and (block_index >= 292000 or config.TESTNET or config.REGTEST)): # Protocol change if tx0['give_asset'] != config.BTC and tx0['get_asset'] != config.BTC: # Fill order, and recredit give_remaining. tx0_status = 'filled' util.credit(db, tx0['source'], tx0['give_asset'], tx0_give_remaining, event=tx1['tx_hash'], action='filled') bindings = { 'give_remaining': tx0_give_remaining, 'get_remaining': tx0_get_remaining, 'fee_required_remaining': tx0_fee_required_remaining, 'fee_provided_remaining': tx0_fee_provided_remaining, 'status': tx0_status, 'tx_hash': tx0['tx_hash'] } sql='update orders set give_remaining = :give_remaining, get_remaining = :get_remaining, fee_required_remaining = :fee_required_remaining, fee_provided_remaining = :fee_provided_remaining, status = :status where tx_hash = :tx_hash' cursor.execute(sql, bindings) log.message(db, block_index, 'update', 'orders', bindings) # tx1 if tx1_give_remaining <= 0 or (tx1_get_remaining <= 0 and (block_index >= 292000 or config.TESTNET or config.REGTEST)): # Protocol change if tx1['give_asset'] != config.BTC and tx1['get_asset'] != config.BTC: # Fill order, and recredit give_remaining. tx1_status = 'filled' util.credit(db, tx1['source'], tx1['give_asset'], tx1_give_remaining, event=tx0['tx_hash'], action='filled') bindings = { 'give_remaining': tx1_give_remaining, 'get_remaining': tx1_get_remaining, 'fee_required_remaining': tx1_fee_required_remaining, 'fee_provided_remaining': tx1_fee_provided_remaining, 'status': tx1_status, 'tx_hash': tx1['tx_hash'] } sql='update orders set give_remaining = :give_remaining, get_remaining = :get_remaining, fee_required_remaining = :fee_required_remaining, fee_provided_remaining = :fee_provided_remaining, status = :status where tx_hash = :tx_hash' cursor.execute(sql, bindings) log.message(db, block_index, 'update', 'orders', bindings) # Calculate when the match will expire. if block_index >= 308000 or config.TESTNET or config.REGTEST: # Protocol change. match_expire_index = block_index + 20 elif block_index >= 286500 or config.TESTNET or config.REGTEST: # Protocol change. match_expire_index = block_index + 10 else: match_expire_index = min(tx0['expire_index'], tx1['expire_index']) # Record order match. bindings = { 'id': util.make_id(tx0['tx_hash'], tx['tx_hash']), 'tx0_index': tx0['tx_index'], 'tx0_hash': tx0['tx_hash'], 'tx0_address': tx0['source'], 'tx1_index': tx1['tx_index'], 'tx1_hash': tx1['tx_hash'], 'tx1_address': tx1['source'], 'forward_asset': forward_asset, 'forward_quantity': forward_quantity, 'backward_asset': backward_asset, 'backward_quantity': backward_quantity, 'tx0_block_index': tx0['block_index'], 'tx1_block_index': tx1['block_index'], 'block_index': block_index, 'tx0_expiration': tx0['expiration'], 'tx1_expiration': tx1['expiration'], 'match_expire_index': match_expire_index, 'fee_paid': fee, 'status': status, } sql='insert into order_matches values(:id, :tx0_index, :tx0_hash, :tx0_address, :tx1_index, :tx1_hash, :tx1_address, :forward_asset, :forward_quantity, :backward_asset, :backward_quantity, :tx0_block_index, :tx1_block_index, :block_index, :tx0_expiration, :tx1_expiration, :match_expire_index, :fee_paid, :status)' cursor.execute(sql, bindings) if tx1_status == 'filled': break cursor.close() return def expire (db, block_index): cursor = db.cursor() # Expire orders and give refunds for the quantity give_remaining (if non-zero; if not BTC). cursor.execute('''SELECT * FROM orders \ WHERE (status = ? AND expire_index < ?)''', ('open', block_index)) orders = list(cursor) for order in orders: cancel_order(db, order, 'expired', block_index) # Expire order_matches for BTC with no BTC. cursor.execute('''SELECT * FROM order_matches \ WHERE (status = ? and match_expire_index < ?)''', ('pending', block_index)) order_matches = list(cursor) for order_match in order_matches: cancel_order_match(db, order_match, 'expired', block_index) # Expire btc sell order if match expires if util.enabled('btc_sell_expire_on_match_expire'): # Check for other pending order matches involving either tx0_hash or tx1_hash bindings = { 'status': 'pending', 'tx0_hash': order_match['tx0_hash'], 'tx1_hash': order_match['tx1_hash'] } sql='select * from order_matches where status = :status and ((tx0_hash in (:tx0_hash, :tx1_hash)) or ((tx1_hash in (:tx0_hash, :tx1_hash))))' cursor.execute(sql, bindings) order_matches_pending = cursor.fetchall() # Set BTC sell order status as expired only if there are no pending order matches if len(order_matches_pending) == 0: if order_match['backward_asset'] == "BTC" and order_match['status'] == "expired": cursor.execute('''SELECT * FROM orders \ WHERE tx_hash = ?''', (order_match['tx1_hash'],)) cancel_order(db, list(cursor)[0], 'expired', block_index) if order_match['forward_asset'] == "BTC" and order_match['status'] == "expired": cursor.execute('''SELECT * FROM orders \ WHERE tx_hash = ?''', (order_match['tx0_hash'],)) cancel_order(db, list(cursor)[0], 'expired', block_index) if block_index >= 315000 or config.TESTNET or config.REGTEST: # Protocol change. # Re‐match. for order_match in order_matches: cursor.execute('''SELECT * FROM transactions\ WHERE tx_hash = ?''', (order_match['tx0_hash'],)) match(db, list(cursor)[0], block_index) cursor.execute('''SELECT * FROM transactions\ WHERE tx_hash = ?''', (order_match['tx1_hash'],)) match(db, list(cursor)[0], block_index) cursor.close() # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 #### rps.py #! /usr/bin/python3 """ Transaction 1: rps (Open the game) source: address used to play the game wager: amount to bet move_random_hash: sha256(sha256(move + random)) (stored as bytes, 16 bytes random) possible_moves: arbitrary odd number >= 3 expiration: how many blocks the game is valid Matching conditions: - tx0_possible_moves = tx1_possible_moves - tx0_wager = tx1_wager Transaction 2: rpsresolve (Resolve the game) source: same address as first transaction random: 16 bytes random move: the move number rps_match_id: matching id """ import struct import decimal D = decimal.Decimal import time import binascii import string from counterpartylib.lib import config from counterpartylib.lib import exceptions from counterpartylib.lib import util from counterpartylib.lib import log from counterpartylib.lib import message_type # possible_moves wager move_random_hash expiration FORMAT = '>HQ32sI' LENGTH = 2 + 8 + 32 + 4 ID = 80 def initialise (db): cursor = db.cursor() # RPS (Rock-Paper-Scissors) cursor.execute('''CREATE TABLE IF NOT EXISTS rps( tx_index INTEGER UNIQUE, tx_hash TEXT UNIQUE, block_index INTEGER, source TEXT, possible_moves INTEGER, wager INTEGER, move_random_hash TEXT, expiration INTEGER, expire_index INTEGER, status TEXT, FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index), PRIMARY KEY (tx_index, tx_hash)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS source_idx ON rps (source) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS matching_idx ON rps (wager, possible_moves) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS status_idx ON rps (status) ''') # RPS Matches cursor.execute('''CREATE TABLE IF NOT EXISTS rps_matches( id TEXT PRIMARY KEY, tx0_index INTEGER, tx0_hash TEXT, tx0_address TEXT, tx1_index INTEGER, tx1_hash TEXT, tx1_address TEXT, tx0_move_random_hash TEXT, tx1_move_random_hash TEXT, wager INTEGER, possible_moves INTEGER, tx0_block_index INTEGER, tx1_block_index INTEGER, block_index INTEGER, tx0_expiration INTEGER, tx1_expiration INTEGER, match_expire_index INTEGER, status TEXT, FOREIGN KEY (tx0_index, tx0_hash, tx0_block_index) REFERENCES transactions(tx_index, tx_hash, block_index), FOREIGN KEY (tx1_index, tx1_hash, tx1_block_index) REFERENCES transactions(tx_index, tx_hash, block_index)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS rps_match_expire_idx ON rps_matches (status, match_expire_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS rps_tx0_address_idx ON rps_matches (tx0_address) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS rps_tx1_address_idx ON rps_matches (tx1_address) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS status_idx ON rps_matches (status) ''') # RPS Expirations cursor.execute('''CREATE TABLE IF NOT EXISTS rps_expirations( rps_index INTEGER PRIMARY KEY, rps_hash TEXT UNIQUE, source TEXT, block_index INTEGER, FOREIGN KEY (block_index) REFERENCES blocks(block_index), FOREIGN KEY (rps_index, rps_hash) REFERENCES rps(tx_index, tx_hash)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS block_index_idx ON rps_expirations (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS source_idx ON rps_expirations (source) ''') # RPS Match Expirations cursor.execute('''CREATE TABLE IF NOT EXISTS rps_match_expirations( rps_match_id TEXT PRIMARY KEY, tx0_address TEXT, tx1_address TEXT, block_index INTEGER, FOREIGN KEY (rps_match_id) REFERENCES rps_matches(id), FOREIGN KEY (block_index) REFERENCES blocks(block_index)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS block_index_idx ON rps_match_expirations (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS tx0_address_idx ON rps_match_expirations (tx0_address) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS tx1_address_idx ON rps_match_expirations (tx1_address) ''') def cancel_rps (db, rps, status, block_index): cursor = db.cursor() # Update status of rps. bindings = { 'status': status, 'tx_hash': rps['tx_hash'] } sql='''UPDATE rps SET status = :status WHERE tx_hash = :tx_hash''' cursor.execute(sql, bindings) log.message(db, block_index, 'update', 'rps', bindings) util.credit(db, rps['source'], 'XCP', rps['wager'], action='recredit wager', event=rps['tx_hash']) cursor.close() def update_rps_match_status (db, rps_match, status, block_index): cursor = db.cursor() if status in ['expired', 'concluded: tie']: # Recredit tx0 address. util.credit(db, rps_match['tx0_address'], 'XCP', rps_match['wager'], action='recredit wager', event=rps_match['id']) # Recredit tx1 address. util.credit(db, rps_match['tx1_address'], 'XCP', rps_match['wager'], action='recredit wager', event=rps_match['id']) elif status.startswith('concluded'): # Credit the winner winner = rps_match['tx0_address'] if status == 'concluded: first player wins' else rps_match['tx1_address'] util.credit(db, winner, 'XCP', 2 * rps_match['wager'], action='wins', event=rps_match['id']) # Update status of rps match. bindings = { 'status': status, 'rps_match_id': rps_match['id'] } sql='UPDATE rps_matches SET status = :status WHERE id = :rps_match_id' cursor.execute(sql, bindings) log.message(db, block_index, 'update', 'rps_matches', bindings) cursor.close() def validate (db, source, possible_moves, wager, move_random_hash, expiration, block_index): problems = [] if util.enabled('disable_rps'): problems.append('rps disabled') if not isinstance(possible_moves, int): problems.append('possible_moves must be a integer') return problems if not isinstance(wager, int): problems.append('wager must be in satoshis') return problems if not isinstance(expiration, int): problems.append('expiration must be expressed as an integer block delta') return problems if not all(c in string.hexdigits for c in move_random_hash): problems.append('move_random_hash must be an hexadecimal string') return problems move_random_hash_bytes = binascii.unhexlify(move_random_hash) if possible_moves < 3: problems.append('possible moves must be at least 3') if possible_moves % 2 == 0: problems.append('possible moves must be odd') if wager <= 0: problems.append('non‐positive wager') if expiration < 0: problems.append('negative expiration') if expiration == 0 and not (block_index >= 317500 or config.TESTNET or config.REGTEST): # Protocol change. problems.append('zero expiration') if expiration > config.MAX_EXPIRATION: problems.append('expiration overflow') if len(move_random_hash_bytes) != 32: problems.append('move_random_hash must be 32 bytes in hexadecimal format') return problems def compose(db, source, possible_moves, wager, move_random_hash, expiration): problems = validate(db, source, possible_moves, wager, move_random_hash, expiration, util.CURRENT_BLOCK_INDEX) if problems: raise exceptions.ComposeError(problems) data = message_type.pack(ID) data += struct.pack(FORMAT, possible_moves, wager, binascii.unhexlify(move_random_hash), expiration) return (source, [], data) def parse(db, tx, message): rps_parse_cursor = db.cursor() # Unpack message. try: if len(message) != LENGTH: raise exceptions.UnpackError (possible_moves, wager, move_random_hash, expiration) = struct.unpack(FORMAT, message) status = 'open' except (exceptions.UnpackError, struct.error): (possible_moves, wager, move_random_hash, expiration) = 0, 0, '', 0 status = 'invalid: could not unpack' if status == 'open': move_random_hash = binascii.hexlify(move_random_hash).decode('utf8') # Overbet rps_parse_cursor.execute('''SELECT * FROM balances \ WHERE (address = ? AND asset = ?)''', (tx['source'], 'XCP')) balances = list(rps_parse_cursor) if not balances: wager = 0 else: balance = balances[0]['quantity'] if balance < wager: wager = balance problems = validate(db, tx['source'], possible_moves, wager, move_random_hash, expiration, tx['block_index']) if problems: status = 'invalid: {}'.format(', '.join(problems)) # Debit quantity wagered. (Escrow.) if status == 'open': util.debit(db, tx['source'], 'XCP', wager, action="open RPS", event=tx['tx_hash']) # Add parsed transaction to message-type–specific table. bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'source': tx['source'], 'possible_moves': possible_moves, 'wager': wager, 'move_random_hash': move_random_hash, 'expiration': expiration, 'expire_index': tx['block_index'] + expiration, 'status': status, } sql = '''INSERT INTO rps VALUES (:tx_index, :tx_hash, :block_index, :source, :possible_moves, :wager, :move_random_hash, :expiration, :expire_index, :status)''' rps_parse_cursor.execute(sql, bindings) # Match. if status == 'open': match(db, tx, tx['block_index']) rps_parse_cursor.close() def match (db, tx, block_index): cursor = db.cursor() # Get rps in question. rps = list(cursor.execute('''SELECT * FROM rps WHERE tx_index = ? AND status = ?''', (tx['tx_index'], 'open'))) if not rps: cursor.close() return else: assert len(rps) == 1 tx1 = rps[0] possible_moves = tx1['possible_moves'] wager = tx1['wager'] tx1_status = 'open' # Get rps match bindings = (possible_moves, 'open', wager, tx1['source']) # dont match twice same RPS already_matched = [] old_rps_matches = cursor.execute('''SELECT * FROM rps_matches WHERE tx0_hash = ? OR tx1_hash = ?''', (tx1['tx_hash'], tx1['tx_hash'])) for old_rps_match in old_rps_matches: counter_tx_hash = old_rps_match['tx1_hash'] if tx1['tx_hash'] == old_rps_match['tx0_hash'] else old_rps_match['tx0_hash'] already_matched.append(counter_tx_hash) already_matched_cond = '' if already_matched: already_matched_cond = '''AND tx_hash NOT IN ({})'''.format(','.join(['?' for e in range(0, len(already_matched))])) bindings += tuple(already_matched) sql = '''SELECT * FROM rps WHERE (possible_moves = ? AND status = ? AND wager = ? AND source != ? {}) ORDER BY tx_index LIMIT 1'''.format(already_matched_cond) rps_matches = list(cursor.execute(sql, bindings)) if rps_matches: tx0 = rps_matches[0] # update status for txn in [tx0, tx1]: bindings = { 'status': 'matched', 'tx_index': txn['tx_index'] } cursor.execute('''UPDATE rps SET status = :status WHERE tx_index = :tx_index''', bindings) log.message(db, block_index, 'update', 'rps', bindings) bindings = { 'id': util.make_id(tx0['tx_hash'], tx1['tx_hash']), 'tx0_index': tx0['tx_index'], 'tx0_hash': tx0['tx_hash'], 'tx0_address': tx0['source'], 'tx1_index': tx1['tx_index'], 'tx1_hash': tx1['tx_hash'], 'tx1_address': tx1['source'], 'tx0_move_random_hash': tx0['move_random_hash'], 'tx1_move_random_hash': tx1['move_random_hash'], 'wager': wager, 'possible_moves': possible_moves, 'tx0_block_index': tx0['block_index'], 'tx1_block_index': tx1['block_index'], 'block_index': block_index, 'tx0_expiration': tx0['expiration'], 'tx1_expiration': tx1['expiration'], 'match_expire_index': block_index + 20, 'status': 'pending' } sql = '''INSERT INTO rps_matches VALUES (:id, :tx0_index, :tx0_hash, :tx0_address, :tx1_index, :tx1_hash, :tx1_address, :tx0_move_random_hash, :tx1_move_random_hash, :wager, :possible_moves, :tx0_block_index, :tx1_block_index, :block_index, :tx0_expiration, :tx1_expiration, :match_expire_index, :status)''' cursor.execute(sql, bindings) cursor.close() def expire (db, block_index): cursor = db.cursor() # Expire rps and give refunds for the quantity wager. cursor.execute('''SELECT * FROM rps WHERE (status = ? AND expire_index < ?)''', ('open', block_index)) for rps in cursor.fetchall(): cancel_rps(db, rps, 'expired', block_index) # Record rps expiration. bindings = { 'rps_index': rps['tx_index'], 'rps_hash': rps['tx_hash'], 'source': rps['source'], 'block_index': block_index } sql = '''INSERT INTO rps_expirations VALUES (:rps_index, :rps_hash, :source, :block_index)''' cursor.execute(sql, bindings) # Expire rps matches expire_bindings = ('pending', 'pending and resolved', 'resolved and pending', block_index) cursor.execute('''SELECT * FROM rps_matches WHERE (status IN (?, ?, ?) AND match_expire_index < ?)''', expire_bindings) for rps_match in cursor.fetchall(): new_rps_match_status = 'expired' # pending loses against resolved if rps_match['status'] == 'pending and resolved': new_rps_match_status = 'concluded: second player wins' elif rps_match['status'] == 'resolved and pending': new_rps_match_status = 'concluded: first player wins' update_rps_match_status(db, rps_match, new_rps_match_status, block_index) # Record rps match expiration. bindings = { 'rps_match_id': rps_match['id'], 'tx0_address': rps_match['tx0_address'], 'tx1_address': rps_match['tx1_address'], 'block_index': block_index } sql = '''INSERT INTO rps_match_expirations VALUES (:rps_match_id, :tx0_address, :tx1_address, :block_index)''' cursor.execute(sql, bindings) # Rematch not expired and not resolved RPS if new_rps_match_status == 'expired': sql = '''SELECT * FROM rps WHERE tx_hash IN (?, ?) AND status = ? AND expire_index >= ?''' bindings = (rps_match['tx0_hash'], rps_match['tx1_hash'], 'matched', block_index) matched_rps = list(cursor.execute(sql, bindings)) for rps in matched_rps: cursor.execute('''UPDATE rps SET status = ? WHERE tx_index = ?''', ('open', rps['tx_index'])) # Re-debit XCP refund by close_rps_match. util.debit(db, rps['source'], 'XCP', rps['wager'], action='reopen RPS after matching expiration', event=rps_match['id']) # Rematch match(db, {'tx_index': rps['tx_index']}, block_index) cursor.close() # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 #### rpsresolve.py #! /usr/bin/python3 import binascii import struct import logging logger = logging.getLogger(__name__) import string from counterpartylib.lib import (config, exceptions, util, message_type) from . import rps # move random rps_match_id FORMAT = '>H16s32s32s' LENGTH = 2 + 16 + 32 + 32 ID = 81 def initialise (db): cursor = db.cursor() cursor.execute('''CREATE TABLE IF NOT EXISTS rpsresolves( tx_index INTEGER PRIMARY KEY, tx_hash TEXT UNIQUE, block_index INTEGER, source TEXT, move INTEGER, random TEXT, rps_match_id TEXT, status TEXT, FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS block_index_idx ON rpsresolves (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS source_idx ON rpsresolves (source) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS rps_match_id_idx ON rpsresolves (rps_match_id) ''') def validate (db, source, move, random, rps_match_id): problems = [] rps_match = None if not isinstance(move, int): problems.append('move must be a integer') return None, None, problems if not all(c in string.hexdigits for c in random): problems.append('random must be an hexadecimal string') return None, None, problems random_bytes = binascii.unhexlify(random) if len(random_bytes) != 16: problems.append('random must be 16 bytes in hexadecimal format') return None, None, problems cursor = db.cursor() rps_matches = list(cursor.execute('''SELECT * FROM rps_matches WHERE id = ?''', (rps_match_id,))) cursor.close() if len(rps_matches) == 0: problems.append('no such rps match') return None, rps_match, problems elif len(rps_matches) > 1: assert False rps_match = rps_matches[0] if move<1: problems.append('move must be greater than 0') elif move > rps_match['possible_moves']: problems.append('move must be lower than {}'.format(rps_match['possible_moves'])) if source not in [rps_match['tx0_address'], rps_match['tx1_address']]: problems.append('invalid source address') return None, rps_match, problems if rps_match['tx0_address'] == source: txn = 0 rps_match_status = ['pending', 'pending and resolved'] else: txn = 1 rps_match_status = ['pending', 'resolved and pending'] move_random_hash = util.dhash(random_bytes + int(move).to_bytes(2, byteorder='big')) move_random_hash = binascii.hexlify(move_random_hash).decode('utf-8') if rps_match['tx{}_move_random_hash'.format(txn)] != move_random_hash: problems.append('invalid move or random value') return txn, rps_match, problems if rps_match['status'] == 'expired': problems.append('rps match expired') elif rps_match['status'].startswith('concluded'): problems.append('rps match concluded') elif rps_match['status'].startswith('invalid'): problems.append('rps match invalid') elif rps_match['status'] not in rps_match_status: problems.append('rps already resolved') return txn, rps_match, problems def compose (db, source, move, random, rps_match_id): tx0_hash, tx1_hash = util.parse_id(rps_match_id) txn, rps_match, problems = validate(db, source, move, random, rps_match_id) if problems: raise exceptions.ComposeError(problems) # Warn if down to the wire. time_left = rps_match['match_expire_index'] - util.CURRENT_BLOCK_INDEX if time_left < 4: logger.warning('Only {} blocks until that rps match expires. The conclusion might not make into the blockchain in time.'.format(time_left)) tx0_hash_bytes = binascii.unhexlify(bytes(tx0_hash, 'utf-8')) tx1_hash_bytes = binascii.unhexlify(bytes(tx1_hash, 'utf-8')) random_bytes = binascii.unhexlify(bytes(random, 'utf-8')) data = message_type.pack(ID) data += struct.pack(FORMAT, move, random_bytes, tx0_hash_bytes, tx1_hash_bytes) return (source, [], data) def parse (db, tx, message): cursor = db.cursor() # Unpack message. try: if len(message) != LENGTH: raise exceptions.UnpackError move, random, tx0_hash_bytes, tx1_hash_bytes = struct.unpack(FORMAT, message) tx0_hash, tx1_hash = binascii.hexlify(tx0_hash_bytes).decode('utf-8'), binascii.hexlify(tx1_hash_bytes).decode('utf-8') rps_match_id = util.make_id(tx0_hash, tx1_hash) random = binascii.hexlify(random).decode('utf-8') status = 'valid' except (exceptions.UnpackError, struct.error) as e: move, random, tx0_hash, tx1_hash, rps_match_id = None, None, None, None, None status = 'invalid: could not unpack' if status == 'valid': txn, rps_match, problems = validate(db, tx['source'], move, random, rps_match_id) if problems: rps_match = None status = 'invalid: ' + '; '.join(problems) # Add parsed transaction to message-type–specific table. rpsresolves_bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'source': tx['source'], 'move': move, 'random': random, 'rps_match_id': rps_match_id, 'status': status } if status == 'valid': rps_match_status = 'concluded' if rps_match['status'] == 'pending': rps_match_status = 'resolved and pending' if txn==0 else 'pending and resolved' if rps_match_status == 'concluded': counter_txn = 0 if txn == 1 else 1 counter_source = rps_match['tx{}_address'.format(counter_txn)] sql = '''SELECT * FROM rpsresolves WHERE rps_match_id = ? AND source = ? AND status = ?''' counter_games = list(cursor.execute(sql, (rps_match_id, counter_source, 'valid'))) assert len(counter_games) == 1 counter_game = counter_games[0] winner = resolve_game(db, rpsresolves_bindings, counter_game) if winner == 0: rps_match_status = 'concluded: tie' elif winner == counter_game['tx_index']: rps_match_status = 'concluded: {} player wins'.format('first' if counter_txn == 0 else 'second') else: rps_match_status = 'concluded: {} player wins'.format('first' if txn == 0 else 'second') rps.update_rps_match_status(db, rps_match, rps_match_status, tx['block_index']) sql = '''INSERT INTO rpsresolves VALUES (:tx_index, :tx_hash, :block_index, :source, :move, :random, :rps_match_id, :status)''' cursor.execute(sql, rpsresolves_bindings) cursor.close() # https://en.wikipedia.org/wiki/Rock-paper-scissors#Additional_weapons: def resolve_game(db, resovlerps1, resovlerps2): move1 = resovlerps1['move'] move2 = resovlerps2['move'] same_parity = (move1 % 2) == (move2 % 2) if (same_parity and move1 < move2) or (not same_parity and move1 > move2): return resovlerps1['tx_index'] elif (same_parity and move1 > move2) or (not same_parity and move1 < move2): return resovlerps2['tx_index'] else: return 0 # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 #### send.py #! /usr/bin/python3 from counterpartylib.lib.messages.versions import send1 from counterpartylib.lib.messages.versions import enhanced_send from counterpartylib.lib.messages.versions import mpma from counterpartylib.lib import util from counterpartylib.lib import exceptions from counterpartylib.lib import config ID = send1.ID def initialise (db): cursor = db.cursor() cursor.execute('''CREATE TABLE IF NOT EXISTS sends( tx_index INTEGER PRIMARY KEY, tx_hash TEXT UNIQUE, block_index INTEGER, source TEXT, destination TEXT, asset TEXT, quantity INTEGER, status TEXT, FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index)) ''') columns = [column['name'] for column in cursor.execute('''PRAGMA table_info(sends)''')] # If CIP10 activated, Create Sends copy, copy old data, drop old table, rename new table, recreate indexes # SQLite can’t do `ALTER TABLE IF COLUMN NOT EXISTS` nor can drop UNIQUE constraints if 'msg_index' not in columns: if 'memo' not in columns: cursor.execute('''CREATE TABLE IF NOT EXISTS new_sends( tx_index INTEGER, tx_hash TEXT, block_index INTEGER, source TEXT, destination TEXT, asset TEXT, quantity INTEGER, status TEXT, msg_index INTEGER DEFAULT 0, PRIMARY KEY (tx_index, msg_index), FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index), UNIQUE (tx_hash, msg_index) ON CONFLICT FAIL) ''') cursor.execute('''INSERT INTO new_sends(tx_index, tx_hash, block_index, source, destination, asset, quantity, status) SELECT tx_index, tx_hash, block_index, source, destination, asset, quantity, status FROM sends''', {}) else: cursor.execute('''CREATE TABLE IF NOT EXISTS new_sends( tx_index INTEGER, tx_hash TEXT, block_index INTEGER, source TEXT, destination TEXT, asset TEXT, quantity INTEGER, status TEXT, memo BLOB, msg_index INTEGER DEFAULT 0, PRIMARY KEY (tx_index, msg_index), FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index), UNIQUE (tx_hash, msg_index) ON CONFLICT FAIL) ''') cursor.execute('''INSERT INTO new_sends (tx_index, tx_hash, block_index, source, destination, asset, quantity, status, memo) SELECT tx_index, tx_hash, block_index, source, destination, asset, quantity, status, memo FROM sends''', {}) cursor.execute('DROP TABLE sends') cursor.execute('ALTER TABLE new_sends RENAME TO sends') cursor.execute('''CREATE INDEX IF NOT EXISTS block_index_idx ON sends (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS source_idx ON sends (source) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS destination_idx ON sends (destination) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS asset_idx ON sends (asset) ''') # Adds a memo to sends # SQLite can’t do `ALTER TABLE IF COLUMN NOT EXISTS`. if 'memo' not in columns: cursor.execute('''ALTER TABLE sends ADD COLUMN memo BLOB''') cursor.execute('''CREATE INDEX IF NOT EXISTS memo_idx ON sends (memo) ''') def unpack(db, message, block_index): return send1.unpack(db, message, block_index) def validate (db, source, destination, asset, quantity, block_index): return send1.validate(db, source, destination, asset, quantity, block_index) def compose (db, source, destination, asset, quantity, memo=None, memo_is_hex=False, use_enhanced_send=None): # special case - enhanced_send replaces send by default when it is enabled # but it can be explicitly disabled with an API parameter if util.enabled('enhanced_sends'): # Another special case, if destination, asset and quantity are arrays, it's an MPMA send if isinstance(destination, list) and isinstance(asset, list) and isinstance(quantity, list): if util.enabled('mpma_sends'): if len(destination) == len(asset) and len(asset) == len(quantity): # Sending memos in a MPMA message can be done by several approaches: # 1. Send a list of memos, there must be one for each send and they correspond to the sends by index # - In this case memo_is_hex should be a list with the same cardinality # 2. Send a dict with the message specific memos and the message wide memo (same for the hex specifier): # - Each dict should have 2 members: # + list: same as case (1). An array that specifies the memo for each send # + msg_wide: the memo for the whole message. This memo will be used for sends that don't have a memo specified. Same as in (3) # 3. Send one memo (either bytes or string) and True/False in memo_is_hex. This will be interpreted as a message wide memo. if (len(destination) > config.MPMA_LIMIT): raise exceptions.ComposeError('mpma sends have a maximum of '+str(config.MPMA_LIMIT)+' sends') if isinstance(memo, list) and isinstance(memo_is_hex, list): # (1) implemented here if len(memo) != len(memo_is_hex): raise exceptions.ComposeError('memo and memo_is_hex lists should have the same length') elif len(memo) != len(destination): raise exceptions.ComposeError('memo/memo_is_hex lists should have the same length as sends') return mpma.compose(db, source, util.flat(zip(asset, destination, quantity, memo, memo_is_hex)), None, None) elif isinstance(memo, dict) and isinstance(memo_is_hex, dict): # (2) implemented here if not('list' in memo and 'list' in memo_is_hex and 'msg_wide' in memo and 'msg_wide' in memo_is_hex): raise exceptions.ComposeError('when specifying memo/memo_is_hex as a dict, they must contain keys "list" and "msg_wide"') elif len(memo['list']) != len(memo_is_hex['list']): raise exceptions.ComposeError('length of memo.list and memo_is_hex.list must be equal') elif len(memo['list']) != len(destination): raise exceptions.ComposeError('length of memo.list/memo_is_hex.list must be equal to the amount of sends') return mpma.compose(db, source, util.flat(zip(asset, destination, quantity, memo['list'], memo_is_hex['list'])), memo['msg_wide'], memo_is_hex['msg_wide']) else: # (3) the default case return mpma.compose(db, source, util.flat(zip(asset, destination, quantity)), memo, memo_is_hex) else: raise exceptions.ComposeError('destination, asset and quantity arrays must have the same amount of elements') else: raise exceptions.ComposeError('mpma sends are not enabled') elif use_enhanced_send is None or use_enhanced_send == True: return enhanced_send.compose(db, source, destination, asset, quantity, memo, memo_is_hex) elif memo is not None or use_enhanced_send == True: raise exceptions.ComposeError('enhanced sends are not enabled') return send1.compose(db, source, destination, asset, quantity) def parse (db, tx, message): # TODO: *args return send1.parse(db, tx, message) # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 #### sweep.py #! /usr/bin/python3 import struct from counterpartylib.lib import exceptions from counterpartylib.lib import config from counterpartylib.lib import util from counterpartylib.lib import log from counterpartylib.lib import message_type from counterpartylib.lib import address from counterpartylib.lib.exceptions import * FORMAT = '>21sB' LENGTH = 22 MAX_MEMO_LENGTH = 34 # Could be higher, but we will keep it consistent with enhanced send ID = 4 ANTISPAM_FEE_DECIMAL = 0.5 ANTISPAM_FEE = ANTISPAM_FEE_DECIMAL * config.UNIT FLAG_BALANCES = 1 FLAG_OWNERSHIP = 2 FLAG_BINARY_MEMO = 4 FLAGS_ALL = FLAG_BINARY_MEMO | FLAG_BALANCES | FLAG_OWNERSHIP def initialise(db): cursor = db.cursor() cursor.execute('''CREATE TABLE IF NOT EXISTS sweeps( tx_index INTEGER PRIMARY KEY, tx_hash TEXT UNIQUE, block_index INTEGER, source TEXT, destination TEXT, flags INTEGER, status TEXT, memo BLOB, fee_paid INTEGER, FOREIGN KEY (tx_index, tx_hash, block_index) REFERENCES transactions(tx_index, tx_hash, block_index)) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS block_index_idx ON sweeps (block_index) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS source_idx ON sweeps (source) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS destination_idx ON sweeps (destination) ''') cursor.execute('''CREATE INDEX IF NOT EXISTS memo_idx ON sweeps (memo) ''') def validate (db, source, destination, flags, memo, block_index): problems = [] if source == destination: problems.append('destination cannot be the same as source') cursor = db.cursor() cursor.execute('''SELECT * FROM balances WHERE (address = ? AND asset = ?)''', (source, 'XCP')) result = cursor.fetchall() if len(result) == 0: problems.append('insufficient XCP balance for sweep. Need %s XCP for antispam fee' % ANTISPAM_FEE_DECIMAL) elif result[0]['quantity'] < ANTISPAM_FEE: problems.append('insufficient XCP balance for sweep. Need %s XCP for antispam fee' % ANTISPAM_FEE_DECIMAL) cursor.close() if flags > FLAGS_ALL: problems.append('invalid flags %i' % flags) elif not(flags & (FLAG_BALANCES | FLAG_OWNERSHIP)): problems.append('must specify which kind of transfer in flags') if memo and len(memo) > MAX_MEMO_LENGTH: problems.append('memo too long') return problems def compose (db, source, destination, flags, memo): if memo is None: memo = b'' elif flags & FLAG_BINARY_MEMO: memo = bytes.fromhex(memo) else: memo = memo.encode('utf-8') memo = struct.pack(">{}s".format(len(memo)), memo) block_index = util.CURRENT_BLOCK_INDEX problems = validate(db, source, destination, flags, memo, block_index) if problems: raise exceptions.ComposeError(problems) short_address_bytes = address.pack(destination) data = message_type.pack(ID) data += struct.pack(FORMAT, short_address_bytes, flags) data += memo return (source, [], data) def unpack(db, message, block_index): try: memo_bytes_length = len(message) - LENGTH if memo_bytes_length < 0: raise exceptions.UnpackError('invalid message length') if memo_bytes_length > MAX_MEMO_LENGTH: raise exceptions.UnpackError('memo too long') struct_format = FORMAT + ('{}s'.format(memo_bytes_length)) short_address_bytes, flags, memo_bytes = struct.unpack(struct_format, message) if len(memo_bytes) == 0: memo_bytes = None elif not(flags & FLAG_BINARY_MEMO): memo_bytes = memo_bytes.decode('utf-8') # unpack address full_address = address.unpack(short_address_bytes) except (struct.error) as e: logger.warning("sweep send unpack error: {}".format(e)) raise exceptions.UnpackError('could not unpack') unpacked = { 'destination': full_address, 'flags': flags, 'memo': memo_bytes, } return unpacked def parse (db, tx, message): cursor = db.cursor() fee_paid = round(config.UNIT/2) # Unpack message. try: unpacked = unpack(db, message, tx['block_index']) destination, flags, memo_bytes = unpacked['destination'], unpacked['flags'], unpacked['memo'] status = 'valid' except (exceptions.UnpackError, exceptions.AssetNameError, struct.error) as e: destination, flags, memo_bytes = None, None, None status = 'invalid: could not unpack ({})'.format(e) except BalanceError: destination, flags, memo_bytes = None, None, None status = 'invalid: insufficient balance for antispam fee for sweep' except Exception as err: destination, flags, memo_bytes = None, None, None status = 'invalid: could not unpack, ' + str(err) if status == 'valid': problems = validate(db, tx['source'], destination, flags, memo_bytes, tx['block_index']) if problems: status = 'invalid: ' + '; '.join(problems) if status == 'valid': try: util.debit(db, tx['source'], 'XCP', fee_paid, action='sweep fee', event=tx['tx_hash']) except BalanceError: destination, flags, memo_bytes = None, None, None status = 'invalid: insufficient balance for antispam fee for sweep' if status == 'valid': cursor.execute('''SELECT * FROM balances WHERE address = ?''', (tx['source'],)) balances = cursor.fetchall() if flags & FLAG_BALANCES: for balance in balances: util.debit(db, tx['source'], balance['asset'], balance['quantity'], action='sweep', event=tx['tx_hash']) util.credit(db, destination, balance['asset'], balance['quantity'], action='sweep', event=tx['tx_hash']) if flags & FLAG_OWNERSHIP: sweep_pos = 0 for balance in balances: cursor.execute('''SELECT * FROM issuances \ WHERE (status = ? AND asset = ?) ORDER BY tx_index ASC''', ('valid', balance['asset'])) issuances = cursor.fetchall() if len(issuances) > 0: last_issuance = issuances[-1] if last_issuance['issuer'] == tx['source']: bindings= { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'msg_index': sweep_pos, 'block_index': tx['block_index'], 'asset': balance['asset'], 'quantity': 0, 'divisible': last_issuance['divisible'], 'source': last_issuance['source'], 'issuer': destination, 'transfer': True, 'callable': last_issuance['callable'], 'call_date': last_issuance['call_date'], 'call_price': last_issuance['call_price'], 'description': last_issuance['description'], 'fee_paid': 0, 'locked': last_issuance['locked'], 'status': status, 'asset_longname': last_issuance['asset_longname'], 'reset': False } sql='insert into issuances values(:tx_index, :tx_hash, :msg_index, :block_index, :asset, :quantity, :divisible, :source, :issuer, :transfer, :callable, :call_date, :call_price, :description, :fee_paid, :locked, :status, :asset_longname, :reset)' cursor.execute(sql, bindings) sweep_pos += 1 bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'source': tx['source'], 'destination': destination, 'flags': flags, 'status': status, 'memo': memo_bytes, 'fee_paid': fee_paid } sql = 'insert into sweeps values(:tx_index, :tx_hash, :block_index, :source, :destination, :flags, :status, :memo, :fee_paid)' cursor.execute(sql, bindings) cursor.close() #### __init__.py # Each message gets identified by its first byte. # The only exception is send.version1 which is 0 the first to fourth byte. # # Used IDs for messages: # # 0 send.version1 # 1 send.enhanced_send # 10 order # 11 btcpay # 12 dispenser # 20 issuance # 21 issuance.subasset # 30 broadcast # 40 bet # 50 dividend # 60 burn # 70 cancel # 80 rps # 81 rpsresolve # 110 destroy # # Allocate each new type of message within the "logical" 10 number boundary # Only allocate a 10 number boundary if it makes sense #### enhanced_send.py #! /usr/bin/python3 import struct import json import logging logger = logging.getLogger(__name__) from counterpartylib.lib import (config, util, exceptions, util, message_type, address) FORMAT = '>QQ21s' LENGTH = 8 + 8 + 21 MAX_MEMO_LENGTH = 34 ID = 2 # 0x02 def unpack(db, message, block_index): try: # account for memo bytes memo_bytes_length = len(message) - LENGTH if memo_bytes_length < 0: raise exceptions.UnpackError('invalid message length') if memo_bytes_length > MAX_MEMO_LENGTH: raise exceptions.UnpackError('memo too long') struct_format = FORMAT + ('{}s'.format(memo_bytes_length)) asset_id, quantity, short_address_bytes, memo_bytes = struct.unpack(struct_format, message) if len(memo_bytes) == 0: memo_bytes = None # unpack address full_address = address.unpack(short_address_bytes) # asset id to name asset = util.generate_asset_name(asset_id, block_index) if asset == config.BTC: raise exceptions.AssetNameError('{} not allowed'.format(config.BTC)) except (struct.error) as e: logger.warning("enhanced send unpack error: {}".format(e)) raise exceptions.UnpackError('could not unpack') except (exceptions.AssetNameError, exceptions.AssetIDError) as e: logger.warning("enhanced send invalid asset id: {}".format(e)) raise exceptions.UnpackError('asset id invalid') unpacked = { 'asset': asset, 'quantity': quantity, 'address': full_address, 'memo': memo_bytes, } return unpacked def validate (db, source, destination, asset, quantity, memo_bytes, block_index): problems = [] if asset == config.BTC: problems.append('cannot send {}'.format(config.BTC)) if not isinstance(quantity, int): problems.append('quantity must be in satoshis') return problems if quantity < 0: problems.append('negative quantity') if quantity == 0: problems.append('zero quantity') # For SQLite3 if quantity > config.MAX_INT: problems.append('integer overflow') # destination is always required if not destination: problems.append('destination is required') # check memo if memo_bytes is not None and len(memo_bytes) > MAX_MEMO_LENGTH: problems.append('memo is too long') if util.enabled('options_require_memo'): cursor = db.cursor() try: results = cursor.execute('SELECT options FROM addresses WHERE address=?', (destination,)) if results: result = results.fetchone() if result and util.active_options(result['options'], config.ADDRESS_OPTION_REQUIRE_MEMO): if memo_bytes is None or (len(memo_bytes) == 0): problems.append('destination requires memo') finally: cursor.close() return problems def compose (db, source, destination, asset, quantity, memo, memo_is_hex): cursor = db.cursor() # Just send BTC? if asset == config.BTC: return (source, [(destination, quantity)], None) # resolve subassets asset = util.resolve_subasset_longname(db, asset) #quantity must be in int satoshi (not float, string, etc) if not isinstance(quantity, int): raise exceptions.ComposeError('quantity must be an int (in satoshi)') # Only for outgoing (incoming will overburn). balances = list(cursor.execute('''SELECT * FROM balances WHERE (address = ? AND asset = ?)''', (source, asset))) if not balances or balances[0]['quantity'] < quantity: raise exceptions.ComposeError('insufficient funds') # convert memo to memo_bytes based on memo_is_hex setting if memo is None: memo_bytes = b'' elif memo_is_hex: memo_bytes = bytes.fromhex(memo) else: memo = memo.encode('utf-8') memo_bytes = struct.pack(">{}s".format(len(memo)), memo) block_index = util.CURRENT_BLOCK_INDEX problems = validate(db, source, destination, asset, quantity, memo_bytes, block_index) if problems: raise exceptions.ComposeError(problems) asset_id = util.get_asset_id(db, asset, block_index) short_address_bytes = address.pack(destination) data = message_type.pack(ID) data += struct.pack(FORMAT, asset_id, quantity, short_address_bytes) data += memo_bytes cursor.close() # return an empty array as the second argument because we don't need to send BTC dust to the recipient return (source, [], data) def parse (db, tx, message): cursor = db.cursor() # Unpack message. try: unpacked = unpack(db, message, tx['block_index']) asset, quantity, destination, memo_bytes = unpacked['asset'], unpacked['quantity'], unpacked['address'], unpacked['memo'] status = 'valid' except (exceptions.UnpackError, exceptions.AssetNameError, struct.error) as e: asset, quantity, destination, memo_bytes = None, None, None, None status = 'invalid: could not unpack ({})'.format(e) except: asset, quantity, destination, memo_bytes = None, None, None, None status = 'invalid: could not unpack' if status == 'valid': # don't allow sends over MAX_INT at all if quantity and quantity > config.MAX_INT: status = 'invalid: quantity is too large' quantity = None if status == 'valid': problems = validate(db, tx['source'], destination, asset, quantity, memo_bytes, tx['block_index']) if problems: status = 'invalid: ' + '; '.join(problems) if status == 'valid': # verify balance is present cursor.execute('''SELECT * FROM balances \ WHERE (address = ? AND asset = ?)''', (tx['source'], asset)) balances = cursor.fetchall() if not balances or balances[0]['quantity'] < quantity: status = 'invalid: insufficient funds' if status == 'valid': util.debit(db, tx['source'], asset, quantity, action='send', event=tx['tx_hash']) util.credit(db, destination, asset, quantity, action='send', event=tx['tx_hash']) # log invalid transactions if status != 'valid': if quantity is None: logger.warn("Invalid send from %s with status %s. (%s)" % (tx['source'], status, tx['tx_hash'])) else: logger.warn("Invalid send of %s %s from %s to %s. status is %s. (%s)" % (quantity, asset, tx['source'], destination, status, tx['tx_hash'])) # Add parsed transaction to message-type–specific table. bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'source': tx['source'], 'destination': destination, 'asset': asset, 'quantity': quantity, 'status': status, 'memo': memo_bytes, } if "integer overflow" not in status and "quantity must be in satoshis" not in status: sql = 'insert into sends (tx_index, tx_hash, block_index, source, destination, asset, quantity, status, memo) values(:tx_index, :tx_hash, :block_index, :source, :destination, :asset, :quantity, :status, :memo)' cursor.execute(sql, bindings) else: logger.warn("Not storing [send] tx [%s]: %s" % (tx['tx_hash'], status)) logger.debug("Bindings: %s" % (json.dumps(bindings), )) cursor.close() # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 #### mpma.py #! /usr/bin/python3 import struct import json import logging import binascii import math from bitcoin.core import key from functools import reduce from itertools import groupby logger = logging.getLogger(__name__) from bitstring import ReadError from counterpartylib.lib import (config, util, exceptions, util, message_type, address) from .mpma_util.internals import (_decode_mpmaSendDecode, _encode_mpmaSend) ID = 3 # 0x03 is this specific message type ## expected functions for message version def unpack(db, message, block_index): try: unpacked = _decode_mpmaSendDecode(message, block_index) except (struct.error) as e: raise exceptions.UnpackError('could not unpack') except (exceptions.AssetNameError, exceptions.AssetIDError) as e: raise exceptions.UnpackError('invalid asset in mpma send') except (ReadError) as e: raise exceptions.UnpackError('truncated data') return unpacked def validate (db, source, asset_dest_quant_list, block_index): problems = [] if len(asset_dest_quant_list) == 0: problems.append('send list cannot be empty') if len(asset_dest_quant_list) == 1: problems.append('send list cannot have only one element') if len(asset_dest_quant_list) > 0: # Need to manually unpack the tuple to avoid errors on scenarios where no memo is specified grpd = groupby([(t[0], t[1]) for t in asset_dest_quant_list]) lengrps = [len(list(grpr)) for (group, grpr) in grpd] cardinality = max(lengrps) if cardinality > 1: problems.append('cannot specify more than once a destination per asset') cursor = db.cursor() for t in asset_dest_quant_list: # Need to manually unpack the tuple to avoid errors on scenarios where no memo is specified asset = t[0] destination = t[1] quantity = t[2] sendMemo = None if len(t) > 3: sendMemo = t[3] if asset == config.BTC: problems.append('cannot send {} to {}'.format(config.BTC, destination)) if not isinstance(quantity, int): problems.append('quantities must be an int (in satoshis) for {} to {}'.format(asset, destination)) if quantity < 0: problems.append('negative quantity for {} to {}'.format(asset, destination)) if quantity == 0: problems.append('zero quantity for {} to {}'.format(asset, destination)) # For SQLite3 if quantity > config.MAX_INT: problems.append('integer overflow for {} to {}'.format(asset, destination)) # destination is always required if not destination: problems.append('destination is required for {}'.format(asset)) if util.enabled('options_require_memo'): results = cursor.execute('SELECT options FROM addresses WHERE address=?', (destination,)) if results: result = results.fetchone() if result and result['options'] & config.ADDRESS_OPTION_REQUIRE_MEMO and (sendMemo is None): problems.append('destination {} requires memo'.format(destination)) cursor.close() return problems def compose (db, source, asset_dest_quant_list, memo, memo_is_hex): cursor = db.cursor() out_balances = util.accumulate([(t[0], t[2]) for t in asset_dest_quant_list]) for (asset, quantity) in out_balances: if util.enabled('mpma_subasset_support'): # resolve subassets asset = util.resolve_subasset_longname(db, asset) if not isinstance(quantity, int): raise exceptions.ComposeError('quantities must be an int (in satoshis) for {}'.format(asset)) balances = list(cursor.execute('''SELECT * FROM balances WHERE (address = ? AND asset = ?)''', (source, asset))) if not balances or balances[0]['quantity'] < quantity: raise exceptions.ComposeError('insufficient funds for {}'.format(asset)) block_index = util.CURRENT_BLOCK_INDEX cursor.close() problems = validate(db, source, asset_dest_quant_list, block_index) if problems: raise exceptions.ComposeError(problems) data = message_type.pack(ID) data += _encode_mpmaSend(db, asset_dest_quant_list, block_index, memo=memo, memo_is_hex=memo_is_hex) return (source, [], data) def parse (db, tx, message): try: unpacked = unpack(db, message, tx['block_index']) status = 'valid' except (struct.error) as e: status = 'invalid: truncated message' except (exceptions.AssetNameError, exceptions.AssetIDError) as e: status = 'invalid: invalid asset name/id' except (Exception) as e: status = 'invalid: couldn\'t unpack; %s' % e cursor = db.cursor() plain_sends = [] all_debits = [] all_credits = [] if status == 'valid': for asset_id in unpacked: try: asset = util.get_asset_name(db, asset_id, tx['block_index']) except (exceptions.AssetNameError) as e: status = 'invalid: asset %s invalid at block index %i' % (asset_id, tx['block_index']) break cursor.execute('''SELECT * FROM balances \ WHERE (address = ? AND asset = ?)''', (tx['source'], asset_id)) balances = cursor.fetchall() if not balances: status = 'invalid: insufficient funds for asset %s, address %s has no balance' % (asset_id, tx['source']) break credits = unpacked[asset_id] total_sent = reduce(lambda p, t: p + t[1], credits, 0) if balances[0]['quantity'] < total_sent: status = 'invalid: insufficient funds for asset %s, needs %i' % (asset_id, total_sent) break if status == 'valid': plain_sends += map(lambda t: util.py34TupleAppend(asset_id, t), credits) all_credits += map(lambda t: {"asset": asset_id, "destination": t[0], "quantity": t[1]}, credits) all_debits.append({"asset": asset_id, "quantity": total_sent}) if status == 'valid': problems = validate(db, tx['source'], plain_sends, tx['block_index']) if problems: status = 'invalid:' + '; '.join(problems) if status == 'valid': for op in all_credits: util.credit(db, op['destination'], op['asset'], op['quantity'], action='mpma send', event=tx['tx_hash']) for op in all_debits: util.debit(db, tx['source'], op['asset'], op['quantity'], action='mpma send', event=tx['tx_hash']) # Enumeration of the plain sends needs to be deterministic, so we sort them by asset and then by address plain_sends = sorted(plain_sends, key=lambda x: ''.join([x[0], x[1]])) for i, op in enumerate(plain_sends): if len(op) > 3: memo_bytes = op[3] else: memo_bytes = None bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'source': tx['source'], 'asset': op[0], 'destination': op[1], 'quantity': op[2], 'status': status, 'memo': memo_bytes, 'msg_index': i } sql = 'insert into sends (tx_index, tx_hash, block_index, source, destination, asset, quantity, status, memo, msg_index) values(:tx_index, :tx_hash, :block_index, :source, :destination, :asset, :quantity, :status, :memo, :msg_index)' cursor.execute(sql, bindings) if status != 'valid': logger.warn("Not storing [mpma] tx [%s]: %s" % (tx['tx_hash'], status)) cursor.close() # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 #### send1.py #! /usr/bin/python3 """Create and parse 'send'-type messages.""" import struct import json import logging logger = logging.getLogger(__name__) from ... import (config, exceptions, util, message_type) FORMAT = '>QQ' LENGTH = 8 + 8 ID = 0 def unpack(db, message, block_index): # Only used for `unpack` API call at the moment. try: asset_id, quantity = struct.unpack(FORMAT, message) asset = util.get_asset_name(db, asset_id, block_index) except struct.error: raise exceptions.UnpackError('could not unpack') except AssetNameError: raise exceptions.UnpackError('asset id invalid') unpacked = { 'asset': asset, 'quantity': quantity } return unpacked def validate (db, source, destination, asset, quantity, block_index): problems = [] if asset == config.BTC: problems.append('cannot send bitcoins') # Only for parsing. if not isinstance(quantity, int): problems.append('quantity must be in satoshis') return problems if quantity < 0: problems.append('negative quantity') # For SQLite3 if quantity > config.MAX_INT: problems.append('integer overflow') if util.enabled('send_destination_required'): # Protocol change. if not destination: problems.append('destination is required') if util.enabled('options_require_memo'): # Check destination address options cursor = db.cursor() results = cursor.execute('SELECT options FROM addresses WHERE address=?', (destination,)) if results: result = results.fetchone() if result and util.active_options(result['options'], config.ADDRESS_OPTION_REQUIRE_MEMO): problems.append('destination requires memo') cursor.close() return problems def compose (db, source, destination, asset, quantity): cursor = db.cursor() # Just send BTC? if asset == config.BTC: return (source, [(destination, quantity)], None) # resolve subassets asset = util.resolve_subasset_longname(db, asset) #quantity must be in int satoshi (not float, string, etc) if not isinstance(quantity, int): raise exceptions.ComposeError('quantity must be an int (in satoshi)') # Only for outgoing (incoming will overburn). balances = list(cursor.execute('''SELECT * FROM balances WHERE (address = ? AND asset = ?)''', (source, asset))) if not balances or balances[0]['quantity'] < quantity: raise exceptions.ComposeError('insufficient funds') block_index = util.CURRENT_BLOCK_INDEX problems = validate(db, source, destination, asset, quantity, block_index) if problems: raise exceptions.ComposeError(problems) asset_id = util.get_asset_id(db, asset, block_index) data = message_type.pack(ID) data += struct.pack(FORMAT, asset_id, quantity) cursor.close() return (source, [(destination, None)], data) def parse (db, tx, message): cursor = db.cursor() # Unpack message. try: if len(message) != LENGTH: raise exceptions.UnpackError asset_id, quantity = struct.unpack(FORMAT, message) asset = util.get_asset_name(db, asset_id, tx['block_index']) status = 'valid' except (exceptions.UnpackError, exceptions.AssetNameError, struct.error) as e: asset, quantity = None, None status = 'invalid: could not unpack' if status == 'valid': # Oversend cursor.execute('''SELECT * FROM balances \ WHERE (address = ? AND asset = ?)''', (tx['source'], asset)) balances = cursor.fetchall() if not balances: status = 'invalid: insufficient funds' elif balances[0]['quantity'] < quantity: quantity = min(balances[0]['quantity'], quantity) # For SQLite3 if quantity: quantity = min(quantity, config.MAX_INT) if status == 'valid': problems = validate(db, tx['source'], tx['destination'], asset, quantity, tx['block_index']) if problems: status = 'invalid: ' + '; '.join(problems) if status == 'valid': util.debit(db, tx['source'], asset, quantity, action='send', event=tx['tx_hash']) util.credit(db, tx['destination'], asset, quantity, action='send', event=tx['tx_hash']) # Add parsed transaction to message-type–specific table. bindings = { 'tx_index': tx['tx_index'], 'tx_hash': tx['tx_hash'], 'block_index': tx['block_index'], 'source': tx['source'], 'destination': tx['destination'], 'asset': asset, 'quantity': quantity, 'status': status, } if "integer overflow" not in status and "quantity must be in satoshis" not in status: sql = 'insert into sends (tx_index, tx_hash, block_index, source, destination, asset, quantity, status, memo) values(:tx_index, :tx_hash, :block_index, :source, :destination, :asset, :quantity, :status, NULL)' cursor.execute(sql, bindings) else: logger.warn("Not storing [send] tx [%s]: %s" % (tx['tx_hash'], status)) logger.debug("Bindings: %s" % (json.dumps(bindings), )) cursor.close() # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 #### __init__.py #### internals.py import struct import json import logging import binascii import math from bitcoin.core import key from functools import reduce from itertools import groupby from bitstring import BitArray, BitStream, ConstBitStream, ReadError logger = logging.getLogger(__name__) from counterpartylib.lib import (config, util, exceptions, util, message_type, address) ## encoding functions def _encode_constructBaseLUT(snds): # t is a tuple of the form (asset, addr, amnt [, memo, is_hex]) return sorted(list(set([t[1] for t in snds]))) # Sorted to make list determinist def _encode_constructBaseAssets(sends): # t is a tuple of the form (asset, addr, amnt [, memo, is_hex]) return sorted(list(set([t[0] for t in sends]))) # Sorted to make list determinist def _encode_constructLUT(sends): baseLUT = _encode_constructBaseLUT(sends) # What's this? It calculates the minimal number of bits needed to represent an item index inside the baseLUT lutNbits = math.ceil(math.log2(len(baseLUT))) return { "nbits": lutNbits, "addrs": baseLUT } def _encode_compressLUT(lut): return b''.join([struct.pack('>H', len(lut['addrs']))] + [ address.pack(addr) for addr in lut['addrs'] ]) def _encode_memo(memo=None, is_hex=False): '''Tightly pack a memo as a Bit array''' if memo is not None: # signal a 1 bit for existence of the memo barr = BitArray('0b1') if is_hex: # signal a 1 bit for hex encoded memos barr.append('0b1') if type(memo) is str: # append the string as hex-string barr.append('uint:6=%i' % (len(memo) >> 1)) memo = '0x%s' % memo else: barr.append('uint:6=%i' % len(memo)) barr.append(memo) else: # signal a 0 bit for a string encoded memo barr.append('0b0') barr.append('uint:6=%i' % len(memo)) barr.append(BitArray(memo.encode('utf-8'))) return barr else: # if the memo is None, return just a 0 bit return BitArray('0b0') def _safe_tuple_index(t, i): '''Get an element from a tuple, returning None if it's out of bounds''' if len(t) <= i: return None else: return t[i] def _encode_constructSendList(send_asset, lut, sends): # t is a tuple of the form (asset, addr, amnt, memo, is_hex) # if there's no memo specified, memo and is_hex are None return [ (lut['addrs'].index(t[1]), t[2], _safe_tuple_index(t, 3), _safe_tuple_index(t, 4)) for t in sends if t[0] == send_asset ] def _solve_asset(db, assetName, block_index): asset = util.resolve_subasset_longname(db, assetName) return util.get_asset_id(db, asset, block_index) def _encode_compressSendList(db, nbits, send, block_index): r = BitArray() r.append('uintbe:64=%i' % _solve_asset(db, send['assetName'], block_index)) r.append('uint:%i=%i' % (nbits, len(send['sendList'])-1)) for sendItem in send['sendList']: idx = sendItem[0] amnt = sendItem[1] r.append('uint:%i=%i' % (nbits, idx)) r.append('uintbe:64=%i' % amnt) try: memoStr = _encode_memo(memo=sendItem[2], is_hex=sendItem[3]) except: memoStr = BitArray('0b0') r.append(memoStr) return r def _encode_constructSends(sends): lut = _encode_constructLUT(sends) assets = _encode_constructBaseAssets(sends) sendLists = [ { "assetName": asset, "sendList": _encode_constructSendList(asset, lut, sends) } for asset in assets ] return { "lut": lut, "sendLists": sendLists } def _encode_compressSends(db, mpmaSend, block_index, memo=None, memo_is_hex=False): compressedLUT = _encode_compressLUT(mpmaSend['lut']) memo_arr = _encode_memo(memo, memo_is_hex).bin isends = '0b' + memo_arr + ''.join([ ''.join(['1', _encode_compressSendList(db, mpmaSend['lut']['nbits'], sendList, block_index).bin]) for sendList in mpmaSend['sendLists'] ]) bstr = ''.join([isends, '0']) pad = '0' * ((8 - (len(bstr) - 2)) % 8) # That -2 is because the prefix 0b is there barr = BitArray(bstr + pad) return b''.join([ compressedLUT, barr.bytes ]) def _encode_mpmaSend(db, sends, block_index, memo=None, memo_is_hex=False): mpma = _encode_constructSends(sends) send = _encode_compressSends(db, mpma, block_index, memo=memo, memo_is_hex=memo_is_hex) return send ## decoding functions def _decode_decodeLUT(data): (numAddresses,) = struct.unpack('>H', data[0:2]) if numAddresses == 0: raise exceptions.DecodeError('address list can\'t be empty') p = 2 addressList = [] bytesPerAddress = 21 for i in range(0, numAddresses): addr_raw = data[p:p+bytesPerAddress] addressList.append(address.unpack(addr_raw)) p += bytesPerAddress lutNbits = math.ceil(math.log2(numAddresses)) return addressList, lutNbits, data[p:] def _decode_decodeSendList(stream, nbits, lut, block_index): asset_id = stream.read('uintbe:64') if nbits > 0: numRecipients = stream.read('uint:%i' % nbits) rangeLimit = numRecipients + 1 else: numRecipients = 1 rangeLimit = numRecipients sendList = [] asset = util.generate_asset_name(asset_id, block_index) for i in range(0, rangeLimit): if nbits > 0: idx = stream.read('uint:%i' % nbits) else: idx = 0 addr = lut[idx] amount = stream.read('uintbe:64') memo, is_hex = _decode_memo(stream) if memo is not None: sendList.append((addr, amount, memo, is_hex)) else: sendList.append((addr, amount)) return asset, sendList def _decode_decodeSends(stream, nbits, lut, block_index): #stream = ConstBitStream(data) sends = {} while stream.read('bool'): asset, sendList = _decode_decodeSendList(stream, nbits, lut, block_index) sends[asset] = sendList return sends def _decode_memo(stream): if stream.read('bool'): is_hex = stream.read('bool') mlen = stream.read('uint:6') data = stream.read('bytes:%i' % mlen) if not(is_hex): # is an utf8 string data = data.decode('utf-8') return data, is_hex else: return None, None def _decode_mpmaSendDecode(data, block_index): lut, nbits, remain = _decode_decodeLUT(data) stream = ConstBitStream(remain) memo, is_hex = _decode_memo(stream) sends = _decode_decodeSends(stream, nbits, lut, block_index) if memo is not None: for asset in sends: sendList = sends[asset] for idx, send in enumerate(sendList): if len(send) == 2: sendList[idx] = (send[0], send[1], memo, is_hex) return sends #### __init__.py