Repository: skepticoin/skepticoin (language: python)

Issue:
MemoryError in disk_interface.py:81 - pickle.dump(data, file)
`skepticoin-mine` occasionally crashes with the following error when running on a machine with 4GB of RAM. Earlier I was getting the error on a 2GB machine, and upgrading the memory allowed it to keep it going a few more weeks.

```
Traceback (most recent call last):
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/local_peer.py", line 86, in handle_remote_peer_selector_event
    remote_peer.handle_receive_data(recv_data)
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/remote_peer.py", line 305, in handle_receive_data
    self.receiver.receive(data)
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/remote_peer.py", line 109, in receive
    self.handle_message_data(self.buffer[:self.len])
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/remote_peer.py", line 120, in handle_message_data
    self.peer.handle_message_received(header, message)
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/remote_peer.py", line 279, in handle_message_received
    return self.handle_data_message_received(header, message)
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/remote_peer.py", line 438, in handle_data_message_received
    return self.handle_block_received(header, message)
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/remote_peer.py", line 486, in handle_block_received
    self.local_peer.disk_interface.write_chain_cache_to_disk(coinstate_changed)
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/disk_interface.py", line 81, in write_chain_cache_to_disk
    coinstate.dump(lambda data: pickle.dump(data, file))
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/coinstate.py", line 132, in dump
    dumper(self.block_by_hash)
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/disk_interface.py", line 81, in <lambda>
    coinstate.dump(lambda data: pickle.dump(data, file))
```

A quick search through Google and Stackoverflow indicates excessive memory usage is a common problem with `pickle`, and one common solution is to replace `pickle` with `cPickle`.

Reference:
https://stackoverflow.com/questions/17513036/pickle-dump-huge-file-without-memory-error

As a workaround, add memory to mining machine.


network instability issues (updated)
These are fun times to be mining Skepticoin.

A few things I noticed:

- Only one peer remains up (not mine)
- My own nodes I try to run keep crashing, or they are running but don't appear on the list of peers when I try to add a 3rd or 4th machine.
- The one peer which is not mine behaves erratically, my logs showing Connection refused, Connection reset, No route to host.
- My node keeps retransmitting the entire blockchain to the one peer, over and over.
- Multiple "microforks" (blockchain diverges by few blocks) logged by the peers that manage to stay up a while.
- Additional instances of the one peer showing up on different TCP/IP Ports (non-2412) causing "No route to host" on my end
- Peers not responding to GetBlocksMessages or GetBlocksMessages not being sent. 
- Initially noticed GetBlocksMessages are sent twice to the same peer, after fixing this I noticed the peer disconnects in response to a legit 2nd GetBlocksMessages
- `netstat -an` showing "Recv-Q" backlog indicating the code is not reading the data from the sockets

MemoryError in disk_interface.py:81 - pickle.dump(data, file)
`skepticoin-mine` occasionally crashes with the following error when running on a machine with 4GB of RAM. Earlier I was getting the error on a 2GB machine, and upgrading the memory allowed it to keep it going a few more weeks.

```
Traceback (most recent call last):
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/local_peer.py", line 86, in handle_remote_peer_selector_event
    remote_peer.handle_receive_data(recv_data)
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/remote_peer.py", line 305, in handle_receive_data
    self.receiver.receive(data)
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/remote_peer.py", line 109, in receive
    self.handle_message_data(self.buffer[:self.len])
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/remote_peer.py", line 120, in handle_message_data
    self.peer.handle_message_received(header, message)
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/remote_peer.py", line 279, in handle_message_received
    return self.handle_data_message_received(header, message)
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/remote_peer.py", line 438, in handle_data_message_received
    return self.handle_block_received(header, message)
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/remote_peer.py", line 486, in handle_block_received
    self.local_peer.disk_interface.write_chain_cache_to_disk(coinstate_changed)
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/disk_interface.py", line 81, in write_chain_cache_to_disk
    coinstate.dump(lambda data: pickle.dump(data, file))
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/coinstate.py", line 132, in dump
    dumper(self.block_by_hash)
  File "/home/ec2-user/venv/lib64/python3.8/site-packages/skepticoin-0.1.15.dev124+g9dbb2bd-py3.8.egg/skepticoin/networking/disk_interface.py", line 81, in <lambda>
    coinstate.dump(lambda data: pickle.dump(data, file))
```

A quick search through Google and Stackoverflow indicates excessive memory usage is a common problem with `pickle`, and one common solution is to replace `pickle` with `cPickle`.

Reference:
https://stackoverflow.com/questions/17513036/pickle-dump-huge-file-without-memory-error

As a workaround, add memory to mining machine.




Discussion / hints:

Fix patch:
diff --git a/extensions/aws-cloud-mining/README.md b/extensions/aws-cloud-mining/README.md
index 9a99854..6ae4fdb 100644
--- a/extensions/aws-cloud-mining/README.md
+++ b/extensions/aws-cloud-mining/README.md
@@ -17,21 +17,21 @@ Despite being generally a bad idea, there are a couple of scenarios when you mig
 
 ## How to Use
 
-1. Pre-requisites: 
+1. Pre-requisites:
     - An AWS Account.
     - An EC2 Key Pair: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html
     - Cloud Formation templates downloaded to your computer (`network.yaml`, `storage.yaml`, `compute.yaml`). These are found in the same folder where this README is located.
-    - skepticoin files: `peers.json`, `chain.cache`, and `wallet.json`. You'll need to run skepticoin on your own computer first to obtain these files.
+    - skepticoin files: `peers.json`, `chain.db`, and `wallet.json`. You'll need to run skepticoin on your own computer first to obtain these files.
 2. Login to the AWS Console and navigate to the Cloud Formation service.
 3. Deploy `network.yaml`
     - Click 'Create Stack' -> 'with new resources' -> 'Upload a template file' and select `network.yaml` from your computer.
-    - Click Next to get to the Parameters tab. Update the SSHLocation parameter to whitelist your home/office IP address. 
+    - Click Next to get to the Parameters tab. Update the SSHLocation parameter to whitelist your home/office IP address.
     - Click Next, Next, then Create Stack.
 4. Deploy `storage.yaml` and upload data files
     - Click 'Create Stack' -> 'with new resources' -> 'Upload a template file' and select `storage.yaml` from your computer
-    - Click Next to get to the Parameters tab. Pick a unique name for your public skepticoin bucket. This where you will publish `peers.json` and `chain.cache`
+    - Click Next to get to the Parameters tab. Pick a unique name for your public skepticoin bucket. This where you will publish `peers.json` and `chain.db`
     - Click Next, Next, then Create Stack. Click the *Outputs* tab and note down the names of the Public and Private Buckets
-    - Navigate to the AWS S3 Service. Go to the Public Bucket and upload `peers.json` and `chain.cache`
+    - Navigate to the AWS S3 Service. Go to the Public Bucket and upload `peers.json` and `chain.db`
     - Go to the Private Bucket and upload your `wallet.json`.
 5. Deploy `compute.yaml`
     - Click 'Create Stack' -> 'with new resources' -> 'Upload a template file' and select `compute.yaml` from your computer
