package neutrino import ( "context" "errors" "fmt" "net" "sync" "time" "git.smesh.lol/nostrino/pkg/btcwire" "git.smesh.lol/nostrino/pkg/chain" "git.smesh.lol/nostrino/pkg/crypto/chainhash" "git.smesh.lol/nostrino/pkg/filter" "git.smesh.lol/nostrino/pkg/p2p" ) // Config configures a light client. type Config struct { // Profile is the chain being followed; required. Profile *chain.Profile // Store is the durable state; required. Store Store // Dial opens the transport. When nil, Address is dialed over TCP. Dial func(ctx context.Context) (net.Conn, error) // Address is a TCP P2P address used when Dial is nil. Address string // UserAgent is advertised in version. Empty means p2p.DefaultUserAgent. UserAgent string // HandshakeTimeout bounds connect and version/verack. Zero means 30s. HandshakeTimeout time.Duration // ReadTimeout bounds one frame read. Zero means 60s. ReadTimeout time.Duration // RequireCompactFilters refuses a peer without the BIP157 service bit. RequireCompactFilters bool // Logf receives diagnostics; nil discards them. Logf func(string, ...any) } // Client is a compact-filter light client for one chain over one peer // connection. type Client struct { cfg Config store Store connectMu sync.Mutex mu sync.Mutex peer *p2p.Conn pending net.Conn closed bool // peerTip is the peer's last block height from version. peerTip int32 } // New validates cfg and returns a client. It does not connect. func New(cfg Config) (*Client, error) { if cfg.Profile == nil { return nil, errors.New("neutrino: nil profile") } if err := cfg.Profile.Validate(); err != nil { return nil, err } if cfg.Store == nil { return nil, errors.New("neutrino: nil store") } if cfg.Dial == nil && cfg.Address == "" { return nil, errors.New("neutrino: no address or dialer") } if cfg.HandshakeTimeout == 0 { cfg.HandshakeTimeout = 30 * time.Second } if cfg.ReadTimeout == 0 { cfg.ReadTimeout = 60 * time.Second } if cfg.Logf == nil { cfg.Logf = func(string, ...any) {} } return &Client{cfg: cfg, store: cfg.Store}, nil } // MaxBlockPayload is the largest block this client will accept. // // It is comfortably above the 4 MB serialised maximum for a block and below the wire package's // 32 MiB frame limit, so a real block always fits and a peer that claims otherwise is refused // before its bytes are read. const MaxBlockPayload = 8 << 20 // ErrNoHeader is a block requested at a height this store has no header for. var ErrNoHeader = errors.New("neutrino: no header at that height") // ErrBlockTooLarge is a block over the cap, or one the peer answered with something else. var ErrBlockTooLarge = errors.New("neutrino: the block is larger than the cap") // MaxTransactionPayload bounds a broadcast. Bitcoin policy caps a standard transaction well // below this; the bound exists so a caller cannot hand the peer an arbitrary frame. const MaxTransactionPayload = 1 << 20 // ErrTransactionTooLarge is a broadcast over the cap. var ErrTransactionTooLarge = errors.New("neutrino: the transaction is larger than the cap") // rawPayload is a payload whose bytes are already encoded. // // Broadcast sends a transaction exactly as the wallet built and signed it: re-encoding it // through a decoder here would be a second place that could disagree with the signer. type rawPayload []byte // EncodePayload returns the bytes unchanged. func (p rawPayload) EncodePayload() ([]byte, error) { return p, nil } // DecodePayload is not part of what a broadcast does. func (p rawPayload) DecodePayload([]byte) error { return errors.New("neutrino: a raw payload is encode-only") } // Broadcast sends a raw transaction to the peer over the connected tunnel. // // A tx message has no reply, so a nil error means the frame reached the peer, not that the // transaction was accepted into its mempool. A caller that needs the second fact must watch // for the transaction; claiming it here would be a claim without evidence. func (c *Client) Broadcast(ctx context.Context, raw []byte) error { if len(raw) == 0 { return errors.New("neutrino: refusing to broadcast an empty transaction") } if len(raw) > MaxTransactionPayload { return fmt.Errorf( "%w: transaction is %d bytes, maximum is %d", ErrTransactionTooLarge, len(raw), MaxTransactionPayload, ) } c.mu.Lock() peer := c.peer c.mu.Unlock() if peer == nil { return errors.New("neutrino: not connected") } if err := peer.Send(btcwire.CmdTx, rawPayload(raw)); err != nil { return fmt.Errorf("neutrino: tx: %w", err) } return nil } // Block fetches the raw block at a height. // // The height is resolved to a hash from the header store, so the request names what the wallet // has already verified. The first 80 bytes of the answer must hash to that name: a peer can send // any bytes it likes, and this is the cheap check before the expensive one in pkg/block. func (c *Client) Block(ctx context.Context, height uint32) ([]byte, error) { header, ok := c.store.Header(height) if !ok { return nil, fmt.Errorf("%w: %d", ErrNoHeader, height) } hash := header.BlockHash() c.mu.Lock() peer := c.peer c.mu.Unlock() if peer == nil { return nil, errors.New("neutrino: not connected") } request := &btcwire.GetData{InvList: []btcwire.InvVect{{Type: btcwire.InvVectTypeBlock, Hash: hash}}} if err := peer.Send(btcwire.CmdGetData, request); err != nil { return nil, fmt.Errorf("neutrino: getdata: %w", err) } message, err := peer.ReadUntilMax(ctx, btcwire.CmdBlock, MaxBlockPayload) if err != nil { return nil, fmt.Errorf("neutrino: reading block %d: %w", height, err) } if len(message.Payload) > MaxBlockPayload { return nil, fmt.Errorf("%w: %d bytes", ErrBlockTooLarge, len(message.Payload)) } if len(message.Payload) < btcwire.BlockHeaderSize { return nil, fmt.Errorf("%w: %d bytes is not a block", ErrBlockTooLarge, len(message.Payload)) } var got btcwire.BlockHeader if err := got.Deserialize(message.Payload[:btcwire.BlockHeaderSize]); err != nil { return nil, fmt.Errorf("neutrino: block %d header: %w", height, err) } if got.BlockHash() != hash { return nil, fmt.Errorf("%w: the peer sent %v, not %v", ErrBlockTooLarge, got.BlockHash(), hash) } return message.Payload, nil } // Store returns the client's store. func (c *Client) Store() Store { return c.store } // Profile returns the chain profile. func (c *Client) Profile() *chain.Profile { return c.cfg.Profile } // Connect dials and handshakes if not already connected. Close can interrupt // a Connect in progress: it closes the pending socket, not just the finished // peer. func (c *Client) Connect(ctx context.Context) error { c.connectMu.Lock() defer c.connectMu.Unlock() c.mu.Lock() if c.peer != nil { c.mu.Unlock() return nil } if c.closed { c.mu.Unlock() return errors.New("neutrino: closed") } c.mu.Unlock() if err := c.ensureAnchor(); err != nil { return err } tip, _, ok := c.store.HeaderTip() var last int32 if ok { last = int32(tip) } var conn net.Conn var err error if c.cfg.Dial != nil { conn, err = c.cfg.Dial(ctx) if err != nil { return fmt.Errorf("neutrino: dial: %w", err) } } else { var d net.Dialer conn, err = d.DialContext(ctx, "tcp", c.cfg.Address) if err != nil { return fmt.Errorf("neutrino: dial %s: %w", c.cfg.Address, err) } } c.mu.Lock() if c.closed { c.mu.Unlock() conn.Close() return errors.New("neutrino: closed") } c.pending = conn c.mu.Unlock() peer := p2p.New(conn, p2p.Config{ Magic: c.cfg.Profile.Magic, UserAgent: c.cfg.UserAgent, LastBlock: last, ReadTimeout: c.cfg.ReadTimeout, RequireCompactFilters: c.cfg.RequireCompactFilters, Logf: c.cfg.Logf, }) hctx, cancel := context.WithTimeout(ctx, c.cfg.HandshakeTimeout) defer cancel() version, err := peer.Handshake(hctx) if err != nil { c.mu.Lock() c.pending = nil c.mu.Unlock() conn.Close() return fmt.Errorf("neutrino: handshake: %w", err) } c.mu.Lock() if c.closed { c.mu.Unlock() peer.Close() return errors.New("neutrino: closed") } c.pending = nil c.peer = peer c.peerTip = version.LastBlock c.mu.Unlock() c.cfg.Logf("neutrino: peer %s protocol=%d services=%#x tip=%d user=%s", c.cfg.Profile.Name, version.ProtocolVersion, version.Services, version.LastBlock, version.UserAgent) return nil } // ensureAnchor installs the trusted starting point on a fresh store. A // profile checkpoint pins both the block and its filter header, which is what // makes a first mainnet sync small; without one, the anchor is genesis. func (c *Client) ensureAnchor() error { if _, ok := c.store.Anchor(); ok { return nil } if h := c.cfg.Profile.GenesisHeader.BlockHash(); h != c.cfg.Profile.GenesisHash { return errors.New("neutrino: profile genesis header does not match its hash") } anchor := Anchor{Height: 0, BlockHash: c.cfg.Profile.GenesisHash, FilterHeight: -1} if cp := c.cfg.Profile.Checkpoint; cp != nil { anchor = Anchor{ Height: cp.Height, BlockHash: cp.BlockHash, FilterHeight: int32(cp.Height), FilterHeader: cp.FilterHeader, } } if anchor.BlockHash == (chainhash.Hash{}) { return errors.New("neutrino: profile has no usable anchor") } if err := c.store.SetAnchor(anchor); err != nil { return fmt.Errorf("neutrino: set anchor: %w", err) } if c.cfg.Profile.Checkpoint == nil { if _, ok := c.store.Header(0); !ok { if err := c.store.PutHeaders(0, []btcwire.BlockHeader{c.cfg.Profile.GenesisHeader}); err != nil { return fmt.Errorf("neutrino: seed genesis header: %w", err) } } } return nil } // Close ends the connection, including a dial or handshake in progress. func (c *Client) Close() error { c.mu.Lock() c.closed = true pending := c.pending peer := c.peer c.pending = nil c.peer = nil c.mu.Unlock() if pending != nil { pending.Close() } if peer != nil { return peer.Close() } return nil } // SelfPeer reports the peer's advertised tip from the handshake. func (c *Client) PeerTip() int32 { c.mu.Lock() defer c.mu.Unlock() return c.peerTip } // Result summarises a sync. type Result struct { StartHeight uint32 TipHeight uint32 HeadersAdded int FilterHeadersAdded int FiltersAdded int FilterBytes int Elapsed time.Duration } // Sync runs header, filter header and filter sync to the peer's tip. func (c *Client) Sync(ctx context.Context) (*Result, error) { return c.SyncFrom(ctx, 0, 0) } // SyncFrom is Sync with a first filter height and a filter count bound. A // wallet that scans only recent history passes its birth height here; header // and filter-header verification is always complete. func (c *Client) SyncFrom(ctx context.Context, from uint32, max int) (*Result, error) { start := time.Now() res := &Result{} if err := c.Connect(ctx); err != nil { return res, err } if first, _, ok := c.store.HeaderTip(); ok { res.StartHeight = first } n, err := c.SyncHeaders(ctx) if err != nil { return res, err } res.HeadersAdded = n m, err := c.SyncFilterHeaders(ctx) if err != nil { return res, err } res.FilterHeadersAdded = m k, bytes, err := c.SyncFilters(ctx, from, max) if err != nil { return res, err } res.FiltersAdded = k res.FilterBytes = bytes tip, _, _ := c.store.HeaderTip() res.TipHeight = tip res.Elapsed = time.Since(start) return res, nil } // SyncHeaders requests headers until the peer's advertised tip is reached or // the peer has nothing more. It returns the number of headers added. func (c *Client) SyncHeaders(ctx context.Context) (int, error) { c.mu.Lock() peer := c.peer peerTip := c.peerTip c.mu.Unlock() if peer == nil { return 0, errors.New("neutrino: not connected") } if err := c.ensureAnchor(); err != nil { return 0, err } added := 0 reorgs := 0 for { tip, tipHash, ok := c.store.HeaderTip() if !ok { return added, errors.New("neutrino: no header tip") } req := &btcwire.GetHeaders{ ProtocolVersion: btcwire.ProtocolVersion, LocatorHashes: c.locator(tip), StopHash: chainhash.Hash{}, } if err := peer.Send(btcwire.CmdGetHeaders, req); err != nil { return added, err } msg, err := peer.ReadUntil(ctx, btcwire.CmdHeaders) if err != nil { return added, fmt.Errorf("neutrino: headers: %w", err) } var resp btcwire.Headers if err := resp.DecodePayload(msg.Payload); err != nil { return added, fmt.Errorf("neutrino: decode headers: %w", err) } if len(resp.Headers) == 0 { break } base := tip prev := tipHash if resp.Headers[0].PrevBlock != tipHash { forkHeight, ok := c.findFork(resp.Headers[0].PrevBlock, tip) if !ok { return added, fmt.Errorf("neutrino: peer chain does not connect to the stored chain") } reorgs++ if reorgs > 5 { return added, errors.New("neutrino: too many reorg attempts") } if err := c.store.Truncate(forkHeight + 1); err != nil { return added, fmt.Errorf("neutrino: reorg rollback: %w", err) } c.cfg.Logf("neutrino: reorg at height %d, rolling back from %d", forkHeight, tip) base = forkHeight prev = resp.Headers[0].PrevBlock } for i := range resp.Headers { if err := c.validateHeader(base+uint32(i)+1, &resp.Headers[i], prev); err != nil { return added, err } prev = resp.Headers[i].BlockHash() } if err := c.store.PutHeaders(base+1, resp.Headers); err != nil { return added, err } added += len(resp.Headers) newTip := base + uint32(len(resp.Headers)) if peerTip >= 0 && int64(newTip) >= int64(peerTip) { break } if len(resp.Headers) < btcwire.MaxBlockHeadersPerMsg { break } } return added, nil } // findFork walks back from tip looking for hash. It is bounded: a reorg deeper // than the bound is refused rather than scanning an entire mainnet history. func (c *Client) findFork(hash chainhash.Hash, tip uint32) (uint32, bool) { anchor, hasAnchor := c.store.Anchor() const maxScan = 10000 for i := 0; i < maxScan; i++ { if tip < uint32(i) { return 0, false } h := tip - uint32(i) if hasAnchor && h < anchor.Height { return 0, false } if hasAnchor && h == anchor.Height { return h, hash == anchor.BlockHash } hdr, ok := c.store.Header(h) if !ok { if hasAnchor && h == anchor.Height+1 { return anchor.Height, hash == anchor.BlockHash } return 0, false } if hdr.BlockHash() == hash { return h, true } } return 0, false } // locator builds a block locator from the stored chain, with ten single steps // followed by exponentially growing gaps. func (c *Client) locator(tip uint32) []chainhash.Hash { var out []chainhash.Hash anchor, hasAnchor := c.store.Anchor() h := tip step := uint32(1) for { if hasAnchor && h == anchor.Height { out = append(out, anchor.BlockHash) break } hdr, ok := c.store.Header(h) if !ok { if hasAnchor && h > anchor.Height { h = anchor.Height continue } break } out = append(out, hdr.BlockHash()) if h == 0 || len(out) >= btcwire.MaxLocatorHashesPerMsg { break } if h < step { h = 0 } else { h -= step } if len(out) >= 10 { step *= 2 } } return out } // filterHeaderStop returns the last height a getcfheaders request that starts // at start may name, given the header tip. BIP157 caps one request at // MaxFilterHashesPerMsg filter hashes, so a taller chain is walked in chunks. func filterHeaderStop(start, tip uint32) uint32 { if tip-start+1 > uint32(btcwire.MaxFilterHashesPerMsg) { return start + uint32(btcwire.MaxFilterHashesPerMsg) - 1 } return tip } // SyncFilterHeaders requests the compact filter header chain up to the stored // block tip. It returns the number of filter headers added. func (c *Client) SyncFilterHeaders(ctx context.Context) (int, error) { c.mu.Lock() peer := c.peer c.mu.Unlock() if peer == nil { return 0, errors.New("neutrino: not connected") } added := 0 for { tip, tipHash, ok := c.store.HeaderTip() if !ok { return added, errors.New("neutrino: no header tip") } start := uint32(0) prev := chainhash.Hash{} if fTip, fHash, ok := c.store.FilterHeaderTip(); ok { start = fTip + 1 prev = fHash } if start > tip { break } // One getcfheaders covers at most MaxFilterHashesPerMsg blocks: BIP157 // limits the range, and a peer refuses a wider request by dropping the // connection. The chain is walked in as many requests as it takes. stop := filterHeaderStop(start, tip) stopHash := tipHash if stop != tip { stopHeader, ok := c.store.Header(stop) if !ok { return added, fmt.Errorf("neutrino: cfheaders stop block %d missing", stop) } stopHash = stopHeader.BlockHash() } req := &btcwire.GetCFHeaders{ FilterType: btcwire.FilterTypeBasic, StartHeight: start, StopHash: stopHash, } if err := peer.Send(btcwire.CmdGetCFHeaders, req); err != nil { return added, err } msg, err := peer.ReadUntil(ctx, btcwire.CmdCFHeaders) if err != nil { return added, fmt.Errorf("neutrino: cfheaders: %w", err) } var resp btcwire.CFHeaders if err := resp.DecodePayload(msg.Payload); err != nil { return added, fmt.Errorf("neutrino: decode cfheaders: %w", err) } if resp.FilterType != btcwire.FilterTypeBasic { return added, fmt.Errorf("neutrino: filter type %d in cfheaders", resp.FilterType) } if resp.PrevFilterHeader != prev { return added, fmt.Errorf("neutrino: cfheaders previous filter header %s, want %s", resp.PrevFilterHeader, prev) } if len(resp.FilterHashes) == 0 { break } end := start + uint32(len(resp.FilterHashes)) if end > tip+1 { return added, fmt.Errorf("neutrino: cfheaders returned past the block tip") } endHeader, ok := c.store.Header(end - 1) if !ok { return added, fmt.Errorf("neutrino: cfheaders end block %d missing", end-1) } if resp.StopHash != endHeader.BlockHash() { return added, fmt.Errorf("neutrino: cfheaders stop hash %s, want %s", resp.StopHash, endHeader.BlockHash()) } chain := make([]chainhash.Hash, len(resp.FilterHashes)) cur := prev for i, fh := range resp.FilterHashes { cur = btcwire.FilterHeader(fh, cur) chain[i] = cur } if err := c.store.PutFilterHeaders(start, resp.FilterHashes, chain); err != nil { return added, err } added += len(chain) } return added, nil } // SyncFilters downloads and verifies compact filters. from is the first height // to fetch (0 for the earliest stored), max bounds the count (0 for all). It // returns the number of filters and their total bytes. func (c *Client) SyncFilters(ctx context.Context, from uint32, max int) (int, int, error) { c.mu.Lock() peer := c.peer c.mu.Unlock() if peer == nil { return 0, 0, errors.New("neutrino: not connected") } tip, _, ok := c.store.HeaderTip() if !ok { return 0, 0, errors.New("neutrino: no header tip") } fTip, hasFilter := c.store.FilterTip() start := from if hasFilter && fTip+1 > start { start = fTip + 1 } if a, ok := c.store.Anchor(); ok && a.FilterHeight >= 0 && start < uint32(a.FilterHeight)+1 { // Filters below the checkpoint cannot be verified: only the filter // header at the checkpoint itself is trusted, not the filter hashes // before it. start = uint32(a.FilterHeight) + 1 } if start > tip { return 0, 0, nil } batch := 500 if max > 0 && max < batch { batch = max } count, bytes := 0, 0 for start <= tip { end := start + uint32(batch) - 1 if end > tip { end = tip } if max > 0 && count+int(end-start+1) > max { end = start + uint32(max-count) - 1 } stopHeader, ok := c.store.Header(end) if !ok { return count, bytes, fmt.Errorf("neutrino: filter range end block %d missing", end) } req := &btcwire.GetCFilters{ FilterType: btcwire.FilterTypeBasic, StartHeight: start, StopHash: stopHeader.BlockHash(), } if err := peer.Send(btcwire.CmdGetCFilters, req); err != nil { return count, bytes, err } for h := start; h <= end; h++ { msg, err := peer.ReadUntil(ctx, btcwire.CmdCFilter) if err != nil { return count, bytes, fmt.Errorf("neutrino: cfilter %d: %w", h, err) } var cf btcwire.CFilter if err := cf.DecodePayload(msg.Payload); err != nil { return count, bytes, fmt.Errorf("neutrino: decode cfilter %d: %w", h, err) } if cf.FilterType != btcwire.FilterTypeBasic { return count, bytes, fmt.Errorf("neutrino: filter %d has type %d", h, cf.FilterType) } hdr, ok := c.store.Header(h) if !ok { return count, bytes, fmt.Errorf("neutrino: filter %d has no header", h) } blockHash := hdr.BlockHash() if cf.BlockHash != blockHash { return count, bytes, fmt.Errorf("neutrino: filter %d block hash %s, want %s", h, cf.BlockHash, blockHash) } filterHash := chainhash.DoubleHashH(cf.Filter) expected, ok := c.store.FilterHash(h) if !ok { return count, bytes, fmt.Errorf("neutrino: filter %d has no committed filter hash", h) } if filterHash != expected { return count, bytes, fmt.Errorf("neutrino: filter %d hash %s, filter header commits to %s", h, filterHash, expected) } if err := c.store.PutFilter(h, blockHash, filterHash, cf.Filter); err != nil { return count, bytes, err } count++ bytes += len(cf.Filter) } start = end + 1 } return count, bytes, nil } // MatchScripts scans stored filters in [from,to] (to 0 means the filter tip) // for any of scripts, returning the matching block heights. It never contacts // the peer; the match is local, so the gateway does not learn the wallet's // script set. func (c *Client) MatchScripts(from, to uint32, scripts [][]byte) ([]uint32, error) { return MatchStoredScripts(c.store, from, to, scripts) } // MatchStoredScripts is MatchScripts without a client, for a caller that // already holds a store (such as the wasm page). func MatchStoredScripts(store Store, from, to uint32, scripts [][]byte) ([]uint32, error) { if len(scripts) == 0 { return nil, nil } tip, ok := store.FilterTip() if !ok { return nil, nil } if to == 0 || to > tip { to = tip } var out []uint32 for h := from; h <= to; h++ { f, ok := store.Filter(h) if !ok { continue } hdr, ok := store.Header(h) if !ok { return nil, fmt.Errorf("neutrino: filter %d has no header", h) } blockHash := hdr.BlockHash() match, err := filter.Match(f, [32]byte(blockHash), scripts) if err != nil { return nil, fmt.Errorf("neutrino: filter %d: %w", h, err) } if match { out = append(out, h) } } return out, nil }