🏡 index : ~doyle/blocks.ls.git

author Jordan Doyle <jordan@doyle.la> 2022-05-22 21:39:45.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2022-05-22 21:39:45.0 +00:00:00
commit
a87528a3f6ac6199a1a4c64ee1740b41111ccec6 [patch]
tree
f2a6a7c50d10ed1f5d0b473e173218368d36cf34
parent
4ef3ec2d70bb6e40f9cee0b0a6fd17208f05494c
download
a87528a3f6ac6199a1a4c64ee1740b41111ccec6.tar.gz

Hook up transfers, show mining pool, show script messages & QOL improvements



Diff

 frontend/src/lib/AsmScript.svelte             |  21 ++-
 frontend/src/lib/Blocks.svelte                |  48 ++++-
 frontend/src/lib/Time.svelte                  |  50 ++--
 frontend/src/lib/Transaction.svelte           | 245 +++++++++++++-----
 frontend/src/lib/TransactionInputInfo.svelte  |  80 ++++++-
 frontend/src/lib/TransactionOutputInfo.svelte |  63 +++++-
 frontend/src/lib/Transactions.svelte          |  31 ++-
 frontend/src/lib/bitcoinScript.ts             | 362 ++++++++++++++++-----------
 frontend/src/lib/dayjs.ts                     |   4 +-
 frontend/src/lib/i18n.ts                      |  22 +--
 frontend/src/lib/i18n/en.json                 |   1 +-
 frontend/src/lib/store.ts                     |  19 +-
 frontend/src/routes/__layout.svelte           |   4 +-
 frontend/src/routes/address/[address].svelte  |  62 ++---
 frontend/src/routes/block/[id].svelte         |  17 +-
 frontend/src/routes/block/index.svelte        |  78 +++++-
 frontend/src/routes/index.svelte              | 113 +-------
 frontend/src/routes/tx/[id].svelte            |  67 ++++-
 frontend/src/routes/tx/index.svelte           |  40 ++-
 indexer/src/main.rs                           |  71 +----
 migrations/up/V1__initial_schema.sql          |   2 +-
 web-api/src/database/blocks.rs                |  23 +-
 web-api/src/database/transactions.rs          | 109 +++++++-
 web-api/src/methods/address.rs                |  20 +-
 web-api/src/methods/block.rs                  | 175 +++++++++++--
 web-api/src/methods/mod.rs                    |   3 +-
 web-api/src/methods/transaction.rs            |  45 +++-
 27 files changed, 1282 insertions(+), 493 deletions(-)

diff --git a/frontend/src/lib/AsmScript.svelte b/frontend/src/lib/AsmScript.svelte
new file mode 100644
index 0000000..2523e56
--- /dev/null
+++ b/frontend/src/lib/AsmScript.svelte
@@ -0,0 +1,21 @@
<script>
  import { hexToAsm, fromHex, getScriptType } from "./bitcoinScript";

  export let asm;
</script>