diff --git a/extensions/aws-cloud-mining/compute.yaml b/extensions/aws-cloud-mining/compute.yaml
index 5c673fd..2f4dca4 100644
--- a/extensions/aws-cloud-mining/compute.yaml
+++ b/extensions/aws-cloud-mining/compute.yaml
@@ -1,5 +1,5 @@
 AWSTemplateFormatVersion: '2010-09-09'
-Description: 
+Description:
   This template creates AWS EC2 instances for mining skepticoin.
   The skepticoin miner is automatically installed and started on each EC2 instance.
   Requires storage.yaml be deployed and wallet, peers, and chain files uploaded to S3 first.
@@ -181,9 +181,9 @@ Resources:
                 #!/bin/bash -xe
                 cd /home/ec2-user
                 mkdir -p /var/log/skepticoin
-                LOG=/var/log/skepticoin/skepticoin-mine.log                
+                LOG=/var/log/skepticoin/skepticoin-mine.log
                 echo "InstanceType=${InstanceType}" >> $LOG
-                
+
                 # install python infrastructure
                 amazon-linux-extras install python3.8
                 /usr/bin/python3.8 -m venv ./venv
@@ -202,14 +202,11 @@ Resources:
                 cd /tmp/skepticoin
                 aws s3 cp s3://$PRIVATE_BUCKET/wallet.json .
                 aws s3 cp s3://$PUBLIC_BUCKET/peers.json .
-                aws s3 cp s3://$PUBLIC_BUCKET/chain.cache .
-
-                # supress automatic download of uncached chain
-                mkdir chain
+                aws s3 cp s3://$PUBLIC_BUCKET/chain.db .
 
-                # perform a balance operation first, to update the chain.cache and peers.json
+                # perform a balance operation first, to update the chain.db and peers.json
                 PYTHONUNBUFFERED=1 skepticoin-balance >> $LOG 2>&1
-                aws s3 sync . s3://$PUBLIC_BUCKET/ --exclude "*" --include chain.cache
+                aws s3 sync . s3://$PUBLIC_BUCKET/ --exclude "*" --include chain.db
 
                 # start mining
                 PYTHONUNBUFFERED=1 nohup skepticoin-mine ${SkepticoinMiningParams} >> $LOG 2>&1 &
@@ -242,7 +239,7 @@ Resources:
 
     Properties:
       AssociatePublicIpAddress: true
-      ImageId: !Ref LatestAmiId      
+      ImageId: !Ref LatestAmiId
       InstanceType:
         Ref: InstanceType
       KeyName:
@@ -267,7 +264,7 @@ Resources:
         Count:
           Ref: AsgMaxSize
     Properties:
-      VPCZoneIdentifier: 
+      VPCZoneIdentifier:
       - !ImportValue "skepticoin-subnet-1"
       - !ImportValue "skepticoin-subnet-2"
       - !ImportValue "skepticoin-subnet-3"
diff --git a/performance/profile_disk_chain.py b/performance/profile_disk_chain.py
index 68c93a9..6106960 100644
--- a/performance/profile_disk_chain.py
+++ b/performance/profile_disk_chain.py
@@ -1,42 +1,53 @@
 import cProfile
-import pickle
-
-
-# Profiling results: before optimizations, Block Height 73000
-#   first run: 308 seconds
-#   - 86% of time is spent in io.open()
-#   - 10% in add_block_no_validation()
-#   - <5% in serialize()
-#   second run (disk cache in effect?): 42 seconds
-#   - 65% of time is spent in add_block_no_validation()
-#   - 25% of time is in serialize()
-# After optimization:
-#   first run: 14 seconds
-#   - this is fast enough for now, didn't analyse details.
-#   - later increased to 17 seconds after removing some of the hash() caching
-
-from skepticoin.scripts.utils import (
-    check_chain_dir,
-    read_chain_from_disk
-)
-from skepticoin.coinstate import CoinState
+from datetime import datetime
+
+from skepticoin.scripts.utils import read_chain_from_disk
+
+# Run with: python -m pytest performance/profile_disk_chain.py -s
+
+# Sample output (YMMV):
+#
+# performance/profile_disk_chain.py Reading chain.db
+#          28908648 function calls (28908646 primitive calls) in 90.558 seconds
+
+#    Ordered by: cumulative time
+
+#    ncalls  tottime  percall  cumtime  percall filename:lineno(function)
+#         1    0.000    0.000   90.558   90.558 cProfile.py:106(runcall)
+#         1    1.298    1.298   90.558   90.558 utils.py:44(read_chain_from_disk)
+#    317234   14.483    0.000   59.835    0.000 coinstate.py:88(add_block_no_validation)
+#   1268932   29.742    0.000   29.742    0.000 {method 'set' of 'immutables._map.Map' objects}
+#    317234    4.632    0.000   29.424    0.000 blockstore.py:182(read_blocks_from_disk)
+#    671729    1.438    0.000   12.018    0.000 serialization.py:26(deserialize)
+#         1    2.234    2.234   10.947   10.947 blockstore.py:167(load_inputs)
+#         1    1.309    1.309    6.258    6.258 blockstore.py:177(load_outputs)
+#    353747    0.612    0.000    6.228    0.000 signing.py:80(stream_deserialize)
+#    317234    0.581    0.000    6.009    0.000 balances.py:33(uto_apply_block)
+#    317646    3.303    0.000    5.429    0.000 balances.py:12(uto_apply_transaction)
+#   1977945    2.945    0.000    4.768    0.000 serialization.py:40(safe_read)
+#    317234    1.239    0.000    4.546    0.000 signing.py:146(stream_deserialize)
+#    317233    1.888    0.000    4.417    0.000 blockstore.py:219(<listcomp>)
+#    317983    0.540    0.000    4.010    0.000 signing.py:28(stream_deserialize)
+#    634880    3.714    0.000    3.714    0.000 {method 'mutate' of 'immutables._map.Map' objects}
+#   2220633    2.529    0.000    3.627    0.000 datatypes.py:328(__getattr__)
+#    317983    0.522    0.000    2.689    0.000 signing.py:56(stream_deserialize)
+#    317235    1.227    0.000    2.087    0.000 coinstate.py:16(__init__)
+#    635290    1.140    0.000    1.517    0.000 {built-in method builtins.sorted}
+#   3321406    1.514    0.000    1.514    0.000 {built-in method builtins.len}
+#         1    0.000    0.000    1.448    1.448 blockstore.py:160(load_transaction_builders)
+#         1    0.771    0.771    1.442    1.442 blockstore.py:161(<dictcomp>)
+#    671730    1.102    0.000    1.437    0.000 datatypes.py:23(__init__)
+#    317983    1.252    0.000    1.393    0.000 signing.py:41(__init__)
+#   2220633    1.098    0.000    1.098    0.000 {built-in method builtins.getattr}
 
 
 def test_read_chain_from_disk():
 
-    check_chain_dir()
-
     with cProfile.Profile() as pr:
+        started = datetime.now()
         coinstate = pr.runcall(read_chain_from_disk)
