Repository: tablelandnetwork/go-tableland
Pull request #210 (2022-08-02 20:47:32)

Adds AllowTransactionRelay config
Adds a new config `AllowTransactionRelay` that applies to `RelayWriteQuery` and `SetController` RPC calls. 

The way to set this config is to set it to `true` for testnets and `false` for mainnets. That means that calls to `RelayWriteQuery` and `SetController` for mainnets won't get through (it will throw an error). 

This PR also refactors the way tests are setup in `mesa_test.go`. Check this [comment](https://github.com/tablelandnetwork/go-tableland/pull/210/files#r936031981).


Patch:
diff --git a/cmd/api/config.go b/cmd/api/config.go
index 7be08f4..d975cac 100644
--- a/cmd/api/config.go
+++ b/cmd/api/config.go
@@ -55,9 +55,10 @@ type QueryConstraints struct {
 
 // ChainConfig contains all the chain execution stack configuration for a particular EVM chain.
 type ChainConfig struct {
-	Name     string            `default:""`
-	ChainID  tableland.ChainID `default:"0"`
-	Registry struct {
+	Name                  string            `default:""`
+	ChainID               tableland.ChainID `default:"0"`
+	AllowTransactionRelay bool              `default:"false"`
+	Registry              struct {
 		EthEndpoint     string `default:"eth_endpoint"`
 		ContractAddress string `default:"contract_address"`
 	}
diff --git a/cmd/api/main.go b/cmd/api/main.go
index a95d827..01d682f 100644
--- a/cmd/api/main.go
+++ b/cmd/api/main.go
@@ -302,10 +302,10 @@ func createChainIDStack(
 	if err := ep.Start(); err != nil {
 		return chains.ChainStack{}, fmt.Errorf("starting event processor")
 	}
-
 	return chains.ChainStack{
-		Store:    systemStore,
-		Registry: registry,
+		Store:                 systemStore,
+		Registry:              registry,
+		AllowTransactionRelay: config.AllowTransactionRelay,
 		Close: func(ctx context.Context) error {
 			log.Info().Int64("chainId", int64(config.ChainID)).Msg("closing stack...")
 			defer log.Info().Int64("chainId", int64(config.ChainID)).Msg("stack closed")
diff --git a/docker/deployed/staging/api/config.json b/docker/deployed/staging/api/config.json
index a475a58..d163a5f 100644
--- a/docker/deployed/staging/api/config.json
+++ b/docker/deployed/staging/api/config.json
@@ -29,6 +29,7 @@
   "Chains": [
     {
       "Name": "Optimism Kovan",
+      "AllowTransactionRelay" : true,
       "ChainID": 69,
       "Registry": {
         "EthEndpoint": "wss://opt-kovan.g.alchemy.com/v2/${VALIDATOR_ALCHEMY_OPTIMISM_KOVAN_API_KEY}",
diff --git a/docker/local/api/config.json b/docker/local/api/config.json
index b2ffef7..2f05320 100644
--- a/docker/local/api/config.json
+++ b/docker/local/api/config.json
@@ -10,6 +10,7 @@
     {
       "Name": "Local Hardhat",
       "ChainID": 31337,
+      "AllowTransactionRelay": true,
       "Registry": {
         "EthEndpoint": "ws://host.docker.internal:8545",
         "ContractAddress": "[FILL ME]"
diff --git a/internal/chains/chains.go b/internal/chains/chains.go
index 07cf80d..1fd0720 100644
--- a/internal/chains/chains.go
+++ b/internal/chains/chains.go
@@ -9,8 +9,9 @@ import (
 
 // ChainStack contains components running for a specific ChainID.
 type ChainStack struct {
-	Store    sqlstore.SystemStore
-	Registry tables.TablelandTables
+	Store                 sqlstore.SystemStore
+	Registry              tables.TablelandTables
+	AllowTransactionRelay bool
 
 	// close gracefully closes all the chain stack components.
 	Close func(ctx context.Context) error
diff --git a/internal/tableland/impl/mesa.go b/internal/tableland/impl/mesa.go
index dc5c9fc..3e6579a 100644
--- a/internal/tableland/impl/mesa.go
+++ b/internal/tableland/impl/mesa.go
@@ -117,6 +117,11 @@ func (t *TablelandMesa) RelayWriteQuery(
 		return tableland.RelayWriteQueryResponse{}, fmt.Errorf("chain id %d isn't supported in the validator", chainID)
 	}
 
+	if !stack.AllowTransactionRelay {
+		return tableland.RelayWriteQueryResponse{},
+			fmt.Errorf("chain id %d does not suppport relaying of transactions", chainID)
+	}
+
 	mutatingStmts, err := t.parser.ValidateMutatingQuery(req.Statement, chainID)
 	if err != nil {
 		return tableland.RelayWriteQueryResponse{}, fmt.Errorf("validating query: %s", err)
@@ -219,6 +224,11 @@ func (t *TablelandMesa) SetController(
 		return tableland.SetControllerResponse{}, fmt.Errorf("chain id %d isn't supported in the validator", chainID)
 	}
 
+	if !stack.AllowTransactionRelay {
+		return tableland.SetControllerResponse{},
+			fmt.Errorf("chain id %d does not suppport relaying of transactions", chainID)
+	}
+
 	tx, err := stack.Registry.SetController(
 		ctx, common.HexToAddress(caller), tableID, common.HexToAddress(req.Controller))
 	if err != nil {


Test patch:
diff --git a/docker/deployed/testnet/api/config.json b/docker/deployed/testnet/api/config.json
index 52861e0..a3220fb 100644
--- a/docker/deployed/testnet/api/config.json
+++ b/docker/deployed/testnet/api/config.json
@@ -31,6 +31,7 @@
         {
             "Name": "Optimism Kovan",
             "ChainID": 69,
+            "AllowTransactionRelay" : true,
             "Registry": {
                 "EthEndpoint": "wss://opt-kovan.g.alchemy.com/v2/${VALIDATOR_ALCHEMY_OPTIMISM_KOVAN_API_KEY}",
                 "ContractAddress": "0xf2C9Fc73884A9c6e6Db58778176Ab67989139D06"
@@ -56,6 +57,7 @@
         {
             "Name": "Ethereum Goerli",
             "ChainID": 5,
+            "AllowTransactionRelay" : true,
             "Registry": {
                 "EthEndpoint": "wss://eth-goerli.alchemyapi.io/v2/${VALIDATOR_ALCHEMY_ETHEREUM_GOERLI_API_KEY}",
                 "ContractAddress": "0xDA8EA22d092307874f30A1F277D1388dca0BA97a"
@@ -81,6 +83,7 @@
         {
             "Name": "Polygon Mumbai",
             "ChainID": 80001,
+            "AllowTransactionRelay" : true,
             "Registry": {
                 "EthEndpoint": "wss://polygon-mumbai.g.alchemy.com/v2/${VALIDATOR_ALCHEMY_POLYGON_MUMBAI_API_KEY}",
                 "ContractAddress": "0x4b48841d4b32C4650E4ABc117A03FE8B51f38F68"
diff --git a/internal/tableland/impl/mesa_test.go b/internal/tableland/impl/mesa_test.go
index 07aff9b..997a50b 100644
--- a/internal/tableland/impl/mesa_test.go
+++ b/internal/tableland/impl/mesa_test.go
@@ -43,7 +43,13 @@ import (
 func TestTodoAppWorkflow(t *testing.T) {
 	t.Parallel()
 
-	ctx, tbld, backend, sc, txOpts := setup(t)
+	setup := newTablelandSetupBuilder().
+		withAllowTransactionRelay(true).
+		build(t)
+	tablelandClient := setup.newTablelandClient(t)
+
+	ctx, backend, sc := setup.ctx, setup.ethClient, setup.contract
+	tbld, txOpts := tablelandClient.tableland, tablelandClient.txOpts
 
 	caller := txOpts.From
 	_, err := sc.CreateTable(txOpts, caller,
@@ -61,7 +67,13 @@ func TestTodoAppWorkflow(t *testing.T) {
 func TestAny(t *testing.T) {
 	t.Parallel()
 
-	ctx, tbld, backend, sc, txOpts := setup(t)
+	setup := newTablelandSetupBuilder().
+		withAllowTransactionRelay(true).
+		build(t)
+	tablelandClient := setup.newTablelandClient(t)
+
+	ctx, backend, sc := setup.ctx, setup.ethClient, setup.contract
+	tbld, txOpts := tablelandClient.tableland, tablelandClient.txOpts
 
 	caller := txOpts.From
 	_, err := sc.CreateTable(txOpts, caller,
@@ -79,7 +91,14 @@ func TestInsertOnConflict(t *testing.T) {
 	//       It's disabled temporarily until some soon related work in the validator will fix this.
 	t.SkipNow()
 
-	ctx, tbld, backend, sc, txOpts := setup(t)
+	setup := newTablelandSetupBuilder().
+		withAllowTransactionRelay(true).
+		build(t)
+	tablelandClient := setup.newTablelandClient(t)
+
+	ctx, backend, sc := setup.ctx, setup.ethClient, setup.contract
+	tbld, txOpts := tablelandClient.tableland, tablelandClient.txOpts
+
 	caller := txOpts.From
 
 	_, err := sc.CreateTable(txOpts, caller,
@@ -114,7 +133,13 @@ func TestInsertOnConflict(t *testing.T) {
 func TestMultiStatement(t *testing.T) {
 	t.Parallel()
 
-	ctx, tbld, backend, sc, txOpts := setup(t)
+	setup := newTablelandSetupBuilder().
+		withAllowTransactionRelay(true).
+		build(t)
+	tablelandClient := setup.newTablelandClient(t)
+
+	ctx, backend, sc := setup.ctx, setup.ethClient, setup.contract
+	tbld, txOpts := tablelandClient.tableland, tablelandClient.txOpts
 	caller := txOpts.From
 
 	_, err := sc.CreateTable(txOpts, caller,
@@ -144,7 +169,13 @@ func TestMultiStatement(t *testing.T) {
 func TestReadSystemTable(t *testing.T) {
 	t.Parallel()
 
-	ctx, tbld, _, sc, txOpts := setup(t)
+	setup := newTablelandSetupBuilder().
+		withAllowTransactionRelay(true).
+		build(t)
+	tablelandClient := setup.newTablelandClient(t)
+
+	ctx, sc := setup.ctx, setup.contract
+	tbld, txOpts := tablelandClient.tableland, tablelandClient.txOpts
 	caller := txOpts.From
 
 	_, err := sc.CreateTable(txOpts, caller, `CREATE TABLE foo_1337 (myjson TEXT);`)
@@ -159,7 +190,13 @@ func TestReadSystemTable(t *testing.T) {
 func TestJSON(t *testing.T) {
 	t.Parallel()
 
-	ctx, tbld, backend, sc, txOpts := setup(t)
+	setup := newTablelandSetupBuilder().
+		withAllowTransactionRelay(true).
+		build(t)
+	tablelandClient := setup.newTablelandClient(t)
+
+	ctx, backend, sc := setup.ctx, setup.ethClient, setup.contract
+	tbld, txOpts := tablelandClient.tableland, tablelandClient.txOpts
 	caller := txOpts.From
 
 	_, err := sc.CreateTable(txOpts, caller, `CREATE TABLE foo_1337 (myjson TEXT);`)
@@ -193,7 +230,17 @@ func TestCheckInsertPrivileges(t *testing.T) {
 			return func(t *testing.T) {
 				t.Parallel()
 
-				ctx, tbldGranter, tbldGrantee, backend, sc, txOptsGranter, txOptsGrantee := setupTablelandForTwoAddresses(t)
+				setup := newTablelandSetupBuilder().
+					withAllowTransactionRelay(true).
+					build(t)
+
+				granterSetup := setup.newTablelandClient(t)
+				granteeSetup := setup.newTablelandClient(t)
+
+				ctx, backend, sc := setup.ctx, setup.ethClient, setup.contract
+				tbldGranter, txOptsGranter := granterSetup.tableland, granterSetup.txOpts
+				tbldGrantee, txOptsGrantee := granteeSetup.tableland, granteeSetup.txOpts
+
 				granter := txOptsGranter.From.Hex()
 				grantee := txOptsGrantee.From.Hex()
 
@@ -264,7 +311,17 @@ func TestCheckUpdatePrivileges(t *testing.T) {
 			return func(t *testing.T) {
 				t.Parallel()
 
-				ctx, tbldGranter, tbldGrantee, backend, sc, txOptsGranter, txOptsGrantee := setupTablelandForTwoAddresses(t)
+				setup := newTablelandSetupBuilder().
+					withAllowTransactionRelay(true).
+					build(t)
+
+				granterSetup := setup.newTablelandClient(t)
+				granteeSetup := setup.newTablelandClient(t)
+
+				ctx, backend, sc := setup.ctx, setup.ethClient, setup.contract
+				tbldGranter, txOptsGranter := granterSetup.tableland, granterSetup.txOpts
+				tbldGrantee, txOptsGrantee := granteeSetup.tableland, granteeSetup.txOpts
+
 				granter := txOptsGranter.From.Hex()
 				grantee := txOptsGrantee.From.Hex()
 
@@ -341,7 +398,17 @@ func TestCheckDeletePrivileges(t *testing.T) {
 			return func(t *testing.T) {
 				t.Parallel()
 
-				ctx, tbldGranter, tbldGrantee, backend, sc, txOptsGranter, txOptsGrantee := setupTablelandForTwoAddresses(t)
+				setup := newTablelandSetupBuilder().
+					withAllowTransactionRelay(true).
+					build(t)
+
+				granterSetup := setup.newTablelandClient(t)
+				granteeSetup := setup.newTablelandClient(t)
+
+				ctx, backend, sc := setup.ctx, setup.ethClient, setup.contract
+				tbldGranter, txOptsGranter := granterSetup.tableland, granterSetup.txOpts
+				tbldGrantee, txOptsGrantee := granteeSetup.tableland, granteeSetup.txOpts
+
 				granter := txOptsGranter.From.Hex()
 				grantee := txOptsGrantee.From.Hex()
 
@@ -393,7 +460,13 @@ func TestCheckDeletePrivileges(t *testing.T) {
 func TestOwnerRevokesItsPrivilegeInsideMultipleStatements(t *testing.T) {
 	t.Parallel()
 
-	ctx, tbld, backend, sc, txOpts := setup(t)
+	setup := newTablelandSetupBuilder().
+		withAllowTransactionRelay(true).
+		build(t)
+	tablelandClient := setup.newTablelandClient(t)
+
+	ctx, backend, sc := setup.ctx, setup.ethClient, setup.contract
+	tbld, txOpts := tablelandClient.tableland, tablelandClient.txOpts
 	caller := txOpts.From.Hex()
 
 	_, err := sc.CreateTable(txOpts, common.HexToAddress(caller), `CREATE TABLE foo_1337 (bar text);`)
@@ -418,7 +491,16 @@ func TestOwnerRevokesItsPrivilegeInsideMultipleStatements(t *testing.T) {
 func TestTransferTable(t *testing.T) {
 	t.Parallel()
 
-	ctx, tbldOwner1, tbldOwner2, backend, sc, txOptsOwner1, txOptsOwner2 := setupTablelandForTwoAddresses(t)
+	setup := newTablelandSetupBuilder().
+		withAllowTransactionRelay(true).
+		build(t)
+
+	owner1Setup := setup.newTablelandClient(t)
+	owner2Setup := setup.newTablelandClient(t)
+
+	ctx, backend, sc := setup.ctx, setup.ethClient, setup.contract
+	tbldOwner1, txOptsOwner1 := owner1Setup.tableland, owner1Setup.txOpts
+	tbldOwner2, txOptsOwner2 := owner2Setup.tableland, owner2Setup.txOpts
 
 	_, err := sc.CreateTable(txOptsOwner1, txOptsOwner1.From, `CREATE TABLE foo_1337 (bar text);`)
 	require.NoError(t, err)
@@ -477,7 +559,15 @@ func TestQueryConstraints(t *testing.T) {
 		parsingOpts := []parsing.Option{
 			parsing.WithMaxWriteQuerySize(45),
 		}
-		ctx, tbld, backend, sc, txOpts := setup(t, parsingOpts...)
+
+		setup := newTablelandSetupBuilder().
+			withAllowTransactionRelay(true).
+			withParsingOpts(parsingOpts...).
+			build(t)
+		tablelandClient := setup.newTablelandClient(t)
+
+		ctx, backend, sc := setup.ctx, setup.ethClient, setup.contract
+		tbld, txOpts := tablelandClient.tableland, tablelandClient.txOpts
 		caller := txOpts.From
 
 		_, err := sc.CreateTable(txOpts, caller, `CREATE TABLE foo_1337 (bar text);`)
@@ -497,7 +587,14 @@ func TestQueryConstraints(t *testing.T) {
 		parsingOpts := []parsing.Option{
 			parsing.WithMaxWriteQuerySize(45),
 		}
-		ctx, tbld, _, _, txOpts := setup(t, parsingOpts...)
+		setup := newTablelandSetupBuilder().
+			withAllowTransactionRelay(true).
+			withParsingOpts(parsingOpts...).
+			build(t)
+		tablelandClient := setup.newTablelandClient(t)
+
+		ctx := setup.ctx
+		tbld, txOpts := tablelandClient.tableland, tablelandClient.txOpts
 		caller := txOpts.From
 
 		ctx = context.WithValue(ctx, middlewares.ContextKeyAddress, caller.Hex())
@@ -514,7 +611,15 @@ func TestQueryConstraints(t *testing.T) {
 		parsingOpts := []parsing.Option{
 			parsing.WithMaxReadQuerySize(44),
 		}
-		ctx, tbld, backend, sc, txOpts := setup(t, parsingOpts...)
+
+		setup := newTablelandSetupBuilder().
+			withAllowTransactionRelay(true).
+			withParsingOpts(parsingOpts...).
+			build(t)
+		tablelandClient := setup.newTablelandClient(t)
+
+		ctx, backend, sc := setup.ctx, setup.ethClient, setup.contract
+		tbld, txOpts := tablelandClient.tableland, tablelandClient.txOpts
 		caller := txOpts.From
 
 		_, err := sc.CreateTable(txOpts, caller, `CREATE TABLE foo_1337 (bar text);`)
@@ -540,7 +645,15 @@ func TestQueryConstraints(t *testing.T) {
 		parsingOpts := []parsing.Option{
 			parsing.WithMaxReadQuerySize(44),
 		}
-		ctx, tbld, _, _, txOpts := setup(t, parsingOpts...)
+
+		setup := newTablelandSetupBuilder().
+			withAllowTransactionRelay(true).
+			withParsingOpts(parsingOpts...).
+			build(t)
+		tablelandClient := setup.newTablelandClient(t)
+
+		ctx := setup.ctx
+		tbld, txOpts := tablelandClient.tableland, tablelandClient.txOpts
 		caller := txOpts.From
 
 		ctx = context.WithValue(ctx, middlewares.ContextKeyAddress, caller.Hex())
@@ -552,6 +665,30 @@ func TestQueryConstraints(t *testing.T) {
 	})
 }
 
+func TestAllowTransactionRelayConfig(t *testing.T) {
+	t.Parallel()
+
+	setup := newTablelandSetupBuilder().
+		withAllowTransactionRelay(false).
+		build(t)
+
+	tablelandClient := setup.newTablelandClient(t)
+
+	ctx, tbld, txOpts := setup.ctx, tablelandClient.tableland, tablelandClient.txOpts
+
+	t.Run("relay write query", func(t *testing.T) {
+		_, err := relayWriteQuery(ctx, t, tbld, "INSERT INTO foo_1337_1 VALUES ('bar', 0)", txOpts.From.Hex())
+		require.Error(t, err)
+		require.ErrorContains(t, err, "chain id 1337 does not suppport relaying of transactions")
+	})
+
+	t.Run("set controller", func(t *testing.T) {
+		_, err := setController(ctx, t, tbld, txOpts.From.Hex(), "", "1") // values don't matter
+		require.Error(t, err)
+		require.ErrorContains(t, err, "chain id 1337 does not suppport relaying of transactions")
+	})
+}
+
 func processCSV(
 	ctx context.Context,
 	t *testing.T,
@@ -667,11 +804,11 @@ func relayWriteQuery(
 	t *testing.T,
 	tbld tableland.Tableland,
 	sql string,
-	controller string,
+	caller string,
 ) (tableland.RelayWriteQueryResponse, error) {
 	t.Helper()
 
-	ctx = context.WithValue(ctx, middlewares.ContextKeyAddress, controller)
+	ctx = context.WithValue(ctx, middlewares.ContextKeyAddress, caller)
 	req := tableland.RelayWriteQueryRequest{
 		Statement: sql,
 	}
@@ -679,6 +816,25 @@ func relayWriteQuery(
 	return tbld.RelayWriteQuery(ctx, req)
 }
 
+func setController(
+	ctx context.Context,
+	t *testing.T,
+	tbld tableland.Tableland,
+	caller string,
+	controller string,
+	tokenID string,
+) (tableland.SetControllerResponse, error) {
+	t.Helper()
+
+	ctx = context.WithValue(ctx, middlewares.ContextKeyAddress, caller)
+	req := tableland.SetControllerRequest{
+		Controller: controller,
+		TokenID:    tokenID,
+	}
+
+	return tbld.SetController(ctx, req)
+}
+
 func readCsvFile(t *testing.T, filePath string) [][]string {
 	t.Helper()
 
@@ -697,154 +853,6 @@ func readCsvFile(t *testing.T, filePath string) [][]string {
 	return records
 }
 
-func setup(
-	t *testing.T,
-	opts ...parsing.Option) (context.Context,
-	tableland.Tableland,
-	*backends.SimulatedBackend,
-	*ethereum.Contract,
-	*bind.TransactOpts,
-) {
-	t.Helper()
-
-	url := tests.Sqlite3URI()
-
-	ctx := context.WithValue(context.Background(), middlewares.ContextKeyChainID, tableland.ChainID(1337))
-	store, err := system.New(url, tableland.ChainID(1337))
-	require.NoError(t, err)
-
-	parser, err := parserimpl.New([]string{"system_", "registry", "sqlite_"}, opts...)
-	require.NoError(t, err)
-
-	txnp, err := txnpimpl.NewTxnProcessor(1337, url, 0, &aclHalfMock{store})
-	require.NoError(t, err)
-
-	backend, addr, sc, auth, sk := testutil.Setup(t)
-
-	wallet, err := wallet.NewWallet(hex.EncodeToString(crypto.FromECDSA(sk)))
-	require.NoError(t, err)
-
-	registry, err := ethereum.NewClient(
-		backend,
-		1337,
-		addr,
-		wallet,
-		impl.NewSimpleTracker(wallet, backend),
-	)
-	require.NoError(t, err)
-
-	userStore, err := user.New(url)
-	require.NoError(t, err)
-
-	tbld := NewTablelandMesa(
-		parser,
-		userStore,
-		map[tableland.ChainID]chains.ChainStack{
-			1337: {Store: store, Registry: registry},
-		})
-
-	// Spin up dependencies needed for the EventProcessor.
-	// i.e: TxnProcessor, Parser, and EventFeed (connected to the EVM chain)
-	ef, err := efimpl.New(1337, backend, addr, eventfeed.WithMinBlockDepth(0))
-	require.NoError(t, err)
-
-	// Create EventProcessor for our test.
-	ep, err := epimpl.New(parser, txnp, ef, 1337)
-	require.NoError(t, err)
-
-	err = ep.Start()
-	require.NoError(t, err)
-	t.Cleanup(func() { ep.Stop() })
-
-	return ctx, tbld, backend, sc, auth
-}
-
-func setupTablelandForTwoAddresses(t *testing.T) (context.Context,
-	tableland.Tableland,
-	tableland.Tableland,
-	*backends.SimulatedBackend,
-	*ethereum.Contract,
-	*bind.TransactOpts,
-	*bind.TransactOpts,
-) {
-	t.Helper()
-
-	url := tests.Sqlite3URI()
-
-	ctx := context.WithValue(context.Background(), middlewares.ContextKeyChainID, tableland.ChainID(1337))
-	store, err := system.New(url, tableland.ChainID(1337))
-	require.NoError(t, err)
-
-	parser, err := parserimpl.New([]string{"system_", "registry"})
-	require.NoError(t, err)
-
-	txnp, err := txnpimpl.NewTxnProcessor(1337, url, 0, &aclHalfMock{store})
-	require.NoError(t, err)
-
-	backend, addr, sc, txOpts1, key1 := testutil.Setup(t)
-
-	wallet1, err := wallet.NewWallet(hex.EncodeToString(crypto.FromECDSA(key1)))
-	require.NoError(t, err)
-
-	registry, err := ethereum.NewClient(
-		backend,
-		1337,
-		addr,
-		wallet1,
-		impl.NewSimpleTracker(wallet1, backend),
-	)
-	require.NoError(t, err)
-	userStore, err := user.New(url)
-	require.NoError(t, err)
-	tbld1 := NewTablelandMesa(
-		parser,
-		userStore,
-		map[tableland.ChainID]chains.ChainStack{
-			1337: {Store: store, Registry: registry},
-		})
-
-	key2, err := crypto.GenerateKey()
-	require.NoError(t, err)
-
-	wallet2, err := wallet.NewWallet(hex.EncodeToString(crypto.FromECDSA(key2)))
-	require.NoError(t, err)
-
-	txOpts2, err := bind.NewKeyedTransactorWithChainID(key2, big.NewInt(1337)) // nolint
-	require.NoError(t, err)
-
-	requireTxn(t, backend, key1, wallet1.Address(), wallet2.Address(), big.NewInt(1000000000000000000))
-
-	registry2, err := ethereum.NewClient(
-		backend,
-		1337,
-		addr,
-		wallet2,
-		impl.NewSimpleTracker(wallet2, backend),
-	)
-	require.NoError(t, err)
-	tbld2 := NewTablelandMesa(
-		parser,
-		userStore,
-		map[tableland.ChainID]chains.ChainStack{
-			1337: {Store: store, Registry: registry2},
-		})
-
-	// Spin up dependencies needed for the EventProcessor.
-	// i.e: TxnProcessor, Parser, and EventFeed (connected to the EVM chain)
-	ef, err := efimpl.New(1337, backend, addr, eventfeed.WithMinBlockDepth(0))
-	require.NoError(t, err)
-
-	// Create EventProcessor for our test.
-	ep, err := epimpl.New(parser, txnp, ef, 1337)
-	require.NoError(t, err)
-
-	err = ep.Start()
-	require.NoError(t, err)
-	t.Cleanup(func() { ep.Stop() })
-
-	return ctx, tbld1, tbld2, backend, sc, txOpts1, txOpts2
-}
-
 type aclHalfMock struct {
 	sqlStore sqlstore.SystemStore
 }
@@ -930,3 +938,149 @@ func requireTxn(
 	require.NoError(t, err)
 	require.NotNil(t, receipt)
 }
+
+type tablelandSetupBuilder struct {
+	allowTransactionRelay bool
+	parsingOpts           []parsing.Option
+}
+
+func newTablelandSetupBuilder() *tablelandSetupBuilder {
+	return &tablelandSetupBuilder{}
+}
+
+func (b *tablelandSetupBuilder) withAllowTransactionRelay(v bool) *tablelandSetupBuilder {
+	b.allowTransactionRelay = v
+	return b
+}
+
+func (b *tablelandSetupBuilder) withParsingOpts(opts ...parsing.Option) *tablelandSetupBuilder {
+	b.parsingOpts = opts
+	return b
+}
+
+func (b *tablelandSetupBuilder) build(t *testing.T) *tablelandSetup {
+	t.Helper()
+	url := tests.Sqlite3URI()
+
+	ctx := context.WithValue(context.Background(), middlewares.ContextKeyChainID, tableland.ChainID(1337))
+	store, err := system.New(url, tableland.ChainID(1337))
+	require.NoError(t, err)
+
+	parser, err := parserimpl.New([]string{"system_", "registry", "sqlite_"}, b.parsingOpts...)
+	require.NoError(t, err)
+
+	txnp, err := txnpimpl.NewTxnProcessor(1337, url, 0, &aclHalfMock{store})
+	require.NoError(t, err)
+
+	backend, addr, sc, auth, sk := testutil.Setup(t)
+
+	// Spin up dependencies needed for the EventProcessor.
+	// i.e: TxnProcessor, Parser, and EventFeed (connected to the EVM chain)
+	ef, err := efimpl.New(1337, backend, addr, eventfeed.WithMinBlockDepth(0))
+	require.NoError(t, err)
+
+	// Create EventProcessor for our test.
+	ep, err := epimpl.New(parser, txnp, ef, 1337)
+	require.NoError(t, err)
+
+	err = ep.Start()
+	require.NoError(t, err)
+	t.Cleanup(func() { ep.Stop() })
+
+	userStore, err := user.New(url)
+	require.NoError(t, err)
+
+	return &tablelandSetup{
+		ctx: ctx,
+
+		// ethereum client
+		ethClient: backend,
+
+		// contract bindings
+		contract:     sc,
+		contractAddr: addr,
+
+		// contract deployer
+		deployerPrivateKey: sk,
+		deployerTxOpts:     auth,
+
+		// common dependencies among mesa clients
+		parser:      parser,
+		userStore:   userStore,
+		systemStore: store,
+
+		// configs
+		allowTransactionRelay: b.allowTransactionRelay,
+	}
+}
+
+type tablelandSetup struct {
+	ctx context.Context
+
+	// ethereum client
+	ethClient *backends.SimulatedBackend
+
+	// contract bindings
+	contract     *ethereum.Contract
+	contractAddr common.Address
+
+	// contract deployer
+	deployerPrivateKey *ecdsa.PrivateKey
+	deployerTxOpts     *bind.TransactOpts
+
+	// common dependencies among tableland clients
+	parser      parsing.SQLValidator
+	userStore   *user.UserStore
+	systemStore *system.SystemStore
+
+	// configs
+	allowTransactionRelay bool
+}
+
+func (s *tablelandSetup) newTablelandClient(t *testing.T) *tablelandClient {
+	key, err := crypto.GenerateKey()
+	require.NoError(t, err)
+
+	wallet, err := wallet.NewWallet(hex.EncodeToString(crypto.FromECDSA(key)))
+	require.NoError(t, err)
+
+	txOpts, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(1337)) // nolint
+	require.NoError(t, err)
+
+	requireTxn(t,
+		s.ethClient,
+		s.deployerPrivateKey,
+		s.deployerTxOpts.From,
+		wallet.Address(),
+		big.NewInt(1000000000000000000),
+	)
+
+	registry, err := ethereum.NewClient(
+		s.ethClient,
+		1337,
+		s.contractAddr,
+		wallet,
+		impl.NewSimpleTracker(wallet, s.ethClient),
+	)
+	require.NoError(t, err)
+	tbld := NewTablelandMesa(
+		s.parser,
+		s.userStore,
+		map[tableland.ChainID]chains.ChainStack{
+			1337: {
+				Store:                 s.systemStore,
+				Registry:              registry,
+				AllowTransactionRelay: s.allowTransactionRelay,
+			},
+		})
+
+	return &tablelandClient{
+		tableland: tbld,
+		txOpts:    txOpts,
+	}
+}
+
+type tablelandClient struct {
+	tableland tableland.Tableland
+	txOpts    *bind.TransactOpts
+}
diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go
index 73be7c5..d8ca90f 100644
--- a/pkg/client/client_test.go
+++ b/pkg/client/client_test.go
@@ -168,7 +168,11 @@ func setup(t *testing.T) clientCalls {
 	require.NoError(t, err)
 
 	chainStack := map[tableland.ChainID]chains.ChainStack{
-		1337: {Store: store, Registry: registry},
+		1337: {
+			Store:                 store,
+			Registry:              registry,
+			AllowTransactionRelay: true,
+		},
 	}
 
 	router := router.ConfiguredRouter(