Skip to content

Commit 3ea0b77

Browse files
harden fee receiver validation: never give up, surface status via /call
Previously validateFeeReceivers gave up for good after 5 quick attempts, so a flaky access node at startup plus a stale config could leave the server running with an unvalidated fee set indefinitely, and the only signal was a single log line. - Retry forever: quick backoff for the first 5 attempts, then a slow one-minute poll. Treat malformed script results as retryable instead of giving up. - Re-check every 10 minutes after a definitive result, so receivers added on chain while the server is running are also detected. - Downgrade a config mismatch from a fatal exit to a logged error that is surfaced via the new fee_receiver_validation_status /call method, matching the balance_validation_status pattern. - Replace the stringly-typed validation status with a validationStatus enum shared by balance and fee validation. - Add tests for the fee validation state machine, including a concurrent access test for the race detector.
1 parent 5eab151 commit 3ea0b77

5 files changed

Lines changed: 367 additions & 77 deletions

File tree

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -375,9 +375,12 @@ config file:
375375
```
376376

377377
* The canonical list is returned by `FlowFees.getFeeReceiverAddresses()` on
378-
chain. On startup, the server validates the configured addresses against
379-
that list and exits with a fatal error if any on-chain receiver is
380-
missing from the config.
378+
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.
381384

382385
* `data_dir: string`
383386

api/api.go

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/hex"
77
"fmt"
88
"net/http"
9+
"strings"
910
"sync"
1011
"time"
1112

@@ -27,6 +28,7 @@ const (
2728
callAccountPublicKeys = "account_public_keys"
2829
callBalanceValidationStatus = "balance_validation_status"
2930
callEcho = "echo"
31+
callFeeValidationStatus = "fee_receiver_validation_status"
3032
callLatestBlock = "latest_block"
3133
callListAccounts = "list_accounts"
3234
callVerifyAddress = "verify_address"
@@ -48,6 +50,7 @@ var (
4850
callAccountPublicKeys,
4951
callBalanceValidationStatus,
5052
callEcho,
53+
callFeeValidationStatus,
5154
callLatestBlock,
5255
callListAccounts,
5356
callVerifyAddress,
@@ -96,13 +99,18 @@ type Server struct {
9699
scriptSetContract []byte
97100
validation *validation
98101
validationMu sync.RWMutex // protects validation
102+
feeValidation *feeValidation
103+
feeValidationMu sync.RWMutex // protects feeValidation
99104
}
100105

101106
// Run initializes the server and starts serving Rosetta API calls.
102107
func (s *Server) Run(ctx context.Context) {
103108
s.compileScripts()
104109
s.validation = &validation{
105-
status: "not_started",
110+
status: validationNotStarted,
111+
}
112+
s.feeValidation = &feeValidation{
113+
status: validationNotStarted,
106114
}
107115
go s.validateBalances(ctx)
108116
s.feeAddrs = s.Chain.Contracts.FeeAddresses()
@@ -212,37 +220,95 @@ func (s *Server) setIndexedStateErr(format string, a ...interface{}) {
212220
s.mu.Unlock()
213221
s.validationMu.Lock()
214222
defer s.validationMu.Unlock()
215-
if s.validation.status == "failure" {
223+
if s.validation.status == validationFailure {
216224
return
217225
}
218226
s.validation = &validation{
219227
err: msg,
220-
status: "failure",
228+
status: validationFailure,
229+
}
230+
}
231+
232+
func (s *Server) getFeeValidationStatus() *feeValidation {
233+
s.feeValidationMu.RLock()
234+
defer s.feeValidationMu.RUnlock()
235+
return s.feeValidation
236+
}
237+
238+
func (s *Server) setFeeValidationRetrying(format string, a ...interface{}) {
239+
msg := fmt.Sprintf(format, a...)
240+
log.Errorf("%s", msg)
241+
s.feeValidationMu.Lock()
242+
defer s.feeValidationMu.Unlock()
243+
// We only track transient errors while we're still waiting for the first
244+
// definitive result. Once we have one, it stays in place until the next
245+
// definitive result replaces it.
246+
if s.feeValidation.status == validationSuccess || s.feeValidation.status == validationFailure {
247+
return
248+
}
249+
s.feeValidation = &feeValidation{
250+
err: msg,
251+
status: validationInProgress,
252+
}
253+
}
254+
255+
func (s *Server) setFeeValidationFailure(onchain []string, missing []string) {
256+
msg := fmt.Sprintf(
257+
"On-chain fee receiver account(s) %s are missing from the configured fee addresses: "+
258+
"fee deposits to them would be misclassified as transfers; add them to .contracts.fee_receivers",
259+
strings.Join(missing, ", "),
260+
)
261+
log.Errorf("%s", msg)
262+
s.feeValidationMu.Lock()
263+
defer s.feeValidationMu.Unlock()
264+
s.feeValidation = &feeValidation{
265+
err: msg,
266+
missing: missing,
267+
onchain: onchain,
268+
status: validationFailure,
269+
}
270+
}
271+
272+
func (s *Server) setFeeValidationSuccess(onchain []string) {
273+
s.feeValidationMu.Lock()
274+
prev := s.feeValidation.status
275+
s.feeValidation = &feeValidation{
276+
onchain: onchain,
277+
status: validationSuccess,
278+
}
279+
s.feeValidationMu.Unlock()
280+
// We only log on transitions so that the periodic re-checks don't flood
281+
// the logs.
282+
if prev != validationSuccess {
283+
log.Infof(
284+
"Validated the configured fee addresses against the on-chain fee receivers: %s",
285+
strings.Join(onchain, ", "),
286+
)
221287
}
222288
}
223289

224290
func (s *Server) setValidationProgress(accounts int, checked int) {
225291
s.validationMu.Lock()
226292
defer s.validationMu.Unlock()
227-
if s.validation.status == "failure" || s.validation.status == "success" {
293+
if s.validation.status == validationFailure || s.validation.status == validationSuccess {
228294
return
229295
}
230296
s.validation = &validation{
231297
accounts: accounts,
232298
checked: checked,
233-
status: "in_progress",
299+
status: validationInProgress,
234300
}
235301
}
236302

237303
func (s *Server) setValidationSuccess(accounts int) {
238304
s.validationMu.Lock()
239305
defer s.validationMu.Unlock()
240-
if s.validation.status == "failure" {
306+
if s.validation.status == validationFailure {
241307
return
242308
}
243309
s.validation = &validation{
244310
accounts: accounts,
245-
status: "success",
311+
status: validationSuccess,
246312
}
247313
}
248314

@@ -293,9 +359,44 @@ type txnIntent struct {
293359
sender []byte
294360
}
295361

362+
// validationStatus enumerates the states a background validation process can
363+
// be in.
364+
type validationStatus int
365+
366+
const (
367+
validationNotStarted validationStatus = iota
368+
validationInProgress
369+
validationSuccess
370+
validationFailure
371+
)
372+
373+
// String returns the status in the form reported by the /call endpoint.
374+
func (v validationStatus) String() string {
375+
switch v {
376+
case validationNotStarted:
377+
return "not_started"
378+
case validationInProgress:
379+
return "in_progress"
380+
case validationSuccess:
381+
return "success"
382+
case validationFailure:
383+
return "failure"
384+
default:
385+
log.Fatalf("Unsupported validation status %d", int(v))
386+
panic("unreachable code")
387+
}
388+
}
389+
296390
type validation struct {
297391
accounts int
298392
checked int
299393
err string
300-
status string
394+
status validationStatus
395+
}
396+
397+
type feeValidation struct {
398+
err string
399+
missing []string
400+
onchain []string
401+
status validationStatus
301402
}

api/call_service.go

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ func (s *Server) Call(ctx context.Context, r *types.CallRequest) (*types.CallRes
2424
return s.balanceValidationStatus(ctx)
2525
case callEcho:
2626
return s.echo(r.Parameters)
27+
case callFeeValidationStatus:
28+
return s.feeReceiverValidationStatus()
2729
case callLatestBlock:
2830
return s.latestBlock(ctx, r.Parameters)
2931
case callListAccounts:
@@ -176,40 +178,57 @@ func (s *Server) accountPublicKeys(ctx context.Context, params map[string]interf
176178
func (s *Server) balanceValidationStatus(ctx context.Context) (*types.CallResponse, *types.Error) {
177179
v := s.getValidationStatus()
178180
switch v.status {
179-
case "failure":
181+
case validationFailure:
180182
return &types.CallResponse{
181183
Result: map[string]interface{}{
182184
"error": v.err,
183-
"status": v.status,
185+
"status": v.status.String(),
184186
},
185187
}, nil
186-
case "in_progress":
188+
case validationInProgress:
187189
return &types.CallResponse{
188190
Result: map[string]interface{}{
189191
"accounts": v.accounts,
190192
"checked": v.checked,
191-
"status": v.status,
193+
"status": v.status.String(),
192194
},
193195
}, nil
194-
case "not_started":
196+
case validationNotStarted:
195197
return &types.CallResponse{
196198
Result: map[string]interface{}{
197-
"status": v.status,
199+
"status": v.status.String(),
198200
},
199201
}, nil
200-
case "success":
202+
case validationSuccess:
201203
return &types.CallResponse{
202204
Result: map[string]interface{}{
203205
"accounts": v.accounts,
204-
"status": v.status,
206+
"status": v.status.String(),
205207
},
206208
}, nil
207209
default:
208-
log.Fatalf("Unsupported validation status %q", v.status)
210+
log.Fatalf("Unsupported validation status %d", int(v.status))
209211
panic("unreachable code")
210212
}
211213
}
212214

215+
func (s *Server) feeReceiverValidationStatus() (*types.CallResponse, *types.Error) {
216+
v := s.getFeeValidationStatus()
217+
result := map[string]interface{}{
218+
"status": v.status.String(),
219+
}
220+
if v.err != "" {
221+
result["error"] = v.err
222+
}
223+
if v.onchain != nil {
224+
result["fee_receivers"] = v.onchain
225+
}
226+
if v.missing != nil {
227+
result["missing"] = v.missing
228+
}
229+
return &types.CallResponse{Result: result}, nil
230+
}
231+
213232
func (s *Server) echo(params map[string]interface{}) (*types.CallResponse, *types.Error) {
214233
return &types.CallResponse{
215234
Idempotent: true,

0 commit comments

Comments
 (0)