diff --git a/README.md b/README.md index 42d3d6e..8a922f3 100644 --- a/README.md +++ b/README.md @@ -357,6 +357,28 @@ config file: use the zero address `"0000000000000000"` to disable support for proxy accounts. + * The optional `fee_receivers` key lists accounts, in addition to the + FlowFees contract account, that receive transaction fee deposits. Networks + may distribute fees across several receiver accounts (testnet does so + since the concurrent fee collection upgrade), and without them configured, + fee deposits to those accounts would be misclassified as ordinary + transfers, e.g. + +```json +{ + "fee_receivers": [ + "e1ac6b2740d204c2", + "05cbd2fa5128041d", + "139fb7c9c82c0e7c" + ] +} +``` + + * The canonical list is returned by `FlowFees.getFeeReceiverAddresses()` on + chain. On startup, the server validates the configured addresses against + that list and exits with a fatal error if any on-chain receiver is + missing from the config. + * `data_dir: string` * This defines the path to the data directory where the server stores data, diff --git a/api/api.go b/api/api.go index 1039e48..d49f4b2 100644 --- a/api/api.go +++ b/api/api.go @@ -78,7 +78,7 @@ type Server struct { Indexer *state.Indexer Offline bool Port uint16 - feeAddr []byte + feeAddrs map[string]bool genesis *model.BlockMeta indexedStateErr *types.Error mu sync.RWMutex // protects indexedStateErr @@ -89,6 +89,7 @@ type Server struct { scriptCreateProxyAccount []byte scriptGetBalances []byte scriptGetBalancesBasic []byte + scriptGetFeeReceivers []byte scriptGetProxyNonce []byte scriptGetProxyPublicKey []byte scriptProxyTransfer []byte @@ -104,14 +105,8 @@ func (s *Server) Run(ctx context.Context) { status: "not_started", } go s.validateBalances(ctx) - feeAddr, err := hex.DecodeString(s.Chain.Contracts.FlowFees) - if err != nil { - log.Fatalf( - "Invalid FlowFees contract address %q: %s", - s.Chain.Contracts.FlowFees, err, - ) - } - s.feeAddr = feeAddr + s.feeAddrs = s.Chain.Contracts.FeeAddresses() + go s.validateFeeReceivers(ctx) s.genesis = s.Index.Genesis() s.networks = []*types.NetworkIdentifier{{ Blockchain: "flow", @@ -166,6 +161,7 @@ func (s *Server) compileScripts() { s.scriptCreateProxyAccount = script.Compile("create_proxy_account", script.CreateProxyAccount, s.Chain) s.scriptGetBalances = script.Compile("get_balances", script.GetBalances, s.Chain) s.scriptGetBalancesBasic = script.Compile("get_balances_basic", script.GetBalancesBasic, s.Chain) + s.scriptGetFeeReceivers = script.Compile("get_fee_receivers", script.GetFeeReceivers, s.Chain) s.scriptGetProxyNonce = script.Compile("get_proxy_nonce", script.GetProxyNonce, s.Chain) s.scriptGetProxyPublicKey = script.Compile("get_proxy_public_key", script.GetProxyPublicKey, s.Chain) s.scriptProxyTransfer = script.Compile("proxy_transfer", script.ProxyTransfer, s.Chain) diff --git a/api/construction_service.go b/api/construction_service.go index b5c7217..f6bc3c6 100644 --- a/api/construction_service.go +++ b/api/construction_service.go @@ -523,13 +523,13 @@ func (s *Server) ConstructionPreprocess(ctx context.Context, r *types.Constructi if xerr != nil { return nil, xerr } - // NOTE(tav): We explicitly error on transfers to the fee address so as to + // NOTE(tav): We explicitly error on transfers to a fee address so as to // simplify our event processing logic. - if bytes.Equal(intent.receiver, s.feeAddr) { + if s.feeAddrs[string(intent.receiver)] { return nil, wrapErrorf( errInvalidOpsIntent, - "cannot make transfers to the fee address: 0x%s", - s.Chain.Contracts.FlowFees, + "cannot make transfers to the fee address: 0x%x", + intent.receiver, ) } opts := &model.ConstructOpts{ diff --git a/api/validate.go b/api/validate.go index e7e7165..443b7ef 100644 --- a/api/validate.go +++ b/api/validate.go @@ -3,11 +3,81 @@ package api import ( "context" "os" + "strings" "time" + "github.com/onflow/cadence" "github.com/onflow/rosetta/log" ) +// validateFeeReceivers checks the configured fee addresses (the FlowFees +// contract account plus .contracts.fee_receivers) against the fee receiver +// accounts the FlowFees contract rotates deposits across on chain. If an +// on-chain receiver is missing from the config, fee deposits to it would be +// misclassified as ordinary transfers, so we exit with a fatal error. +// Configured addresses that are no longer on chain are fine — they may be +// needed to classify fees in historical blocks. +func (s *Server) validateFeeReceivers(ctx context.Context) { + if s.Offline { + return + } + const attempts = 5 + for attempt := 1; attempt <= attempts; attempt++ { + select { + case <-ctx.Done(): + return + default: + } + if attempt > 1 { + time.Sleep(time.Duration(attempt) * time.Second) + } + // Pick a client on each attempt so a retry can land on a different + // access node if the previously selected one is unavailable. + client := s.DataAccessNodes.Client() + latest, err := client.LatestBlockHeader(ctx) + if err != nil { + log.Errorf("Failed to get the latest block header to validate fee receivers: %s", err) + continue + } + resp, err := client.Execute(ctx, latest.Id, s.scriptGetFeeReceivers, nil) + if err != nil { + log.Errorf("Failed to execute the get_fee_receivers script: %s", err) + continue + } + arr, ok := resp.(cadence.Array) + if !ok { + log.Errorf("Failed to convert get_fee_receivers result to an array: got %T", resp) + return + } + onchain := []string{} + missing := []string{} + for _, val := range arr.Values { + addr, ok := val.(cadence.Address) + if !ok { + log.Errorf("Failed to convert get_fee_receivers element to an address: got %T", val) + return + } + onchain = append(onchain, addr.String()) + if !s.feeAddrs[string(addr.Bytes())] { + missing = append(missing, addr.String()) + } + } + if len(missing) > 0 { + log.Fatalf( + "On-chain fee receiver account(s) %s are missing from the configured fee addresses: "+ + "fee deposits to them would be misclassified as transfers; add them to .contracts.fee_receivers", + strings.Join(missing, ", "), + ) + } + log.Infof( + "Validated the configured fee addresses against the on-chain fee receivers: %s", + strings.Join(onchain, ", "), + ) + return + } + log.Errorf("Giving up on fee receiver validation after %d attempts", attempts) +} + // NOTE(tav): We exit with a fatal error if the on-chain state doesn't match // what we expect. This assumes that we can trust the data returned to us by the // Access API servers, which may not necessarily be true. diff --git a/config/config.go b/config/config.go index 19e078a..a41b59b 100644 --- a/config/config.go +++ b/config/config.go @@ -84,6 +84,33 @@ type Contracts struct { FlowToken string `json:"flow_token"` FungibleToken string `json:"fungible_token"` FlowColdStorageProxy string `json:"flow_cold_storage_proxy"` + // FeeReceivers lists accounts, in addition to the FlowFees contract + // account, that receive transaction fee deposits. Networks may distribute + // fees across several receiver accounts: testnet does so since the + // FlowFees upgrade in transaction + // be210889dd26a320f530595bd369093e866e26c3941bf7a3d01f861db3eeda81 (the + // canonical list is returned by FlowFees.getFeeReceiverAddresses() on + // chain). Without them, fee deposits are misclassified as ordinary + // transfers. + FeeReceivers []string `json:"fee_receivers"` +} + +// FeeAddresses returns the set of accounts whose FLOW deposits represent +// transaction fees: the FlowFees contract account plus any configured +// fee_receivers. The map is keyed by the raw 8-byte address string. +func (c *Contracts) FeeAddresses() map[string]bool { + addrs := map[string]bool{} + for _, src := range append([]string{c.FlowFees}, c.FeeReceivers...) { + addr, err := hex.DecodeString(src) + if err != nil { + log.Fatalf("Invalid fee address %q: %s", src, err) + } + if len(addr) != 8 { + log.Fatalf("Invalid fee address %q: expected 8 bytes, got %d", src, len(addr)) + } + addrs[string(addr)] = true + } + return addrs } // Consensus defines the metadata needed to initialize a consensus follower for diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..dce6fc9 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,41 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFeeAddresses(t *testing.T) { + t.Run("defaults to the FlowFees contract account", func(t *testing.T) { + contracts := &Contracts{FlowFees: "912d5440f7e3769e"} + require.Equal(t, map[string]bool{ + "\x91\x2d\x54\x40\xf7\xe3\x76\x9e": true, + }, contracts.FeeAddresses()) + }) + + t.Run("includes configured fee receivers", func(t *testing.T) { + contracts := &Contracts{ + FlowFees: "912d5440f7e3769e", + FeeReceivers: []string{ + "e1ac6b2740d204c2", + "05cbd2fa5128041d", + "139fb7c9c82c0e7c", + }, + } + require.Equal(t, map[string]bool{ + "\x91\x2d\x54\x40\xf7\xe3\x76\x9e": true, + "\xe1\xac\x6b\x27\x40\xd2\x04\xc2": true, + "\x05\xcb\xd2\xfa\x51\x28\x04\x1d": true, + "\x13\x9f\xb7\xc9\xc8\x2c\x0e\x7c": true, + }, contracts.FeeAddresses()) + }) + + t.Run("deduplicates a receiver equal to the FlowFees account", func(t *testing.T) { + contracts := &Contracts{ + FlowFees: "912d5440f7e3769e", + FeeReceivers: []string{"912d5440f7e3769e"}, + } + require.Len(t, contracts.FeeAddresses(), 1) + }) +} diff --git a/go.mod b/go.mod index 73a3836..470383c 100644 --- a/go.mod +++ b/go.mod @@ -11,9 +11,9 @@ require ( github.com/golang/protobuf v1.5.4 github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/libp2p/go-libp2p v0.38.2 - github.com/onflow/cadence v1.10.3 + github.com/onflow/cadence v1.10.5 github.com/onflow/crypto v0.25.4 - github.com/onflow/flow-go v0.48.1-evm-cache-block.0.20260518173711-5b9fa9c8352e + github.com/onflow/flow-go v0.50.1-0.20260804214725-b73fea20b252 github.com/onflow/flow/protobuf/go/flow v0.4.20 github.com/rs/zerolog v1.29.0 github.com/stretchr/testify v1.11.1 @@ -267,11 +267,11 @@ require ( github.com/multiformats/go-multistream v0.6.0 // indirect github.com/multiformats/go-varint v0.0.7 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect - github.com/onflow/atree v0.16.0 // indirect - github.com/onflow/flow-core-contracts/lib/go/contracts v1.10.2 // indirect; v1.2.4-0.20230703193002-53362441b57d // indirect - github.com/onflow/flow-core-contracts/lib/go/templates v1.10.2 // indirect; v1.2.3 // indirect + github.com/onflow/atree v0.16.1 // indirect + github.com/onflow/flow-core-contracts/lib/go/contracts v1.10.4 // indirect; v1.2.4-0.20230703193002-53362441b57d // indirect + github.com/onflow/flow-core-contracts/lib/go/templates v1.10.4 // indirect; v1.2.3 // indirect github.com/onflow/flow-ft/lib/go/contracts v1.1.1 // indirect - github.com/onflow/flow-go-sdk v1.10.3 // indirect + github.com/onflow/flow-go-sdk v1.10.5 // indirect github.com/onflow/flow-nft/lib/go/contracts v1.4.1 // indirect github.com/onflow/go-ethereum v1.16.2 // indirect github.com/onflow/sdks v0.6.0-preview.1 // indirect diff --git a/go.sum b/go.sum index 21d72f9..50a6c16 100644 --- a/go.sum +++ b/go.sum @@ -732,30 +732,30 @@ github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JX github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= -github.com/onflow/atree v0.16.0 h1:b+f/suzcnnr1Lx1KdJEjpn2CX+AKSAz1yIB30NQDutU= -github.com/onflow/atree v0.16.0/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree v0.16.1 h1:EmlaIz/GwQ39o5agAb2KT2ynt4SHRBkgMMWU5bp6iTs= +github.com/onflow/atree v0.16.1/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/boxo v0.0.0-20240201202436-f2477b92f483 h1:LpiQhTAfM9CAmNVEs0n//cBBgCg+vJSiIxTHYUklZ84= github.com/onflow/boxo v0.0.0-20240201202436-f2477b92f483/go.mod h1:pIZgTWdm3k3pLF9Uq6MB8JEcW07UDwNJjlXW1HELW80= -github.com/onflow/cadence v1.10.3 h1:PJIIYKbaOT2DcZBnSO4O8ZF/Xc/fKV9vvOFLChgy85c= -github.com/onflow/cadence v1.10.3/go.mod h1:tyUNaYlAgeQVgfR2C38MI1dtFFjKay+yGGPMrCRc068= +github.com/onflow/cadence v1.10.5 h1:Y5kk4aY70SpxJtG/Wd05+xvkUL6tvodeQjSxnXNq65A= +github.com/onflow/cadence v1.10.5/go.mod h1:axaADpRs+qTlq5cdHBawCiJ7dgqusRbBqOPkyWUwUOo= github.com/onflow/crypto v0.25.4 h1:R615PWPdSoA5RATNb/j3cYaloBIZlSXVNgS7BjwHiwM= github.com/onflow/crypto v0.25.4/go.mod h1:DlkW/1SPUvLHYvUcjWa9PkLIRgSBKR4EDc3i+ATQKW4= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= github.com/onflow/fixed-point v0.1.1/go.mod h1:gJdoHqKtToKdOZbvryJvDZfcpzC7d2fyWuo3ZmLtcGY= -github.com/onflow/flow-core-contracts/lib/go/contracts v1.10.2 h1:OQr7LyoAzk9kVfVjPcHXoFtWIBSqbB7ksNb6wIJlFE8= -github.com/onflow/flow-core-contracts/lib/go/contracts v1.10.2/go.mod h1:fn0eOOINlOdQSOWptENC92MpPorB7dHzZaC3VTAmiQY= -github.com/onflow/flow-core-contracts/lib/go/templates v1.10.2 h1:qq16IwoT+xAh45GfmC9lR+gFCixJWx9NxKgkkP/W4Nw= -github.com/onflow/flow-core-contracts/lib/go/templates v1.10.2/go.mod h1:bXe+VkZmvM3QYGjfizprStRfasLA/7ii7l6+LHP5V1U= +github.com/onflow/flow-core-contracts/lib/go/contracts v1.10.4 h1:tUmbvKlApxfLfguNj9UCvgIB9kxiYqdPbK/IGdKzINQ= +github.com/onflow/flow-core-contracts/lib/go/contracts v1.10.4/go.mod h1:fn0eOOINlOdQSOWptENC92MpPorB7dHzZaC3VTAmiQY= +github.com/onflow/flow-core-contracts/lib/go/templates v1.10.4 h1:CifICeJM0FpOVzmGM328VT7OSJOQ7GRioZTRmSBrMgQ= +github.com/onflow/flow-core-contracts/lib/go/templates v1.10.4/go.mod h1:bXe+VkZmvM3QYGjfizprStRfasLA/7ii7l6+LHP5V1U= github.com/onflow/flow-evm-bridge v0.2.1 h1:S32kk+UV7/COdQZakIMsJw6vxShel0s8lI3FyGFl9jM= github.com/onflow/flow-evm-bridge v0.2.1/go.mod h1:ExhTZax2F+boo13dzT/uAI7rvwewAoz9v+dEXhhFjYg= github.com/onflow/flow-ft/lib/go/contracts v1.1.1 h1:BNbP3CrTIgScpx2NS9snq9XDESFjgXrMXTrwk5H4iSs= github.com/onflow/flow-ft/lib/go/contracts v1.1.1/go.mod h1:PwsL8fC81cjnUnTfmyL/HOIyHnyaw/JA474Wfj2tl6A= github.com/onflow/flow-ft/lib/go/templates v1.1.1 h1:X+EGTWKeVlsF33JD5QBFZLr8KW2apl6Oh1AXRWHmzLI= github.com/onflow/flow-ft/lib/go/templates v1.1.1/go.mod h1:uQ8XFqmMK2jxyBSVrmyuwdWjTEb+6zGjRYotfDJ5pAE= -github.com/onflow/flow-go v0.48.1-evm-cache-block.0.20260518173711-5b9fa9c8352e h1:n8yp4pz72O0CqHwzCmTgpeYyxwOVf8Vth1jEn2/4wIk= -github.com/onflow/flow-go v0.48.1-evm-cache-block.0.20260518173711-5b9fa9c8352e/go.mod h1:x+/B1Ki53/TbRtdd317T6wmQ2QRMF4YKhkSE+3fy76A= -github.com/onflow/flow-go-sdk v1.10.3 h1:4zJYkdDNqeQqUJmdQJXlHIZuEjOLp8lsu8dRz5GZ/Cc= -github.com/onflow/flow-go-sdk v1.10.3/go.mod h1:cnpuCUvKLGqVrhz6yPEv0+LdsT9ib+cbn0YxfAJxHEI= +github.com/onflow/flow-go v0.50.1-0.20260804214725-b73fea20b252 h1:XUvRo0Zt8GQtgSHKzcQ/EpCm1HdSthNEgcNci2wJzMU= +github.com/onflow/flow-go v0.50.1-0.20260804214725-b73fea20b252/go.mod h1:NPiMixDFGz4/IQjdeV4phjZfbfQgxtmNJ9zU9DWkcYg= +github.com/onflow/flow-go-sdk v1.10.5 h1:aE9E2xXW2AiR/7KzZApZ8HpmEzsLdNQWso5y/RHADsQ= +github.com/onflow/flow-go-sdk v1.10.5/go.mod h1:efpOBjGw/Gmdu2yKcAjVsAARUekyHL06QftCXXQebM8= github.com/onflow/flow-nft/lib/go/contracts v1.4.1 h1:iQ8s4W5HNWd92MVRZbKxYpQ6UJn9snHLKQ9hFFNCiys= github.com/onflow/flow-nft/lib/go/contracts v1.4.1/go.mod h1:XUsJjlbVoI0kebgv87xsO70U/ITGYbSEgTwbyg1RcOs= github.com/onflow/flow-nft/lib/go/templates v1.4.1 h1:P+FN51waQrACpyVeXzLl1cnlD5J8bUYiemHXgeZBM+8= diff --git a/script/cadence/scripts/get-fee-receivers.cdc b/script/cadence/scripts/get-fee-receivers.cdc new file mode 100644 index 0000000..51bf9eb --- /dev/null +++ b/script/cadence/scripts/get-fee-receivers.cdc @@ -0,0 +1,9 @@ +import FlowFees from 0x{{.Contracts.FlowFees}} + +// Returns the addresses of all accounts that may receive transaction fee +// deposits: the FlowFees contract account itself, plus any child fee accounts +// that FlowFees.deductTransactionFee rotates deposits across (see +// onflow/flow-core-contracts#575, "Enable concurrent fee collection"). +access(all) fun main(): [Address] { + return FlowFees.getFeeReceiverAddresses() +} diff --git a/script/script.go b/script/script.go index be47b2d..6d7e00e 100644 --- a/script/script.go +++ b/script/script.go @@ -56,6 +56,14 @@ var GetBalances string //go:embed cadence/scripts/get-balances-basic.cdc var GetBalancesBasic string +// GetFeeReceivers defines the template for the read-only transaction script +// that returns the addresses of all accounts that may receive transaction fee +// deposits: the FlowFees contract account plus any child fee accounts +// configured on chain. +// +//go:embed cadence/scripts/get-fee-receivers.cdc +var GetFeeReceivers string + // GetProxyNonce defines the template for the read-only transaction script that // returns a proxy account's sequence number, i.e. the next nonce value for its // FlowColdStorageProxy Vault. diff --git a/script/script_test.go b/script/script_test.go index 0557ce4..ffb5e38 100644 --- a/script/script_test.go +++ b/script/script_test.go @@ -2,8 +2,10 @@ package script import ( "context" - "github.com/onflow/rosetta/config" + "strings" "testing" + + "github.com/onflow/rosetta/config" ) // TestCompile tests the Compile function @@ -22,3 +24,24 @@ func TestCompileComputeFees(t *testing.T) { t.Errorf("Expected %q but got %q", expected, string(result)) } } + +// TestCompileGetFeeReceivers tests that the FlowFees address is rendered into +// the get-fee-receivers script. +// +// NOTE: config.Init cannot be called a second time within the same test +// binary (it locks the Badger cache database), so the chain is constructed +// directly. +func TestCompileGetFeeReceivers(t *testing.T) { + chain := &config.Chain{Contracts: &config.Contracts{FlowFees: "912d5440f7e3769e"}} + + result := string(Compile("get_fee_receivers", GetFeeReceivers, chain)) + + for _, expected := range []string{ + "import FlowFees from 0x912d5440f7e3769e", + "return FlowFees.getFeeReceiverAddresses()", + } { + if !strings.Contains(result, expected) { + t.Errorf("Expected compiled script to contain %q:\n%s", expected, result) + } + } +} diff --git a/state/process.go b/state/process.go index da8111c..c442345 100644 --- a/state/process.go +++ b/state/process.go @@ -803,7 +803,7 @@ outer: Receiver: receiver[:], Type: model.TransferType_DEPOSIT, }) - if bytes.Equal(receiver[:], i.feeAddr) { + if i.feeAddrs[string(receiver[:])] { // NOTE(tav): When the deposit is to the fee // address, just increment the fee amount. fees += amount @@ -904,9 +904,9 @@ outer: // of our tracked accounts. if i.isTracked(payer, newAccounts) && fees > 0 { // NOTE(tav): This is theoretically possible if someone - // manually deposits FLOW into the FlowFees contract. + // manually deposits FLOW into a fee address. // - // We explicitly disallow making direct transfers to the fee + // We explicitly disallow making direct transfers to a fee // address within transaction construction. But, just in // case, we add this additional check here which is // effectively a fatal error. diff --git a/state/state.go b/state/state.go index 6019aa5..9e9e92d 100644 --- a/state/state.go +++ b/state/state.go @@ -55,7 +55,7 @@ type Indexer struct { Store *indexdb.Store accts map[string]bool consensus storage.DB - feeAddr []byte + feeAddrs map[string]bool jobs chan uint64 lastIndexed *model.BlockMeta liveRoot *model.BlockMeta @@ -541,13 +541,7 @@ func (i *Indexer) initState() { for acct, isProxy := range accts { i.accts[string(acct[:])] = isProxy } - i.feeAddr, err = hex.DecodeString(i.Chain.Contracts.FlowFees) - if err != nil { - log.Fatalf( - "Invalid FlowFees contract address %q: %s", - i.Chain.Contracts.FlowFees, err, - ) - } + i.feeAddrs = i.Chain.Contracts.FeeAddresses() i.originators = map[string]bool{} for _, addr := range i.Chain.Originators { i.originators[string(addr)] = true diff --git a/testnet.json b/testnet.json index 73d7103..0a63ff7 100644 --- a/testnet.json +++ b/testnet.json @@ -6,6 +6,11 @@ } ], "contracts": { + "fee_receivers": [ + "e1ac6b2740d204c2", + "05cbd2fa5128041d", + "139fb7c9c82c0e7c" + ], "flow_cold_storage_proxy": "0000000000000000", "flow_fees": "912d5440f7e3769e", "flow_token": "7e60df042a9c0868",