Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,28 @@ config file:
use the zero address `"0000000000000000"` to disable support for proxy
accounts.

* The optional `fee_receivers` key 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 concurrent fee collection upgrade), and without them configured,
fee deposits to those accounts would be misclassified as ordinary
transfers, e.g.

```json
{
"fee_receivers": [
"e1ac6b2740d204c2",
"05cbd2fa5128041d",
"139fb7c9c82c0e7c"
]
}
```

* The canonical list is returned by `FlowFees.getFeeReceiverAddresses()` on
chain. On startup, the server validates the configured addresses against
that list and exits with a fatal error if any on-chain receiver is
missing from the config.

* `data_dir: string`

* This defines the path to the data directory where the server stores data,
Expand Down
14 changes: 5 additions & 9 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -89,6 +89,7 @@ type Server struct {
scriptCreateProxyAccount []byte
scriptGetBalances []byte
scriptGetBalancesBasic []byte
scriptGetFeeReceivers []byte
scriptGetProxyNonce []byte
scriptGetProxyPublicKey []byte
scriptProxyTransfer []byte
Expand All @@ -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)
Comment on lines +108 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make successful fee-receiver validation a readiness requirement.

Run starts validation in a goroutine and then starts the HTTP server. If configuration omits an active receiver, ConstructionPreprocess can accept a construction request for a transfer to that receiver before validation completes. A malformed result or exhausted retries also ends validation with only a log entry, so the service can continue without successful validation.

  • api/api.go#L108-L109: wait for successful validation before serving, or keep the service unready and reject construction requests until validation succeeds.
  • api/validate.go#L45-L56: propagate malformed-result failures to the startup or readiness gate.
  • api/validate.go#L76-L76: propagate retry exhaustion to the startup or readiness gate.
  • README.md#L377-L380: document the finalized readiness behavior.
