From bdf9056c2a1bda45cb9e74800a0d90dbbb14ec85 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Sun, 30 Aug 2020 13:32:17 -0300 Subject: [PATCH] Add tx details page Plus various improvements around how small metadata is displayed. --- README.md | 2 +- config.py | 7 +- observer.py | 153 +++++- static/style.css | 47 +- templates/_basic.html | 2 +- templates/include/mempool.html | 2 +- templates/include/sn_active.html | 2 +- templates/include/sn_awaiting.html | 2 +- templates/include/tx_type_symbol.html | 35 +- templates/index.html | 95 ++-- templates/not_found.html | 15 + templates/tx.html | 649 ++++++++++++++++++++++++++ 12 files changed, 927 insertions(+), 84 deletions(-) create mode 100644 templates/not_found.html create mode 100644 templates/tx.html diff --git a/README.md b/README.md index 8e36b41..7432ebb 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Quick and dirty setup instructions for now: make -j6 cd ../.. ln -s pylokimq/build/pylokimq/pylokimq.cpython-*.so . - sudo apt install python3-flask python3-babel + sudo apt install python3-flask python3-babel python3-pygments (Note that we require a very recent python3-jinja package (2.11+), which may not be installed by the above.) diff --git a/config.py b/config.py index 26c155c..b0ac6b4 100644 --- a/config.py +++ b/config.py @@ -9,10 +9,11 @@ blocks_per_page=20 max_blocks_per_page=100 # Some display and/or feature options: -pusher=True -key_image_checker=True -output_key_checker=True +pusher=False +key_image_checker=False +output_key_checker=False autorefresh_option=True +enable_mixins_details=True # URLs to networks other than the one we are on: mainnet_url='https://blocks.lokinet.dev' diff --git a/observer.py b/observer.py index 9135aaa..4535407 100644 --- a/observer.py +++ b/observer.py @@ -1,11 +1,15 @@ #!/usr/bin/env python3 import flask -from datetime import datetime +from datetime import datetime, timedelta import babel.dates import json import sys import statistics +from werkzeug.routing import BaseConverter +from pygments import highlight +from pygments.lexers import JsonLexer +from pygments.formatters import HtmlFormatter import config from lmq import FutureJSON, lmq_connection @@ -19,6 +23,14 @@ if __name__ == '__main__': app.config['TEMPLATES_AUTO_RELOAD'] = True app.jinja_env.auto_reload = True +class Hex64Converter(BaseConverter): + def __init__(self, url_map): + super().__init__(url_map) + self.regex = "[0-9a-fA-F]{64}" + +app.url_map.converters['hex64'] = Hex64Converter + + @app.template_filter('format_datetime') def format_datetime(value, format='long'): return babel.dates.format_datetime(value, format, tzinfo=babel.dates.get_timezone('UTC')) @@ -36,20 +48,32 @@ def datetime_ago(value): disp += '-' if delta.days > 0: disp += '{}d '.format(delta.days) - disp += '{:2d}:{:02d}:{:02d}'.format(delta.seconds // 3600, delta.seconds // 60 % 60, delta.seconds % 60) + disp += '{:d}:{:02d}:{:02d}'.format(delta.seconds // 3600, delta.seconds // 60 % 60, delta.seconds % 60) return disp @app.template_filter('reltime') -def relative_time(seconds): +def relative_time(seconds, two_part=False, in_ago=True, neg_is_now=False): + if isinstance(seconds, timedelta): + seconds = seconds.seconds + 86400*seconds.days + ago = False - if seconds == 0: + if seconds == 0 or (neg_is_now and seconds < 0): return 'now' elif seconds < 0: seconds = -seconds ago = True - if seconds < 90: + if two_part: + if seconds < 3600: + delta = '{:.0f} minutes {:.0f} seconds'.format(seconds//60, seconds%60//1) + elif seconds < 24 * 3600: + delta = '{:.0f} hours {:.1f} minutes'.format(seconds//3600, seconds%3600/60) + elif seconds < 10 * 86400: + delta = '{:.0f} days {:.1f} hours'.format(seconds//86400, seconds%86400/3600) + else: + delta = '{:.1f} days'.format(seconds / 86400) + elif seconds < 90: delta = '{:.0f} seconds'.format(seconds) elif seconds < 90 * 60: delta = '{:.1f} minutes'.format(seconds / 60) @@ -60,7 +84,7 @@ def relative_time(seconds): else: delta = '{:.0f} days'.format(seconds / 86400) - return delta + ' ago' if ago else 'in ' + delta + return delta if not in_ago else delta + ' ago' if ago else 'in ' + delta @app.template_filter('roundish') @@ -84,19 +108,29 @@ def format_si(value): return filter_round(value) + '{}'.format(si_suffix[i]) @app.template_filter('loki') -def format_loki(atomic, tag=True, fixed=False, decimals=9): +def format_loki(atomic, tag=True, fixed=False, decimals=9, zero=None): """Formats an atomic current value as a human currency value. tag - if False then don't append " LOKI" fixed - if True then don't strip insignificant trailing 0's and '.' decimals - at how many decimal we should round; the default is full precision + fixed - if specified, replace 0 with this string """ - disp = "{{:.{}f}}".format(decimals).format(atomic * 1e-9) - if not fixed and decimals > 0: - disp = disp.rstrip('0').rstrip('.') + if atomic == 0 and zero: + disp = zero + else: + disp = "{{:.{}f}}".format(decimals).format(atomic * 1e-9) + if not fixed and decimals > 0: + disp = disp.rstrip('0').rstrip('.') if tag: disp += ' LOKI' return disp +# For some inexplicable reason some hex fields are provided as array of byte integer values rather +# than hex. This converts such a monstrosity to hex. +@app.template_filter('bytes_to_hex') +def bytes_to_hex(b): + return "".join("{:02x}".format(x) for x in b) + @app.after_request def add_global_headers(response): if 'Cache-Control' not in response.headers: @@ -110,13 +144,13 @@ def css(): def get_sns_future(lmq, lokid): return FutureJSON(lmq, lokid, 'rpc.get_service_nodes', 5, - args=[json.dumps({ + args={ 'all': False, 'fields': { x: True for x in ('service_node_pubkey', 'requested_unlock_height', 'last_reward_block_height', 'last_reward_transaction_index', 'active', 'funded', 'earned_downtime_blocks', 'service_node_version', 'contributors', 'total_contributed', 'total_reserved', 'staking_requirement', 'portions_for_operator', 'operator_address', 'pubkey_ed25519', - 'last_uptime_proof', 'service_node_version') } }).encode()]) + 'last_uptime_proof', 'service_node_version') } }) def get_sns(sns_future, info_future): info = info_future.get() @@ -140,7 +174,7 @@ def get_sns(sns_future, info_future): def template_globals(): return { 'config': conf, - 'server': { 'timestamp': datetime.utcnow() } + 'server': { 'datetime': datetime.utcnow() } } @@ -155,14 +189,14 @@ def main(refresh=None, page=0, per_page=None, first=None, last=None): stake = FutureJSON(lmq, lokid, 'rpc.get_staking_requirement', 10) base_fee = FutureJSON(lmq, lokid, 'rpc.get_fee_estimate', 10) hfinfo = FutureJSON(lmq, lokid, 'rpc.hard_fork_info', 10) - mempool = FutureJSON(lmq, lokid, 'rpc.get_transaction_pool', 5, args=[json.dumps({"tx_extra":True}).encode()]) + mempool = FutureJSON(lmq, lokid, 'rpc.get_transaction_pool', 5, args={"tx_extra":True}) sns = get_sns_future(lmq, lokid) # This call is slow the first time it gets called in lokid but will be fast after that, so call # it with a very short timeout. It's also an admin-only command, so will always fail if we're # using a restricted RPC interface. coinbase = FutureJSON(lmq, lokid, 'admin.get_coinbase_tx_sum', 10, timeout=1, fail_okay=True, - args=[json.dumps({"height":0, "count":2**31-1}).encode()]) + args={"height":0, "count":2**31-1}) custom_per_page = '' if per_page is None or per_page <= 0 or per_page > config.max_blocks_per_page: @@ -190,11 +224,11 @@ def main(refresh=None, page=0, per_page=None, first=None, last=None): end_height = max(0, height - per_page*page - 1) start_height = max(0, end_height - per_page + 1) - blocks = FutureJSON(lmq, lokid, 'rpc.get_block_headers_range', args=[json.dumps({ + blocks = FutureJSON(lmq, lokid, 'rpc.get_block_headers_range', args={ 'start_height': start_height, 'end_height': end_height, 'get_tx_hashes': True, - }).encode()]).get()['headers'] + }).get()['headers'] # If 'txs' is already there then it is probably left over from our cached previous call through # here. @@ -205,12 +239,12 @@ def main(refresh=None, page=0, per_page=None, first=None, last=None): txids.append(b['miner_tx_hash']) if 'tx_hashes' in b: txids += b['tx_hashes'] - txs = FutureJSON(lmq, lokid, 'rpc.get_transactions', args=[json.dumps({ + txs = FutureJSON(lmq, lokid, 'rpc.get_transactions', args={ "txs_hashes": txids, "decode_as_json": True, "tx_extra": True, "prune": True, - }).encode()]).get() + }).get() txs = txs['txs'] i = 0 for tx in txs: @@ -276,3 +310,84 @@ def sns(): inactive_sns=inactive, **template_globals(), ) + + +@app.route('/tx/') +@app.route('/tx//') +def show_tx(txid, more_details=False): + lmq, lokid = lmq_connection() + info = FutureJSON(lmq, lokid, 'rpc.get_info', 1) + txs = FutureJSON(lmq, lokid, 'rpc.get_transactions', cache_seconds=10, args={ + "txs_hashes": [txid], + "decode_as_json": True, + "tx_extra": True, + "prune": True, + }).get() + + if 'txs' not in txs or not txs['txs']: + return flask.render_template('not_found.html', + info=info.get(), + type='tx', + id=txid, + **template_globals(), + ) + tx = txs['txs'][0] + if 'info' not in tx: + tx['info'] = json.loads(tx["as_json"]) + del tx["as_json"] + + # The "extra" field is retardedly in per-byte values, convert it to a hex string: + tx['info']['extra'] = bytes_to_hex(tx['info']['extra']) + + koffset_info = {} # { amount => { keyoffset => {output-info} } } + block_info_req = None + if 'vin' in tx['info']: + if len(tx['info']['vin']) == 1 and 'gen' in tx['info']['vin'][0]: + tx['coinbase'] = True + elif tx['info']['vin'] and config.enable_mixins_details: + # Load output details for all outputs contained in the inputs + outs_req = [{"amount":inp['key']['amount'], "index":koff} for inp in tx['info']['vin'] for koff in inp['key']['key_offsets']] + outputs = FutureJSON(lmq, lokid, 'rpc.get_outs', args={ + 'get_txid': True, + 'outputs': outs_req, + }).get() + if outputs and 'outs' in outputs and len(outputs['outs']) == len(outs_req): + outputs = outputs['outs'] + # Also load block details for all of those outputs: + block_info_req = FutureJSON(lmq, lokid, 'rpc.get_block_header_by_height', args={ + 'heights': [o["height"] for o in outputs] + }) + i = 0 + for inp in tx['info']['vin']: + amount = inp['key']['amount'] + if amount not in koffset_info: + koffset_info[amount] = {} + ki = koffset_info[amount] + for ko in inp['key']['key_offsets']: + ki[ko] = outputs[i] + i += 1 + + if more_details: + formatter = HtmlFormatter(cssclass="syntax-highlight", style="native") + more_details = { + 'details_css': formatter.get_style_defs('.syntax-highlight'), + 'details_html': highlight(json.dumps(tx, indent="\t", sort_keys=True), JsonLexer(), formatter), + } + else: + more_details = {} + + block_info = {} # { height => {block-info} } + if block_info_req: + bi = block_info_req.get() + if 'block_headers' in bi: + for bh in bi['block_headers']: + block_info[bh['height']] = bh + + return flask.render_template('tx.html', + info=info.get(), + tx=tx, + koffset_info=koffset_info, + block_info=block_info, + **more_details, + **template_globals(), + ) diff --git a/static/style.css b/static/style.css index ad51490..23dc5ec 100644 --- a/static/style.css +++ b/static/style.css @@ -18,6 +18,18 @@ h1, h2, h3, h4, h5, h6 { margin-top: 5px; margin-bottom: 3px; } +.info_list>span:not(:first-child):before { + content: " | "; +} +.info_list>span { + font-weight: bold; +} +.info_list>span>label { + font-weight: normal; +} +p>label, span>label, h3>label, h4>label, td>label, .info-item>label { + color: #30a532; +} .nowrap-spans { text-indent: -2em; padding-left: 2em; @@ -25,6 +37,18 @@ h1, h2, h3, h4, h5, h6 { .nowrap-spans>span { white-space: nowrap; } +.info-item:before { + content: " | "; + white-space: nowrap; +} +.info-item { + word-wrap: break-word; + padding-left: 6em; + text-indent: -4em; +} +.info-item>label { + white-space: nowrap; +} .Subtitle { font-size: 1.0em; @@ -92,12 +116,18 @@ table thead tr, background-color: #008522; } -.TitleDivider { +.TitleUnderliner { height: 2px; width: 100%; background-color: #008522; margin-bottom: 1em; } +.TitleDivider { + height: 2px; + width: 100%; + background-color: #008522; + margin: 1em 0; +} .LinkNoUnderline { text-decoration: none !important; @@ -290,3 +320,18 @@ h3 .sn-count { span.icon { cursor: help; } + +.syntax-highlight>pre { + font-size: 125%; + overflow-x: auto; + tab-size: 4; + -o-tab-size: 4; + -moz-tab-size: 4; + border: 1px solid #008522; + padding: 10px; +} + +.tx-inputs, .tx-outputs { + max-width: 1000px; + margin: auto; +} diff --git a/templates/_basic.html b/templates/_basic.html index 6014f32..5910089 100644 --- a/templates/_basic.html +++ b/templates/_basic.html @@ -31,7 +31,7 @@ -
+
{% endblock %} diff --git a/templates/include/mempool.html b/templates/include/mempool.html index d0d75a5..2874aa6 100644 --- a/templates/include/mempool.html +++ b/templates/include/mempool.html @@ -7,7 +7,7 @@