-        pr.print_stats()
-
-    with open('test.tmp', 'wb') as file:
-        coinstate.dump(lambda data: pickle.dump(data, file))
-
-
-def test_faster_read():
-
-    with open('test.tmp', 'rb') as file:
-        with cProfile.Profile() as pr:
-            pr.runcall(lambda: CoinState.load(lambda: pickle.load(file)))
-            pr.print_stats()
+        pr.print_stats(sort='cumulative')
+        n = len(coinstate.block_by_hash.keys())
+        ended = datetime.now()
+        bpm = n / ((ended - started).seconds / 60)
+        print(f"read {n} blocks in {ended - started}: {bpm} blocks per minute")
diff --git a/performance/profile_fresh_chain.py b/performance/profile_fresh_chain.py
index f79c5b8..3e96cf5 100644
--- a/performance/profile_fresh_chain.py
+++ b/performance/profile_fresh_chain.py
@@ -5,7 +5,6 @@
 from skepticoin.scripts.utils import (
     configure_logging_from_args,
     DefaultArgumentParser,
-    check_chain_dir,
     read_chain_from_disk,
 )
 import cProfile
@@ -21,7 +20,6 @@ def test_main():
     # Initially were using an empty chain for this test.
     # And then we found that the performance of newer blocks is different!
     # coinstate = CoinState.zero()
-    check_chain_dir()
     coinstate = read_chain_from_disk()
 
     # we need to run in the current thread to profile it
@@ -30,13 +28,19 @@ def test_main():
     def runner():
         started = datetime.now()
         nt.local_peer.running = True
-        print("start height = %d" % nt.local_peer.chain_manager.coinstate.head().height)
+        start_height = nt.local_peer.chain_manager.coinstate.head().height
+        print("start height = %d" % start_height)
         while (datetime.now() - started).seconds <= 100:
             current_time = int(time())
             nt.local_peer.step_managers(current_time)
             nt.local_peer.handle_selector_events()
-        print("final height = %d" % nt.local_peer.chain_manager.coinstate.head().height)
+        end_height = nt.local_peer.chain_manager.coinstate.head().height
+        print("final height = %d" % end_height)
+        elapsed = datetime.now() - started
+        return (start_height, end_height, elapsed)
 
     with cProfile.Profile() as pr:
