Repository: coinbase/rosetta-cli
Pull request #122 (2020-09-01 15:46:18)

Construction End Conditions
Related Issue: #112 

This PR adds end condition support to `check:construction`.

![image](https://user-images.githubusercontent.com/13023275/91879878-f91b6c80-ec34-11ea-96cc-9b27839e1cab.png)

### Changes
- [x] Fix end condition error: https://community.rosetta-api.org/t/rosetta-cli-check-data-unable-to-lookup-live-balance/198
- [x] Fix coin storage duplicate check: https://github.com/coinbase/rosetta-sdk-go/pull/123
- [x] Allow ending after some # of workflows
- [x] add Bitcoin configuration example
- [x] update version to `v0.5.0`


Patch:
diff --git a/Makefile b/Makefile
index 3b28aaf..08975e2 100644
--- a/Makefile
+++ b/Makefile
@@ -30,6 +30,7 @@ check-format:
 	! gofmt -s -l . | read;
 
 validate-configuration-files:
+	go run main.go configuration:validate examples/configuration/bitcoin.json;
 	go run main.go configuration:validate examples/configuration/ethereum.json;
 	go run main.go configuration:validate examples/configuration/simple.json;
 	go run main.go configuration:create examples/configuration/default.json;
diff --git a/cmd/check_construction.go b/cmd/check_construction.go
index 8d02254..c9adb5e 100644
--- a/cmd/check_construction.go
+++ b/cmd/check_construction.go
@@ -16,15 +16,14 @@ package cmd
 
 import (
 	"context"
+	"fmt"
 	"log"
-	"os"
 	"time"
 
 	"github.com/coinbase/rosetta-cli/pkg/tester"
 
 	"github.com/coinbase/rosetta-sdk-go/fetcher"
 	"github.com/coinbase/rosetta-sdk-go/utils"
-	"github.com/fatih/color"
 	"github.com/spf13/cobra"
 	"golang.org/x/sync/errgroup"
 )
@@ -70,12 +69,24 @@ func runCheckConstructionCmd(cmd *cobra.Command, args []string) {
 
 	_, _, fetchErr := fetcher.InitializeAsserter(ctx)
 	if fetchErr != nil {
-		log.Fatalf("%s: unable to initialize asserter", fetchErr.Err.Error())
+		tester.ExitConstruction(
+			Config,
+			nil,
+			nil,
+			fmt.Errorf("%w: unable to initialize asserter", fetchErr.Err),
+			1,
+		)
 	}
 
 	_, err := utils.CheckNetworkSupported(ctx, Config.Network, fetcher)
 	if err != nil {
-		log.Fatalf("%s: unable to confirm network is supported", err.Error())
+		tester.ExitConstruction(
+			Config,
+			nil,
+			nil,
+			fmt.Errorf("%w: unable to confirm network is supported", err),
+			1,
+		)
 	}
 
 	constructionTester, err := tester.InitializeConstruction(
@@ -84,9 +95,16 @@ func runCheckConstructionCmd(cmd *cobra.Command, args []string) {
 		Config.Network,
 		fetcher,
 		cancel,
+		&SignalReceived,
 	)
 	if err != nil {
-		log.Fatalf("%s: unable to initialize construction tester", err.Error())
+		tester.ExitConstruction(
+			Config,
+			nil,
+			nil,
+			fmt.Errorf("%w: unable to initialize construction tester", err),
+			1,
+		)
 	}
 
 	defer constructionTester.CloseDatabase(ctx)
@@ -108,21 +126,13 @@ func runCheckConstructionCmd(cmd *cobra.Command, args []string) {
 		return constructionTester.StartConstructor(ctx)
 	})
 
+	g.Go(func() error {
+		return constructionTester.WatchEndConditions(ctx)
+	})
+
 	sigListeners := []context.CancelFunc{cancel}
 	go handleSignals(sigListeners)
 
 	err = g.Wait()
-	if SignalReceived {
-		color.Red("Check halted")
-		os.Exit(1)
-		return
-	}
-
-	if err != nil {
-		color.Red("Check failed: %s", err.Error())
-		os.Exit(1)
-	}
-
-	// Will only hit this once exit conditions are added
-	color.Green("Check succeeded")
+	constructionTester.HandleErr(err)
 }
diff --git a/cmd/check_data.go b/cmd/check_data.go
index 01de949..2db9744 100644
--- a/cmd/check_data.go
+++ b/cmd/check_data.go
@@ -82,7 +82,7 @@ func runCheckDataCmd(cmd *cobra.Command, args []string) {
 
 	_, _, fetchErr := fetcher.InitializeAsserter(ctx)
 	if fetchErr != nil {
-		tester.Exit(
+		tester.ExitData(
 			Config,
 			nil,
 			nil,
@@ -95,7 +95,7 @@ func runCheckDataCmd(cmd *cobra.Command, args []string) {
 
 	networkStatus, err := utils.CheckNetworkSupported(ctx, Config.Network, fetcher)
 	if err != nil {
-		tester.Exit(
+		tester.ExitData(
 			Config,
 			nil,
 			nil,
diff --git a/cmd/root.go b/cmd/root.go
index bf8d0a2..a8fab93 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -132,6 +132,6 @@ var versionCmd = &cobra.Command{
 	Use:   "version",
 	Short: "Print rosetta-cli version",
 	Run: func(cmd *cobra.Command, args []string) {
-		fmt.Println("v0.4.2")
+		fmt.Println("v0.5.0")
 	},
 }
diff --git a/configuration/configuration.go b/configuration/configuration.go
index c26e3ce..997c3bf 100644
--- a/configuration/configuration.go
+++ b/configuration/configuration.go
@@ -79,9 +79,6 @@ var (
 	}
 )
 
-// TODO: Add support for sophisticated end conditions
-// (https://github.com/coinbase/rosetta-cli/issues/66)
-
 // ConstructionConfiguration contains all configurations
 // to run check:construction.
 type ConstructionConfiguration struct {
@@ -119,12 +116,23 @@ type ConstructionConfiguration struct {
 
 	// PrefundedAccounts is an array of prefunded accounts
 	// to use while testing.
-	PrefundedAccounts []*storage.PrefundedAccount `json:"prefunded_accounts"`
+	PrefundedAccounts []*storage.PrefundedAccount `json:"prefunded_accounts,omitempty"`
 
 	// Workflows are executed by the rosetta-cli to test
 	// certain construction flows. Make sure to define a
 	// "request_funds" and "create_account" workflow.
 	Workflows []*job.Workflow `json:"workflows"`
+
+	// EndConditions is a map of workflow:count that
+	// indicates how many of each workflow should be performed
+	// before check:construction should stop. For example,
+	// {"create_account": 5} indicates that 5 "create_account"
+	// workflows should be performed before stopping.
+	EndConditions map[string]int `json:"end_conditions,omitempty"`
+
+	// ResultsOutputFile is the absolute filepath of where to save
+	// the results of a check:construction run.
+	ResultsOutputFile string `json:"results_output_file,omitempty"`
 }
 
 // DefaultDataConfiguration returns the default *DataConfiguration
@@ -155,7 +163,6 @@ func DefaultConfiguration() *Configuration {
 
 // DataEndConditions contains all the conditions for the syncer to stop
 // when running check:data.
-// Only 1 end condition can be populated at once!
 type DataEndConditions struct {
 	// Index configures the syncer to stop once reaching a particular block height.
 	Index *int64 `json:"index,omitempty"`
diff --git a/examples/configuration/bitcoin.json b/examples/configuration/bitcoin.json
new file mode 100644
index 0000000..211c322
--- /dev/null
+++ b/examples/configuration/bitcoin.json
@@ -0,0 +1,212 @@
+{
+ "network": {
+  "blockchain": "Bitcoin",
+  "network": "Testnet3"
+ },
+ "online_url": "",
+ "data_directory": "bitcoin-data",
+ "http_timeout": 300,
+ "retry_elapsed_time": 0,
+ "sync_concurrency": 0,
+ "transaction_concurrency": 0,
+ "tip_delay": 1800,
+ "disable_memory_limit": false,
+ "log_configuration": false,
+ "construction": {
+  "offline_url": "",
+  "stale_depth": 0,
+  "broadcast_limit": 0,
+  "ignore_broadcast_failures": false,
+  "clear_broadcasts": false,
+  "broadcast_behind_tip": false,
+  "block_broadcast_limit": 0,
+  "rebroadcast_all": false,
+  "workflows": [
+   {
+    "name": "request_funds",
+    "concurrency": 1,
+    "scenarios": [
+     {
+      "name": "find_address",
+      "actions": [
+       {
+        "input": "{\"symbol\":\"tBTC\", \"decimals\":8}",
+        "type": "set_variable",
+        "output_path": "currency"
+       },
+       {
+        "input": "{\"minimum_balance\":{\"value\": \"0\", \"currency\": {{currency}}}, \"create_limit\":1}",
+        "type": "find_balance",
+        "output_path": "random_address"
+       }
+      ]
+     },
+     {
+      "name": "request",
+      "actions": [
+       {
+        "input": "{\"address\": {{random_address.account.address}}, \"minimum_balance\":{\"value\": \"1000000\", \"currency\": {{currency}}}}",
+        "type": "find_balance",
+        "output_path": "loaded_address"
+       }
+      ]
+     }
+    ]
+   },
+   {
+    "name": "create_account",
+    "concurrency": 1,
+    "scenarios": [
+     {
+      "name": "create_account",
+      "actions": [
+       {
+        "input": "{\"network\":\"Testnet3\", \"blockchain\":\"Bitcoin\"}",
+        "type": "set_variable",
+        "output_path": "network"
+       },
+       {
+        "input": "{\"curve_type\": \"secp256k1\"}",
+        "type": "generate_key",
+        "output_path": "key"
+       },
+       {
+        "input": "{\"network_identifier\": {{network}}, \"public_key\": {{key.public_key}}}",
+        "type": "derive",
+        "output_path": "address"
+       },
+       {
+        "input": "{\"address\": {{address.address}}, \"keypair\": {{key}}}",
+        "type": "save_address"
+       }
+      ]
+     }
+    ]
+   },
+   {
+    "name": "transfer",
+    "concurrency": 10,
+    "scenarios": [
+     {
+      "name": "transfer",
+      "actions": [
+       {
+        "input": "{\"network\":\"Testnet3\", \"blockchain\":\"Bitcoin\"}",
+        "type": "set_variable",
+        "output_path": "transfer.network"
+       },
+       {
+        "input": "{\"symbol\":\"tBTC\", \"decimals\":8}",
+        "type": "set_variable",
+        "output_path": "currency"
+       },
+       {
+        "input": "\"600\"",
+        "type": "set_variable",
+        "output_path": "dust_amount"
+       },
+       {
+        "input": "\"600\"",
+        "type": "set_variable",
+        "output_path": "fee_amount"
+       },
+       {
+        "input": "{\"operation\":\"addition\", \"left_value\": {{dust_amount}}, \"right_value\": {{fee_amount}}}",
+        "type": "math",
+        "output_path": "send_buffer"
+       },
+       {
+        "input": "\"1800\"",
+        "type": "set_variable",
+        "output_path": "reserved_amount"
+       },
+       {
+        "input": "{\"require_coin\":true, \"minimum_balance\":{\"value\": {{reserved_amount}}, \"currency\": {{currency}}}}",
+        "type": "find_balance",
+        "output_path": "sender"
+       },
+       {
+        "input": "{\"operation\":\"subtraction\", \"left_value\": {{sender.balance.value}}, \"right_value\": {{send_buffer}}}",
+        "type": "math",
+        "output_path": "available_amount"
+       },
+       {
+        "input": "{\"minimum\": {{dust_amount}}, \"maximum\": {{available_amount}}}",
+        "type": "random_number",
+        "output_path": "recipient_amount"
+       },
+       {
+        "input": "{\"recipient_amount\":{{recipient_amount}}}",
+        "type": "print_message"
+       },
+       {
+        "input": "{\"operation\":\"subtraction\", \"left_value\": {{sender.balance.value}}, \"right_value\": {{recipient_amount}}}",
+        "type": "math",
+        "output_path": "change_amount"
+       },
+       {
+        "input": "{\"operation\":\"subtraction\", \"left_value\": {{change_amount}}, \"right_value\": {{fee_amount}}}",
+        "type": "math",
+        "output_path": "change_amount"
+       },
+       {
+        "input": "{\"change_amount\":{{change_amount}}}",
+        "type": "print_message"
+       },
+       {
+        "input": "{\"operation\":\"subtraction\", \"left_value\": \"0\", \"right_value\":{{sender.balance.value}}}",
+        "type": "math",
+        "output_path": "sender_amount"
+       },
+       {
+        "input": "{\"not_address\":[{{sender.account.address}}], \"not_coins\":[{{sender.coin}}], \"minimum_balance\":{\"value\": \"0\", \"currency\": {{currency}}}, \"create_limit\": 100, \"create_probability\": 50}",
+        "type": "find_balance",
+        "output_path": "recipient"
+       },
+       {
+        "input": "\"1\"",
+        "type": "set_variable",
+        "output_path": "transfer.confirmation_depth"
+       },
+       {
+        "input": "[{\"operation_identifier\":{\"index\":0},\"type\":\"INPUT\",\"account\":{\"address\":{{sender.account.address}}},\"amount\":{\"value\":{{sender_amount}},\"currency\":{{currency}}}, \"coin_change\":{\"coin_action\":\"coin_spent\", \"coin_identifier\":{{sender.coin}}}},{\"operation_identifier\":{\"index\":1},\"type\":\"OUTPUT\",\"account\":{\"address\":{{recipient.account.address}}},\"amount\":{\"value\":{{recipient_amount}},\"currency\":{{currency}}}}, {\"operation_identifier\":{\"index\":2},\"type\":\"OUTPUT\",\"account\":{\"address\":{{sender.account.address}}},\"amount\":{\"value\":{{change_amount}},\"currency\":{{currency}}}}]",
+        "type": "set_variable",
+        "output_path": "transfer.operations"
+       },
+       {
+        "input": "{{transfer.operations}}",
+        "type": "print_message"
+       }
+      ]
+     }
+    ]
+   }
+  ],
+  "end_conditions": {
+   "create_account": 10,
+   "transfer": 10
+  }
+ },
+ "data": {
+  "active_reconciliation_concurrency": 0,
+  "inactive_reconciliation_concurrency": 0,
+  "inactive_reconciliation_frequency": 0,
+  "log_blocks": false,
+  "log_transactions": false,
+  "log_balance_changes": false,
+  "log_reconciliations": false,
+  "ignore_reconciliation_error": false,
+  "exempt_accounts": "",
+  "bootstrap_balances": "",
+  "historical_balance_disabled": true,
+  "interesting_accounts": "",
+  "reconciliation_disabled": false,
+  "inactive_discrepency_search_disabled": false,
+  "balance_tracking_disabled": false,
+  "coin_tracking_disabled": false,
+  "end_conditions": {
+   "reconciliation_coverage": 0.95
+  },
+  "results_output_file": ""
+ }
+}
diff --git a/examples/configuration/ethereum.json b/examples/configuration/ethereum.json
index d6423bc..65ea4d0 100644
--- a/examples/configuration/ethereum.json
+++ b/examples/configuration/ethereum.json
@@ -4,7 +4,7 @@
   "network": "Ropsten"
  },
  "online_url": "",
- "data_directory": "",
+ "data_directory": "ethereum-data",
  "http_timeout": 300,
  "retry_elapsed_time": 0,
  "sync_concurrency": 0,
@@ -21,7 +21,6 @@
   "broadcast_behind_tip": false,
   "block_broadcast_limit": 0,
   "rebroadcast_all": false,
-  "prefunded_accounts": null,
   "workflows": [
    {
     "name": "request_funds",
@@ -149,7 +148,32 @@
      }
     ]
    }
-  ]
+  ],
+  "end_conditions": {
+   "create_account": 10,
+   "transfer": 10
+  }
  },
- "data": null
+ "data": {
+  "active_reconciliation_concurrency": 0,
+  "inactive_reconciliation_concurrency": 0,
+  "inactive_reconciliation_frequency": 0,
+  "log_blocks": false,
+  "log_transactions": false,
+  "log_balance_changes": false,
+  "log_reconciliations": false,
+  "ignore_reconciliation_error": false,
+  "exempt_accounts": "",
+  "bootstrap_balances": "",
+  "historical_balance_disabled": false,
+  "interesting_accounts": "",
+  "reconciliation_disabled": false,
+  "inactive_discrepency_search_disabled": false,
+  "balance_tracking_disabled": false,
+  "coin_tracking_disabled": false,
+  "end_conditions": {
+   "reconciliation_coverage": 0.95
+  },
+  "results_output_file": ""
+ }
 }
\ No newline at end of file
diff --git a/go.mod b/go.mod
index f7f7190..cab4ec8 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/coinbase/rosetta-cli
 go 1.13
 
 require (
-	github.com/coinbase/rosetta-sdk-go v0.3.5-0.20200901005342-2c16b7a9ff30
+	github.com/coinbase/rosetta-sdk-go v0.3.5-0.20200901051936-be1d05c3bec0
 	github.com/fatih/color v1.9.0
 	github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a
 	github.com/olekukonko/tablewriter v0.0.2-0.20190409134802-7e037d187b0c
diff --git a/go.sum b/go.sum
index 9a5320e..4c3880d 100644
--- a/go.sum
+++ b/go.sum
@@ -57,6 +57,7 @@ github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cloudflare/cloudflare-go v0.10.2-0.20190916151808-a80f83b9add9/go.mod h1:1MxXX1Ux4x6mqPmjkUgTP1CdXIBXKX7T+Jk9Gxrmx+U=
+github.com/coinbase/rosetta-sdk-go v0.3.4 h1:jWKgajozco/T0FNnZb2TqBsmsUoF6ZuCLnUJkEE+vNg=
 github.com/coinbase/rosetta-sdk-go v0.3.5-0.20200824221853-d7b1fe2f9239 h1:eP88DoIkcbMG5zHja3lUh4F2c+9am1T0zk/AChy1Vpo=
 github.com/coinbase/rosetta-sdk-go v0.3.5-0.20200824221853-d7b1fe2f9239/go.mod h1:/dKD5dZJ0bzDXrd/M3XMQmpXRk0bru3s9EMIeyBJfy4=
 github.com/coinbase/rosetta-sdk-go v0.3.5-0.20200825180137-4b6b8c12e59e h1:gPomoE/+wkTz6/eFWdugcOvWlH/FjiKjNgsIrHrrhZ0=
@@ -109,6 +110,8 @@ github.com/coinbase/rosetta-sdk-go v0.3.5-0.20200831200955-3c1868507a81 h1:6R/X/
 github.com/coinbase/rosetta-sdk-go v0.3.5-0.20200831200955-3c1868507a81/go.mod h1:ERvHFmf3+JUQB/6ZysASs8CCEcWJMAhN+y/ioqnBsLQ=
 github.com/coinbase/rosetta-sdk-go v0.3.5-0.20200901005342-2c16b7a9ff30 h1:beBdN7OZ0OO/wjADaievjIE0OAuk0qb4zBd2wNn9NfQ=
 github.com/coinbase/rosetta-sdk-go v0.3.5-0.20200901005342-2c16b7a9ff30/go.mod h1:ERvHFmf3+JUQB/6ZysASs8CCEcWJMAhN+y/ioqnBsLQ=
+github.com/coinbase/rosetta-sdk-go v0.3.5-0.20200901051936-be1d05c3bec0 h1:KIjma/2YyrC7S0UJoRO0Zdqneixkbks2uwe1lK5KfqU=
+github.com/coinbase/rosetta-sdk-go v0.3.5-0.20200901051936-be1d05c3bec0/go.mod h1:ERvHFmf3+JUQB/6ZysASs8CCEcWJMAhN+y/ioqnBsLQ=
 github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
 github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
 github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=


Test patch:
diff --git a/pkg/tester/construction.go b/pkg/tester/construction.go
index 9ed2bf2..850d888 100644
--- a/pkg/tester/construction.go
+++ b/pkg/tester/construction.go
@@ -19,6 +19,7 @@ import (
 	"errors"
 	"fmt"
 	"log"
+	"os"
 	"time"
 
 	"github.com/coinbase/rosetta-cli/configuration"
@@ -39,6 +40,8 @@ const (
 	// constructionCmdName is used as the prefix on the data directory
 	// for all data saved using this command.
 	constructionCmdName = "check-construction"
+
+	endConditionsCheckInterval = 10 * time.Second
 )
 
 // ConstructionTester coordinates the `check:construction` test.
@@ -51,7 +54,13 @@ type ConstructionTester struct {
 	onlineFetcher    *fetcher.Fetcher
 	broadcastStorage *storage.BroadcastStorage
 	blockStorage     *storage.BlockStorage
+	jobStorage       *storage.JobStorage
+	counterStorage   *storage.CounterStorage
 	coordinator      *coordinator.Coordinator
+	cancel           context.CancelFunc
+	signalReceived   *bool
+
+	reachedEndConditions bool
 }
 
 // InitializeConstruction initiates the construction API tester.
@@ -61,6 +70,7 @@ func InitializeConstruction(
 	network *types.NetworkIdentifier,
 	onlineFetcher *fetcher.Fetcher,
 	cancel context.CancelFunc,
+	signalReceived *bool,
 ) (*ConstructionTester, error) {
 	dataPath, err := utils.CreateCommandPath(config.DataDirectory, constructionCmdName, network)
 	if err != nil {
@@ -237,7 +247,11 @@ func InitializeConstruction(
 		coordinator:      coordinator,
 		broadcastStorage: broadcastStorage,
 		blockStorage:     blockStorage,
+		jobStorage:       jobStorage,
+		counterStorage:   counterStorage,
 		onlineFetcher:    onlineFetcher,
+		cancel:           cancel,
+		signalReceived:   signalReceived,
 	}, nil
 }
 
@@ -329,3 +343,59 @@ func (t *ConstructionTester) PerformBroadcasts(ctx context.Context) error {
 
 	return nil
 }
+
+// WatchEndConditions cancels check:construction once
+// all end conditions are met (provided workflows
+// are executed at least minOccurences).
+func (t *ConstructionTester) WatchEndConditions(
+	ctx context.Context,
+) error {
+	endConditions := t.config.Construction.EndConditions
+	if endConditions == nil {
+		return nil
+	}
+
+	tc := time.NewTicker(endConditionsCheckInterval)
+	defer tc.Stop()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-tc.C:
+			conditionsMet := true
+			for workflow, minOccurences := range endConditions {
+				completed, err := t.jobStorage.Completed(ctx, workflow)
+				if err != nil {
+					return fmt.Errorf("%w: unable to fetch completed %s", err, workflow)
+				}
+
+				if len(completed) < minOccurences {
+					conditionsMet = false
+					break
+				}
+			}
+
+			if conditionsMet {
+				t.reachedEndConditions = true
+				t.cancel()
+				return nil
+			}
+		}
+	}
+}
+
+// HandleErr is called when `check:construction` returns an error.
+func (t *ConstructionTester) HandleErr(err error) {
+	if *t.signalReceived {
+		color.Red("Check halted")
+		os.Exit(1)
+		return
+	}
+
+	if t.reachedEndConditions {
+		ExitConstruction(t.config, t.counterStorage, t.jobStorage, nil, 0)
+	}
+
+	ExitConstruction(t.config, t.counterStorage, t.jobStorage, err, 1)
+}
diff --git a/pkg/tester/construction_results.go b/pkg/tester/construction_results.go
new file mode 100644
index 0000000..55ff54b
--- /dev/null
+++ b/pkg/tester/construction_results.go
@@ -0,0 +1,247 @@
+// Copyright 2020 Coinbase, Inc.
+//
+// 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 tester
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"os"
+	"strconv"
+
+	"github.com/coinbase/rosetta-cli/configuration"
+
+	"github.com/coinbase/rosetta-sdk-go/storage"
+	"github.com/coinbase/rosetta-sdk-go/types"
+	"github.com/coinbase/rosetta-sdk-go/utils"
+	"github.com/fatih/color"
+	"github.com/olekukonko/tablewriter"
+)
+
+// CheckConstructionResults contains any error that
+// occurred on a check:construction run and a collection
+// of interesting stats.
+type CheckConstructionResults struct {
+	Error         string                  `json:"error"`
+	EndConditions map[string]int          `json:"end_conditions"`
+	Stats         *CheckConstructionStats `json:"stats"`
+	// TODO: add test output (like check data)
+}
+
+// Print logs CheckConstructionResults to the console.
+func (c *CheckConstructionResults) Print() {
+	if len(c.Error) > 0 {
+		fmt.Printf("\n")
+		color.Red("Error: %s", c.Error)
+	} else {
+		fmt.Printf("\n")
+		color.Green("Success: %s", types.PrintStruct(c.EndConditions))
+	}
+
+	fmt.Printf("\n")
+	c.Stats.Print()
+	fmt.Printf("\n")
+}
+
+// Output writes CheckConstructionResults to the provided
+// path.
+func (c *CheckConstructionResults) Output(path string) {
+	if len(path) > 0 {
+		writeErr := utils.SerializeAndWrite(path, c)
+		if writeErr != nil {
+			log.Printf("%s: unable to save results\n", writeErr.Error())
+		}
+	}
+}
+
+// ComputeCheckConstructionResults returns a populated
+// CheckConstructionResults.
+func ComputeCheckConstructionResults(
+	cfg *configuration.Configuration,
+	err error,
+	counterStorage *storage.CounterStorage,
+	jobStorage *storage.JobStorage,
+) *CheckConstructionResults {
+	ctx := context.Background()
+	stats := ComputeCheckConstructionStats(ctx, cfg, counterStorage, jobStorage)
+	results := &CheckConstructionResults{
+		Stats: stats,
+	}
+
+	if err != nil {
+		results.Error = err.Error()
+
+		// We never want to populate an end condition
+		// if there was an error!
+		return results
+	}
+
+	results.EndConditions = cfg.Construction.EndConditions
+
+	return results
+}
+
+// CheckConstructionStats contains interesting stats
+// that are tracked while running check:construction.
+type CheckConstructionStats struct {
+	TransactionsConfirmed int64 `json:"transactions_confirmed"`
+	TransactionsCreated   int64 `json:"transactions_created"`
+	StaleBroadcasts       int64 `json:"stale_broadcasts"`
+	FailedBroadcasts      int64 `json:"failed_broadcasts"`
+	AddressesCreated      int64 `json:"addresses_created"`
+
+	WorkflowsCompleted map[string]int64 `json:"workflows_completed"`
+}
+
+// PrintCounts logs counter-related stats to the console.
+func (c *CheckConstructionStats) PrintCounts() {
+	table := tablewriter.NewWriter(os.Stdout)
+	table.SetRowLine(true)
+	table.SetRowSeparator("-")
+	table.SetHeader([]string{"check:construction Stats", "Description", "Value"})
+	table.Append([]string{
+		"Addresses Created",
+		"# of addresses created",
+		strconv.FormatInt(c.AddressesCreated, 10),
+	})
+	table.Append([]string{
+		"Transactions Created",
+		"# of transactions created",
+		strconv.FormatInt(c.TransactionsCreated, 10),
+	})
+	table.Append([]string{
+		"Stale Broadcasts",
+		"# of broadcasts missing after stale depth",
+		strconv.FormatInt(c.StaleBroadcasts, 10),
+	})
+	table.Append([]string{
+		"Transactions Confirmed",
+		"# of transactions seen on-chain",
+		strconv.FormatInt(c.TransactionsConfirmed, 10),
+	})
+	table.Append([]string{
+		"Failed Broadcasts",
+		"# of transactions that exceeded broadcast limit",
+		strconv.FormatInt(c.FailedBroadcasts, 10),
+	})
+
+	table.Render()
+}
+
+// PrintWorkflows logs workflow counts to the console.
+func (c *CheckConstructionStats) PrintWorkflows() {
+	table := tablewriter.NewWriter(os.Stdout)
+	table.SetRowLine(true)
+	table.SetRowSeparator("-")
+	table.SetHeader([]string{"check:construction Workflows", "Count"})
+	for workflow, count := range c.WorkflowsCompleted {
+		table.Append([]string{
+			workflow,
+			strconv.FormatInt(count, 10),
+		})
+	}
+
+	table.Render()
+}
+
+// Print calls PrintCounts and PrintWorkflows.
+func (c *CheckConstructionStats) Print() {
+	c.PrintCounts()
+	c.PrintWorkflows()
+}
+
+// ComputeCheckConstructionStats returns a populated
+// CheckConstructionStats.
+func ComputeCheckConstructionStats(
+	ctx context.Context,
+	config *configuration.Configuration,
+	counters *storage.CounterStorage,
+	jobs *storage.JobStorage,
+) *CheckConstructionStats {
+	if counters == nil || jobs == nil {
+		return nil
+	}
+
+	transactionsCreated, err := counters.Get(ctx, storage.TransactionsCreatedCounter)
+	if err != nil {
+		log.Printf("%s cannot get transactions created counter\n", err.Error())
+		return nil
+	}
+
+	transactionsConfirmed, err := counters.Get(ctx, storage.TransactionsConfirmedCounter)
+	if err != nil {
+		log.Printf("%s cannot get transactions confirmed counter\n", err.Error())
+		return nil
+	}
+
+	staleBroadcasts, err := counters.Get(ctx, storage.StaleBroadcastsCounter)
+	if err != nil {
+		log.Printf("%s cannot get stale broadcasts counter\n", err)
+		return nil
+	}
+
+	failedBroadcasts, err := counters.Get(ctx, storage.FailedBroadcastsCounter)
+	if err != nil {
+		log.Printf("%s cannot get failed broadcasts counter\n", err.Error())
+		return nil
+	}
+
+	addressesCreated, err := counters.Get(ctx, storage.AddressesCreatedCounter)
+	if err != nil {
+		log.Printf("%s cannot get addresses created counter\n", err.Error())
+		return nil
+	}
+
+	workflowsCompleted := map[string]int64{}
+	for _, workflow := range config.Construction.Workflows {
+		completed, err := jobs.Completed(ctx, workflow.Name)
+		if err != nil {
+			log.Printf("%s cannot get completed count for %s\n", err.Error(), workflow.Name)
+			return nil
+		}
+
+		workflowsCompleted[workflow.Name] = int64(len(completed))
+	}
+
+	return &CheckConstructionStats{
+		TransactionsCreated:   transactionsCreated.Int64(),
+		TransactionsConfirmed: transactionsConfirmed.Int64(),
+		StaleBroadcasts:       staleBroadcasts.Int64(),
+		FailedBroadcasts:      failedBroadcasts.Int64(),
+		AddressesCreated:      addressesCreated.Int64(),
+		WorkflowsCompleted:    workflowsCompleted,
+	}
+}
+
+// ExitConstruction exits check:data, logs the test results to the console,
+// and to a provided output path.
+func ExitConstruction(
+	config *configuration.Configuration,
+	counterStorage *storage.CounterStorage,
+	jobStorage *storage.JobStorage,
+	err error,
+	status int,
+) {
+	results := ComputeCheckConstructionResults(
+		config,
+		err,
+		counterStorage,
+		jobStorage,
+	)
+	results.Print()
+	results.Output(config.Construction.ResultsOutputFile)
+
+	os.Exit(status)
+}
diff --git a/pkg/tester/data.go b/pkg/tester/data.go
index 8b397f2..36f24be 100644
--- a/pkg/tester/data.go
+++ b/pkg/tester/data.go
@@ -472,7 +472,8 @@ func (t *DataTester) HandleErr(ctx context.Context, err error, sigListeners []co
 		return
 	}
 
-	if (err == nil || err == context.Canceled) && len(t.endCondition) == 0 && t.config.Data.EndConditions != nil &&
+	if (err == nil || errors.Is(err, context.Canceled)) &&
+		len(t.endCondition) == 0 && t.config.Data.EndConditions != nil &&
 		t.config.Data.EndConditions.Index != nil { // occurs at syncer end
 		t.endCondition = configuration.IndexEndCondition
 		t.endConditionDetail = fmt.Sprintf(
@@ -482,7 +483,7 @@ func (t *DataTester) HandleErr(ctx context.Context, err error, sigListeners []co
 	}
 
 	if len(t.endCondition) != 0 {
-		Exit(
+		ExitData(
 			t.config,
 			t.counterStorage,
 			t.balanceStorage,
@@ -495,19 +496,19 @@ func (t *DataTester) HandleErr(ctx context.Context, err error, sigListeners []co
 
 	fmt.Printf("\n")
 	if t.reconcilerHandler.InactiveFailure == nil {
-		Exit(t.config, t.counterStorage, t.balanceStorage, err, 1, "", "")
+		ExitData(t.config, t.counterStorage, t.balanceStorage, err, 1, "", "")
 	}
 
 	if t.config.Data.HistoricalBalanceDisabled {
 		color.Yellow(
 			"Can't find the block missing operations automatically, please enable --lookup-balance-by-block",
 		)
-		Exit(t.config, t.counterStorage, t.balanceStorage, err, 1, "", "")
+		ExitData(t.config, t.counterStorage, t.balanceStorage, err, 1, "", "")
 	}
 
 	if t.config.Data.InactiveDiscrepencySearchDisabled {
 		color.Yellow("Search for inactive reconciliation discrepency is disabled")
-		Exit(t.config, t.counterStorage, t.balanceStorage, err, 1, "", "")
+		ExitData(t.config, t.counterStorage, t.balanceStorage, err, 1, "", "")
 	}
 
 	t.FindMissingOps(ctx, err, sigListeners)
@@ -531,7 +532,7 @@ func (t *DataTester) FindMissingOps(
 	)
 	if err != nil {
 		color.Yellow("%s: could not find block with missing ops", err.Error())
-		Exit(t.config, t.counterStorage, t.balanceStorage, originalErr, 1, "", "")
+		ExitData(t.config, t.counterStorage, t.balanceStorage, originalErr, 1, "", "")
 	}
 
 	color.Yellow(
@@ -541,7 +542,7 @@ func (t *DataTester) FindMissingOps(
 		badBlock.Hash,
 	)
 
-	Exit(t.config, t.counterStorage, t.balanceStorage, originalErr, 1, "", "")
+	ExitData(t.config, t.counterStorage, t.balanceStorage, originalErr, 1, "", "")
 }
 
 func (t *DataTester) recursiveOpSearch(
diff --git a/pkg/tester/data_results.go b/pkg/tester/data_results.go
index f4d92bd..93696b0 100644
--- a/pkg/tester/data_results.go
+++ b/pkg/tester/data_results.go
@@ -453,9 +453,9 @@ func ComputeCheckDataResults(
 	return results
 }
 
-// Exit exits the program, logs the test results to the console,
+// ExitData exits check:data, logs the test results to the console,
 // and to a provided output path.
-func Exit(
+func ExitData(
 	config *configuration.Configuration,
 	counterStorage *storage.CounterStorage,
 	balanceStorage *storage.BalanceStorage,