Skip to content
61 changes: 61 additions & 0 deletions common/internalstate.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,67 @@ type InternalState struct {
// shared /64. Guarded by its own mutex (consulted on every connection attempt).
wsBlockMux sync.Mutex
wsBlockedIPs map[string]*WsBlockedIP

// rpcCallAllowlists is the effective websocket rpcCall allowlist snapshot,
// resolved from the DB runtime-setting overrides and the environment
// defaults (see server initRpcCallAllowlists). Unexported so it is never
// serialized by Pack; replaced wholesale via the accessors below, giving
// the rpcCall hot path a lock-free, consistent view.
rpcCallAllowlists atomic.Pointer[RpcCallAllowlists]
}

// Sources of a runtime setting value, reported by the /admin runtime-settings
// interface.
const (
RuntimeSettingSourceUnset = "unset"
RuntimeSettingSourceEnv = "env"
RuntimeSettingSourceDB = "db"
)

// RpcCallAllowlists is an immutable snapshot of the websocket rpcCall
// allowlists. Readers must not mutate the maps; writers build a new snapshot
// and replace it via SetRpcCallAllowlists.
type RpcCallAllowlists struct {
// To and Methods are the parsed allowlists; a nil map means that dimension
// is unconfigured. With both nil, rpcCall is unrestricted.
//
// Each dimension's key format must match what the rpcCall check looks up
// (server rpcCallAllowed), and the two deliberately differ:
// - To keys are the configured entries trimmed and lowercased verbatim —
// in practice 0x-prefixed hex addresses, because they are matched
// against the lowercased `to` field of the request as sent by clients.
// - Methods keys are 4-byte selectors as 8 lowercase hex characters
// without the 0x prefix, because they are matched against selectors
// hex-decoded from the request calldata (server evmCallSelector).
// A future dimension should document its key format here and keep the
// parser (server runtimeSettingDefs) and the rpcCall lookup in lockstep.
To map[string]struct{}
Methods map[string]struct{}
// Raw comma-separated values and their sources (unset/env/db), kept for
// the admin interface and logging.
ToValue string
MethodsValue string
ToSource string
MethodsSource string
}

// GetRpcCallAllowlists returns the current rpcCall allowlist snapshot, nil
// when not yet initialized.
func (is *InternalState) GetRpcCallAllowlists() *RpcCallAllowlists {
return is.rpcCallAllowlists.Load()
}

// SetRpcCallAllowlists atomically replaces the rpcCall allowlist snapshot.
func (is *InternalState) SetRpcCallAllowlists(a *RpcCallAllowlists) {
is.rpcCallAllowlists.Store(a)
}

// InitRpcCallAllowlists publishes the initial snapshot only when none exists
// yet and reports whether it did. The compare-and-swap keeps a snapshot that
// was already published — possibly updated by the admin interface in the
// meantime — intact when several server constructors initialize in any order.
func (is *InternalState) InitRpcCallAllowlists(a *RpcCallAllowlists) bool {
return is.rpcCallAllowlists.CompareAndSwap(nil, a)
}

// WsBlockedIP records a websocket client key (IPv4 address or full IPv6 /128)
Expand Down
93 changes: 93 additions & 0 deletions db/rocksdb_contracts.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package db

import (
vlq "github.com/bsm/go-vlq"
"github.com/juju/errors"
"github.com/linxGnu/grocksdb"
"github.com/trezor/blockbook/bchain"
)
Expand Down Expand Up @@ -186,3 +187,95 @@ func (d *RocksDB) storeContractInfo(wb *grocksdb.WriteBatch, contractInfo *bchai
}
return nil
}

// ListContractInfos returns up to limit stored contract records ordered by
// address descriptor, starting at the optional from address (inclusive), and
// the address to pass as from to fetch the next page ("" when the listing is
// complete). A page limit is mandatory: the rows are sync-populated (every
// contract creation when internal data processing is enabled), so the full
// set can run into millions on a busy chain.
func (d *RocksDB) ListContractInfos(from string, limit int) ([]bchain.ContractInfo, string, error) {
var start bchain.AddressDescriptor
if from != "" {
var err error
start, err = d.chainParser.GetAddrDescFromAddress(from)
if err != nil {
return nil, "", err
}
if start == nil {
return nil, "", errors.Errorf("invalid address %s", from)
}
}
it := d.db.NewIteratorCF(d.ro, d.cfh[cfContracts])
defer it.Close()
if start != nil {
it.Seek(start)
} else {
it.SeekToFirst()
}
contracts := make([]bchain.ContractInfo, 0, limit)
for ; it.Valid(); it.Next() {
addresses, _, _ := d.chainParser.GetAddressesFromAddrDesc(it.Key().Data())
if len(contracts) == limit {
// one more row exists — its address is the cursor of the next page
if len(addresses) > 0 {
return contracts, addresses[0], nil
}
return contracts, "", nil
}
contractInfo, err := unpackContractInfo(it.Value().Data())
if err != nil {
return nil, "", err
}
if len(addresses) > 0 {
contractInfo.Contract = addresses[0]
}
contracts = append(contracts, *contractInfo)
}
return contracts, "", nil
}