-        pr.runcall(runner)
-        pr.print_stats()
+        (start_height, end_height, elapsed) = pr.runcall(runner)
+        tps = (end_height-start_height) // (elapsed.seconds/60)
+        pr.print_stats(sort='cumulative')
+        print(f"start={start_height} end={end_height} elapsed={elapsed} throughput={tps} bpm")
diff --git a/performance/profile_get_balance.py b/performance/profile_get_balance.py
new file mode 100644
index 0000000..4384b16
--- /dev/null
+++ b/performance/profile_get_balance.py
@@ -0,0 +1,22 @@
+import cProfile
+from datetime import datetime
+from skepticoin.params import SASHIMI_PER_COIN
+
+from skepticoin.scripts.utils import open_or_init_wallet, read_chain_from_disk
+
+# Run with: python -m pytest performance/profile_get_balance.py -s
+
+
+def steps():
+    coinstate = read_chain_from_disk()
+    wallet = open_or_init_wallet()
+    print(
+        wallet.get_balance(coinstate) / SASHIMI_PER_COIN, "SKEPTI at h. %s," % coinstate.head().height,
+        datetime.fromtimestamp(coinstate.head().timestamp).isoformat())
+
+
+def test_get_balance():
+
+    with cProfile.Profile() as pr:
+        pr.runcall(steps)
+        pr.print_stats(sort='cumulative')
diff --git a/skepticoin/balances.py b/skepticoin/balances.py
new file mode 100644
index 0000000..96a52e1
--- /dev/null
+++ b/skepticoin/balances.py
@@ -0,0 +1,132 @@
+from collections import namedtuple
+from typing import Dict, Iterator, List, Optional
+import immutables
+
+from skepticoin.datatypes import Block, Output, OutputReference, Transaction
+from skepticoin.signing import PublicKey
+
+
+PKBalance = namedtuple('PKBalance', ['value', 'output_references'])
+
+
+def uto_apply_transaction(
+    unspent_transaction_outs: immutables.Map[OutputReference, Output],
+    transaction: Transaction,
+    is_coinbase: bool,
+) -> immutables.Map[OutputReference, Output]:
+    with unspent_transaction_outs.mutate() as mutable_unspent_transaction_outs:
+
+        # for coinbase we must skip the input-removal because the input references "thin air" rather than an output.
+        if not is_coinbase:
+            for input in transaction.inputs:
+                # we don't explicitly check that the transaction is spendable; inadvertant violation of that expectation
+                # will lead to an application-crash (which is preferable over the alternative: double-spending).
+                del mutable_unspent_transaction_outs[input.output_reference]
+
+        for i, output in enumerate(transaction.outputs):
+            output_reference = OutputReference(transaction.hash(), i)
+            mutable_unspent_transaction_outs[output_reference] = output
+
+        return mutable_unspent_transaction_outs.finish()
+
+
+def uto_apply_block(
+    unspent_transaction_outs: immutables.Map[OutputReference, Output], block: Block
+) -> immutables.Map[OutputReference, Output]:
+    unspent_transaction_outs = uto_apply_transaction(unspent_transaction_outs, block.transactions[0], is_coinbase=True)
+    for transaction in block.transactions[1:]:
+        unspent_transaction_outs = uto_apply_transaction(unspent_transaction_outs, transaction, is_coinbase=False)
+    return unspent_transaction_outs
+
+
+def pkb_apply_transaction(
+    unspent_transaction_outs: immutables.Map[OutputReference, Output],
+    public_key_balances: immutables.Map[PublicKey, PKBalance],
+    transaction: Transaction,
+    is_coinbase: bool,
+) -> immutables.Map[PublicKey, PKBalance]:
+    with public_key_balances.mutate() as mutable_public_key_balances:
+        # for coinbase we must skip the input-removal because the input references "thin air" rather than an output.
+        if not is_coinbase:
+            for input in transaction.inputs:
+                previously_unspent_output = unspent_transaction_outs[input.output_reference]
+
+                public_key: PublicKey = previously_unspent_output.public_key
+                mutable_public_key_balances[public_key] = PKBalance(
+                    mutable_public_key_balances[public_key].value - previously_unspent_output.value,
+                    [to for to in mutable_public_key_balances[public_key].output_references
+                     if to != input.output_reference]
+                )
+
+        for i, output in enumerate(transaction.outputs):
+            output_reference = OutputReference(transaction.hash(), i)
+
+            if output.public_key not in mutable_public_key_balances:
+                mutable_public_key_balances[output.public_key] = PKBalance(0, [])
+
+            mutable_public_key_balances[output.public_key] = PKBalance(
+                mutable_public_key_balances[output.public_key].value + output.value,
+                mutable_public_key_balances[output.public_key].output_references + [output_reference],
+            )
+
+        return mutable_public_key_balances.finish()
+
+
+def pkb_apply_block(
+    unspent_transaction_outs: immutables.Map[OutputReference, Output],
+    public_key_balances: immutables.Map[PublicKey, PKBalance],
+    block: Block,
+) -> immutables.Map[PublicKey, PKBalance]:
+    # unspent_transaction_outs is used as a "reference" only (for looking up outputs); note that we never have to update
+    # that reference inside this function, because intra-block spending is invalid per the consensus.
+
+    public_key_balances = pkb_apply_transaction(
+        unspent_transaction_outs, public_key_balances, block.transactions[0], is_coinbase=True)
+
+    for transaction in block.transactions[1:]:
+        public_key_balances = pkb_apply_transaction(unspent_transaction_outs, public_key_balances, transaction, False)
+
+    return public_key_balances
+
+
+class PublicKeyBalances():
+
+    def __init__(
+        self,
+        block_by_hash: immutables.Map[bytes, Block],
+    ) -> None:
+
+        self.block_by_hash = block_by_hash
+        self.cache: Dict[bytes, immutables.Map[PublicKey, PKBalance]] = {}
+
+    def chain_at_hash(self, hash: bytes) -> Iterator[Block]:
+        block = self.block_by_hash[hash]
+        reverse_chain: List[Block] = [block]
+        while block.previous_block_hash != b'\x00' * 32:
+            previous_hash = block.previous_block_hash
+            block = self.block_by_hash[previous_hash]
+            reverse_chain.append(block)
+        return reversed(reverse_chain)
+
+    def public_key_balances_by_hash(self, head: Optional[bytes]) -> immutables.Map[PublicKey, PKBalance]:
+
+        if head is None:
+            return immutables.Map()
+
+        public_key_balances: immutables.Map[PublicKey, PKBalance] = immutables.Map()
+        unspent_transaction_outs: immutables.Map[OutputReference, Output] = immutables.Map()
+
+        for block in self.chain_at_hash(head):
+
+            public_key_balances = pkb_apply_block(unspent_transaction_outs,
+                                                  public_key_balances,
+                                                  block)
+
+            unspent_transaction_outs = uto_apply_block(unspent_transaction_outs, block)
+
+        return public_key_balances
+
+    def __getitem__(self, key: bytes) -> immutables.Map[PublicKey, PKBalance]:
+        if key not in self.cache:
+            self.cache[key] = self.public_key_balances_by_hash(key)
+        return self.cache[key]
diff --git a/skepticoin/blockstore.py b/skepticoin/blockstore.py
new file mode 100644
index 0000000..9114195
--- /dev/null
+++ b/skepticoin/blockstore.py
@@ -0,0 +1,234 @@
+from itertools import groupby
+import os
+import sqlite3
+import threading
+from typing import Dict, Iterator, List, Optional
+
+from skepticoin.datatypes import Block, BlockHeader, BlockSummary, Input, Output, OutputReference, PowEvidence, Transaction  # noqa: E501
+from skepticoin.hash import sha256d
+from skepticoin.signing import PublicKey, Signature
+from .genesis import genesis_block_data
+
+
+def nullify_zeros(value: bytes) -> Optional[bytes]:
+    return value if value != b'\00' * 32 else None
+
+
+def zeroify_nulls(value: Optional[bytes]) -> bytes:
+    return value if value else b'\00' * 32
+
+
+class TransactionBuilder:
+
+    def __init__(self, block_hash: bytes) -> None:
+        self.block_hash = block_hash
+        self.inputs: Dict[int, Input] = {}
+        self.outputs: Dict[int, Output] = {}
+
+
+class BlockStore:
+
+    def __init__(self, path: str) -> None:
+
+        self.lock = threading.Lock()
+
+        is_memory: bool = path == ":memory:"
+        self.is_new: bool = is_memory or not os.path.isfile(path)
+
+        self.connection = sqlite3.connect(path, check_same_thread=False)
+        self.sql = self.connection.execute
+
+        self.sql("PRAGMA foreign_keys = ON")
+        self.sql("PRAGMA journal_mode = MEMORY")
+        self.sql("PRAGMA synchronous = OFF")
+        self.sql("PRAGMA page_size = 65536")
+        self.sql("PRAGMA cache_size = 10000")
+
+        if self.is_new and not is_memory:
+            print("Creating new block database: " + path)
+        elif not is_memory:
+            print("Reading blocks from " + path)
+
+        if self.is_new:
+
+            self.sql('''CREATE TABLE chain (
+                block_hash blob primary key,
+                version int,
+                height int,
+                previous_block_hash blob REFERENCES chain(block_hash),
+                merkle_root_hash blob,
+                timestamp int,
+                target blob,
+                nonce int,
+                pow_summary_hash blob,
+                pow_chain_sample blob,
+                pow_block_hash blob
+            )''')
+            self.sql('''CREATE TABLE transaction_locator (
+                transaction_hash blob primary key,
+                block_hash blob REFERENCES chain(block_hash)
+            )''')
+            self.sql('''CREATE TABLE transaction_inputs (
+                transaction_hash blob REFERENCES transaction_locator(transaction_hash),
+                seq int,
+                output_reference_hash blob,
+                output_reference_index int,
+                signature blob null,
+                PRIMARY KEY(transaction_hash, seq),
+                FOREIGN KEY(output_reference_hash, output_reference_index)
+                REFERENCES transaction_outputs(transaction_hash, seq)
+            )''')
+            self.sql('''CREATE TABLE transaction_outputs (
+                transaction_hash blob REFERENCES transaction_locator(transaction_hash),
+                seq int,
+                value int,
+                public_key blob,
+                PRIMARY KEY(transaction_hash, seq)
+            )''')
+            self.sql('CREATE INDEX chainlink_index ON chain(height, block_hash, previous_block_hash)')
+            self.sql('CREATE INDEX previous_block_hash ON chain(previous_block_hash)')
+            self.sql('CREATE INDEX tr_locator_block_hash ON transaction_locator(block_hash)')
+
+            self.write_blocks_to_disk([Block.deserialize(genesis_block_data)])
+
+        self.path = path
+        self.write_buffer: List[Block] = []
+
+    def add_block_to_buffer(self, block: Block) -> None:
+        with self.lock:
+            self.write_buffer.append(block)
+
+    def close(self) -> None:
+        self.connection.close()
+        self.connection = None  # type: ignore
+
+    def write_blocks_to_disk(self, blocks: List[Block]) -> None:
+
+        blocks_param = []
+        transactions_param = []
+        transaction_inputs_param = []
+        transaction_outputs_param = []
+
+        for block in blocks:
+            block_hash = block.hash()
+            for transaction in block.transactions:
+                transaction_bytes = transaction.serialize()
+                transaction_hash = sha256d(transaction_bytes)
+                transactions_param.append((
+                    transaction_hash,
+                    block_hash
+                ))
+                for seq, input in enumerate(transaction.inputs):
+                    transaction_inputs_param.append((
+                        transaction_hash,
+                        seq,
+                        nullify_zeros(input.output_reference.hash),
+                        input.output_reference.index,
+                        input.signature.serialize() if input.signature else None
+                    ))
+                for seq, output in enumerate(transaction.outputs):
+                    transaction_outputs_param.append((
+                        transaction_hash,
+                        seq,
+                        output.value,
+                        output.public_key.serialize()
+                    ))
+            blocks_param.append((
+                block_hash,
+                block.header.version,
+                block.header.summary.height,
+                nullify_zeros(block.header.summary.previous_block_hash),
+                block.header.summary.merkle_root_hash,
+                block.header.summary.timestamp,
+                block.header.summary.target,
+                block.header.summary.nonce,
+                block.header.pow_evidence.summary_hash,
+                block.header.pow_evidence.chain_sample,
+                block.header.pow_evidence.block_hash
+            ))
+
+        cur = self.connection.cursor()
+        cur.execute('BEGIN TRANSACTION')
+        cur.executemany("insert or ignore into chain values (?,?,?,?,?,?,?,?,?,?,?)", blocks_param)
+        cur.executemany("insert or ignore into transaction_locator values (?,?)", transactions_param)
+        cur.executemany("insert or ignore into transaction_outputs values (?,?,?,?)", transaction_outputs_param)
+        cur.executemany("insert or ignore into transaction_inputs values (?,?,?,?,?)", transaction_inputs_param)
+        cur.execute('COMMIT')
+        cur.close()
+
+    def flush_blocks_to_disk(self) -> None:
+        with self.lock:
+            if(len(self.write_buffer)):
+                self.write_blocks_to_disk(self.write_buffer)
+                self.write_buffer.clear()
+
+    def load_transaction_builders(self) -> Dict[bytes, TransactionBuilder]:
+        return {
+            row[0]: TransactionBuilder(row[1]) for row in self.sql(
+                "select transaction_hash, block_hash from transaction_locator"
+            )
+        }
+
+    def load_inputs(self, transaction_builders: Dict[bytes, TransactionBuilder]) -> None:
+        for row in self.sql("""
+                    select output_reference_hash, output_reference_index, signature, transaction_hash, seq
+                    from transaction_inputs"""):
+            (output_reference_hash, output_reference_index, signature, transaction_hash, seq) = row
+            transaction_builders[transaction_hash].inputs[seq] = Input(
+                    OutputReference(zeroify_nulls(output_reference_hash), output_reference_index),
+                    Signature.deserialize(signature) if signature else None
+            )
+
+    def load_outputs(self, transaction_builders: Dict[bytes, TransactionBuilder]) -> None:
+        for row in self.sql("select value, public_key, transaction_hash, seq from transaction_outputs"):
+            (value, public_key, transaction_hash, seq) = row
+            transaction_builders[transaction_hash].outputs[seq] = Output(value, PublicKey.deserialize(public_key))
+
+    def read_blocks_from_disk(self) -> Iterator[Block]:
+
+        transaction_builders = self.load_transaction_builders()
+
+        self.load_inputs(transaction_builders)
+
+        self.load_outputs(transaction_builders)
+
+        block_builders = {
+            block_hash: list(builder_tuples)
+            for block_hash, builder_tuples
+            in groupby(transaction_builders.items(), lambda item: item[1].block_hash)
+        }
+
+        for row in self.sql(
+                """select height, previous_block_hash, merkle_root_hash, timestamp, target, nonce,
+                   pow_summary_hash, pow_chain_sample, pow_block_hash, block_hash
+                   from chain order by height"""):
+            (height, previous_block_hash, merkle_root_hash, timestamp, target, nonce,
+             pow_summary_hash, pow_chain_sample, pow_block_hash, block_hash) = row
+
+            yield Block(
+                    BlockHeader(
+                        BlockSummary(
+                            height=height,
+                            previous_block_hash=zeroify_nulls(previous_block_hash),
+                            merkle_root_hash=merkle_root_hash,
+                            timestamp=timestamp,
+                            target=target,
+                            nonce=nonce
+                        ),
+                        PowEvidence(
+                            summary_hash=pow_summary_hash,
+                            chain_sample=pow_chain_sample,
+                            block_hash=pow_block_hash
+                        )
+                    ),
+                    [Transaction(
+                        [v for k, v in sorted(builder.inputs.items(), key=lambda i: i[0])],
+                        [v for k, v in sorted(builder.outputs.items(), key=lambda i: i[0])],
+                        transaction_hash
+                    ) for transaction_hash, builder in block_builders[block_hash]],
+                    hash=block_hash
+            )
+
+
+class DefaultBlockStore:
+    instance = BlockStore('chain.db')
diff --git a/skepticoin/coinstate.py b/skepticoin/coinstate.py
index c40baeb..65d3c05 100644
--- a/skepticoin/coinstate.py
+++ b/skepticoin/coinstate.py
@@ -1,99 +1,17 @@
 from __future__ import annotations
 
