Skip to content

Commit a708258

Browse files
track fee receivers on chain and validate them at the indexed tip
1 parent 3ea0b77 commit a708258

11 files changed

Lines changed: 580 additions & 35 deletions

File tree

README.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -376,11 +376,32 @@ config file:
376376

377377
* The canonical list is returned by `FlowFees.getFeeReceiverAddresses()` on
378378
chain. The server validates the configured addresses against that list in
379-
the background: retrying until an access node responds, and then
380-
re-checking periodically so receivers added on chain while the server is
381-
running are still detected. If any on-chain receiver is missing from the
382-
config, the server logs an error and reports the mismatch via the
383-
`fee_receiver_validation_status` method of the `/call` endpoint.
379+
the background: the script runs at the latest indexed block (the genesis
380+
block if nothing has been indexed yet), retrying until an access node
381+
responds, and then re-checking periodically so receivers added on chain
382+
while the server is running are still detected. On networks whose FlowFees
383+
contract predates the concurrent fee collection upgrade (and therefore
384+
doesn't define `getFeeReceiverAddresses`), the FlowFees contract account
385+
is treated as the only receiver, and the server keeps polling so that a
386+
later upgrade is detected. If any on-chain receiver is missing from the
387+
fee addresses used for classification, the server logs an error and
388+
reports the mismatch via the `fee_receiver_validation_status` method of
389+
the `/call` endpoint.
390+
391+
* The indexer also watches for `FlowFees.ChildFeeAccountsChanged` events and
392+
stores them in the index database. The most recent such event overrides
393+
the configured fee addresses from the block containing it onward, so receivers
394+
added on chain are picked up automatically — no config update or restart
395+
needed. (The configured addresses remain the base for chains where the
396+
child fee accounts were registered without emitting the event, e.g.
397+
testnet.)
398+
399+
* Note: if your index database contains blocks indexed before the
400+
`fee_receivers` config was available (e.g. testnet blocks at or after
401+
height 309507846 indexed with an older version), fee deposits to the child
402+
fee accounts in those blocks will have been misclassified as ordinary
403+
transfers. Use `resync_from` to reindex from before the on-chain upgrade
404+
if you need those blocks classified correctly.
384405

385406
* `data_dir: string`
386407

api/api.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,48 @@ func (s *Server) setIndexedStateErr(format string, a ...interface{}) {
229229
}
230230
}
231231

