profile.go raw
1 package chain
2
3 import (
4 "encoding/hex"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "strconv"
10 "strings"
11
12 "git.smesh.lol/nostrino/pkg/btcwire"
13 "git.smesh.lol/nostrino/pkg/crypto/chainhash"
14 )
15
16 // ProfileJSON is the on-disk form of a chain profile. Hashes are written in
17 // the usual display order and the genesis header as 160 hex characters.
18 //
19 // A checkpoint pins both the block and the compact filter header at a height,
20 // which is what lets a first sync start near the tip. It must come from a
21 // source the operator trusts; a wrong filter header is detected on the first
22 // filter header response, not silently accepted.
23 type ProfileJSON struct {
24 Name string `json:"name"`
25 Magic string `json:"magic"`
26 GenesisHeader string `json:"genesis_header"`
27 GenesisHash string `json:"genesis_hash"`
28 P2PPort string `json:"p2p_port,omitempty"`
29 Bech32HRP string `json:"bech32_hrp,omitempty"`
30 CoinType uint32 `json:"coin_type,omitempty"`
31 PowLimitBits string `json:"pow_limit_bits,omitempty"`
32 TargetSpacing int64 `json:"target_spacing,omitempty"`
33 TargetTimespan int64 `json:"target_timespan,omitempty"`
34 RetargetInterval uint32 `json:"retarget_interval,omitempty"`
35 MinDifficulty bool `json:"min_difficulty,omitempty"`
36 NoRetarget bool `json:"no_retarget,omitempty"`
37 Checkpoint *CheckpointJSON `json:"checkpoint,omitempty"`
38 }
39
40 // CheckpointJSON is the checkpoint form of ProfileJSON.
41 type CheckpointJSON struct {
42 Height uint32 `json:"height"`
43 BlockHash string `json:"block_hash"`
44 FilterHeader string `json:"filter_header"`
45 }
46
47 // ParseProfile parses a profile from JSON.
48 func ParseProfile(raw []byte) (*Profile, error) {
49 var pj ProfileJSON
50 dec := json.NewDecoder(strings.NewReader(string(raw)))
51 dec.DisallowUnknownFields()
52 if err := dec.Decode(&pj); err != nil {
53 return nil, fmt.Errorf("chain: profile json: %w", err)
54 }
55 return profileFromJSON(&pj)
56 }
57
58 // LoadProfile reads a profile file.
59 func LoadProfile(path string) (*Profile, error) {
60 raw, err := os.ReadFile(path)
61 if err != nil {
62 return nil, fmt.Errorf("chain: read profile: %w", err)
63 }
64 p, err := ParseProfile(raw)
65 if err != nil {
66 return nil, fmt.Errorf("chain: %s: %w", path, err)
67 }
68 return p, nil
69 }
70
71 func profileFromJSON(pj *ProfileJSON) (*Profile, error) {
72 if strings.TrimSpace(pj.Name) == "" {
73 return nil, errors.New("chain: profile has no name")
74 }
75 magicText := strings.TrimSpace(pj.Magic)
76 magicText = strings.TrimPrefix(magicText, "0x")
77 if len(magicText) != 8 {
78 return nil, fmt.Errorf("chain: magic %q must be 4 bytes of hex", pj.Magic)
79 }
80 magic, err := strconv.ParseUint(magicText, 16, 32)
81 if err != nil {
82 return nil, fmt.Errorf("chain: magic: %w", err)
83 }
84 hdrText := strings.TrimSpace(pj.GenesisHeader)
85 hdrBytes, err := hex.DecodeString(hdrText)
86 if err != nil {
87 return nil, fmt.Errorf("chain: genesis header is not hex: %w", err)
88 }
89 if len(hdrBytes) != btcwire.BlockHeaderSize {
90 return nil, fmt.Errorf("chain: genesis header is %d bytes, want %d", len(hdrBytes), btcwire.BlockHeaderSize)
91 }
92 var header btcwire.BlockHeader
93 if err := header.Deserialize(hdrBytes); err != nil {
94 return nil, fmt.Errorf("chain: genesis header: %w", err)
95 }
96 hash, err := chainhash.NewHashFromStr(strings.TrimSpace(pj.GenesisHash))
97 if err != nil {
98 return nil, fmt.Errorf("chain: genesis hash: %w", err)
99 }
100 p := &Profile{
101 Name: strings.ToLower(strings.TrimSpace(pj.Name)),
102 Magic: uint32(magic),
103 GenesisHash: *hash,
104 GenesisHeader: header,
105 P2PPort: pj.P2PPort,
106 Bech32HRP: pj.Bech32HRP,
107 CoinType: pj.CoinType,
108 TargetSpacing: pj.TargetSpacing,
109 TargetTimespan: pj.TargetTimespan,
110 RetargetInterval: pj.RetargetInterval,
111 MinDifficulty: pj.MinDifficulty,
112 NoRetarget: pj.NoRetarget,
113 }
114 if p.TargetSpacing == 0 {
115 p.TargetSpacing = 600
116 }
117 if p.TargetTimespan == 0 {
118 p.TargetTimespan = 1209600
119 }
120 if p.RetargetInterval == 0 {
121 p.RetargetInterval = 2016
122 }
123 if strings.TrimSpace(pj.PowLimitBits) != "" {
124 bits, err := parseBits(pj.PowLimitBits)
125 if err != nil {
126 return nil, err
127 }
128 p.PowLimitBits = bits
129 } else {
130 p.PowLimitBits = header.Bits
131 }
132 if pj.Checkpoint != nil {
133 bh, err := chainhash.NewHashFromStr(strings.TrimSpace(pj.Checkpoint.BlockHash))
134 if err != nil {
135 return nil, fmt.Errorf("chain: checkpoint block hash: %w", err)
136 }
137 fh, err := chainhash.NewHashFromStr(strings.TrimSpace(pj.Checkpoint.FilterHeader))
138 if err != nil {
139 return nil, fmt.Errorf("chain: checkpoint filter header: %w", err)
140 }
141 p.Checkpoint = &Checkpoint{
142 Height: pj.Checkpoint.Height,
143 BlockHash: *bh,
144 FilterHeader: *fh,
145 }
146 }
147 if err := p.Validate(); err != nil {
148 return nil, err
149 }
150 return p, nil
151 }
152
153 func parseBits(raw string) (uint32, error) {
154 text := strings.TrimPrefix(strings.TrimSpace(raw), "0x")
155 if len(text) != 8 {
156 return 0, fmt.Errorf("chain: pow_limit_bits %q must be 4 bytes of hex", raw)
157 }
158 v, err := strconv.ParseUint(text, 16, 32)
159 if err != nil {
160 return 0, fmt.Errorf("chain: pow_limit_bits: %w", err)
161 }
162 return uint32(v), nil
163 }
164
165 // ParseCheckpoint parses the three checkpoint values an operator supplies.
166 func ParseCheckpoint(height uint32, blockHash, filterHeader string) (*Checkpoint, error) {
167 bh, err := chainhash.NewHashFromStr(strings.TrimSpace(blockHash))
168 if err != nil {
169 return nil, fmt.Errorf("chain: checkpoint block hash: %w", err)
170 }
171 fh, err := chainhash.NewHashFromStr(strings.TrimSpace(filterHeader))
172 if err != nil {
173 return nil, fmt.Errorf("chain: checkpoint filter header: %w", err)
174 }
175 return &Checkpoint{Height: height, BlockHash: *bh, FilterHeader: *fh}, nil
176 }
177