-from collections import namedtuple
 from typing import Any, List, Optional, Tuple, Callable
 
 import immutables
 
+from skepticoin.balances import PKBalance, PublicKeyBalances, uto_apply_block
+
 from .signing import PublicKey
-from .datatypes import OutputReference, Block, Output, BlockSummary, Transaction
+from .datatypes import OutputReference, Block, Output, BlockSummary
 from .humans import human
 from .genesis import genesis_block_data
 
 
-PKBalance = namedtuple('PKBalance', ['value', 'output_references'])
-
-
-def uto_apply_transaction(
-    unspent_transaction_outs: immutables.Map[OutputReference, Output],
-    transaction: Transaction,
-    is_coinbase: bool,
-) -> immutables.Map[OutputReference, Output]:
-    with unspent_transaction_outs.mutate() as mutable_unspent_transaction_outs:
-
-        # for coinbase we must skip the input-removal because the input references "thin air" rather than an output.
-        if not is_coinbase:
-            for input in transaction.inputs:
-                # we don't explicitly check that the transaction is spendable; inadvertant violation of that expectation
-                # will lead to an application-crash (which is preferable over the alternative: double-spending).
-                del mutable_unspent_transaction_outs[input.output_reference]
-
-        for i, output in enumerate(transaction.outputs):
-            output_reference = OutputReference(transaction.hash(), i)
-            mutable_unspent_transaction_outs[output_reference] = output
-
-        return mutable_unspent_transaction_outs.finish()
-
-
-def uto_apply_block(
-    unspent_transaction_outs: immutables.Map[OutputReference, Output], block: Block
-) -> immutables.Map[OutputReference, Output]:
-    unspent_transaction_outs = uto_apply_transaction(unspent_transaction_outs, block.transactions[0], is_coinbase=True)
-    for transaction in block.transactions[1:]:
-        unspent_transaction_outs = uto_apply_transaction(unspent_transaction_outs, transaction, is_coinbase=False)
-    return unspent_transaction_outs
-
-
-def pkb_apply_transaction(
-    unspent_transaction_outs: immutables.Map[OutputReference, Output],
-    public_key_balances: immutables.Map[PublicKey, PKBalance],
-    transaction: Transaction,
-    is_coinbase: bool,
-) -> immutables.Map[PublicKey, PKBalance]:
-    with public_key_balances.mutate() as mutable_public_key_balances:
-        # for coinbase we must skip the input-removal because the input references "thin air" rather than an output.
-        if not is_coinbase:
-            for input in transaction.inputs:
-                previously_unspent_output = unspent_transaction_outs[input.output_reference]
-
-                public_key: PublicKey = previously_unspent_output.public_key
-                mutable_public_key_balances[public_key] = PKBalance(
-                    mutable_public_key_balances[public_key].value - previously_unspent_output.value,
-                    [to for to in mutable_public_key_balances[public_key].output_references
-                     if to != input.output_reference]
-                )
-
-        for i, output in enumerate(transaction.outputs):
-            output_reference = OutputReference(transaction.hash(), i)
-
-            if output.public_key not in mutable_public_key_balances:
-                mutable_public_key_balances[output.public_key] = PKBalance(0, [])
-
-            mutable_public_key_balances[output.public_key] = PKBalance(
-                mutable_public_key_balances[output.public_key].value + output.value,
-                mutable_public_key_balances[output.public_key].output_references + [output_reference],
-            )
-
-        return mutable_public_key_balances.finish()
-
-
-def pkb_apply_block(
-    unspent_transaction_outs: immutables.Map[OutputReference, Output],
-    public_key_balances: immutables.Map[PublicKey, PKBalance],
-    block: Block,
-) -> immutables.Map[PublicKey, PKBalance]:
-    # unspent_transaction_outs is used as a "reference" only (for looking up outputs); note that we never have to update
-    # that reference inside this function, because intra-block spending is invalid per the consensus.
-
-    public_key_balances = pkb_apply_transaction(
-        unspent_transaction_outs, public_key_balances, block.transactions[0], is_coinbase=True)
-
-    for transaction in block.transactions[1:]:
-        public_key_balances = pkb_apply_transaction(unspent_transaction_outs, public_key_balances, transaction, False)
-
-    return public_key_balances
-
-
 class CoinState:
     def __init__(
         self,
@@ -104,9 +22,6 @@ def __init__(
         block_by_height_by_hash: immutables.Map[bytes, immutables.Map[int, Block]],
         heads: immutables.Map[bytes, Block],
         current_chain_hash: Optional[bytes],
-        public_key_balances_by_hash: immutables.Map[
-            bytes, immutables.Map[PublicKey, PKBalance]
-        ],
     ):
 
         self.block_by_hash = block_by_hash
@@ -121,7 +36,7 @@ def __init__(
         self.current_chain_hash = current_chain_hash
 
         # block_hash -> (public_key -> (value, [OutputReference]))
-        self.public_key_balances_by_hash = public_key_balances_by_hash
+        self.public_key_balances_by_hash = PublicKeyBalances(self.block_by_hash)
 
     def dump(self, dumper: Callable[[Any], None]) -> None:
         """
@@ -131,17 +46,6 @@ def dump(self, dumper: Callable[[Any], None]) -> None:
         """
         dumper(self.block_by_hash)
 
-    @classmethod
-    def load(self, loader: Callable[[], Any]) -> CoinState:
-        "Faster way to load a previously dump()'d blockchain."
-        block_by_hash = loader()
-        coinstate = CoinState.zero()
-        sorted_blocks = list(block_by_hash.values())
-        sorted_blocks.sort(key=lambda block: block.height)  # type: ignore
-        for block in sorted_blocks:
-            coinstate = coinstate.add_block_no_validation(block)
-        return coinstate
-
     def __repr__(self) -> str:
         if self.current_chain_hash is None:
             return "CoinState @ empty"
@@ -156,7 +60,6 @@ def empty(cls) -> CoinState:
             block_by_height_by_hash=immutables.Map(),
             heads=immutables.Map(),
             current_chain_hash=None,
-            public_key_balances_by_hash=immutables.Map(),
         )
 
     @classmethod
@@ -174,13 +77,9 @@ def add_block(self, block: Block, current_timestamp: int) -> CoinState:
     def add_block_no_validation(self, block: Block) -> CoinState:
         if block.previous_block_hash == b'\00' * 32:
             unspent_transaction_outs: immutables.Map[OutputReference, Output] = immutables.Map()
-            public_key_balances: immutables.Map[PublicKey, PKBalance] = immutables.Map()
         else:
             unspent_transaction_outs = self.unspent_transaction_outs_by_hash[block.previous_block_hash]
-            public_key_balances = self.public_key_balances_by_hash[block.previous_block_hash]
 
-        public_key_balances = pkb_apply_block(unspent_transaction_outs, public_key_balances, block)
-        # NOTE: ordering matters b/c we assign to unspent_transaction_outs; perhaps just distinguish?
         unspent_transaction_outs = uto_apply_block(unspent_transaction_outs, block)
 
         block_hash = block.hash()
@@ -193,9 +92,6 @@ def add_block_no_validation(self, block: Block) -> CoinState:
         unspent_transaction_outs_by_hash = self.unspent_transaction_outs_by_hash.set(
             block_hash, unspent_transaction_outs)
 
-        public_key_balances_by_hash = self.public_key_balances_by_hash.set(
-            block_hash, public_key_balances)
-
         if block.previous_block_hash == b'\00' * 32:
             block_by_height_by_hash: immutables.Map[bytes, immutables.Map[int, Block]]
             block_by_height_by_hash = immutables.Map({block_hash: immutables.Map({0: block})})
@@ -230,7 +126,6 @@ def add_block_no_validation(self, block: Block) -> CoinState:
             block_by_height_by_hash=block_by_height_by_hash,
             heads=heads,
             current_chain_hash=current_chain_hash,
-            public_key_balances_by_hash=public_key_balances_by_hash,
         )
 
     def head(self) -> BlockSummary:
