Repository: hyperledger/firefly
Pull request #919 (2022-08-02 18:33:00)

E2E test for tokens only namespaces
* Adds an E2E test for namespaces with only a `tokens` plugin
* Moves `keynormalization` config under `namespaces.predefined[].assets.manager.keynormalization`
* Adds common for code adding namespaces, resetting firefly, etc. to a common package


Patch:
diff --git a/docs/reference/config.md b/docs/reference/config.md
index e1009783d..72c388127 100644
--- a/docs/reference/config.md
+++ b/docs/reference/config.md
@@ -30,7 +30,7 @@ nav_order: 2
 
 |Key|Description|Type|Default Value|
 |---|-----------|----|-------------|
-|keyNormalization|Mechanism to normalize keys before using them. Valid options are `blockchain_plugin` - use blockchain plugin (default) or `none` - do not attempt normalization|`string`|`<nil>`
+|keynormalization|Mechanism to normalize keys before using them. Valid options are `blockchain_plugin` - use blockchain plugin (default) or `none` - do not attempt normalization (deprecated - use namespaces.predefined[].asset.manager.keynormalization)|`string`|`<nil>`
 
 ## batch.cache
 
@@ -132,10 +132,10 @@ nav_order: 2
 |batchTimeout|How long Ethconnect should wait for new events to arrive and fill a batch, before sending the batch to FireFly core. Only applies when automatically creating a new event stream|[`time.Duration`](https://pkg.go.dev/time#Duration)|`500`
 |connectionTimeout|The maximum amount of time that a connection is allowed to remain with no data transmitted|[`time.Duration`](https://pkg.go.dev/time#Duration)|`30s`
 |expectContinueTimeout|See [ExpectContinueTimeout in the Go docs](https://pkg.go.dev/net/http#Transport)|[`time.Duration`](https://pkg.go.dev/time#Duration)|`1s`
-|fromBlock|The first event this FireFly instance should listen to from the BatchPin smart contract. Default=0. Only affects initial creation of the event stream (deprecated - use namespace.predefined[].multiparty.contract[].location.firstEvent)|Address `string`|`0`
+|fromBlock|The first event this FireFly instance should listen to from the BatchPin smart contract. Default=0. Only affects initial creation of the event stream (deprecated - use namespaces.predefined[].multiparty.contract[].location.firstEvent)|Address `string`|`0`
 |headers|Adds custom headers to HTTP requests|`map[string]string`|`<nil>`
 |idleTimeout|The max duration to hold a HTTP keepalive connection between calls|[`time.Duration`](https://pkg.go.dev/time#Duration)|`475ms`
-|instance|The Ethereum address of the FireFly BatchPin smart contract that has been deployed to the blockchain (deprecated - use namespace.predefined[].multiparty.contract[].location.address)|Address `string`|`<nil>`
+|instance|The Ethereum address of the FireFly BatchPin smart contract that has been deployed to the blockchain (deprecated - use namespaces.predefined[].multiparty.contract[].location.address)|Address `string`|`<nil>`
 |maxIdleConns|The max number of idle connections to hold pooled|`int`|`100`
 |prefixLong|The prefix that will be used for Ethconnect specific HTTP headers when FireFly makes requests to Ethconnect|`string`|`firefly`
 |prefixShort|The prefix that will be used for Ethconnect specific query parameters when FireFly makes requests to Ethconnect|`string`|`fly`
@@ -217,8 +217,8 @@ nav_order: 2
 |---|-----------|----|-------------|
 |batchSize|The number of events Fabconnect should batch together for delivery to FireFly core. Only applies when automatically creating a new event stream|`int`|`50`
 |batchTimeout|The maximum amount of time to wait for a batch to complete|[`time.Duration`](https://pkg.go.dev/time#Duration)|`500`
-|chaincode|The name of the Fabric chaincode that FireFly will use for BatchPin transactions (deprecated - use namespace.predefined[].multiparty.contract[].location.chaincode)|`string`|`<nil>`
-|channel|The Fabric channel that FireFly will use for BatchPin transactions (deprecated - use namespace.predefined[].multiparty.contract[].location.channel)|`string`|`<nil>`
+|chaincode|The name of the Fabric chaincode that FireFly will use for BatchPin transactions (deprecated - use namespaces.predefined[].multiparty.contract[].location.chaincode)|`string`|`<nil>`
+|channel|The Fabric channel that FireFly will use for BatchPin transactions (deprecated - use namespaces.predefined[].multiparty.contract[].location.channel)|`string`|`<nil>`
 |connectionTimeout|The maximum amount of time that a connection is allowed to remain with no data transmitted|[`time.Duration`](https://pkg.go.dev/time#Duration)|`30s`
 |expectContinueTimeout|See [ExpectContinueTimeout in the Go docs](https://pkg.go.dev/net/http#Transport)|[`time.Duration`](https://pkg.go.dev/time#Duration)|`1s`
 |headers|Adds custom headers to HTTP requests|`map[string]string`|`<nil>`
@@ -622,6 +622,12 @@ nav_order: 2
 |plugins|The list of plugins for this namespace|`string`|`<nil>`
 |remotename|The namespace name to be sent in plugin calls, if it differs from namespace name|`string`|`<nil>`
 
+## namespaces.predefined[].asset.manager
+
+|Key|Description|Type|Default Value|
+|---|-----------|----|-------------|
+|keynormalization|Mechanism to normalize keys before using them. Valid options are `blockchain_plugin` - use blockchain plugin (default) or `none` - do not attempt normalization|`string`|`<nil>`
+
 ## namespaces.predefined[].multiparty
 
 |Key|Description|Type|Default Value|
diff --git a/internal/assets/manager.go b/internal/assets/manager.go
index 2d9228439..fc05916fe 100644
--- a/internal/assets/manager.go
+++ b/internal/assets/manager.go
@@ -19,11 +19,9 @@ package assets
 import (
 	"context"
 
-	"github.com/hyperledger/firefly-common/pkg/config"
 	"github.com/hyperledger/firefly-common/pkg/fftypes"
 	"github.com/hyperledger/firefly-common/pkg/i18n"
 	"github.com/hyperledger/firefly/internal/broadcast"
-	"github.com/hyperledger/firefly/internal/coreconfig"
 	"github.com/hyperledger/firefly/internal/coremsgs"
 	"github.com/hyperledger/firefly/internal/identity"
 	"github.com/hyperledger/firefly/internal/metrics"
@@ -84,7 +82,7 @@ type assetManager struct {
 	keyNormalization int
 }
 
-func NewAssetManager(ctx context.Context, ns string, di database.Plugin, ti map[string]tokens.Plugin, im identity.Manager, sa syncasync.Bridge, bm broadcast.Manager, pm privatemessaging.Manager, mm metrics.Manager, om operations.Manager, txHelper txcommon.Helper) (Manager, error) {
+func NewAssetManager(ctx context.Context, ns, keyNormalization string, di database.Plugin, ti map[string]tokens.Plugin, im identity.Manager, sa syncasync.Bridge, bm broadcast.Manager, pm privatemessaging.Manager, mm metrics.Manager, om operations.Manager, txHelper txcommon.Helper) (Manager, error) {
 	if di == nil || im == nil || sa == nil || ti == nil || mm == nil || om == nil {
 		return nil, i18n.NewError(ctx, coremsgs.MsgInitializationNilDepError, "AssetManager")
 	}
@@ -98,7 +96,7 @@ func NewAssetManager(ctx context.Context, ns string, di database.Plugin, ti map[
 		broadcast:        bm,
 		messaging:        pm,
 		tokens:           ti,
-		keyNormalization: identity.ParseKeyNormalizationConfig(config.GetString(coreconfig.AssetManagerKeyNormalization)),
+		keyNormalization: identity.ParseKeyNormalizationConfig(keyNormalization),
 		metrics:          mm,
 		operations:       om,
 	}
diff --git a/internal/coreconfig/coreconfig.go b/internal/coreconfig/coreconfig.go
index 6e04aa28d..e3b3498fd 100644
--- a/internal/coreconfig/coreconfig.go
+++ b/internal/coreconfig/coreconfig.go
@@ -41,6 +41,8 @@ const (
 	NamespacePlugins = "plugins"
 	// NamespaceDefaultKey is the default signing key for blockchain transactions within this namespace
 	NamespaceDefaultKey = "defaultkey"
+	// NamespaceAssetKeyNormalization mechanism to normalize keys before using them. Valid options: "blockchain_plugin" - use blockchain plugin (default), "none" - do not attempt normalization
+	NamespaceAssetKeyNormalization = "asset.manager.keynormalization"
 	// NamespaceMultiparty contains the multiparty configuration for a namespace
 	NamespaceMultiparty = "multiparty"
 	// NamespaceMultipartyEnabled specifies if multi-party mode is enabled for a namespace
@@ -287,7 +289,7 @@ var (
 	// TransactionCacheTTL
 	TransactionCacheTTL = ffc("transaction.cache.ttl")
 	// AssetManagerKeyNormalization mechanism to normalize keys before using them. Valid options: "blockchain_plugin" - use blockchain plugin (default), "none" - do not attempt normalization
-	AssetManagerKeyNormalization = ffc("asset.manager.keyNormalization")
+	AssetManagerKeyNormalization = ffc("asset.manager.keynormalization")
 	// UIEnabled set to false to disable the UI (default is true, so UI will be enabled if ui.path is valid)
 	UIEnabled = ffc("ui.enabled")
 	// UIPath the path on which to serve the UI
diff --git a/internal/coremsgs/en_config_descriptions.go b/internal/coremsgs/en_config_descriptions.go
index cd26b5283..c9c062fbd 100644
--- a/internal/coremsgs/en_config_descriptions.go
+++ b/internal/coremsgs/en_config_descriptions.go
@@ -41,7 +41,7 @@ var (
 	ConfigAPIDefaultFilterLimit        = ffc("config.api.defaultFilterLimit", "The maximum number of rows to return if no limit is specified on an API request", i18n.IntType)
 	ConfigAPIMaxFilterLimit            = ffc("config.api.maxFilterLimit", "The largest value of `limit` that an HTTP client can specify in a request", i18n.IntType)
 	ConfigAPIRequestMaxTimeout         = ffc("config.api.requestMaxTimeout", "The maximum amount of time that an HTTP client can specify in a `Request-Timeout` header to keep a specific request open", i18n.TimeDurationType)
-	ConfigAssetManagerKeyNormalization = ffc("config.asset.manager.keyNormalization", "Mechanism to normalize keys before using them. Valid options are `blockchain_plugin` - use blockchain plugin (default) or `none` - do not attempt normalization", i18n.StringType)
+	ConfigAssetManagerKeyNormalization = ffc("config.asset.manager.keynormalization", "Mechanism to normalize keys before using them. Valid options are `blockchain_plugin` - use blockchain plugin (default) or `none` - do not attempt normalization (deprecated - use namespaces.predefined[].asset.manager.keynormalization)", i18n.StringType)
 
 	ConfigBatchManagerMinimumPollDelay = ffc("config.batch.manager.minimumPollDelay", "The minimum time the batch manager waits between polls on the DB - to prevent thrashing", i18n.TimeDurationType)
 	ConfigBatchManagerPollTimeout      = ffc("config.batch.manager.pollTimeout", "How long to wait without any notifications of new messages before doing a page query", i18n.TimeDurationType)
@@ -70,8 +70,8 @@ var (
 
 	ConfigBlockchainEthereumEthconnectBatchSize    = ffc("config.blockchain.ethereum.ethconnect.batchSize", "The number of events Ethconnect should batch together for delivery to FireFly core. Only applies when automatically creating a new event stream", i18n.IntType)
 	ConfigBlockchainEthereumEthconnectBatchTimeout = ffc("config.blockchain.ethereum.ethconnect.batchTimeout", "How long Ethconnect should wait for new events to arrive and fill a batch, before sending the batch to FireFly core. Only applies when automatically creating a new event stream", i18n.TimeDurationType)
-	ConfigBlockchainEthereumEthconnectInstance     = ffc("config.blockchain.ethereum.ethconnect.instance", "The Ethereum address of the FireFly BatchPin smart contract that has been deployed to the blockchain (deprecated - use namespace.predefined[].multiparty.contract[].location.address)", "Address "+i18n.StringType)
-	ConfigBlockchainEthereumEthconnectFromBlock    = ffc("config.blockchain.ethereum.ethconnect.fromBlock", "The first event this FireFly instance should listen to from the BatchPin smart contract. Default=0. Only affects initial creation of the event stream (deprecated - use namespace.predefined[].multiparty.contract[].location.firstEvent)", "Address "+i18n.StringType)
+	ConfigBlockchainEthereumEthconnectInstance     = ffc("config.blockchain.ethereum.ethconnect.instance", "The Ethereum address of the FireFly BatchPin smart contract that has been deployed to the blockchain (deprecated - use namespaces.predefined[].multiparty.contract[].location.address)", "Address "+i18n.StringType)
+	ConfigBlockchainEthereumEthconnectFromBlock    = ffc("config.blockchain.ethereum.ethconnect.fromBlock", "The first event this FireFly instance should listen to from the BatchPin smart contract. Default=0. Only affects initial creation of the event stream (deprecated - use namespaces.predefined[].multiparty.contract[].location.firstEvent)", "Address "+i18n.StringType)
 	ConfigBlockchainEthereumEthconnectPrefixLong   = ffc("config.blockchain.ethereum.ethconnect.prefixLong", "The prefix that will be used for Ethconnect specific HTTP headers when FireFly makes requests to Ethconnect", i18n.StringType)
 	ConfigBlockchainEthereumEthconnectPrefixShort  = ffc("config.blockchain.ethereum.ethconnect.prefixShort", "The prefix that will be used for Ethconnect specific query parameters when FireFly makes requests to Ethconnect", i18n.StringType)
 	ConfigBlockchainEthereumEthconnectTopic        = ffc("config.blockchain.ethereum.ethconnect.topic", "The websocket listen topic that the node should register on, which is important if there are multiple nodes using a single ethconnect", i18n.StringType)
@@ -83,8 +83,8 @@ var (
 
 	ConfigBlockchainFabricFabconnectBatchSize    = ffc("config.blockchain.fabric.fabconnect.batchSize", "The number of events Fabconnect should batch together for delivery to FireFly core. Only applies when automatically creating a new event stream", i18n.IntType)
 	ConfigBlockchainFabricFabconnectBatchTimeout = ffc("config.blockchain.fabric.fabconnect.batchTimeout", "The maximum amount of time to wait for a batch to complete", i18n.TimeDurationType)
-	ConfigBlockchainFabricFabconnectChaincode    = ffc("config.blockchain.fabric.fabconnect.chaincode", "The name of the Fabric chaincode that FireFly will use for BatchPin transactions (deprecated - use namespace.predefined[].multiparty.contract[].location.chaincode)", i18n.StringType)
-	ConfigBlockchainFabricFabconnectChannel      = ffc("config.blockchain.fabric.fabconnect.channel", "The Fabric channel that FireFly will use for BatchPin transactions (deprecated - use namespace.predefined[].multiparty.contract[].location.channel)", i18n.StringType)
+	ConfigBlockchainFabricFabconnectChaincode    = ffc("config.blockchain.fabric.fabconnect.chaincode", "The name of the Fabric chaincode that FireFly will use for BatchPin transactions (deprecated - use namespaces.predefined[].multiparty.contract[].location.chaincode)", i18n.StringType)
+	ConfigBlockchainFabricFabconnectChannel      = ffc("config.blockchain.fabric.fabconnect.channel", "The Fabric channel that FireFly will use for BatchPin transactions (deprecated - use namespaces.predefined[].multiparty.contract[].location.channel)", i18n.StringType)
 	ConfigBlockchainFabricFabconnectPrefixLong   = ffc("config.blockchain.fabric.fabconnect.prefixLong", "The prefix that will be used for Fabconnect specific HTTP headers when FireFly makes requests to Fabconnect", i18n.StringType)
 	ConfigBlockchainFabricFabconnectPrefixShort  = ffc("config.blockchain.fabric.fabconnect.prefixShort", "The prefix that will be used for Fabconnect specific query parameters when FireFly makes requests to Fabconnect", i18n.StringType)
 	ConfigBlockchainFabricFabconnectSigner       = ffc("config.blockchain.fabric.fabconnect.signer", "The Fabric signing key to use when submitting transactions to Fabconnect", i18n.StringType)
@@ -257,6 +257,7 @@ var (
 	ConfigNamespacesPredefinedPlugins            = ffc("config.namespaces.predefined[].plugins", "The list of plugins for this namespace", i18n.StringType)
 	ConfigNamespacesPredefinedRemoteName         = ffc("config.namespaces.predefined[].remotename", "The namespace name to be sent in plugin calls, if it differs from namespace name", i18n.StringType)
 	ConfigNamespacesPredefinedDefaultKey         = ffc("config.namespaces.predefined[].defaultkey", "A default signing key for blockchain transactions within this namespace", i18n.StringType)
+	ConfigNamespacesPredefinedKeyNormalization   = ffc("config.namespaces.predefined[].asset.manager.keynormalization", "Mechanism to normalize keys before using them. Valid options are `blockchain_plugin` - use blockchain plugin (default) or `none` - do not attempt normalization", i18n.StringType)
 	ConfigNamespacesMultipartyEnabled            = ffc("config.namespaces.predefined[].multiparty.enabled", "Enables multi-party mode for this namespace (defaults to true if an org name or key is configured, either here or at the root level)", i18n.BooleanType)
 	ConfigNamespacesMultipartyOrgName            = ffc("config.namespaces.predefined[].multiparty.org.name", "A short name for the local root organization within this namespace", i18n.StringType)
 	ConfigNamespacesMultipartyOrgDesc            = ffc("config.namespaces.predefined[].multiparty.org.description", "A description for the local root organization within this namespace", i18n.StringType)
diff --git a/internal/identity/identitymanager.go b/internal/identity/identitymanager.go
index 38dd73318..5c862b108 100644
--- a/internal/identity/identitymanager.go
+++ b/internal/identity/identitymanager.go
@@ -135,7 +135,7 @@ func (im *identityManager) NormalizeSigningKey(ctx context.Context, inputKey str
 	}
 	// If the caller is not confident that the blockchain plugin/connector should be used to resolve,
 	// for example it might be a different blockchain (Eth vs Fabric etc.), or it has its own
-	// verification/management of keys, it should set `assets.keyNormalization: "none"` in the config.
+	// verification/management of keys, it should set `namespaces.predefined[].asset.manager.keynormalization: "none"` in the config.
 	if keyNormalizationMode != KeyNormalizationBlockchainPlugin {
 		return inputKey, nil
 	}
diff --git a/internal/namespace/config.go b/internal/namespace/config.go
index 1a8909464..c8d57abcd 100644
--- a/internal/namespace/config.go
+++ b/internal/namespace/config.go
@@ -39,6 +39,7 @@ func InitConfig(withDefaults bool) {
 	namespacePredefined.AddKnownKey(coreconfig.NamespaceRemoteName)
 	namespacePredefined.AddKnownKey(coreconfig.NamespacePlugins)
 	namespacePredefined.AddKnownKey(coreconfig.NamespaceDefaultKey)
+	namespacePredefined.AddKnownKey(coreconfig.NamespaceAssetKeyNormalization)
 
 	multipartyConf := namespacePredefined.SubSection(coreconfig.NamespaceMultiparty)
 	multipartyConf.AddKnownKey(coreconfig.NamespaceMultipartyEnabled)
@@ -55,5 +56,6 @@ func InitConfig(withDefaults bool) {
 	if withDefaults {
 		namespaceConfig.AddKnownKey(NamespacePredefined+".0."+coreconfig.NamespaceName, "default")
 		namespaceConfig.AddKnownKey(NamespacePredefined+".0."+coreconfig.NamespaceDescription, "Default predefined namespace")
+		namespaceConfig.AddKnownKey(NamespacePredefined+".0."+coreconfig.NamespaceAssetKeyNormalization, "blockchain_plugin")
 	}
 }
diff --git a/internal/namespace/manager.go b/internal/namespace/manager.go
index 07e22cad9..2dca5d5a0 100644
--- a/internal/namespace/manager.go
+++ b/internal/namespace/manager.go
@@ -791,6 +791,11 @@ func (nm *namespaceManager) loadNamespace(ctx context.Context, name string, inde
 		remoteName = name
 	}
 
+	keyNormalization := conf.GetString(coreconfig.NamespaceAssetKeyNormalization)
+	if keyNormalization == "" {
+		keyNormalization = config.GetString(coreconfig.AssetManagerKeyNormalization)
+	}
+
 	multipartyConf := conf.SubSection(coreconfig.NamespaceMultiparty)
 	// If any multiparty org information is configured (here or at the root), assume multiparty mode by default
 	orgName := multipartyConf.GetString(coreconfig.NamespaceMultipartyOrgName)
@@ -859,6 +864,7 @@ func (nm *namespaceManager) loadNamespace(ctx context.Context, name string, inde
 	config := orchestrator.Config{
 		DefaultKey:       conf.GetString(coreconfig.NamespaceDefaultKey),
 		TokenRemoteNames: nm.tokenRemoteNames,
+		KeyNormalization: keyNormalization,
 	}
 	if multipartyEnabled.(bool) {
 		contractsConf := multipartyConf.SubArray(coreconfig.NamespaceMultipartyContract)
diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go
index 5f44e3b0d..21ed04f27 100644
--- a/internal/orchestrator/orchestrator.go
+++ b/internal/orchestrator/orchestrator.go
@@ -170,6 +170,7 @@ type Plugins struct {
 
 type Config struct {
 	DefaultKey       string
+	KeyNormalization string
 	Multiparty       multiparty.Config
 	TokenRemoteNames map[string]string
 }
@@ -449,7 +450,7 @@ func (or *orchestrator) initManagers(ctx context.Context) (err error) {
 	}
 
 	if or.assets == nil {
-		or.assets, err = assets.NewAssetManager(ctx, or.namespace.LocalName, or.database(), or.tokens(), or.identity, or.syncasync, or.broadcast, or.messaging, or.metrics, or.operations, or.txHelper)
+		or.assets, err = assets.NewAssetManager(ctx, or.namespace.LocalName, or.config.KeyNormalization, or.database(), or.tokens(), or.identity, or.syncasync, or.broadcast, or.messaging, or.metrics, or.operations, or.txHelper)
 		if err != nil {
 			return err
 		}


Test patch:
diff --git a/internal/assets/manager_test.go b/internal/assets/manager_test.go
index 6cae98b09..851cf54ff 100644
--- a/internal/assets/manager_test.go
+++ b/internal/assets/manager_test.go
@@ -63,7 +63,7 @@ func newTestAssetsCommon(t *testing.T, metrics bool) (*assetManager, func()) {
 	mom.On("RegisterHandler", mock.Anything, mock.Anything, mock.Anything)
 	mti.On("Name").Return("ut").Maybe()
 	ctx, cancel := context.WithCancel(context.Background())
-	a, err := NewAssetManager(ctx, "ns1", mdi, map[string]tokens.Plugin{"magic-tokens": mti}, mim, msa, mbm, mpm, mm, mom, txHelper)
+	a, err := NewAssetManager(ctx, "ns1", "blockchain_plugin", mdi, map[string]tokens.Plugin{"magic-tokens": mti}, mim, msa, mbm, mpm, mm, mom, txHelper)
 	rag := mdi.On("RunAsGroup", mock.Anything, mock.Anything).Maybe()
 	rag.RunFn = func(a mock.Arguments) {
 		rag.ReturnArguments = mock.Arguments{a[1].(func(context.Context) error)(a[0].(context.Context))}
@@ -75,7 +75,7 @@ func newTestAssetsCommon(t *testing.T, metrics bool) (*assetManager, func()) {
 }
 
 func TestInitFail(t *testing.T) {
-	_, err := NewAssetManager(context.Background(), "", nil, nil, nil, nil, nil, nil, nil, nil, nil)
+	_, err := NewAssetManager(context.Background(), "", "", nil, nil, nil, nil, nil, nil, nil, nil, nil)
 	assert.Regexp(t, "FF10128", err)
 }
 
diff --git a/test/e2e/common.go b/test/e2e/common.go
new file mode 100644
index 000000000..d975a44dc
--- /dev/null
+++ b/test/e2e/common.go
@@ -0,0 +1,70 @@
+// Copyright © 2022 Kaleido, Inc.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package e2e
+
+import (
+	"crypto/rand"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"testing"
+
+	"github.com/go-resty/resty/v2"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"gopkg.in/yaml.v2"
+)
+
+func ReadConfig(t *testing.T, configFile string) map[string]interface{} {
+	yfile, err := ioutil.ReadFile(configFile)
+	assert.NoError(t, err)
+	data := make(map[string]interface{})
+	err = yaml.Unmarshal(yfile, &data)
+	assert.NoError(t, err)
+	return data
+}
+
+func WriteConfig(t *testing.T, configFile string, data map[string]interface{}) {
+	out, err := yaml.Marshal(data)
+	assert.NoError(t, err)
+	f, err := os.Create(configFile)
+	assert.NoError(t, err)
+	_, err = f.Write(out)
+	assert.NoError(t, err)
+	f.Close()
+}
+
+func AddNamespace(data map[string]interface{}, ns map[string]interface{}) {
+	namespaces := data["namespaces"].(map[interface{}]interface{})
+	predefined := namespaces["predefined"].([]interface{})
+	namespaces["predefined"] = append(predefined, ns)
+}
+
+func ResetFireFly(t *testing.T, client *resty.Client) {
+	resp, err := client.R().
+		SetBody(map[string]interface{}{}).
+		Post("/reset")
+	require.NoError(t, err)
+	assert.Equal(t, 204, resp.StatusCode())
+}
+
+func RandomName(t *testing.T) string {
+	b := make([]byte, 5)
+	_, err := rand.Read(b)
+	assert.NoError(t, err)
+	return fmt.Sprintf("e2e_%x", b)
+}
diff --git a/test/e2e/gateway/common.go b/test/e2e/gateway/common.go
index 8f1fef2b9..9b707a288 100644
--- a/test/e2e/gateway/common.go
+++ b/test/e2e/gateway/common.go
@@ -17,18 +17,17 @@
 package gateway
 
 import (
-	"crypto/rand"
 	"encoding/base64"
 	"fmt"
 	"net/http"
 	"os"
+	"path/filepath"
 	"testing"
 	"time"
 
 	"github.com/gorilla/websocket"
 	"github.com/hyperledger/firefly/test/e2e"
 	"github.com/hyperledger/firefly/test/e2e/client"
-	"github.com/stretchr/testify/assert"
 )
 
 const (
@@ -44,6 +43,9 @@ type testState struct {
 	client1              *client.FireFlyClient
 	unregisteredAccounts []interface{}
 	namespace            string
+	stackName            string
+	adminHost1           string
+	configFile1          string
 }
 
 func (m *testState) T() *testing.T {
@@ -59,6 +61,10 @@ func (m *testState) Done() func() {
 }
 
 func beforeE2ETest(t *testing.T) *testState {
+	stackDir := os.Getenv("STACK_DIR")
+	if stackDir == "" {
+		t.Fatal("STACK_DIR must be set")
+	}
 	stack := e2e.ReadStack(t)
 	stackState := e2e.ReadStackState(t)
 
@@ -74,7 +80,9 @@ func beforeE2ETest(t *testing.T) *testState {
 		member0WithPort = fmt.Sprintf(":%d", stack.Members[0].ExposedFireflyPort)
 	}
 
-	baseURL := fmt.Sprintf("%s://%s%s", httpProtocolClient1, stack.Members[0].FireflyHostname, member0WithPort)
+	base1 := fmt.Sprintf("%s://%s%s", httpProtocolClient1, stack.Members[0].FireflyHostname, member0WithPort)
+	admin1 := fmt.Sprintf("%s://%s:%d", httpProtocolClient1, stack.Members[0].FireflyHostname, stack.Members[0].ExposedAdminPort)
+
 	namespace := os.Getenv("NAMESPACE")
 	if namespace == "" {
 		namespace = "default"
@@ -82,10 +90,13 @@ func beforeE2ETest(t *testing.T) *testState {
 
 	ts := &testState{
 		t:                    t,
+		stackName:            stack.Name,
 		startTime:            time.Now(),
-		client1:              client.NewFireFly(t, baseURL, namespace),
+		client1:              client.NewFireFly(t, base1, namespace),
 		unregisteredAccounts: stackState.Accounts[2:],
 		namespace:            namespace,
+		adminHost1:           admin1,
+		configFile1:          filepath.Join(stackDir, "runtime", "config", "firefly_core_0.yml"),
 	}
 
 	t.Logf("Blockchain provider: %s", stack.BlockchainProvider)
@@ -117,10 +128,3 @@ func beforeE2ETest(t *testing.T) *testState {
 	}
 	return ts
 }
-
-func randomName(t *testing.T) string {
-	b := make([]byte, 5)
-	_, err := rand.Read(b)
-	assert.NoError(t, err)
-	return fmt.Sprintf("e2e_%x", b)
-}
diff --git a/test/e2e/gateway/tokens.go b/test/e2e/gateway/tokens.go
index 81cb49a22..8e67a2711 100644
--- a/test/e2e/gateway/tokens.go
+++ b/test/e2e/gateway/tokens.go
@@ -56,7 +56,7 @@ func (suite *TokensTestSuite) TestE2EFungibleTokensAsync() {
 
 	received1 := e2e.WsReader(suite.testState.ws1)
 
-	poolName := fmt.Sprintf("pool_%s", randomName(suite.T()))
+	poolName := fmt.Sprintf("pool_%s", e2e.RandomName(suite.T()))
 	suite.T().Logf("Pool name: %s", poolName)
 
 	pool := &core.TokenPool{
diff --git a/test/e2e/gateway/tokens_only.go b/test/e2e/gateway/tokens_only.go
new file mode 100644
index 000000000..3b49060ad
--- /dev/null
+++ b/test/e2e/gateway/tokens_only.go
@@ -0,0 +1,128 @@
+// Copyright © 2022 Kaleido, Inc.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package gateway
+
+import (
+	"fmt"
+
+	"github.com/hyperledger/firefly-common/pkg/fftypes"
+	"github.com/hyperledger/firefly/pkg/core"
+	"github.com/hyperledger/firefly/test/e2e"
+	"github.com/hyperledger/firefly/test/e2e/client"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/suite"
+)
+
+type TokensOnlyTestSuite struct {
+	suite.Suite
+	testState *testState
+	connector string
+	db        string
+	key       string
+}
+
+func (suite *TokensOnlyTestSuite) SetupSuite() {
+	suite.testState = beforeE2ETest(suite.T())
+	stack := e2e.ReadStack(suite.T())
+	stackState := e2e.ReadStackState(suite.T())
+
+	suite.connector = stack.TokenProviders[0]
+	suite.db = stack.Database
+	account := stackState.Accounts[0].(map[string]interface{})
+	suite.key = account["address"].(string)
+}
+
+func (suite *TokensOnlyTestSuite) BeforeTest(suiteName, testName string) {
+	suite.testState = beforeE2ETest(suite.T())
+}
+
+func (suite *TokensOnlyTestSuite) AfterTest(suiteName, testName string) {
+	e2e.VerifyAllOperationsSucceeded(suite.T(), []*client.FireFlyClient{suite.testState.client1}, suite.testState.startTime)
+}
+
+func (suite *TokensOnlyTestSuite) TestTokensOnlyNamespaces() {
+	defer suite.testState.Done()
+	testNamespace := e2e.RandomName(suite.T())
+	suite.T().Logf("Test namespace: %s", testNamespace)
+
+	namespaceInfo := map[string]interface{}{
+		"name": testNamespace,
+		"asset": map[string]interface{}{
+			"manager": map[string]interface{}{
+				"keynormalization": "none",
+			},
+		},
+		"plugins": []string{"database0", suite.connector},
+	}
+
+	// Add the new namespace
+	data1 := e2e.ReadConfig(suite.T(), suite.testState.configFile1)
+	e2e.AddNamespace(data1, namespaceInfo)
+	e2e.WriteConfig(suite.T(), suite.testState.configFile1, data1)
+
+	admin1 := client.NewResty(suite.T())
+	admin1.SetBaseURL(suite.testState.adminHost1 + "/spi/v1")
+
+	e2e.ResetFireFly(suite.T(), admin1)
+	e2e.PollForUp(suite.T(), suite.testState.client1)
+
+	client1 := client.NewFireFly(suite.T(), suite.testState.client1.Hostname, testNamespace)
+	eventNames := "token_pool_confirmed|token_transfer_confirmed"
+	queryString := fmt.Sprintf("namespace=%s&ephemeral&autoack&filter.events=%s", testNamespace, eventNames)
+	received1 := e2e.WsReader(client1.WebSocket(suite.T(), queryString, nil))
+
+	// Attempt async token operations on new namespace
+	poolName := fmt.Sprintf("pool_%s", e2e.RandomName(suite.T()))
+	suite.T().Logf("Pool name: %s", poolName)
+
+	pool := &core.TokenPool{
+		Name: poolName,
+		Key:  suite.key,
+		Type: core.TokenTypeFungible,
+	}
+	poolResp := client1.CreateTokenPool(suite.T(), pool, false)
+	poolID := poolResp.ID
+
+	e2e.WaitForEvent(suite.T(), received1, core.EventTypePoolConfirmed, poolID)
+	pools := client1.GetTokenPools(suite.T(), suite.testState.startTime)
+	assert.Equal(suite.T(), 1, len(pools))
+	assert.Equal(suite.T(), testNamespace, pools[0].Namespace)
+	assert.Equal(suite.T(), suite.connector, pools[0].Connector)
+	assert.Equal(suite.T(), poolName, pools[0].Name)
+	assert.Equal(suite.T(), core.TokenTypeFungible, pools[0].Type)
+	assert.NotEmpty(suite.T(), pools[0].Locator)
+
+	transfer := &core.TokenTransferInput{
+		TokenTransfer: core.TokenTransfer{Amount: *fftypes.NewFFBigInt(1), Key: suite.key},
+		Pool:          poolName,
+	}
+	transferOut := client1.MintTokens(suite.T(), transfer, false)
+	e2e.WaitForEvent(suite.T(), received1, core.EventTypeTransferConfirmed, transferOut.LocalID)
+	e2e.ValidateAccountBalances(suite.T(), client1, poolID, "", map[string]int64{
+		suite.key: 1,
+	})
+
+	transfer = &core.TokenTransferInput{
+		TokenTransfer: core.TokenTransfer{Amount: *fftypes.NewFFBigInt(1), Key: suite.key},
+		Pool:          poolName,
+	}
+	transferOut = client1.BurnTokens(suite.T(), transfer, false)
+	e2e.WaitForEvent(suite.T(), received1, core.EventTypeTransferConfirmed, transferOut.LocalID)
+	e2e.ValidateAccountBalances(suite.T(), client1, poolID, "", map[string]int64{
+		suite.key: 0,
+	})
+}
diff --git a/test/e2e/multiparty/common.go b/test/e2e/multiparty/common.go
index d1197a71a..c890cb28c 100644
--- a/test/e2e/multiparty/common.go
+++ b/test/e2e/multiparty/common.go
@@ -18,26 +18,21 @@ package multiparty
 
 import (
 	"context"
-	"crypto/rand"
 	"crypto/sha256"
 	"encoding/base64"
 	"fmt"
-	"io/ioutil"
 	"net/http"
 	"os"
 	"path/filepath"
 	"testing"
 	"time"
 
-	"github.com/go-resty/resty/v2"
 	"github.com/gorilla/websocket"
 	"github.com/hyperledger/firefly-common/pkg/fftypes"
 	"github.com/hyperledger/firefly/pkg/core"
 	"github.com/hyperledger/firefly/test/e2e"
 	"github.com/hyperledger/firefly/test/e2e/client"
 	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-	"gopkg.in/yaml.v2"
 )
 
 const (
@@ -256,43 +251,3 @@ func validateReceivedMessages(ts *testState, client *client.FireFlyClient, topic
 	// Flip data (returned in most recent order) into delivery order
 	return returnData
 }
-
-func randomName(t *testing.T) string {
-	b := make([]byte, 5)
-	_, err := rand.Read(b)
-	assert.NoError(t, err)
-	return fmt.Sprintf("e2e_%x", b)
-}
-
-func readConfig(t *testing.T, configFile string) map[string]interface{} {
-	yfile, err := ioutil.ReadFile(configFile)
-	assert.NoError(t, err)
-	data := make(map[string]interface{})
-	err = yaml.Unmarshal(yfile, &data)
-	assert.NoError(t, err)
-	return data
-}
-
-func writeConfig(t *testing.T, configFile string, data map[string]interface{}) {
-	out, err := yaml.Marshal(data)
-	assert.NoError(t, err)
-	f, err := os.Create(configFile)
-	assert.NoError(t, err)
-	_, err = f.Write(out)
-	assert.NoError(t, err)
-	f.Close()
-}
-
-func addNamespace(data map[string]interface{}, ns map[string]interface{}) {
-	namespaces := data["namespaces"].(map[interface{}]interface{})
-	predefined := namespaces["predefined"].([]interface{})
-	namespaces["predefined"] = append(predefined, ns)
-}
-
-func resetFireFly(t *testing.T, client *resty.Client) {
-	resp, err := client.R().
-		SetBody(map[string]interface{}{}).
-		Post("/reset")
-	require.NoError(t, err)
-	assert.Equal(t, 204, resp.StatusCode())
-}
diff --git a/test/e2e/multiparty/contract_migration.go b/test/e2e/multiparty/contract_migration.go
index d9d1ac114..3205f768d 100644
--- a/test/e2e/multiparty/contract_migration.go
+++ b/test/e2e/multiparty/contract_migration.go
@@ -55,7 +55,7 @@ func (suite *ContractMigrationTestSuite) TestContractMigration() {
 }
 
 func runMigrationTest(suite *ContractMigrationTestSuite, address1, address2 string, startOnV1 bool) {
-	testNamespace := randomName(suite.T())
+	testNamespace := e2e.RandomName(suite.T())
 	suite.T().Logf("Test namespace: %s", testNamespace)
 
 	org := map[string]interface{}{}
@@ -77,17 +77,17 @@ func runMigrationTest(suite *ContractMigrationTestSuite, address1, address2 stri
 	data := &core.DataRefOrValue{Value: fftypes.JSONAnyPtr(`"test"`)}
 
 	// Add the new namespace to both config files
-	data1 := readConfig(suite.T(), suite.testState.configFile1)
+	data1 := e2e.ReadConfig(suite.T(), suite.testState.configFile1)
 	org["name"] = suite.testState.org1.Name
 	org["key"] = suite.testState.org1key.Value
-	addNamespace(data1, namespaceInfo)
-	writeConfig(suite.T(), suite.testState.configFile1, data1)
+	e2e.AddNamespace(data1, namespaceInfo)
+	e2e.WriteConfig(suite.T(), suite.testState.configFile1, data1)
 
-	data2 := readConfig(suite.T(), suite.testState.configFile2)
+	data2 := e2e.ReadConfig(suite.T(), suite.testState.configFile2)
 	org["name"] = suite.testState.org2.Name
 	org["key"] = suite.testState.org2key.Value
-	addNamespace(data2, namespaceInfo)
-	writeConfig(suite.T(), suite.testState.configFile2, data2)
+	e2e.AddNamespace(data2, namespaceInfo)
+	e2e.WriteConfig(suite.T(), suite.testState.configFile2, data2)
 
 	admin1 := client.NewResty(suite.T())
 	admin2 := client.NewResty(suite.T())
@@ -95,8 +95,8 @@ func runMigrationTest(suite *ContractMigrationTestSuite, address1, address2 stri
 	admin2.SetBaseURL(suite.testState.adminHost2 + "/spi/v1")
 
 	// Reset both nodes to pick up the new namespace
-	resetFireFly(suite.T(), admin1)
-	resetFireFly(suite.T(), admin2)
+	e2e.ResetFireFly(suite.T(), admin1)
+	e2e.ResetFireFly(suite.T(), admin2)
 	e2e.PollForUp(suite.T(), suite.testState.client1)
 	e2e.PollForUp(suite.T(), suite.testState.client2)
 
diff --git a/test/e2e/multiparty/namespace_alias.go b/test/e2e/multiparty/namespace_alias.go
index 7ba36a7fc..c203221e2 100644
--- a/test/e2e/multiparty/namespace_alias.go
+++ b/test/e2e/multiparty/namespace_alias.go
@@ -49,9 +49,9 @@ func (suite *NamespaceAliasSuite) TestNamespaceMapping() {
 	defer suite.testState.Done()
 
 	address := deployContract(suite.T(), suite.testState.stackName, "firefly/Firefly.json")
-	localNamespace1 := randomName(suite.T())
-	localNamespace2 := randomName(suite.T())
-	remoteNamespace := randomName(suite.T())
+	localNamespace1 := e2e.RandomName(suite.T())
+	localNamespace2 := e2e.RandomName(suite.T())
+	remoteNamespace := e2e.RandomName(suite.T())
 	suite.T().Logf("Test namespace: local1=%s, local2=%s, remote=%s", localNamespace1, localNamespace2, remoteNamespace)
 
 	org := map[string]interface{}{}
@@ -70,19 +70,19 @@ func (suite *NamespaceAliasSuite) TestNamespaceMapping() {
 	data := &core.DataRefOrValue{Value: fftypes.JSONAnyPtr(`"test"`)}
 
 	// Add the new namespace to both config files
-	data1 := readConfig(suite.T(), suite.testState.configFile1)
+	data1 := e2e.ReadConfig(suite.T(), suite.testState.configFile1)
 	namespaceInfo["name"] = localNamespace1
 	org["name"] = suite.testState.org1.Name
 	org["key"] = suite.testState.org1key.Value
-	addNamespace(data1, namespaceInfo)
-	writeConfig(suite.T(), suite.testState.configFile1, data1)
+	e2e.AddNamespace(data1, namespaceInfo)
+	e2e.WriteConfig(suite.T(), suite.testState.configFile1, data1)
 
-	data2 := readConfig(suite.T(), suite.testState.configFile2)
+	data2 := e2e.ReadConfig(suite.T(), suite.testState.configFile2)
 	namespaceInfo["name"] = localNamespace2
 	org["name"] = suite.testState.org2.Name
 	org["key"] = suite.testState.org2key.Value
-	addNamespace(data2, namespaceInfo)
-	writeConfig(suite.T(), suite.testState.configFile2, data2)
+	e2e.AddNamespace(data2, namespaceInfo)
+	e2e.WriteConfig(suite.T(), suite.testState.configFile2, data2)
 
 	admin1 := client.NewResty(suite.T())
 	admin2 := client.NewResty(suite.T())
@@ -90,8 +90,8 @@ func (suite *NamespaceAliasSuite) TestNamespaceMapping() {
 	admin2.SetBaseURL(suite.testState.adminHost2 + "/spi/v1")
 
 	// Reset both nodes to pick up the new namespace
-	resetFireFly(suite.T(), admin1)
-	resetFireFly(suite.T(), admin2)
+	e2e.ResetFireFly(suite.T(), admin1)
+	e2e.ResetFireFly(suite.T(), admin2)
 	e2e.PollForUp(suite.T(), suite.testState.client1)
 	e2e.PollForUp(suite.T(), suite.testState.client2)
 
diff --git a/test/e2e/multiparty/tokens.go b/test/e2e/multiparty/tokens.go
index af02d12f2..e0d4e9ba4 100644
--- a/test/e2e/multiparty/tokens.go
+++ b/test/e2e/multiparty/tokens.go
@@ -53,7 +53,7 @@ func (suite *TokensTestSuite) TestE2EFungibleTokensAsync() {
 	received1 := e2e.WsReader(suite.testState.ws1)
 	received2 := e2e.WsReader(suite.testState.ws2)
 
-	poolName := fmt.Sprintf("pool_%s", randomName(suite.T()))
+	poolName := fmt.Sprintf("pool_%s", e2e.RandomName(suite.T()))
 	suite.T().Logf("Pool name: %s", poolName)
 
 	pool := &core.TokenPool{
@@ -219,7 +219,7 @@ func (suite *TokensTestSuite) TestE2ENonFungibleTokensSync() {
 	received1 := e2e.WsReader(suite.testState.ws1)
 	received2 := e2e.WsReader(suite.testState.ws2)
 
-	poolName := fmt.Sprintf("pool_%s", randomName(suite.T()))
+	poolName := fmt.Sprintf("pool_%s", e2e.RandomName(suite.T()))
 	suite.T().Logf("Pool name: %s", poolName)
 
 	pool := &core.TokenPool{
diff --git a/test/e2e/runners/ethereum_gateway_test.go b/test/e2e/runners/ethereum_gateway_test.go
index d0f53cfbb..91afbd057 100644
--- a/test/e2e/runners/ethereum_gateway_test.go
+++ b/test/e2e/runners/ethereum_gateway_test.go
@@ -26,4 +26,5 @@ import (
 func TestEthereumGatewayE2ESuite(t *testing.T) {
 	suite.Run(t, new(gateway.TokensTestSuite))
 	suite.Run(t, new(gateway.EthereumContractTestSuite))
+	suite.Run(t, new(gateway.TokensOnlyTestSuite))
 }
diff --git a/test/e2e/stack.go b/test/e2e/stack.go
index 1d9392194..74f322511 100644
--- a/test/e2e/stack.go
+++ b/test/e2e/stack.go
@@ -27,6 +27,7 @@ type Stack struct {
 	BlockchainProvider    string    `json:"blockchainProvider"`
 	TokenProviders        []string  `json:"tokenProviders"`
 	Members               []*Member `json:"members,omitempty"`
+	Database              string    `json:"database"`
 }
 
 type StackState struct {