232+
// currentFeeAddrs returns the set of fee addresses used to classify fee
233+
// deposits at the given indexed height: the configured fee addresses,
234+
// overridden by the most recent FlowFees.ChildFeeAccountsChanged event
235+
// indexed at or before that height, if any.
236+
func (s *Server) currentFeeAddrs(height uint64) map[string]bool {
237+
children, err := s.Index.FeeReceiversAt(height)
238+
if err != nil {
239+
log.Errorf(
240+
"Failed to get the indexed fee receivers at height %d, falling back to the configured fee addresses: %s",
241+
height, err,
242+
)
243+
return s.feeAddrs
244+
}
245+
if children == nil {
246+
return s.feeAddrs
247+
}
248+
return s.Chain.Contracts.FeeAddressesWith(children)
249+
}
250+
251+
// setFeeValidationFallback records a successful validation against a FlowFees
252+
// contract that predates the concurrent fee collection upgrade: the FlowFees
253+
// account is the only fee receiver until the contract is upgraded.
254+
func (s *Server) setFeeValidationFallback() {
255+
onchain := []string{s.Chain.Contracts.FlowFees}
256+
s.feeValidationMu.Lock()
257+
prev := s.feeValidation.status
258+
s.feeValidation = &feeValidation{
259+
onchain: onchain,
260+
status: validationSuccess,
261+
}
262+
s.feeValidationMu.Unlock()
263+
// We only log on transitions so that the periodic re-checks don't flood
264+
// the logs.
265+
if prev != validationSuccess {
266+
log.Infof(
267+
"The FlowFees contract does not define getFeeReceiverAddresses (pre concurrent fee collection); "+
268+
"falling back to the FlowFees account %s as the only fee receiver and continuing to poll for an upgrade",
269+
onchain[0],
270+
)
271+
}
272+
}
273+
232274
func (s *Server) getFeeValidationStatus() *feeValidation {
233275
s.feeValidationMu.RLock()
234276
defer s.feeValidationMu.RUnlock()

api/construction_service.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,11 @@ func (s *Server) ConstructionPreprocess(ctx context.Context, r *types.Constructi
525525
}
526526
// NOTE(tav): We explicitly error on transfers to a fee address so as to
527527
// simplify our event processing logic.
528-
if s.feeAddrs[string(intent.receiver)] {
528+
feeAddrs := s.feeAddrs
529+
if latest := s.Index.Latest(); latest != nil {
530+
feeAddrs = s.currentFeeAddrs(latest.Height)
531+
}
532+
if feeAddrs[string(intent.receiver)] {
529533
return nil, wrapErrorf(
530534
errInvalidOpsIntent,
531535
"cannot make transfers to the fee address: 0x%x",

api/validate.go

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api
33
import (
44
"context"
55
"os"
6+
"strings"
67
"time"
78

89
"github.com/onflow/cadence"
@@ -15,10 +16,11 @@ const (
1516
feeValidateRecheckInterval = 10 * time.Minute // re-check interval after a definitive result
1617
)
1718

18-
// validateFeeReceivers runs a background loop that checks the configured fee
19-
// addresses (the FlowFees contract account plus .contracts.fee_receivers)
19+
// validateFeeReceivers runs a background loop that checks the fee addresses
20+
// used to classify fee deposits (the configured fee addresses, overridden by
21+
// the most recent indexed FlowFees.ChildFeeAccountsChanged event, if any)
2022
// against the fee receiver accounts the FlowFees contract rotates deposits
21-
// across on chain. If an on-chain receiver is missing from the config, fee
23+
// across on chain. If an on-chain receiver is missing from that set, fee
2224
// deposits to it would be misclassified as ordinary transfers, so we log an
2325
// error and surface the failure via the fee_receiver_validation_status /call
2426
// method. Configured addresses that are no longer on chain are fine — they
@@ -62,25 +64,50 @@ func sleepCtx(ctx context.Context, d time.Duration) bool {
6264
}
6365
}
6466

65-
// checkFeeReceivers makes a single attempt at validating the configured fee
66-
// addresses against the on-chain fee receivers, and records the outcome in
67-
// the server's fee validation state. It returns false if the attempt failed
68-
// and should be retried.
67+
// checkFeeReceivers makes a single attempt at validating the fee addresses
68+
// used for classification against the on-chain fee receivers, and records the
69+
// outcome in the server's fee validation state. It returns false if the
70+
// attempt failed and should be retried.
6971
func (s *Server) checkFeeReceivers(ctx context.Context) bool {
70-
// Pick a client on each attempt so a retry can land on a different
71-
// access node if the previously selected one is unavailable.
72-
client := s.DataAccessNodes.Client()
73-
latest, err := client.LatestBlockHeader(ctx)
74-
if err != nil {
72+
// We validate at the latest indexed block (the genesis block if nothing
73+
// has been indexed yet), rather than the latest block available on the
74+
// Access API: the fee addresses only matter for the blocks the indexer is
75+
// currently classifying, and this keeps the check consistent with the
76+
// indexed fee receiver overrides.
77+
latest := s.Index.Latest()
78+
if latest == nil {
79+
s.setFeeValidationRetrying(
80+
"Failed to validate fee receivers: no block has been indexed yet",
81+
)
82+
return false
83+
}
84+
// The latest indexed block may belong to a past spork while the indexer
85+
// is catching up, so we use the access nodes of the spork containing it —
86+
// and re-select a client on each attempt so a retry can land on a
87+
// different access node if the previously selected one is unavailable.
88+
spork := s.Chain.SporkFor(latest.Height)
89+
if spork == nil {
7590
s.setFeeValidationRetrying(
76-
"Failed to get the latest block header to validate fee receivers: %s", err,
91+
"Failed to validate fee receivers: the latest indexed block at height %d cannot be associated with any sporks in the config",
92+
latest.Height,
7793
)
7894
return false
7995
}
80-
resp, err := client.Execute(ctx, latest.Id, s.scriptGetFeeReceivers, nil)
96+
client := spork.AccessNodes.Client()
97+
resp, err := client.Execute(ctx, latest.Hash, s.scriptGetFeeReceivers, nil)
8198
if err != nil {
99+
if isMissingFeeReceiverFunc(err) {
100+
// The FlowFees contract predates the concurrent fee collection
101+
// upgrade (onflow/flow-core-contracts#575): the FlowFees account
102+
// is the only fee receiver until the contract is upgraded. We
103+
// record this as a successful validation and keep polling so
104+
// that a later upgrade is detected.
105+
s.setFeeValidationFallback()
106+
return true
107+
}
82108
s.setFeeValidationRetrying(
83-
"Failed to execute the get_fee_receivers script: %s", err,
109+
"Failed to execute the get_fee_receivers script at the latest indexed block %x (%d): %s",
110+
latest.Hash, latest.Height, err,
84111
)
85112
return false
86113
}
@@ -91,6 +118,7 @@ func (s *Server) checkFeeReceivers(ctx context.Context) bool {
91118
)
92119
return false
93120
}
121+
feeAddrs := s.currentFeeAddrs(latest.Height)
94122
onchain := []string{}
95123
missing := []string{}
96124
for _, val := range arr.Values {
@@ -102,7 +130,7 @@ func (s *Server) checkFeeReceivers(ctx context.Context) bool {
102130
return false
103131
}
104132
onchain = append(onchain, addr.String())
105-
if !s.feeAddrs[string(addr.Bytes())] {
133+
if !feeAddrs[string(addr.Bytes())] {
106134
missing = append(missing, addr.String())
107135
}
108136
}
@@ -114,6 +142,18 @@ func (s *Server) checkFeeReceivers(ctx context.Context) bool {
114142
return true
115143
}
116144

145+
// isMissingFeeReceiverFunc returns whether the script execution error
146+
// indicates that the FlowFees contract predates the concurrent fee collection
147+
// upgrade (onflow/flow-core-contracts#575), i.e. it does not define
148+
// getFeeReceiverAddresses. This is a deterministic script type-checking
149+
// failure, so retrying it would never succeed — unlike transient access node
150+
// errors.
151+
func isMissingFeeReceiverFunc(err error) bool {
152+
msg := err.Error()
153+
return strings.Contains(msg, "getFeeReceiverAddresses") &&
154+
(strings.Contains(msg, "has no member") || strings.Contains(msg, "cannot find"))
155+
}
156+
117157
// NOTE(tav): We exit with a fatal error if the on-chain state doesn't match
118158
// what we expect. This assumes that we can trust the data returned to us by the
119159
// Access API servers, which may not necessarily be true.

api/validate_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package api
22

33
import (
4+
"errors"
45
"sync"
56
"testing"
7+
8+
"github.com/onflow/rosetta/config"
9+
"github.com/onflow/rosetta/indexdb"
610
)
711

812
func TestValidationStatusString(t *testing.T) {
@@ -114,6 +118,86 @@ func TestFeeValidationConcurrentAccess(t *testing.T) {
114118
wg.Wait()
115119
}
116120

121+
func TestIsMissingFeeReceiverFunc(t *testing.T) {
122+
for name, tt := range map[string]struct {
123+
err error
124+
want bool
125+
}{
126+
"missing member": {
127+
err: errors.New("rpc error: code = InvalidArgument desc = failed to execute script: error: value of type `&FlowFees` has no member `getFeeReceiverAddresses`"),
128+
want: true,
129+
},
130+
"unavailable access node": {
131+
err: errors.New("rpc error: code = Unavailable desc = connection refused"),
132+
want: false,
133+
},
134+
"unrelated member error": {
135+
err: errors.New("error: value of type `&FlowToken` has no member `getBalance`"),
136+
want: false,
137+
},
138+
} {
139+
t.Run(name, func(t *testing.T) {
140+
if got := isMissingFeeReceiverFunc(tt.err); got != tt.want {
141+
t.Errorf("isMissingFeeReceiverFunc(%v) = %v, want %v", tt.err, got, tt.want)
142+
}
143+
})
144+
}
145+
}
146+
147+
func TestFeeValidationFallback(t *testing.T) {
148+
s := newFeeValidationServer()
149+
s.Chain = &config.Chain{
150+
Contracts: &config.Contracts{FlowFees: "912d5440f7e3769e"},
151+
}
152+
s.setFeeValidationFallback()
153+
v := s.getFeeValidationStatus()
154+
if v.status != validationSuccess {
155+
t.Fatalf("status = %s, want success", v.status)
156+
}
157+
if len(v.onchain) != 1 || v.onchain[0] != "912d5440f7e3769e" {
158+
t.Fatalf("onchain = %v, want [912d5440f7e3769e]", v.onchain)
159+
}
160+
}
161+
162+
func TestCurrentFeeAddrs(t *testing.T) {
163+
store := indexdb.New(t.TempDir())
164+
chain := &config.Chain{
165+
Contracts: &config.Contracts{
166+
FlowFees: "912d5440f7e3769e",
167+
FeeReceivers: []string{"e1ac6b2740d204c2"},
168+
},
169+
}
170+
s := &Server{
171+
Chain: chain,
172+
Index: store,
173+
feeAddrs: chain.Contracts.FeeAddresses(),
174+
}
175+
flowFees := []byte{0x91, 0x2d, 0x54, 0x40, 0xf7, 0xe3, 0x76, 0x9e}
176+
configured := []byte{0xe1, 0xac, 0x6b, 0x27, 0x40, 0xd2, 0x04, 0xc2}
177+
child := []byte{0x05, 0xcb, 0xd2, 0xfa, 0x51, 0x28, 0x04, 0x1d}
178+
179+
// Without any indexed event, the configured fee addresses apply.
180+
addrs := s.currentFeeAddrs(100)
181+
if !addrs[string(flowFees)] || !addrs[string(configured)] || addrs[string(child)] {
182+
t.Fatalf("currentFeeAddrs without event = %v, want the configured fee addresses", addrs)
183+
}
184+
185+
// An indexed event overrides the configured fee addresses.
186+
if err := store.SetFeeReceivers(50, [][]byte{child}); err != nil {
187+
t.Fatalf("SetFeeReceivers: %s", err)
188+
}
189+
addrs = s.currentFeeAddrs(100)
190+
if !addrs[string(flowFees)] || !addrs[string(child)] || addrs[string(configured)] {
191+
t.Fatalf("currentFeeAddrs with event = %v, want the FlowFees account and the event's child account", addrs)
192+
}
193+
194+
// Events after the given height do not apply.
195+
addrs = s.currentFeeAddrs(49)
196+
if !addrs[string(configured)] || addrs[string(child)] {
197+
t.Fatalf("currentFeeAddrs before the event = %v, want the configured fee addresses", addrs)
198+
}
199+
}
200+
117201
// TestFeeValidationFailureRecovery checks that a later successful check
118202
// replaces a previous mismatch, e.g. after the on-chain receiver list
119203
// changes.

config/config.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,24 @@ func (c *Contracts) FeeAddresses() map[string]bool {
113113
return addrs
114114
}
115115

116+
// FeeAddressesWith returns the set of accounts whose FLOW deposits represent
117+
// transaction fees after a FlowFees.ChildFeeAccountsChanged event carrying
118+
// the given child fee accounts: the FlowFees contract account plus the given
119+
// accounts. The map is keyed by the raw 8-byte address string.
120+
func (c *Contracts) FeeAddressesWith(children [][]byte) map[string]bool {
121+
addr, err := hex.DecodeString(c.FlowFees)
122+
if err != nil || len(addr) != 8 {
123+
log.Fatalf("Invalid FlowFees contract address %q", c.FlowFees)
124+
}
125+
addrs := map[string]bool{
126+
string(addr): true,
127+
}
128+
for _, child := range children {
129+
addrs[string(child)] = true
130+
}
131+
return addrs
132+
}
133+
116134
// Consensus defines the metadata needed to initialize a consensus follower for
117135
// a live spork.
118136
type Consensus struct {

config/config_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,25 @@ func TestFeeAddresses(t *testing.T) {
3939
require.Len(t, contracts.FeeAddresses(), 1)
4040
})
4141
}
42+
43+
func TestFeeAddressesWith(t *testing.T) {
44+
contracts := &Contracts{
45+
FlowFees: "912d5440f7e3769e",
46+
FeeReceivers: []string{"e1ac6b2740d204c2"},
47+
}
48+
49+
t.Run("returns the FlowFees account plus the given child accounts", func(t *testing.T) {
50+
require.Equal(t, map[string]bool{
51+
"\x91\x2d\x54\x40\xf7\xe3\x76\x9e": true,
52+
"\x05\xcb\xd2\xfa\x51\x28\x04\x1d": true,
53+
}, contracts.FeeAddressesWith([][]byte{
54+
{0x05, 0xcb, 0xd2, 0xfa, 0x51, 0x28, 0x04, 0x1d},
55+
}))
56+
})
57+
58+
t.Run("an empty list resets to just the FlowFees account", func(t *testing.T) {
59+
require.Equal(t, map[string]bool{
60+
"\x91\x2d\x54\x40\xf7\xe3\x76\x9e": true,
61+
}, contracts.FeeAddressesWith(nil))
62+
})
63+
}

0 commit comments

Comments
 (0)