diff --git a/skepticoin/datatypes.py b/skepticoin/datatypes.py
index 67c3aea..86acac3 100644
--- a/skepticoin/datatypes.py
+++ b/skepticoin/datatypes.py
@@ -126,10 +126,11 @@ def stream_serialize(self, f: BinaryIO) -> None:
 
 class Transaction(Serializable):
 
-    def __init__(self, inputs: List[Input], outputs: List[Output]):
+    def __init__(self, inputs: List[Input], outputs: List[Output], cached_hash: Optional[bytes] = None):
         self.version = 0  # reserved for future use; the class does not take this as a param.
         self.inputs = inputs
         self.outputs = outputs
+        self.cached_hash = cached_hash
 
     def __repr__(self) -> str:
         return "Transaction #%s" % human(self.hash())
@@ -142,12 +143,22 @@ def __eq__(self, other: object) -> bool:
 
     @classmethod
     def stream_deserialize(cls, f: BinaryIO) -> Transaction:
+
+        start_position = f.tell()
+
         if safe_read(f, 1) != b'\x00':
             raise ValueError("Current version supports only version 0 transactions")
 
         inputs = stream_deserialize_list(f, Input)
         outputs = stream_deserialize_list(f, Output)
-        return cls(inputs, outputs)
+
+        # speed optimization: self.serialize() is very expensive, so when loading from disk,
+        # calculate the hash directly from the original bytes before discarding them.
+        end_position = f.tell()
+        f.seek(start_position)
+        cached_hash = sha256d(f.read(end_position - start_position))
+
+        return cls(inputs, outputs, cached_hash)
 
     def stream_serialize(self, f: BinaryIO) -> None:
         f.write(struct.pack(b"B", self.version))
@@ -155,7 +166,7 @@ def stream_serialize(self, f: BinaryIO) -> None:
         stream_serialize_list(f, self.outputs)
 
     def hash(self) -> bytes:
-        return sha256d(self.serialize())
+        return self.cached_hash or sha256d(self.serialize())
 
     def __hash__(self) -> int:
         return hash(self.hash())
@@ -304,15 +315,19 @@ def hash(self) -> bytes:
 
 
 class Block(Serializable):
