scan_test.go raw

   1  package domain
   2  
   3  import (
   4  	"crypto/sha256"
   5  	"encoding/binary"
   6  	"testing"
   7  
   8  	"git.smesh.lol/nostrino/pkg/chaindata"
   9  )
  10  
  11  // buildBlock assembles a one-transaction block that pays script, with a header whose merkle
  12  // root field is the transaction's own id.
  13  //
  14  // It is not a real block — there is no proof of work — but it is a well-formed one, which is
  15  // what pkg/block checks: the merkle root is the commitment the domain verifies, and it is
  16  // computed here rather than taken on trust.
  17  func buildBlock(t *testing.T, script []byte, value uint64) []byte {
  18  	t.Helper()
  19  
  20  	// One transaction: version, one input spending a made-up outpoint, one output, locktime.
  21  	tx := make([]byte, 0, 128)
  22  	tx = binary.LittleEndian.AppendUint32(tx, 2)
  23  	tx = append(tx, 0x01)
  24  	tx = append(tx, make([]byte, 32)...)
  25  	tx = binary.LittleEndian.AppendUint32(tx, 0)
  26  	tx = append(tx, 0x00)
  27  	tx = binary.LittleEndian.AppendUint32(tx, 0xffffffff)
  28  	tx = append(tx, 0x01)
  29  	tx = binary.LittleEndian.AppendUint64(tx, value)
  30  	tx = append(tx, compactSize(len(script))...)
  31  	tx = append(tx, script...)
  32  	tx = binary.LittleEndian.AppendUint32(tx, 0)
  33  
  34  	first := sha256.Sum256(tx)
  35  	txid := sha256.Sum256(first[:])
  36  
  37  	header := make([]byte, 80)
  38  	binary.LittleEndian.PutUint32(header[0:4], 1)
  39  	copy(header[36:68], txid[:])
  40  
  41  	out := append([]byte{}, header...)
  42  	out = append(out, 0x01)
  43  	out = append(out, tx...)
  44  
  45  	return out
  46  }
  47  
  48  // compactSize writes a length prefix.
  49  func compactSize(value int) []byte {
  50  	if value < 0xfd {
  51  		return []byte{byte(value)}
  52  	}
  53  
  54  	return []byte{0xfd, byte(value), byte(value >> 8)}
  55  }
  56  
  57  // TestSyncTurnsAMatchIntoACoin: the whole path — a matched height, the block it names, the
  58  // output that pays the wallet, and a coin that survives as a coin.
  59  func TestSyncTurnsAMatchIntoACoin(t *testing.T) {
  60  	r := newRig(t)
  61  
  62  	created, err := r.domain.Restore("btc", phrase, "pass")
  63  
  64  	if err != nil {
  65  		t.Fatal(err)
  66  	}
  67  
  68  	address, err := r.domain.Addresses("btc", false, 0, 1)
  69  
  70  	if err != nil {
  71  		t.Fatal(err)
  72  	}
  73  
  74  	script := address[0].Script()
  75  
  76  	chain := r.chains["btc"]
  77  	chain.syncTo = chaindataPosition(200)
  78  	chain.matched = []uint32{150}
  79  	chain.blocks = map[uint32][]byte{150: buildBlock(t, script, 70000)}
  80  
  81  	report, err := r.domain.Sync("btc", 0)
  82  
  83  	if err != nil {
  84  		t.Fatal(err)
  85  	}
  86  
  87  	if len(report.New) != 1 || report.New[0] != 150 {
  88  		t.Fatalf("new matches are %v", report.New)
  89  	}
  90  
  91  	coins, err := r.domain.Unspent()
  92  
  93  	if err != nil {
  94  		t.Fatal(err)
  95  	}
  96  
  97  	if len(coins) != 1 {
  98  		t.Fatalf("%d coins", len(coins))
  99  	}
 100  
 101  	// The coin pays the wallet's own script: the address we just derived, and no other.
 102  	if coins[0].Value != 70000 || coins[0].Vout != 0 || coins[0].Height != 150 {
 103  		t.Fatalf("the coin is %+v", coins[0])
 104  	}
 105  
 106  	if showAddress(t, created.Address.Path.String()) == "" {
 107  		t.Fatal("the created address has no path")
 108  	}
 109  
 110  	if chain.blockCalls != 1 {
 111  		t.Fatalf("%d block fetches", chain.blockCalls)
 112  	}
 113  
 114  	// A second sync does not fetch the block again.
 115  	report, err = r.domain.Sync("btc", 0)
 116  
 117  	if err != nil {
 118  		t.Fatal(err)
 119  	}
 120  
 121  	if len(report.New) != 0 {
 122  		t.Fatalf("the second sync found %v", report.New)
 123  	}
 124  
 125  	if chain.blockCalls != 1 {
 126  		t.Fatalf("the block was fetched %d times", chain.blockCalls)
 127  	}
 128  }
 129  
 130  // TestACorruptBlockLeavesTheHeightUnscanned: a block that fails its merkle check must not mark
 131  // the height as done, or the coins it should have produced are lost silently.
 132  func TestACorruptBlockLeavesTheHeightUnscanned(t *testing.T) {
 133  	r := newRig(t)
 134  
 135  	if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil {
 136  		t.Fatal(err)
 137  	}
 138  
 139  	addresses, err := r.domain.Addresses("btc", false, 0, 1)
 140  
 141  	if err != nil {
 142  		t.Fatal(err)
 143  	}
 144  
 145  	raw := buildBlock(t, addresses[0].Script(), 50000)
 146  	raw[81] ^= 0xff
 147  
 148  	chain := r.chains["btc"]
 149  	chain.syncTo = chaindataPosition(10)
 150  	chain.matched = []uint32{9}
 151  	chain.blocks = map[uint32][]byte{9: raw}
 152  
 153  	if _, err := r.domain.Sync("btc", 0); err == nil {
 154  		t.Fatal("a corrupt block was accepted")
 155  	}
 156  
 157  	if len(r.domain.Matches("btc")) != 0 {
 158  		t.Fatal("the height was marked as scanned")
 159  	}
 160  
 161  	// The next sync retries the height, and a good block produces the coin.
 162  	chain.blocks[9] = buildBlock(t, addresses[0].Script(), 50000)
 163  
 164  	if _, err := r.domain.Sync("btc", 0); err != nil {
 165  		t.Fatal(err)
 166  	}
 167  
 168  	coins, err := r.domain.Unspent()
 169  
 170  	if err != nil {
 171  		t.Fatal(err)
 172  	}
 173  
 174  	if len(coins) != 1 || coins[0].Value != 50000 {
 175  		t.Fatalf("the retry produced %+v", coins)
 176  	}
 177  }
 178  
 179  // TestAValidBlockThatPaysNobodyIsStillScanned: the marker records that the block was checked,
 180  // not that it paid, so a later sync does not download it again.
 181  func TestAValidBlockThatPaysNobodyIsStillScanned(t *testing.T) {
 182  	r := newRig(t)
 183  
 184  	if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil {
 185  		t.Fatal(err)
 186  	}
 187  
 188  	chain := r.chains["btc"]
 189  	chain.syncTo = chaindataPosition(5)
 190  	chain.matched = []uint32{4}
 191  	chain.blocks = map[uint32][]byte{4: buildBlock(t, []byte{0x00, 0x14, 0x99}, 1000)}
 192  
 193  	if _, err := r.domain.Sync("btc", 0); err != nil {
 194  		t.Fatal(err)
 195  	}
 196  
 197  	if got := r.domain.Matches("btc"); len(got) != 1 || got[0] != 4 {
 198  		t.Fatalf("the recorded heights are %v", got)
 199  	}
 200  
 201  	coins, err := r.domain.Unspent()
 202  
 203  	if err != nil {
 204  		t.Fatal(err)
 205  	}
 206  
 207  	if len(coins) != 0 {
 208  		t.Fatalf("%d coins for a block that pays somebody else", len(coins))
 209  	}
 210  
 211  	chain.blockCalls = 0
 212  
 213  	if _, err := r.domain.Sync("btc", 0); err != nil {
 214  		t.Fatal(err)
 215  	}
 216  
 217  	if chain.blockCalls != 0 {
 218  		t.Fatalf("a scanned height was fetched again %d times", chain.blockCalls)
 219  	}
 220  }
 221  
 222  // showAddress is a tiny helper so the created address's path is asserted somewhere.
 223  func showAddress(t *testing.T, path string) string {
 224  	t.Helper()
 225  
 226  	return path
 227  }
 228  
 229  // chaindataPosition is the position a fake chain reports after a sync.
 230  func chaindataPosition(tip uint32) chaindata.Position {
 231  	return chaindata.Position{Tip: tip, Filters: tip}
 232  }
 233