{{mempool.transactions|length}} transactions, {{mempool.transactions|sum(attribute='blob_size') | si}}B

-
+
diff --git a/templates/include/sn_active.html b/templates/include/sn_active.html index 272eecc..1fdd02f 100644 --- a/templates/include/sn_active.html +++ b/templates/include/sn_active.html @@ -22,7 +22,7 @@
{%if sn.requested_unlock_height%} 🔓 - {{((sn.requested_unlock_height - info.height) * 120 + server.timestamp.timestamp()) | from_timestamp | format_datetime('short')}} + {{((sn.requested_unlock_height - info.height) * 120 + server.datetime.timestamp()) | from_timestamp | format_datetime('short')}} ({{((sn.requested_unlock_height - info.height) * 120) | reltime}}) {%else%} Staking Infinitely diff --git a/templates/include/sn_awaiting.html b/templates/include/sn_awaiting.html index 3f31f19..fdd6c22 100644 --- a/templates/include/sn_awaiting.html +++ b/templates/include/sn_awaiting.html @@ -28,7 +28,7 @@ {%if sn.requested_unlock_height%} 🔓 - {{((sn.requested_unlock_height - info.height) * 120 + server.timestamp.timestamp()) | from_timestamp | format_datetime('short')}} + {{((sn.requested_unlock_height - info.height) * 120 + server.datetime.timestamp()) | from_timestamp | format_datetime('short')}} ({{((sn.requested_unlock_height - info.height) * 120) | reltime}}) {%else%} Staking Infinitely diff --git a/templates/include/tx_type_symbol.html b/templates/include/tx_type_symbol.html index bcbfc6c..7cf174b 100644 --- a/templates/include/tx_type_symbol.html +++ b/templates/include/tx_type_symbol.html @@ -1,24 +1,24 @@ -{% macro display(tx) -%} +{%- macro display(tx, text=false) -%} {% if tx.info.version >= 4 -%} {% if tx.info.type == 1 and 'sn_state_change' in tx.extra -%} {% if tx.extra.sn_state_change.type == 'decom' -%} - 👎 + 👎{%if text%} decommission{%endif%} {% elif tx.extra.sn_state_change.type == 'recom' -%} - 👍 + 👍{%if text%} recommission{%endif%} {% elif tx.extra.sn_state_change.type == 'dereg' -%} - 🚫 + 🚫{%if text%} deregistration{%endif%} {% elif tx.extra.sn_state_change.type == 'ip' -%} - 📋 + 📋{%if text%} ip change{%endif%} {% else -%} - + ❓{%if text%} unknown state change{%endif%} {% endif -%} {% elif tx.info.type == 2 -%} - 🔓 + 🔓{%if text%} unlock{%endif%} {% elif tx.info.type == 4 and 'lns' in tx.extra -%} {% if 'buy' in tx.extra.lns -%} - 🎫 + 🎫{%if text%} LNS purchase{%endif%} {% elif 'update' in tx.extra.lns -%} - 💾 + 💾{%if text%} LNS update{%endif%} {% endif -%} {% elif 'sn_registration' in tx.extra -%} 🏁 - {% elif 'sn_contributor' in tx.extra -%} - - {% endif -%} - {% endif -%} -{% endmacro %} +{%-endfor%}">🏁{%if text%} registration{%endif%} + {%- elif 'sn_contributor' in tx.extra -%} + ⚑ + {%-if text%} contribution{%endif%} + {%- elif text -%} + {%if tx.coinbase%}block reward{%else%}transfer{%endif%} + {%- endif -%} + {%- elif standard -%} + {%if tx.coinbase%}block reward{%else%}transfer{%endif%} + {%- endif -%} +{% endmacro -%} diff --git a/templates/index.html b/templates/index.html index ffe42df..eae89bb 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,20 +3,20 @@ {% block content %}
-
- Server Time: {{ server.timestamp | format_datetime }} - | Transaction Pool +
+ {{ server.datetime | format_datetime }} + Transaction Pool {% if config.pusher %} - | Transaction pusher + Transaction pusher {% endif %} {% if config.key_image_checker %} - | Key images checker + Key images checker {% endif %} {% if config.output_key_checker %} - | Output keys checker + Output keys checker {% endif %} {% if config.autorefresh_option %} - | + {% if refresh %} Autorefresh is ON ({{refresh}} s) {% else %} @@ -25,53 +25,66 @@ {% endif %} {% if config.testnet_url and not info.testnet %} - | Go to testnet explorer + Go to testnet explorer {% endif %} {% if config.devnet_url and not info.devnet %} - | Go to devnet explorer + Go to devnet explorer {% endif %} {% if config.mainnet_url and not info.mainnet %} - | Go to mainnet explorer + Go to mainnet explorer {% endif %} {% if info.testnet %} - | This is TESTNET blockchain + This is TESTNET blockchain {% elif info.devnet %} - | This is DEVNET blockchain + This is DEVNET blockchain {% endif %}
+
{% if info %} -