// DeleteContractInfoForAddress removes the stored contract metadata for the given
// address (and its in-memory cache entry) so the next read re-fetches it from the
// backend node. It returns the purged record (nil when no row was stored): the
// whole row is discarded, including the sync-owned CreatedInBlock and
// DestructedInBlock, which a backend re-fetch cannot restore — only a reindex or
// storing the returned record back can. ERC-4626 protocol-detection rows in
// cfErcProtocols are kept: they live in their own column family with their own
// lifecycle and are merged on read.
func (d *RocksDB) DeleteContractInfoForAddress(address string) (*bchain.ContractInfo, error) {
contract, err := d.chainParser.GetAddrDescFromAddress(address)
if err != nil {
return nil, err
}
if contract == nil {
return nil, errors.Errorf("invalid address %s", address)
}
val, err := d.db.GetCF(d.ro, d.cfh[cfContracts], contract)
if err != nil {
return nil, err
}
buf := val.Data()
var purged *bchain.ContractInfo
if len(buf) > 0 {
purged, _ = unpackContractInfo(buf)
addresses, _, _ := d.chainParser.GetAddressesFromAddrDesc(contract)
if len(addresses) > 0 {
purged.Contract = addresses[0]
}
}
val.Free()
if purged == nil {
return nil, nil
}
if err := d.db.DeleteCF(d.wo, d.cfh[cfContracts], contract); err != nil {
return nil, err
}
// Bump before the cache delete: a concurrent GetContractInfo that already
// sampled the old protocolGen and is about to re-add the just-deleted row
// will mismatch on the next read and miss, even if its add lands after our
// delete clears the slot (same idiom as SetErcProtocol).
d.protocolGen.Add(1)
cachedContracts.delete(string(contract))
return purged, nil
}
126 changes: 126 additions & 0 deletions db/rocksdb_contracts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,137 @@ package db

import (
"reflect"
"strconv"
"strings"
"testing"

"github.com/trezor/blockbook/bchain"
"github.com/trezor/blockbook/tests/dbtestdata"
)

func TestRocksDB_ListContractInfos(t *testing.T) {
d := setupRocksDB(t, &testEthereumParser{
EthereumParser: ethereumTestnetParser(),
})
defer closeAndDestroyRocksDB(t, d)

// ordered by address descriptor: 0x20… < 0x4b… < 0x55…
addresses := []string{"0x" + dbtestdata.EthAddr20, "0x" + dbtestdata.EthAddr4b, "0x" + dbtestdata.EthAddr55}
for i, a := range addresses {
if err := d.StoreContractInfo(&bchain.ContractInfo{
Standard: bchain.ERC20TokenStandard,
Type: bchain.ERC20TokenStandard,
Contract: a,
Name: "Contract " + strconv.Itoa(i),
Decimals: 18,
}); err != nil {
t.Fatal(err)
}
}

contracts, next, err := d.ListContractInfos("", 10)
if err != nil {
t.Fatal(err)
}
if len(contracts) != 3 || next != "" {
t.Fatalf("ListContractInfos() = %d rows, next %q, want 3 rows and no next", len(contracts), next)
}
for i, c := range contracts {
if !strings.EqualFold(c.Contract, addresses[i]) {
t.Errorf("row %d = %s, want %s", i, c.Contract, addresses[i])
}
}

// paging: a full first page and a next cursor pointing at the third row
contracts, next, err = d.ListContractInfos("", 2)
if err != nil {
t.Fatal(err)
}
if len(contracts) != 2 || !strings.EqualFold(next, addresses[2]) {
t.Fatalf("ListContractInfos(limit 2) = %d rows, next %q, want 2 rows and next %s", len(contracts), next, addresses[2])
}
contracts, next, err = d.ListContractInfos(next, 2)
if err != nil {
t.Fatal(err)
}
if len(contracts) != 1 || next != "" || !strings.EqualFold(contracts[0].Contract, addresses[2]) {
t.Fatalf("ListContractInfos(from next) = %+v next %q, want only the third row", contracts, next)
}

if _, _, err = d.ListContractInfos("not-an-address", 2); err == nil {
t.Error("ListContractInfos() with invalid from: expected error")
}
}

