Repository: hyperledger/firefly (go)

Issue:
Move source of truth for EVMConnect/FFTM blockchain transaction status to from Core to FFTM
In https://github.com/hyperledger/firefly/issues/989 we had an observation as follows:

> We are now spending a lot of time processing receipts, and updating operations, with how chatty EVMConnect is. Large payloads, more often. Optimizing this would be best achieved by switching the webhooks from EVMconnect to just be status changes, and making the large payload be queried through on demand (as we made changes like this on listeners for example).

There is another challenge with the fact that we do best-effort WebSocket updates of operations from EVMConnect -> FF Core:
- If a websocket event is missed that changes a tranasction from Pending -> Succeeded, we will never reconcile that state

I propose a straight forward change that is aligned with a number of recent tweaks (specifically in #1028 ):
- We introduce a `status` section to `operation` 
- We only send a WebSocket updates from FFTM (EVMConnect) to Core when they result in a state transition:
    - `Pending`->`Succeeded`/`Failed`
- We only include a small `output` body in these WebSocket transactions
    - Transaction hash
    - Blockchain identifier of the form `0000012345/0000032/0000001`
- We add a `fetchstatus` option to the `GET` `/operations/{operationId}` calls\
    - Makes an in-line `GET` to FFTM/EVMConnect for the latest full body of the `/transactions/{txId}` FFTM API
    - This is returned under a `status` field in the response to the `/operations/{operationId}` call
    - The UI enables `fetchstatus` by default
- In the case that the status of the backend during such a `GET` is resolved to `Succeeded`/`Failed`, but the status of the operation is still `Pending` - we dispatch an update of the FF Core status, as if we'd just received the websocket event

Pull request:
Add 'fetchstatus=true' query param to /operations/{opid}
Closes #1108 

See related UI PR https://github.com/hyperledger/firefly-ui/pull/208 which uses this query parameter to populate a `Detail` section of the operation slide out.

Signed-off-by: Matthew Whitehead <matthew.whitehead@kaleido.io>

Patch:
diff --git a/db/migrations/postgres/000044_add_tokenpool_state.up.sql b/db/migrations/postgres/000044_add_tokenpool_state.up.sql
index 85c8a3ed6..0fde5e851 100644
--- a/db/migrations/postgres/000044_add_tokenpool_state.up.sql
+++ b/db/migrations/postgres/000044_add_tokenpool_state.up.sql
@@ -1,5 +1,5 @@
 BEGIN;
 ALTER TABLE tokenpool ADD COLUMN state VARCHAR(64);
-UPDATE tokenpool SET state='confirmed';
+UPDATE tokenpool SET state='unknown';
 ALTER TABLE tokenpool ALTER COLUMN state SET NOT NULL;
 COMMIT;
diff --git a/db/migrations/sqlite/000044_add_tokenpool_state.up.sql b/db/migrations/sqlite/000044_add_tokenpool_state.up.sql
index a1f4542ee..6aeb17a2f 100644
--- a/db/migrations/sqlite/000044_add_tokenpool_state.up.sql
+++ b/db/migrations/sqlite/000044_add_tokenpool_state.up.sql
@@ -1,2 +1,2 @@
 ALTER TABLE tokenpool ADD COLUMN state VARCHAR(64);
-UPDATE tokenpool SET state="confirmed";
+UPDATE tokenpool SET state="unknown";
diff --git a/docs/reference/types/tokenpool.md b/docs/reference/types/tokenpool.md
index 8dfd14c52..d61c8e5aa 100644
--- a/docs/reference/types/tokenpool.md
+++ b/docs/reference/types/tokenpool.md
@@ -62,7 +62,7 @@ nav_order: 10
 | `decimals` | Number of decimal places that this token has | `int` |
 | `connector` | The name of the token connector, as specified in the FireFly core configuration file that is responsible for the token pool. Required on input when multiple token connectors are configured | `string` |
 | `message` | The UUID of the broadcast message used to inform the network to index this pool | [`UUID`](simpletypes#uuid) |
-| `state` | The current state of the token pool | `FFEnum`:<br/>`"pending"`<br/>`"confirmed"` |
+| `state` | The current state of the token pool | `FFEnum`:<br/>`"unknown"`<br/>`"pending"`<br/>`"confirmed"` |
 | `created` | The creation time of the pool | [`FFTime`](simpletypes#fftime) |
 | `config` | Input only field, with token connector specific configuration of the pool, such as an existing Ethereum address and block number to used to index the pool. See your chosen token connector documentation for details | [`JSONObject`](simpletypes#jsonobject) |
 | `info` | Token connector specific information about the pool. See your chosen token connector documentation for details | [`JSONObject`](simpletypes#jsonobject) |
diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml
index 3b0e0a522..6eafa38dc 100644
--- a/docs/swagger/swagger.yaml
+++ b/docs/swagger/swagger.yaml
@@ -21700,6 +21700,13 @@ paths:
         schema:
           example: default
           type: string
+      - description: When set, the API will return additional status information if
+          available
+        in: query
+        name: fetchstatus
+        schema:
+          example: "true"
+          type: string
       - description: Server-side request timeout (milliseconds, or set a custom suffix
           like 10s)
         in: header
@@ -25495,6 +25502,7 @@ paths:
                     state:
                       description: The current state of the token pool
                       enum:
+                      - unknown
                       - pending
                       - confirmed
                       type: string
@@ -25655,6 +25663,7 @@ paths:
                   state:
                     description: The current state of the token pool
                     enum:
+                    - unknown
                     - pending
                     - confirmed
                     type: string
@@ -25740,6 +25749,7 @@ paths:
                   state:
                     description: The current state of the token pool
                     enum:
+                    - unknown
                     - pending
                     - confirmed
                     type: string
@@ -25855,6 +25865,7 @@ paths:
                   state:
                     description: The current state of the token pool
                     enum:
+                    - unknown
                     - pending
                     - confirmed
                     type: string
@@ -29128,6 +29139,13 @@ paths:
         required: true
         schema:
           type: string
+      - description: When set, the API will return additional status information if
+          available
+        in: query
+        name: fetchstatus
+        schema:
+          example: "true"
+          type: string
       - description: Server-side request timeout (milliseconds, or set a custom suffix
           like 10s)
         in: header
@@ -32782,6 +32800,7 @@ paths:
                     state:
                       description: The current state of the token pool
                       enum:
+                      - unknown
                       - pending
                       - confirmed
                       type: string
@@ -32935,6 +32954,7 @@ paths:
                   state:
                     description: The current state of the token pool
                     enum:
+                    - unknown
                     - pending
                     - confirmed
                     type: string
@@ -33020,6 +33040,7 @@ paths:
                   state:
                     description: The current state of the token pool
                     enum:
+                    - unknown
                     - pending
                     - confirmed
                     type: string
@@ -33128,6 +33149,7 @@ paths:
                   state:
                     description: The current state of the token pool
                     enum:
+                    - unknown
                     - pending
                     - confirmed
                     type: string
diff --git a/internal/apiserver/route_get_op_by_id.go b/internal/apiserver/route_get_op_by_id.go
index f52705944..4c61d7930 100644
--- a/internal/apiserver/route_get_op_by_id.go
+++ b/internal/apiserver/route_get_op_by_id.go
@@ -18,6 +18,7 @@ package apiserver
 
 import (
 	"net/http"
+	"strings"
 
 	"github.com/hyperledger/firefly-common/pkg/ffapi"
 	"github.com/hyperledger/firefly/internal/coremsgs"
@@ -31,13 +32,18 @@ var getOpByID = &ffapi.Route{
 	PathParams: []*ffapi.PathParam{
 		{Name: "opid", Description: coremsgs.APIParamsOperationIDGet},
 	},
-	QueryParams:     nil,
+	QueryParams: []*ffapi.QueryParam{
+		{Name: "fetchstatus", Example: "true", Description: coremsgs.APIParamsFetchStatus, IsBool: true},
+	},
 	Description:     coremsgs.APIEndpointsGetOpByID,
 	JSONInputValue:  nil,
 	JSONOutputValue: func() interface{} { return &core.Operation{} },
 	JSONOutputCodes: []int{http.StatusOK},
 	Extensions: &coreExtensions{
 		CoreJSONHandler: func(r *ffapi.APIRequest, cr *coreRequest) (output interface{}, err error) {
+			if strings.EqualFold(r.QP["fetchstatus"], "true") {
+				return cr.or.GetOperationByIDWithStatus(cr.ctx, r.PP["opid"])
+			}
 			output, err = cr.or.GetOperationByID(cr.ctx, r.PP["opid"])
 			return output, err
 		},
diff --git a/internal/blockchain/common/common.go b/internal/blockchain/common/common.go
index 3c63d5a3f..0da135b7d 100644
--- a/internal/blockchain/common/common.go
+++ b/internal/blockchain/common/common.go
@@ -19,6 +19,8 @@ package common
 import (
 	"context"
 	"encoding/hex"
+	"encoding/json"
+	"fmt"
 	"strings"
 
 	"github.com/hyperledger/firefly-common/pkg/fftypes"
@@ -70,6 +72,18 @@ type SubscriptionInfo struct {
 	Extra       interface{}
 }
 
+type BlockchainReceiptHeaders struct {
+	ReceiptID string `json:"requestId,omitempty"`
+	ReplyType string `json:"type,omitempty"`
+}
+
+type BlockchainReceiptNotification struct {
+	Headers    BlockchainReceiptHeaders
+	TxHash     string `json:"transactionHash,omitempty"`
+	Message    string `json:"errorMessage,omitempty"`
+	ProtocolID string `json:"protocolId,omitempty"`
+}
+
 func NewBlockchainCallbacks() BlockchainCallbacks {
 	return &callbacks{
 		handlers:   make(map[string]blockchain.Callbacks),
@@ -275,3 +289,34 @@ func (s *subscriptions) RemoveSubscription(ctx context.Context, subID string) {
 func (s *subscriptions) GetSubscription(subID string) *SubscriptionInfo {
 	return s.subs[subID]
 }
+
+// Common function for handling receipts from blockchain connectors.
+func HandleReceipt(ctx context.Context, plugin core.Named, reply *BlockchainReceiptNotification, callbacks BlockchainCallbacks) error {
+	l := log.L(ctx)
+
+	if reply.Headers.ReceiptID == "" || reply.Headers.ReplyType == "" {
+		return fmt.Errorf("reply cannot be processed - missing fields: %+v", reply)
+	}
+	var updateType core.OpStatus
+	switch reply.Headers.ReplyType {
+	case "TransactionSuccess":
+		updateType = core.OpStatusSucceeded
+	case "TransactionUpdate":
+		updateType = core.OpStatusPending
+	default:
+		updateType = core.OpStatusFailed
+	}
+
+	// Slightly upgly conversion from ReceiptFromBlockchain -> JSONObject which the generic OperationUpdate() function requires
+	var output fftypes.JSONObject
+	obj, err := json.Marshal(reply)
+	if err != nil {
+		return fmt.Errorf("reply cannot be processed - marshalling error: %+v", reply)
+	}
+	_ = json.Unmarshal(obj, &output)
+
+	l.Infof("Received operation update: status=%s request=%s tx=%s message=%s", updateType, reply.Headers.ReceiptID, reply.TxHash, reply.Message)
+	callbacks.OperationUpdate(ctx, plugin, reply.Headers.ReceiptID, updateType, reply.TxHash, reply.Message, output)
+
+	return nil
+}
diff --git a/internal/blockchain/ethereum/ethereum.go b/internal/blockchain/ethereum/ethereum.go
index 62292c9f0..17d0341b8 100644
--- a/internal/blockchain/ethereum/ethereum.go
+++ b/internal/blockchain/ethereum/ethereum.go
@@ -47,6 +47,15 @@ const (
 	broadcastBatchEventSignature = "BatchPin(address,uint256,string,bytes32,bytes32,string,bytes32[])"
 )
 
+const (
+	ethTxStatusPending string = "Pending"
+)
+
+const (
+	ReceiptTransactionSuccess string = "TransactionSuccess"
+	ReceiptTransactionFailed  string = "TransactionFailed"
+)
+
 type Ethereum struct {
 	ctx             context.Context
 	cancelCtx       context.CancelFunc
@@ -345,31 +354,6 @@ func (e *Ethereum) handleContractEvent(ctx context.Context, msgJSON fftypes.JSON
 	return err
 }
 
-func (e *Ethereum) handleReceipt(ctx context.Context, reply fftypes.JSONObject) {
-	l := log.L(ctx)
-
-	headers := reply.GetObject("headers")
-	requestID := headers.GetString("requestId")
-	replyType := headers.GetString("type")
-	txHash := reply.GetString("transactionHash")
-	message := reply.GetString("errorMessage")
-	if requestID == "" || replyType == "" {
-		l.Errorf("Reply cannot be processed - missing fields: %+v", reply)
-		return
-	}
-	var updateType core.OpStatus
-	switch replyType {
-	case "TransactionSuccess":
-		updateType = core.OpStatusSucceeded
-	case "TransactionUpdate":
-		updateType = core.OpStatusPending
-	default:
-		updateType = core.OpStatusFailed
-	}
-	l.Infof("Received operation update: status=%s request=%s tx=%s message=%s", updateType, requestID, txHash, message)
-	e.callbacks.OperationUpdate(ctx, e, requestID, updateType, txHash, message, reply)
-}
-
 func (e *Ethereum) buildEventLocationString(msgJSON fftypes.JSONObject) string {
 	return fmt.Sprintf("address=%s", msgJSON.GetString("address"))
 }
@@ -479,7 +463,12 @@ func (e *Ethereum) eventLoop() {
 					}
 				}
 				if !isBatch {
-					e.handleReceipt(ctx, fftypes.JSONObject(msgTyped))
+					var receipt common.BlockchainReceiptNotification
+					_ = json.Unmarshal(msgBytes, &receipt)
+					err := common.HandleReceipt(ctx, e, &receipt, e.callbacks)
+					if err != nil {
+						l.Errorf("Failed to process receipt: %+v", msgTyped)
+					}
 				}
 			default:
 				l.Errorf("Message unexpected: %+v", msgTyped)
@@ -947,3 +936,55 @@ func (e *Ethereum) GetAndConvertDeprecatedContractConfig(ctx context.Context) (l
 	})
 	return location, fromBlock, err
 }
+
+func (e *Ethereum) GetTransactionStatus(ctx context.Context, operation *core.Operation) (interface{}, error) {
+	txnID := (&core.PreparedOperation{ID: operation.ID, Namespace: operation.Namespace}).NamespacedIDString()
+
+	transactionRequestPath := fmt.Sprintf("/transactions/%s", txnID)
+	client := e.client
+	var resErr ethError
+	var statusResponse fftypes.JSONObject
+	res, err := client.R().
+		SetContext(ctx).
+		SetError(&resErr).
+		SetResult(&statusResponse).
+		Get(transactionRequestPath)
+	if err != nil || !res.IsSuccess() {
+		if res.StatusCode() == 404 {
+			return nil, nil
+		}
+		return nil, wrapError(ctx, &resErr, res, err)
+	}
+
+	receiptInfo := statusResponse.GetObject("receipt")
+	txStatus := statusResponse.GetString("status")
+
+	if txStatus != "" {
+		var replyType string
+		if txStatus == "Succeeded" {
+			replyType = ReceiptTransactionSuccess
+		} else {
+			replyType = ReceiptTransactionFailed
+		}
+		// If the status has changed, mock up blockchain receipt as if we'd received it
+		// as a web socket notification
+		if operation.Status == core.OpStatusPending && txStatus != ethTxStatusPending {
+			receipt := &common.BlockchainReceiptNotification{
+				Headers: common.BlockchainReceiptHeaders{
+					ReceiptID: statusResponse.GetString("id"),
+					ReplyType: replyType},
+				TxHash:     statusResponse.GetString("transactionHash"),
+				Message:    statusResponse.GetString("errorMessage"),
+				ProtocolID: receiptInfo.GetString("protocolId")}
+			err := common.HandleReceipt(ctx, e, receipt, e.callbacks)
+			if err != nil {
+				log.L(ctx).Warnf("Failed to handle receipt")
+			}
+		}
+	} else {
+		// Don't expect to get here so issue a warning
+		log.L(ctx).Warnf("Transaction status didn't include txStatus information")
+	}
+
+	return statusResponse, nil
+}
diff --git a/internal/blockchain/fabric/fabric.go b/internal/blockchain/fabric/fabric.go
index d18478394..6247eb591 100644
--- a/internal/blockchain/fabric/fabric.go
+++ b/internal/blockchain/fabric/fabric.go
@@ -369,26 +369,6 @@ func (f *Fabric) handleContractEvent(ctx context.Context, msgJSON fftypes.JSONOb
 	})
 }
 
-func (f *Fabric) handleReceipt(ctx context.Context, reply fftypes.JSONObject) {
-	l := log.L(ctx)
-
-	headers := reply.GetObject("headers")
-	requestID := headers.GetString("requestId")
-	replyType := headers.GetString("type")
-	txHash := reply.GetString("transactionId")
-	message := reply.GetString("errorMessage")
-	if requestID == "" || replyType == "" {
-		l.Errorf("Reply cannot be processed: %+v", reply)
-		return
-	}
-	updateType := core.OpStatusSucceeded
-	if replyType != "TransactionSuccess" {
-		updateType = core.OpStatusFailed
-	}
-	l.Infof("Received operation update: status=%s request=%s tx=%s message=%s", updateType, requestID, txHash, message)
-	f.callbacks.OperationUpdate(ctx, f, requestID, updateType, txHash, message, reply)
-}
-
 func (f *Fabric) AddFireflySubscription(ctx context.Context, namespace *core.Namespace, location *fftypes.JSONAny, firstEvent string) (string, error) {
 	fabricOnChainLocation, err := parseContractLocation(ctx, location)
 	if err != nil {
@@ -498,7 +478,13 @@ func (f *Fabric) eventLoop() {
 					err = f.wsconn.Send(ctx, ack)
 				}
 			case map[string]interface{}:
-				f.handleReceipt(ctx, fftypes.JSONObject(msgTyped))
+				var receipt common.BlockchainReceiptNotification
+				_ = json.Unmarshal(msgBytes, &receipt)
+
+				err := common.HandleReceipt(ctx, f, &receipt, f.callbacks)
+				if err != nil {
+					l.Errorf("Failed to process receipt: %+v", msgTyped)
+				}
 			default:
 				l.Errorf("Message unexpected: %+v", msgTyped)
 				continue
@@ -921,3 +907,28 @@ func (f *Fabric) GetAndConvertDeprecatedContractConfig(ctx context.Context) (loc
 	})
 	return location, fromBlock, err
 }
+
+func (f *Fabric) GetTransactionStatus(ctx context.Context, operation *core.Operation) (interface{}, error) {
+	txnID := (&core.PreparedOperation{ID: operation.ID, Namespace: operation.Namespace}).NamespacedIDString()
+
+	transactionRequestPath := fmt.Sprintf("/transactions/%s", txnID)
+	client := f.client
+	var resErr fabError
+	var statusResponse fftypes.JSONObject
+	res, err := client.R().
+		SetContext(ctx).
+		SetError(&resErr).
+		SetResult(&statusResponse).
+		Get(transactionRequestPath)
+	if err != nil || !res.IsSuccess() {
+		if res.StatusCode() == 404 {
+			return nil, nil
+		}
+		return nil, wrapError(ctx, &resErr, res, err)
+	}
+
+	// TODO - could implement the same enhancement ethconnect has, and build a mock WS receipt if an API query
+	// happens to update the status of a pending transaction in our store.
+
+	return statusResponse, nil
+}
diff --git a/internal/events/token_pool_created.go b/internal/events/token_pool_created.go
index 627f4491f..917c89ed9 100644
--- a/internal/events/token_pool_created.go
+++ b/internal/events/token_pool_created.go
@@ -93,20 +93,30 @@ func (em *eventManager) findTXOperation(ctx context.Context, tx *fftypes.UUID, o
 	return nil, nil
 }
 
-func (em *eventManager) loadExisting(ctx context.Context, pool *tokens.TokenPool) (existingPool *core.TokenPool, err error) {
+func (em *eventManager) shouldConfirm(ctx context.Context, pool *tokens.TokenPool) (existingPool *core.TokenPool, err error) {
 	if existingPool, err = em.database.GetTokenPoolByLocator(ctx, em.namespace.Name, pool.Connector, pool.PoolLocator); err != nil || existingPool == nil {
 		log.L(ctx).Debugf("Pool not found with ns=%s connector=%s locator=%s (err=%v)", em.namespace.Name, pool.Connector, pool.PoolLocator, err)
 		return existingPool, err
 	}
-
 	if err = addPoolDetailsFromPlugin(existingPool, pool); err != nil {
 		log.L(ctx).Errorf("Error processing pool for transaction '%s' (%s) - ignoring", pool.TX.ID, err)
 		return nil, nil
 	}
+
+	log.L(ctx).Debugf("shouldConfirm checking pool: state=%s name=%s connector=%s locator=%s", existingPool.State, em.namespace.Name, pool.Connector, pool.PoolLocator)
+	if existingPool.State == core.TokenPoolStateUnknown {
+		// Unknown pool state - should only happen on first run after database migration
+		// Activate the pool, then immediately confirm
+		// TODO: can this state eventually be removed?
+		if err = em.assets.ActivateTokenPool(ctx, existingPool); err != nil {
+			log.L(ctx).Errorf("Failed to activate token pool '%s': %s", existingPool.ID, err)
+			return nil, err
+		}
+	}
 	return existingPool, nil
 }
 
-func (em *eventManager) loadFromOperation(ctx context.Context, pool *tokens.TokenPool) (announcePool *core.TokenPool, err error) {
+func (em *eventManager) shouldAnnounce(ctx context.Context, pool *tokens.TokenPool) (announcePool *core.TokenPool, err error) {
 	op, err := em.findTXOperation(ctx, pool.TX.ID, core.OpTypeTokenCreatePool)
 	if err != nil {
 		return nil, err
@@ -138,7 +148,7 @@ func (em *eventManager) TokenPoolCreated(ti tokens.Plugin, pool *tokens.TokenPoo
 	err = em.retry.Do(em.ctx, "persist token pool transaction", func(attempt int) (bool, error) {
 		err := em.database.RunAsGroup(em.ctx, func(ctx context.Context) error {
 			// See if this is a confirmation of an unconfirmed pool
-			existingPool, err := em.loadExisting(ctx, pool)
+			existingPool, err := em.shouldConfirm(ctx, pool)
 			if err != nil {
 				return err
 			}
@@ -157,7 +167,7 @@ func (em *eventManager) TokenPoolCreated(ti tokens.Plugin, pool *tokens.TokenPoo
 			}
 
 			// See if this pool was submitted locally and needs to be announced
-			if announcePool, err = em.loadFromOperation(ctx, pool); err != nil {
+			if announcePool, err = em.shouldAnnounce(ctx, pool); err != nil {
 				return err
 			} else if announcePool != nil {
 				return nil // trigger announce after completion of database transaction
diff --git a/internal/orchestrator/data_query.go b/internal/orchestrator/data_query.go
index 2f3b8cfc7..b860dd434 100644
--- a/internal/orchestrator/data_query.go
+++ b/internal/orchestrator/data_query.go
@@ -1,4 +1,4 @@
-// Copyright © 2022 Kaleido, Inc.
+// Copyright © 2023 Kaleido, Inc.
 //
 // SPDX-License-Identifier: Apache-2.0
 //
@@ -128,6 +128,46 @@ func (or *orchestrator) GetOperationByID(ctx context.Context, id string) (*core.
 	return or.operations.GetOperationByIDCached(ctx, u)
 }
 
+func (or *orchestrator) GetOperationByIDWithStatus(ctx context.Context, id string) (*core.OperationWithDetailedStatus, error) {
+	var enrichedOperation *core.OperationWithDetailedStatus
+	u, err := fftypes.ParseUUID(ctx, id)
+	if err != nil {
+		return nil, err
+	}
+	op, err := or.operations.GetOperationByIDCached(ctx, u)
+
+	if op == nil || err != nil {
+		return nil, err
+	}
+
+	if op.IsBlockchainOperation() || op.IsTokenOperation() {
+		status, err := or.blockchain().GetTransactionStatus(ctx, op)
+		if err != nil {
+			status = core.OperationDetailedStatusError{
+				StatusError: err.Error(),
+			}
+		}
+
+		// Not all blockchain plugins will necessarily implement GetTransactionStatus()
+		// so check if we get any extra status back
+		if status != nil {
+			enrichedOperation = &core.OperationWithDetailedStatus{
+				Operation:      *op,
+				DetailedStatus: status,
+			}
+		} else {
+			enrichedOperation = &core.OperationWithDetailedStatus{
+				Operation: *op,
+			}
+		}
+	} else {
+		enrichedOperation = &core.OperationWithDetailedStatus{
+			Operation: *op,
+		}
+	}
+	return enrichedOperation, err
+}
+
 func (or *orchestrator) GetEventByID(ctx context.Context, id string) (*core.Event, error) {
 	u, err := fftypes.ParseUUID(ctx, id)
 	if err != nil {
diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go
index ca9bdffa3..3f6f5c164 100644
--- a/internal/orchestrator/orchestrator.go
+++ b/internal/orchestrator/orchestrator.go
@@ -1,4 +1,4 @@
-// Copyright © 2022 Kaleido, Inc.
+// Copyright © 2023 Kaleido, Inc.
 //
 // SPDX-License-Identifier: Apache-2.0
 //
@@ -105,6 +105,7 @@ type Orchestrator interface {
 	GetDatatypeByName(ctx context.Context, name, version string) (*core.Datatype, error)
 	GetDatatypes(ctx context.Context, filter ffapi.AndFilter) ([]*core.Datatype, *ffapi.FilterResult, error)
 	GetOperationByID(ctx context.Context, id string) (*core.Operation, error)
+	GetOperationByIDWithStatus(ctx context.Context, id string) (*core.OperationWithDetailedStatus, error)
 	GetOperations(ctx context.Context, filter ffapi.AndFilter) ([]*core.Operation, *ffapi.FilterResult, error)
 	GetEventByID(ctx context.Context, id string) (*core.Event, error)
 	GetEventByIDWithReference(ctx context.Context, id string) (*core.EnrichedEvent, error)
diff --git a/internal/orchestrator/txn_status.go b/internal/orchestrator/txn_status.go
index f7abcb1b2..6d6da9b03 100644
--- a/internal/orchestrator/txn_status.go
+++ b/internal/orchestrator/txn_status.go
@@ -187,7 +187,7 @@ func (or *orchestrator) GetTransactionStatus(ctx context.Context, id string) (*c
 			})
 		}
 
-	case core.TransactionTypeContractInvoke, core.TransactionTypeDataPublish:
+	case core.TransactionTypeContractInvoke, core.TransactionTypeContractDeploy, core.TransactionTypeDataPublish:
 		// no blockchain events or other objects
 
 	default:
diff --git a/manifest.json b/manifest.json
index ad218b373..6350410cc 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,8 +6,8 @@
   },
   "evmconnect": {
     "image": "ghcr.io/hyperledger/firefly-evmconnect",
-    "tag": "v1.1.9",
-    "sha": "6a81ba67bf2611274cbc1c1597ba0377ec91618d5bff5874f0bc489b1a7b0e10"
+    "tag": "v1.1.11",
+    "sha": "a6fa5e764da7103e628fab1ca6cbc7fbcf9b9f47f5ea016981b7947e77d3e74e"
   },
   "fabconnect": {
     "image": "ghcr.io/hyperledger/firefly-fabconnect",
@@ -31,8 +31,8 @@
   },
   "signer": {
     "image": "ghcr.io/hyperledger/firefly-signer",
-    "tag": "v1.1.2",
-    "sha": "1023af58afadc0e62f423984f4dd863ef13552282a9fe60210b7cda4f9b43518"
+    "tag": "v1.1.5",
+    "sha": "4ee8549d12339f6d4224a277faf143da1749a51a5994e074224c95e3cce64670"
   },
   "build": {
     "firefly-builder": {
diff --git a/mocks/blockchainmocks/plugin.go b/mocks/blockchainmocks/plugin.go
index 7de499aaa..118537825 100644
--- a/mocks/blockchainmocks/plugin.go
+++ b/mocks/blockchainmocks/plugin.go
@@ -237,6 +237,29 @@ func (_m *Plugin) GetNetworkVersion(ctx context.Context, location *fftypes.JSONA
 	return r0, r1
 }
 
+// GetTransactionStatus provides a mock function with given fields: ctx, operation
+func (_m *Plugin) GetTransactionStatus(ctx context.Context, operation *core.Operation) (interface{}, error) {
+	ret := _m.Called(ctx, operation)
+
+	var r0 interface{}
+	if rf, ok := ret.Get(0).(func(context.Context, *core.Operation) interface{}); ok {
+		r0 = rf(ctx, operation)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(interface{})
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, *core.Operation) error); ok {
+		r1 = rf(ctx, operation)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
 // Init provides a mock function with given fields: ctx, cancelCtx, _a2, _a3, cacheManager
 func (_m *Plugin) Init(ctx context.Context, cancelCtx context.CancelFunc, _a2 config.Section, _a3 metrics.Manager, cacheManager cache.Manager) error {
 	ret := _m.Called(ctx, cancelCtx, _a2, _a3, cacheManager)
diff --git a/mocks/orchestratormocks/orchestrator.go b/mocks/orchestratormocks/orchestrator.go
index a7653ad3f..f57a6b174 100644
--- a/mocks/orchestratormocks/orchestrator.go
+++ b/mocks/orchestratormocks/orchestrator.go
@@ -865,6 +865,29 @@ func (_m *Orchestrator) GetOperationByID(ctx context.Context, id string) (*core.
 	return r0, r1
 }
 
+// GetOperationByIDWithStatus provides a mock function with given fields: ctx, id
+func (_m *Orchestrator) GetOperationByIDWithStatus(ctx context.Context, id string) (*core.OperationWithDetailedStatus, error) {
+	ret := _m.Called(ctx, id)
+
+	var r0 *core.OperationWithDetailedStatus
+	if rf, ok := ret.Get(0).(func(context.Context, string) *core.OperationWithDetailedStatus); ok {
+		r0 = rf(ctx, id)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*core.OperationWithDetailedStatus)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
+		r1 = rf(ctx, id)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
 // GetOperations provides a mock function with given fields: ctx, filter
 func (_m *Orchestrator) GetOperations(ctx context.Context, filter ffapi.AndFilter) ([]*core.Operation, *ffapi.FilterResult, error) {
 	ret := _m.Called(ctx, filter)
diff --git a/pkg/blockchain/plugin.go b/pkg/blockchain/plugin.go
index 80642144d..34f075b09 100644
--- a/pkg/blockchain/plugin.go
+++ b/pkg/blockchain/plugin.go
@@ -104,6 +104,9 @@ type Plugin interface {
 
 	// RemoveFireFlySubscription removes the provided FireFly subscription
 	RemoveFireflySubscription(ctx context.Context, subID string)
+
+	// Get the latest status of the given transaction
+	GetTransactionStatus(ctx context.Context, operation *core.Operation) (interface{}, error)
 }
 
 const FireFlyActionPrefix = "firefly:"
diff --git a/pkg/core/operation.go b/pkg/core/operation.go
index 589aa1984..1a8a49cdd 100644
--- a/pkg/core/operation.go
+++ b/pkg/core/operation.go
@@ -63,6 +63,17 @@ var (
 	OpTypeTokenApproval = fftypes.FFEnumValue("optype", "token_approval")
 )
 
+func (op *Operation) IsBlockchainOperation() bool {
+	return op.Type == OpTypeBlockchainInvoke ||
+		op.Type == OpTypeBlockchainNetworkAction ||
+		op.Type == OpTypeBlockchainPinBatch ||
+		op.Type == OpTypeBlockchainContractDeploy
+}
+
+func (op *Operation) IsTokenOperation() bool {
+	return op.Type == OpTypeTokenActivatePool || op.Type == OpTypeTokenApproval || op.Type == OpTypeTokenCreatePool || op.Type == OpTypeTokenTransfer
+}
+
 // OpStatus is the current status of an operation
 type OpStatus string
 
@@ -168,3 +179,12 @@ type OperationUpdate struct {
 	DXHash         string
 	OnComplete     func()
 }
+
+type OperationDetailedStatusError struct {
+	StatusError string `ffstruct:"OperationDetailedStatusError" json:"error,omitempty"`
+}
+
+type OperationWithDetailedStatus struct {
+	Operation
+	DetailedStatus interface{} `ffstruct:"OperationWithDetailedStatus" json:"detailedStatus,omitempty" ffexcludeinput:"true"`
+}
diff --git a/pkg/core/tokenpool.go b/pkg/core/tokenpool.go
index 69d31d681..6886105c0 100644
--- a/pkg/core/tokenpool.go
+++ b/pkg/core/tokenpool.go
@@ -33,6 +33,9 @@ var (
 type TokenPoolState = fftypes.FFEnum
 
 var (
+	// TokenPoolStateUnknown is a token pool that may not yet be activated
+	// (should not be used in the code - only set via database migration for previously-created pools)
+	TokenPoolStateUnknown = fftypes.FFEnumValue("tokenpoolstate", "unknown")
 	// TokenPoolStatePending is a token pool that has been announced but not yet confirmed
 	TokenPoolStatePending = fftypes.FFEnumValue("tokenpoolstate", "pending")
 	// TokenPoolStateConfirmed is a token pool that has been confirmed on chain


Test patch:
diff --git a/internal/apiserver/route_get_op_by_id_test.go b/internal/apiserver/route_get_op_by_id_test.go
index c4289f2d6..aa23b20aa 100644
--- a/internal/apiserver/route_get_op_by_id_test.go
+++ b/internal/apiserver/route_get_op_by_id_test.go
@@ -38,3 +38,17 @@ func TestGetOperationByID(t *testing.T) {
 
 	assert.Equal(t, 200, res.Result().StatusCode)
 }
+
+func TestGetOperationByIDFetchStatus(t *testing.T) {
+	o, r := newTestAPIServer()
+	o.On("Authorize", mock.Anything, mock.Anything).Return(nil)
+	req := httptest.NewRequest("GET", "/api/v1/namespaces/mynamespace/operations/abcd12345?fetchstatus=true", nil)
+	req.Header.Set("Content-Type", "application/json; charset=utf-8")
+	res := httptest.NewRecorder()
+
+	o.On("GetOperationByIDWithStatus", mock.Anything, "abcd12345").
+		Return(&core.OperationWithDetailedStatus{}, nil)
+	r.ServeHTTP(res, req)
+
+	assert.Equal(t, 200, res.Result().StatusCode)
+}
diff --git a/internal/blockchain/common/common_test.go b/internal/blockchain/common/common_test.go
index d1aee5572..9cec955de 100644
--- a/internal/blockchain/common/common_test.go
+++ b/internal/blockchain/common/common_test.go
@@ -18,6 +18,7 @@ package common
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"testing"
 
@@ -224,3 +225,60 @@ func TestSubscriptionsRemoveInvalid(t *testing.T) {
 	subs := NewFireflySubscriptions()
 	subs.RemoveSubscription(context.Background(), "sub1")
 }
+
+func TestGoodSuccessReceipt(t *testing.T) {
+	var reply BlockchainReceiptNotification
+	reply.Headers.ReceiptID = "ID"
+	reply.Headers.ReplyType = "TransactionSuccess"
+	reply.ProtocolID = "123456/098765453"
+
+	mcb := &blockchainmocks.Callbacks{}
+	cb := NewBlockchainCallbacks()
+	cb.SetHandler("ns1", mcb)
+	mcb.On("OperationUpdate", "ns1", mock.Anything).Return()
+
+	err := HandleReceipt(context.Background(), nil, &reply, cb)
+	assert.NoError(t, err)
+
+	reply.Headers.ReplyType = "TransactionUpdate"
+	err = HandleReceipt(context.Background(), nil, &reply, cb)
+	assert.NoError(t, err)
+
+	reply.Headers.ReplyType = "TransactionFailed"
+	err = HandleReceipt(context.Background(), nil, &reply, cb)
+	assert.NoError(t, err)
+}
+
+func TestReceiptMarshallingError(t *testing.T) {
+	var reply BlockchainReceiptNotification
+	reply.Headers.ReceiptID = "ID"
+	reply.Headers.ReplyType = "force-marshall-error"
+	reply.ProtocolID = "123456/098765453"
+
+	mcb := &blockchainmocks.Callbacks{}
+	cb := NewBlockchainCallbacks()
+	cb.SetHandler("ns1", mcb)
+	mcb.On("OperationUpdate", "ns1", mock.Anything).Return()
+
+	err := HandleReceipt(context.Background(), nil, &reply, cb)
+	assert.Error(t, err)
+	assert.Regexp(t, ".*[^n]marshalling error.*", err)
+}
+
+type TestReceipt BlockchainReceiptNotification
+
+func (receipt BlockchainReceiptNotification) MarshalJSON() ([]byte, error) {
+	if receipt.Headers.ReplyType == "force-marshall-error" {
+		return []byte(`null`), fmt.Errorf("json error")
+	}
+	return json.Marshal(TestReceipt(receipt))
+}
+
+func TestBadReceipt(t *testing.T) {
+	var reply BlockchainReceiptNotification
+	data := fftypes.JSONAnyPtr(`{}`)
+	err := json.Unmarshal(data.Bytes(), &reply)
+	assert.NoError(t, err)
+	err = HandleReceipt(context.Background(), nil, &reply, nil)
+	assert.Error(t, err)
+}
diff --git a/internal/blockchain/ethereum/ethereum_test.go b/internal/blockchain/ethereum/ethereum_test.go
index 5c2180398..c77e9ee05 100644
--- a/internal/blockchain/ethereum/ethereum_test.go
+++ b/internal/blockchain/ethereum/ethereum_test.go
@@ -1458,7 +1458,7 @@ func TestHandleReceiptTXSuccess(t *testing.T) {
 	}
 	e.SetOperationHandler("ns1", em)
 
-	var reply fftypes.JSONObject
+	var reply common.BlockchainReceiptNotification
 	operationID := fftypes.NewUUID()
 	data := fftypes.JSONAnyPtr(`{
 		"_id": "4373614c-e0f7-47b0-640e-7eacec417a9e",
@@ -1492,7 +1492,8 @@ func TestHandleReceiptTXSuccess(t *testing.T) {
 
 	err := json.Unmarshal(data.Bytes(), &reply)
 	assert.NoError(t, err)
-	e.handleReceipt(context.Background(), reply)
+
+	common.HandleReceipt(context.Background(), e, &reply, e.callbacks)
 
 	em.AssertExpectations(t)
 }
@@ -1508,7 +1509,7 @@ func TestHandleReceiptTXUpdateEVMConnect(t *testing.T) {
 	}
 	e.SetOperationHandler("ns1", em)
 
-	var reply fftypes.JSONObject
+	var reply common.BlockchainReceiptNotification
 	operationID := fftypes.NewUUID()
 	data := fftypes.JSONAnyPtr(`{
 		"created": "2022-08-03T18:55:42.671166Z",
@@ -1577,7 +1578,9 @@ func TestHandleReceiptTXUpdateEVMConnect(t *testing.T) {
 
 	err := json.Unmarshal(data.Bytes(), &reply)
 	assert.NoError(t, err)
-	e.handleReceipt(context.Background(), reply)
+	expectedReceiptId := "ns1:" + operationID.String()
+	assert.Equal(t, reply.Headers.ReceiptID, expectedReceiptId)
+	common.HandleReceipt(context.Background(), e, &reply, e.callbacks)
 
 	em.AssertExpectations(t)
 }
@@ -1637,11 +1640,11 @@ func TestHandleMsgBatchBadData(t *testing.T) {
 		wsconn: wsm,
 	}
 
-	var reply fftypes.JSONObject
+	var reply common.BlockchainReceiptNotification
 	data := fftypes.JSONAnyPtr(`{}`)
 	err := json.Unmarshal(data.Bytes(), &reply)
 	assert.NoError(t, err)
-	e.handleReceipt(context.Background(), reply)
+	common.HandleReceipt(context.Background(), e, &reply, e.callbacks)
 }
 
 func TestFormatNil(t *testing.T) {
@@ -3554,3 +3557,115 @@ func TestGetContractListenerStatusGetSubFail(t *testing.T) {
 	assert.Nil(t, status)
 	assert.Regexp(t, "FF10111", err)
 }
+
+func TestGetTransactionStatusSuccess(t *testing.T) {
+	e, cancel := newTestEthereum()
+	defer cancel()
+	httpmock.ActivateNonDefault(e.client.GetClient())
+	defer httpmock.DeactivateAndReset()
+
+	op := &core.Operation{
+		Namespace: "ns1",
+		ID:        fftypes.MustParseUUID("9ffc50ff-6bfe-4502-adc7-93aea54cc059"),
+		Status:    "Pending",
+	}
+
+	httpmock.RegisterResponder("GET", `http://localhost:12345/transactions/ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059`,
+		func(req *http.Request) (*http.Response, error) {
+			transactionStatus := make(map[string]interface{})
+			transactionStatus["status"] = "Succeeded"
+			return httpmock.NewJsonResponderOrPanic(200, transactionStatus)(req)
+		})
+
+	status, err := e.GetTransactionStatus(context.Background(), op)
+	assert.NotNil(t, status)
+	assert.NoError(t, err)
+}
+
+func TestGetTransactionStatusFailed(t *testing.T) {
+	e, cancel := newTestEthereum()
+	defer cancel()
+	httpmock.ActivateNonDefault(e.client.GetClient())
+	defer httpmock.DeactivateAndReset()
+
+	op := &core.Operation{
+		Namespace: "ns1",
+		ID:        fftypes.MustParseUUID("9ffc50ff-6bfe-4502-adc7-93aea54cc059"),
+		Status:    "Pending",
+	}
+
+	httpmock.RegisterResponder("GET", `http://localhost:12345/transactions/ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059`,
+		func(req *http.Request) (*http.Response, error) {
+			transactionStatus := make(map[string]interface{})
+			transactionStatus["status"] = "Failed"
+			return httpmock.NewJsonResponderOrPanic(200, transactionStatus)(req)
+		})
+
+	status, err := e.GetTransactionStatus(context.Background(), op)
+	assert.NotNil(t, status)
+	assert.NoError(t, err)
+}
+
+func TestGetTransactionStatusEmptyResult(t *testing.T) {
+	e, cancel := newTestEthereum()
+	defer cancel()
+	httpmock.ActivateNonDefault(e.client.GetClient())
+	defer httpmock.DeactivateAndReset()
+
+	op := &core.Operation{
+		Namespace: "ns1",
+		ID:        fftypes.MustParseUUID("9ffc50ff-6bfe-4502-adc7-93aea54cc059"),
+		Status:    "Pending",
+	}
+
+	httpmock.RegisterResponder("GET", `http://localhost:12345/transactions/ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059`,
+		func(req *http.Request) (*http.Response, error) {
+			return httpmock.NewJsonResponderOrPanic(200, make(map[string]interface{}))(req)
+		})
+
+	status, err := e.GetTransactionStatus(context.Background(), op)
+	assert.NotNil(t, status)
+	assert.NoError(t, err)
+}
+
+func TestGetTransactionStatusNoResult(t *testing.T) {
+	e, cancel := newTestEthereum()
+	defer cancel()
+	httpmock.ActivateNonDefault(e.client.GetClient())
+	defer httpmock.DeactivateAndReset()
+
+	op := &core.Operation{
+		Namespace: "ns1",
+		ID:        fftypes.MustParseUUID("9ffc50ff-6bfe-4502-adc7-93aea54cc059"),
+	}
+
+	httpmock.RegisterResponder("GET", `http://localhost:12345/transactions/ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059`,
+		func(req *http.Request) (*http.Response, error) {
+			return httpmock.NewJsonResponderOrPanic(404, make(map[string]interface{}))(req)
+		})
+
+	status, err := e.GetTransactionStatus(context.Background(), op)
+	assert.Nil(t, status)
+	assert.Nil(t, err)
+}
+
+func TestGetTransactionStatusBadResult(t *testing.T) {
+	e, cancel := newTestEthereum()
+	defer cancel()
+	httpmock.ActivateNonDefault(e.client.GetClient())
+	defer httpmock.DeactivateAndReset()
+
+	op := &core.Operation{
+		Namespace: "ns1",
+		ID:        fftypes.MustParseUUID("9ffc50ff-6bfe-4502-adc7-93aea54cc059"),
+	}
+
+	httpmock.RegisterResponder("GET", `http://localhost:12345/transactions/ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059`,
+		func(req *http.Request) (*http.Response, error) {
+			return httpmock.NewJsonResponderOrPanic(500, make(map[string]interface{}))(req)
+		})
+
+	status, err := e.GetTransactionStatus(context.Background(), op)
+	assert.Nil(t, status)
+	assert.Error(t, err)
+}
diff --git a/internal/blockchain/fabric/fabric_test.go b/internal/blockchain/fabric/fabric_test.go
index 3e63d6122..dbe35706e 100644
--- a/internal/blockchain/fabric/fabric_test.go
+++ b/internal/blockchain/fabric/fabric_test.go
@@ -1310,7 +1310,7 @@ func TestHandleReceiptTXSuccess(t *testing.T) {
 	}
 	e.SetOperationHandler("ns1", em)
 
-	var reply fftypes.JSONObject
+	var reply common.BlockchainReceiptNotification
 	operationID := fftypes.NewUUID()
 	data := []byte(`{
 		"_id": "748e7587-9e72-4244-7351-808f69b88291",
@@ -1322,7 +1322,7 @@ func TestHandleReceiptTXSuccess(t *testing.T) {
 				"timeReceived": "2021-08-27T03:04:34.199742Z",
 				"type": "TransactionSuccess"
 		},
-		"transactionId": "ce79343000e851a0c742f63a733ce19a5f8b9ce1c719b6cecd14f01bcf81fff2",
+		"transactionHash": "ce79343000e851a0c742f63a733ce19a5f8b9ce1c719b6cecd14f01bcf81fff2",
 		"receivedAt": 1630033474675
   }`)
 
@@ -1335,7 +1335,7 @@ func TestHandleReceiptTXSuccess(t *testing.T) {
 
 	err := json.Unmarshal(data, &reply)
 	assert.NoError(t, err)
-	e.handleReceipt(context.Background(), reply)
+	common.HandleReceipt(context.Background(), e, &reply, e.callbacks)
 
 	em.AssertExpectations(t)
 }
@@ -1352,11 +1352,11 @@ func TestHandleReceiptNoRequestID(t *testing.T) {
 	}
 	e.SetHandler("ns1", em)
 
-	var reply fftypes.JSONObject
+	var reply common.BlockchainReceiptNotification
 	data := []byte(`{}`)
 	err := json.Unmarshal(data, &reply)
 	assert.NoError(t, err)
-	e.handleReceipt(context.Background(), reply)
+	common.HandleReceipt(context.Background(), e, &reply, e.callbacks)
 }
 
 func TestHandleReceiptFailedTx(t *testing.T) {
@@ -1371,7 +1371,7 @@ func TestHandleReceiptFailedTx(t *testing.T) {
 	}
 	e.SetOperationHandler("ns1", em)
 
-	var reply fftypes.JSONObject
+	var reply common.BlockchainReceiptNotification
 	operationID := fftypes.NewUUID()
 	data := []byte(`{
 		"_id": "748e7587-9e72-4244-7351-808f69b88291",
@@ -1384,7 +1384,7 @@ func TestHandleReceiptFailedTx(t *testing.T) {
 				"type": "TransactionFailure"
 		},
 		"receivedAt": 1630033474675,
-		"transactionId": "ce79343000e851a0c742f63a733ce19a5f8b9ce1c719b6cecd14f01bcf81fff2"
+		"transactionHash": "ce79343000e851a0c742f63a733ce19a5f8b9ce1c719b6cecd14f01bcf81fff2"
   }`)
 
 	em.On("OperationUpdate", mock.MatchedBy(func(update *core.OperationUpdate) bool {
@@ -1396,7 +1396,7 @@ func TestHandleReceiptFailedTx(t *testing.T) {
 
 	err := json.Unmarshal(data, &reply)
 	assert.NoError(t, err)
-	e.handleReceipt(context.Background(), reply)
+	common.HandleReceipt(context.Background(), e, &reply, e.callbacks)
 
 	em.AssertExpectations(t)
 }
@@ -2705,3 +2705,66 @@ func TestGetContractListenerStatus(t *testing.T) {
 	assert.Nil(t, status)
 	assert.NoError(t, err)
 }
+
+func TestGetTransactionStatus(t *testing.T) {
+	e, cancel := newTestFabric()
+	defer cancel()
+	httpmock.ActivateNonDefault(e.client.GetClient())
+	defer httpmock.DeactivateAndReset()
+
+	op := &core.Operation{
+		Namespace: "ns1",
+		ID:        fftypes.MustParseUUID("9ffc50ff-6bfe-4502-adc7-93aea54cc059"),
+	}
+
+	httpmock.RegisterResponder("GET", `http://localhost:12345/transactions/ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059`,
+		func(req *http.Request) (*http.Response, error) {
+			return httpmock.NewJsonResponderOrPanic(200, make(map[string]interface{}))(req)
+		})
+
+	status, err := e.GetTransactionStatus(context.Background(), op)
+	assert.NotNil(t, status)
+	assert.NoError(t, err)
+}
+
+func TestGetTransactionStatusNoResult(t *testing.T) {
+	e, cancel := newTestFabric()
+	defer cancel()
+	httpmock.ActivateNonDefault(e.client.GetClient())
+	defer httpmock.DeactivateAndReset()
+
+	op := &core.Operation{
+		Namespace: "ns1",
+		ID:        fftypes.MustParseUUID("9ffc50ff-6bfe-4502-adc7-93aea54cc059"),
+	}
+
+	httpmock.RegisterResponder("GET", `http://localhost:12345/transactions/ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059`,
+		func(req *http.Request) (*http.Response, error) {
+			return httpmock.NewJsonResponderOrPanic(404, make(map[string]interface{}))(req)
+		})
+
+	status, err := e.GetTransactionStatus(context.Background(), op)
+	assert.Nil(t, status)
+	assert.Nil(t, err)
+}
+
+func TestGetTransactionStatusBadResult(t *testing.T) {
+	e, cancel := newTestFabric()
+	defer cancel()
+	httpmock.ActivateNonDefault(e.client.GetClient())
+	defer httpmock.DeactivateAndReset()
+
+	op := &core.Operation{
+		Namespace: "ns1",
+		ID:        fftypes.MustParseUUID("9ffc50ff-6bfe-4502-adc7-93aea54cc059"),
+	}
+
+	httpmock.RegisterResponder("GET", `http://localhost:12345/transactions/ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059`,
+		func(req *http.Request) (*http.Response, error) {
+			return httpmock.NewJsonResponderOrPanic(500, make(map[string]interface{}))(req)
+		})
+
+	status, err := e.GetTransactionStatus(context.Background(), op)
+	assert.Nil(t, status)
+	assert.NotNil(t, err)
+}
diff --git a/internal/events/token_pool_created_test.go b/internal/events/token_pool_created_test.go
index 45e3d74a0..8eb612505 100644
--- a/internal/events/token_pool_created_test.go
+++ b/internal/events/token_pool_created_test.go
@@ -225,6 +225,56 @@ func TestTokenPoolCreatedConfirmFailBadSymbol(t *testing.T) {
 
 }
 
+func TestTokenPoolCreatedMigrate(t *testing.T) {
+	em := newTestEventManager(t)
+	defer em.cleanup(t)
+	mti := &tokenmocks.Plugin{}
+
+	txID := fftypes.NewUUID()
+	chainPool := &tokens.TokenPool{
+		Type:        core.TokenTypeFungible,
+		PoolLocator: "123",
+		Connector:   "magic-tokens",
+		TX: core.TransactionRef{
+			ID:   txID,
+			Type: core.TransactionTypeTokenPool,
+		},
+		Event: &blockchain.Event{
+			BlockchainTXID: "0xffffeeee",
+			ProtocolID:     "tx1",
+			Info:           fftypes.JSONObject{"some": "info"},
+		},
+	}
+	storedPool := &core.TokenPool{
+		Namespace: "ns1",
+		ID:        fftypes.NewUUID(),
+		State:     core.TokenPoolStateUnknown,
+		TX: core.TransactionRef{
+			Type: core.TransactionTypeTokenPool,
+			ID:   txID,
+		},
+	}
+
+	em.mdi.On("GetTokenPoolByLocator", em.ctx, "ns1", "magic-tokens", "123").Return(storedPool, nil).Times(2)
+	em.mth.On("InsertOrGetBlockchainEvent", em.ctx, mock.MatchedBy(func(e *core.BlockchainEvent) bool {
+		return e.Name == chainPool.Event.Name
+	})).Return(nil, nil).Once()
+	em.mdi.On("InsertEvent", em.ctx, mock.MatchedBy(func(e *core.Event) bool {
+		return e.Type == core.EventTypeBlockchainEventReceived
+	})).Return(nil).Once()
+	em.mth.On("PersistTransaction", mock.Anything, txID, core.TransactionTypeTokenPool, "0xffffeeee").Return(true, nil).Once()
+	em.mdi.On("UpsertTokenPool", em.ctx, storedPool).Return(nil).Once()
+	em.mdi.On("InsertEvent", em.ctx, mock.MatchedBy(func(e *core.Event) bool {
+		return e.Type == core.EventTypePoolConfirmed && *e.Reference == *storedPool.ID
+	})).Return(nil).Once()
+	em.mam.On("ActivateTokenPool", em.ctx, storedPool).Return(fmt.Errorf("pop")).Once()
+	em.mam.On("ActivateTokenPool", em.ctx, storedPool).Return(nil).Once()
+
+	err := em.TokenPoolCreated(mti, chainPool)
+	assert.NoError(t, err)
+
+}
+
 func TestConfirmPoolBlockchainEventFail(t *testing.T) {
 	em := newTestEventManager(t)
 	defer em.cleanup(t)
diff --git a/internal/orchestrator/data_query_test.go b/internal/orchestrator/data_query_test.go
index 40be6db08..3c8a653e0 100644
--- a/internal/orchestrator/data_query_test.go
+++ b/internal/orchestrator/data_query_test.go
@@ -542,13 +542,102 @@ func TestGetOperationByID(t *testing.T) {
 	assert.NoError(t, err)
 }
 
-func TestGetOperationIDdBadID(t *testing.T) {
+func TestGetOperationIDBadID(t *testing.T) {
 	or := newTestOrchestrator()
 	defer or.cleanup(t)
 	_, err := or.GetOperationByID(context.Background(), "")
 	assert.Regexp(t, "FF00138", err)
 }
 
+type txnStatus struct {
+	TxnId string
+}
+
+func TestGetOperationByIDWithStatus(t *testing.T) {
+	or := newTestOrchestrator()
+	defer or.cleanup(t)
+	u := fftypes.NewUUID()
+
+	or.mom.On("GetOperationByIDCached", mock.Anything, u).Return(&core.Operation{
+		Namespace: "ns1",
+		Type:      core.OpTypeBlockchainInvoke,
+	}, nil)
+
+	or.mbi.On("GetTransactionStatus", mock.Anything, mock.Anything).Return(&txnStatus{TxnId: "abc123"}, nil)
+	opStatus, err := or.GetOperationByIDWithStatus(context.Background(), u.String())
+	assert.Nil(t, err)
+	assert.NotNil(t, opStatus)
+	var expectedTxnStatus = &txnStatus{TxnId: "abc123"}
+	assert.Equal(t, expectedTxnStatus, opStatus.DetailedStatus)
+}
+
+func TestGetOperationByIDWithGoodEmptyStatus(t *testing.T) {
+	or := newTestOrchestrator()
+	defer or.cleanup(t)
+	u := fftypes.NewUUID()
+
+	or.mom.On("GetOperationByIDCached", mock.Anything, u).Return(&core.Operation{
+		Namespace: "ns1",
+		Type:      core.OpTypeBlockchainInvoke,
+	}, nil)
+
+	or.mbi.On("GetTransactionStatus", mock.Anything, mock.Anything).Return(nil, nil)
+	opStatus, err := or.GetOperationByIDWithStatus(context.Background(), u.String())
+	assert.Nil(t, err)
+	assert.NotNil(t, opStatus)
+	assert.Equal(t, nil, opStatus.DetailedStatus)
+}
+
+func TestGetOperationByIDWithStatusWrongOpType(t *testing.T) {
+	or := newTestOrchestrator()
+	defer or.cleanup(t)
+	u := fftypes.NewUUID()
+
+	or.mom.On("GetOperationByIDCached", mock.Anything, u).Return(&core.Operation{
+		Namespace: "ns1",
+		Type:      core.OpTypeDataExchangeSendBatch,
+	}, nil)
+
+	opStatus, err := or.GetOperationByIDWithStatus(context.Background(), u.String())
+	assert.Nil(t, err)
+	assert.NotNil(t, opStatus)
+	assert.Equal(t, nil, opStatus.DetailedStatus)
+}
+
+func TestGetOperationIDWithStatusBadID(t *testing.T) {
+	or := newTestOrchestrator()
+	defer or.cleanup(t)
+	_, err := or.GetOperationByIDWithStatus(context.Background(), "")
+	assert.Regexp(t, "FF00138", err)
+}
+
+func TestGetOperationByIDWithStatusNoOp(t *testing.T) {
+	or := newTestOrchestrator()
+	defer or.cleanup(t)
+	u := fftypes.NewUUID()
+
+	or.mom.On("GetOperationByIDCached", mock.Anything, u).Return(nil, fmt.Errorf("pop"))
+	_, err := or.GetOperationByIDWithStatus(context.Background(), u.String())
+	assert.Error(t, err)
+}
+
+func TestGetOperationByIDWithStatusError(t *testing.T) {
+	or := newTestOrchestrator()
+	defer or.cleanup(t)
+	u := fftypes.NewUUID()
+
+	or.mom.On("GetOperationByIDCached", mock.Anything, u).Return(&core.Operation{
+		Namespace: "ns1",
+		Type:      core.OpTypeBlockchainInvoke,
+	}, nil)
+
+	or.mbi.On("GetTransactionStatus", mock.Anything, mock.Anything).Return(nil, fmt.Errorf("pop"))
+	opStatus, err := or.GetOperationByIDWithStatus(context.Background(), u.String())
+	assert.Nil(t, err)
+	assert.NotNil(t, opStatus)
+	assert.Regexp(t, "pop", opStatus.DetailedStatus)
+}
+
 func TestGetEventByID(t *testing.T) {
 	or := newTestOrchestrator()
 	defer or.cleanup(t)
diff --git a/pkg/core/operation_test.go b/pkg/core/operation_test.go
index 4fef232d1..e0204b614 100644
--- a/pkg/core/operation_test.go
+++ b/pkg/core/operation_test.go
@@ -44,6 +44,59 @@ func TestNewPendingMessageOp(t *testing.T) {
 	}, *op)
 }
 
+func TestOperationTypes(t *testing.T) {
+
+	txID := fftypes.NewUUID()
+	op := NewOperation(&fakePlugin{}, "ns1", txID, OpTypeSharedStorageUploadBatch)
+	assert.Equal(t, Operation{
+		ID:          op.ID,
+		Namespace:   "ns1",
+		Transaction: txID,
+		Plugin:      "fake",
+		Type:        OpTypeSharedStorageUploadBatch,
+		Status:      OpStatusPending,
+		Created:     op.Created,
+		Updated:     op.Created,
+	}, *op)
+
+	assert.False(t, op.IsBlockchainOperation())
+	assert.False(t, op.IsTokenOperation())
+
+	// Blockchain operation types
+	op.Type = OpTypeBlockchainContractDeploy
+	assert.True(t, op.IsBlockchainOperation())
+	assert.False(t, op.IsTokenOperation())
+
+	op.Type = OpTypeBlockchainInvoke
+	assert.True(t, op.IsBlockchainOperation())
+	assert.False(t, op.IsTokenOperation())
+
+	op.Type = OpTypeBlockchainNetworkAction
+	assert.True(t, op.IsBlockchainOperation())
+	assert.False(t, op.IsTokenOperation())
+
+	op.Type = OpTypeBlockchainPinBatch
+	assert.True(t, op.IsBlockchainOperation())
+	assert.False(t, op.IsTokenOperation())
+
+	// Token operation types
+	op.Type = OpTypeTokenActivatePool
+	assert.True(t, op.IsTokenOperation())
+	assert.False(t, op.IsBlockchainOperation())
+
+	op.Type = OpTypeTokenApproval
+	assert.True(t, op.IsTokenOperation())
+	assert.False(t, op.IsBlockchainOperation())
+
+	op.Type = OpTypeTokenCreatePool
+	assert.True(t, op.IsTokenOperation())
+	assert.False(t, op.IsBlockchainOperation())
+
+	op.Type = OpTypeTokenTransfer
+	assert.True(t, op.IsTokenOperation())
+	assert.False(t, op.IsBlockchainOperation())
+}
+
 func TestParseNamespacedOpID(t *testing.T) {
 
 	ctx := context.Background()