Repository: algorand/indexer
Pull request #474 (2021-04-28 20:05:44)

Block Generator Prototype
<!--
Thanks for submitting a pull request! We appreciate the time and effort you spent to get this far.

If you haven't already, please make sure that you've reviewed the CONTRIBUTING guide:
https://github.com/algorand/go-algorand/blob/master/CONTRIBUTING.md#code-guidelines
-->

## Summary

Working prototype for a block generator performance tool. It generates pseudorandom transactions and fulls up blocks. By imitating algod's `/genesis` and `/v2/blocks/<>` endpoints it interoperates with indexer.

## Test Plan

For now there is a simple driver script which starts postgres in a docker container, starts generator, and connects indexer to them.

tolikzinovyev: Would it make more sense to have it in a different repository and have the code depend on types in go-algorand? Since this intends to mimic algod, we shouldn't depend on indexer but on go-algorand.
winder: @tolikzinovyev These blocks aren't useful to anything except Indexer because they don't have real signatures and there is no block certificate, so I don't think it makes sense to put in go-algorand. If all of the types were available in the go SDK then maybe it could be a standalone project. At the moment I think this repo is the best spot for it.
tolikzinovyev: Looks good overall! One small concern I have is that we don't account for the fee. If the initial amount is too small, we could make some balance negative in indexer. Also, could you rebase?