<code>
  {#each asm as opcode}
    {#if opcode.startsWith("OP_")}
      <span class="opcode">{` ${opcode} `}</span>
    {:else}
      <span>{` ${opcode} `}</span>
    {/if}
  {/each}
</code>

<style lang="scss">
  .opcode {
    @apply text-blue-200;
  }
</style>
diff --git a/frontend/src/lib/Blocks.svelte b/frontend/src/lib/Blocks.svelte
new file mode 100644
index 0000000..dcbdd06
--- /dev/null
+++ b/frontend/src/lib/Blocks.svelte
@@ -0,0 +1,48 @@
<script>
  import { t as _ } from "$lib/i18n";
  import { relativeTimestamps, toggleTimestamps } from "./store";
  import Time from "./Time.svelte";

  export let blocks;
</script>

<div class="table-responsive">
  <table class="md:!table-fixed">
    <thead>
      <tr>
        <th>{$_("home.latest_blocks.table.height")}</th>
        <th>{$_("home.latest_blocks.table.timestamp")}</th>
        <th>{$_("home.latest_blocks.table.pool")}</th>
        <th>{$_("home.latest_blocks.table.txns")}</th>
        <th>{$_("home.latest_blocks.table.size")}</th>
        <th>{$_("home.latest_blocks.table.weight")}</th>
      </tr>
    </thead>

    <tbody>
      {#each blocks as block}
        <tr>
          <th><a href={`/block/${block.height}`}>{block.height}</a></th>
          <td>
            <span on:click={toggleTimestamps}>
              <Time live relative={$relativeTimestamps} timestamp={block.timestamp} />
            </span>
          </td>
          <td>{block.mined_by?.pool || "Unknown"}</td>
          <td>{block.tx_count}</td>
          <td>{block.bits}</td>
          <!-- todo: this isn't really size -->
          <td>{block.weight}</td>
        </tr>
      {/each}
    </tbody>
  </table>
</div>

<style lang="scss">
  @import "../_table.scss";

  section {
    @apply text-xs;
  }
</style>
diff --git a/frontend/src/lib/Time.svelte b/frontend/src/lib/Time.svelte
index 3d99465..3f360b2 100644
--- a/frontend/src/lib/Time.svelte
+++ b/frontend/src/lib/Time.svelte
@@ -1,34 +1,34 @@
<script>
    import { dayjs } from "./dayjs";
    import { onMount } from "svelte";
  import { dayjs } from "./dayjs";
  import { onMount } from "svelte";

    export let timestamp = new Date().toISOString();
    export let format = "LLL";
    export let relative = false;
    export let live = false;
    export let formatted = "";
  export let timestamp = new Date().toISOString();
  export let format = "LLL";
  export let relative = false;
  export let live = false;
  export let formatted = "";

    let interval = undefined;
  let interval = undefined;

    const DEFAULT_INTERVAL = 60 * 1000;
  const DEFAULT_INTERVAL = 60 * 1000;

    onMount(() => {
        if (relative && live !== false) {
            interval = setInterval(() => {
                formatted = dayjs(timestamp).from();
            }, Math.abs(typeof live === "number" ? live : DEFAULT_INTERVAL));
        }
        return () => {
            if (typeof interval === "number") {
                clearInterval(interval);
            }
        };
    });
  onMount(() => {
    if (relative && live !== false) {
      interval = setInterval(() => {
        formatted = dayjs(timestamp).from();
      }, Math.abs(typeof live === "number" ? live : DEFAULT_INTERVAL));
    }
    return () => {
      if (typeof interval === "number") {
        clearInterval(interval);
      }
    };
  });

    $: formatted = relative ? dayjs(timestamp).from() : dayjs(timestamp).format(format);
    $: title = relative ? dayjs(timestamp).format(format) : undefined;
  $: formatted = relative ? dayjs(timestamp).from() : dayjs(timestamp).format(format);
  $: title = relative ? dayjs(timestamp).format(format) : undefined;
</script>

<time {...$$restProps} title={title} datetime={timestamp}>
    {formatted}
<time {...$$restProps} {title} datetime={timestamp}>
  {formatted}
</time>
diff --git a/frontend/src/lib/Transaction.svelte b/frontend/src/lib/Transaction.svelte
index 877a131..9c5d6dd 100644
--- a/frontend/src/lib/Transaction.svelte
+++ b/frontend/src/lib/Transaction.svelte
@@ -1,86 +1,156 @@
<script>
    import {briefHexToAsm} from "./bitcoinScript";
  import AsmScript from "./AsmScript.svelte";
  import { fromHex, getScriptType } from "./bitcoinScript";
  import TransactionInputInfo from "./TransactionInputInfo.svelte";
  import TransactionOutputInfo from "./TransactionOutputInfo.svelte";
  import { browser } from "$app/env";
  import { page } from "$app/stores";

    export let transaction;
    const scale = Math.pow(10, 8);
</script>
  export let transaction;
  export let showingMoreInfo = !browser; // default to showing more info for non-js users
  export let highlight = false;
  export let attachAnchor = false;
  export let showTxHeader = true;

<section class="p-4">
    <h3 class="text-base md:text-lg m-2 mb-4 md:mb-2 break-all" id={transaction.hash}>
        <a href={`#${transaction.hash}`}>§</a>
        {transaction.hash}
    </h3>

    <div class="flex flex-col md:table table-fixed w-full">
        <div class="table-cell break-all">
            {#if transaction.coinbase}
                <div class="item w-full">
                    <code>Coinbase</code>
                </div>
            {:else}
                {#each transaction.inputs as input}
                    <div class="item w-full">
                        <div class="item-inner">
                            <div class="flex-grow">
                                {#if input.previous_output?.address}
                                    <a href="/address/{input.previous_output?.address}">
                                        <code>{input.previous_output?.address}</code>
                                    </a>
                                {:else}
                                    <code>{briefHexToAsm(input.script).join('\n') || 'WITNESS (TODO)'}</code>
                                {/if}
                            </div>

                            {#if input.previous_output}
                                <div class="amount">
                                    <code>{(input.previous_output.value / scale).toFixed(8)} BTC</code>
                                </div>
                            {/if}
                        </div>
                    </div>
                {/each}
            {/if}
        </div>
  let clazz = "";

        <div class="hidden md:table-cell text-2xl w-10 align-middle text-center">
        </div>
  const scale = Math.pow(10, 8);

        <div class="block md:hidden text-center text-2xl m-2 w-full align-middle text-center">
        </div>
  export { clazz as class };
</script>

        <div class="table-cell break-all">
            {#each transaction.outputs as output}
                <div class="item w-full">
                    <div class="item-inner">
                        <div class="flex-grow">
                            {#if output.address}
                                <a href="/address/{output.address}">
                                    <code>{output.address}</code>
                                </a>
                            {:else}
                                <code>{briefHexToAsm(output.script).join(' ').trim() || output.script}</code>
                            {/if}
                        </div>

                        <div class="amount">
                            <code>{(output.value / scale).toFixed(8)} BTC</code>
                        </div>
                    </div>
<section class="p-4 {clazz}">
  {#if showTxHeader}
    <div class="flex">
      <h3 class="text-base md:text-lg m-2 mb-4 md:mb-2 break-all flex-grow" id={transaction.hash}>
        <a href="#{transaction.hash}">§</a>
        <a href="/tx/{transaction.hash}">{transaction.hash}</a>
      </h3>

      <button
        on:click={() => (showingMoreInfo = !showingMoreInfo)}
        class="!text-white cursor-pointer text-base md:text-lg"
      >
        {showingMoreInfo ? "-" : "+"}
      </button>
    </div>
  {/if}

  <div class="flex flex-col md:table table-fixed w-full">
    <div class="table-cell break-all">
      {#if transaction.coinbase}
        <div class="item-outer" class:expanded={showingMoreInfo}>
          <div class="item w-full">
            <div class="item-inner">
              <code>Coinbase</code>
            </div>
          </div>

          <div class="item-info">
            <div class="table-responsive">
              <TransactionInputInfo input={transaction.inputs[0]} showMessage={true} />
            </div>
          </div>
        </div>
      {:else}
        {#each transaction.inputs as input}
          <div class="item-outer" class:expanded={showingMoreInfo}>
            <div class="item w-full">
              <div class="item-inner">
                <div class="flex-grow">
                  {#if highlight && input.previous_output?.address === highlight}
                    <span class="active"><code>{input.previous_output?.address}</code></span>
                  {:else if input.previous_output?.address}
                    <a href="/address/{input.previous_output?.address}">
                      <code>{input.previous_output?.address}</code>
                    </a>
                  {:else if input.previous_output?.script}
                    <code>{getScriptType(fromHex(input.previous_output?.script))}</code>
                  {:else if input.previous_output}
                    <a
                      href="/tx/{input.previous_output.tx_hash}#output-{input.previous_output
                        .tx_index}"
                    >
                      {input.previous_output.tx_hash}:{input.previous_output.tx_index}
                    </a>
                  {:else}
                    Unknown
                  {/if}
                </div>
            {/each}

                {#if input.previous_output}
                  <div class="amount">
                    <code>{(input.previous_output.value / scale).toFixed(8)} BTC</code>
                  </div>
                {/if}
              </div>
            </div>

            <div class="item-info">
              <div class="table-responsive">
                <TransactionInputInfo {input} />
              </div>
            </div>
          </div>
        {/each}
      {/if}
    </div>

    <div class="hidden md:table-cell text-2xl w-10 align-middle text-center"></div>

    <div class="block md:hidden text-center text-2xl m-2 w-full align-middle text-center"></div>

    <div class="table-cell break-all">
      {#each transaction.outputs as output}
        <div
          class="item-outer highlight-target"
          id={attachAnchor ? `output-${output.index}` : undefined}
          class:anchored={$page.url.hash === `#output-${output.index}`}
          class:expanded={showingMoreInfo}
        >
          <div class="item w-full">
            <div class="item-inner">
              <div class="flex-grow">
                {#if highlight && output.address === highlight}
                  <span class="active"><code>{output.address}</code></span>
                {:else if output.address}
                  <a href="/address/{output.address}">
                    <code>{output.address}</code>
                  </a>
                {:else}
                  {getScriptType(fromHex(output.script)) || "Unknown"}
                {/if}
              </div>

              <div class="amount">
                <code>{(output.value / scale).toFixed(8)} BTC</code>
              </div>
            </div>
          </div>

          <div class="item-info">
            <div class="table-responsive">
              <TransactionOutputInfo {output} />
            </div>
          </div>
        </div>
      {/each}
    </div>
  </div>
</section>

<style lang="scss">
  @import "../src/_section.scss";
  @import "../src/_table.scss";

  section {
    @apply text-xs;
  }

  .active {
    @apply text-orange-400;
  }

  .table-cell {
    counter-reset: inout;
  }
@@ -89,10 +159,53 @@
    @apply whitespace-nowrap ml-0 mt-2 md:mt-0 md:ml-4;
  }

  .item-outer {
    @apply rounded-lg border-2 border-gray-900/40 mb-2;

    &:target,
    &.anchored {
      a {
        @apply text-orange-400;
      }
    }
  }

  .item-info {
    @apply hidden;

    .expanded & {
      @apply block;
    }

    table {
      tr {
        td:first-of-type {
          @apply whitespace-nowrap;
        }

        td,
        th {
          @apply border-b border-gray-900/40 p-4 pl-8 text-left font-normal;
        }

        &:hover {
          td,
          th {
            background: transparent !important;
          }
        }
      }
    }
  }

  div.item {
    @apply bg-gray-900/40 p-4 rounded-lg flex flex mb-2;
    @apply rounded-sm bg-gray-900/40 p-4 flex flex;
    counter-increment: inout;

    .expanded & {
      @apply border-b-2 border-transparent rounded-b-lg;
    }

    &:last-of-type {
      @apply mb-0;
    }
diff --git a/frontend/src/lib/TransactionInputInfo.svelte b/frontend/src/lib/TransactionInputInfo.svelte
new file mode 100644
index 0000000..c3250d3
--- /dev/null
+++ b/frontend/src/lib/TransactionInputInfo.svelte
@@ -0,0 +1,80 @@
<script>
  import AsmScript from "./AsmScript.svelte";
  import { fromHex, hexToAsm, takeScriptMessage } from "./bitcoinScript";

  export let input;
  export let showMessage = false;

  const bytes = fromHex(input.script);
  const asm = hexToAsm(bytes);
  const message = showMessage ? bytes.toString("ascii") : null;
</script>

<table class="tii">
  <tbody>
    {#if input.script}
      <tr>
        <td>Script (ASM)</td>
        <td><AsmScript {asm} /></td>
      </tr>
      <tr>
        <td>Script (Hex)</td>
        <td><code>{input.script}</code></td>
      </tr>
    {/if}

    {#if message}
      <tr>
        <td>Script Message</td>
        <td><code>{message}</code></td>
      </tr>
    {/if}

    <tr>
      <td>Sequence</td>
      <td><code>0x{input.sequence.toString(16).padStart(2, "0")}</code></td>
    </tr>

    {#if input.witness.length > 0}
      <tr>
        <td>Witness</td>
        <td><code>{input.witness.join(" ")}</code></td>
      </tr>
    {/if}

    {#if input.previous_output}
      <tr>
        <td>Previous Output</td>
        <td>
          <a href="/tx/{input.previous_output.tx_hash}#output-{input.previous_output.tx_index}">
            {input.previous_output.tx_hash}:{input.previous_output.tx_index}
          </a>
        </td>
      </tr>
    {/if}
  </tbody>
</table>

<style lang="scss">
  @import "../table";

  table.tii {
    tr {
      td:first-of-type {
        @apply whitespace-nowrap;
      }

      td,
      th {
        @apply border-b border-gray-900/40 p-4 pl-8 text-left font-normal;
      }

      &:hover {
        td,
        th {
          background: transparent !important;
        }
      }
    }
  }
</style>
diff --git a/frontend/src/lib/TransactionOutputInfo.svelte b/frontend/src/lib/TransactionOutputInfo.svelte
new file mode 100644
index 0000000..adb3251
--- /dev/null
+++ b/frontend/src/lib/TransactionOutputInfo.svelte
@@ -0,0 +1,63 @@
<script>
  import AsmScript from "./AsmScript.svelte";
  import { fromHex, getScriptType, hexToAsm, takeScriptMessage } from "./bitcoinScript.ts";

  export let output;

  const bytes = fromHex(output.script);
  const asm = hexToAsm(bytes);
  const scriptType = getScriptType(bytes);
  const message = scriptType === "OP_RETURN" ? takeScriptMessage(asm) : null;
</script>

<table class="toi">
  <tbody>
    {#if scriptType && scriptType !== "OP_RETURN"}
      <tr>
        <td>Script Type</td>
        <td>{scriptType}</td>
      </tr>
    {/if}

    <tr>
      <td>Script (ASM)</td>
      <td><AsmScript {asm} /></td>
    </tr>

    <tr>
      <td>Script (Hex)</td>
      <td><code>{output.script}</code></td>
    </tr>

    {#if message}
      <tr>
        <td>Script Message</td>
        <td><code>{message}</code></td>
      </tr>
    {/if}
  </tbody>
</table>

<style lang="scss">
  @import "../table";

  table.toi {
    tr {
      td:first-of-type {
        @apply whitespace-nowrap;
      }

      td,
      th {
        @apply border-b border-gray-900/40 p-4 pl-8 text-left font-normal;
      }

      &:hover {
        td,
        th {
          background: transparent !important;
        }
      }
    }
  }
</style>
diff --git a/frontend/src/lib/Transactions.svelte b/frontend/src/lib/Transactions.svelte
new file mode 100644
index 0000000..134b2b7
--- /dev/null
+++ b/frontend/src/lib/Transactions.svelte
@@ -0,0 +1,31 @@
<script>
  import { t as _ } from "$lib/i18n";

  export let transactions;
</script>

<table>
  <thead>
    <tr>
      <th>{$_("home.latest_txns.table.txn_id")}</th>
      <th>{$_("home.latest_txns.table.value")}</th>
      <th>{$_("home.latest_txns.table.size")}</th>
      <th>{$_("home.latest_txns.table.fee")}</th>
    </tr>
  </thead>

  <tbody>
    {#each transactions as txn}
      <tr>
        <th><a href={`/tx/${txn.hash}`}><code>{txn.hash}</code></a></th>
        <td>{txn.amount} BTC</td>
        <td>{txn.size} vB</td>
        <td>{txn.fee} sat/vB</td>
      </tr>
    {/each}
  </tbody>
</table>

<style lang="scss">
  @import "../_table.scss";
</style>
diff --git a/frontend/src/lib/bitcoinScript.ts b/frontend/src/lib/bitcoinScript.ts
index 0a73125..333cde5 100644
--- a/frontend/src/lib/bitcoinScript.ts
+++ b/frontend/src/lib/bitcoinScript.ts
@@ -1,164 +1,238 @@
import { Buffer } from "buffer/";

export function hexToAsm(hex: string) {
    const bytes = Buffer.from(hex, 'hex');
export function fromHex(hex: string): Buffer {
  return Buffer.from(hex, "hex");
}

    const out = [];
export function getScriptType(bytes: Buffer): string | null {
  if (isP2pk(bytes)) {
    return "P2PK";
  } else if (isP2pkh(bytes)) {
    return "P2PKH";
  } else if (isP2sh(bytes)) {
    return "P2SH";
  } else if (isReturn(bytes)) {
    return "OP_RETURN";
  } else {
    return null;
  }
}

    // adapted from https://gist.github.com/rsbondi/674ec5ac823a244b889773be57304f85
    for (let i = 0; i < bytes.length; i++) {
        const byte = bytes[i];
export function takeScriptMessage(asmInput: string[]): string | null {
  try {
    let input = "";

        if (byte < 0x02) {
            out.push(byte);
            continue;
        }
    for (let i = 0; i < asmInput.length; i++) {
      const asm = asmInput[i];

        if (byte >= 0x52 && byte <= 0x60)  {
            out.push(byte - 0x50)
            continue;
        }
      if (asm.startsWith("OP_PUSHBYTES_")) {
        input += asmInput[i + 1];
        i++;
      }
    }

        if(byte >= 0x02 && byte <= 0x4b) {
            out.push("OP_PUSHBYTES_" + byte);
            out.push(bytes.slice(i + 1, i + 1 + byte).toString("hex"));
            i += byte;
            continue;
        }
    if (input) {
      return Buffer.from(input, "hex").toString("ascii").trim();
    } else {
      return null;
    }
  } catch (e) {
    return null;
  }
}

export function hexToAsm(bytes: Buffer) {
  const out = [];

  // adapted from https://gist.github.com/rsbondi/674ec5ac823a244b889773be57304f85
  for (let i = 0; i < bytes.length; i++) {
    const byte = bytes[i];

    if (byte >= 0x52 && byte <= 0x60) {
      out.push((byte - 0x50).toString(16));
      continue;
    }

        out.push(Operation[byte] || byte.toString(16))
    if (byte >= 0x02 && byte <= 0x4b) {
      out.push("OP_PUSHBYTES_" + byte);
      out.push(bytes.slice(i + 1, i + 1 + byte).toString("hex"));
      i += byte;
      continue;
    }

    return out;
    out.push(Operation[byte] || byte.toString(16));
  }

  return out;
}

export function briefHexToAsm(hex: string) {
    const asm = hexToAsm(hex);
export function briefHexToAsm(hex: Buffer) {
  const asm = hexToAsm(hex);

    const OP_RETURN = Operation[Operation.OP_RETURN];
  const OP_RETURN = Operation[Operation.OP_RETURN];

    for (const op of asm) {
        if (op === OP_RETURN) {
            return [OP_RETURN];
        }
  for (const op of asm) {
    if (op === OP_RETURN) {
      return [OP_RETURN];
    }
  }

  return asm;
}

function isP2sh(bytes: Buffer): boolean {
  return (
    bytes.length === 23 &&
    bytes[0] == Operation["OP_HASH160"] &&
    bytes[1] == 0x14 && // Operation["OP_PUSHBYTES_20"]
    bytes[22] == Operation["OP_EQUAL"]
  );
}

function isP2pkh(bytes: Buffer): boolean {
  return (
    bytes.length == 25 &&
    bytes[0] == Operation["OP_DUP"] &&
    bytes[1] == Operation["OP_HASH160"] &&
    bytes[2] == 0x14 && // Operation["OP_PUSHBYTES_20"]
    bytes[23] == Operation["OP_EQUALVERIFY"] &&
    bytes[24] == Operation["OP_CHECKSIG"]
  );
}

function isP2pk(bytes: Buffer): boolean {
  if (bytes.length == 67) {
    return (
      bytes[0] == 0x41 && // Operation["OP_PUSHBYTES_65"]
      bytes[66] == Operation["OP_CHECKSIG"]
    );
  } else if (bytes.length == 35) {
    return (
      bytes[0] == 0x21 && // Operation["OP_PUSHBYTES_33"]
      bytes[34] == Operation["OP_CHECKSIG"]
    );
  } else {
    return false;
  }
}

    return asm;
function isReturn(bytes: Buffer): boolean {
  return bytes.includes(Operation["OP_RETURN"]);
}

enum Operation {
    OP_FALSE = 0x00,
    OP_PUSHDATA1 = 0x4c,
    OP_PUSHDATA2 = 0x4d,
    OP_PUSHDATA4 = 0x4e,
    OP_1NEGATE = 0x4f,
    OP_RESERVED = 0x50,
    OP_TRUE = 0x51,
    OP_2 = 0x52,
    OP_3 = 0x53,
    OP_4 = 0x54,
    OP_5 = 0x55,
    OP_6 = 0x56,
    OP_7 = 0x57,
    OP_8 = 0x58,
    OP_9 = 0x59,
    OP_10 = 0x5a,
    OP_11 = 0x5b,
    OP_12 = 0x5c,
    OP_13 = 0x5d,
    OP_14 = 0x5e,
    OP_15 = 0x5f,
    OP_16 = 0x60,
    OP_NOP = 0x61,
    OP_VER = 0x62,
    OP_IF = 0x63,
    OP_NOTIF = 0x64,
    OP_VERIF = 0x65,
    OP_VERNOTIF = 0x66,
    OP_ELSE = 0x67,
    OP_ENDIF = 0x68,
    OP_VERIFY = 0x69,
    OP_RETURN = 0x6a,
    OP_TOALTSTACK = 0x6b,
    OP_FROMALTSTACK = 0x6c,
    OP_2DROP = 0x6d,
    OP_2DUP = 0x6e,
    OP_3DUP = 0x6f,
    OP_2OVER = 0x70,
    OP_2ROT = 0x71,
    OP_2SWAP = 0x72,
    OP_IFDUP = 0x73,
    OP_DEPTH = 0x74,
    OP_DROP = 0x75,
    OP_DUP = 0x76,
    OP_NIP = 0x77,
    OP_OVER = 0x78,
    OP_PICK = 0x79,
    OP_ROLL = 0x7a,
    OP_ROT = 0x7b,
    OP_SWAP = 0x7c,
    OP_TUCK = 0x7d,
    OP_CAT = 0x7e,
    OP_SUBSTR = 0x7f,
    OP_LEFT = 0x80,
    OP_RIGHT = 0x81,
    OP_SIZE = 0x82,
    OP_INVERT = 0x83,
    OP_AND = 0x84,
    OP_OR = 0x85,
    OP_XOR = 0x86,
    OP_EQUAL = 0x87,
    OP_EQUALVERIFY = 0x88,
    OP_RESERVED1 = 0x89,
    OP_RESERVED2 = 0x8a,
    OP_1ADD = 0x8b,
    OP_1SUB = 0x8c,
    OP_2MUL = 0x8d,
    OP_2DIV = 0x8e,
    OP_NEGATE = 0x8f,
    OP_ABS = 0x90,
    OP_NOT = 0x91,
    OP_0NOTEQUAL = 0x92,
    OP_ADD = 0x93,
    OP_SUB = 0x94,
    OP_MUL = 0x95,
    OP_DIV = 0x96,
    OP_MOD = 0x97,
    OP_LSHIFT = 0x98,
    OP_RSHIFT = 0x99,
    OP_BOOLAND = 0x9a,
    OP_BOOLOR = 0x9b,
    OP_NUMEQUAL = 0x9c,
    OP_NUMEQUALVERIFY = 0x9d,
    OP_NUMNOTEQUAL = 0x9e,
    OP_LESSTHAN = 0x9f,
    OP_GREATERTHAN = 0xa0,
    OP_LESSTHANOREQUAL = 0xa1,
    OP_GREATERTHANOREQUAL = 0xa2,
    OP_MIN = 0xa3,
    OP_MAX = 0xa4,
    OP_WITHIN = 0xa5,
    OP_RIPEMD160 = 0xa6,
    OP_SHA1 = 0xa7,
    OP_SHA256 = 0xa8,
    OP_HASH160 = 0xa9,
    OP_HASH256 = 0xaa,
    OP_CODESEPARATOR = 0xab,
    OP_CHECKSIG = 0xac,
    OP_CHECKSIGVERIFY = 0xad,
    OP_CHECKMULTISIG = 0xae,
    OP_CHECKMULTISIGVERIFY = 0xaf,
    OP_NOP1 = 0xb0,
    OP_CHECKLOCKTIMEVERIFY = 0xb1,
    OP_CHECKSEQUENCEVERIFY = 0xb2,
    OP_NOP4 = 0xb3,
    OP_NOP5 = 0xb4,
    OP_NOP6 = 0xb5,
    OP_NOP7 = 0xb6,
    OP_NOP8 = 0xb7,
    OP_NOP9 = 0xb8,
    OP_NOP10 = 0xb9,
    OP_PUBKEYHASH = 0xfd,
    OP_PUBKEY = 0xfe,
    OP_INVALIDOPCODE = 0xff,
  OP_FALSE = 0x00,
  OP_PUSHDATA1 = 0x4c,
  OP_PUSHDATA2 = 0x4d,
  OP_PUSHDATA4 = 0x4e,
  OP_1NEGATE = 0x4f,
  OP_RESERVED = 0x50,
  OP_TRUE = 0x51,
  OP_2 = 0x52,
  OP_3 = 0x53,
  OP_4 = 0x54,
  OP_5 = 0x55,
  OP_6 = 0x56,
  OP_7 = 0x57,
  OP_8 = 0x58,
  OP_9 = 0x59,
  OP_10 = 0x5a,
  OP_11 = 0x5b,
  OP_12 = 0x5c,
  OP_13 = 0x5d,
  OP_14 = 0x5e,
  OP_15 = 0x5f,
  OP_16 = 0x60,
  OP_NOP = 0x61,
  OP_VER = 0x62,
  OP_IF = 0x63,
  OP_NOTIF = 0x64,
  OP_VERIF = 0x65,
  OP_VERNOTIF = 0x66,
  OP_ELSE = 0x67,
  OP_ENDIF = 0x68,
  OP_VERIFY = 0x69,
  OP_RETURN = 0x6a,
  OP_TOALTSTACK = 0x6b,
  OP_FROMALTSTACK = 0x6c,
  OP_2DROP = 0x6d,
  OP_2DUP = 0x6e,
  OP_3DUP = 0x6f,
  OP_2OVER = 0x70,
  OP_2ROT = 0x71,
  OP_2SWAP = 0x72,
  OP_IFDUP = 0x73,
  OP_DEPTH = 0x74,
  OP_DROP = 0x75,
  OP_DUP = 0x76,
  OP_NIP = 0x77,
  OP_OVER = 0x78,
  OP_PICK = 0x79,
  OP_ROLL = 0x7a,
  OP_ROT = 0x7b,
  OP_SWAP = 0x7c,
  OP_TUCK = 0x7d,
  OP_CAT = 0x7e,
  OP_SUBSTR = 0x7f,
  OP_LEFT = 0x80,
  OP_RIGHT = 0x81,
  OP_SIZE = 0x82,
  OP_INVERT = 0x83,
  OP_AND = 0x84,
  OP_OR = 0x85,
  OP_XOR = 0x86,
  OP_EQUAL = 0x87,
  OP_EQUALVERIFY = 0x88,
  OP_RESERVED1 = 0x89,
  OP_RESERVED2 = 0x8a,
  OP_1ADD = 0x8b,
  OP_1SUB = 0x8c,
  OP_2MUL = 0x8d,
  OP_2DIV = 0x8e,
  OP_NEGATE = 0x8f,
  OP_ABS = 0x90,
  OP_NOT = 0x91,
  OP_0NOTEQUAL = 0x92,
  OP_ADD = 0x93,
  OP_SUB = 0x94,
  OP_MUL = 0x95,
  OP_DIV = 0x96,
  OP_MOD = 0x97,
  OP_LSHIFT = 0x98,
  OP_RSHIFT = 0x99,
  OP_BOOLAND = 0x9a,
  OP_BOOLOR = 0x9b,
  OP_NUMEQUAL = 0x9c,
  OP_NUMEQUALVERIFY = 0x9d,
  OP_NUMNOTEQUAL = 0x9e,
  OP_LESSTHAN = 0x9f,
  OP_GREATERTHAN = 0xa0,
  OP_LESSTHANOREQUAL = 0xa1,
  OP_GREATERTHANOREQUAL = 0xa2,
  OP_MIN = 0xa3,
  OP_MAX = 0xa4,
  OP_WITHIN = 0xa5,
  OP_RIPEMD160 = 0xa6,
  OP_SHA1 = 0xa7,
  OP_SHA256 = 0xa8,
  OP_HASH160 = 0xa9,
  OP_HASH256 = 0xaa,
  OP_CODESEPARATOR = 0xab,
  OP_CHECKSIG = 0xac,
  OP_CHECKSIGVERIFY = 0xad,
  OP_CHECKMULTISIG = 0xae,
  OP_CHECKMULTISIGVERIFY = 0xaf,
  OP_NOP1 = 0xb0,
  OP_CHECKLOCKTIMEVERIFY = 0xb1,
  OP_CHECKSEQUENCEVERIFY = 0xb2,
  OP_NOP4 = 0xb3,
  OP_NOP5 = 0xb4,
  OP_NOP6 = 0xb5,
  OP_NOP7 = 0xb6,
  OP_NOP8 = 0xb7,
  OP_NOP9 = 0xb8,
  OP_NOP10 = 0xb9,
  OP_PUBKEYHASH = 0xfd,
  OP_PUBKEY = 0xfe,
  OP_INVALIDOPCODE = 0xff,
}
diff --git a/frontend/src/lib/dayjs.ts b/frontend/src/lib/dayjs.ts
index 82466c0..c230266 100644
--- a/frontend/src/lib/dayjs.ts
+++ b/frontend/src/lib/dayjs.ts
@@ -2,9 +2,9 @@ import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime.js";
import localizedFormat from "dayjs/plugin/localizedFormat.js";

import 'dayjs/locale/en-gb';
import "dayjs/locale/en-gb";

dayjs.locale('en-gb');
dayjs.locale("en-gb");

dayjs.extend(relativeTime);
dayjs.extend(localizedFormat);
diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts
index c4eb7e2..0795ed4 100644
--- a/frontend/src/lib/i18n.ts
+++ b/frontend/src/lib/i18n.ts
@@ -1,16 +1,14 @@
import i18n from 'sveltekit-i18n';
import i18n from "sveltekit-i18n";

/** @type {import('sveltekit-i18n').Config} */
const config = ({
    loaders: [
        {
            locale: 'en',
            key: '',
            loader: async () => (
                await import('./i18n/en.json')
            ).default,
        }
    ],
});
const config = {
  loaders: [
    {
      locale: "en",
      key: "",
      loader: async () => (await import("./i18n/en.json")).default,
    },
  ],
};

export const { t, locale, locales, loading, loadTranslations } = new i18n(config);
diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json
index 90dc94d..db18aff 100644
--- a/frontend/src/lib/i18n/en.json
+++ b/frontend/src/lib/i18n/en.json
@@ -11,6 +11,7 @@
      "table": {
        "height": "Height",
        "timestamp": "Timestamp",
        "pool": "Pool",
        "txns": "Transactions",
        "size": "Size (KB)",
        "weight": "Weight (KWU)"
diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts
new file mode 100644
index 0000000..6260e50
--- /dev/null
+++ b/frontend/src/lib/store.ts
@@ -0,0 +1,19 @@
import { browser } from "$app/env";
import { writable } from "svelte/store";

let initialRelativeTimestamps;
if (browser) {
  const storedRelativeTimestamps = window.localStorage.getItem("relativeTimestamps");
  initialRelativeTimestamps =
    storedRelativeTimestamps !== null ? storedRelativeTimestamps === "1" : true;
} else {
  initialRelativeTimestamps = false;
}

export const relativeTimestamps = writable(initialRelativeTimestamps);
export const toggleTimestamps = () => relativeTimestamps.update((v) => !v);
relativeTimestamps.subscribe((v) => {
  if (browser) {
    window.localStorage.setItem("relativeTimestamps", v ? "1" : "0");
  }
});
diff --git a/frontend/src/routes/__layout.svelte b/frontend/src/routes/__layout.svelte
index a0aadb4..8e1eee0 100644
--- a/frontend/src/routes/__layout.svelte
+++ b/frontend/src/routes/__layout.svelte
@@ -1,10 +1,10 @@
<script context="module">
  import { locale, loadTranslations } from '$lib/i18n';
  import { locale, loadTranslations } from "$lib/i18n";

  export async function load({ url }) {
    const { pathname } = url;

    const defaultLocale = 'en'; // TODO: get from cookie, user session, ...
    const defaultLocale = "en"; // TODO: get from cookie, user session, ...
    const initLocale = locale.get() || defaultLocale;

    await loadTranslations(initLocale, pathname);
diff --git a/frontend/src/routes/address/[address].svelte b/frontend/src/routes/address/[address].svelte
index ed0902a..49059c5 100644
--- a/frontend/src/routes/address/[address].svelte
+++ b/frontend/src/routes/address/[address].svelte
@@ -1,42 +1,44 @@
<script context="module">
    export async function load({ fetch, params, url }) {
        let res = await fetch(`http://localhost:3001/address/${params.address}`);

        if (res.ok) {
            return {
                props: {
                    transactions: await res.json(),
                    address: params.address,
                }
            };
        }
        return {
            status: res.status,
            error: new Error()
        };
  export async function load({ fetch, params, url }) {
    let res = await fetch(`http://localhost:3001/address/${params.address}`);

    if (res.ok) {
      return {
        props: {
          transactions: await res.json(),
          address: params.address,
        },
      };
    }
    return {
      status: res.status,
      error: new Error(),
    };
  }
</script>

<script>
    import { briefHexToAsm } from "$lib/bitcoinScript";
    import Transaction from "$lib/Transaction.svelte";
  import { briefHexToAsm } from "$lib/bitcoinScript";
  import Transaction from "$lib/Transaction.svelte";

    export let transactions = {};
    export let address = '';
  export let transactions = {};
  export let address = "";
</script>

<div>
    <section class="p-7">
        <h2 class="!p-0 !py-4">{address}</h2>
    </section>

    <section class="!bg-transparent">
        <h3 class="text-white text-2xl">{transactions.length} Transaction{transactions.length > 1 ? 's' : ''}</h3>
    </section>

    {#each transactions as transaction}
        <Transaction transaction={transaction} />
    {/each}
  <section class="p-7">
    <h2 class="!p-0 !text-base md:!text-lg">{address}</h2>
  </section>

  <section class="!bg-transparent">
    <h3 class="text-white text-2xl">
      {transactions.length} Transaction{transactions.length > 1 ? "s" : ""}
    </h3>
  </section>

  {#each transactions as transaction}
    <Transaction highlight={address} {transaction} />
  {/each}
</div>

<style lang="scss">
diff --git a/frontend/src/routes/block/[id].svelte b/frontend/src/routes/block/[id].svelte
index 43e94f1..96a0563 100644
--- a/frontend/src/routes/block/[id].svelte
+++ b/frontend/src/routes/block/[id].svelte
@@ -1,6 +1,6 @@
<script context="module">
  export async function load({ fetch, params, url }) {
    const offset = Math.max(0, Number.parseInt(url.searchParams.get('offset') || '0'));
    const offset = Math.max(0, Number.parseInt(url.searchParams.get("offset") || "0"));

    let res = await fetch(`http://localhost:3001/block/${params.id}?offset=${offset}`);

@@ -10,7 +10,7 @@
      if (offset >= block.tx_count) {
        return {
          status: 404,
          error: new Error("Offset exceeds the transaction count in this block")
          error: new Error("Offset exceeds the transaction count in this block"),
        };
      }

@@ -18,12 +18,12 @@
        props: {
          block,
          currentPage: Math.floor(offset / 30),
        }
        },
      };
    }
    return {
      status: res.status,
      error: new Error()
      error: new Error(),
    };
  }
</script>
@@ -58,16 +58,19 @@
  </section>

  <section class="!bg-transparent">
    <h3 class="text-white text-2xl">{block.tx_count} Transaction{block.tx_count > 1 ? 's' : ''}</h3>
    <h3 class="text-white text-2xl">{block.tx_count} Transaction{block.tx_count > 1 ? "s" : ""}</h3>
  </section>

  {#each block.transactions as transaction}
    <Transaction transaction={transaction} />
    <Transaction {transaction} />
  {/each}

  <div class="pagination">
    {#each { length: Math.ceil(block.tx_count / 30) } as _, i}
      <a href="/block/{block.height}{i === 0 ? '' : `?offset=${i * 30}`}" class:active={i === currentPage}>{i + 1}</a>
      <a
        href="/block/{block.height}{i === 0 ? '' : `?offset=${i * 30}`}"
        class:active={i === currentPage}>{i + 1}</a
      >
    {/each}
  </div>
</div>
diff --git a/frontend/src/routes/block/index.svelte b/frontend/src/routes/block/index.svelte
index 25661a5..762890d 100644
--- a/frontend/src/routes/block/index.svelte
+++ b/frontend/src/routes/block/index.svelte
@@ -1,7 +1,79 @@
<section>
  <h2>block index</h2>
</section>
<script context="module">
  export async function load({ fetch, url }) {
    const offset = Math.max(0, Number.parseInt(url.searchParams.get("offset") || 0));
    const limit = 20;

    let res = await fetch(`http://localhost:3001/block?limit=${limit}&offset=${offset}`);

    if (res.ok) {
      return {
        props: {
          blocks: await res.json(),
          offset,
          limit,
        },
      };
    }
    return {
      status: res.status,
      error: new Error(),
    };
  }
</script>

<script>
  import Blocks from "$lib/Blocks.svelte";

  export let blocks;
  export let offset;
  export let limit;
</script>

<div class="mb-4">
  <section class="mb-3">
    <h2>Blocks</h2>

    <div class="table-responsive">
      <Blocks {blocks} />
    </div>
  </section>

  <section class="!bg-transparent !mt-0 mb-3 flex">
    {#if offset > 0}
      {#if offset - limit <= 0}
        <a
          href="/block"
          class="!text-slate-200 text-base rounded-lg bg-gray-800 p-3 cursor-pointer"
        >
          ← Previous
        </a>
      {:else}
        <a
          href="/block?offset={Math.max(0, offset - limit)}"
          class="!text-slate-200 text-base rounded-lg bg-gray-800 p-3 cursor-pointer"
        >
          ← Previous
        </a>
      {/if}
    {/if}

    <div class="flex-grow" />

    {#if blocks.length >= limit}
      <a
        href="/block?offset={offset + limit}"
        class="!text-slate-200 text-base rounded-lg bg-gray-800 p-3 cursor-pointer"
      >
        Next →
      </a>
    {/if}
  </section>
</div>

<style lang="scss">
  @import "../../_section.scss";

  section {
    @apply text-xs;
  }
</style>
diff --git a/frontend/src/routes/index.svelte b/frontend/src/routes/index.svelte
index a4eb572..d43c0b6 100644
--- a/frontend/src/routes/index.svelte
+++ b/frontend/src/routes/index.svelte
@@ -1,17 +1,23 @@
<script context="module">
  export async function load({ fetch }) {
    let res = await fetch('http://localhost:3001/block');
    const [blocks, txs] = await Promise.all([
      fetch("http://localhost:3001/block?limit=5"),
      fetch("http://localhost:3001/tx?limit=5"),
    ]);

    if (txs.ok && blocks.ok) {
      const [blocksJson, txsJson] = await Promise.all([blocks.json(), txs.json()]);

    if (res.ok) {
      return {
        props: {
          blocks: await res.json()
        }
          blocks: blocksJson,
          transactions: txsJson,
        },
      };
    }
    return {
      status: res.status,
      error: new Error()
      status: blocks.status !== 200 ? blocks.status : txs.status,
      error: new Error(),
    };
  }
</script>
@@ -19,45 +25,12 @@
<script>
  import { t as _ } from "$lib/i18n";
  import Time from "$lib/Time.svelte";
  import Blocks from "$lib/Blocks.svelte";
  import Transactions from "$lib/Transactions.svelte";

  // TODO: needs loader
  export let blocks = [];

  let relativeTimestamps = true;
  const toggleTimestamps = () => relativeTimestamps = !relativeTimestamps;

  let transactions = [
    {
      hash: "cd16563757e4504d7f204cb3af0f3f388e6118b4f91e7fc711b3afe8108f7bf2",
      amount: {
        value: "50.13948574",
        unit: "BTC",
      },
      size: {
        value: "141",
        unit: "vB",
      },
      fee: {
        value: "28.6",
        unit: "sat/vB",
      },
    },
    {
      hash: "4a22799110919efb8fd476d3b06d21c563813bd4a523ffd50963565445ccbab2",
      amount: {
        value: "12.24129544",
        unit: "BTC",
      },
      size: {
        value: "201",
        unit: "vB",
      },
      fee: {
        value: "1.2",
        unit: "sat/vB",
      },
    },
  ];
  export let transactions = [];
</script>

<div>
@@ -68,68 +41,18 @@
    </div>

    <div class="table-responsive">
      <table class="table-fixed md:table-auto">
        <thead>
          <tr>
            <th>{$_("home.latest_blocks.table.height")}</th>
            <th>{$_("home.latest_blocks.table.timestamp")}</th>
            <th>{$_("home.latest_blocks.table.txns")}</th>
            <th>{$_("home.latest_blocks.table.size")}</th>
            <th>{$_("home.latest_blocks.table.weight")}</th>
          </tr>
        </thead>

        <tbody>
          {#each blocks as block}
            <tr>
              <th><a href={`/block/${block.height}`}>{block.height}</a></th>
              <td>
                <span on:click={toggleTimestamps}>
                  <Time
                    live
                    relative={relativeTimestamps}
                    timestamp={block.timestamp}
                  />
                </span>
              </td>
              <td>{block.tx_count}</td>
              <td>{block.bits}</td> <!-- todo: this isn't really size -->
              <td>{block.weight}</td>
            </tr>
          {/each}
        </tbody>
      </table>
      <Blocks {blocks} />
    </div>
  </section>

  <section>
  <section class="mb-2">
    <div class="flex">
      <h2 class="flex-grow">{$_("home.latest_txns.header")}</h2>
      <a class="header-size text-white hover:text-slate-400" href="/tx"></a>
    </div>

    <div class="table-responsive">
      <table>
        <thead>
          <tr>
            <th>{$_("home.latest_txns.table.txn_id")}</th>
            <th>{$_("home.latest_txns.table.value")}</th>
            <th>{$_("home.latest_txns.table.size")}</th>
            <th>{$_("home.latest_txns.table.fee")}</th>
          </tr>
        </thead>

        <tbody>
          {#each transactions as txn}
            <tr>
              <th><a href={`/tx/${txn.hash}`}><pre>{txn.hash}</pre></a></th>
              <td>{txn.amount.value} {txn.amount.unit}</td>
              <td>{txn.size.value} {txn.size.unit}</td>
              <td>{txn.fee.value} {txn.fee.unit}</td>
            </tr>
          {/each}
        </tbody>
      </table>
      <Transactions {transactions} />
    </div>
  </section>
</div>
diff --git a/frontend/src/routes/tx/[id].svelte b/frontend/src/routes/tx/[id].svelte
index 3864b02..d050607 100644
--- a/frontend/src/routes/tx/[id].svelte
+++ b/frontend/src/routes/tx/[id].svelte
@@ -1,7 +1,68 @@
<section>
  <h2>single txn</h2>
</section>
<script context="module">
  export async function load({ fetch, params, url }) {
    let res = await fetch(`http://localhost:3001/tx/${params.id}`);

    if (res.ok) {
      return {
        props: {
          tx: await res.json(),
        },
      };
    }
    return {
      status: res.status,
      error: new Error(),
    };
  }
</script>

<script>
  import Transaction from "$lib/Transaction.svelte";

  let showingMoreInfo = false;

  export let tx;
</script>

<div>
  <section class="p-7">
    <h2 class="!p-0">Transaction {tx.hash}</h2>
  </section>

  <section class="table-responsive">
    <table class="text-xs">
      <tbody>
        <tr>
          <th>Version</th>
          <td>{tx.version}</td>
        </tr>
        <tr>
          <th>Weight</th>
          <td>{tx.weight}</td>
        </tr>
        <tr>
          <th>Replace By Fee</th>
          <td>{tx.replace_by_fee ? "Opted in" : "No"}</td>
        </tr>
      </tbody>
    </table>
  </section>

  <section class="flex !bg-transparent mb-2">
    <div class="flex-grow" />

    <button
      on:click={() => (showingMoreInfo = !showingMoreInfo)}
      class="text-slate-200 text-base rounded-lg bg-gray-800 p-2 cursor-pointer"
    >
      {showingMoreInfo ? "- Less Info" : "+ More Info"}
    </button>
  </section>

  <Transaction attachAnchor class="!mt-0" transaction={tx} {showingMoreInfo} showTxHeader={false} />
</div>

<style lang="scss">
  @import "../../_section.scss";
  @import "../../_table.scss";
</style>
diff --git a/frontend/src/routes/tx/index.svelte b/frontend/src/routes/tx/index.svelte
index 4720032..7e6556a 100644
--- a/frontend/src/routes/tx/index.svelte
+++ b/frontend/src/routes/tx/index.svelte
@@ -1,7 +1,41 @@
<section>
  <h2>txn index</h2>
</section>
<script context="module">
  export async function load({ fetch }) {
    const txs = await fetch("http://localhost:3001/tx?limit=20");

    if (txs.ok) {
      return {
        props: {
          transactions: await txs.json(),
        },
      };
    }
    return {
      status: txs.status,
      error: new Error(),
    };
  }
</script>

<script>
  import Transactions from "$lib/Transactions.svelte";

  export let transactions;
</script>

<div class="mb-4">
  <section class="mb-3">
    <h2>Transactions</h2>

    <div class="table-responsive">
      <Transactions {transactions} />
    </div>
  </section>
</div>

<style lang="scss">
  @import "../../_section.scss";

  section {
    @apply text-xs;
  }
</style>
diff --git a/indexer/src/main.rs b/indexer/src/main.rs
index 0aeae00..1c2c266 100644
--- a/indexer/src/main.rs
+++ b/indexer/src/main.rs
@@ -188,16 +188,12 @@ async fn insert_transaction(
    transaction: &Transaction,
) -> Result<i64, Box<dyn std::error::Error>> {
    let query = "
        WITH inserted AS (
            INSERT INTO transactions
            (hash, block_id, version, lock_time, weight, coinbase, replace_by_fee)
            VALUES ($1, $2, $3, $4, $5, $6, $7)
            ON CONFLICT DO NOTHING
            RETURNING id
        ) SELECT COALESCE(
            (SELECT id FROM inserted),
            (SELECT id FROM transactions WHERE hash = $1)
        ) AS id
        INSERT INTO transactions
        (hash, block_id, version, lock_time, weight, coinbase, replace_by_fee)
        VALUES ($1, $2, $3, $4, $5, $6, $7)
        ON CONFLICT (hash) DO UPDATE
            SET block_id = excluded.block_id
        RETURNING id
    ";

    Ok(tx
@@ -223,17 +219,24 @@ async fn insert_transaction_input(
    transaction_id: i64,
    transaction_input: &TxIn,
) -> Result<(), Box<dyn std::error::Error>> {
    let previous_output = select_transaction_output(
        tx,
        &transaction_input.previous_output.txid.to_vec(),
        transaction_input.previous_output.vout as i64,
    )
    .await?;

    let query = "
        INSERT INTO transaction_inputs
        (transaction_id, index, previous_output, script)
        VALUES ($1, $2, $3, $4)
        (transaction_id, index, sequence, witness, script, previous_output)
        VALUES (
            $1,
            $2,
            $3,
            $4,
            $5,
            (
                SELECT transaction_outputs.id
                FROM transactions
                INNER JOIN transaction_outputs
                    ON transactions.id = transaction_outputs.transaction_id
                WHERE transactions.hash = $6
                    AND transaction_outputs.index = $7
            )
        )
        ON CONFLICT DO NOTHING
    ";

@@ -242,8 +245,11 @@ async fn insert_transaction_input(
        &[
            &transaction_id,
            &index,
            &previous_output,
            &i64::from(transaction_input.sequence),
            &transaction_input.witness.to_vec(),
            &transaction_input.script_sig.as_bytes(),
            &transaction_input.previous_output.txid.to_vec(),
            &(transaction_input.previous_output.vout as i64),
        ],
    )
    .await?;
@@ -281,31 +287,6 @@ async fn insert_transaction_output(
    Ok(())
}

// TODO: this is a _very_ efficient query involving just two index scans, right now we're inserting
//  it alongside transaction_outputs, but we need sequential inserts for that to work. maybe we can
//  just call this query on-demand? or figure out a way to sequentialise inserts - that's quite risky
//  to our insert speed though.
async fn select_transaction_output(
    tx: &tokio_postgres::Transaction<'_>,
    transaction_hash: &[u8],
    transaction_index: i64,
) -> Result<Option<i64>, Box<dyn std::error::Error>> {
    let query = "
        SELECT transaction_outputs.id AS output_id
        FROM transactions
        INNER JOIN transaction_outputs
            ON transactions.id = transaction_outputs.transaction_id
        WHERE transactions.hash = $1
            AND transaction_outputs.index = $2
    ";

    let row = tx
        .query_opt(query, &[&transaction_hash, &transaction_index])
        .await?;

    Ok(row.map(|v| v.get("output_id")))
}

#[derive(Parser, Debug)]
#[clap(version, about, long_about = None)]
pub struct Args {
diff --git a/migrations/up/V1__initial_schema.sql b/migrations/up/V1__initial_schema.sql
index e7b97cb..01deae3 100644
--- a/migrations/up/V1__initial_schema.sql
+++ b/migrations/up/V1__initial_schema.sql
@@ -56,6 +56,8 @@ CREATE TABLE transaction_inputs (
    id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    transaction_id BIGINT NOT NULL,
    index BIGINT NOT NULL,
    sequence BIGINT NOT NULL,
    witness BYTEA[] NOT NULL,
    previous_output BIGINT,
    script BYTEA NOT NULL,
    CONSTRAINT fk_transaction_id
diff --git a/web-api/src/database/blocks.rs b/web-api/src/database/blocks.rs
index 6cbe3c1..04cd55b 100644
--- a/web-api/src/database/blocks.rs
+++ b/web-api/src/database/blocks.rs
@@ -46,17 +46,29 @@ pub type TransactionCount = i64;
pub async fn fetch_latest_blocks(
    db: &Connection,
    count: i64,
) -> Result<Vec<(Block, TransactionCount)>> {
    offset: i64,
) -> Result<Vec<(Block, TransactionCount, Vec<u8>)>> {
    let blocks = db
        .query(
            "SELECT blocks.*, COUNT(transactions.id) AS tx_count
            "SELECT
               blocks.*,
               COUNT(transactions.id) AS tx_count,
               (
                 SELECT script
                 FROM transactions
                 INNER JOIN transaction_inputs
                   ON transaction_inputs.transaction_id = transactions.id
                 WHERE transactions.block_id = blocks.id
                   AND transactions.coinbase = true
                 LIMIT 1
               ) AS coinbase_script
             FROM blocks
             LEFT JOIN transactions
               ON transactions.block_id = blocks.id
             GROUP BY blocks.id
             ORDER BY blocks.height DESC
             LIMIT $1",
            &[&count],
             LIMIT $1 OFFSET $2",
            &[&count, &offset],
        )
        .await?;

@@ -64,7 +76,8 @@ pub async fn fetch_latest_blocks(
        .into_iter()
        .map(|row| {
            let tx_count = row.try_get("tx_count")?;
            Ok((Block::from_row(row)?, tx_count))
            let coinbase_script = row.try_get("coinbase_script")?;
            Ok((Block::from_row(row)?, tx_count, coinbase_script))
        })
        .collect::<Result<Vec<_>>>()
}
diff --git a/web-api/src/database/transactions.rs b/web-api/src/database/transactions.rs
index 499ac0a..9ac1466 100644
--- a/web-api/src/database/transactions.rs
+++ b/web-api/src/database/transactions.rs
@@ -1,4 +1,5 @@
use crate::database::{Connection, Result};
use futures::StreamExt;
use serde::de::Error;
use serde::{Deserialize, Deserializer};
use tokio::time::Instant;
@@ -34,13 +35,20 @@ impl Transaction {

#[derive(Deserialize, Debug)]
pub struct TransactionInput {
    pub previous_output_tx: Option<TransactionOutput>,
    pub sequence: i64,
    #[serde(deserialize_with = "trim_hex_prefix_vec")]
    pub witness: Vec<String>,
    #[serde(deserialize_with = "trim_hex_prefix")]
    pub script: String,
    #[serde(deserialize_with = "parse_hex_opt")]
    pub previous_output_tx_hash: Option<Vec<u8>>,
    #[serde(rename = "previous_output_item")]
    pub previous_output: Option<TransactionOutput>,
}

#[derive(Deserialize, Debug)]
pub struct TransactionOutput {
    pub index: i64,
    pub value: i64,
    #[serde(deserialize_with = "trim_hex_prefix")]
    pub script: String,
@@ -48,6 +56,31 @@ pub struct TransactionOutput {
    pub address: Option<String>,
}

fn parse_hex_opt<'de, D: Deserializer<'de>>(
    deserializer: D,
) -> std::result::Result<Option<Vec<u8>>, D::Error> {
    let s = <Option<String>>::deserialize(deserializer)?;
    s.map(|mut s| {
        s.remove(0);
        s.remove(0);
        hex::decode(s).map_err(D::Error::custom)
    })
    .transpose()
}

fn trim_hex_prefix_vec<'de, D: Deserializer<'de>>(
    deserializer: D,
) -> std::result::Result<Vec<String>, D::Error> {
    let s = <Vec<String>>::deserialize(deserializer)?;
    Ok(s.into_iter()
        .map(|mut s| {
            s.remove(0);
            s.remove(0);
            s
        })
        .collect())
}

fn trim_hex_prefix<'de, D: Deserializer<'de>>(
    deserializer: D,
) -> std::result::Result<String, D::Error> {
@@ -77,10 +110,15 @@ pub async fn fetch_transactions_for_block(
            (
                SELECT JSON_AGG(transaction_inputs)
                FROM (
                    SELECT ROW_TO_JSON(transaction_outputs) AS previous_output_tx, transaction_inputs.*
                    SELECT
                        pot.hash AS previous_output_tx_hash,
                        ROW_TO_JSON(po) AS previous_output_item,
                        transaction_inputs.*
                    FROM transaction_inputs
                    LEFT JOIN transaction_outputs
                        ON transaction_outputs.id = transaction_inputs.previous_output
                    LEFT JOIN transaction_outputs po
                        ON po.id = transaction_inputs.previous_output
                    LEFT JOIN transactions pot
                        ON pot.id = po.transaction_id
                    WHERE transactions.id = transaction_inputs.transaction_id
                ) transaction_inputs
            ) AS inputs,
@@ -121,10 +159,15 @@ pub async fn fetch_transactions_for_address(
	            (
	                SELECT JSON_AGG(transaction_inputs)
	                FROM (
	                    SELECT ROW_TO_JSON(transaction_outputs) AS previous_output_tx, transaction_inputs.*
	                    SELECT
                            pot.hash AS previous_output_tx_hash,
	                        ROW_TO_JSON(po) AS previous_output_item,
	                        transaction_inputs.*
	                    FROM transaction_inputs
	                    LEFT JOIN transaction_outputs
	                        ON transaction_outputs.id = transaction_inputs.previous_output
	                    LEFT JOIN transaction_outputs po
	                        ON po.id = transaction_inputs.previous_output
                        LEFT JOIN transactions pot
                            ON pot.id = po.transaction_id
	                    WHERE transactions.id = transaction_inputs.transaction_id
	                ) transaction_inputs
	            ) AS inputs,
@@ -155,3 +198,55 @@ pub async fn fetch_transactions_for_address(
        .map(Transaction::from_row)
        .collect()
}

pub async fn fetch_latest_transactions(db: &Connection, limit: i64) -> Result<Vec<Transaction>> {
    let select_query = "
        SELECT *, JSON_BUILD_ARRAY() AS inputs, JSON_BUILD_ARRAY() AS outputs
        FROM transactions
        ORDER BY transactions.id DESC
        LIMIT $1
    ";

    let transactions = db.query(select_query, &[&limit]).await?;

    transactions
        .into_iter()
        .map(Transaction::from_row)
        .collect()
}

pub async fn fetch_transaction_by_hash(
    db: &Connection,
    hash: &[u8],
) -> Result<Option<Transaction>> {
    let select_query = "
        SELECT
	            transactions.*,
	            (
	                SELECT JSON_AGG(transaction_inputs)
	                FROM (
	                    SELECT
                            pot.hash AS previous_output_tx_hash,
	                        ROW_TO_JSON(po) AS previous_output_item,
	                        transaction_inputs.*
	                    FROM transaction_inputs
	                    LEFT JOIN transaction_outputs po
	                        ON po.id = transaction_inputs.previous_output
                        LEFT JOIN transactions pot
                            ON pot.id = po.transaction_id
	                    WHERE transactions.id = transaction_inputs.transaction_id
	                ) transaction_inputs
	            ) AS inputs,
	            (
	                SELECT JSON_AGG(transaction_outputs.*)
	                FROM transaction_outputs
	                WHERE transactions.id = transaction_outputs.transaction_id
	            ) AS outputs
	        FROM transactions
	        WHERE transactions.hash = $1
    ";

    let transaction = db.query_opt(select_query, &[&hash]).await?;

    transaction.map(Transaction::from_row).transpose()
}
diff --git a/web-api/src/methods/address.rs b/web-api/src/methods/address.rs
index 3a94331..8e99039 100644
--- a/web-api/src/methods/address.rs
+++ b/web-api/src/methods/address.rs
@@ -12,23 +12,5 @@ pub async fn handle(
        .await
        .unwrap();

    Json(
        transactions
            .into_iter()
            .map(|mut tx| {
                tx.hash.reverse();

                Transaction {
                    hash: hex::encode(tx.hash),
                    version: tx.version,
                    lock_time: tx.lock_time,
                    weight: tx.weight,
                    coinbase: tx.coinbase,
                    replace_by_fee: tx.replace_by_fee,
                    inputs: tx.inputs.0.into_iter().map(Into::into).collect(),
                    outputs: tx.outputs.0.into_iter().map(Into::into).collect(),
                }
            })
            .collect(),
    )
    Json(transactions.into_iter().map(Into::into).collect())
}
diff --git a/web-api/src/methods/block.rs b/web-api/src/methods/block.rs
index d6fa61d..d79fb69 100644
--- a/web-api/src/methods/block.rs
+++ b/web-api/src/methods/block.rs
@@ -4,11 +4,22 @@ use axum::{Extension, Json};
use chrono::NaiveDateTime;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use std::ptr::hash;

#[derive(Serialize)]
pub struct MinedBy {
    pool: &'static str,
}

impl From<Pool> for MinedBy {
    fn from(pool: Pool) -> Self {
        Self { pool: pool.name() }
    }
}

#[derive(Serialize)]
pub struct BlockList {
    hash: String,
    mined_by: Option<MinedBy>,
    height: i64,
    version: i32,
    timestamp: NaiveDateTime,
@@ -18,22 +29,41 @@ pub struct BlockList {
    tx_count: i64,
}

pub async fn list(Extension(database): Extension<Database>) -> Json<Vec<BlockList>> {
#[derive(Deserialize)]
pub struct ListParams {
    #[serde(default)]
    limit: u32,
    #[serde(default)]
    offset: u32,
}

pub async fn list(
    Extension(database): Extension<Database>,
    Query(params): Query<ListParams>,
) -> Json<Vec<BlockList>> {
    let database = database.get().await.unwrap();

    let blocks = crate::database::blocks::fetch_latest_blocks(&database, 5)
        .await
        .unwrap();
    let limit = std::cmp::min(20, std::cmp::max(5, params.limit));
    let offset = params.offset;

    let blocks = crate::database::blocks::fetch_latest_blocks(
        &database,
        i64::from(limit),
        i64::from(offset),
    )
    .await
    .unwrap();

    Json(
        blocks
            .into_iter()
            .map(|(mut block, tx_count)| {
            .map(|(mut block, tx_count, coinbase_script)| {
                // TODO: do this on insert
                block.hash.reverse();

                BlockList {
                    hash: hex::encode(block.hash),
                    mined_by: Pool::fetch_from_script(&coinbase_script).map(Into::into),
                    height: block.height,
                    version: block.version,
                    timestamp: block.timestamp,
@@ -77,20 +107,61 @@ pub struct Transaction {
    pub weight: i64,
    pub coinbase: bool,
    pub replace_by_fee: bool,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub inputs: Vec<TransactionInput>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub outputs: Vec<TransactionOutput>,
}

impl From<crate::database::transactions::Transaction> for Transaction {
    fn from(mut tx: crate::database::transactions::Transaction) -> Self {
        tx.hash.reverse();

        Transaction {
            hash: hex::encode(tx.hash),
            version: tx.version,
            lock_time: tx.lock_time,
            weight: tx.weight,
            coinbase: tx.coinbase,
            replace_by_fee: tx.replace_by_fee,
            inputs: tx.inputs.0.into_iter().map(Into::into).collect(),
            outputs: tx.outputs.0.into_iter().map(Into::into).collect(),
        }
    }
}

#[derive(Serialize)]
pub struct PreviousOutput {
    #[serde(flatten)]
    output: TransactionOutput,
    tx_hash: String,
    tx_index: i64,
}

#[derive(Serialize)]
pub struct TransactionInput {
    previous_output: Option<TransactionOutput>,
    witness: Vec<String>,
    sequence: i64,
    previous_output: Option<PreviousOutput>,
    script: String,
}

impl From<crate::database::transactions::TransactionInput> for TransactionInput {
    fn from(txi: crate::database::transactions::TransactionInput) -> Self {
        Self {
            previous_output: txi.previous_output_tx.map(Into::into),
            witness: txi.witness.into_iter().map(hex::encode).collect(),
            sequence: txi.sequence,
            previous_output: txi.previous_output.map(|v| PreviousOutput {
                tx_index: v.index,
                output: v.into(),
                tx_hash: txi
                    .previous_output_tx_hash
                    .map(|mut h| {
                        h.reverse();
                        hex::encode(h)
                    })
                    .unwrap_or_default(),
            }),
            script: txi.script,
        }
    }
@@ -98,6 +169,7 @@ impl From<crate::database::transactions::TransactionInput> for TransactionInput 

#[derive(Serialize)]
pub struct TransactionOutput {
    index: i64,
    value: i64,
    script: String,
    unspendable: bool,
@@ -107,6 +179,7 @@ pub struct TransactionOutput {
impl From<crate::database::transactions::TransactionOutput> for TransactionOutput {
    fn from(txo: crate::database::transactions::TransactionOutput) -> Self {
        Self {
            index: txo.index,
            value: txo.value,
            script: txo.script,
            unspendable: txo.unspendable,
@@ -154,23 +227,7 @@ pub async fn handle(
        bits: block.bits,
        nonce: block.nonce,
        difficulty: block.difficulty,
        transactions: transactions
            .into_iter()
            .map(|mut tx| {
                tx.hash.reverse();

                Transaction {
                    hash: hex::encode(tx.hash),
                    version: tx.version,
                    lock_time: tx.lock_time,
                    weight: tx.weight,
                    coinbase: tx.coinbase,
                    replace_by_fee: tx.replace_by_fee,
                    inputs: tx.inputs.0.into_iter().map(Into::into).collect(),
                    outputs: tx.outputs.0.into_iter().map(Into::into).collect(),
                }
            })
            .collect(),
        transactions: transactions.into_iter().map(Into::into).collect(),
    };

    Json(GetResponse {
@@ -178,3 +235,71 @@ pub async fn handle(
        block,
    })
}

pub enum Pool {
    Luxor,
    F2Pool,
    Binance,
    FoundryUsa,
    Slush,
    Poolin,
    ViaBtc,
    BtcCom,
    AntPool,
    MaraPool,
    SbiCrypto,
}

impl Pool {
    pub fn name(&self) -> &'static str {
        match self {
            Pool::Luxor => "Luxor",
            Pool::F2Pool => "F2Pool",
            Pool::Binance => "Binance",
            Pool::FoundryUsa => "Foundry USA",
            Pool::Slush => "Slush",
            Pool::Poolin => "Poolin",
            Pool::ViaBtc => "ViaBTC",
            Pool::BtcCom => "BTC.com",
            Pool::AntPool => "AntPool",
            Pool::MaraPool => "MaraPool",
            Pool::SbiCrypto => "SBICrypto",
        }
    }
}

impl Pool {
    fn fetch_from_script(coinbase_script: &[u8]) -> Option<Self> {
        let text = String::from_utf8_lossy(coinbase_script);

        macro_rules! define {
            ($($signature:expr => $pool:ident,)*) => {
                if false {
                    None
                }
                $(
                    else if text.contains($signature) {
                        Some(Self::$pool)
                    }
                )*
                else {
                    None
                }
            }
        }

        define! {
            "Powered by Luxor Tech" => Luxor,
            "F2Pool" => F2Pool,
            "binance" => Binance,
            "Foundry USA Pool" => FoundryUsa,
            "slush" => Slush,
            "poolin.com" => Poolin,
            "ViaBTC" => ViaBtc,
            "btcpool" => BtcCom,
            "Mined by AntPool" => AntPool,
            "MARA Pool" => MaraPool,
            "SBICrypto" => SbiCrypto,
        }
    }
}
diff --git a/web-api/src/methods/mod.rs b/web-api/src/methods/mod.rs
index c362378..7745af2 100644
--- a/web-api/src/methods/mod.rs
+++ b/web-api/src/methods/mod.rs
@@ -4,6 +4,7 @@ use axum::Router;
mod address;
mod block;
mod height;
mod transaction;

pub fn router() -> Router {
    Router::new()
@@ -11,4 +12,6 @@ pub fn router() -> Router {
        .route("/block", get(block::list))
        .route("/block/:height", get(block::handle))
        .route("/address/:address", get(address::handle))
        .route("/tx", get(transaction::list))
        .route("/tx/:hash", get(transaction::handle))
}
diff --git a/web-api/src/methods/transaction.rs b/web-api/src/methods/transaction.rs
new file mode 100644
index 0000000..4254588
--- /dev/null
+++ b/web-api/src/methods/transaction.rs
@@ -0,0 +1,45 @@
use crate::database::transactions::{fetch_latest_transactions, fetch_transaction_by_hash};
use crate::{
    database::transactions::fetch_transactions_for_address, methods::block::Transaction, Database,
};
use axum::extract::Query;
use axum::{extract::Path, Extension, Json};
use futures::StreamExt;
use serde::Deserialize;

#[derive(Deserialize)]
pub struct ListQuery {
    #[serde(default)]
    limit: u32,
}

pub async fn list(
    Extension(database): Extension<Database>,
    Query(query): Query<ListQuery>,
) -> Json<Vec<Transaction>> {
    let database = database.get().await.unwrap();

    let limit = std::cmp::min(20, std::cmp::max(5, query.limit));

    let transactions = fetch_latest_transactions(&database, limit.into())
        .await
        .unwrap();

    Json(transactions.into_iter().map(Into::into).collect())
}

pub async fn handle(
    Extension(database): Extension<Database>,
    Path(hash): Path<String>,
) -> Json<Transaction> {
    let mut hash = hex::decode(&hash).unwrap();
    hash.reverse();

    let database = database.get().await.unwrap();
    let transaction = fetch_transaction_by_hash(&database, &hash)
        .await
        .unwrap()
        .unwrap();

    Json(transaction.into())
}