-    def __init__(self, header: BlockHeader, transactions: List[Transaction]):
+    def __init__(self, header: BlockHeader, transactions: List[Transaction], hash: Optional[bytes] = None):
         self.header = header
         self.transactions = transactions
+        self.cached_hash = hash
+
+    def hash(self) -> bytes:
+        return self.cached_hash or self.header.hash()
 
     def __getattr__(self, attr: str) -> Any:
         """convenience: merge header and summary's attributes into the Block's accessors"""
         # TODO for improved mypy type-checking, this needs to be split up
 
-        if attr in ['version', 'summary', 'pow_evidence', 'hash']:
+        if attr in ['version', 'summary', 'pow_evidence']:
             return getattr(self.header, attr)
 
         if attr in ['height', 'previous_block_hash', 'merkle_root_hash', 'timestamp', 'target', 'nonce']:
@@ -333,9 +348,13 @@ def __eq__(self, other: Any) -> bool:
 
     @classmethod
     def stream_deserialize(cls, f: BinaryIO) -> Block:
+        start_position = f.tell()
         header = BlockHeader.stream_deserialize(f)
+        end_position = f.tell()
+        f.seek(start_position)
+        hash = sha256d(f.read(end_position - start_position))
         transactions = stream_deserialize_list(f, Transaction)
-        return cls(header, transactions)
+        return cls(header, transactions, hash)
 
     def stream_serialize(self, f: BinaryIO) -> None:
         self.header.stream_serialize(f)
diff --git a/skepticoin/mining.py b/skepticoin/mining.py
index ede2e9c..cdf1e5f 100644
--- a/skepticoin/mining.py
+++ b/skepticoin/mining.py
@@ -98,6 +98,7 @@ def __init__(self) -> None:
         self.network_thread: NetworkingThread
         self.mining_args: Dict[int, Tuple[BlockSummary, int, List[Transaction]]] = {}
         self.public_key: bytes
+        self.log_silencer: List[Any] = []
 
     def __call__(self) -> None:
         configure_logging_from_args(self.args)
@@ -161,8 +162,18 @@ def __call__(self) -> None:
                 process.join()
 
     def print_stats_line(self, timestamp: int) -> None:
+
+        n_peers = len(self.network_thread.local_peer.network_manager.get_active_peers())
+        height = self.coinstate.head().height
+        forks = [True for (head, lca) in self.coinstate.forks()
+                 if head.height >= self.coinstate.head().height - 10 and head.height != lca.height]
+
         if self.args.quiet:
-            return
+            stat = [height, n_peers, forks]
+            if self.log_silencer == stat:
+                return
+            else:
+                self.log_silencer = stat
 
         now = datetime.fromtimestamp(timestamp)
         now_str = now.strftime("%Y-%m-%d %H:%M:%S")
@@ -175,11 +186,11 @@ def print_stats_line(self, timestamp: int) -> None:
 
         mine_speed = (float(mined) / uptime.total_seconds()) * 60 * 60
 
-        n_peers = len(self.network_thread.local_peer.network_manager.get_active_peers())
-
         print(f"{now_str} | uptime: {uptime_str} | {hashes:>3} hash/sec" +
               f" | mined: {mined:>3} SKEPTI | {mine_speed:5.2f} SKEPTI/h" +
-              f" | {n_peers:3d} peers")
+              f" | {n_peers:3d} peers | h. {height}" +
+              f" @ {timestamp - self.coinstate.head().timestamp}s ago" +
+              (f" | {len(forks)} forks" if forks else ""))
 
     def send_message(self, miner_id: int, message_type: str, data: Any) -> None:
         self.send_queues[miner_id].put((message_type, data))
@@ -246,9 +257,7 @@ def handle_scrypt_output_message(self, miner_id: int, data: bytes) -> None:
 
         self.coinstate = self.coinstate.add_block(block, int(time()))
 
-        # Originally there was a disk write in this spot. During testing of the chain.cache changes,
-        # it was found there is a race condition between the mining thread and the networking thread.
-        # Better to skip the write here and just let the networking thread do it.
+        self.network_thread.local_peer.disk_interface.save_block(block)
 
         print(f"miner {miner_id} found block: {block_filename(block)}")
 
diff --git a/skepticoin/networking/disk_interface.py b/skepticoin/networking/disk_interface.py
index 9d58ea8..139b6a2 100644
--- a/skepticoin/networking/disk_interface.py
+++ b/skepticoin/networking/disk_interface.py
@@ -1,15 +1,14 @@
 import os
+from skepticoin.blockstore import DefaultBlockStore
 from skepticoin.networking.params import MAX_CONNECTION_ATTEMPTS
 from typing import Dict, List, Set, Tuple
-from skepticoin.datatypes import Transaction
+from skepticoin.datatypes import Block, Transaction
 from skepticoin.networking.remote_peer import (
     ConnectedRemotePeer, DisconnectedRemotePeer, OUTGOING, load_peers_from_list
 )
 import json
 import urllib.request
-import pickle
 from skepticoin.humans import human