Patch:
diff --git a/cmd/block-generator/generator/generate.go b/cmd/block-generator/generator/generate.go
new file mode 100644
index 0000000..28da414
--- /dev/null
+++ b/cmd/block-generator/generator/generate.go
@@ -0,0 +1,268 @@
+package generator
+
+import (
+	"encoding/binary"
+	"fmt"
+	"io"
+	"math/rand"
+
+	"github.com/algorand/go-algorand-sdk/encoding/json"
+	"github.com/algorand/go-algorand-sdk/encoding/msgpack"
+	sdk_types "github.com/algorand/go-algorand-sdk/types"
+
+	"github.com/algorand/indexer/types"
+)
+
+// GenerationConfig defines the tunable parameters for block generation.
+type GenerationConfig struct {
+	// Block generation features
+	TxnPerBlock         uint64
+	NewAccountFrequency uint64
+
+	Protocol                     string
+	NumGenesisAccounts           uint64
+	GenesisAccountInitialBalance uint64
+	GenesisID                    string
+	GenesisHash                  sdk_types.Digest
+}
+
+// MakeGenerator initializes the Generator object.
+func MakeGenerator(config GenerationConfig) Generator {
+	gen := &generator{
+		config:                    config,
+		prevBlockHash:             "",
+		round:                     0,
+		txnCounter:                0,
+		timestamp:                 0,
+		rewardsLevel:              0,
+		rewardsResidue:            0,
+		rewardsRate:               0,
+		rewardsRecalculationRound: 0,
+	}
+
+	gen.feeSink[31] = 1
+	gen.rewardsPool[31] = 2
+
+	gen.initializeAccounting()
+
+	return gen
+}
+
+// Generator is the interface needed to generate blocks.
+type Generator interface {
+	WriteBlock(output io.Writer, round uint64)
+	WriteGenesis(output io.Writer)
+}
+
+type generator struct {
+	config GenerationConfig
+
+	// payment transaction metadata
+	numPayments uint64
+
+	// Number of algorand accounts
+	numAccounts uint64
+
+	// Blockchain stuff
+	round         uint64
+	txnCounter    uint64
+	prevBlockHash string
+	//currentProtocol string
+	timestamp int64
+	//genesisID       string
+	//genesisHash		[32]byte
+
+	// Rewards stuff
+	feeSink                   types.Address
+	rewardsPool               types.Address
+	rewardsLevel              uint64
+	rewardsResidue            uint64
+	rewardsRate               uint64
+	rewardsRecalculationRound uint64
+
+	// balances To avoid crypto and reduce storage, accounts are faked.
+	// The account is based on the index into the balances array.
+	balances []uint64
+
+	assetBalances map[uint32][]uint64
+	numAssets     uint64
+}
+
+func (g *generator) WriteGenesis(output io.Writer) {
+	var allocations []types.GenesisAllocation
+
+	for i := uint64(0); i < g.config.NumGenesisAccounts; i++ {
+		addr := indexToAccount(i)
+		allocations = append(allocations, types.GenesisAllocation{
+			Address: addr.String(),
+			State: types.AccountData{
+				MicroAlgos: types.MicroAlgos(g.config.GenesisAccountInitialBalance),
+			},
+		})
+	}
+
+	gen := types.Genesis{
+		SchemaID:    "v1",
+		Network:     "generated-network",
+		Proto:       "future",
+		Allocation:  allocations,
+		RewardsPool: g.rewardsPool.String(),
+		FeeSink:     g.feeSink.String(),
+		Timestamp:   g.timestamp,
+	}
+
+	output.Write(json.Encode(gen))
+}
+
+func (g *generator) generateTransaction(round uint64, intra uint64) (types.SignedTxnWithAD, error) {
+	// TODO: Distribute transactions according to configuration
+	return g.generatePaymentTxn(round, intra)
+}
+
+// WriteBlock generates a block full of new transactions and writes it to the writer.
+func (g *generator) WriteBlock(output io.Writer, _ uint64) {
+	// Generate the transactions
+	transactions := make([]types.SignedTxnInBlock, 0, g.config.TxnPerBlock)
+	for i := uint64(0); i < g.config.TxnPerBlock; i++ {
+		txn, err := g.generateTransaction(g.round, i)
+		stxnib := types.SignedTxnInBlock{
+			SignedTxnWithAD: txn,
+			HasGenesisID:    true,
+			HasGenesisHash:  true,
+		}
+		if err != nil {
+			panic(fmt.Sprintf("failed to generate transaction: %v\n", err))
+		}
+		transactions = append(transactions, stxnib)
+	}
+
+	block := types.Block{
+		BlockHeader: types.BlockHeader{
+			Round:       types.Round(g.round),
+			Branch:      types.BlockHash{},
+			Seed:        types.Seed{},
+			TxnRoot:     types.Digest{},
+			TimeStamp:   g.timestamp,
+			GenesisID:   g.config.GenesisID,
+			GenesisHash: g.config.GenesisHash,
+			RewardsState: types.RewardsState{
+				FeeSink:                   g.feeSink,
+				RewardsPool:               g.rewardsPool,
+				RewardsLevel:              0,
+				RewardsRate:               0,
+				RewardsResidue:            0,
+				RewardsRecalculationRound: 0,
+			},
+			UpgradeState: types.UpgradeState{
+				CurrentProtocol: "future",
+			},
+			//UpgradeVote:  types.UpgradeVote{},
+			TxnCounter:  g.txnCounter,
+			CompactCert: nil,
+		},
+		Payset: types.Payset(transactions),
+	}
+
+	cert := types.EncodedBlockCert{
+		Block:       block,
+		Certificate: types.Certificate{},
+	}
+
+	g.txnCounter += g.config.TxnPerBlock
+	g.timestamp += 4500
+	g.round++
+
+	fmt.Println(g.txnCounter)
+	output.Write(msgpack.Encode(cert))
+}
+
+func indexToAccount(i uint64) (addr sdk_types.Address) {
+	binary.LittleEndian.PutUint64(addr[:], i)
+	return
+}
+
+// initializeAccounting creates the genesis accounts.
+func (g *generator) initializeAccounting() {
+	if g.config.NumGenesisAccounts == 0 {
+		panic("Number of genesis accounts must be > 0.")
+	}
+
+	g.numPayments = 0
+	g.numAccounts = g.config.NumGenesisAccounts
+	for i := uint64(0); i < g.config.NumGenesisAccounts; i++ {
+		g.balances = append(g.balances, g.config.GenesisAccountInitialBalance)
+	}
+}
+
+// generatePaymentTxn creates a new payment transaction.
+func (g *generator) generatePaymentTxn(round uint64, intra uint64) (types.SignedTxnWithAD, error) {
+	var receiveIndex uint64
+	if g.numPayments%g.config.NewAccountFrequency == 0 {
+		g.balances = append(g.balances, 0)
+		g.numAccounts++
+		receiveIndex = g.numAccounts - 1
+	} else {
+		receiveIndex = rand.Uint64() % g.numAccounts
+	}
+
+	sendIndex := g.numPayments % g.numAccounts
+	sender := indexToAccount(sendIndex)
+	receiver := indexToAccount(receiveIndex)
+
+	if g.balances[sendIndex] < 2 {
+		panic(fmt.Sprintf("the sender account does not have two microalgos to rub together. idx %d, payment number %d", sendIndex, g.numPayments))
+	}
+
+	amount := g.balances[sendIndex] / 2
+	g.balances[sendIndex] -= amount
+	g.balances[receiveIndex] += amount
+
+	g.numPayments++
+	txn := sdk_types.Transaction{
+		Type: "pay",
+		Header: sdk_types.Header{
+			Sender:     sender,
+			Fee:        1000,
+			FirstValid: sdk_types.Round(round),
+			LastValid:  sdk_types.Round(round + 1000),
+			//Note:        nil,
+			GenesisID:   g.config.GenesisID,
+			GenesisHash: g.config.GenesisHash,
+			//Group:       sdk_types.Digest{},
+			//Lease:       [32]byte{},
+			//RekeyTo:     sdk_types.Address{},
+		},
+		//KeyregTxnFields:        sdk_types.KeyregTxnFields{},
+		PaymentTxnFields: sdk_types.PaymentTxnFields{
+			Receiver: receiver,
+			Amount:   sdk_types.MicroAlgos(amount),
+			//CloseRemainderTo: sdk_types.Address{},
+		},
+		//AssetConfigTxnFields:   sdk_types.AssetConfigTxnFields{},
+		//AssetTransferTxnFields: sdk_types.AssetTransferTxnFields{},
+		//AssetFreezeTxnFields:   sdk_types.AssetFreezeTxnFields{},
+		//ApplicationFields:      sdk_types.ApplicationFields{},
+	}
+
+	stxn := sdk_types.SignedTxn{
+		Sig:      sdk_types.Signature{},
+		Msig:     sdk_types.MultisigSig{},
+		Lsig:     sdk_types.LogicSig{},
+		Txn:      txn,
+		AuthAddr: sdk_types.Address{},
+	}
+	stxn.Sig[32] = 50
+	/*
+		// Would it be useful to generate a random signature?
+		_, err := rand.Read(stxn.Sig[:])
+		if err != nil {
+			fmt.Println("Failed to generate a random signature")
+		}
+	*/
+
+	withAd := types.SignedTxnWithAD{
+		SignedTxn: stxn,
+		ApplyData: types.ApplyData{},
+	}
+	return withAd, nil
+}
diff --git a/cmd/block-generator/main.go b/cmd/block-generator/main.go
new file mode 100644
index 0000000..aa31831
--- /dev/null
+++ b/cmd/block-generator/main.go
@@ -0,0 +1,115 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+	"math/rand"
+	"net/http"
+	"os"
+	"strings"
+
+	"github.com/spf13/viper"
+
+	"github.com/algorand/indexer/cmd/block-generator/generator"
+	"github.com/algorand/indexer/util"
+)
+
+var configFile string
+var port uint
+var gen generator.Generator
+
+const configFileName = "block_generator_config"
+
+func init() {
+	rand.Seed(12345)
+	flag.StringVar(&configFile, "config", "", fmt.Sprintf("Override default config file from '%s'.", configFileName))
+	flag.UintVar(&port, "port", 4010, "Port to start the server at.")
+
+	viper.SetConfigName(configFileName)
+	viper.SetConfigType("yaml")
+	viper.AddConfigPath(".")
+}
+
+func initializeConfigFile() error {
+	if len(configFile) > 0 {
+		f, err := os.Open(configFile)
+		if err != nil {
+			return err
+		}
+
+		return viper.ReadConfig(f)
+	}
+
+	return viper.ReadInConfig()
+}
+
+func main() {
+	flag.Parse()
+
+	util.MaybeFail(initializeConfigFile(), "problem loading config file. Use '-config' or create a config file.")
+
+	// Pass everything from the configuration into the generator.
+	gen = generator.MakeGenerator(generator.GenerationConfig{
+		TxnPerBlock:                  15000,
+		NewAccountFrequency:          100,
+		Protocol:                     "future",
+		NumGenesisAccounts:           10,
+		GenesisAccountInitialBalance: 1000000000,
+		GenesisID:                    "blockgen-test",
+		GenesisHash:                  [32]byte{},
+	})
+
+	http.HandleFunc("/", help)
+	http.HandleFunc("/v2/blocks/", handleBlock)
+	http.HandleFunc("/genesis", handleGenesis)
+
+	portStr := fmt.Sprintf(":%d", port)
+	fmt.Printf("Starting server at %s\n", portStr)
+	http.ListenAndServe(portStr, nil)
+}
+
+func help(w http.ResponseWriter, r *http.Request) {
+	fmt.Fprintf(w, "Use /v2/blocks/:blocknum: to get a block.")
+}
+
+func handleGenesis(w http.ResponseWriter, r *http.Request) {
+	gen.WriteGenesis(w)
+}
+
+func handleBlock(w http.ResponseWriter, r *http.Request) {
+	// The generator doesn't actually care about the block...
+	round, err := parseRound(r.URL.Path)
+	if err != nil {
+		fmt.Fprintf(w, err.Error())
+		return
+	}
+
+	gen.WriteBlock(w, round)
+}
+
+const blockQueryPrefix = "/v2/blocks/"
+const blockQueryBlockIdx = len(blockQueryPrefix)
+
+func parseRound(path string) (uint64, error) {
+	if !strings.HasPrefix(path, blockQueryPrefix) {
+		return 0, fmt.Errorf("not a blocks query: %s", path)
+	}
+
+	result := uint64(0)
+	pathlen := len(path)
+
+	if pathlen == blockQueryBlockIdx {
+		return 0, fmt.Errorf("no block in path")
+	}
+
+	for i := blockQueryBlockIdx; i < pathlen; i++ {
+		if path[i] < '0' || path[i] > '9' {
+			if i == blockQueryBlockIdx {
+				return 0, fmt.Errorf("no block in path")
+			}
+			break
+		}
+		result = (uint64(10) * result) + uint64(int(path[i])-'0')
+	}
+	return result, nil
+}
diff --git a/cmd/block-generator/run_generator.sh b/cmd/block-generator/run_generator.sh
new file mode 100755
index 0000000..4657dd5
--- /dev/null
+++ b/cmd/block-generator/run_generator.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+
+# Demonstrate how to run the generator and connect it to indexer.
+
+POSTGRES_CONTAINER=generator-test-container
+POSTGRES_PORT=15432
+POSTGRES_DATABASE=generator_db
+
+function start_postgres() {
+  docker rm -f $POSTGRES_CONTAINER > /dev/null 2>&1 || true
+
+  # Start postgres container...
+  docker run \
+     -d \
+     --name $POSTGRES_CONTAINER \
+     -e POSTGRES_USER=algorand \
+     -e POSTGRES_PASSWORD=algorand \
+     -e PGPASSWORD=algorand \
+     -p $POSTGRES_PORT:5432 \
+     postgres
+ 
+   sleep 5
+
+  docker exec -it $POSTGRES_CONTAINER psql -Ualgorand -c "create database $POSTGRES_DATABASE"
+}
+
+function shutdown() {
+  docker rm -f $POSTGRES_CONTAINER > /dev/null 2>&1 || true
+  kill -9 $GENERATOR_PID
+}
+
+trap shutdown EXIT
+
+echo "Building generator."
+go build
+pushd ../.. > /dev/null
+echo "Building indexer."
+make
+echo "Starting postgres container."
+start_postgres
+echo "Starting block generator (see generator.log)"
+./cmd/block-generator/block-generator -port 11111 -config cmd/block-generator/config.yml 2>&1 > generator.log &
+GENERATOR_PID=$!
+echo "Starting indexer"
+./cmd/algorand-indexer/algorand-indexer daemon \
+              -S localhost:8980 \
+              --algod-net localhost:11111 \
+              --algod-token security-is-our-number-one-priority \
+              -P "host=localhost user=algorand password=algorand dbname=generator_db port=15432 sslmode=disable"
diff --git a/go.sum b/go.sum
index a7a208d..c8bbb0b 100644
--- a/go.sum
+++ b/go.sum
@@ -55,8 +55,6 @@ github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
 github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/algorand/go-algorand-sdk v1.6.0 h1:0xl694OAz611CNuZ88l510DPvzTPH4D1dJX+KNNB428=
