package chain import ( "encoding/hex" "encoding/json" "errors" "fmt" "os" "strconv" "strings" "git.smesh.lol/nostrino/pkg/btcwire" "git.smesh.lol/nostrino/pkg/crypto/chainhash" ) // ProfileJSON is the on-disk form of a chain profile. Hashes are written in // the usual display order and the genesis header as 160 hex characters. // // A checkpoint pins both the block and the compact filter header at a height, // which is what lets a first sync start near the tip. It must come from a // source the operator trusts; a wrong filter header is detected on the first // filter header response, not silently accepted. type ProfileJSON struct { Name string `json:"name"` Magic string `json:"magic"` GenesisHeader string `json:"genesis_header"` GenesisHash string `json:"genesis_hash"` P2PPort string `json:"p2p_port,omitempty"` Bech32HRP string `json:"bech32_hrp,omitempty"` CoinType uint32 `json:"coin_type,omitempty"` PowLimitBits string `json:"pow_limit_bits,omitempty"` TargetSpacing int64 `json:"target_spacing,omitempty"` TargetTimespan int64 `json:"target_timespan,omitempty"` RetargetInterval uint32 `json:"retarget_interval,omitempty"` MinDifficulty bool `json:"min_difficulty,omitempty"` NoRetarget bool `json:"no_retarget,omitempty"` Checkpoint *CheckpointJSON `json:"checkpoint,omitempty"` } // CheckpointJSON is the checkpoint form of ProfileJSON. type CheckpointJSON struct { Height uint32 `json:"height"` BlockHash string `json:"block_hash"` FilterHeader string `json:"filter_header"` } // ParseProfile parses a profile from JSON. func ParseProfile(raw []byte) (*Profile, error) { var pj ProfileJSON dec := json.NewDecoder(strings.NewReader(string(raw))) dec.DisallowUnknownFields() if err := dec.Decode(&pj); err != nil { return nil, fmt.Errorf("chain: profile json: %w", err) } return profileFromJSON(&pj) } // LoadProfile reads a profile file. func LoadProfile(path string) (*Profile, error) { raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("chain: read profile: %w", err) } p, err := ParseProfile(raw) if err != nil { return nil, fmt.Errorf("chain: %s: %w", path, err) } return p, nil } func profileFromJSON(pj *ProfileJSON) (*Profile, error) { if strings.TrimSpace(pj.Name) == "" { return nil, errors.New("chain: profile has no name") } magicText := strings.TrimSpace(pj.Magic) magicText = strings.TrimPrefix(magicText, "0x") if len(magicText) != 8 { return nil, fmt.Errorf("chain: magic %q must be 4 bytes of hex", pj.Magic) } magic, err := strconv.ParseUint(magicText, 16, 32) if err != nil { return nil, fmt.Errorf("chain: magic: %w", err) } hdrText := strings.TrimSpace(pj.GenesisHeader) hdrBytes, err := hex.DecodeString(hdrText) if err != nil { return nil, fmt.Errorf("chain: genesis header is not hex: %w", err) } if len(hdrBytes) != btcwire.BlockHeaderSize { return nil, fmt.Errorf("chain: genesis header is %d bytes, want %d", len(hdrBytes), btcwire.BlockHeaderSize) } var header btcwire.BlockHeader if err := header.Deserialize(hdrBytes); err != nil { return nil, fmt.Errorf("chain: genesis header: %w", err) } hash, err := chainhash.NewHashFromStr(strings.TrimSpace(pj.GenesisHash)) if err != nil { return nil, fmt.Errorf("chain: genesis hash: %w", err) } p := &Profile{ Name: strings.ToLower(strings.TrimSpace(pj.Name)), Magic: uint32(magic), GenesisHash: *hash, GenesisHeader: header, P2PPort: pj.P2PPort, Bech32HRP: pj.Bech32HRP, CoinType: pj.CoinType, TargetSpacing: pj.TargetSpacing, TargetTimespan: pj.TargetTimespan, RetargetInterval: pj.RetargetInterval, MinDifficulty: pj.MinDifficulty, NoRetarget: pj.NoRetarget, } if p.TargetSpacing == 0 { p.TargetSpacing = 600 } if p.TargetTimespan == 0 { p.TargetTimespan = 1209600 } if p.RetargetInterval == 0 { p.RetargetInterval = 2016 } if strings.TrimSpace(pj.PowLimitBits) != "" { bits, err := parseBits(pj.PowLimitBits) if err != nil { return nil, err } p.PowLimitBits = bits } else { p.PowLimitBits = header.Bits } if pj.Checkpoint != nil { bh, err := chainhash.NewHashFromStr(strings.TrimSpace(pj.Checkpoint.BlockHash)) if err != nil { return nil, fmt.Errorf("chain: checkpoint block hash: %w", err) } fh, err := chainhash.NewHashFromStr(strings.TrimSpace(pj.Checkpoint.FilterHeader)) if err != nil { return nil, fmt.Errorf("chain: checkpoint filter header: %w", err) } p.Checkpoint = &Checkpoint{ Height: pj.Checkpoint.Height, BlockHash: *bh, FilterHeader: *fh, } } if err := p.Validate(); err != nil { return nil, err } return p, nil } func parseBits(raw string) (uint32, error) { text := strings.TrimPrefix(strings.TrimSpace(raw), "0x") if len(text) != 8 { return 0, fmt.Errorf("chain: pow_limit_bits %q must be 4 bytes of hex", raw) } v, err := strconv.ParseUint(text, 16, 32) if err != nil { return 0, fmt.Errorf("chain: pow_limit_bits: %w", err) } return uint32(v), nil } // ParseCheckpoint parses the three checkpoint values an operator supplies. func ParseCheckpoint(height uint32, blockHash, filterHeader string) (*Checkpoint, error) { bh, err := chainhash.NewHashFromStr(strings.TrimSpace(blockHash)) if err != nil { return nil, fmt.Errorf("chain: checkpoint block hash: %w", err) } fh, err := chainhash.NewHashFromStr(strings.TrimSpace(filterHeader)) if err != nil { return nil, fmt.Errorf("chain: checkpoint filter header: %w", err) } return &Checkpoint{Height: height, BlockHash: *bh, FilterHeader: *fh}, nil }