- Hard fork: v{{hf.version}} - | Network difficulty: {{info.difficulty}} - | Hash rate: ~{{(info.difficulty / info.target) | si }}H/s - | Staking requirement: {{stake.staking_requirement | loki}} - | - Base fee: +

+ {{info.height}} + v{{hf.version}} + {% if hf.version >= 16 %} + + {{(info.pulse_target_timestamp|from_timestamp - server.datetime) | reltime(neg_is_now=true) }} + + {% else %} + {{info.difficulty}} + ~{{(info.difficulty / info.target) | si }}H/s + {% endif %} + {{stake.staking_requirement | loki}} + + {{fees.fee_per_output | loki}}/output + {{(fees.fee_per_byte * 1000) | loki}}/kB - | - Blink fee: + + {{fees.blink_fee_per_output | loki}}/output + {{(fees.blink_fee_per_byte * 1000) | loki}}/kB - | - Block size limit: + + {{(info.block_size_limit / 2) | si}}B/{{info.block_size_limit | si}}B - | Blockchain size: {{info.database_size | si}}B + {{info.database_size | si}}B

{% endif %} -

- Circulating Supply*: +

+ : {% if not emission or emission.status == 'BUSY' %} (still calculating...) {% elif emission.status == 'OK' %} {{(emission.emission_amount - emission.burn_amount) | loki}} - (Coinbase: {{emission.emission_amount | loki}} - | Fees: {{emission.fee_amount | loki}} - | Burned: {{emission.burn_amount | loki}}). + {{emission.emission_amount | loki}} + {{emission.fee_amount | loki}} + {{emission.burn_amount | loki}} {%endif%}

