diff --git a/cmd/util/cmd/inspect-token-movements/storage.go b/cmd/util/cmd/inspect-token-movements/storage.go index ae498db48ff..5f0623dbe19 100644 --- a/cmd/util/cmd/inspect-token-movements/storage.go +++ b/cmd/util/cmd/inspect-token-movements/storage.go @@ -266,7 +266,7 @@ func (ci *chunkInspector) inspectChunk( events = append(events, output.ServiceEvents...) // Run the inspector - result, err := ci.inspector.Inspect(ci.logger, snapshotTree, executionSnapshot, events) + result, err := ci.inspector.Inspect(ci.logger, snapshotTree, executionSnapshot, events, inspection.AuthorizingSigners(tx.Transaction)) if err != nil { ci.logger.Warn(). Err(err). diff --git a/fvm/fvm.go b/fvm/fvm.go index f4edff5c50f..6258c0d557d 100644 --- a/fvm/fvm.go +++ b/fvm/fvm.go @@ -102,11 +102,14 @@ func inspectProcedureResults( ) []inspection.Result { inspectionResults := make([]inspection.Result, 0, len(context.Inspectors)) + // TxBody is nil for non-transaction procedures (e.g. scripts) + signers := inspection.AuthorizingSigners(context.TxBody) + for i, inspector := range context.Inspectors { log := log.With().Str("inspector", inspector.Name()).Int("inspector-num", i).Logger() log.Debug().Msg("starting inspection") - result, err := inspector.Inspect(log, storageSnapshot, executionSnapshot, events) + result, err := inspector.Inspect(log, storageSnapshot, executionSnapshot, events, signers) if err != nil { log.Error().Err(err).Msg("failed to inspect procedure results") } diff --git a/fvm/fvm_test.go b/fvm/fvm_test.go index e2dfdf90b23..15ebe2fe6a4 100644 --- a/fvm/fvm_test.go +++ b/fvm/fvm_test.go @@ -4448,6 +4448,10 @@ func TestFlowTokenChangesInspector(t *testing.T) { }, } + // payerOnlyAllowlist is the SignerAllowlist of the "mint where only the payer + // is allow-listed" test case; see its tokenDefinitions and txBody. + payerOnlyAllowlist := map[flow.Address]struct{}{} + testCases := []testCase{ { name: "transfer", @@ -4506,11 +4510,13 @@ func TestFlowTokenChangesInspector(t *testing.T) { GetBalance: func(value *interpreter.CompositeValue) uint64 { return uint64(value.GetField(nil, "balance").(interpreter.UFix64Value).UFix64Value) }, - SinksSources: map[string]func(flow.Event) (int64, error){ - flowTokenMintedEventID: func(evt flow.Event) (int64, error) { - payload, err := ccf.Decode(nil, evt.Payload) - require.NoError(t, err) - return int64(payload.(cadence.Event).SearchFieldByName("amount").(cadence.UFix64)), nil + SinksSources: map[string]inspection.SourceSink{ + flowTokenMintedEventID: { + Amount: func(evt flow.Event) (int64, error) { + payload, err := ccf.Decode(nil, evt.Payload) + require.NoError(t, err) + return int64(payload.(cadence.Event).SearchFieldByName("amount").(cadence.UFix64)), nil + }, }, }, }, @@ -4538,6 +4544,7 @@ func TestFlowTokenChangesInspector(t *testing.T) { unaccounted := result.UnaccountedTokens() require.Len(t, unaccounted, 0, "expectation: all tokens were accounted for") require.Len(t, result.Changes, 3, "change should be on 3 addresses: sender, receiver, fees") + require.Len(t, result.UnauthorizedSourcesSinks, 0, "no allow-list configured: no violations expected") }, }, { @@ -4566,6 +4573,109 @@ func TestFlowTokenChangesInspector(t *testing.T) { unaccounted := result.UnaccountedTokens() require.Len(t, unaccounted, 0, "expectation: all tokens were accounted for") require.Len(t, result.Changes, 3, "change should be on 3 addresses: sender, receiver, fees") + require.Len(t, result.UnauthorizedSourcesSinks, 0, + "mint is signed by the service account, which is allow-listed by default") + }, + }, { + name: "mint by non-allow-listed minter", + tokenDefinitions: map[string]inspection.SearchToken{ + flowTokenVaultID: { + ID: flowTokenVaultID, + GetBalance: func(value *interpreter.CompositeValue) uint64 { + return uint64(value.GetField(nil, "balance").(interpreter.UFix64Value).UFix64Value) + }, + SinksSources: map[string]inspection.SourceSink{ + flowTokenMintedEventID: { + Amount: func(evt flow.Event) (int64, error) { + payload, err := ccf.Decode(nil, evt.Payload) + require.NoError(t, err) + return int64(payload.(cadence.Event).SearchFieldByName("amount").(cadence.UFix64)), nil + }, + // Allow-list an account that is NOT the transaction signer + // (the service account), so the mint triggers a violation. + SignerAllowlist: map[flow.Address]struct{}{ + flow.HexToAddress("0000000000000123"): {}, + }, + }, + }, + }, + }, + txBody: func(t *testing.T, chain flow.Chain, accounts []flow.Address) *flow.TransactionBody { + sc := systemcontracts.SystemContractsForChain(chain.ChainID()) + env := sc.AsTemplateEnv() + + txBodyBuilder := flow.NewTransactionBodyBuilder(). + SetScript(templates.GenerateMintFlowScript(env)). + AddArgument(jsoncdc.MustEncode(cadence.Address(accounts[0]))). + AddArgument(jsoncdc.MustEncode(cadence.UFix64(10_000_000))). + AddAuthorizer(chain.ServiceAddress()). + SetPayer(chain.ServiceAddress()) + + err := testutil.SignTransactionAsServiceAccount(txBodyBuilder, 0, chain) + require.NoError(t, err) + + txBody, err := txBodyBuilder.Build() + require.NoError(t, err) + + return txBody + }, + resultChecker: func(t *testing.T, result inspection.TokenDiffResult) { + unaccounted := result.UnaccountedTokens() + require.Len(t, unaccounted, 0, "the mint amount is still accounted for via the event") + require.Len(t, result.UnauthorizedSourcesSinks, 1, "mint signed by a non-allow-listed account must be flagged") + require.Equal(t, flowTokenMintedEventID, result.UnauthorizedSourcesSinks[0].EventType) + require.Equal(t, int64(10_000_000), result.UnauthorizedSourcesSinks[0].Amount) + }, + }, { + // A transaction merely paid for by an allow-listed account must still be + // flagged: only the proposer and authorizers authorize the mint. + name: "mint where only the payer is allow-listed", + tokenDefinitions: func() map[string]inspection.SearchToken { + searchTokens := inspection.DefaultTokenDiffSearchTokens(chain) + ss := searchTokens[flowTokenVaultID].SinksSources[flowTokenMintedEventID] + // Allow-list only the payer, replacing the default (service account). + // The payer's address is only known once the test accounts are + // created, so txBody below inserts it into this map before the + // transaction (and thus the inspector) runs. + ss.SignerAllowlist = payerOnlyAllowlist + searchTokens[flowTokenVaultID].SinksSources[flowTokenMintedEventID] = ss + return searchTokens + }(), + txBody: func(t *testing.T, chain flow.Chain, accounts []flow.Address) *flow.TransactionBody { + sc := systemcontracts.SystemContractsForChain(chain.ChainID()) + env := sc.AsTemplateEnv() + + txBodyBuilder := flow.NewTransactionBodyBuilder(). + SetScript(templates.GenerateMintFlowScript(env)). + AddArgument(jsoncdc.MustEncode(cadence.Address(accounts[0]))). + AddArgument(jsoncdc.MustEncode(cadence.UFix64(10_000_000))). + AddAuthorizer(chain.ServiceAddress()) + + err := testutil.SignTransactionAsServiceAccount(txBodyBuilder, 0, chain) + require.NoError(t, err) + + // Override the payer set by SignTransactionAsServiceAccount with the + // allow-listed account. Authorization checks are disabled in this + // test, so the payer's missing envelope signature is not an issue. + // The payer is also the mint's recipient, so the minted tokens cover + // the transaction fees (the test accounts are created unfunded). + payerOnlyAllowlist[accounts[0]] = struct{}{} + txBodyBuilder.SetPayer(accounts[0]) + + txBody, err := txBodyBuilder.Build() + require.NoError(t, err) + + return txBody + }, + resultChecker: func(t *testing.T, result inspection.TokenDiffResult) { + unaccounted := result.UnaccountedTokens() + require.Len(t, unaccounted, 0, "the mint amount is still accounted for via the event") + require.Len(t, result.UnauthorizedSourcesSinks, 1, + "the payer's signature must not authorize the mint") + require.Equal(t, flowTokenMintedEventID, result.UnauthorizedSourcesSinks[0].EventType) + require.Equal(t, int64(10_000_000), result.UnauthorizedSourcesSinks[0].Amount) + require.Equal(t, []flow.Address{chain.ServiceAddress()}, result.UnauthorizedSourcesSinks[0].Signers, + "only the proposer and authorizers count as signers") }, }, { name: "create account", diff --git a/fvm/inspection/inspector.go b/fvm/inspection/inspector.go index e253e7d060d..90030a84c0b 100644 --- a/fvm/inspection/inspector.go +++ b/fvm/inspection/inspector.go @@ -15,11 +15,15 @@ type Inspector interface { // only the executionSnapshot.Reads, will be read // - executionSnapshot is the reads and writes of the procedure // - events are all of the events the procedure is emitting + // - signers are the transaction's authorizing signers, as returned by + // [AuthorizingSigners]. Empty for procedures that are not transactions + // (e.g. scripts). Inspect( logger zerolog.Logger, storage snapshot.StorageSnapshot, executionSnapshot *snapshot.ExecutionSnapshot, events []flow.Event, + signers []flow.Address, ) (Result, error) // Name is the name of the inspector @@ -31,3 +35,42 @@ type Result interface { InspectionName() string AsLogEvent() (zerolog.Level, func(e *zerolog.Event)) } + +// AuthorizingSigners returns the deduplicated addresses whose signatures +// authorize the transaction's actions: the proposer followed by the +// authorizers (in insertion order). If the same account appears in multiple +// roles, only its first occurrence is included. Empty addresses (e.g. an unset +// proposer on the system transaction) are omitted. +// +// The payer is deliberately excluded: its signature only authorizes paying the +// transaction's fees, not the transaction's actions. +// +// A nil transaction body returns nil, allowing callers holding an optional +// *TransactionBody (e.g. a non-transaction procedure such as a script) to call +// this function without a nil check. +func AuthorizingSigners(tb *flow.TransactionBody) []flow.Address { + if tb == nil { + return nil + } + + signers := make([]flow.Address, 0, len(tb.Authorizers)+1) + seen := make(map[flow.Address]struct{}) + + addSigner := func(address flow.Address) { + if address == flow.EmptyAddress { + return + } + if _, ok := seen[address]; ok { + return + } + signers = append(signers, address) + seen[address] = struct{}{} + } + + addSigner(tb.ProposalKey.Address) + for _, authorizer := range tb.Authorizers { + addSigner(authorizer) + } + + return signers +} diff --git a/fvm/inspection/inspector_test.go b/fvm/inspection/inspector_test.go new file mode 100644 index 00000000000..3824d5d32c7 --- /dev/null +++ b/fvm/inspection/inspector_test.go @@ -0,0 +1,73 @@ +package inspection_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/onflow/flow-go/fvm/inspection" + "github.com/onflow/flow-go/model/flow" + "github.com/onflow/flow-go/utils/unittest" +) + +// TestAuthorizingSigners verifies AuthorizingSigners against its documented +// contract. +func TestAuthorizingSigners(t *testing.T) { + proposer := unittest.RandomAddressFixture() + payer := unittest.RandomAddressFixture() + authorizer1 := unittest.RandomAddressFixture() + authorizer2 := unittest.RandomAddressFixture() + + t.Run("ordering: proposer, then authorizers in insertion order", func(t *testing.T) { + tb := flow.TransactionBody{ + ProposalKey: flow.ProposalKey{Address: proposer}, + Payer: payer, + Authorizers: []flow.Address{authorizer1, authorizer2}, + } + + assert.Equal(t, + []flow.Address{proposer, authorizer1, authorizer2}, + inspection.AuthorizingSigners(&tb), + ) + }) + + t.Run("payer is excluded even when it is the only signer", func(t *testing.T) { + tb := flow.TransactionBody{ + Payer: payer, + } + + assert.Empty(t, inspection.AuthorizingSigners(&tb)) + }) + + t.Run("deduplication: account in multiple roles appears once at first occurrence", func(t *testing.T) { + // proposer is also an authorizer, and authorizer1 is repeated. + tb := flow.TransactionBody{ + ProposalKey: flow.ProposalKey{Address: proposer}, + Payer: payer, + Authorizers: []flow.Address{authorizer1, proposer, authorizer1, authorizer2}, + } + + assert.Equal(t, + []flow.Address{proposer, authorizer1, authorizer2}, + inspection.AuthorizingSigners(&tb), + ) + }) + + t.Run("empty addresses are omitted", func(t *testing.T) { + // No proposer set (e.g. the system transaction). + tb := flow.TransactionBody{ + Authorizers: []flow.Address{authorizer1}, + } + + assert.Equal(t, []flow.Address{authorizer1}, inspection.AuthorizingSigners(&tb)) + }) + + t.Run("no signers returns empty", func(t *testing.T) { + tb := flow.TransactionBody{} + assert.Empty(t, inspection.AuthorizingSigners(&tb)) + }) + + t.Run("nil transaction body returns nil", func(t *testing.T) { + assert.Nil(t, inspection.AuthorizingSigners(nil)) + }) +} diff --git a/fvm/inspection/token_changes.go b/fvm/inspection/token_changes.go index 78112db40aa..15d3f2e5cae 100644 --- a/fvm/inspection/token_changes.go +++ b/fvm/inspection/token_changes.go @@ -2,6 +2,7 @@ package inspection import ( "fmt" + "maps" "math" "runtime/debug" "sync" @@ -35,7 +36,7 @@ var _ Inspector = (*TokenChanges)(nil) // NewTokenChangesInspector return a TokenChanges inspector, that will be run // after transaction execution and analyze if any unaccounted tokens were created or -// destroy. +// destroyed. func NewTokenChangesInspector(searchedTokens TokenChangesSearchTokens, chain flow.ChainID) *TokenChanges { sc := systemcontracts.SystemContractsForChain(chain) @@ -55,9 +56,7 @@ func (td *TokenChanges) Name() string { func (td *TokenChanges) SetSearchedTokens(searchedTokens TokenChangesSearchTokens) { // copy the map in case the user tries to modify the map st := make(map[string]SearchToken, len(searchedTokens)) - for k, v := range searchedTokens { - st[k] = v - } + maps.Copy(st, searchedTokens) td.searchedTokensMu.Lock() defer td.searchedTokensMu.Unlock() td.searchedTokens = st @@ -81,6 +80,7 @@ func (td *TokenChanges) Inspect( storage snapshot.StorageSnapshot, executionSnapshot *snapshot.ExecutionSnapshot, events []flow.Event, + signers []flow.Address, ) (diff Result, err error) { log.Debug(). Int("events", len(events)). @@ -100,7 +100,7 @@ func (td *TokenChanges) Inspect( } }() - diff, err = td.getTokenDiff(log, storage, executionSnapshot, events, td.getSearchedTokensRef()) + diff, err = td.getTokenDiff(log, storage, executionSnapshot, events, signers, td.getSearchedTokensRef()) return } @@ -109,6 +109,7 @@ func (td *TokenChanges) getTokenDiff( storage snapshot.StorageSnapshot, executionSnapshot *snapshot.ExecutionSnapshot, events []flow.Event, + signers []flow.Address, searchedTokens map[string]SearchToken, ) (TokenDiffResult, error) { executionSnapshotLedgers := executionSnapshotLedgers{ @@ -163,9 +164,7 @@ func (td *TokenChanges) getTokenDiff( for a := range addresses { // Copy beforeTokens before calling diffAccountTokens, which mutates the before map beforeTokens := make(accountTokens, len(before[a])) - for k, v := range before[a] { - beforeTokens[k] = v - } + maps.Copy(beforeTokens, before[a]) afterTokens := after[a] diff := diffAccountTokens(before[a], after[a]) if len(diff) == 0 { @@ -188,11 +187,12 @@ func (td *TokenChanges) getTokenDiff( tokenDiffResult.Changes[flow.Address(a)] = diff } - sourcesSinks, err := td.findSourcesSinks(events, searchedTokens) + sourcesSinks, violations, err := td.findSourcesSinks(events, searchedTokens, signers) if err != nil { return TokenDiffResult{}, fmt.Errorf("failed to find sources/sinks: %w", err) } tokenDiffResult.KnownSourcesSinks = sourcesSinks + tokenDiffResult.UnauthorizedSourcesSinks = violations // Log summary of token movements // Only log as debug because it's going to get properly logged in `TokenDiffResult.AsLogEvent()` @@ -337,15 +337,24 @@ func walkLoaded( f(value) } -func (td *TokenChanges) findSourcesSinks(events []flow.Event, tokens map[string]SearchToken) (map[string]int64, error) { +// findSourcesSinks matches emitted events against the configured per-token +// source/sink handlers and returns the net known supply change per token. It +// also returns any SignerAllowlist violations: matched events whose configured +// allow-list did not include any of the given signers. +func (td *TokenChanges) findSourcesSinks( + events []flow.Event, + tokens map[string]SearchToken, + signers []flow.Address, +) (map[string]int64, []SignerAllowlistViolation, error) { // create a map of all sinks and sources // TODO: could be created once type tokenSourceSink struct { tokenID string - f func(flow.Event) (int64, error) + ss SourceSink } sourcesSinks := make(map[string]tokenSourceSink) results := make(map[string]int64) + var violations []SignerAllowlistViolation for _, token := range tokens { for evt, ss := range token.SinksSources { // Each event ID should be unique across all tokens. If two tokens register @@ -353,27 +362,49 @@ func (td *TokenChanges) findSourcesSinks(events []flow.Event, tokens map[string] // the first, causing incorrect token accounting. This should not happen with // the current token definitions, but we guard against it defensively. if existing, ok := sourcesSinks[evt]; ok { - return nil, fmt.Errorf( + return nil, nil, fmt.Errorf( "event %s is registered by both token %s and token %s", evt, existing.tokenID, token.ID, ) } - sourcesSinks[evt] = tokenSourceSink{tokenID: token.ID, f: ss} + sourcesSinks[evt] = tokenSourceSink{tokenID: token.ID, ss: ss} } } for _, evt := range events { id := string(evt.Type) - if ss, ok := sourcesSinks[id]; ok { - v, err := ss.f(evt) - if err != nil { - return nil, fmt.Errorf("failed to parse source/sink event %s: %w", id, err) - } - results[ss.tokenID] += v + ts, ok := sourcesSinks[id] + if !ok { + continue + } + v, err := ts.ss.Amount(evt) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse source/sink event %s: %w", id, err) + } + results[ts.tokenID] += v + + if ts.ss.SignerAllowlist != nil && !anySignerAllowed(signers, ts.ss.SignerAllowlist) { + violations = append(violations, SignerAllowlistViolation{ + TokenID: ts.tokenID, + EventType: id, + Amount: v, + Signers: signers, + }) } } - return results, nil + return results, violations, nil +} + +// anySignerAllowed reports whether at least one of the signers is present in the +// allow-list. +func anySignerAllowed(signers []flow.Address, allowlist map[flow.Address]struct{}) bool { + for _, s := range signers { + if _, ok := allowlist[s]; ok { + return true + } + } + return false } func newReadonlyStorageRuntimeWithStorage(storage *runtime.Storage, payloadCount int) (*readonlyStorageRuntime, error) { @@ -544,11 +575,41 @@ type readonlyStorageRuntime struct { PayloadCount int } +// SourceSink describes how to account for a single event type that changes a +// token's supply (a source/mint with a positive amount, or a sink/burn with a +// negative amount), and optionally restricts which accounts may trigger it. +type SourceSink struct { + // Amount decodes the signed token-amount delta from the event. A positive + // value is a source (tokens entering/created); a negative value is a sink + // (tokens leaving/destroyed). + Amount func(flow.Event) (int64, error) + + // SignerAllowlist, when non-nil, restricts which accounts may trigger this + // event. The check passes if at least one of the transaction's authorizing + // signers (see [AuthorizingSigners]) is in the set. A nil map disables the + // check (the default, preserving prior behavior). + SignerAllowlist map[flow.Address]struct{} +} + type SearchToken struct { ID string GetBalance func(value *interpreter.CompositeValue) uint64 // TODO: optimize by using decoded events - SinksSources map[string]func(flow.Event) (int64, error) + SinksSources map[string]SourceSink +} + +// SignerAllowlistViolation records a source/sink event (e.g. a mint or burn) +// observed in a transaction whose authorizing signers did not include any +// account from the event's configured SignerAllowlist. +type SignerAllowlistViolation struct { + // TokenID is the token whose supply changed. + TokenID string + // EventType is the event type ID that triggered the violation. + EventType string + // Amount is the signed token-amount delta decoded from the event. + Amount int64 + // Signers are the transaction's authorizing signers. + Signers []flow.Address } // TokenDiffResult is the result of the inspection @@ -560,6 +621,11 @@ type TokenDiffResult struct { // KnownSourcesSinks is a map (by token id) of // know mints/burns for the token parsed from predetermined events KnownSourcesSinks map[string]int64 + + // UnauthorizedSourcesSinks holds source/sink events whose transaction's + // authorizing signers included no allow-listed account. Empty when no + // allow-list is configured or all matched events were authorized. + UnauthorizedSourcesSinks []SignerAllowlistViolation } var _ Result = TokenDiffResult{} @@ -569,35 +635,86 @@ func (r TokenDiffResult) InspectionName() string { } func (r TokenDiffResult) AsLogEvent() (zerolog.Level, func(e *zerolog.Event)) { - unaccountedTokens := r.UnaccountedTokens() + violations := r.violations() + return violations.logLevel(), violations.asLogEvent() +} - if len(unaccountedTokens) == 0 { - // everything is ok: log no issues with debug logging - return zerolog.InfoLevel, func(e *zerolog.Event) { e.Str(r.InspectionName(), "no issues") } +// tokenDiffViolations holds the issues derived from a TokenDiffResult: token +// movements that are unaccounted for, and source/sink events that were not +// authorized by an allow-listed signer. +type tokenDiffViolations struct { + inspectionName string + unaccountedTokens map[string]int64 + unauthorizedSourcesSinks []SignerAllowlistViolation +} + +func (r TokenDiffResult) violations() tokenDiffViolations { + return tokenDiffViolations{ + inspectionName: r.InspectionName(), + unaccountedTokens: r.UnaccountedTokens(), + unauthorizedSourcesSinks: r.UnauthorizedSourcesSinks, } +} - anyPositive := false - for _, v := range unaccountedTokens { - if v > 0 { - anyPositive = true - break +func (v tokenDiffViolations) hasIssues() bool { + return len(v.unaccountedTokens) > 0 || len(v.unauthorizedSourcesSinks) > 0 +} + +// logLevel returns the level at which the violations should be logged: +// - [zerolog.InfoLevel] when there are no issues: all token movements are +// accounted for and no signer allow-list was violated, +// - [zerolog.ErrorLevel] when any tracked token increased in supply, or an +// event's signer allow-list was violated, +// - [zerolog.WarnLevel] otherwise: token movements are unaccounted for, but +// none of them increased a token's supply. +func (v tokenDiffViolations) logLevel() zerolog.Level { + if !v.hasIssues() { + return zerolog.InfoLevel + } + + if len(v.unauthorizedSourcesSinks) > 0 { + return zerolog.ErrorLevel + } + for _, amount := range v.unaccountedTokens { + if amount > 0 { + return zerolog.ErrorLevel } } - level := zerolog.WarnLevel - if anyPositive { - // if any tracked token increase in supply - // log at error level - // otherwise just use warn level - level = zerolog.ErrorLevel + return zerolog.WarnLevel +} + +// asLogEvent returns a function that writes the violations to a log event, or +// a "no issues" marker when there are none. +func (v tokenDiffViolations) asLogEvent() func(e *zerolog.Event) { + if !v.hasIssues() { + return func(e *zerolog.Event) { e.Str(v.inspectionName, "no issues") } } - return level, func(e *zerolog.Event) { - dict := zerolog.Dict() - for k, v := range unaccountedTokens { - dict = dict.Int64(k, v) + return func(e *zerolog.Event) { + if len(v.unaccountedTokens) > 0 { + dict := zerolog.Dict() + for k, amount := range v.unaccountedTokens { + dict = dict.Int64(k, amount) + } + e.Dict(v.inspectionName, dict) + } + + if len(v.unauthorizedSourcesSinks) > 0 { + arr := zerolog.Arr() + for _, unauthorized := range v.unauthorizedSourcesSinks { + signers := make([]string, len(unauthorized.Signers)) + for i, s := range unauthorized.Signers { + signers[i] = s.Hex() + } + arr = arr.Dict(zerolog.Dict(). + Str("token", unauthorized.TokenID). + Str("event", unauthorized.EventType). + Int64("amount", unauthorized.Amount). + Strs("signers", signers)) + } + e.Array(v.inspectionName+"_signer_allowlist_violations", arr) } - e.Dict(r.InspectionName(), dict) } } @@ -745,10 +862,18 @@ func DefaultTokenDiffSearchTokens(chain flow.Chain) TokenChangesSearchTokens { GetBalance: func(value *interpreter.CompositeValue) uint64 { return uint64(value.GetField(nil, "balance").(interpreter.UFix64Value).UFix64Value) }, - SinksSources: map[string]func(flow.Event) (int64, error){}, + SinksSources: map[string]SourceSink{}, + }, + } + + // FlowToken minting is only expected from the service account (e.g. the system + // transaction paying epoch staking rewards). + searchTokens[flowTokenID].SinksSources[flowTokenMintedEventID] = SourceSink{ + Amount: decodeFlowEventAmount(flowAmountSource), + SignerAllowlist: map[flow.Address]struct{}{ + chain.ServiceAddress(): {}, }, } - searchTokens[flowTokenID].SinksSources[flowTokenMintedEventID] = decodeFlowEventAmount(false) // EVM bridge events: FLOW tokens moving between Cadence and EVM. // Deposited = tokens leave Cadence into EVM (sink, negative). @@ -756,17 +881,30 @@ func DefaultTokenDiffSearchTokens(chain flow.Chain) TokenChangesSearchTokens { evmDepositedEventID := fmt.Sprintf("A.%s.EVM.FLOWTokensDeposited", sc.EVMContract.Address.Hex()) evmWithdrawnEventID := fmt.Sprintf("A.%s.EVM.FLOWTokensWithdrawn", sc.EVMContract.Address.Hex()) - searchTokens[flowTokenID].SinksSources[evmDepositedEventID] = decodeFlowEventAmount(true) - searchTokens[flowTokenID].SinksSources[evmWithdrawnEventID] = decodeFlowEventAmount(false) + searchTokens[flowTokenID].SinksSources[evmDepositedEventID] = SourceSink{Amount: decodeFlowEventAmount(flowAmountSink)} + searchTokens[flowTokenID].SinksSources[evmWithdrawnEventID] = SourceSink{Amount: decodeFlowEventAmount(flowAmountSource)} return searchTokens } +// flowAmountDirection indicates whether an event's amount represents tokens +// entering Cadence (a source, positive) or leaving Cadence (a sink, negative). +type flowAmountDirection int + +const ( + // flowAmountSource indicates tokens entering Cadence; the decoded amount is + // returned as a positive value. + flowAmountSource flowAmountDirection = iota + // flowAmountSink indicates tokens leaving Cadence; the decoded amount is + // returned as a negated value. + flowAmountSink +) + // decodeFlowEventAmount returns a function that decodes the "amount" field from a -// CCF-encoded event as a UFix64. If isSink is true, the returned value is negated -// (tokens leaving Cadence). If isSink is false, the value is positive (tokens -// entering Cadence). -func decodeFlowEventAmount(isSink bool) func(flow.Event) (int64, error) { +// CCF-encoded event as a UFix64. The sign of the returned value is determined by +// direction: flowAmountSource yields a positive value, flowAmountSink yields a +// negated value. +func decodeFlowEventAmount(direction flowAmountDirection) func(flow.Event) (int64, error) { return func(evt flow.Event) (int64, error) { payload, err := ccf.Decode(nil, evt.Payload) if err != nil { @@ -782,7 +920,7 @@ func decodeFlowEventAmount(isSink bool) func(flow.Event) (int64, error) { return 0, fmt.Errorf("amount field is too large") } - if isSink { + if direction == flowAmountSink { return -int64(ufix), nil } return int64(ufix), nil