Repository: coinbase/rosetta-cli
Pull request #135 (2020-09-15 23:18:49)

Support Automated Fund Return
Closes: #119 
Closes: #112 

This PR adds support for automated fund return when reaching `check:construction` end conditions. This PR also includes updates to the `bitcoin.json` configuration file that demonstrate how to use the new `dry_run` feature to populate suggested fee.

### Changes
- [x] update to [`rosetta-sdk-go@v0.4.4`](https://github.com/coinbase/rosetta-sdk-go/releases/tag/v0.4.4)
- [x] invoke `coordinator.ReturnFunds` after reaching exit conditions
- [x] output a colored log when attempting to return funds (https://github.com/coinbase/rosetta-sdk-go/pull/158)
- [x] update `ethereum.json` to support `ReturnFunds`
- [x] update `bitcoin.json` to support `DryRun`
- [x] update `bitcoin.json` to support `ReturnFunds`
- [x] Update `bitcoin.json` to include assertion for fee
- [x] fix dbTx locking on exit condition abort (https://github.com/coinbase/rosetta-sdk-go/pull/160)
- [x] update README with updated info about end conditions and how to use construction API tester
- [x] closes: #84 
- [x] update version to `v0.5.2`


Patch:
diff --git a/Makefile b/Makefile
index efd4015..9a5416c 100644
--- a/Makefile
+++ b/Makefile
@@ -20,7 +20,7 @@ deps:
 	go get ./...
 
 lint:
-	golangci-lint run -v \
+	golangci-lint run --timeout 2m0s -v \
 		-E golint,misspell,gocyclo,whitespace,goconst,gocritic,gocognit,bodyclose,unconvert,lll,unparam,gomnd;
 
 format:
diff --git a/README.md b/README.md
index b4b83f0..ab1884b 100644
--- a/README.md
+++ b/README.md
@@ -80,13 +80,123 @@ Use "rosetta-cli [command] --help" for more information about a command.
 All `rosetta-cli` parameters are populated from a configuration file (`--configuration-file`)
 provided at runtime. If a configuration file is not provided, the default
 configuration is used. This default configuration can be viewed
-[here](examples/configuration/default.json).
+[here](examples/configuration/default.json). Note, there is no default
+configuration for running `check:construction` as this is very network-specific.
+You can view a full list of all configuration options [here](https://pkg.go.dev/github.com/coinbase/rosetta-cli/configuration).
 
 In the `examples/configuration` directory, you can find examples configuration
 files for running tests against a Bitcoin Rosetta implementation
 ([config](examples/configuration/bitcoin.json)) and an Ethereum Rosetta
 implementation ([config](examples/configuration/ethereum.json)).
 
+#### Writing check:construction Tests
+The new Construction API testing framework (first released in `rosetta-cli@v0.5.0`) uses
+a new design pattern to allow for complex transaction construction orchestration.
+You can read more about the design goals [here](https://community.rosetta-api.org/t/feedback-request-automated-construction-api-testing-improvements/146).
+
+##### Terminology
+When first learning about a new topic, it is often useful to understand the
+hierarchy of concerns. In the automated Construction API tester, this
+"hierarchy" is as follows:
+```text
+Workflows -> Jobs
+  Scenarios
+    Actions
+```
+
+`Workflows` contain collections of `Scenarios` to execute. `Scenarios` are
+executed atomically in database transactions (rolled back if execution fails)
+and culminate in an optional broadcast. This means that a single `Workflow`
+could contain multiple broadcasts (which can be useful for orchestrating
+staking-related transactions that affect a single account).
+
+To perform a `Workflow`, we create a `Job`. This `Job` has a unique identifier
+and stores state for all `Scenarios` in the `Workflow`. State is shared across
+an entire `Job` so `Actions` in a `Scenario` can access the output of `Actions`
+in other `Scenarios`. The syntax for accessing this shared state can be found
+[here](https://github.com/tidwall/gjson/blob/master/SYNTAX.md).
+
+`Actions` are discrete operations that can be performed in the context of a
+`Scenario`.  A full list of all `Actions` that can be performed can be found
+[here](https://pkg.go.dev/github.com/coinbase/rosetta-sdk-go/constructor/job#ActionType).
+
+If you have suggestions for more actions, please
+[open an issue in `rosetta-sdk-go`](https://github.com/coinbase/rosetta-sdk-go/issues)!
+
+##### Workflows
+To use the automated Construction API tester, you must implement 2 required `Workflows`:
+* `create_account`
+* `request_funds`
+
+Please note that `create_account` can contain a transaction broadcast if
+on-chain origination is required for new accounts on your blockchain.
+
+If you plan to run the automated Construction API tester in CI, you may wish to
+provide [`prefunded accounts`](https://pkg.go.dev/github.com/coinbase/rosetta-cli/configuration#ConstructionConfiguration)
+when running the tester (otherwise you would need to manually fund generated
+accounts).
+
+Optionally, you can also provide a `return_funds` workflow that will be invoked
+when exiting `check:construction`. This can be useful in CI when you want to return
+all funds to a single accout or faucet (instead of black-holing them in all the addresses
+created during testing).
+
+##### Broadcast Invocation
+If you'd like to broadcast a transaction at the end of a `Scenario`,
+you must populate the following fields:
+* `<scenario>.network`
+* `<scenario>.operations`
+* `<scenario>.confirmation_depth` (allows for stake-related transactions to complete before marking as a success)
+
+Optionally, you can populate the following field:
+* `<scenario>.preprocess_metadata`
+
+Once a transaction is confirmed on-chain (after the provided
+`<scenario>.confirmation_depth`, it is stored by the tester at
+`<scenario>.transaction` for access by other `Scenarios` in the same `Job`.
+
+##### Dry Runs
+In UTXO-based blockchains, it may be necessary to amend the `operations` stored
+in `<scenario>.operations` based on the `suggested_fee` returned in
+`/construction/metadata`. The automated Construction API tester supports
+running a "dry run" of a transaction broadcast if you set the follow field:
+* `<scenario>.dry_run = true`
+
+The suggested fee will then be stored as `<scenario>.suggested_fee` for use by
+other `Scenarios` in the same `Job`. You can find an example of this in the
+Bitcoin [config](examples/configuration/bitcoin.json).
+
+*If this field is not populated or set to `false`, the transaction
+will be constructed, signed, and broadcast.*
+
+##### Future Work
+* DSL for writing `Workflows` (if anyone in the community has ideas for
+this, we are all ears!)
+* `Workflow` testing tool (to mock `Workflow` before running on network)
+* Re-usable components (pre-defined logic that can be used in any `Workflow` -
+both user-defined and provided by `rosetta-cli`
+
+#### End Conditions
+When running the `rosetta-cli` in a CI job, it is usually desired to exit
+when certain conditions are met (or before then with an exit code of 1). We
+provide this functionality through the use of "end conditions" which can be
+specified in your configuration file.
+
+##### check:data
+A full list of `check:data` end conditions can be found [here](https://pkg.go.dev/github.com/coinbase/rosetta-cli/configuration#DataEndConditions).
+If any end condition is satisifed, we will exit and output the
+results in `results_output_file` (if it is populated).
+
+##### check:construction
+The `check:construction` end condition 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.
+
+Unlike `check:data`, all `check:construction` end conditions
+must be satisifed before the `rosetta-cli` will exit.
+
 #### Disable Complex Checks
 If you are just getting started with your implementation, you may want
 to disable balance tracking (did any address balance go below zero?) and
@@ -95,15 +205,6 @@ by the `/account/balance` endpoint?). Take a look at the
 [simple configuration](examples/configuration/simple.json) for an example of
 how to do this.
 
-#### Future Work
-In the near future, we will add support for providing complex exit conditions
-(i.e. did we reach tip? did we reconcile every account?) for both
-`check:construction` and `check:data` so that the `rosetta-cli`
-can be integrated into a CI flow. Currently, the only way to exit with a
-successful status in the `rosetta-cli` is to provide an `--end` flag
-when running `check:data` (returns 0 if no errors up to a block index
-are observed).
-
 ### Commands
 #### version
 ```
@@ -385,31 +486,6 @@ Global Flags:
                                     default values.
 ```
 
-## Development
-* `make deps` to install dependencies
-* `make test` to run tests
-* `make lint` to lint the source code (included generated code)
-* `make release` to run one last check before opening a PR
-* `make compile version=RELEASE_TAG` to generate binaries
-
-### Helper/Handler
-Many of the packages use a `Helper/Handler` interface pattern to acquire
-required information or to send events to some client implementation. An example
-of this is in the `reconciler` package where a `Helper` is used to get
-the account balance and the `Handler` is called to incidate whether the
-reconciliation of an account was successful.
-
-### Repo Structure
-```
-cmd
-examples // examples of different config files
-internal
-  logger // logic to write syncing information to stdout/files
-  processor // Helper/Handler implementations for reconciler, storage, and syncer
-  storage // persists block to temporary storage and allows for querying balances
-  utils // useful functions
-```
-
 ## Correctness Checks
 This tool performs a variety of correctness checks using the Rosetta Server. If
 any correctness check fails, the CLI will exit and print out a detailed
@@ -442,6 +518,30 @@ on the blockchain node without being included in an operation
 returned by the Rosetta Data API. Recall that all balance-changing
 operations should be returned by the Rosetta Data API.
 
+## Development
+* `make deps` to install dependencies
+* `make test` to run tests
+* `make lint` to lint the source code (included generated code)
+* `make release` to run one last check before opening a PR
+* `make compile version=RELEASE_TAG` to generate binaries
+
+### Helper/Handler
+Many of the packages use a `Helper/Handler` interface pattern to acquire
+required information or to send events to some client implementation. An example
+of this is in the `reconciler` package where a `Helper` is used to get
+the account balance and the `Handler` is called to incidate whether the
+reconciliation of an account was successful.
+
+### Repo Structure
+```
+cmd
+examples // examples of different config files
+pkg
+  logger // logic to write syncing information to stdout/files
+  processor // Helper/Handler implementations for reconciler, storage, and syncer
+  tester // test orchestrators
+```
+
 ## License
 This project is available open source under the terms of the [Apache 2.0 License](https://opensource.org/licenses/Apache-2.0).
 
diff --git a/cmd/check_construction.go b/cmd/check_construction.go
index 4b42d18..09c15af 100644
--- a/cmd/check_construction.go
+++ b/cmd/check_construction.go
@@ -132,8 +132,8 @@ func runCheckConstructionCmd(cmd *cobra.Command, args []string) {
 	})
 
 	sigListeners := []context.CancelFunc{cancel}
-	go handleSignals(sigListeners)
+	go handleSignals(&sigListeners)
 
 	err = g.Wait()
-	constructionTester.HandleErr(err)
+	constructionTester.HandleErr(err, &sigListeners)
 }
diff --git a/cmd/check_data.go b/cmd/check_data.go
index 08cefb6..169d513 100644
--- a/cmd/check_data.go
+++ b/cmd/check_data.go
@@ -138,7 +138,7 @@ func runCheckDataCmd(cmd *cobra.Command, args []string) {
 	})
 
 	sigListeners := []context.CancelFunc{cancel}
-	go handleSignals(sigListeners)
+	go handleSignals(&sigListeners)
 
 	err = g.Wait()
 
@@ -148,5 +148,5 @@ func runCheckDataCmd(cmd *cobra.Command, args []string) {
 
 	// HandleErr will exit if we should not attempt
 	// to find missing operations.
-	dataTester.HandleErr(ctx, err, sigListeners)
+	dataTester.HandleErr(ctx, err, &sigListeners)
 }
diff --git a/cmd/root.go b/cmd/root.go
index 2939f84..f95dde4 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -115,14 +115,14 @@ func ensureDataDirectoryExists() {
 // handleSignals handles OS signals so we can ensure we close database
 // correctly. We call multiple sigListeners because we
 // may need to cancel more than 1 context.
-func handleSignals(listeners []context.CancelFunc) {
+func handleSignals(listeners *[]context.CancelFunc) {
 	sigs := make(chan os.Signal, 1)
 	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
 	go func() {
 		sig := <-sigs
 		color.Red("Received signal: %s", sig)
 		SignalReceived = true
-		for _, listener := range listeners {
+		for _, listener := range *listeners {
 			listener()
 		}
 	}()
@@ -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.5.1")
+		fmt.Println("v0.5.2")
 	},
 }
diff --git a/examples/configuration/bitcoin.json b/examples/configuration/bitcoin.json
index a077cec..9896ec5 100644
--- a/examples/configuration/bitcoin.json
+++ b/examples/configuration/bitcoin.json
@@ -3,16 +3,15 @@
   "blockchain": "Bitcoin",
   "network": "Testnet3"
  },
- "online_url": "",
  "data_directory": "bitcoin-data",
  "http_timeout": 300,
+ "max_retries": 5,
  "retry_elapsed_time": 0,
  "max_online_connections": 0,
  "max_sync_concurrency": 0,
  "tip_delay": 1800,
  "log_configuration": false,
  "construction": {
-  "offline_url": "",
   "max_offline_connections": 0,
   "stale_depth": 0,
   "broadcast_limit": 0,
@@ -88,12 +87,12 @@
     "concurrency": 10,
     "scenarios": [
      {
-      "name": "transfer",
+      "name": "transfer_dry_run",
       "actions": [
        {
         "input": "{\"network\":\"Testnet3\", \"blockchain\":\"Bitcoin\"}",
         "type": "set_variable",
-        "output_path": "transfer.network"
+        "output_path": "transfer_dry_run.network"
        },
        {
         "input": "{\"symbol\":\"tBTC\", \"decimals\":8}",
@@ -106,17 +105,17 @@
         "output_path": "dust_amount"
        },
        {
-        "input": "\"600\"",
+        "input": "\"1200\"",
         "type": "set_variable",
-        "output_path": "fee_amount"
+        "output_path": "max_fee_amount"
        },
        {
-        "input": "{\"operation\":\"addition\", \"left_value\": {{dust_amount}}, \"right_value\": {{fee_amount}}}",
+        "input": "{\"operation\":\"addition\", \"left_value\": {{dust_amount}}, \"right_value\": {{max_fee_amount}}}",
         "type": "math",
         "output_path": "send_buffer"
        },
        {
-        "input": "\"1800\"",
+        "input": "\"2400\"",
         "type": "set_variable",
         "output_path": "reserved_amount"
        },
@@ -142,10 +141,10 @@
        {
         "input": "{\"operation\":\"subtraction\", \"left_value\": {{sender.balance.value}}, \"right_value\": {{recipient_amount}}}",
         "type": "math",
-        "output_path": "change_amount"
+        "output_path": "total_change_amount"
        },
        {
-        "input": "{\"operation\":\"subtraction\", \"left_value\": {{change_amount}}, \"right_value\": {{fee_amount}}}",
+        "input": "{\"operation\":\"subtraction\", \"left_value\": {{total_change_amount}}, \"right_value\": {{max_fee_amount}}}",
         "type": "math",
         "output_path": "change_amount"
        },
@@ -163,6 +162,54 @@
         "type": "find_balance",
         "output_path": "recipient"
        },
+       {
+        "input": "\"1\"",
+        "type": "set_variable",
+        "output_path": "transfer_dry_run.confirmation_depth"
+       },
+       {
+        "input": "\"true\"",
+        "type": "set_variable",
+        "output_path": "transfer_dry_run.dry_run"
+       },
+       {
+        "input": "[{\"operation_identifier\":{\"index\":0},\"type\":\"INPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{sender_amount}},\"currency\":{{currency}}}, \"coin_change\":{\"coin_action\":\"coin_spent\", \"coin_identifier\":{{sender.coin}}}},{\"operation_identifier\":{\"index\":1},\"type\":\"OUTPUT\",\"account\":{{recipient.account_identifier}},\"amount\":{\"value\":{{recipient_amount}},\"currency\":{{currency}}}}, {\"operation_identifier\":{\"index\":2},\"type\":\"OUTPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{change_amount}},\"currency\":{{currency}}}}]",
+        "type": "set_variable",
+        "output_path": "transfer_dry_run.operations"
+       },
+       {
+        "input": "{{transfer_dry_run.operations}}",
+        "type": "print_message"
+       }
+      ]
+     },
+     {
+      "name": "transfer",
+      "actions": [
+       {
+        "input": "{\"currency\":{{currency}}, \"amounts\":{{transfer_dry_run.suggested_fee}}}",
+        "type": "find_currency_amount",
+        "output_path": "suggested_fee"
+       },
+       {
+        "input": "{\"operation\":\"subtraction\", \"left_value\": {{total_change_amount}}, \"right_value\": {{suggested_fee.value}}}",
+        "type": "math",
+        "output_path": "change_amount"
+       },
+       {
+        "input": "{\"operation\":\"subtraction\", \"left_value\": {{change_amount}}, \"right_value\": {{dust_amount}}}",
+        "type": "math",
+        "output_path": "change_minus_dust"
+       },
+       {
+        "input": "{{change_minus_dust}}",
+        "type": "assert"
+       },
+       {
+        "input": "{\"network\":\"Testnet3\", \"blockchain\":\"Bitcoin\"}",
+        "type": "set_variable",
+        "output_path": "transfer.network"
+       },
        {
         "input": "\"1\"",
         "type": "set_variable",
@@ -180,6 +227,128 @@
       ]
      }
     ]
+   },
+   {
+    "name": "return_funds",
+    "concurrency": 10,
+    "scenarios": [
+     {
+      "name": "transfer_dry_run",
+      "actions": [
+       {
+        "input": "{\"network\":\"Testnet3\", \"blockchain\":\"Bitcoin\"}",
+        "type": "set_variable",
+        "output_path": "transfer_dry_run.network"
+       },
+       {
+        "input": "{\"symbol\":\"tBTC\", \"decimals\":8}",
+        "type": "set_variable",
+        "output_path": "currency"
+       },
+       {
+        "input": "\"1200\"",
+        "type": "set_variable",
+        "output_path": "max_fee_amount"
+       },
+       {
+        "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\": {{max_fee_amount}}}",
+        "type": "math",
+        "output_path": "recipient_amount"
+       },
+       {
+        "input": "{\"recipient_amount\":{{recipient_amount}}}",
+        "type": "print_message"
+       },
+       {
+        "input": "{\"operation\":\"subtraction\", \"left_value\": \"0\", \"right_value\":{{sender.balance.value}}}",
+        "type": "math",
+        "output_path": "sender_amount"
+       },
+       {
+        "input": "\"1\"",
+        "type": "set_variable",
+        "output_path": "transfer_dry_run.confirmation_depth"
+       },
+       {
+        "input": "\"true\"",
+        "type": "set_variable",
+        "output_path": "transfer_dry_run.dry_run"
+       },
+       {
+        "input": "{\"address\": \"mkHS9ne12qx9pS9VojpwU5xtRd4T7X7ZUt\"}",
+        "type": "set_variable",
+        "output_path": "recipient"
+       },
+       {
+        "input": "[{\"operation_identifier\":{\"index\":0},\"type\":\"INPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{sender_amount}},\"currency\":{{currency}}}, \"coin_change\":{\"coin_action\":\"coin_spent\", \"coin_identifier\":{{sender.coin}}}},{\"operation_identifier\":{\"index\":1},\"type\":\"OUTPUT\",\"account\":{{recipient}},\"amount\":{\"value\":{{recipient_amount}},\"currency\":{{currency}}}}]",
+        "type": "set_variable",
+        "output_path": "transfer_dry_run.operations"
+       },
+       {
+        "input": "{{transfer_dry_run.operations}}",
+        "type": "print_message"
+       }
+      ]
+     },
+     {
+      "name": "transfer",
+      "actions": [
+       {
+        "input": "{\"currency\":{{currency}}, \"amounts\":{{transfer_dry_run.suggested_fee}}}",
+        "type": "find_currency_amount",
+        "output_path": "suggested_fee"
+       },
+       {
+        "input": "{\"operation\":\"subtraction\", \"left_value\": {{sender.balance.value}}, \"right_value\": {{suggested_fee.value}}}",
+        "type": "math",
+        "output_path": "recipient_amount"
+       },
+       {
+        "input": "\"600\"",
+        "type": "set_variable",
+        "output_path": "dust_amount"
+       },
+       {
+        "input": "{\"operation\":\"subtraction\", \"left_value\": {{recipient_amount}}, \"right_value\": {{dust_amount}}}",
+        "type": "math",
+        "output_path": "recipient_minus_dust"
+       },
+       {
+        "input": "{{recipient_minus_dust}}",
+        "type": "assert"
+       },
+       {
+        "input": "{\"network\":\"Testnet3\", \"blockchain\":\"Bitcoin\"}",
+        "type": "set_variable",
+        "output_path": "transfer.network"
+       },
+       {
+        "input": "\"1\"",
+        "type": "set_variable",
+        "output_path": "transfer.confirmation_depth"
+       },
+       {
+        "input": "[{\"operation_identifier\":{\"index\":0},\"type\":\"INPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{sender_amount}},\"currency\":{{currency}}}, \"coin_change\":{\"coin_action\":\"coin_spent\", \"coin_identifier\":{{sender.coin}}}},{\"operation_identifier\":{\"index\":1},\"type\":\"OUTPUT\",\"account\":{{recipient}},\"amount\":{\"value\":{{recipient_amount}},\"currency\":{{currency}}}}]",
+        "type": "set_variable",
+        "output_path": "transfer.operations"
+       },
+       {
+        "input": "{{transfer.operations}}",
+        "type": "print_message"
+       }
+      ]
+     }
+    ]
    }
   ],
   "end_conditions": {
@@ -208,4 +377,4 @@
   },
   "results_output_file": ""
  }
-}
\ No newline at end of file
+}
diff --git a/examples/configuration/ethereum.json b/examples/configuration/ethereum.json
index 7d8d01e..0a3b0a2 100644
--- a/examples/configuration/ethereum.json
+++ b/examples/configuration/ethereum.json
@@ -6,6 +6,7 @@
  "online_url": "",
  "data_directory": "ethereum-data",
  "http_timeout": 300,
+ "max_retries": 5,
  "retry_elapsed_time": 0,
  "max_online_connections": 0,
  "max_sync_concurrency": 0,
@@ -147,6 +148,66 @@
       ]
      }
     ]
+   },
+   {
+    "name": "return_funds",
+    "concurrency": 10,
+    "scenarios": [
+     {
+      "name": "transfer",
+      "actions": [
+       {
+        "input": "{\"network\":\"Ropsten\", \"blockchain\":\"Ethereum\"}",
+        "type": "set_variable",
+        "output_path": "transfer.network"
+       },
+       {
+        "input": "{\"symbol\":\"ETH\", \"decimals\":18}",
+        "type": "set_variable",
+        "output_path": "currency"
+       },
+       {
+        "input": "\"42000000000000\"",
+        "type": "set_variable",
+        "output_path": "max_fee"
+       },
+       {
+        "input": "{\"minimum_balance\":{\"value\": {{max_fee}}, \"currency\": {{currency}}}}",
+        "type": "find_balance",
+        "output_path": "sender"
+       },
+       {
+        "input": "{\"operation\":\"subtraction\", \"left_value\": {{sender.balance.value}}, \"right_value\": {{max_fee}}}",
+        "type": "math",
+        "output_path": "available_amount"
+       },
+       {
+        "input": "{\"return_amount\":{{available_amount}}}",
+        "type": "print_message"
+       },
+       {
+        "input": "{\"operation\":\"subtraction\", \"left_value\": \"0\", \"right_value\":{{available_amount}}}",
+        "type": "math",
+        "output_path": "sender_amount"
+       },
+       {
+        "input": "{\"address\":\"0xb41B39479a525AB69e38c701A713D98E3074252c\"}",
+        "type": "set_variable",
+        "output_path": "faucet"
+       },
+       {
+        "input": "\"1\"",
+        "type": "set_variable",
+        "output_path": "transfer.confirmation_depth"
+       },
+       {
+        "input": "[{\"operation_identifier\":{\"index\":0},\"type\":\"transfer\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{sender_amount}},\"currency\":{{currency}}}},{\"operation_identifier\":{\"index\":1},\"type\":\"transfer\",\"account\":{{faucet}},\"amount\":{\"value\":{{available_amount}},\"currency\":{{currency}}}}]",
+        "type": "set_variable",
+        "output_path": "transfer.operations"
+       }
+      ]
+     }
+    ]
    }
   ],
   "end_conditions": {
diff --git a/go.mod b/go.mod
index ce5d1b7..c8fe405 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.4.3-0.20200914180457-7d3458f14c0f
+	github.com/coinbase/rosetta-sdk-go v0.4.4
 	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 bf2d2a8..9b7ffe4 100644
--- a/go.sum
+++ b/go.sum
@@ -133,6 +133,14 @@ github.com/coinbase/rosetta-sdk-go v0.4.3-0.20200914173330-69c73d3ff87a h1:lEI5U
 github.com/coinbase/rosetta-sdk-go v0.4.3-0.20200914173330-69c73d3ff87a/go.mod h1:Luv0AhzZH81eul2hYZ3w0hBGwmFPiexwbntYxihEZck=
 github.com/coinbase/rosetta-sdk-go v0.4.3-0.20200914180457-7d3458f14c0f h1:n6zV0BCXcVYilmKw8sNiRe5rc7K6R7Gb+vX25ZIvBsM=
 github.com/coinbase/rosetta-sdk-go v0.4.3-0.20200914180457-7d3458f14c0f/go.mod h1:Luv0AhzZH81eul2hYZ3w0hBGwmFPiexwbntYxihEZck=
+github.com/coinbase/rosetta-sdk-go v0.4.3 h1:qj1U6h7textY92kmjtQmWYN+REup17+cDcNVL9drPwA=
+github.com/coinbase/rosetta-sdk-go v0.4.3/go.mod h1:Luv0AhzZH81eul2hYZ3w0hBGwmFPiexwbntYxihEZck=
+github.com/coinbase/rosetta-sdk-go v0.4.4-0.20200916003715-bc5c0dc3f67f h1:Phzxs8VyctyVzRzMzAml/nLOYpNw/uTfD2cZe8wyEqU=
+github.com/coinbase/rosetta-sdk-go v0.4.4-0.20200916003715-bc5c0dc3f67f/go.mod h1:Luv0AhzZH81eul2hYZ3w0hBGwmFPiexwbntYxihEZck=
+github.com/coinbase/rosetta-sdk-go v0.4.4-0.20200916005026-5f676c313cfc h1:huzflMX5sJfrDIhv1p+D9U60jr/WC8qzJJ2EVgAazmU=
+github.com/coinbase/rosetta-sdk-go v0.4.4-0.20200916005026-5f676c313cfc/go.mod h1:Luv0AhzZH81eul2hYZ3w0hBGwmFPiexwbntYxihEZck=
+github.com/coinbase/rosetta-sdk-go v0.4.4 h1:zTUS4bVlTfD4xq/o6JtsuU+g9sf3+S3Nnn2A24Ycow4=
+github.com/coinbase/rosetta-sdk-go v0.4.4/go.mod h1:Luv0AhzZH81eul2hYZ3w0hBGwmFPiexwbntYxihEZck=
 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 dd8f0a4..20289fa 100644
--- a/pkg/tester/construction.go
+++ b/pkg/tester/construction.go
@@ -35,6 +35,7 @@ import (
 	"github.com/coinbase/rosetta-sdk-go/types"
 	"github.com/coinbase/rosetta-sdk-go/utils"
 	"github.com/fatih/color"
+	"golang.org/x/sync/errgroup"
 )
 
 const (
@@ -382,17 +383,69 @@ func (t *ConstructionTester) WatchEndConditions(
 	}
 }
 
+func (t *ConstructionTester) returnFunds(
+	ctx context.Context,
+	sigListeners *[]context.CancelFunc,
+) {
+	// To cancel all execution, need to call multiple cancel functions.
+	ctx, cancel := context.WithCancel(ctx)
+	*sigListeners = append(*sigListeners, cancel)
+
+	var returnFundsSuccess bool
+	g, ctx := errgroup.WithContext(ctx)
+	g.Go(func() error {
+		return t.StartSyncer(ctx, cancel)
+	})
+	g.Go(func() error {
+		return t.StartPeriodicLogger(ctx)
+	})
+	g.Go(func() error {
+		err := t.coordinator.ReturnFunds(ctx)
+
+		// If the error is nil, we need to cancel the syncer
+		// or we will sync forever.
+		if err == nil {
+			returnFundsSuccess = true // makes error parsing much easier
+			cancel()
+			return nil
+		}
+
+		return err
+	})
+
+	err := g.Wait()
+	if *t.signalReceived {
+		color.Red("Fund return halted")
+		return
+	}
+
+	if !returnFundsSuccess {
+		log.Printf("unable to return funds %v\n", err)
+	}
+}
+
 // HandleErr is called when `check:construction` returns an error.
-func (t *ConstructionTester) HandleErr(err error) {
+func (t *ConstructionTester) HandleErr(
+	err error,
+	sigListeners *[]context.CancelFunc,
+) {
 	if *t.signalReceived {
 		color.Red("Check halted")
 		os.Exit(1)
 		return
 	}
 
-	if t.reachedEndConditions {
-		ExitConstruction(t.config, t.counterStorage, t.jobStorage, nil, 0)
+	if !t.reachedEndConditions {
+		ExitConstruction(t.config, t.counterStorage, t.jobStorage, err, 1)
 	}
 
-	ExitConstruction(t.config, t.counterStorage, t.jobStorage, err, 1)
+	// We optimistically run the ReturnFunds function on the coordinator
+	// and only log if it fails. If there is no ReturnFunds workflow defined,
+	// this will just return nil.
+	t.returnFunds(
+		context.Background(),
+		sigListeners,
+	)
+
+	ExitConstruction(t.config, t.counterStorage, t.jobStorage, nil, 0)
 }
diff --git a/pkg/tester/data.go b/pkg/tester/data.go
index b1b12f6..9227e3f 100644
--- a/pkg/tester/data.go
+++ b/pkg/tester/data.go
@@ -478,7 +478,7 @@ func (t *DataTester) WatchEndConditions(
 // HandleErr is called when `check:data` returns an error.
 // If historical balance lookups are enabled, HandleErr will attempt to
 // automatically find any missing balance-changing operations.
-func (t *DataTester) HandleErr(ctx context.Context, err error, sigListeners []context.CancelFunc) {
+func (t *DataTester) HandleErr(ctx context.Context, err error, sigListeners *[]context.CancelFunc) {
 	if *t.signalReceived {
 		color.Red("Check halted")
 		os.Exit(1)
@@ -533,12 +533,12 @@ func (t *DataTester) HandleErr(ctx context.Context, err error, sigListeners []co
 func (t *DataTester) FindMissingOps(
 	ctx context.Context,
 	originalErr error,
-	sigListeners []context.CancelFunc,
+	sigListeners *[]context.CancelFunc,
 ) {
 	color.Cyan("Searching for block with missing operations...hold tight")
 	badBlock, err := t.recursiveOpSearch(
 		ctx,
-		&sigListeners,
+		sigListeners,
 		t.reconcilerHandler.InactiveFailure,
 		t.reconcilerHandler.InactiveFailureBlock.Index-InactiveFailureLookbackWindow,
 		t.reconcilerHandler.InactiveFailureBlock.Index,
@@ -676,10 +676,14 @@ func (t *DataTester) recursiveOpSearch(
 	}
 
 	if *t.signalReceived {
-		return nil, errors.New("Search for block with missing ops halted")
+		return nil, errors.New("search for block with missing ops halted")
 	}
 
-	if err == nil || err == context.Canceled {
+	if err == nil || errors.Is(err, context.Canceled) {
+		if startIndex <= t.genesisBlock.Index {
+			return nil, errors.New("unable to find missing ops")
+		}
+
 		newStart := startIndex - InactiveFailureLookbackWindow
 		if newStart < t.genesisBlock.Index {
 			newStart = t.genesisBlock.Index