Skip to content

Commit e3e9e69

Browse files
authored
node: Accountant audit improvements (#4839)
* node: Accountant audit improvements * Address Claude comments * Update comment and acquire lock in called function * More minor PR feedback * Add guardian arg to change batch size * Only run accountant audit for NTT/WTT when enabled
1 parent 5587826 commit e3e9e69

12 files changed

Lines changed: 1359 additions & 253 deletions

File tree

node/cmd/guardiand/node.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"time"
1616

1717
"github.com/btcsuite/btcd/btcec/v2"
18+
"github.com/certusone/wormhole/node/pkg/accountant"
1819
"github.com/certusone/wormhole/node/pkg/guardiansigner"
1920
"github.com/certusone/wormhole/node/pkg/watchers"
2021
"github.com/certusone/wormhole/node/pkg/watchers/ibc"
@@ -129,11 +130,12 @@ var (
129130
ibcBlockHeightURL *string
130131
ibcContract *string
131132

132-
accountantContract *string
133-
accountantWS *string
134-
accountantCheckEnabled *bool
135-
accountantKeyPath *string
136-
accountantKeyPassPhrase *string
133+
accountantContract *string
134+
accountantWS *string
135+
accountantCheckEnabled *bool
136+
accountantKeyPath *string
137+
accountantKeyPassPhrase *string
138+
accountantSubmitObservationBatchSize *int
137139

138140
accountantNttContract *string
139141
accountantNttKeyPath *string
@@ -407,6 +409,7 @@ func init() {
407409
accountantKeyPath = NodeCmd.Flags().String("accountantKeyPath", "", "path to accountant private key for signing transactions")
408410
accountantKeyPassPhrase = NodeCmd.Flags().String("accountantKeyPassPhrase", "", "pass phrase used to unarmor the accountant key file")
409411
accountantCheckEnabled = NodeCmd.Flags().Bool("accountantCheckEnabled", false, "Should accountant be enforced on transfers")
412+
accountantSubmitObservationBatchSize = NodeCmd.Flags().Int("accountantSubmitObservationBatchSize", accountant.DefaultSubmitObservationBatchSize, "maximum number of observations to submit to the accountant contract in one transaction")
410413

411414
accountantNttContract = NodeCmd.Flags().String("accountantNttContract", "", "Address of the NTT accountant smart contract on wormchain")
412415
accountantNttKeyPath = NodeCmd.Flags().String("accountantNttKeyPath", "", "path to NTT accountant private key for signing transactions")
@@ -2073,7 +2076,7 @@ func runNode(cmd *cobra.Command, args []string) {
20732076
guardianOptions := []*node.GuardianOption{
20742077
node.GuardianOptionDatabase(db),
20752078
node.GuardianOptionWatchers(watcherConfigs, ibcWatcherConfig),
2076-
node.GuardianOptionAccountant(*accountantWS, *accountantContract, *accountantCheckEnabled, accountantWormchainConn, *accountantNttContract, accountantNttWormchainConn),
2079+
node.GuardianOptionAccountant(*accountantWS, *accountantContract, *accountantCheckEnabled, accountantWormchainConn, *accountantNttContract, accountantNttWormchainConn, *accountantSubmitObservationBatchSize),
20772080
node.GuardianOptionGovernor(*chainGovernorEnabled, *governorFlowCancelEnabled, *coinGeckoApiKey),
20782081
node.GuardianOptionNotary(*notaryEnabled),
20792082
node.GuardianOptionManagerService(*managerServiceEnabled, managerSigners, *ethRPC),

node/pkg/accountant/accountant.go

Lines changed: 116 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import (
3030

3131
// MsgChannelCapacity specifies the capacity of the message channel used to publish messages released from the accountant.
3232
// This channel should not back up, but if it does, the accountant will start dropping messages, which would require reobservations.
33-
const MsgChannelCapacity = 5 * batchSize
33+
const MsgChannelCapacity = 5 * DefaultSubmitObservationBatchSize
3434

3535
type (
3636
AccountantWormchainConn interface {
@@ -75,23 +75,24 @@ type (
7575

7676
// Accountant is the object that manages the interface to the wormchain accountant smart contract.
7777
type Accountant struct {
78-
ctx context.Context
79-
logger *zap.Logger
80-
db guardianDB.AccountantDB
81-
obsvReqWriteC chan<- *gossipv1.ObservationRequest
82-
contract string
83-
wsUrl string
84-
wormchainConn AccountantWormchainConn
85-
enforceFlag bool
86-
guardianSigner guardiansigner.GuardianSigner
87-
gst *common.GuardianSetState
88-
guardianAddr ethCommon.Address
89-
msgChan chan<- *common.MessagePublication
90-
tokenBridges validEmitters
91-
pendingTransfersLock sync.Mutex
92-
pendingTransfers map[string]*pendingEntry // Key is the message ID (emitterChain/emitterAddr/seqNo)
93-
subChan chan *common.MessagePublication
94-
env common.Environment
78+
ctx context.Context
79+
logger *zap.Logger
80+
db guardianDB.AccountantDB
81+
obsvReqWriteC chan<- *gossipv1.ObservationRequest
82+
contract string
83+
wsUrl string
84+
wormchainConn AccountantWormchainConn
85+
enforceFlag bool
86+
guardianSigner guardiansigner.GuardianSigner
87+
gst *common.GuardianSetState
88+
guardianAddr ethCommon.Address
89+
msgChan chan<- *common.MessagePublication
90+
tokenBridges validEmitters
91+
pendingTransfersLock sync.Mutex
92+
pendingTransfers map[string]*pendingEntry // Key is the message ID (emitterChain/emitterAddr/seqNo)
93+
subChan chan *common.MessagePublication
94+
submitObservationBatchSize int
95+
env common.Environment
9596

9697
nttContract string
9798
nttWormchainConn AccountantWormchainConn
@@ -101,7 +102,10 @@ type Accountant struct {
101102
}
102103

103104
// On startup, there can be a large number of re-submission requests.
104-
const subChanSize = 500
105+
const subChanSize = 5000
106+
107+
// auditSubmitTimeout is the timeout for blocking channel writes during audit.
108+
const auditSubmitTimeout = 30 * time.Second
105109

106110
// baseEnabled returns true if the base accountant is enabled, false if not.
107111
func (acct *Accountant) baseEnabled() bool {
@@ -123,25 +127,31 @@ func NewAccountant(
123127
guardianSigner guardiansigner.GuardianSigner, // the guardian signer used for signing observation requests
124128
gst *common.GuardianSetState, // used to get the current guardian set index when sending observation requests
125129
msgChan chan<- *common.MessagePublication, // the channel where transfers received by the accountant runnable should be published
130+
submitObservationBatchSize int, // maximum number of observations to submit to the contract in one transaction
126131
env common.Environment, // Controls the set of token bridges to be monitored
127132
) *Accountant {
133+
if submitObservationBatchSize <= 0 {
134+
submitObservationBatchSize = DefaultSubmitObservationBatchSize
135+
}
136+
128137
return &Accountant{
129-
ctx: ctx,
130-
logger: logger.With(zap.String("component", "gacct")),
131-
db: db,
132-
obsvReqWriteC: obsvReqWriteC,
133-
contract: contract,
134-
wsUrl: wsUrl,
135-
wormchainConn: wormchainConn,
136-
enforceFlag: enforceFlag,
137-
guardianSigner: guardianSigner,
138-
gst: gst,
139-
guardianAddr: ethCrypto.PubkeyToAddress(guardianSigner.PublicKey(ctx)),
140-
msgChan: msgChan,
141-
tokenBridges: make(validEmitters),
142-
pendingTransfers: make(map[string]*pendingEntry),
143-
subChan: make(chan *common.MessagePublication, subChanSize),
144-
env: env,
138+
ctx: ctx,
139+
logger: logger.With(zap.String("component", "gacct")),
140+
db: db,
141+
obsvReqWriteC: obsvReqWriteC,
142+
contract: contract,
143+
wsUrl: wsUrl,
144+
wormchainConn: wormchainConn,
145+
enforceFlag: enforceFlag,
146+
guardianSigner: guardianSigner,
147+
gst: gst,
148+
guardianAddr: ethCrypto.PubkeyToAddress(guardianSigner.PublicKey(ctx)),
149+
msgChan: msgChan,
150+
tokenBridges: make(validEmitters),
151+
pendingTransfers: make(map[string]*pendingEntry),
152+
subChan: make(chan *common.MessagePublication, subChanSize),
153+
submitObservationBatchSize: submitObservationBatchSize,
154+
env: env,
145155

146156
nttContract: nttContract,
147157
nttWormchainConn: nttWormchainConn,
@@ -153,7 +163,7 @@ func NewAccountant(
153163

154164
// Start initializes the accountant and starts the worker and watcher runnables.
155165
func (acct *Accountant) Start(ctx context.Context) error {
156-
acct.logger.Debug("entering Start", zap.Bool("enforceFlag", acct.enforceFlag), zap.Bool("baseEnabled", acct.baseEnabled()), zap.Bool("nttEnabled", acct.nttEnabled()))
166+
acct.logger.Debug("entering Start", zap.Bool("enforceFlag", acct.enforceFlag), zap.Bool("baseEnabled", acct.baseEnabled()), zap.Bool("nttEnabled", acct.nttEnabled()), zap.Int("submitObservationBatchSize", acct.submitObservationBatchSize))
157167
acct.pendingTransfersLock.Lock()
158168
defer acct.pendingTransfersLock.Unlock()
159169

@@ -215,9 +225,13 @@ func (acct *Accountant) Start(ctx context.Context) error {
215225
return fmt.Errorf("failed to start watcher: %w", err)
216226
}
217227

218-
if err := supervisor.Run(ctx, "acctaudit", common.WrapWithScissors(acct.audit, "acctaudit")); err != nil {
219-
return fmt.Errorf("failed to start audit worker: %w", err)
220-
}
228+
}
229+
}
230+
231+
// Start the audit worker if not mocking/testing and either Global or NTT accountant are enabled
232+
if acct.env != common.AccountantMock && acct.env != common.GoTest && (acct.baseEnabled() || acct.nttEnabled()) {
233+
if err := supervisor.Run(ctx, "acctaudit", common.WrapWithScissors(acct.audit, "acctaudit")); err != nil {
234+
return fmt.Errorf("failed to start audit worker: %w", err)
221235
}
222236
}
223237

@@ -315,7 +329,11 @@ func (acct *Accountant) SubmitObservation(msg *common.MessagePublication) (bool,
315329
acct.pendingTransfersLock.Lock()
316330
defer acct.pendingTransfersLock.Unlock()
317331

318-
// If this is already pending, don't send it again.
332+
var pe *pendingEntry
333+
334+
// If there is a digest mismatch, don't send it again.
335+
// Otherwise resubmit it and rely on the submitPending flag to prevent duplicate submissions to the contract.
336+
// This allows manual reobservations to proceed.
319337
if oldEntry, exists := acct.pendingTransfers[msgId]; exists {
320338
if oldEntry.digest != digest {
321339
digestMismatches.Inc()
@@ -325,17 +343,18 @@ func (acct *Accountant) SubmitObservation(msg *common.MessagePublication) (bool,
325343
zap.String("newDigest", digest),
326344
zap.Bool("enforcing", enforceFlag),
327345
)
328-
} else {
329-
acct.logger.Info("blocking transfer because it is already outstanding", zap.String("msgID", msgId), zap.Bool("enforcing", enforceFlag))
330-
}
331-
return !enforceFlag, nil
332-
}
333346

334-
// Add it to the pending map and the database.
335-
pe := &pendingEntry{msg: msg, msgId: msgId, digest: digest, isNTT: isNTT, enforceFlag: enforceFlag}
336-
if err := acct.addPendingTransferAlreadyLocked(pe); err != nil {
337-
acct.logger.Error("failed to persist pending transfer, blocking publishing", zap.String("msgID", msgId), zap.Error(err))
338-
return false, err
347+
return !enforceFlag, nil
348+
}
349+
pe = oldEntry
350+
} else {
351+
// Add it to the pending map and the database.
352+
// We only add it if it is not already present.
353+
pe = &pendingEntry{msg: msg, msgId: msgId, digest: digest, isNTT: isNTT, enforceFlag: enforceFlag}
354+
if err := acct.addPendingTransferAlreadyLocked(pe); err != nil {
355+
acct.logger.Error("failed to persist pending transfer, blocking publishing", zap.String("msgID", msgId), zap.Error(err))
356+
return false, err
357+
}
339358
}
340359

341360
// This transaction may take a while. Pass it off to the worker so we don't block the processor.
@@ -345,7 +364,7 @@ func (acct *Accountant) SubmitObservation(msg *common.MessagePublication) (bool,
345364
tag = "ntt-accountant"
346365
}
347366
acct.logger.Info(fmt.Sprintf("submitting transfer to %s for approval", tag), zap.String("msgID", msgId), zap.Bool("canPublish", !enforceFlag))
348-
_ = acct.submitObservation(pe)
367+
_ = acct.submitObservation(acct.ctx, pe, false) // Non-blocking from processor
349368
}
350369

351370
// If we are not enforcing accountant, the event can be published. Otherwise we have to wait to hear back from the contract.
@@ -436,37 +455,68 @@ func (acct *Accountant) loadPendingTransfers() error {
436455

437456
// submitObservation sends an observation request to the worker so it can be submitted to the contract. If the transfer is already
438457
// marked as "submit pending", this function returns false without doing anything. Otherwise it returns true. The return value can
439-
// be used to avoid unnecessary error logging. If writing to the channel would block, this function returns without doing anything,
440-
// assuming the pending transfer will be handled on the next audit interval. This function grabs the state lock.
441-
func (acct *Accountant) submitObservation(pe *pendingEntry) bool {
458+
// be used to avoid unnecessary error logging. If blocking is false and writing to the channel would block, this function returns
459+
// without doing anything, assuming the pending transfer will be handled on the next audit interval. If blocking is true, it will
460+
// block until the channel has space, a timeout occurs, or the context is cancelled. This function grabs the state lock.
461+
func (acct *Accountant) submitObservation(ctx context.Context, pe *pendingEntry, blocking bool) bool {
442462
pe.stateLock.Lock()
443-
defer pe.stateLock.Unlock()
444463

445464
if pe.state.submitPending {
465+
pe.stateLock.Unlock()
446466
return false
447467
}
448468

449469
pe.state.submitPending = true
450470
pe.state.updTime = time.Now()
471+
pe.stateLock.Unlock()
472+
473+
timeout := time.Duration(0)
474+
if blocking {
475+
timeout = auditSubmitTimeout
476+
}
451477

452478
if pe.isNTT {
453-
acct.submitToChannel(pe, acct.nttSubChan, "ntt-accountant")
479+
acct.submitToChannel(ctx, pe, acct.nttSubChan, "ntt-accountant", blocking, timeout)
454480
} else {
455-
acct.submitToChannel(pe, acct.subChan, "accountant")
481+
acct.submitToChannel(ctx, pe, acct.subChan, "accountant", blocking, timeout)
456482
}
457483

458484
return true
459485
}
460486

461-
// submitToChannel submits an observation to the specified channel. If the submission fails because the channel is full,
462-
// it marks the transfer as pending so it will be resubmitted by the audit.
463-
func (acct *Accountant) submitToChannel(pe *pendingEntry, subChan chan *common.MessagePublication, tag string) {
464-
select {
465-
case subChan <- pe.msg:
466-
acct.logger.Debug(fmt.Sprintf("submitted observation to channel for %s", tag), zap.String("msgId", pe.msgId))
467-
default:
468-
acct.logger.Error(fmt.Sprintf("unable to submit observation to %s because the channel is full, will try next interval", tag), zap.String("msgId", pe.msgId))
469-
pe.state.submitPending = false
487+
// submitToChannel submits an observation to the specified channel. If blocking is false and the channel is full,
488+
// it marks the transfer as no longer pending so it will be resubmitted by the audit. If blocking is true, it will
489+
// block until the channel has space, a timeout occurs, or the context is cancelled.
490+
func (acct *Accountant) submitToChannel(ctx context.Context, pe *pendingEntry, subChan chan *common.MessagePublication, tag string, blocking bool, timeout time.Duration) {
491+
if blocking {
492+
select {
493+
case subChan <- pe.msg:
494+
acct.logger.Debug(fmt.Sprintf("submitted observation to channel for %s", tag), zap.String("msgId", pe.msgId))
495+
case <-time.After(timeout):
496+
channelSubmitTimeouts.Inc()
497+
acct.logger.Warn(fmt.Sprintf("timeout submitting observation to %s channel, will retry next audit", tag),
498+
zap.String("msgId", pe.msgId),
499+
zap.Duration("timeout", timeout))
500+
pe.stateLock.Lock()
501+
pe.state.submitPending = false
502+
pe.stateLock.Unlock()
503+
case <-ctx.Done():
504+
acct.logger.Warn(fmt.Sprintf("context cancelled while submitting to %s channel", tag), zap.String("msgId", pe.msgId))
505+
pe.stateLock.Lock()
506+
pe.state.submitPending = false
507+
pe.stateLock.Unlock()
508+
}
509+
} else {
510+
// Non-blocking write
511+
select {
512+
case subChan <- pe.msg:
513+
acct.logger.Debug(fmt.Sprintf("submitted observation to channel for %s", tag), zap.String("msgId", pe.msgId))
514+
default:
515+
acct.logger.Error(fmt.Sprintf("unable to submit observation to %s because the channel is full, will try next interval", tag), zap.String("msgId", pe.msgId))
516+
pe.stateLock.Lock()
517+
pe.state.submitPending = false
518+
pe.stateLock.Unlock()
519+
}
470520
}
471521
}
472522

0 commit comments

Comments
 (0)