func TestRocksDB_DeleteContractInfoForAddress(t *testing.T) {
d := setupRocksDB(t, &testEthereumParser{
EthereumParser: ethereumTestnetParser(),
})
defer closeAndDestroyRocksDB(t, d)

address := "0x" + dbtestdata.EthAddr20
ci := &bchain.ContractInfo{
Standard: bchain.ERC20TokenStandard,
Type: bchain.ERC20TokenStandard,
Contract: address,
Name: "Test contract",
Symbol: "TCT",
Decimals: 18,
CreatedInBlock: 1234567,
}
if err := d.StoreContractInfo(ci); err != nil {
t.Fatal(err)
}
// The get populates the in-memory cache, so a successful delete below also
// proves the cache entry is purged along with the DB row.
got, err := d.GetContractInfoForAddress(address)
if err != nil {
t.Fatal(err)
}
if got == nil || got.Name != ci.Name {
t.Fatalf("GetContractInfoForAddress() = %+v, want stored contract", got)
}

genBefore := d.protocolGen.Load()
purged, err := d.DeleteContractInfoForAddress(address)
if err != nil {
t.Fatal(err)
}
if purged == nil || purged.Name != ci.Name || purged.CreatedInBlock != ci.CreatedInBlock {
t.Errorf("DeleteContractInfoForAddress() = %+v, want the stored record", purged)
}
// The generation bump protects against a concurrent GetContractInfo
// re-inserting the deleted row into the cache (see SetErcProtocol).
if d.protocolGen.Load() != genBefore+1 {
t.Error("DeleteContractInfoForAddress() did not bump protocolGen")
}
got, err = d.GetContractInfoForAddress(address)
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Errorf("GetContractInfoForAddress() after delete = %+v, want nil", got)
}

// Idempotent: deleting a missing row is not an error and does not bump
// the generation (nothing a concurrent reader could re-insert).
genBefore = d.protocolGen.Load()
purged, err = d.DeleteContractInfoForAddress(address)
if err != nil {
t.Fatal(err)
}
if purged != nil {
t.Errorf("DeleteContractInfoForAddress() = %+v, want nil for a missing row", purged)
}
if d.protocolGen.Load() != genBefore {
t.Error("DeleteContractInfoForAddress() of a missing row must not bump protocolGen")
}

if _, err = d.DeleteContractInfoForAddress("not-an-address"); err == nil {
t.Error("DeleteContractInfoForAddress() with invalid address: expected error")
}
}

// packContractInfo only carries the sync-owned core fields. ERC4626 detection
// data lives in the cfErcProtocols column family and is exercised
// separately in rocksdb_protocols_test.go.
Expand Down
56 changes: 56 additions & 0 deletions db/rocksdb_runtime_settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package db

import (
"encoding/json"
"time"

"github.com/juju/errors"
)

// runtimeSettingKeyPrefix prefixes cfDefault keys holding runtime setting
// overrides written through the internal /admin interface. Each setting is
// stored under its own key, so a write or delete touches nothing else in the
// database (in particular it is independent of the periodically stored
// internalState blob) and the rows are ignored by older blockbook versions.
const runtimeSettingKeyPrefix = "runtimeSetting:"

// runtimeSettingEnvelope wraps a stored runtime setting value. The envelope
// keeps a stored empty value distinguishable from a missing row and records
// when the value was last changed.
type runtimeSettingEnvelope struct {
Value string `json:"value"`
Updated time.Time `json:"updated"`
}

// GetRuntimeSetting returns the stored override of the given runtime setting
// and whether an override exists.
func (d *RocksDB) GetRuntimeSetting(name string) (string, bool, error) {
val, err := d.db.GetCF(d.ro, d.cfh[cfDefault], []byte(runtimeSettingKeyPrefix+name))
if err != nil {
return "", false, err
}
defer val.Free()
data := val.Data()
if len(data) == 0 {
return "", false, nil
}
var e runtimeSettingEnvelope
if err := json.Unmarshal(data, &e); err != nil {
return "", false, errors.Annotatef(err, "cannot unpack runtime setting %s", name)
}
return e.Value, true, nil
}

// StoreRuntimeSetting persists an override of the given runtime setting.
func (d *RocksDB) StoreRuntimeSetting(name, value string) error {
buf, err := json.Marshal(&runtimeSettingEnvelope{Value: value, Updated: time.Now()})
if err != nil {
return err
}
return d.db.PutCF(d.wo, d.cfh[cfDefault], []byte(runtimeSettingKeyPrefix+name), buf)
}

// DeleteRuntimeSetting removes the override of the given runtime setting.
func (d *RocksDB) DeleteRuntimeSetting(name string) error {
return d.db.DeleteCF(d.wo, d.cfh[cfDefault], []byte(runtimeSettingKeyPrefix+name))
}
Loading
Loading