Skip to content
Open
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
2 changes: 1 addition & 1 deletion cmd/util/cmd/inspect-token-movements/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
5 changes: 4 additions & 1 deletion fvm/fvm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
120 changes: 115 additions & 5 deletions fvm/fvm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
},
},
},
},
Expand Down Expand Up @@ -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")
},
},
{
Expand Down Expand Up @@ -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",
Expand Down
43 changes: 43 additions & 0 deletions fvm/inspection/inspector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
73 changes: 73 additions & 0 deletions fvm/inspection/inspector_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
Loading
Loading