* — Circulating supply may exclude any currently, publicised locked tokens, otherwise it is equal to the Coinbase minus burned coins. @@ -79,18 +92,18 @@

+
-

TX Types Legend

-

- Service Node - Registration: 🏁 - | Contribution: ⚑ - | Recommission: 👍 - | Decommission: 👎 - | Deregister: 🚫 - | IP Change Penalty: 📋 - | Stake Unlock: 🔓 - | Loki Name System Buy: 🎫 - | LNS Update: 💾 +

+ 🏁 Service Node Registration + ⚑ Contribution + 👍 Recommission + 👎 Decommission + 🚫 Deregistration + 📋 IP Change Penalty + 🔓 Stake Unlock + 🎫 Loki Name System Purchase + 💾 LNS Update

@@ -115,7 +128,7 @@ {{block_sizes[-1] | si}}B) {%endif%} -
+
{% include 'include/block_page_controls.html' %} diff --git a/templates/not_found.html b/templates/not_found.html new file mode 100644 index 0000000..08657be --- /dev/null +++ b/templates/not_found.html @@ -0,0 +1,15 @@ +{% extends "_basic.html" %} + +{% block content %} + +
+

Not Found!

+ + {%if type == 'tx'%} +

The transaction with id {{id}} was not found on the blockchain.