-from skepticoin.coinstate import CoinState
 
 
 PEER_URLS: List[str] = [
@@ -74,12 +73,11 @@ def write_peers(self, peers: Dict[Tuple[str, int, str], ConnectedRemotePeer]) ->
 
             self.last_saved_peers = db
 
-    def write_chain_cache_to_disk(self, coinstate: CoinState) -> None:
-        # Currently this takes about 2 seconds. It could be optimized further
-        # if we switch to an appendable file format for the cache.
-        with open('chain.cache.tmp', 'wb') as file:
-            coinstate.dump(lambda data: pickle.dump(data, file))
-        os.replace('chain.cache.tmp', 'chain.cache')
+    def save_block(self, block: Block) -> None:
+        DefaultBlockStore.instance.add_block_to_buffer(block)
+
+    def flush_blocks(self) -> None:
+        DefaultBlockStore.instance.flush_blocks_to_disk()
 
     def save_transaction_for_debugging(self, transaction: Transaction) -> None:
         with open("/tmp/%s.transaction" % human(transaction.hash()), 'wb') as f:
diff --git a/skepticoin/networking/params.py b/skepticoin/networking/params.py
index 95ef3e7..c2bb872 100644
--- a/skepticoin/networking/params.py
+++ b/skepticoin/networking/params.py
@@ -13,7 +13,7 @@
 
 MAX_IBD_PEERS = 1
 IBD_PEER_TIMEOUT = 60
-IBD_VALIDATION_SKIP = 1000
+IBD_VALIDATION_SKIP = 10000
 
 GET_BLOCKS_INVENTORY_SIZE = 500
 
diff --git a/skepticoin/networking/remote_peer.py b/skepticoin/networking/remote_peer.py
index ad1e125..9eb1328 100644
--- a/skepticoin/networking/remote_peer.py
+++ b/skepticoin/networking/remote_peer.py
@@ -5,6 +5,8 @@
 from ipaddress import IPv6Address
 from typing import Dict, TYPE_CHECKING, Tuple
 
+from skepticoin.blockstore import DefaultBlockStore
+
 if TYPE_CHECKING:
     from skepticoin.networking.local_peer import LocalPeer
 
@@ -466,6 +468,7 @@ def handle_block_received(
                 return
 
             validate_block_by_itself(block, int(time()))
+            self.local_peer.disk_interface.save_block(block)
             coinstate_changed = coinstate_prior.add_block_no_validation(block)
 
             if header.in_response_to == 0 or block.height % IBD_VALIDATION_SKIP == 0:
@@ -483,10 +486,11 @@ def handle_block_received(
                     if self.local_peer.chain_manager.last_known_valid_coinstate:
                         self.local_peer.chain_manager.set_coinstate(
                             self.local_peer.chain_manager.last_known_valid_coinstate)
+                    DefaultBlockStore.instance.write_buffer.clear()  # don't save bad blocks
                     return
 
                 self.local_peer.chain_manager.set_coinstate(coinstate_changed, validated=True)
-                self.local_peer.disk_interface.write_chain_cache_to_disk(coinstate_changed)
+                self.local_peer.disk_interface.flush_blocks()
             else:
                 self.local_peer.chain_manager.set_coinstate(coinstate_changed, validated=False)
 
diff --git a/skepticoin/scripts/balance.py b/skepticoin/scripts/balance.py
index a58a7c7..c90431e 100644
--- a/skepticoin/scripts/balance.py
+++ b/skepticoin/scripts/balance.py
@@ -1,5 +1,5 @@
 from datetime import datetime
-from skepticoin.networking.disk_interface import DiskInterface
+from skepticoin.blockstore import DefaultBlockStore
 
 from .utils import (
     open_or_init_wallet,
@@ -35,7 +35,7 @@ def main() -> None:
         wallet.get_balance(coinstate) / SASHIMI_PER_COIN, "SKEPTI at h. %s," % coinstate.head().height,
         datetime.fromtimestamp(coinstate.head().timestamp).isoformat())
 
-    DiskInterface().write_chain_cache_to_disk(coinstate)
+    DefaultBlockStore.instance.flush_blocks_to_disk()
 
     print("Waiting for networking thread to exit.")
     thread.stop()
diff --git a/skepticoin/scripts/utils.py b/skepticoin/scripts/utils.py
index 9529e56..5c53e9a 100644
--- a/skepticoin/scripts/utils.py
+++ b/skepticoin/scripts/utils.py
@@ -1,4 +1,4 @@
-from skepticoin.networking.disk_interface import DiskInterface
+from skepticoin.blockstore import DefaultBlockStore
 import sys
 from pathlib import Path
 from time import sleep, time
@@ -7,13 +7,11 @@
 import tempfile
 import logging
 import argparse
-import pickle
 
-from skepticoin.datatypes import Block
 from skepticoin.coinstate import CoinState
 from skepticoin.networking.threading import NetworkingThread
 from skepticoin.wallet import Wallet, save_wallet
-from skepticoin.humans import computer, human
+from skepticoin.humans import human
 
 
 class DefaultArgumentParser(argparse.ArgumentParser):
@@ -42,46 +40,21 @@ def wait_for_fresh_chain(thread: NetworkingThread) -> None:
 
 
 def read_chain_from_disk() -> CoinState:
-    if os.path.isfile('chain.cache'):
-        print("Reading cached chain")
-        with open('chain.cache', 'rb') as file:
-            coinstate = CoinState.load(lambda: pickle.load(file))
-    else:
-        coinstate = CoinState.zero()
 
-    rewrite = False
+    coinstate = CoinState.empty()
+    for block in DefaultBlockStore.instance.read_blocks_from_disk():
+        try:
+            coinstate = coinstate.add_block_no_validation(block)
+        except Exception as e:
+            raise Exception(e, f' @ block_hash={human(block.hash())}, height={block.height}')
+
+    # It is no longer possible to load old files, due to pickle issues not worth solving.
+
+    if os.path.isfile('chain.cache'):
+        print("Old chain.cache file found (ignored)")
 
     if os.path.isdir('chain'):
-        # the code below is no longer needed by normal users, but some old testcases still rely on it:
-        for filename in sorted(os.listdir('chain')):
-            (height_str, hash_str) = filename.split("-")
-            (height, hash) = (int(height_str), computer(hash_str))
-
-            if hash not in coinstate.block_by_hash:
-
-                if height % 1000 == 0:
-                    print(filename)
-
-                if os.path.getsize(f"chain/{filename}") == 0:
-                    print("Stopping at empty block file: %s" % filename)
-                    break
-
-                with open(Path("chain") / filename, 'rb') as f:
-                    try:
-                        block = Block.stream_deserialize(f)
-                    except Exception as e:
-                        raise Exception("Corrupted block on disk: %s" % filename) from e
-                    try:
-                        coinstate = coinstate.add_block_no_validation(block)
-                    except Exception:
-                        print("Failed to add block at height=%d, previous_hash=%s"
-                              % (block.height, human(block.header.summary.previous_block_hash)))
-                        break
-
-                rewrite = True
-
-    if rewrite:
-        DiskInterface().write_chain_cache_to_disk(coinstate)
+        print("Old chain folder found (ignored)")
 
     return coinstate
 


Test patch:
diff --git a/tests/test_coinstate.py b/tests/test_coinstate.py
index 43a5a01..5ca25b1 100644
--- a/tests/test_coinstate.py
+++ b/tests/test_coinstate.py
@@ -2,7 +2,7 @@
 
 from skepticoin.signing import CoinbaseData, SECP256k1PublicKey, SECP256k1Signature
 from skepticoin.datatypes import Transaction, OutputReference, Input, Output
-from skepticoin.coinstate import uto_apply_transaction, pkb_apply_transaction, PKBalance
+from skepticoin.balances import uto_apply_transaction, pkb_apply_transaction, PKBalance
 from skepticoin.consensus import construct_reference_to_thin_air
 
 
diff --git a/tests/test_db.py b/tests/test_db.py
new file mode 100644
index 0000000..b5c29cf
--- /dev/null
+++ b/tests/test_db.py
@@ -0,0 +1,40 @@
+from skepticoin.blockstore import BlockStore
+from skepticoin.coinstate import CoinState
+from skepticoin.humans import human
+import os
+
+from skepticoin.scripts.utils import open_or_init_wallet
+
+
+def test_db():
+    coinstate = CoinState.zero()
+
+    if os.path.exists('test.db'):
+        os.remove('test.db')
+
+    db = BlockStore(path='test.db')
+    for block in coinstate.block_by_hash.values():
+        db.add_block_to_buffer(block)
+
+    db.flush_blocks_to_disk()
+
+    cur = db.connection.cursor()
+    for row in cur.execute("select count(*) from chain"):
+        assert row[0] == 1
+
+    readstate = CoinState.zero()
+    for block in db.read_blocks_from_disk():
+        readstate.add_block_no_validation(block)
+
+    block1 = list(readstate.block_by_hash.values())[0]
+    block2 = list(coinstate.block_by_hash.values())[0]
+
+    assert block1.hash() == block2.hash()
+    assert block1.serialize() == block2.serialize()
+    assert block1.transactions == block2.transactions
+    assert human(block1.hash()) == '00c4ff1d0788c7058f3d8388d77b2feda0921fa141078fb895871634e0c36780'
+
+    wallet = open_or_init_wallet()
+    assert wallet.get_balance(coinstate) == 0
+
+    db.close()