📍 Affects 3 files
  • api/api.go#L108-L109 (this comment)
  • api/validate.go#L45-L56
  • api/validate.go#L76-L76
  • README.md#L377-L380
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/api.go` around lines 108 - 109, Make successful fee-receiver validation a
prerequisite for readiness: update api/api.go lines 108-109 so Run either waits
for validateFeeReceivers before serving or keeps the service unready and rejects
construction requests until it succeeds. In api/validate.go lines 45-56 and 76,
propagate malformed validation results and retry exhaustion to that
startup/readiness gate instead of only logging them. Document the finalized
readiness behavior in README.md lines 377-380.

s.genesis = s.Index.Genesis()
s.networks = []*types.NetworkIdentifier{{
Blockchain: "flow",
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions api/construction_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
70 changes: 70 additions & 0 deletions api/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,81 @@ 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
}
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)
}
// Pick a client on each attempt so a retry can land on a different
// access node if the previously selected one is unavailable.
client := s.DataAccessNodes.Client()
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.
Expand Down
27 changes: 27 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,33 @@ 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 returned by FlowFees.getFeeReceiverAddresses() on
// chain). 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
Expand Down
41 changes: 41 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
12 changes: 6 additions & 6 deletions go.mod
Comment thread
janezpodhostnik marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ require (
github.com/golang/protobuf v1.5.4
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0
github.com/libp2p/go-libp2p v0.38.2
github.com/onflow/cadence v1.10.3
github.com/onflow/cadence v1.10.5
github.com/onflow/crypto v0.25.4
github.com/onflow/flow-go v0.48.1-evm-cache-block.0.20260518173711-5b9fa9c8352e
github.com/onflow/flow-go v0.50.1-0.20260804214725-b73fea20b252
github.com/onflow/flow/protobuf/go/flow v0.4.20
github.com/rs/zerolog v1.29.0
github.com/stretchr/testify v1.11.1
Expand Down Expand Up @@ -267,11 +267,11 @@ require (
github.com/multiformats/go-multistream v0.6.0 // indirect
github.com/multiformats/go-varint v0.0.7 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/onflow/atree v0.16.0 // indirect
github.com/onflow/flow-core-contracts/lib/go/contracts v1.10.2 // indirect; v1.2.4-0.20230703193002-53362441b57d // indirect
github.com/onflow/flow-core-contracts/lib/go/templates v1.10.2 // indirect; v1.2.3 // indirect
github.com/onflow/atree v0.16.1 // indirect
github.com/onflow/flow-core-contracts/lib/go/contracts v1.10.4 // indirect; v1.2.4-0.20230703193002-53362441b57d // indirect
github.com/onflow/flow-core-contracts/lib/go/templates v1.10.4 // indirect; v1.2.3 // indirect
github.com/onflow/flow-ft/lib/go/contracts v1.1.1 // indirect
github.com/onflow/flow-go-sdk v1.10.3 // indirect
github.com/onflow/flow-go-sdk v1.10.5 // indirect
github.com/onflow/flow-nft/lib/go/contracts v1.4.1 // indirect
github.com/onflow/go-ethereum v1.16.2 // indirect
github.com/onflow/sdks v0.6.0-preview.1 // indirect
Expand Down
24 changes: 12 additions & 12 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -732,30 +732,30 @@ github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JX
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onflow/atree v0.16.0 h1:b+f/suzcnnr1Lx1KdJEjpn2CX+AKSAz1yIB30NQDutU=
github.com/onflow/atree v0.16.0/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI=
github.com/onflow/atree v0.16.1 h1:EmlaIz/GwQ39o5agAb2KT2ynt4SHRBkgMMWU5bp6iTs=
github.com/onflow/atree v0.16.1/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI=
github.com/onflow/boxo v0.0.0-20240201202436-f2477b92f483 h1:LpiQhTAfM9CAmNVEs0n//cBBgCg+vJSiIxTHYUklZ84=
github.com/onflow/boxo v0.0.0-20240201202436-f2477b92f483/go.mod h1:pIZgTWdm3k3pLF9Uq6MB8JEcW07UDwNJjlXW1HELW80=
github.com/onflow/cadence v1.10.3 h1:PJIIYKbaOT2DcZBnSO4O8ZF/Xc/fKV9vvOFLChgy85c=
github.com/onflow/cadence v1.10.3/go.mod h1:tyUNaYlAgeQVgfR2C38MI1dtFFjKay+yGGPMrCRc068=
github.com/onflow/cadence v1.10.5 h1:Y5kk4aY70SpxJtG/Wd05+xvkUL6tvodeQjSxnXNq65A=
github.com/onflow/cadence v1.10.5/go.mod h1:axaADpRs+qTlq5cdHBawCiJ7dgqusRbBqOPkyWUwUOo=
github.com/onflow/crypto v0.25.4 h1:R615PWPdSoA5RATNb/j3cYaloBIZlSXVNgS7BjwHiwM=
github.com/onflow/crypto v0.25.4/go.mod h1:DlkW/1SPUvLHYvUcjWa9PkLIRgSBKR4EDc3i+ATQKW4=
github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90=
github.com/onflow/fixed-point v0.1.1/go.mod h1:gJdoHqKtToKdOZbvryJvDZfcpzC7d2fyWuo3ZmLtcGY=
github.com/onflow/flow-core-contracts/lib/go/contracts v1.10.2 h1:OQr7LyoAzk9kVfVjPcHXoFtWIBSqbB7ksNb6wIJlFE8=
github.com/onflow/flow-core-contracts/lib/go/contracts v1.10.2/go.mod h1:fn0eOOINlOdQSOWptENC92MpPorB7dHzZaC3VTAmiQY=
github.com/onflow/flow-core-contracts/lib/go/templates v1.10.2 h1:qq16IwoT+xAh45GfmC9lR+gFCixJWx9NxKgkkP/W4Nw=
github.com/onflow/flow-core-contracts/lib/go/templates v1.10.2/go.mod h1:bXe+VkZmvM3QYGjfizprStRfasLA/7ii7l6+LHP5V1U=
github.com/onflow/flow-core-contracts/lib/go/contracts v1.10.4 h1:tUmbvKlApxfLfguNj9UCvgIB9kxiYqdPbK/IGdKzINQ=
github.com/onflow/flow-core-contracts/lib/go/contracts v1.10.4/go.mod h1:fn0eOOINlOdQSOWptENC92MpPorB7dHzZaC3VTAmiQY=
github.com/onflow/flow-core-contracts/lib/go/templates v1.10.4 h1:CifICeJM0FpOVzmGM328VT7OSJOQ7GRioZTRmSBrMgQ=
github.com/onflow/flow-core-contracts/lib/go/templates v1.10.4/go.mod h1:bXe+VkZmvM3QYGjfizprStRfasLA/7ii7l6+LHP5V1U=
github.com/onflow/flow-evm-bridge v0.2.1 h1:S32kk+UV7/COdQZakIMsJw6vxShel0s8lI3FyGFl9jM=
github.com/onflow/flow-evm-bridge v0.2.1/go.mod h1:ExhTZax2F+boo13dzT/uAI7rvwewAoz9v+dEXhhFjYg=
github.com/onflow/flow-ft/lib/go/contracts v1.1.1 h1:BNbP3CrTIgScpx2NS9snq9XDESFjgXrMXTrwk5H4iSs=
github.com/onflow/flow-ft/lib/go/contracts v1.1.1/go.mod h1:PwsL8fC81cjnUnTfmyL/HOIyHnyaw/JA474Wfj2tl6A=
github.com/onflow/flow-ft/lib/go/templates v1.1.1 h1:X+EGTWKeVlsF33JD5QBFZLr8KW2apl6Oh1AXRWHmzLI=
github.com/onflow/flow-ft/lib/go/templates v1.1.1/go.mod h1:uQ8XFqmMK2jxyBSVrmyuwdWjTEb+6zGjRYotfDJ5pAE=
github.com/onflow/flow-go v0.48.1-evm-cache-block.0.20260518173711-5b9fa9c8352e h1:n8yp4pz72O0CqHwzCmTgpeYyxwOVf8Vth1jEn2/4wIk=
github.com/onflow/flow-go v0.48.1-evm-cache-block.0.20260518173711-5b9fa9c8352e/go.mod h1:x+/B1Ki53/TbRtdd317T6wmQ2QRMF4YKhkSE+3fy76A=
github.com/onflow/flow-go-sdk v1.10.3 h1:4zJYkdDNqeQqUJmdQJXlHIZuEjOLp8lsu8dRz5GZ/Cc=
github.com/onflow/flow-go-sdk v1.10.3/go.mod h1:cnpuCUvKLGqVrhz6yPEv0+LdsT9ib+cbn0YxfAJxHEI=
github.com/onflow/flow-go v0.50.1-0.20260804214725-b73fea20b252 h1:XUvRo0Zt8GQtgSHKzcQ/EpCm1HdSthNEgcNci2wJzMU=
github.com/onflow/flow-go v0.50.1-0.20260804214725-b73fea20b252/go.mod h1:NPiMixDFGz4/IQjdeV4phjZfbfQgxtmNJ9zU9DWkcYg=
github.com/onflow/flow-go-sdk v1.10.5 h1:aE9E2xXW2AiR/7KzZApZ8HpmEzsLdNQWso5y/RHADsQ=
github.com/onflow/flow-go-sdk v1.10.5/go.mod h1:efpOBjGw/Gmdu2yKcAjVsAARUekyHL06QftCXXQebM8=
github.com/onflow/flow-nft/lib/go/contracts v1.4.1 h1:iQ8s4W5HNWd92MVRZbKxYpQ6UJn9snHLKQ9hFFNCiys=
github.com/onflow/flow-nft/lib/go/contracts v1.4.1/go.mod h1:XUsJjlbVoI0kebgv87xsO70U/ITGYbSEgTwbyg1RcOs=
github.com/onflow/flow-nft/lib/go/templates v1.4.1 h1:P+FN51waQrACpyVeXzLl1cnlD5J8bUYiemHXgeZBM+8=
Expand Down
9 changes: 9 additions & 0 deletions script/cadence/scripts/get-fee-receivers.cdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import FlowFees from 0x{{.Contracts.FlowFees}}

// Returns the addresses of all accounts that may receive transaction fee
// deposits: the FlowFees contract account itself, plus any child fee accounts
// that FlowFees.deductTransactionFee rotates deposits across (see
// onflow/flow-core-contracts#575, "Enable concurrent fee collection").
access(all) fun main(): [Address] {
return FlowFees.getFeeReceiverAddresses()
}
8 changes: 8 additions & 0 deletions script/script.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 24 additions & 1 deletion script/script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,3 +24,24 @@ 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{
"import FlowFees from 0x912d5440f7e3769e",
"return FlowFees.getFeeReceiverAddresses()",
} {
if !strings.Contains(result, expected) {
t.Errorf("Expected compiled script to contain %q:\n%s", expected, result)
}
}
}
Loading
Loading