-github.com/algorand/go-algorand-sdk v1.6.0/go.mod h1:U12d8fTN/CyKPR1HObrt51ITxb6OgXxpGCH743Ds2GQ=
 github.com/algorand/go-algorand-sdk v1.7.0 h1:eRg9UQy7FndRE5nTgniDpbyjTvP2FUKX4/0e8NdXn/E=
 github.com/algorand/go-algorand-sdk v1.7.0/go.mod h1:U12d8fTN/CyKPR1HObrt51ITxb6OgXxpGCH743Ds2GQ=
 github.com/algorand/go-codec v1.1.7 h1:6nvCh2nfgnfkaoVHKQyk2wxyl2GQBAlI7IkbqbB/e4s=
diff --git a/importer/helper.go b/importer/helper.go
index 0c4b85e..9cec894 100644
--- a/importer/helper.go
+++ b/importer/helper.go
@@ -229,7 +229,6 @@ func loadGenesis(db idb.IndexerDb, in io.Reader) (err error) {
 
 // InitialImport imports the genesis block if needed. Returns true if the initial import occurred.
 func InitialImport(db idb.IndexerDb, genesisJSONPath string, client *algod.Client, l *log.Logger) bool {
-	log.Infof("Initial import. Client is null:  %t", client == nil)
 	_, err := db.GetImportState()
 
 	// Exit immediately or crash if we don't see ErrorNotInitialized.
diff --git a/util/util.go b/util/util.go
index 27ec8e9..7420adf 100644
--- a/util/util.go
+++ b/util/util.go
@@ -1,6 +1,10 @@
 package util
 
-import "strings"
+import (
+	"fmt"
+	"os"
+	"strings"
+)
 
 // KeysStringInt returns all of the keys in the map joined by a comma.
 func KeysStringInt(m map[string]int) string {
@@ -19,3 +23,13 @@ func KeysStringBool(m map[string]bool) string {
 	}
 	return strings.Join(keys, ", ")
 }
+
+// MaybeFail exits if there was an error.
+func MaybeFail(err error, errfmt string, params ...interface{}) {
+	if err == nil {
+		return
+	}
+	fmt.Fprintf(os.Stderr, errfmt, params...)
+	fmt.Fprintf(os.Stderr, "\nError: %v\n", err)
+	os.Exit(1)
+}


Test patch:
diff --git a/cmd/block-generator/main_test.go b/cmd/block-generator/main_test.go
new file mode 100644
index 0000000..17330e1
--- /dev/null
+++ b/cmd/block-generator/main_test.go
@@ -0,0 +1,46 @@
+package main
+
+import (
+	"fmt"
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestParseRound(t *testing.T) {
+	var testcases = []struct {
+		name          string
+		url           string
+		expectedRound uint64
+	}{
+		{
+			name:          "invalid prefix",
+			url:           fmt.Sprintf("/v2/wrong/prefix/1"),
+			expectedRound: 0,
+		},
+		{
+			name:          "normal one digit",
+			url:           fmt.Sprintf("%s1", blockQueryPrefix),
+			expectedRound: 1,
+		},
+		{
+			name:          "normal long number",
+			url:           fmt.Sprintf("%s12345678", blockQueryPrefix),
+			expectedRound: 12345678,
+		},
+		{
+			name:          "with query parameters",
+			url:           fmt.Sprintf("%s1234?pretty", blockQueryPrefix),
+			expectedRound: 1234,
+		},
+	}
+
+	for _, testcase := range testcases {
+		t.Run(testcase.name, func(t *testing.T) {
+			round, err := parseRound(testcase.url)
+			if err != nil && testcase.expectedRound != 0 {
+				assert.Fail(t, fmt.Sprintf("Unexpected error parsing '%s', expected round '%d' received error: %v", testcase.url, testcase.expectedRound, err))
+			}
+			assert.Equal(t, testcase.expectedRound, round)
+		})
+	}
+}
diff --git a/cmd/e2equeries/main.go b/cmd/e2equeries/main.go
index 8722c8d..581b4ee 100644
--- a/cmd/e2equeries/main.go
+++ b/cmd/e2equeries/main.go
@@ -13,6 +13,7 @@ import (
 	"github.com/algorand/indexer/idb"
 	_ "github.com/algorand/indexer/idb/postgres"
 	"github.com/algorand/indexer/types"
+	"github.com/algorand/indexer/util"
 	testutil "github.com/algorand/indexer/util/test"
 )
 
@@ -22,7 +23,7 @@ var (
 	truev = true
 )
 
-var maybeFail = testutil.MaybeFail
+var maybeFail = util.MaybeFail
 var printAccountQuery = testutil.PrintAccountQuery
 var printTxnQuery = testutil.PrintTxnQuery
 
diff --git a/cmd/idbtest/idbtest.go b/cmd/idbtest/idbtest.go
index 73fa333..e7267f0 100644
--- a/cmd/idbtest/idbtest.go
+++ b/cmd/idbtest/idbtest.go
@@ -16,6 +16,7 @@ import (
 	models "github.com/algorand/indexer/api/generated/v2"
 	"github.com/algorand/indexer/idb"
 	_ "github.com/algorand/indexer/idb/postgres"
+	"github.com/algorand/indexer/util"
 	testutil "github.com/algorand/indexer/util/test"
 )
 
@@ -30,7 +31,7 @@ var (
 
 var exitValue = 0
 
-var maybeFail = testutil.MaybeFail
+var maybeFail = util.MaybeFail
 var printAssetQuery func(db idb.IndexerDb, q idb.AssetsQuery) = testutil.PrintAssetQuery
 var printAccountQuery = testutil.PrintAccountQuery
 var printTxnQuery = testutil.PrintTxnQuery
diff --git a/util/test/testutil.go b/util/test/testutil.go
index d178141..971cbbc 100644
--- a/util/test/testutil.go
+++ b/util/test/testutil.go
@@ -11,6 +11,7 @@ import (
 	sdk_types "github.com/algorand/go-algorand-sdk/types"
 	"github.com/algorand/indexer/idb"
 	"github.com/algorand/indexer/types"
+	"github.com/algorand/indexer/util"
 )
 
 var quiet = false
@@ -58,9 +59,9 @@ func PrintAssetQuery(db idb.IndexerDb, q idb.AssetsQuery) {
 	count := uint64(0)
 	assetchan, _ := db.Assets(context.Background(), q)
 	for ar := range assetchan {
-		MaybeFail(ar.Error, "asset query %v\n", ar.Error)
+		util.MaybeFail(ar.Error, "asset query %v\n", ar.Error)
 		pjs, err := json.Marshal(ar.Params)
-		MaybeFail(err, "json.Marshal params %v\n", err)
+		util.MaybeFail(err, "json.Marshal params %v\n", err)
 		var creator sdk_types.Address
 		copy(creator[:], ar.Creator)
 		info("%d %s %s\n", ar.AssetID, creator.String(), pjs)
@@ -79,9 +80,9 @@ func PrintAccountQuery(db idb.IndexerDb, q idb.AccountQueryOptions) {
 	accountchan, _ := db.GetAccounts(context.Background(), q)
 	count := uint64(0)
 	for ar := range accountchan {
-		MaybeFail(ar.Error, "GetAccounts err %v\n", ar.Error)
+		util.MaybeFail(ar.Error, "GetAccounts err %v\n", ar.Error)
 		jb, err := json.Marshal(ar.Account)
-		MaybeFail(err, "err %v\n", err)
+		util.MaybeFail(err, "err %v\n", err)
 		infoln(string(jb))
 		//fmt.Printf("%#v\n", ar.Account)
 		count++
@@ -99,10 +100,10 @@ func PrintTxnQuery(db idb.IndexerDb, q idb.TransactionFilter) {
 	rowchan, _ := db.Transactions(context.Background(), q)
 	count := uint64(0)
 	for txnrow := range rowchan {
-		MaybeFail(txnrow.Error, "err %v\n", txnrow.Error)
+		util.MaybeFail(txnrow.Error, "err %v\n", txnrow.Error)
 		var stxn types.SignedTxnWithAD
 		err := msgpack.Decode(txnrow.TxnBytes, &stxn)
-		MaybeFail(err, "decode txnbytes %v\n", err)
+		util.MaybeFail(err, "decode txnbytes %v\n", err)
 		//tjs, err := json.Marshal(stxn.Txn) // nope, ugly
 		//MaybeFail(err, "err %v\n", err)
 		//tjs := string(JSONOneLine(stxn.Txn))
@@ -117,12 +118,3 @@ func PrintTxnQuery(db idb.IndexerDb, q idb.TransactionFilter) {
 		exitValue = 1
 	}
 }
-
-// MaybeFail exits if there was an error.
-func MaybeFail(err error, errfmt string, params ...interface{}) {
-	if err == nil {
-		return
-	}
-	fmt.Fprintf(os.Stderr, errfmt, params...)
-	os.Exit(1)
-}