diff --git a/scripts/obwatch/ob-watcher.py b/scripts/obwatch/ob-watcher.py
index b0cdc84..fe6cf7e 100755
--- a/scripts/obwatch/ob-watcher.py
+++ b/scripts/obwatch/ob-watcher.py
@@ -16,12 +16,18 @@ from future.moves.urllib.parse import parse_qs
from decimal import Decimal
from optparse import OptionParser
from twisted.internet import reactor
+from datetime import datetime
if sys.version_info < (3, 7):
print("ERROR: this script requires at least python 3.7")
exit(1)
from jmbase.support import EXIT_FAILURE
+from jmbase import bintohex
+from jmclient import FidelityBondMixin, get_interest_rate
+from jmclient.fidelity_bond import FidelityBondProof
+
+import sybil_attack_calculations as sybil
from jmbase import get_log
log = get_log()
@@ -86,31 +92,45 @@ def cjfee_display(cjfee, order, btc_unit, rel_unit):
return str(Decimal(cjfee) * Decimal(rel_unit_to_factor[rel_unit])) + rel_unit
-def satoshi_to_unit(sat, order, btc_unit, rel_unit):
- power = unit_to_power[btc_unit]
+def satoshi_to_unit_power(sat, power):
return ("%." + str(power) + "f") % float(
Decimal(sat) / Decimal(10 ** power))
+def satoshi_to_unit(sat, order, btc_unit, rel_unit):
+ return satoshi_to_unit_power(sat, unit_to_power[btc_unit])
def order_str(s, order, btc_unit, rel_unit):
return str(s)
-def create_table_heading(btc_unit, rel_unit):
+def create_offerbook_table_heading(btc_unit, rel_unit):
col = '
{1} \n' # .format(field,label)
tableheading = '\n ' + ''.join(
[
- col.format('ordertype', 'Type'), col.format(
- 'counterparty', 'Counterparty'),
+ col.format('ordertype', 'Type'),
+ col.format('counterparty', 'Counterparty'),
col.format('oid', 'Order ID'),
- col.format('cjfee', 'Fee'), col.format(
- 'txfee', 'Miner Fee Contribution / ' + btc_unit),
- col.format(
- 'minsize', 'Minimum Size / ' + btc_unit), col.format(
- 'maxsize', 'Maximum Size / ' + btc_unit)
+ col.format('cjfee', 'Fee'),
+ col.format('txfee', 'Miner Fee Contribution / ' + btc_unit),
+ col.format('minsize', 'Minimum Size / ' + btc_unit),
+ col.format('maxsize', 'Maximum Size / ' + btc_unit),
+ col.format('bondvalue', 'Bond value / ' + btc_unit + '²')
]) + ' '
return tableheading
+def create_bonds_table_heading(btc_unit):
+ tableheading = (''
+ + 'Counterparty '
+ + 'UTXO '
+ + 'Bond value / ' + btc_unit + '² '
+ + 'Locktime '
+ + 'Locked coins / ' + btc_unit + ' '
+ + 'Confirmation time '
+ + 'Signature expiry height '
+ + 'Redeem script '
+ + ' '
+ )
+ return tableheading
def create_choose_units_form(selected_btc, selected_rel):
choose_units_form = (
@@ -128,6 +148,53 @@ def create_choose_units_form(selected_btc, selected_rel):
'' + selected_rel)
return choose_units_form
+def get_fidelity_bond_data(taker):
+ with taker.dblock:
+ fbonds = taker.db.execute("SELECT * FROM fidelitybonds;").fetchall()
+
+ blocks = jm_single().bc_interface.get_current_block_height()
+ mediantime = jm_single().bc_interface.get_best_block_median_time()
+ interest_rate = get_interest_rate()
+
+ bond_utxo_set = set()
+ fidelity_bond_data = []
+ bond_outpoint_conf_times = []
+ fidelity_bond_values = []
+ for fb in fbonds:
+ try:
+ parsed_bond = FidelityBondProof.parse_and_verify_proof_msg(fb["counterparty"],
+ fb["takernick"], fb["proof"])
+ except ValueError:
+ continue
+ bond_utxo_data = FidelityBondMixin.get_validated_timelocked_fidelity_bond_utxo(
+ parsed_bond.utxo, parsed_bond.utxo_pub, parsed_bond.locktime, parsed_bond.cert_expiry,
+ blocks)
+ if bond_utxo_data == None:
+ continue
+ #check for duplicated utxos i.e. two or more makers using the same UTXO
+ # which is obviously not allowed, a fidelity bond must only be usable by one maker nick
+ utxo_str = parsed_bond.utxo[0] + b":" + str(parsed_bond.utxo[1]).encode("ascii")
+ if utxo_str in bond_utxo_set:
+ continue
+ bond_utxo_set.add(utxo_str)
+
+ fidelity_bond_data.append((parsed_bond, bond_utxo_data))
+ conf_time = jm_single().bc_interface.get_block_time(
+ jm_single().bc_interface.get_block_hash(
+ blocks - bond_utxo_data["confirms"] + 1
+ )
+ )
+ bond_outpoint_conf_times.append(conf_time)
+
+ bond_value = FidelityBondMixin.calculate_timelocked_fidelity_bond_value(
+ bond_utxo_data["value"],
+ conf_time,
+ parsed_bond.locktime,
+ mediantime,
+ interest_rate)
+ fidelity_bond_values.append(bond_value)
+ return (fidelity_bond_data, fidelity_bond_values, bond_outpoint_conf_times)
+
class OrderbookPageRequestHeader(http.server.SimpleHTTPRequestHandler):
def __init__(self, request, client_address, base_server):
self.taker = base_server.taker
@@ -137,15 +204,21 @@ class OrderbookPageRequestHeader(http.server.SimpleHTTPRequestHandler):
directory=os.path.dirname(os.path.realpath(__file__)))
def create_orderbook_obj(self):
- try:
- self.taker.dblock.acquire(True)
+ with self.taker.dblock:
rows = self.taker.db.execute('SELECT * FROM orderbook;').fetchall()
- finally:
- self.taker.dblock.release()
- if not rows:
+ fbonds = self.taker.db.execute("SELECT * FROM fidelitybonds;").fetchall()
+ if not rows or not fbonds:
return []
- result = []
+ if jm_single().bc_interface != None:
+ (fidelity_bond_data, fidelity_bond_values, bond_outpoint_conf_times) =\
+ get_fidelity_bond_data(self.taker)
+ fidelity_bond_values_dict = dict([(bond_data["counterparty"], bond_value)
+ for (bond_data, _), bond_value in zip(fidelity_bond_data, fidelity_bond_values)])
+ else:
+ fidelity_bond_values_dict = {}
+
+ offers = []
for row in rows:
o = dict(row)
if 'cjfee' in o:
@@ -154,8 +227,19 @@ class OrderbookPageRequestHeader(http.server.SimpleHTTPRequestHandler):
o['cjfee'] = int(o['cjfee'])
else:
o['cjfee'] = str(Decimal(o['cjfee']))
- result.append(o)
- return result
+ o["fidelity_bond_value"] = fidelity_bond_values_dict.get(o["counterparty"], 0)
+ offers.append(o)
+
+ BIN_KEYS = ["txid", "utxopubkey"]
+ fidelitybonds = []
+ for fbond in fbonds:
+ o = dict(fbond)
+ for k in BIN_KEYS:
+ o[k] = bintohex(o[k])
+ o["fidelity_bond_value"] = fidelity_bond_values_dict.get(o["counterparty"], 0)
+ fidelitybonds.append(o)
+
+ return {"offers": offers, "fidelitybonds": fidelitybonds}
def create_depth_chart(self, cj_amount, args=None):
if 'matplotlib' not in sys.modules:
@@ -232,6 +316,166 @@ class OrderbookPageRequestHeader(http.server.SimpleHTTPRequestHandler):
return get_graph_html(fig) + ("log scale " if
bins == 30 else "linear ")
+ def create_fidelity_bond_table(self, btc_unit):
+ if jm_single().bc_interface == None:
+ with self.taker.dblock:
+ fbonds = self.taker.db.execute("SELECT * FROM fidelitybonds;").fetchall()
+ fidelity_bond_data = []
+ for fb in fbonds:
+ try:
+ proof = FidelityBondProof.parse_and_verify_proof_msg(
+ fb["counterparty"],
+ fb["takernick"],
+ fb["proof"])
+ except ValueError:
+ proof = None
+ fidelity_bond_data.append((proof, None))
+ fidelity_bond_values = [-1]*len(fidelity_bond_data) #-1 means no data
+ bond_outpoint_conf_times = [-1]*len(fidelity_bond_data)
+ total_btc_committed_str = "unknown"
+ else:
+ (fidelity_bond_data, fidelity_bond_values, bond_outpoint_conf_times) =\
+ get_fidelity_bond_data(self.taker)
+ total_btc_committed_str = satoshi_to_unit(
+ sum([utxo_data["value"] for _, utxo_data in fidelity_bond_data]),
+ None, btc_unit, 0)
+
+ RETARGET_INTERVAL = 2016
+ elem = lambda e: "" + e + " "
+ bondtable = ""
+ for (bond_data, utxo_data), bond_value, conf_time in zip(
+ fidelity_bond_data, fidelity_bond_values, bond_outpoint_conf_times):
+
+ if bond_value == -1 or conf_time == -1 or utxo_data == None:
+ bond_value_str = "No data"
+ conf_time_str = "No data"
+ utxo_value_str = "No data"
+ else:
+ bond_value_str = satoshi_to_unit_power(bond_value, 2*unit_to_power[btc_unit])
+ conf_time_str = str(datetime.utcfromtimestamp(conf_time))
+ utxo_value_str = satoshi_to_unit(utxo_data["value"], None, btc_unit, 0)
+ bondtable += (""
+ + elem(bond_data.maker_nick)
+ + elem(bintohex(bond_data.utxo[0]) + ":" + str(bond_data.utxo[1]))
+ + elem(bond_value_str)
+ + elem(datetime.utcfromtimestamp(bond_data.locktime).strftime("%Y-%m-%d"))
+ + elem(utxo_value_str)
+ + elem(conf_time_str)
+ + elem(str(bond_data.cert_expiry*RETARGET_INTERVAL))
+ + elem(bintohex(btc.mk_freeze_script(bond_data.utxo_pub,
+ bond_data.locktime)))
+ + " "
+ )
+
+ heading2 = (str(len(fidelity_bond_data)) + " fidelity bonds found with "
+ + total_btc_committed_str + " " + btc_unit
+ + " total locked up")
+ choose_units_form = (
+ '')
+ choose_units_form = choose_units_form.replace(
+ ' ' + btc_unit,
+ ' ' + btc_unit)
+
+ decodescript_tip = (" Tip: try running the RPC decodescript "
+ + "<redeemscript> as proof that the fidelity bond address matches the "
+ + "locktime. Also run gettxout <utxo_txid> <utxo_vout> "
+ + "as proof that the fidelity bond UTXO is real.")
+
+ return (heading2,
+ choose_units_form + create_bonds_table_heading(btc_unit) + bondtable + "
"
+ + decodescript_tip)
+
+ def create_sybil_resistance_page(self, btc_unit):
+ if jm_single().bc_interface == None:
+ return "", "Calculations unavailable, requires configured bitcoin node."
+
+ (fidelity_bond_data, fidelity_bond_values, bond_outpoint_conf_times) =\
+ get_fidelity_bond_data(self.taker)
+
+ choose_units_form = (
+ '')
+ choose_units_form = choose_units_form.replace(
+ '' + btc_unit,
+ ' ' + btc_unit)
+ mainbody = choose_units_form
+
+ honest_weight = sum(fidelity_bond_values)
+ mainbody += ("Assuming the makers in the offerbook right now are not sybil attackers, "
+ + "how much would a sybil attacker starting now have to sacrifice to succeed in their"
+ + " attack with 95% probability. Honest weight="
+ + satoshi_to_unit_power(honest_weight, 2*unit_to_power[btc_unit]) + " " + btc_unit
+ + "² Also assumes that takers are not price-sensitive and that their max "
+ + "coinjoin fee is configured high enough that they dont exclude any makers.")
+ heading2 = "Sybil attacks from external enemies."
+
+ mainbody += (''
+ + 'Maker count '
+ + '6month locked coins / ' + btc_unit + ' '
+ + '1y locked coins / ' + btc_unit + ' '
+ + '2y locked coins / ' + btc_unit + ' '
+ + '5y locked coins / ' + btc_unit + ' '
+ + '10y locked coins / ' + btc_unit + ' '
+ + 'Required burned coins / ' + btc_unit + ' '
+ + ' '
+ )
+
+ timelocks = [0.5, 1.0, 2.0, 5.0, 10.0, None]
+ interest_rate = get_interest_rate()
+ for makercount, unit_success_sybil_weight in sybil.successful_attack_95pc_sybil_weight.items():
+ success_sybil_weight = unit_success_sybil_weight * honest_weight
+ row = "" + str(makercount) + " "
+ for timelock in timelocks:
+ if timelock != None:
+ coins_per_sybil = sybil.weight_to_locked_coins(success_sybil_weight,
+ interest_rate, timelock)
+ else:
+ coins_per_sybil = sybil.weight_to_burned_coins(success_sybil_weight)
+ row += ("" + satoshi_to_unit(coins_per_sybil*makercount, None, btc_unit, 0)
+ + " ")
+ row += " "
+ mainbody += row
+ mainbody += "
"
+
+ mainbody += ("Sybil attacks from enemies within Assume a sybil attack is ongoing"
+ + " right now and that the counterparties with the most valuable fidelity bonds are "
+ + " actually controlled by the same entity. Then, what is the probability of a "
+ + " successful sybil attack for a given makercount, and what is the fidelity bond "
+ + " value being foregone by not putting all bitcoins into just one maker.")
+ mainbody += (''
+ + 'Maker count '
+ + 'Success probability '
+ + 'Foregone value / ' + btc_unit + '² '
+ + ' '
+ )
+
+ #limited because calculation is slow, so this avoids server being too slow to respond
+ MAX_MAKER_COUNT_INTERNAL = 10
+ weights = sorted(fidelity_bond_values)[::-1]
+ for makercount in range(1, MAX_MAKER_COUNT_INTERNAL+1):
+ makercount_str = (str(makercount) + " - " + str(MAX_MAKER_COUNT_INTERNAL)
+ if makercount == len(fidelity_bond_data) and len(fidelity_bond_data) !=
+ MAX_MAKER_COUNT_INTERNAL else str(makercount))
+ success_prob = sybil.calculate_top_makers_sybil_attack_success_probability(weights,
+ makercount)
+ total_sybil_weight = sum(weights[:makercount])
+ sacrificed_values = [sybil.weight_to_burned_coins(w) for w in weights[:makercount]]
+ foregone_value = (sybil.coins_burned_to_weight(sum(sacrificed_values))
+ - total_sybil_weight)
+ mainbody += ("" + makercount_str + " " + str(round(success_prob*100.0, 5))
+ + "% " + satoshi_to_unit_power(foregone_value, 2*unit_to_power[btc_unit])
+ + " ")
+ if makercount == len(weights):
+ break
+ mainbody += "
"
+
+ return heading2, mainbody
+
def create_orderbook_table(self, btc_unit, rel_unit):
result = ''
try:
@@ -241,14 +485,59 @@ class OrderbookPageRequestHeader(http.server.SimpleHTTPRequestHandler):
self.taker.dblock.release()
if not rows:
return 0, result
- #print("len rows before filter: " + str(len(rows)))
rows = [o for o in rows if o["ordertype"] in filtered_offername_list]
+ if jm_single().bc_interface == None:
+ for row in rows:
+ row["bondvalue"] = "No data"
+ else:
+ blocks = jm_single().bc_interface.get_current_block_height()
+ mediantime = jm_single().bc_interface.get_best_block_median_time()
+ interest_rate = get_interest_rate()
+ for row in rows:
+ with self.taker.dblock:
+ fbond_data = self.taker.db.execute(
+ "SELECT * FROM fidelitybonds WHERE counterparty=?;", (row["counterparty"],)
+ ).fetchall()
+ if len(fbond_data) == 0:
+ row["bondvalue"] = "0"
+ continue
+ else:
+ try:
+ parsed_bond = FidelityBondProof.parse_and_verify_proof_msg(
+ fbond_data[0]["counterparty"],
+ fbond_data[0]["takernick"],
+ fbond_data[0]["proof"]
+ )
+ except ValueError:
+ row["bondvalue"] = "0"
+ continue
+ utxo_data = FidelityBondMixin.get_validated_timelocked_fidelity_bond_utxo(
+ parsed_bond.utxo, parsed_bond.utxo_pub, parsed_bond.locktime,
+ parsed_bond.cert_expiry, blocks)
+ if utxo_data == None:
+ row["bondvalue"] = "0"
+ continue
+ bond_value = FidelityBondMixin.calculate_timelocked_fidelity_bond_value(
+ utxo_data["value"],
+ jm_single().bc_interface.get_block_time(
+ jm_single().bc_interface.get_block_hash(
+ blocks - utxo_data["confirms"] + 1
+ )
+ ),
+ parsed_bond.locktime,
+ mediantime,
+ interest_rate)
+ row["bondvalue"] = satoshi_to_unit_power(bond_value, 2*unit_to_power[btc_unit])
+
order_keys_display = (('ordertype', ordertype_display),
- ('counterparty', do_nothing), ('oid', order_str),
- ('cjfee', cjfee_display), ('txfee', satoshi_to_unit),
+ ('counterparty', do_nothing),
+ ('oid', order_str),
+ ('cjfee', cjfee_display),
+ ('txfee', satoshi_to_unit),
('minsize', satoshi_to_unit),
- ('maxsize', satoshi_to_unit))
+ ('maxsize', satoshi_to_unit),
+ ('bondvalue', do_nothing))
# somewhat complex sorting to sort by cjfee but with swabsoffers on top
@@ -278,16 +567,15 @@ class OrderbookPageRequestHeader(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
# http.server.SimpleHTTPRequestHandler.do_GET(self)
- # print 'httpd received ' + self.path + ' request'
+ # print('httpd received ' + self.path + ' request')
self.path, query = self.path.split('?', 1) if '?' in self.path else (
self.path, '')
args = parse_qs(query)
- pages = ['/', '/ordersize', '/depth', '/orderbook.json']
+ pages = ['/', '/fidelitybonds', '/ordersize', '/depth', '/sybilresistance',
+ '/orderbook.json']
static_files = {'/vendor/sorttable.js', '/vendor/bootstrap.min.css', '/vendor/jquery-3.5.1.slim.min.js'}
- if self.path in static_files:
+ if self.path in static_files or self.path not in pages:
return super().do_GET()
- elif self.path not in pages:
- return
fd = open(os.path.join(os.path.dirname(os.path.realpath(__file__)),
'orderbook.html'), 'r')
orderbook_fmt = fd.read()
@@ -308,7 +596,7 @@ class OrderbookPageRequestHeader(http.server.SimpleHTTPRequestHandler):
ordercount, ordertable = self.create_orderbook_table(
btc_unit, rel_unit)
choose_units_form = create_choose_units_form(btc_unit, rel_unit)
- table_heading = create_table_heading(btc_unit, rel_unit)
+ table_heading = create_offerbook_table_heading(btc_unit, rel_unit)
replacements = {
'PAGETITLE': 'JoinMarket Browser Interface',
'MAINHEADING': 'JoinMarket Orderbook',
@@ -319,6 +607,18 @@ class OrderbookPageRequestHeader(http.server.SimpleHTTPRequestHandler):
rotateObform + refresh_orderbook_form + choose_units_form +
table_heading + ordertable + '
\n')
}
+ elif self.path == '/fidelitybonds':
+ btc_unit = args['btcunit'][0] if 'btcunit' in args else sorted_units[0]
+ if btc_unit not in sorted_units:
+ btc_unit = sorted_units[0]
+ heading2, mainbody = self.create_fidelity_bond_table(btc_unit)
+
+ replacements = {
+ 'PAGETITLE': 'JoinMarket Browser Interface',
+ 'MAINHEADING': 'Fidelity Bonds',
+ 'SECONDHEADING': heading2,
+ 'MAINBODY': mainbody
+ }
elif self.path == '/ordersize':
replacements = {
'PAGETITLE': 'JoinMarket Browser Interface',
@@ -340,6 +640,17 @@ class OrderbookPageRequestHeader(http.server.SimpleHTTPRequestHandler):
'SECONDHEADING': 'Orderbook Depth' + alert_msg,
'MAINBODY': ' '.join(mainbody)
}
+ elif self.path == '/sybilresistance':
+ btc_unit = args['btcunit'][0] if 'btcunit' in args else sorted_units[0]
+ if btc_unit not in sorted_units:
+ btc_unit = sorted_units[0]
+ heading2, mainbody = self.create_sybil_resistance_page(btc_unit)
+ replacements = {
+ 'PAGETITLE': 'JoinMarket Browser Interface',
+ 'MAINHEADING': 'Resistance to Sybil Attacks from Fidelity Bonds',
+ 'SECONDHEADING': heading2,
+ 'MAINBODY': mainbody
+ }
elif self.path == '/orderbook.json':
replacements = {}
orderbook_fmt = json.dumps(self.create_orderbook_obj())
@@ -437,6 +748,7 @@ class ObIRCMessageChannel(IRCMessageChannel):
_chunks = command.split(" ")
try:
self.check_for_orders(nick, _chunks)
+ self.check_for_fidelity_bond(nick, _chunks)
except:
pass
diff --git a/scripts/obwatch/orderbook.html b/scripts/obwatch/orderbook.html
index 04f2f3c..45ad3a3 100644
--- a/scripts/obwatch/orderbook.html
+++ b/scripts/obwatch/orderbook.html
@@ -84,8 +84,10 @@
diff --git a/scripts/obwatch/sybil_attack_calculations.py b/scripts/obwatch/sybil_attack_calculations.py
new file mode 100755
index 0000000..27255c8
--- /dev/null
+++ b/scripts/obwatch/sybil_attack_calculations.py
@@ -0,0 +1,81 @@
+
+##this file calculates the success probability of a sybil attack on the
+# orderbook with fidelity bonds used in joinmarket
+# see https://gist.github.com/chris-belcher/87ebbcbb639686057a389acb9ab3e25b
+
+
+#precomputed
+#what sybil weight is required per-maker to sybil attack joinmarket with 95% success rate
+#this is for when the honest weight (i.e. value of all fidelity bonds added up) equals 1
+#however it is linear, so to calculate for another honest_weight just multiply
+#see
+#https://gist.github.com/chris-belcher/87ebbcbb639686057a389acb9ab3e25b#appendix-1---fit-to-unit-honest-weight-sybil-attack
+successful_attack_95pc_sybil_weight = {
+ 1: 19.2125,
+ 2: 28.829523311823312,
+ 3: 35.37299702466422,
+ 4: 40.27618399827166,
+ 5: 44.19631358837695,
+ 6: 47.46160578701477,
+ 7: 50.25944623742167,
+ 8: 52.706868994753286,
+ 9: 54.881852860047836,
+ 10: 56.8389576639515,
+ 11: 58.61784778500215,
+ 12: 60.248261563672784,
+ 13: 61.75306801,
+ 14: 62.97189476,
+ 15: 64.28155594,
+ 16: 65.21832112385313,
+ 17: 66.29765063354174,
+ 18: 67.315269563541,
+ 19: 68.27785449480159,
+ 20: 69.19105386203657,
+ 21: 70.05968878944397,
+ 22: 70.88790716279642,
+ 23: 71.67930342495613,
+ 24: 72.43701285697972,
+ 25: 73.16378660022
+}
+
+def descend_probability_tree(weights, remaining_descents, branch_probability):
+ if remaining_descents == 0:
+ return branch_probability
+ else:
+ total_weight = sum(weights)
+ result = 0
+ for i, w in enumerate(weights):
+ #honest makers are at index 0
+ if i == 0:
+ #an honest maker being chosen means the sybil attack failed
+ #so this branch contributes zero to the attack success prob
+ continue
+ if w == 0:
+ continue
+ weight_cache = weights[i]
+ weights[i] = 0
+ result += descend_probability_tree(weights,
+ remaining_descents-1, branch_probability*w/total_weight)
+ weights[i] = weight_cache
+ return result
+
+def calculate_top_makers_sybil_attack_success_probability(weights, taker_peer_count):
+ honest_weight = sum(weights[taker_peer_count:])
+ weights = [honest_weight] + weights[:taker_peer_count]
+ return descend_probability_tree(weights, taker_peer_count, 1.0)
+
+
+def weight_to_burned_coins(w):
+ #calculates how many coins need to be burned to produce a certain bond
+ return w**0.5
+
+def weight_to_locked_coins(w, r, locktime_months):
+ #calculates how many coins need to be locked to produce a certain bond
+ return w**0.5 / r / locktime_months * 12
+
+def coins_locked_to_weight(c, r, locktime_months):
+ return (c*r*locktime_months/12.0)**2
+
+def coins_burned_to_weight(c):
+ return c*c
+