From b9af72e4921dc18f06ab1b743613118957e0802f Mon Sep 17 00:00:00 2001 From: Janez Podhostnik Date: Thu, 23 Jul 2026 15:56:45 +0200 Subject: [PATCH] support multiple fee receiver accounts --- api/api.go | 14 ++-- api/construction_service.go | 8 +-- api/validate.go | 68 ++++++++++++++++++++ config/config.go | 26 ++++++++ config/config_test.go | 41 ++++++++++++ script/cadence/scripts/get-fee-receivers.cdc | 20 ++++++ script/script.go | 8 +++ script/script_test.go | 26 +++++++- state/process.go | 2 +- state/state.go | 10 +-- testnet.json | 5 ++ 11 files changed, 205 insertions(+), 23 deletions(-) create mode 100644 config/config_test.go create mode 100644 script/cadence/scripts/get-fee-receivers.cdc diff --git a/api/api.go b/api/api.go index 1039e48..d49f4b2 100644 --- a/api/api.go +++ b/api/api.go @@ -78,7 +78,7 @@ type Server struct { Indexer *state.Indexer Offline bool Port uint16 - feeAddr []byte + feeAddrs map[string]bool genesis *model.BlockMeta indexedStateErr *types.Error mu sync.RWMutex // protects indexedStateErr @@ -89,6 +89,7 @@ type Server struct { scriptCreateProxyAccount []byte scriptGetBalances []byte scriptGetBalancesBasic []byte + scriptGetFeeReceivers []byte scriptGetProxyNonce []byte scriptGetProxyPublicKey []byte scriptProxyTransfer []byte @@ -104,14 +105,8 @@ func (s *Server) Run(ctx context.Context) { status: "not_started", } go s.validateBalances(ctx) - feeAddr, err := hex.DecodeString(s.Chain.Contracts.FlowFees) - if err != nil { - log.Fatalf( - "Invalid FlowFees contract address %q: %s", - s.Chain.Contracts.FlowFees, err, - ) - } - s.feeAddr = feeAddr + s.feeAddrs = s.Chain.Contracts.FeeAddresses() + go s.validateFeeReceivers(ctx) s.genesis = s.Index.Genesis() s.networks = []*types.NetworkIdentifier{{ Blockchain: "flow", @@ -166,6 +161,7 @@ func (s *Server) compileScripts() { s.scriptCreateProxyAccount = script.Compile("create_proxy_account", script.CreateProxyAccount, s.Chain) s.scriptGetBalances = script.Compile("get_balances", script.GetBalances, s.Chain) s.scriptGetBalancesBasic = script.Compile("get_balances_basic", script.GetBalancesBasic, s.Chain) + s.scriptGetFeeReceivers = script.Compile("get_fee_receivers", script.GetFeeReceivers, s.Chain) s.scriptGetProxyNonce = script.Compile("get_proxy_nonce", script.GetProxyNonce, s.Chain) s.scriptGetProxyPublicKey = script.Compile("get_proxy_public_key", script.GetProxyPublicKey, s.Chain) s.scriptProxyTransfer = script.Compile("proxy_transfer", script.ProxyTransfer, s.Chain) diff --git a/api/construction_service.go b/api/construction_service.go index b5c7217..f6bc3c6 100644 --- a/api/construction_service.go +++ b/api/construction_service.go @@ -523,13 +523,13 @@ func (s *Server) ConstructionPreprocess(ctx context.Context, r *types.Constructi if xerr != nil { return nil, xerr } - // NOTE(tav): We explicitly error on transfers to the fee address so as to + // NOTE(tav): We explicitly error on transfers to a fee address so as to // simplify our event processing logic. - if bytes.Equal(intent.receiver, s.feeAddr) { + if s.feeAddrs[string(intent.receiver)] { return nil, wrapErrorf( errInvalidOpsIntent, - "cannot make transfers to the fee address: 0x%s", - s.Chain.Contracts.FlowFees, + "cannot make transfers to the fee address: 0x%x", + intent.receiver, ) } opts := &model.ConstructOpts{ diff --git a/api/validate.go b/api/validate.go index e7e7165..9a0a463 100644 --- a/api/validate.go +++ b/api/validate.go @@ -3,11 +3,79 @@ package api import ( "context" "os" + "strings" "time" + "github.com/onflow/cadence" "github.com/onflow/rosetta/log" ) +// validateFeeReceivers checks the configured fee addresses (the FlowFees +// contract account plus .contracts.fee_receivers) against the fee receiver +// accounts the FlowFees contract rotates deposits across on chain. If an +// on-chain receiver is missing from the config, fee deposits to it would be +// misclassified as ordinary transfers, so we exit with a fatal error. +// Configured addresses that are no longer on chain are fine — they may be +// needed to classify fees in historical blocks. +func (s *Server) validateFeeReceivers(ctx context.Context) { + if s.Offline { + return + } + client := s.DataAccessNodes.Client() + const attempts = 5 + for attempt := 1; attempt <= attempts; attempt++ { + select { + case <-ctx.Done(): + return + default: + } + if attempt > 1 { + time.Sleep(time.Duration(attempt) * time.Second) + } + latest, err := client.LatestBlockHeader(ctx) + if err != nil { + log.Errorf("Failed to get the latest block header to validate fee receivers: %s", err) + continue + } + resp, err := client.Execute(ctx, latest.Id, s.scriptGetFeeReceivers, nil) + if err != nil { + log.Errorf("Failed to execute the get_fee_receivers script: %s", err) + continue + } + arr, ok := resp.(cadence.Array) + if !ok { + log.Errorf("Failed to convert get_fee_receivers result to an array: got %T", resp) + return + } + onchain := []string{} + missing := []string{} + for _, val := range arr.Values { + addr, ok := val.(cadence.Address) + if !ok { + log.Errorf("Failed to convert get_fee_receivers element to an address: got %T", val) + return + } + onchain = append(onchain, addr.String()) + if !s.feeAddrs[string(addr.Bytes())] { + missing = append(missing, addr.String()) + } + } + if len(missing) > 0 { + log.Fatalf( + "On-chain fee receiver account(s) %s are missing from the configured fee addresses: "+ + "fee deposits to them would be misclassified as transfers; add them to .contracts.fee_receivers", + strings.Join(missing, ", "), + ) + } + log.Infof( + "Validated the configured fee addresses against the on-chain fee receivers: %s", + strings.Join(onchain, ", "), + ) + return + } + log.Errorf("Giving up on fee receiver validation after %d attempts", attempts) +} + // NOTE(tav): We exit with a fatal error if the on-chain state doesn't match // what we expect. This assumes that we can trust the data returned to us by the // Access API servers, which may not necessarily be true. diff --git a/config/config.go b/config/config.go index 19e078a..538363b 100644 --- a/config/config.go +++ b/config/config.go @@ -84,6 +84,32 @@ type Contracts struct { FlowToken string `json:"flow_token"` FungibleToken string `json:"fungible_token"` FlowColdStorageProxy string `json:"flow_cold_storage_proxy"` + // FeeReceivers lists accounts, in addition to the FlowFees contract + // account, that receive transaction fee deposits. Networks may distribute + // fees across several receiver accounts: testnet does so since the + // FlowFees upgrade in transaction + // be210889dd26a320f530595bd369093e866e26c3941bf7a3d01f861db3eeda81 (the + // canonical list is flow-go's systemcontracts.FlowFeesReceivers). Without + // them, fee deposits are misclassified as ordinary transfers. + FeeReceivers []string `json:"fee_receivers"` +} + +// FeeAddresses returns the set of accounts whose FLOW deposits represent +// transaction fees: the FlowFees contract account plus any configured +// fee_receivers. The map is keyed by the raw 8-byte address string. +func (c *Contracts) FeeAddresses() map[string]bool { + addrs := map[string]bool{} + for _, src := range append([]string{c.FlowFees}, c.FeeReceivers...) { + addr, err := hex.DecodeString(src) + if err != nil { + log.Fatalf("Invalid fee address %q: %s", src, err) + } + if len(addr) != 8 { + log.Fatalf("Invalid fee address %q: expected 8 bytes, got %d", src, len(addr)) + } + addrs[string(addr)] = true + } + return addrs } // Consensus defines the metadata needed to initialize a consensus follower for diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..dce6fc9 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,41 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFeeAddresses(t *testing.T) { + t.Run("defaults to the FlowFees contract account", func(t *testing.T) { + contracts := &Contracts{FlowFees: "912d5440f7e3769e"} + require.Equal(t, map[string]bool{ + "\x91\x2d\x54\x40\xf7\xe3\x76\x9e": true, + }, contracts.FeeAddresses()) + }) + + t.Run("includes configured fee receivers", func(t *testing.T) { + contracts := &Contracts{ + FlowFees: "912d5440f7e3769e", + FeeReceivers: []string{ + "e1ac6b2740d204c2", + "05cbd2fa5128041d", + "139fb7c9c82c0e7c", + }, + } + require.Equal(t, map[string]bool{ + "\x91\x2d\x54\x40\xf7\xe3\x76\x9e": true, + "\xe1\xac\x6b\x27\x40\xd2\x04\xc2": true, + "\x05\xcb\xd2\xfa\x51\x28\x04\x1d": true, + "\x13\x9f\xb7\xc9\xc8\x2c\x0e\x7c": true, + }, contracts.FeeAddresses()) + }) + + t.Run("deduplicates a receiver equal to the FlowFees account", func(t *testing.T) { + contracts := &Contracts{ + FlowFees: "912d5440f7e3769e", + FeeReceivers: []string{"912d5440f7e3769e"}, + } + require.Len(t, contracts.FeeAddresses(), 1) + }) +} diff --git a/script/cadence/scripts/get-fee-receivers.cdc b/script/cadence/scripts/get-fee-receivers.cdc new file mode 100644 index 0000000..bf1fbd1 --- /dev/null +++ b/script/cadence/scripts/get-fee-receivers.cdc @@ -0,0 +1,20 @@ +// Returns the addresses of all accounts that may receive transaction fee +// deposits: the FlowFees contract account itself, plus any child fee accounts +// that FlowFees.collectFeesOnChildAccounts rotates deposits across (see +// onflow/flow-core-contracts#575, "Enable concurrent fee collection"). +// +// The FlowFees contract exposes no getter for these addresses, so this script +// reads the capability list the contract keeps at /storage/ChildFeeAccounts. +// The borrowed type must spell the capability's entitlements exactly as the +// contract issues them; if a contract upgrade changes them, the borrow +// returns nil and this script degrades to just the FlowFees account. +access(all) fun main(): [Address] { + let acct = getAuthAccount(0x{{.Contracts.FlowFees}}) + let addresses: [Address] = [0x{{.Contracts.FlowFees}}] + if let childFeeAccounts = acct.storage.borrow<&[Capability]>(from: /storage/ChildFeeAccounts) { + for cap in childFeeAccounts { + addresses.append(cap.address) + } + } + return addresses +} diff --git a/script/script.go b/script/script.go index be47b2d..6d7e00e 100644 --- a/script/script.go +++ b/script/script.go @@ -56,6 +56,14 @@ var GetBalances string //go:embed cadence/scripts/get-balances-basic.cdc var GetBalancesBasic string +// GetFeeReceivers defines the template for the read-only transaction script +// that returns the addresses of all accounts that may receive transaction fee +// deposits: the FlowFees contract account plus any child fee accounts +// configured on chain. +// +//go:embed cadence/scripts/get-fee-receivers.cdc +var GetFeeReceivers string + // GetProxyNonce defines the template for the read-only transaction script that // returns a proxy account's sequence number, i.e. the next nonce value for its // FlowColdStorageProxy Vault. diff --git a/script/script_test.go b/script/script_test.go index 0557ce4..77f2e2d 100644 --- a/script/script_test.go +++ b/script/script_test.go @@ -2,8 +2,10 @@ package script import ( "context" - "github.com/onflow/rosetta/config" + "strings" "testing" + + "github.com/onflow/rosetta/config" ) // TestCompile tests the Compile function @@ -22,3 +24,25 @@ func TestCompileComputeFees(t *testing.T) { t.Errorf("Expected %q but got %q", expected, string(result)) } } + +// TestCompileGetFeeReceivers tests that the FlowFees address is rendered into +// the get-fee-receivers script. +// +// NOTE: config.Init cannot be called a second time within the same test +// binary (it locks the Badger cache database), so the chain is constructed +// directly. +func TestCompileGetFeeReceivers(t *testing.T) { + chain := &config.Chain{Contracts: &config.Contracts{FlowFees: "912d5440f7e3769e"}} + + result := string(Compile("get_fee_receivers", GetFeeReceivers, chain)) + + for _, expected := range []string{ + "getAuthAccount(0x912d5440f7e3769e)", + "let addresses: [Address] = [0x912d5440f7e3769e]", + "from: /storage/ChildFeeAccounts", + } { + if !strings.Contains(result, expected) { + t.Errorf("Expected compiled script to contain %q:\n%s", expected, result) + } + } +} diff --git a/state/process.go b/state/process.go index da8111c..e48afdb 100644 --- a/state/process.go +++ b/state/process.go @@ -803,7 +803,7 @@ outer: Receiver: receiver[:], Type: model.TransferType_DEPOSIT, }) - if bytes.Equal(receiver[:], i.feeAddr) { + if i.feeAddrs[string(receiver[:])] { // NOTE(tav): When the deposit is to the fee // address, just increment the fee amount. fees += amount diff --git a/state/state.go b/state/state.go index 6019aa5..9e9e92d 100644 --- a/state/state.go +++ b/state/state.go @@ -55,7 +55,7 @@ type Indexer struct { Store *indexdb.Store accts map[string]bool consensus storage.DB - feeAddr []byte + feeAddrs map[string]bool jobs chan uint64 lastIndexed *model.BlockMeta liveRoot *model.BlockMeta @@ -541,13 +541,7 @@ func (i *Indexer) initState() { for acct, isProxy := range accts { i.accts[string(acct[:])] = isProxy } - i.feeAddr, err = hex.DecodeString(i.Chain.Contracts.FlowFees) - if err != nil { - log.Fatalf( - "Invalid FlowFees contract address %q: %s", - i.Chain.Contracts.FlowFees, err, - ) - } + i.feeAddrs = i.Chain.Contracts.FeeAddresses() i.originators = map[string]bool{} for _, addr := range i.Chain.Originators { i.originators[string(addr)] = true diff --git a/testnet.json b/testnet.json index 73d7103..0a63ff7 100644 --- a/testnet.json +++ b/testnet.json @@ -6,6 +6,11 @@ } ], "contracts": { + "fee_receivers": [ + "e1ac6b2740d204c2", + "05cbd2fa5128041d", + "139fb7c9c82c0e7c" + ], "flow_cold_storage_proxy": "0000000000000000", "flow_fees": "912d5440f7e3769e", "flow_token": "7e60df042a9c0868",