+ {%else%} +

Whoops! Couldn't find what you were looking for.

+ {%endif%} +
+ +{% endblock %} diff --git a/templates/tx.html b/templates/tx.html new file mode 100644 index 0000000..0df4f39 --- /dev/null +++ b/templates/tx.html @@ -0,0 +1,649 @@ +{% extends "_basic.html" %} + +{% block content %} + +
+ +

{{tx.tx_hash}}

+ {# FIXME + {%if config.enable_mixins_details%} +

{{tx.tx_prefix_hash}}

+ {%endif%} + #} +

{{tx.extra.pubkey}}

+ + + {%if tx.extra.payment_id%} +

{{tx.extra.payment_id}}

+ {%endif%} + +{# FIXME - what is this? + {%if have_prev_hash%} +

Previous TX: {{prev_hash}}

+ {%endif%} + + {%if have_next_hash%} +

Next TX: {{next_hash}}

+ {%endif%} +#} + +

Metadata

+
+ +

+ {{tx.block_height}} + {% import 'include/tx_type_symbol.html' as sym %} + {{tx.info.version}}/{{sym.display(tx, text=true)}} + {%if not have_raw_tx%} + {{tx.block_timestamp | from_timestamp | format_datetime('short')}} UTC + ({{tx.block_timestamp | from_timestamp | ago}} ago) + {%endif%} + + {# FIXME - if in mempool then link to mempool page instead #} + + {%if tx.coinbase%} + N/A + {%else%} + {% import 'include/tx_fee.html' as fee %} + {{fee.display(tx)}} + ({{(tx.info.rct_signatures.txnFee * 1000 / tx.size) | loki(tag=false, decimals=6)}}) + {%endif%} + + {{tx.size|si}}B + + {{info.height - tx.block_height}} + {%if tx.info.version >= 2 and not tx.coinbase%}Yes/{{tx.info.rct_signatures.type}}{%else%}No{%endif%} + + {%if tx.coinbase and tx.extra.sn_winner%} + + {%if tx.extra.sn_winner == "0000000000000000000000000000000000000000000000000000000000000000"%} + None + {%else%} + {{tx.extra.sn_winner}} + {%endif%} + + {%endif%} +

+
{{tx.info.extra}}
+ + {% if tx.info.version >= 4 -%} + {% if tx.info.type == 1 and 'sn_state_change' in tx.extra %} +

+ {%- if tx.extra.sn_state_change.type == 'decom' -%} + 👎 Service Node Decommission Metadata + {% elif tx.extra.sn_state_change.type == 'recom' -%} + 👍 Service Node Recommission Metadata + {% elif tx.extra.sn_state_change.type == 'dereg' -%} + 🚫 Service Node Deregistration Metadata + {% elif tx.extra.sn_state_change.type == 'ip' -%} + 📋 Service Node IP Change Metadata + {% else -%} + ❓ Unknown State Change Metadata + {% endif -%} +

+ + {#FIXME -- all the values below need to be fixed#} +
+ {%if state_change_have_pubkey_info%} +

Service Node Public Key: {{state_change_service_node_pubkey}}

+ {%endif%} +

Service Node Index: {{state_change_service_node_index}}

+

Block Height: {{state_change_block_height}}

+ + + + + {%if state_change_have_pubkey_info%} + + {%endif%} + + + + {%if state_change_vote_array%} + + + {%if state_change_have_pubkey_info%} + + {%endif%} + + + {%endif%} +
Voters Quorum IndexVoter Public KeySignature
{{state_change_voters_quorum_index}}{{state_change_voter_pubkey}}{{state_change_signature}}
+ + {% elif tx.info.type == 2 %} +

🔓 Service Node Unlock

+

{{tx.extra.sn_pubkey}}

+

{{unlock_key_image}}

{# FIXME #} +

{{unlock_signature}}

{# FIXME #} + {% elif tx.info.type == 4 and 'lns' in tx.extra %} + {% if 'buy' in tx.extra.lns %} +

🎫 Loki Name Service Registration

+ {% elif 'update' in tx.extra.lns %} +

💾 Loki Name Service Update

+ {% endif %} + {#FIXME - show some metadata?#} + {% elif 'sn_registration' in tx.extra %} +

🏁 Service Node Register Metadata

+
+

{{tx.extra.sn_pubkey}}

+

+ {%if tx.extra.sn_registration.fee == 1000000%}N/A (solo registration) + {%else%}{{(tx.extra.sn_registration.fee / 10000) | chop0}}% + {%endif%}

+

{{tx.extra.sn_registration.expiry | from_timestamp | format_datetime}} ({{tx.extra.sn_registration.expiry}}), + or {{(tx.extra.sn_registration.expiry|from_timestamp - server.timestamp) | reltime}}

+ +

Service Node Registration Address(es)

+
+ + + + + + + + + + {%for c in tx.extra.sn_registration.contributors%} + + + + + {%endfor%} + +
AddressPortions
{{c.wallet}}{{(c.portion / 10000) | chop0}}%
+ {% elif 'sn_contributor' in tx.extra %} +

⚑ Service Node Contribution

+
+

{{tx.extra.sn_pubkey}}

+

{{tx.extra.sn_contributor}}

+

{#FIXME {contribution_amount}#}

+ {% endif %} + {% endif %} + + +

Outputs

+

{{tx.info.vout|length}} output(s) for total of + {{tx.info.vout | sum(attribute='amount') | loki(zero='???') | safe}}

+
+
+ + + + + + + + + + + {%for out in tx.info.vout%} + + + + + + {%endfor%} + +
Stealth AddressAmountOutput Index
{{out.target.key}}{{out.amount | loki(zero='?')}}{{tx.output_indices[loop.index0]}}{# FIXME: of {{num_outputs}}#}
+
+ + {#FIXME + {%if not have_raw_tx%} +
+
+
+ + +
+

Check which outputs belong to given Loki address/subaddress and viewkey

+

+ For RingCT transactions, outputs' amounts are also decoded +
+ {%if enable_js%} + Note: Address/Subaddress and viewkey are NOT sent to the server, as the calculations are done on the client side + {%else%} + Note: address/Subaddress and viewkey are sent to the server, as the calculations are done on the server side + {%endif%} +

+
+
+
+
+ + + + {%if enable_js%} + + + {%else%} + + {%endif%} +
+
+
+ +
+ + + +
+

Prove to someone that you have sent them Loki in this transaction

+

+ TX private key can be obtained using get_tx_key + command in loki-wallet-cli command line tool +
+ {%if enable_js%} + Note: Address/Subaddress and TX private key are NOT sent to the server, as the calculations are done on the client side + {%else%} + Note: Address/Subaddress and TX private key are sent to the server, as the calculations are done on the server side + {%endif%} +

+
+
+
+ + +
+ + {%if enable_js%} + + + {%else%} + + {%endif%} + +
+
+
+
+
+ {%endif%} + #} + + {#FIXME + {%if enable_js%} + + +
+ +
+ + + + {%endif%} + #} + + {%if not tx.coinbase and tx.info.vin|length > 0 %} +
+ +

Inputs

+

{{tx.info.vin|length}} input(s) for total of + {{tx.info.vin | sum(attribute='key.amount') | loki(zero='???') | safe}}

+
+ + {#FIXME#} + {%if enable_mixins_details%} +

Inputs' ring size time scale (from {{min_mix_time}} till {{max_mix_time}}; + resolution: {{timescales_scale}} days{%if have_raw_tx%}; R - real ring member {%endif%}) +

+
+
    + {%if timescales%} +
  • |{{timescale}}|
  • + {%endif%} +
+
+ {%endif%} + + +
+ + {%for inp in tx.info.vin if 'key' in inp%} + + + + + {%if config.enable_mixins_details%} + + + {%if have_raw_tx%} + + {%endif%} + + + + {%for koffset in inp.key.key_offsets%} + {%set oinfo = koffset_info[inp.key.amount][koffset]%} + {%set binfo = block_info[oinfo.height]%} + + + {%if have_raw_tx%} + {%if mix_is_it_real%} + + {%else%} + + {%endif%} + {%endif%} + + + + {%endfor%} + {%endif%} + {%endfor%} +
+ {{inp.key.k_image}} + {#FIXME:#} + {%if have_raw_tx%} + Already spent: + {%if already_spent%} + True + {%else%} + False + {%endif%} + {%endif%} + Amount: {{inp.key.amount | loki(zero='?')}}
Ring MembersIs It Real?BlockTimestamp (UTC)
{{oinfo.key}}{{mix_is_it_real}}{{mix_is_it_real}}{{binfo.height}}{{binfo.timestamp | from_timestamp | format_datetime('short')}} + ({{(binfo.timestamp|from_timestamp - server.datetime) | reltime}}) +
+
+ {%endif%} + + {%if details_html%} + +
+ {{details_html | safe}} + {%else%} +
+ Show raw details +
+ {%endif%} + + + {%if not have_raw_tx%} +
+ {%if with_ring_signatures%} +
+
+
+ + {{tx_json}} + +
+
+

+

Less Details

+ {%elif show_more_details_link%} +
+ More Details + {%if enable_as_hex%} + | TX As Hex + | TX Ring Members As Hex + {%endif%} +
+ {%endif%} + {%endif%} + + +{%if show_cache_times%} +
+ {%if construction_time%} +
+
+ +

+ TX details construction time: {{construction_time}} s + {%if from_cache%} +
TX read from the TX cache + {%endif%} +

+ {%endif%} +
+{%endif%} + +
+ {%if has_error%} +

Attempt failed

+ {%if error_tx_not_found%} +

Tx {{tx_hash}} not found.

+
+

If this is newly made tx, it can take some time (up to minute) + for it to get propagated to all nodes' txpools. +

+ Please refresh in 10-20 seconds to check if its here then. +

+
+ {%endif%} + {%elif txs%} + {{tx_details}} + {%endif%} +
+ +
+ +{% endblock %}