Repository: hyperledger/firefly
Pull request #820 (2022-05-17 17:39:12)

Support migration of the FireFly contract from one location to another
~~In a chain with #821.~~
~~Depends on https://github.com/hyperledger/firefly-common/pull/8 being merged and integrated here.~~

Summary of changes:

1. Support a list of contract locations in the blockchain config under a new `fireflyContract` key.
New config example (for Ethereum):
```
blockchain:
- name: blockchain0
  type: ethereum
  ethereum:
    ethconnect:
      url: http://ethconnect_0:8080
      topic: "0"
    fireflyContract:
    - address: 0x057b6ae997a77726a2cfed8cbef7fd94ce42b15a
      fromBlock: 0
    - address: 0xb347e9de7d9f312c24c375fff7632e189bf1dd43
```

This means deprecation of the previous keys `ethconnect.instance` and `fabconnect.chaincode`.

3. New `fireflyContract` field added to namespaces.
Records the config index and address of the active contract, as well as the final event for all previously-terminated contracts. New namespace example:
```
{
  "id": "3ee769e3-92c5-4695-acd6-75c6e362082c",
  "name": "ff_system",
  "description": "FireFly system namespace",
  "type": "system",
  "created": "2022-05-24T19:18:40.07772Z",
  "fireflyContract": {
    "active": {
      "index": 1,
      "info": {
        "address": "0xb347e9de7d9f312c24c375fff7632e189bf1dd43",
        "fromBlock": "oldest",
        "subscription": "sb-2f4dc8bb-b577-4b9b-582b-9b5bbd347706"
      }
    },
    "terminated": [
      {
        "index": 0,
        "finalEvent": "000000000007/000000/000000",
        "info": {
          "address": "0x057b6ae997a77726a2cfed8cbef7fd94ce42b15a",
          "fromBlock": "oldest",
          "subscription": "sb-b43ec7ed-b158-48dc-473a-05bda8e0fc28"
        }
      }
    ]
  }
}
```
Currently there is only one globally active FireFly contract, and its info is recorded against the "ff_system" namespace. In a future PR as part of https://github.com/hyperledger/firefly-fir/pull/12, each namespace will have its own blockchain plugin and therefore its own contract.

4. New `/network/operatoraction` API, with a single supported action type of `terminate`.
Sends a specially formatted "BatchPin" transaction to signal that all members should terminate listening to the current FireFly contract and migrate to the next one configured.

**Note that this is all separate from migrating the "network rules" of a namespace (which is also in scope for FIR-12). This first step just allows migrating from one contract to another. In a future PR we can introduce the capability for a new contract to expose a "network version", so that a contract migration may also cause a rules migration (such as changing the signature of BatchPin or the rules for resolving identities).**

codecov-commenter: # [Codecov](https://codecov.io/gh/hyperledger/firefly/pull/820?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger) Report
> Merging [#820](https://codecov.io/gh/hyperledger/firefly/pull/820?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger) (e999cad) into [main](https://codecov.io/gh/hyperledger/firefly/commit/3cf5f35fbee391908459ad2d9d8e794ab8f5219f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger) (3cf5f35) will **decrease** coverage by `0.31%`.
> The diff coverage is `58.27%`.

```diff
@@             Coverage Diff             @@
##              main     #820      +/-   ##
===========================================
- Coverage   100.00%   99.68%   -0.32%     
===========================================
  Files          304      306       +2     
  Lines        18960    19044      +84     
===========================================
+ Hits         18960    18984      +24     
- Misses           0       58      +58     
- Partials         0        2       +2     
```


| [Impacted Files](https://codecov.io/gh/hyperledger/firefly/pull/820?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger) | Coverage Δ | |
|---|---|---|
| [internal/apiserver/routes.go](https://codecov.io/gh/hyperledger/firefly/pull/820/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger#diff-aW50ZXJuYWwvYXBpc2VydmVyL3JvdXRlcy5nbw==) | `100.00% <ø> (ø)` | |
| [internal/events/event\_manager.go](https://codecov.io/gh/hyperledger/firefly/pull/820/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger#diff-aW50ZXJuYWwvZXZlbnRzL2V2ZW50X21hbmFnZXIuZ28=) | `100.00% <ø> (ø)` | |
| [internal/events/operator\_action.go](https://codecov.io/gh/hyperledger/firefly/pull/820/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger#diff-aW50ZXJuYWwvZXZlbnRzL29wZXJhdG9yX2FjdGlvbi5nbw==) | `0.00% <0.00%> (ø)` | |
| [internal/orchestrator/bound\_callbacks.go](https://codecov.io/gh/hyperledger/firefly/pull/820/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger#diff-aW50ZXJuYWwvb3JjaGVzdHJhdG9yL2JvdW5kX2NhbGxiYWNrcy5nbw==) | `94.73% <0.00%> (-5.27%)` | :arrow_down: |
| [pkg/core/namespace.go](https://codecov.io/gh/hyperledger/firefly/pull/820/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger#diff-cGtnL2NvcmUvbmFtZXNwYWNlLmdv) | `100.00% <ø> (ø)` | |
| [internal/orchestrator/orchestrator.go](https://codecov.io/gh/hyperledger/firefly/pull/820/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger#diff-aW50ZXJuYWwvb3JjaGVzdHJhdG9yL29yY2hlc3RyYXRvci5nbw==) | `98.39% <16.66%> (-1.61%)` | :arrow_down: |
| [internal/blockchain/fabric/fabric.go](https://codecov.io/gh/hyperledger/firefly/pull/820/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger#diff-aW50ZXJuYWwvYmxvY2tjaGFpbi9mYWJyaWMvZmFicmljLmdv) | `97.16% <23.07%> (-2.84%)` | :arrow_down: |
| [internal/apiserver/route\_post\_network\_migrate.go](https://codecov.io/gh/hyperledger/firefly/pull/820/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger#diff-aW50ZXJuYWwvYXBpc2VydmVyL3JvdXRlX3Bvc3RfbmV0d29ya19taWdyYXRlLmdv) | `33.33% <33.33%> (ø)` | |
| [internal/blockchain/ethereum/ethereum.go](https://codecov.io/gh/hyperledger/firefly/pull/820/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger#diff-aW50ZXJuYWwvYmxvY2tjaGFpbi9ldGhlcmV1bS9ldGhlcmV1bS5nbw==) | `97.08% <76.62%> (-2.92%)` | :arrow_down: |
| [internal/blockchain/ethereum/config.go](https://codecov.io/gh/hyperledger/firefly/pull/820/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger#diff-aW50ZXJuYWwvYmxvY2tjaGFpbi9ldGhlcmV1bS9jb25maWcuZ28=) | `100.00% <100.00%> (ø)` | |
| ... and [3 more](https://codecov.io/gh/hyperledger/firefly/pull/820/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/hyperledger/firefly/pull/820?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/hyperledger/firefly/pull/820?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger). Last update [3cf5f35...e999cad](https://codecov.io/gh/hyperledger/firefly/pull/820?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger).

awrichar: I think this will now need to slot in after #826

Patch:
diff --git a/db/migrations/postgres/000090_add_namespace_fireflycontracts.down.sql b/db/migrations/postgres/000090_add_namespace_fireflycontracts.down.sql
new file mode 100644
index 000000000..ba47391d6
--- /dev/null
+++ b/db/migrations/postgres/000090_add_namespace_fireflycontracts.down.sql
@@ -0,0 +1,3 @@
+BEGIN;
+ALTER TABLE namespaces DROP COLUMN firefly_contracts;
+COMMIT;
diff --git a/db/migrations/postgres/000090_add_namespace_fireflycontracts.up.sql b/db/migrations/postgres/000090_add_namespace_fireflycontracts.up.sql
new file mode 100644
index 000000000..20ef62799
--- /dev/null
+++ b/db/migrations/postgres/000090_add_namespace_fireflycontracts.up.sql
@@ -0,0 +1,3 @@
+BEGIN;
+ALTER TABLE namespaces ADD COLUMN firefly_contracts TEXT;
+COMMIT;
diff --git a/db/migrations/sqlite/000090_add_namespace_fireflycontracts.down.sql b/db/migrations/sqlite/000090_add_namespace_fireflycontracts.down.sql
new file mode 100644
index 000000000..384148d1c
--- /dev/null
+++ b/db/migrations/sqlite/000090_add_namespace_fireflycontracts.down.sql
@@ -0,0 +1,1 @@
+ALTER TABLE namespaces DROP COLUMN firefly_contracts;
diff --git a/db/migrations/sqlite/000090_add_namespace_fireflycontracts.up.sql b/db/migrations/sqlite/000090_add_namespace_fireflycontracts.up.sql
new file mode 100644
index 000000000..2230dde7b
--- /dev/null
+++ b/db/migrations/sqlite/000090_add_namespace_fireflycontracts.up.sql
@@ -0,0 +1,1 @@
+ALTER TABLE namespaces ADD COLUMN firefly_contracts TEXT;
diff --git a/docs/reference/config.md b/docs/reference/config.md
index 072a0b98c..ec07ecc10 100644
--- a/docs/reference/config.md
+++ b/docs/reference/config.md
@@ -163,10 +163,10 @@ nav_order: 3
 |batchTimeout|How long Ethconnect should wait for new events to arrive and fill a batch, before sending the batch to FireFly core. Only applies when automatically creating a new event stream|[`time.Duration`](https://pkg.go.dev/time#Duration)|`500`
 |connectionTimeout|The maximum amount of time that a connection is allowed to remain with no data transmitted|[`time.Duration`](https://pkg.go.dev/time#Duration)|`30s`
 |expectContinueTimeout|See [ExpectContinueTimeout in the Go docs](https://pkg.go.dev/net/http#Transport)|[`time.Duration`](https://pkg.go.dev/time#Duration)|`1s`
-|fromBlock|The first event this FireFly instance should listen to from the BatchPin smart contract. Default=0. Only affects initial creation of the event stream|Address `string`|`0`
+|fromBlock|The first event this FireFly instance should listen to from the BatchPin smart contract. Default=0. Only affects initial creation of the event stream (deprecated - use fireflyContract[].fromBlock)|Address `string`|`0`
 |headers|Adds custom headers to HTTP requests|`map[string]string`|`<nil>`
 |idleTimeout|The max duration to hold a HTTP keepalive connection between calls|[`time.Duration`](https://pkg.go.dev/time#Duration)|`475ms`
-|instance|The Ethereum address of the FireFly BatchPin smart contract that has been deployed to the blockchain|Address `string`|`<nil>`
+|instance|The Ethereum address of the FireFly BatchPin smart contract that has been deployed to the blockchain (deprecated - use fireflyContract[].address)|Address `string`|`<nil>`
 |maxIdleConns|The max number of idle connections to hold pooled|`int`|`100`
 |prefixLong|The prefix that will be used for Ethconnect specific HTTP headers when FireFly makes requests to Ethconnect|`string`|`firefly`
 |prefixShort|The prefix that will be used for Ethconnect specific query parameters when FireFly makes requests to Ethconnect|`string`|`fly`
@@ -242,13 +242,20 @@ nav_order: 3
 |initWaitTime|The initial retry delay|[`time.Duration`](https://pkg.go.dev/time#Duration)|`250ms`
 |maxWaitTime|The maximum retry delay|[`time.Duration`](https://pkg.go.dev/time#Duration)|`30s`
 
+## blockchain.ethereum.fireflyContract[]
+
+|Key|Description|Type|Default Value|
+|---|-----------|----|-------------|
+|address|The Ethereum address of the FireFly BatchPin smart contract that has been deployed to the blockchain|Address `string`|`<nil>`
+|fromBlock|The first event this FireFly instance should listen to from the BatchPin smart contract. Default=0. Only affects initial creation of the event stream|Address `string`|`<nil>`
+
 ## blockchain.fabric.fabconnect
 
 |Key|Description|Type|Default Value|
 |---|-----------|----|-------------|
 |batchSize|The number of events Fabconnect should batch together for delivery to FireFly core. Only applies when automatically creating a new event stream|`int`|`50`
 |batchTimeout|The maximum amount of time to wait for a batch to complete|[`time.Duration`](https://pkg.go.dev/time#Duration)|`500`
-|chaincode|The name of the Fabric chaincode that FireFly will use for BatchPin transactions|`string`|`<nil>`
+|chaincode|The name of the Fabric chaincode that FireFly will use for BatchPin transactions (deprecated - use fireflyContract[].chaincode)|`string`|`<nil>`
 |channel|The Fabric channel that FireFly will use for BatchPin transactions|`string`|`<nil>`
 |connectionTimeout|The maximum amount of time that a connection is allowed to remain with no data transmitted|[`time.Duration`](https://pkg.go.dev/time#Duration)|`30s`
 |expectContinueTimeout|See [ExpectContinueTimeout in the Go docs](https://pkg.go.dev/net/http#Transport)|[`time.Duration`](https://pkg.go.dev/time#Duration)|`1s`
@@ -295,6 +302,13 @@ nav_order: 3
 |readBufferSize|The size in bytes of the read buffer for the WebSocket connection|[`BytesSize`](https://pkg.go.dev/github.com/docker/go-units#BytesSize)|`16Kb`
 |writeBufferSize|The size in bytes of the write buffer for the WebSocket connection|[`BytesSize`](https://pkg.go.dev/github.com/docker/go-units#BytesSize)|`16Kb`
 
+## blockchain.fabric.fireflyContract[]
+
+|Key|Description|Type|Default Value|
+|---|-----------|----|-------------|
+|chaincode|The name of the Fabric chaincode that FireFly will use for BatchPin transactions|`string`|`<nil>`
+|fromBlock|The first event this FireFly instance should listen to from the BatchPin chaincode. Default=0. Only affects initial creation of the event stream|Address `string`|`<nil>`
+
 ## blockchainevent.cache
 
 |Key|Description|Type|Default Value|
@@ -813,13 +827,20 @@ nav_order: 3
 |initWaitTime|The initial retry delay|[`time.Duration`](https://pkg.go.dev/time#Duration)|`250ms`
 |maxWaitTime|The maximum retry delay|[`time.Duration`](https://pkg.go.dev/time#Duration)|`30s`
 
+## plugins.blockchain[].ethereum.fireflyContract[]
+
+|Key|Description|Type|Default Value|
+|---|-----------|----|-------------|
+|address|The Ethereum address of the FireFly BatchPin smart contract that has been deployed to the blockchain|Address `string`|`<nil>`
+|fromBlock|The first event this FireFly instance should listen to from the BatchPin smart contract. Default=0. Only affects initial creation of the event stream|Address `string`|`<nil>`
+
 ## plugins.blockchain[].fabric.fabconnect
 
 |Key|Description|Type|Default Value|
 |---|-----------|----|-------------|
 |batchSize|The number of events Fabconnect should batch together for delivery to FireFly core. Only applies when automatically creating a new event stream|`int`|`50`
 |batchTimeout|The maximum amount of time to wait for a batch to complete|[`time.Duration`](https://pkg.go.dev/time#Duration)|`500`
-|chaincode|The name of the Fabric chaincode that FireFly will use for BatchPin transactions|`string`|`<nil>`
+|chaincode|The name of the Fabric chaincode that FireFly will use for BatchPin transactions (deprecated - use fireflyContract[].chaincode)|`string`|`<nil>`
 |channel|The Fabric channel that FireFly will use for BatchPin transactions|`string`|`<nil>`
 |connectionTimeout|The maximum amount of time that a connection is allowed to remain with no data transmitted|[`time.Duration`](https://pkg.go.dev/time#Duration)|`30s`
 |expectContinueTimeout|See [ExpectContinueTimeout in the Go docs](https://pkg.go.dev/net/http#Transport)|[`time.Duration`](https://pkg.go.dev/time#Duration)|`1s`
@@ -866,6 +887,13 @@ nav_order: 3
 |readBufferSize|The size in bytes of the read buffer for the WebSocket connection|[`BytesSize`](https://pkg.go.dev/github.com/docker/go-units#BytesSize)|`16Kb`
 |writeBufferSize|The size in bytes of the write buffer for the WebSocket connection|[`BytesSize`](https://pkg.go.dev/github.com/docker/go-units#BytesSize)|`16Kb`
 
+## plugins.blockchain[].fabric.fireflyContract[]
+
+|Key|Description|Type|Default Value|
+|---|-----------|----|-------------|
+|chaincode|The name of the Fabric chaincode that FireFly will use for BatchPin transactions|`string`|`<nil>`
+|fromBlock|The first event this FireFly instance should listen to from the BatchPin chaincode. Default=0. Only affects initial creation of the event stream|Address `string`|`<nil>`
+
 ## plugins.database[]
 
 |Key|Description|Type|Default Value|
diff --git a/docs/reference/types/namespace.md b/docs/reference/types/namespace.md
index d8a89f1fc..901a967b8 100644
--- a/docs/reference/types/namespace.md
+++ b/docs/reference/types/namespace.md
@@ -28,7 +28,12 @@ nav_order: 20
     "name": "default",
     "description": "Default predefined namespace",
     "type": "local",
-    "created": "2022-05-16T01:23:16Z"
+    "created": "2022-05-16T01:23:16Z",
+    "fireflyContract": {
+        "active": {
+            "index": 0
+        }
+    }
 }
 ```
 
@@ -42,4 +47,22 @@ nav_order: 20
 | `description` | A description of the namespace | `string` |
 | `type` | The type of the namespace | `FFEnum`:<br/>`"local"`<br/>`"broadcast"`<br/>`"system"` |
 | `created` | The time the namespace was created | [`FFTime`](simpletypes#fftime) |
+| `fireflyContract` | Info on the FireFly smart contract configured for this namespace | [`FireFlyContracts`](#fireflycontracts) |
+
+## FireFlyContracts
+
+| Field Name | Description | Type |
+|------------|-------------|------|
+| `active` | The currently active FireFly smart contract | [`FireFlyContractInfo`](#fireflycontractinfo) |
+| `terminated` | Previously-terminated FireFly smart contracts | [`FireFlyContractInfo[]`](#fireflycontractinfo) |
+
+## FireFlyContractInfo
+
+| Field Name | Description | Type |
+|------------|-------------|------|
+| `index` | The index of this contract in the config file | `int` |
+| `finalEvent` | The identifier for the final blockchain event received from this contract before termination | `string` |
+| `info` | Blockchain-specific info on the contract, such as its location on chain | [`JSONObject`](simpletypes#jsonobject) |
+
+
 
diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml
index 06d813bc0..b07645700 100644
--- a/docs/swagger/swagger.yaml
+++ b/docs/swagger/swagger.yaml
@@ -8231,6 +8231,52 @@ paths:
                     description:
                       description: A description of the namespace
                       type: string
+                    fireflyContract:
+                      description: Info on the FireFly smart contract configured for
+                        this namespace
+                      properties:
+                        active:
+                          description: The currently active FireFly smart contract
+                          properties:
+                            finalEvent:
+                              description: The identifier for the final blockchain
+                                event received from this contract before termination
+                              type: string
+                            index:
+                              description: The index of this contract in the config
+                                file
+                              type: integer
+                            info:
+                              additionalProperties:
+                                description: Blockchain-specific info on the contract,
+                                  such as its location on chain
+                              description: Blockchain-specific info on the contract,
+                                such as its location on chain
+                              type: object
+                          type: object
+                        terminated:
+                          description: Previously-terminated FireFly smart contracts
+                          items:
+                            description: Previously-terminated FireFly smart contracts
+                            properties:
+                              finalEvent:
+                                description: The identifier for the final blockchain
+                                  event received from this contract before termination
+                                type: string
+                              index:
+                                description: The index of this contract in the config
+                                  file
+                                type: integer
+                              info:
+                                additionalProperties:
+                                  description: Blockchain-specific info on the contract,
+                                    such as its location on chain
+                                description: Blockchain-specific info on the contract,
+                                  such as its location on chain
+                                type: object
+                            type: object
+                          type: array
+                      type: object
                     id:
                       description: The UUID of the namespace. For locally established
                         namespaces will be different on each node in the network.
@@ -8301,6 +8347,52 @@ paths:
                   description:
                     description: A description of the namespace
                     type: string
+                  fireflyContract:
+                    description: Info on the FireFly smart contract configured for
+                      this namespace
+                    properties:
+                      active:
+                        description: The currently active FireFly smart contract
+                        properties:
+                          finalEvent:
+                            description: The identifier for the final blockchain event
+                              received from this contract before termination
+                            type: string
+                          index:
+                            description: The index of this contract in the config
+                              file
+                            type: integer
+                          info:
+                            additionalProperties:
+                              description: Blockchain-specific info on the contract,
+                                such as its location on chain
+                            description: Blockchain-specific info on the contract,
+                              such as its location on chain
+                            type: object
+                        type: object
+                      terminated:
+                        description: Previously-terminated FireFly smart contracts
+                        items:
+                          description: Previously-terminated FireFly smart contracts
+                          properties:
+                            finalEvent:
+                              description: The identifier for the final blockchain
+                                event received from this contract before termination
+                              type: string
+                            index:
+                              description: The index of this contract in the config
+                                file
+                              type: integer
+                            info:
+                              additionalProperties:
+                                description: Blockchain-specific info on the contract,
+                                  such as its location on chain
+                              description: Blockchain-specific info on the contract,
+                                such as its location on chain
+                              type: object
+                          type: object
+                        type: array
+                    type: object
                   id:
                     description: The UUID of the namespace. For locally established
                       namespaces will be different on each node in the network. For
@@ -8336,6 +8428,52 @@ paths:
                   description:
                     description: A description of the namespace
                     type: string
+                  fireflyContract:
+                    description: Info on the FireFly smart contract configured for
+                      this namespace
+                    properties:
+                      active:
+                        description: The currently active FireFly smart contract
+                        properties:
+                          finalEvent:
+                            description: The identifier for the final blockchain event
+                              received from this contract before termination
+                            type: string
+                          index:
+                            description: The index of this contract in the config
+                              file
+                            type: integer
+                          info:
+                            additionalProperties:
+                              description: Blockchain-specific info on the contract,
+                                such as its location on chain
+                            description: Blockchain-specific info on the contract,
+                              such as its location on chain
+                            type: object
+                        type: object
+                      terminated:
+                        description: Previously-terminated FireFly smart contracts
+                        items:
+                          description: Previously-terminated FireFly smart contracts
+                          properties:
+                            finalEvent:
+                              description: The identifier for the final blockchain
+                                event received from this contract before termination
+                              type: string
+                            index:
+                              description: The index of this contract in the config
+                                file
+                              type: integer
+                            info:
+                              additionalProperties:
+                                description: Blockchain-specific info on the contract,
+                                  such as its location on chain
+                              description: Blockchain-specific info on the contract,
+                                such as its location on chain
+                              type: object
+                          type: object
+                        type: array
+                    type: object
                   id:
                     description: The UUID of the namespace. For locally established
                       namespaces will be different on each node in the network. For
@@ -8395,6 +8533,52 @@ paths:
                   description:
                     description: A description of the namespace
                     type: string
+                  fireflyContract:
+                    description: Info on the FireFly smart contract configured for
+                      this namespace
+                    properties:
+                      active:
+                        description: The currently active FireFly smart contract
+                        properties:
+                          finalEvent:
+                            description: The identifier for the final blockchain event
+                              received from this contract before termination
+                            type: string
+                          index:
+                            description: The index of this contract in the config
+                              file
+                            type: integer
+                          info:
+                            additionalProperties:
+                              description: Blockchain-specific info on the contract,
+                                such as its location on chain
+                            description: Blockchain-specific info on the contract,
+                              such as its location on chain
+                            type: object
+                        type: object
+                      terminated:
+                        description: Previously-terminated FireFly smart contracts
+                        items:
+                          description: Previously-terminated FireFly smart contracts
+                          properties:
+                            finalEvent:
+                              description: The identifier for the final blockchain
+                                event received from this contract before termination
+                              type: string
+                            index:
+                              description: The index of this contract in the config
+                                file
+                              type: integer
+                            info:
+                              additionalProperties:
+                                description: Blockchain-specific info on the contract,
+                                  such as its location on chain
+                              description: Blockchain-specific info on the contract,
+                                such as its location on chain
+                              type: object
+                          type: object
+                        type: array
+                    type: object
                   id:
                     description: The UUID of the namespace. For locally established
                       namespaces will be different on each node in the network. For
@@ -22602,6 +22786,46 @@ paths:
           description: ""
       tags:
       - Non-Default Namespace
+  /network/action:
+    post:
+      description: Notify all nodes in the network of a new governance action
+      operationId: postNetworkAction
+      parameters:
+      - description: Server-side request timeout (millseconds, or set a custom suffix
+          like 10s)
+        in: header
+        name: Request-Timeout
+        schema:
+          default: 120s
+          type: string
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                type:
+                  description: The action to be performed
+                  enum:
+                  - terminate
+                  type: string
+              type: object
+      responses:
+        "202":
+          content:
+            application/json:
+              schema:
+                properties:
+                  type:
+                    description: The action to be performed
+                    enum:
+                    - terminate
+                    type: string
+                type: object
+          description: Success
+        default:
+          description: ""
+      tags:
+      - Global
   /network/diddocs/{did}:
     get:
       description: Gets a DID document by its DID
diff --git a/internal/apiserver/route_post_network_action.go b/internal/apiserver/route_post_network_action.go
new file mode 100644
index 000000000..02b22e320
--- /dev/null
+++ b/internal/apiserver/route_post_network_action.go
@@ -0,0 +1,42 @@
+// Copyright © 2022 Kaleido, Inc.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// 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 apiserver
+
+import (
+	"net/http"
+
+	"github.com/hyperledger/firefly/internal/coremsgs"
+	"github.com/hyperledger/firefly/internal/oapispec"
+	"github.com/hyperledger/firefly/pkg/core"
+)
+
+var postNetworkAction = &oapispec.Route{
+	Name:            "postNetworkAction",
+	Path:            "network/action",
+	Method:          http.MethodPost,
+	PathParams:      nil,
+	QueryParams:     nil,
+	FilterFactory:   nil,
+	Description:     coremsgs.APIEndpointsPostNetworkAction,
+	JSONInputValue:  func() interface{} { return &core.NetworkAction{} },
+	JSONOutputValue: func() interface{} { return &core.NetworkAction{} },
+	JSONOutputCodes: []int{http.StatusAccepted},
+	JSONHandler: func(r *oapispec.APIRequest) (output interface{}, err error) {
+		err = getOr(r.Ctx).SubmitNetworkAction(r.Ctx, r.Input.(*core.NetworkAction))
+		return r.Input, err
+	},
+}
diff --git a/internal/apiserver/routes.go b/internal/apiserver/routes.go
index d943792b1..cc7e18eff 100644
--- a/internal/apiserver/routes.go
+++ b/internal/apiserver/routes.go
@@ -38,6 +38,7 @@ var routes = append(
 		getStatusBatchManager,
 		getStatusPins,
 		getStatusWebSockets,
+		postNetworkAction,
 		postNewNamespace,
 		postNewOrganization,
 		postNewOrganizationSelf,
diff --git a/internal/blockchain/ethereum/config.go b/internal/blockchain/ethereum/config.go
index 688fcac4c..7bca63bfb 100644
--- a/internal/blockchain/ethereum/config.go
+++ b/internal/blockchain/ethereum/config.go
@@ -36,10 +36,8 @@ const (
 )
 
 const (
-	// EthconnectConfigKey is a sub-key in the config to contain all the ethconnect specific config,
+	// EthconnectConfigKey is a sub-key in the config to contain all the ethconnect specific config
 	EthconnectConfigKey = "ethconnect"
-	// EthconnectConfigInstancePath is the ethereum address of the contract
-	EthconnectConfigInstancePath = "instance"
 	// EthconnectConfigTopic is the websocket listen topic that the node should register on, which is important if there are multiple
 	// nodes using a single ethconnect
 	EthconnectConfigTopic = "topic"
@@ -51,8 +49,17 @@ const (
 	EthconnectPrefixShort = "prefixShort"
 	// EthconnectPrefixLong is used in HTTP headers in requests to ethconnect
 	EthconnectPrefixLong = "prefixLong"
-	// EthconnectConfigFromBlock is the configuration of the first block to listen to when creating the listener for the FireFly contract
-	EthconnectConfigFromBlock = "fromBlock"
+	// EthconnectConfigInstanceDeprecated is the ethereum address of the FireFly contract
+	EthconnectConfigInstanceDeprecated = "instance"
+	// EthconnectConfigFromBlockDeprecated is the configuration of the first block to listen to when creating the listener for the FireFly contract
+	EthconnectConfigFromBlockDeprecated = "fromBlock"
+
+	// FireFlyContractConfigKey is a sub-key in the config to contain the info on the deployed FireFly contract
+	FireFlyContractConfigKey = "fireflyContract"
+	// FireFlyContractAddress is the ethereum address of the FireFly contract
+	FireFlyContractAddress = "address"
+	// FireFlyContractFromBlock is the configuration of the first block to listen to when creating the listener
+	FireFlyContractFromBlock = "fromBlock"
 
 	// AddressResolverConfigKey is a sub-key in the config to contain an address resolver config.
 	AddressResolverConfigKey = "addressResolver"
@@ -76,15 +83,20 @@ const (
 )
 
 func (e *Ethereum) InitConfig(config config.Section) {
-	ethconnectConf := config.SubSection(EthconnectConfigKey)
-	wsclient.InitConfig(ethconnectConf)
-	ethconnectConf.AddKnownKey(EthconnectConfigInstancePath)
-	ethconnectConf.AddKnownKey(EthconnectConfigTopic)
-	ethconnectConf.AddKnownKey(EthconnectConfigBatchSize, defaultBatchSize)
-	ethconnectConf.AddKnownKey(EthconnectConfigBatchTimeout, defaultBatchTimeout)
-	ethconnectConf.AddKnownKey(EthconnectPrefixShort, defaultPrefixShort)
-	ethconnectConf.AddKnownKey(EthconnectPrefixLong, defaultPrefixLong)
-	ethconnectConf.AddKnownKey(EthconnectConfigFromBlock, defaultFromBlock)
+	e.ethconnectConf = config.SubSection(EthconnectConfigKey)
+	wsclient.InitConfig(e.ethconnectConf)
+	e.ethconnectConf.AddKnownKey(EthconnectConfigTopic)
+	e.ethconnectConf.AddKnownKey(EthconnectConfigBatchSize, defaultBatchSize)
+	e.ethconnectConf.AddKnownKey(EthconnectConfigBatchTimeout, defaultBatchTimeout)
+	e.ethconnectConf.AddKnownKey(EthconnectPrefixShort, defaultPrefixShort)
+	e.ethconnectConf.AddKnownKey(EthconnectPrefixLong, defaultPrefixLong)
+	e.ethconnectConf.AddKnownKey(EthconnectConfigInstanceDeprecated)
+	e.ethconnectConf.AddKnownKey(EthconnectConfigFromBlockDeprecated, defaultFromBlock)
+
+	e.contractConf = config.SubArray(FireFlyContractConfigKey)
+	e.contractConf.AddKnownKey(FireFlyContractAddress)
+	e.contractConf.AddKnownKey(FireFlyContractFromBlock, "oldest")
+	e.contractConfSize = e.contractConf.ArraySize()
 
 	fftmConf := config.SubSection(FFTMConfigKey)
 	ffresty.InitConfig(fftmConf)
diff --git a/internal/blockchain/ethereum/ethereum.go b/internal/blockchain/ethereum/ethereum.go
index 14e8de634..aed2f12dc 100644
--- a/internal/blockchain/ethereum/ethereum.go
+++ b/internal/blockchain/ethereum/ethereum.go
@@ -23,6 +23,7 @@ import (
 	"fmt"
 	"regexp"
 	"strings"
+	"sync"
 
 	"github.com/go-resty/resty/v2"
 	"github.com/hyperledger/firefly-common/pkg/config"
@@ -48,24 +49,29 @@ const (
 )
 
 type Ethereum struct {
-	ctx          context.Context
-	topic        string
-	instancePath string
-	prefixShort  string
-	prefixLong   string
-	capabilities *blockchain.Capabilities
-	callbacks    blockchain.Callbacks
-	client       *resty.Client
-	fftmClient   *resty.Client
-	streams      *streamManager
-	initInfo     struct {
+	ctx              context.Context
+	topic            string
+	fireflyContract  string
+	fireflyFromBlock string
+	fireflyMux       sync.Mutex
+	prefixShort      string
+	prefixLong       string
+	capabilities     *blockchain.Capabilities
+	callbacks        blockchain.Callbacks
+	client           *resty.Client
+	fftmClient       *resty.Client
+	streams          *streamManager
+	initInfo         struct {
 		stream *eventStream
 		sub    *subscription
 	}
-	wsconn          wsclient.WSClient
-	closed          chan struct{}
-	addressResolver *addressResolver
-	metrics         metrics.Manager
+	wsconn           wsclient.WSClient
+	closed           chan struct{}
+	addressResolver  *addressResolver
+	metrics          metrics.Manager
+	ethconnectConf   config.Section
+	contractConf     config.ArraySection
+	contractConfSize int
 }
 
 type eventStreamWebsocket struct {
@@ -157,7 +163,8 @@ func (e *Ethereum) VerifierType() core.VerifierType {
 }
 
 func (e *Ethereum) Init(ctx context.Context, config config.Section, callbacks blockchain.Callbacks, metrics metrics.Manager) (err error) {
-	ethconnectConf := config.SubSection(EthconnectConfigKey)
+	e.InitConfig(config)
+	ethconnectConf := e.ethconnectConf
 	addressResolverConf := config.SubSection(AddressResolverConfigKey)
 	fftmConf := config.SubSection(FFTMConfigKey)
 
@@ -173,68 +180,37 @@ func (e *Ethereum) Init(ctx context.Context, config config.Section, callbacks bl
 	}
 
 	if ethconnectConf.GetString(ffresty.HTTPConfigURL) == "" {
-		return i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "url", "blockchain.ethconnect")
+		return i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "url", "blockchain.ethereum.ethconnect")
 	}
-
 	e.client = ffresty.New(e.ctx, ethconnectConf)
 
 	if fftmConf.GetString(ffresty.HTTPConfigURL) != "" {
 		e.fftmClient = ffresty.New(e.ctx, fftmConf)
 	}
 
-	e.instancePath = ethconnectConf.GetString(EthconnectConfigInstancePath)
-	if e.instancePath == "" {
-		return i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "instance", "blockchain.ethconnect")
-	}
-
-	// Backwards compatibility from when instance path was not a contract address
-	if strings.HasPrefix(strings.ToLower(e.instancePath), "/contracts/") {
-		address, err := e.getContractAddress(ctx, e.instancePath)
-		if err != nil {
-			return err
-		}
-		e.instancePath = address
-	} else if strings.HasPrefix(e.instancePath, "/instances/") {
-		e.instancePath = strings.Replace(e.instancePath, "/instances/", "", 1)
-	}
-
-	// Ethconnect needs the "0x" prefix in some cases
-	if !strings.HasPrefix(e.instancePath, "0x") {
-		e.instancePath = fmt.Sprintf("0x%s", e.instancePath)
-	}
-
 	e.topic = ethconnectConf.GetString(EthconnectConfigTopic)
 	if e.topic == "" {
-		return i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "topic", "blockchain.ethconnect")
+		return i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "topic", "blockchain.ethereum.ethconnect")
 	}
-
 	e.prefixShort = ethconnectConf.GetString(EthconnectPrefixShort)
 	e.prefixLong = ethconnectConf.GetString(EthconnectPrefixLong)
 
 	wsConfig := wsclient.GenerateConfig(ethconnectConf)
-
 	if wsConfig.WSKeyPath == "" {
 		wsConfig.WSKeyPath = "/ws"
 	}
-
 	e.wsconn, err = wsclient.New(ctx, wsConfig, nil, e.afterConnect)
 	if err != nil {
 		return err
 	}
 
-	e.streams = &streamManager{
-		client:                       e.client,
-		fireFlySubscriptionFromBlock: ethconnectConf.GetString(EthconnectConfigFromBlock),
-	}
+	e.streams = &streamManager{client: e.client}
 	batchSize := ethconnectConf.GetUint(EthconnectConfigBatchSize)
 	batchTimeout := uint(ethconnectConf.GetDuration(EthconnectConfigBatchTimeout).Milliseconds())
 	if e.initInfo.stream, err = e.streams.ensureEventStream(e.ctx, e.topic, batchSize, batchTimeout); err != nil {
 		return err
 	}
 	log.L(e.ctx).Infof("Event stream: %s (topic=%s)", e.initInfo.stream.ID, e.topic)
-	if e.initInfo.sub, err = e.streams.ensureFireFlySubscription(e.ctx, e.instancePath, e.initInfo.stream.ID, batchPinEventABI); err != nil {
-		return err
-	}
 
 	e.closed = make(chan struct{})
 	go e.eventLoop()
@@ -242,10 +218,99 @@ func (e *Ethereum) Init(ctx context.Context, config config.Section, callbacks bl
 	return nil
 }
 
-func (e *Ethereum) Start() error {
+func (e *Ethereum) Start() (err error) {
 	return e.wsconn.Connect()
 }
 
+func (e *Ethereum) resolveFireFlyContract(ctx context.Context, contractIndex int) (address, fromBlock string, err error) {
+
+	if e.contractConfSize > 0 || contractIndex > 0 {
+		// New config (array of objects under "fireflyContract")
+		if contractIndex >= e.contractConfSize {
+			return "", "", i18n.NewError(ctx, coremsgs.MsgInvalidFireFlyContractIndex, fmt.Sprintf("blockchain.ethereum.fireflyContract[%d]", contractIndex))
+		}
+		entry := e.contractConf.ArrayEntry(contractIndex)
+		address = entry.GetString(FireFlyContractAddress)
+		if address == "" {
+			return "", "", i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "address", "blockchain.ethereum.fireflyContract")
+		}
+		fromBlock = entry.GetString(FireFlyContractFromBlock)
+	} else {
+		// Old config (attributes under "ethconnect")
+		address = e.ethconnectConf.GetString(EthconnectConfigInstanceDeprecated)
+		if address != "" {
+			log.L(ctx).Warnf("The %s.%s config key has been deprecated. Please use %s.%s instead",
+				EthconnectConfigKey, EthconnectConfigInstanceDeprecated,
+				FireFlyContractConfigKey, FireFlyContractAddress)
+		} else {
+			return "", "", i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "instance", "blockchain.ethereum.ethconnect")
+		}
+		fromBlock = e.ethconnectConf.GetString(EthconnectConfigFromBlockDeprecated)
+		if fromBlock != "" {
+			log.L(ctx).Warnf("The %s.%s config key has been deprecated. Please use %s.%s instead",
+				EthconnectConfigKey, EthconnectConfigFromBlockDeprecated,
+				FireFlyContractConfigKey, FireFlyContractFromBlock)
+		}
+	}
+
+	// Backwards compatibility from when instance path was not a contract address
+	if strings.HasPrefix(strings.ToLower(address), "/contracts/") {
+		address, err = e.getContractAddress(ctx, address)
+		if err != nil {
+			return "", "", err
+		}
+	} else if strings.HasPrefix(address, "/instances/") {
+		address = strings.Replace(address, "/instances/", "", 1)
+	}
+
+	address, err = validateEthAddress(ctx, address)
+	return address, fromBlock, err
+}
+
+func (e *Ethereum) ConfigureContract(ctx context.Context, contracts *core.FireFlyContracts) (err error) {
+
+	log.L(ctx).Infof("Resolving FireFly contract at index %d", contracts.Active.Index)
+	address, fromBlock, err := e.resolveFireFlyContract(ctx, contracts.Active.Index)
+	if err != nil {
+		return err
+	}
+
+	e.initInfo.sub, err = e.streams.ensureFireFlySubscription(ctx, address, fromBlock, e.initInfo.stream.ID, batchPinEventABI)
+	if err == nil {
+		e.fireflyMux.Lock()
+		e.fireflyContract = address
+		e.fireflyFromBlock = fromBlock
+		e.fireflyMux.Unlock()
+		contracts.Active.Info = fftypes.JSONObject{
+			"address":      address,
+			"fromBlock":    fromBlock,
+			"subscription": e.initInfo.sub.ID,
+		}
+	}
+	return err
+}
+
+func (e *Ethereum) TerminateContract(ctx context.Context, contracts *core.FireFlyContracts, termination *blockchain.Event) (err error) {
+
+	address, err := validateEthAddress(ctx, termination.Info.GetString("address"))
+	if err != nil {
+		return err
+	}
+	e.fireflyMux.Lock()
+	if address != e.fireflyContract {
+		log.L(ctx).Warnf("Ignoring termination request from address %s, which differs from active address %s", address, e.fireflyContract)
+		e.fireflyMux.Unlock()
+		return nil
+	}
+	e.fireflyMux.Unlock()
+
+	log.L(ctx).Infof("Processing termination request from address %s", address)
+	contracts.Active.FinalEvent = termination.ProtocolID
+	contracts.Terminated = append(contracts.Terminated, contracts.Active)
+	contracts.Active = core.FireFlyContractInfo{Index: contracts.Active.Index + 1}
+	return e.ConfigureContract(ctx, contracts)
+}
+
 func (e *Ethereum) Capabilities() *blockchain.Capabilities {
 	return e.capabilities
 }
@@ -273,31 +338,55 @@ func ethHexFormatB32(b *fftypes.Bytes32) string {
 	return "0x" + hex.EncodeToString(b[0:32])
 }
 
-func (e *Ethereum) handleBatchPinEvent(ctx context.Context, msgJSON fftypes.JSONObject) (err error) {
+func (e *Ethereum) parseBlockchainEvent(ctx context.Context, msgJSON fftypes.JSONObject) *blockchain.Event {
 	sBlockNumber := msgJSON.GetString("blockNumber")
 	sTransactionHash := msgJSON.GetString("transactionHash")
 	blockNumber := msgJSON.GetInt64("blockNumber")
 	txIndex := msgJSON.GetInt64("transactionIndex")
 	logIndex := msgJSON.GetInt64("logIndex")
 	dataJSON := msgJSON.GetObject("data")
-	authorAddress := dataJSON.GetString("author")
-	ns := dataJSON.GetString("namespace")
-	sUUIDs := dataJSON.GetString("uuids")
-	sBatchHash := dataJSON.GetString("batchHash")
-	sPayloadRef := dataJSON.GetString("payloadRef")
-	sContexts := dataJSON.GetStringArray("contexts")
+	signature := msgJSON.GetString("signature")
+	name := strings.SplitN(signature, "(", 2)[0]
 	timestampStr := msgJSON.GetString("timestamp")
 	timestamp, err := fftypes.ParseTimeString(timestampStr)
 	if err != nil {
-		log.L(ctx).Errorf("BatchPin event is not valid - missing timestamp: %+v", msgJSON)
+		log.L(ctx).Errorf("Blockchain event is not valid - missing timestamp: %+v", msgJSON)
 		return nil // move on
 	}
 
-	if sBlockNumber == "" ||
-		sTransactionHash == "" ||
-		authorAddress == "" ||
-		sUUIDs == "" ||
-		sBatchHash == "" {
+	if sBlockNumber == "" || sTransactionHash == "" {
+		log.L(ctx).Errorf("Blockchain event is not valid - missing data: %+v", msgJSON)
+		return nil // move on
+	}
+
+	delete(msgJSON, "data")
+	return &blockchain.Event{
+		BlockchainTXID: sTransactionHash,
+		Source:         e.Name(),
+		Name:           name,
+		ProtocolID:     fmt.Sprintf("%.12d/%.6d/%.6d", blockNumber, txIndex, logIndex),
+		Output:         dataJSON,
+		Info:           msgJSON,
+		Timestamp:      timestamp,
+		Location:       e.buildEventLocationString(msgJSON),
+		Signature:      signature,
+	}
+}
+
+func (e *Ethereum) handleBatchPinEvent(ctx context.Context, msgJSON fftypes.JSONObject) (err error) {
+	event := e.parseBlockchainEvent(ctx, msgJSON)
+	if event == nil {
+		return nil // move on
+	}
+
+	authorAddress := event.Output.GetString("author")
+	nsOrAction := event.Output.GetString("namespace")
+	sUUIDs := event.Output.GetString("uuids")
+	sBatchHash := event.Output.GetString("batchHash")
+	sPayloadRef := event.Output.GetString("payloadRef")
+	sContexts := event.Output.GetStringArray("contexts")
+
+	if authorAddress == "" || sUUIDs == "" || sBatchHash == "" {
 		log.L(ctx).Errorf("BatchPin event is not valid - missing data: %+v", msgJSON)
 		return nil // move on
 	}
@@ -307,6 +396,16 @@ func (e *Ethereum) handleBatchPinEvent(ctx context.Context, msgJSON fftypes.JSON
 		log.L(ctx).Errorf("BatchPin event is not valid - bad from address (%s): %+v", err, msgJSON)
 		return nil // move on
 	}
+	verifier := &core.VerifierRef{
+		Type:  core.VerifierTypeEthAddress,
+		Value: authorAddress,
+	}
+
+	// Check if this is actually an operator action
+	if strings.HasPrefix(nsOrAction, blockchain.FireFlyActionPrefix) {
+		action := nsOrAction[len(blockchain.FireFlyActionPrefix):]
+		return e.callbacks.BlockchainNetworkAction(action, event, verifier)
+	}
 
 	hexUUIDs, err := hex.DecodeString(strings.TrimPrefix(sUUIDs, "0x"))
 	if err != nil || len(hexUUIDs) != 32 {
@@ -336,67 +435,29 @@ func (e *Ethereum) handleBatchPinEvent(ctx context.Context, msgJSON fftypes.JSON
 		contexts[i] = &hash
 	}
 
-	delete(msgJSON, "data")
 	batch := &blockchain.BatchPin{
-		Namespace:       ns,
+		Namespace:       nsOrAction,
 		TransactionID:   &txnID,
 		BatchID:         &batchID,
 		BatchHash:       &batchHash,
 		BatchPayloadRef: sPayloadRef,
 		Contexts:        contexts,
-		Event: blockchain.Event{
-			BlockchainTXID: sTransactionHash,
-			Source:         e.Name(),
-			Name:           "BatchPin",
-			ProtocolID:     fmt.Sprintf("%.12d/%.6d/%.6d", blockNumber, txIndex, logIndex),
-			Output:         dataJSON,
-			Info:           msgJSON,
-			Timestamp:      timestamp,
-			Location:       e.buildEventLocationString(msgJSON),
-			Signature:      msgJSON.GetString("signature"),
-		},
+		Event:           *event,
 	}
 
 	// If there's an error dispatching the event, we must return the error and shutdown
-	return e.callbacks.BatchPinComplete(batch, &core.VerifierRef{
-		Type:  core.VerifierTypeEthAddress,
-		Value: authorAddress,
-	})
+	return e.callbacks.BatchPinComplete(batch, verifier)
 }
 
 func (e *Ethereum) handleContractEvent(ctx context.Context, msgJSON fftypes.JSONObject) (err error) {
-	sTransactionHash := msgJSON.GetString("transactionHash")
-	blockNumber := msgJSON.GetInt64("blockNumber")
-	txIndex := msgJSON.GetInt64("transactionIndex")
-	logIndex := msgJSON.GetInt64("logIndex")
-	sub := msgJSON.GetString("subId")
-	signature := msgJSON.GetString("signature")
-	dataJSON := msgJSON.GetObject("data")
-	name := strings.SplitN(signature, "(", 2)[0]
-	timestampStr := msgJSON.GetString("timestamp")
-	timestamp, err := fftypes.ParseTimeString(timestampStr)
-	if err != nil {
-		log.L(ctx).Errorf("Contract event is not valid - missing timestamp: %+v", msgJSON)
-		return err // move on
-	}
-	delete(msgJSON, "data")
-
-	event := &blockchain.EventWithSubscription{
-		Subscription: sub,
-		Event: blockchain.Event{
-			BlockchainTXID: sTransactionHash,
-			Source:         e.Name(),
-			Name:           name,
-			ProtocolID:     fmt.Sprintf("%.12d/%.6d/%.6d", blockNumber, txIndex, logIndex),
-			Output:         dataJSON,
-			Info:           msgJSON,
-			Timestamp:      timestamp,
-			Location:       e.buildEventLocationString(msgJSON),
-			Signature:      msgJSON.GetString("signature"),
-		},
+	event := e.parseBlockchainEvent(ctx, msgJSON)
+	if event != nil {
+		err = e.callbacks.BlockchainEvent(&blockchain.EventWithSubscription{
+			Event:        *event,
+			Subscription: msgJSON.GetString("subId"),
+		})
 	}
-
-	return e.callbacks.BlockchainEvent(event)
+	return err
 }
 
 func (e *Ethereum) handleReceipt(ctx context.Context, reply fftypes.JSONObject) {
@@ -447,6 +508,7 @@ func (e *Ethereum) handleMessageBatch(ctx context.Context, messages []interface{
 		l1.Tracef("Message: %+v", msgJSON)
 
 		if sub == e.initInfo.sub.ID {
+			// Matches the active FireFly BatchPin subscription
 			switch signature {
 			case broadcastBatchEventSignature:
 				if err := e.handleBatchPinEvent(ctx1, msgJSON); err != nil {
@@ -455,8 +517,12 @@ func (e *Ethereum) handleMessageBatch(ctx context.Context, messages []interface{
 			default:
 				l.Infof("Ignoring event with unknown signature: %s", signature)
 			}
-		} else if err := e.handleContractEvent(ctx1, msgJSON); err != nil {
-			return err
+		} else {
+			// Subscription not recognized - assume it's from a custom contract listener
+			// (event manager will reject it if it's not)
+			if err := e.handleContractEvent(ctx1, msgJSON); err != nil {
+				return err
+			}
 		}
 	}
 
@@ -599,7 +665,24 @@ func (e *Ethereum) SubmitBatchPin(ctx context.Context, operationID *fftypes.UUID
 		batch.BatchPayloadRef,
 		ethHashes,
 	}
-	return e.invokeContractMethod(ctx, e.instancePath, signingKey, batchPinMethodABI, operationID.String(), input)
+	e.fireflyMux.Lock()
+	address := e.fireflyContract
+	e.fireflyMux.Unlock()
+	return e.invokeContractMethod(ctx, address, signingKey, batchPinMethodABI, operationID.String(), input)
+}
+
+func (e *Ethereum) SubmitNetworkAction(ctx context.Context, operationID *fftypes.UUID, signingKey string, action core.NetworkActionType) error {
+	input := []interface{}{
+		blockchain.FireFlyActionPrefix + action,
+		ethHexFormatB32(nil),
+		ethHexFormatB32(nil),
+		"",
+		[]string{},
+	}
+	e.fireflyMux.Lock()
+	address := e.fireflyContract
+	e.fireflyMux.Unlock()
+	return e.invokeContractMethod(ctx, address, signingKey, batchPinMethodABI, operationID.String(), input)
 }
 
 func (e *Ethereum) InvokeContract(ctx context.Context, operationID *fftypes.UUID, signingKey string, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}) error {
diff --git a/internal/blockchain/ethereum/eventstream.go b/internal/blockchain/ethereum/eventstream.go
index 6e26a45da..4059a60a4 100644
--- a/internal/blockchain/ethereum/eventstream.go
+++ b/internal/blockchain/ethereum/eventstream.go
@@ -31,8 +31,6 @@ import (
 
 type streamManager struct {
 	client *resty.Client
-
-	fireFlySubscriptionFromBlock string
 }
 
 type eventStream struct {
@@ -172,7 +170,7 @@ func (s *streamManager) deleteSubscription(ctx context.Context, subID string) er
 	return nil
 }
 
-func (s *streamManager) ensureFireFlySubscription(ctx context.Context, instancePath, stream string, abi ABIElementMarshaling) (sub *subscription, err error) {
+func (s *streamManager) ensureFireFlySubscription(ctx context.Context, instancePath, fromBlock, stream string, abi ABIElementMarshaling) (sub *subscription, err error) {
 	// Include a hash of the instance path in the subscription, so if we ever point at a different
 	// contract configuration, we re-subscribe from block 0.
 	// We don't need full strength hashing, so just use the first 16 chars for readability.
@@ -200,7 +198,7 @@ func (s *streamManager) ensureFireFlySubscription(ctx context.Context, instanceP
 	}
 
 	if sub == nil {
-		if sub, err = s.createSubscription(ctx, location, stream, subName, s.fireFlySubscriptionFromBlock, abi); err != nil {
+		if sub, err = s.createSubscription(ctx, location, stream, subName, fromBlock, abi); err != nil {
 			return nil, err
 		}
 	}
diff --git a/internal/blockchain/fabric/config.go b/internal/blockchain/fabric/config.go
index fea3db8d1..86695309b 100644
--- a/internal/blockchain/fabric/config.go
+++ b/internal/blockchain/fabric/config.go
@@ -34,8 +34,6 @@ const (
 
 	// FabconnectConfigDefaultChannel is the default Fabric channel to use if no "ledger" is specified in requests
 	FabconnectConfigDefaultChannel = "channel"
-	// FabconnectConfigChaincode is the Fabric Firefly chaincode deployed to the Firefly channels
-	FabconnectConfigChaincode = "chaincode"
 	// FabconnectConfigSigner is the signer identity used to subscribe to FireFly chaincode events
 	FabconnectConfigSigner = "signer"
 	// FabconnectConfigTopic is the websocket listen topic that the node should register on, which is important if there are multiple
@@ -49,17 +47,31 @@ const (
 	FabconnectPrefixShort = "prefixShort"
 	// FabconnectPrefixLong is used in HTTP headers in requests to ethconnect
 	FabconnectPrefixLong = "prefixLong"
+	// FabconnectConfigChaincodeDeprecated is the Fabric Firefly chaincode deployed to the Firefly channels
+	FabconnectConfigChaincodeDeprecated = "chaincode"
+
+	// FireFlyContractConfigKey is a sub-key in the config to contain the info on the deployed FireFly contract
+	FireFlyContractConfigKey = "fireflyContract"
+	// FireFlyContractChaincode is the Fabric Firefly chaincode deployed to the Firefly channels
+	FireFlyContractChaincode = "chaincode"
+	// FireFlyContractFromBlock is the configuration of the first block to listen to when creating the listener
+	FireFlyContractFromBlock = "fromBlock"
 )
 
 func (f *Fabric) InitConfig(config config.Section) {
-	fabconnectConf := config.SubSection(FabconnectConfigKey)
-	wsclient.InitConfig(fabconnectConf)
-	fabconnectConf.AddKnownKey(FabconnectConfigDefaultChannel)
-	fabconnectConf.AddKnownKey(FabconnectConfigChaincode)
-	fabconnectConf.AddKnownKey(FabconnectConfigSigner)
-	fabconnectConf.AddKnownKey(FabconnectConfigTopic)
-	fabconnectConf.AddKnownKey(FabconnectConfigBatchSize, defaultBatchSize)
-	fabconnectConf.AddKnownKey(FabconnectConfigBatchTimeout, defaultBatchTimeout)
-	fabconnectConf.AddKnownKey(FabconnectPrefixShort, defaultPrefixShort)
-	fabconnectConf.AddKnownKey(FabconnectPrefixLong, defaultPrefixLong)
+	f.fabconnectConf = config.SubSection(FabconnectConfigKey)
+	wsclient.InitConfig(f.fabconnectConf)
+	f.fabconnectConf.AddKnownKey(FabconnectConfigDefaultChannel)
+	f.fabconnectConf.AddKnownKey(FabconnectConfigChaincodeDeprecated)
+	f.fabconnectConf.AddKnownKey(FabconnectConfigSigner)
+	f.fabconnectConf.AddKnownKey(FabconnectConfigTopic)
+	f.fabconnectConf.AddKnownKey(FabconnectConfigBatchSize, defaultBatchSize)
+	f.fabconnectConf.AddKnownKey(FabconnectConfigBatchTimeout, defaultBatchTimeout)
+	f.fabconnectConf.AddKnownKey(FabconnectPrefixShort, defaultPrefixShort)
+	f.fabconnectConf.AddKnownKey(FabconnectPrefixLong, defaultPrefixLong)
+
+	f.contractConf = config.SubArray(FireFlyContractConfigKey)
+	f.contractConf.AddKnownKey(FireFlyContractChaincode)
+	f.contractConf.AddKnownKey(FireFlyContractFromBlock, "oldest")
+	f.contractConfSize = f.contractConf.ArraySize()
 }
diff --git a/internal/blockchain/fabric/eventstream.go b/internal/blockchain/fabric/eventstream.go
index 14fceb613..325a2b931 100644
--- a/internal/blockchain/fabric/eventstream.go
+++ b/internal/blockchain/fabric/eventstream.go
@@ -150,7 +150,7 @@ func (s *streamManager) deleteSubscription(ctx context.Context, subID string) er
 	return nil
 }
 
-func (s *streamManager) ensureSubscription(ctx context.Context, location *Location, stream, event string) (sub *subscription, err error) {
+func (s *streamManager) ensureFireFlySubscription(ctx context.Context, location *Location, fromBlock, stream, event string) (sub *subscription, err error) {
 	existingSubs, err := s.getSubscriptions(ctx)
 	if err != nil {
 		return nil, err
@@ -164,7 +164,7 @@ func (s *streamManager) ensureSubscription(ctx context.Context, location *Locati
 	}
 
 	if sub == nil {
-		if sub, err = s.createSubscription(ctx, location, stream, subName, event, string(core.SubOptsFirstEventOldest)); err != nil {
+		if sub, err = s.createSubscription(ctx, location, stream, subName, event, fromBlock); err != nil {
 			return nil, err
 		}
 	}
diff --git a/internal/blockchain/fabric/fabric.go b/internal/blockchain/fabric/fabric.go
index 910849926..2932837ab 100644
--- a/internal/blockchain/fabric/fabric.go
+++ b/internal/blockchain/fabric/fabric.go
@@ -23,8 +23,8 @@ import (
 	"encoding/json"
 	"fmt"
 	"regexp"
-	"strconv"
 	"strings"
+	"sync"
 
 	"github.com/go-resty/resty/v2"
 	"github.com/hyperledger/firefly-common/pkg/config"
@@ -44,25 +44,30 @@ const (
 )
 
 type Fabric struct {
-	ctx            context.Context
-	topic          string
-	defaultChannel string
-	chaincode      string
-	signer         string
-	prefixShort    string
-	prefixLong     string
-	capabilities   *blockchain.Capabilities
-	callbacks      blockchain.Callbacks
-	client         *resty.Client
-	streams        *streamManager
-	initInfo       struct {
+	ctx              context.Context
+	topic            string
+	defaultChannel   string
+	fireflyChaincode string
+	fireflyFromBlock string
+	fireflyMux       sync.Mutex
+	signer           string
+	prefixShort      string
+	prefixLong       string
+	capabilities     *blockchain.Capabilities
+	callbacks        blockchain.Callbacks
+	client           *resty.Client
+	streams          *streamManager
+	initInfo         struct {
 		stream *eventStream
 		sub    *subscription
 	}
-	idCache map[string]*fabIdentity
-	wsconn  wsclient.WSClient
-	closed  chan struct{}
-	metrics metrics.Manager
+	idCache          map[string]*fabIdentity
+	wsconn           wsclient.WSClient
+	closed           chan struct{}
+	metrics          metrics.Manager
+	fabconnectConf   config.Section
+	contractConf     config.ArraySection
+	contractConfSize int
 }
 
 type eventStreamWebsocket struct {
@@ -161,7 +166,8 @@ func (f *Fabric) VerifierType() core.VerifierType {
 }
 
 func (f *Fabric) Init(ctx context.Context, config config.Section, callbacks blockchain.Callbacks, metrics metrics.Manager) (err error) {
-	fabconnectConf := config.SubSection(FabconnectConfigKey)
+	f.InitConfig(config)
+	fabconnectConf := f.fabconnectConf
 
 	f.ctx = log.WithLogField(ctx, "proto", "fabric")
 	f.callbacks = callbacks
@@ -170,53 +176,36 @@ func (f *Fabric) Init(ctx context.Context, config config.Section, callbacks bloc
 	f.capabilities = &blockchain.Capabilities{}
 
 	if fabconnectConf.GetString(ffresty.HTTPConfigURL) == "" {
-		return i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "url", "blockchain.fabconnect")
+		return i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "url", "blockchain.fabric.fabconnect")
 	}
+	f.client = ffresty.New(f.ctx, fabconnectConf)
+
 	f.defaultChannel = fabconnectConf.GetString(FabconnectConfigDefaultChannel)
-	f.chaincode = fabconnectConf.GetString(FabconnectConfigChaincode)
-	if f.chaincode == "" {
-		return i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "chaincode", "blockchain.fabconnect")
-	}
 	// the org identity is guaranteed to be configured by the core
 	f.signer = fabconnectConf.GetString(FabconnectConfigSigner)
 	f.topic = fabconnectConf.GetString(FabconnectConfigTopic)
 	if f.topic == "" {
-		return i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "topic", "blockchain.fabconnect")
+		return i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "topic", "blockchain.fabric.fabconnect")
 	}
-
 	f.prefixShort = fabconnectConf.GetString(FabconnectPrefixShort)
 	f.prefixLong = fabconnectConf.GetString(FabconnectPrefixLong)
 
-	f.client = ffresty.New(f.ctx, fabconnectConf)
-
 	wsConfig := wsclient.GenerateConfig(fabconnectConf)
-
 	if wsConfig.WSKeyPath == "" {
 		wsConfig.WSKeyPath = "/ws"
 	}
-
-	f.wsconn, err = wsclient.New(ctx, wsConfig, nil, f.afterConnect)
+	f.wsconn, err = wsclient.New(f.ctx, wsConfig, nil, f.afterConnect)
 	if err != nil {
 		return err
 	}
 
-	f.streams = &streamManager{
-		client: f.client,
-		signer: f.signer,
-	}
-	batchSize := fabconnectConf.GetUint(FabconnectConfigBatchSize)
-	batchTimeout := uint(fabconnectConf.GetDuration(FabconnectConfigBatchTimeout).Milliseconds())
+	f.streams = &streamManager{client: f.client, signer: f.signer}
+	batchSize := f.fabconnectConf.GetUint(FabconnectConfigBatchSize)
+	batchTimeout := uint(f.fabconnectConf.GetDuration(FabconnectConfigBatchTimeout).Milliseconds())
 	if f.initInfo.stream, err = f.streams.ensureEventStream(f.ctx, f.topic, batchSize, batchTimeout); err != nil {
 		return err
 	}
 	log.L(f.ctx).Infof("Event stream: %s", f.initInfo.stream.ID)
-	location := &Location{
-		Channel:   f.defaultChannel,
-		Chaincode: f.chaincode,
-	}
-	if f.initInfo.sub, err = f.streams.ensureSubscription(f.ctx, location, f.initInfo.stream.ID, batchPinEvent); err != nil {
-		return err
-	}
 
 	f.closed = make(chan struct{})
 	go f.eventLoop()
@@ -224,7 +213,76 @@ func (f *Fabric) Init(ctx context.Context, config config.Section, callbacks bloc
 	return nil
 }
 
-func (f *Fabric) Start() error {
+func (f *Fabric) resolveFireFlyContract(ctx context.Context, contractIndex int) (chaincode, fromBlock string, err error) {
+
+	if f.contractConfSize > 0 || contractIndex > 0 {
+		// New config (array of objects under "fireflyContract")
+		if contractIndex >= f.contractConfSize {
+			return "", "", i18n.NewError(ctx, coremsgs.MsgInvalidFireFlyContractIndex, fmt.Sprintf("blockchain.fabric.fireflyContract[%d]", contractIndex))
+		}
+		chaincode = f.contractConf.ArrayEntry(contractIndex).GetString(FireFlyContractChaincode)
+		if chaincode == "" {
+			return "", "", i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "address", "blockchain.fabric.fireflyContract")
+		}
+		fromBlock = f.contractConf.ArrayEntry(contractIndex).GetString(FireFlyContractFromBlock)
+	} else {
+		// Old config (attributes under "ethconnect")
+		chaincode = f.fabconnectConf.GetString(FabconnectConfigChaincodeDeprecated)
+		if chaincode != "" {
+			log.L(ctx).Warnf("The %s.%s config key has been deprecated. Please use %s.%s instead",
+				FabconnectConfigKey, FabconnectConfigChaincodeDeprecated,
+				FireFlyContractConfigKey, FireFlyContractChaincode)
+			fromBlock = string(core.SubOptsFirstEventOldest)
+		} else {
+			return "", "", i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "chaincode", "blockchain.fabric.fabconnect")
+		}
+	}
+
+	return chaincode, fromBlock, nil
+}
+
+func (f *Fabric) ConfigureContract(ctx context.Context, contracts *core.FireFlyContracts) (err error) {
+
+	chaincode, fromBlock, err := f.resolveFireFlyContract(ctx, contracts.Active.Index)
+	if err != nil {
+		return err
+	}
+
+	location := &Location{Channel: f.defaultChannel, Chaincode: chaincode}
+	f.initInfo.sub, err = f.streams.ensureFireFlySubscription(ctx, location, fromBlock, f.initInfo.stream.ID, batchPinEvent)
+	if err == nil {
+		f.fireflyMux.Lock()
+		f.fireflyChaincode = chaincode
+		f.fireflyFromBlock = fromBlock
+		f.fireflyMux.Unlock()
+		contracts.Active.Info = fftypes.JSONObject{
+			"chaincode":    chaincode,
+			"fromBlock":    fromBlock,
+			"subscription": f.initInfo.sub.ID,
+		}
+	}
+	return err
+}
+
+func (f *Fabric) TerminateContract(ctx context.Context, contracts *core.FireFlyContracts, termination *blockchain.Event) (err error) {
+
+	chaincode := termination.Info.GetString("chaincodeId")
+	f.fireflyMux.Lock()
+	if chaincode != f.fireflyChaincode {
+		log.L(ctx).Warnf("Ignoring termination request from chaincode %s, which differs from active chaincode %s", chaincode, f.fireflyChaincode)
+		f.fireflyMux.Unlock()
+		return nil
+	}
+	f.fireflyMux.Unlock()
+
+	log.L(ctx).Infof("Processing termination request from chaincode %s", chaincode)
+	contracts.Active.FinalEvent = termination.ProtocolID
+	contracts.Terminated = append(contracts.Terminated, contracts.Active)
+	contracts.Active = core.FireFlyContractInfo{Index: contracts.Active.Index + 1}
+	return f.ConfigureContract(ctx, contracts)
+}
+
+func (f *Fabric) Start() (err error) {
 	return f.wsconn.Connect()
 }
 
@@ -263,7 +321,7 @@ func decodeJSONPayload(ctx context.Context, payloadString string) *fftypes.JSONO
 	return &payload
 }
 
-func (f *Fabric) handleBatchPinEvent(ctx context.Context, msgJSON fftypes.JSONObject) (err error) {
+func (f *Fabric) parseBlockchainEvent(ctx context.Context, msgJSON fftypes.JSONObject) *blockchain.Event {
 	payloadString := msgJSON.GetString("payload")
 	payload := decodeJSONPayload(ctx, payloadString)
 	if payload == nil {
@@ -274,13 +332,47 @@ func (f *Fabric) handleBatchPinEvent(ctx context.Context, msgJSON fftypes.JSONOb
 	blockNumber := msgJSON.GetInt64("blockNumber")
 	transactionIndex := msgJSON.GetInt64("transactionIndex")
 	eventIndex := msgJSON.GetInt64("eventIndex")
+	name := msgJSON.GetString("eventName")
 	timestamp := msgJSON.GetInt64("timestamp")
-	signer := payload.GetString("signer")
-	ns := payload.GetString("namespace")
-	sUUIDs := payload.GetString("uuids")
-	sBatchHash := payload.GetString("batchHash")
-	sPayloadRef := payload.GetString("payloadRef")
-	sContexts := payload.GetStringArray("contexts")
+	chaincode := msgJSON.GetString("chaincodeId")
+
+	delete(msgJSON, "payload")
+	return &blockchain.Event{
+		BlockchainTXID: sTransactionHash,
+		Source:         f.Name(),
+		Name:           name,
+		ProtocolID:     fmt.Sprintf("%.12d/%.6d/%.6d", blockNumber, transactionIndex, eventIndex),
+		Output:         *payload,
+		Info:           msgJSON,
+		Timestamp:      fftypes.UnixTime(timestamp),
+		Location:       f.buildEventLocationString(chaincode),
+		Signature:      name,
+	}
+}
+
+func (f *Fabric) handleBatchPinEvent(ctx context.Context, msgJSON fftypes.JSONObject) (err error) {
+	event := f.parseBlockchainEvent(ctx, msgJSON)
+	if event == nil {
+		return nil // move on
+	}
+
+	signer := event.Output.GetString("signer")
+	nsOrAction := event.Output.GetString("namespace")
+	sUUIDs := event.Output.GetString("uuids")
+	sBatchHash := event.Output.GetString("batchHash")
+	sPayloadRef := event.Output.GetString("payloadRef")
+	sContexts := event.Output.GetStringArray("contexts")
+
+	verifier := &core.VerifierRef{
+		Type:  core.VerifierTypeMSPIdentity,
+		Value: signer,
+	}
+
+	// Check if this is actually an operator action
+	if strings.HasPrefix(nsOrAction, blockchain.FireFlyActionPrefix) {
+		action := nsOrAction[len(blockchain.FireFlyActionPrefix):]
+		return f.callbacks.BlockchainNetworkAction(action, event, verifier)
+	}
 
 	hexUUIDs, err := hex.DecodeString(strings.TrimPrefix(sUUIDs, "0x"))
 	if err != nil || len(hexUUIDs) != 32 {
@@ -310,75 +402,33 @@ func (f *Fabric) handleBatchPinEvent(ctx context.Context, msgJSON fftypes.JSONOb
 		contexts[i] = &hash
 	}
 
-	delete(msgJSON, "payload")
 	batch := &blockchain.BatchPin{
-		Namespace:       ns,
+		Namespace:       nsOrAction,
 		TransactionID:   &txnID,
 		BatchID:         &batchID,
 		BatchHash:       &batchHash,
 		BatchPayloadRef: sPayloadRef,
 		Contexts:        contexts,
-		Event: blockchain.Event{
-			BlockchainTXID: sTransactionHash,
-			Source:         f.Name(),
-			Name:           "BatchPin",
-			ProtocolID:     fmt.Sprintf("%.12d/%.6d/%.6d", blockNumber, transactionIndex, eventIndex),
-			Output:         *payload,
-			Info:           msgJSON,
-			Timestamp:      fftypes.UnixTime(timestamp),
-			Location:       f.buildEventLocationString(msgJSON),
-			Signature:      "BatchPin",
-		},
+		Event:           *event,
 	}
 
 	// If there's an error dispatching the event, we must return the error and shutdown
-	return f.callbacks.BatchPinComplete(batch, &core.VerifierRef{
-		Type:  core.VerifierTypeMSPIdentity,
-		Value: signer,
-	})
+	return f.callbacks.BatchPinComplete(batch, verifier)
 }
 
-func (f *Fabric) buildEventLocationString(msgJSON fftypes.JSONObject) string {
-	return fmt.Sprintf("chaincode=%s", msgJSON.GetString("chaincodeId"))
+func (f *Fabric) buildEventLocationString(chaincode string) string {
+	return fmt.Sprintf("chaincode=%s", chaincode)
 }
 
 func (f *Fabric) handleContractEvent(ctx context.Context, msgJSON fftypes.JSONObject) (err error) {
-	payloadString := msgJSON.GetString("payload")
-	payload := decodeJSONPayload(ctx, payloadString)
-	if payload == nil {
+	event := f.parseBlockchainEvent(ctx, msgJSON)
+	if event == nil {
 		return nil // move on
 	}
-	delete(msgJSON, "payload")
-
-	sTransactionHash := msgJSON.GetString("transactionId")
-	blockNumber := msgJSON.GetInt64("blockNumber")
-	transactionIndex := msgJSON.GetInt64("transactionIndex")
-	eventIndex := msgJSON.GetInt64("eventIndex")
-	sub := msgJSON.GetString("subId")
-	name := msgJSON.GetString("eventName")
-	sTimestamp := msgJSON.GetString("timestamp")
-	timestamp, err := strconv.ParseInt(sTimestamp, 10, 64)
-	if err != nil {
-		log.L(ctx).Errorf("Blockchain event is not valid - bad timestamp (%s): %s", sTimestamp, err)
-		// Continue with zero timestamp
-	}
-
-	event := &blockchain.EventWithSubscription{
-		Subscription: sub,
-		Event: blockchain.Event{
-			BlockchainTXID: sTransactionHash,
-			Source:         f.Name(),
-			Name:           name,
-			ProtocolID:     fmt.Sprintf("%.12d/%.6d/%.6d", blockNumber, transactionIndex, eventIndex),
-			Output:         *payload,
-			Info:           msgJSON,
-			Timestamp:      fftypes.UnixTime(timestamp),
-			Location:       f.buildEventLocationString(msgJSON),
-			Signature:      name,
-		},
-	}
-
-	return f.callbacks.BlockchainEvent(event)
+	return f.callbacks.BlockchainEvent(&blockchain.EventWithSubscription{
+		Event:        *event,
+		Subscription: msgJSON.GetString("subId"),
+	})
 }
 
 func (f *Fabric) handleReceipt(ctx context.Context, reply fftypes.JSONObject) {
@@ -425,6 +475,7 @@ func (f *Fabric) handleMessageBatch(ctx context.Context, messages []interface{})
 		l1.Tracef("Message: %+v", msgJSON)
 
 		if sub == f.initInfo.sub.ID {
+			// Matches the active FireFly BatchPin subscription
 			switch eventName {
 			case broadcastBatchEventName:
 				if err := f.handleBatchPinEvent(ctx1, msgJSON); err != nil {
@@ -433,8 +484,12 @@ func (f *Fabric) handleMessageBatch(ctx context.Context, messages []interface{})
 			default:
 				l.Infof("Ignoring event with unknown name: %s", eventName)
 			}
-		} else if err := f.handleContractEvent(ctx, msgJSON); err != nil {
-			return err
+		} else {
+			// Subscription not recognized - assume it's from a custom contract listener
+			// (event manager will reject it if it's not)
+			if err := f.handleContractEvent(ctx, msgJSON); err != nil {
+				return err
+			}
 		}
 	}
 
@@ -585,9 +640,26 @@ func (f *Fabric) SubmitBatchPin(ctx context.Context, operationID *fftypes.UUID,
 		"payloadRef": batch.BatchPayloadRef,
 		"contexts":   hashes,
 	}
+	input, _ := jsonEncodeInput(pinInput)
+	f.fireflyMux.Lock()
+	chaincode := f.fireflyChaincode
+	f.fireflyMux.Unlock()
+	return f.invokeContractMethod(ctx, f.defaultChannel, chaincode, batchPinMethodName, signingKey, operationID.String(), batchPinPrefixItems, input)
+}
 
+func (f *Fabric) SubmitNetworkAction(ctx context.Context, operationID *fftypes.UUID, signingKey string, action core.NetworkActionType) error {
+	pinInput := map[string]interface{}{
+		"namespace":  "firefly:" + action,
+		"uuids":      hexFormatB32(nil),
+		"batchHash":  hexFormatB32(nil),
+		"payloadRef": "",
+		"contexts":   []string{},
+	}
 	input, _ := jsonEncodeInput(pinInput)
-	return f.invokeContractMethod(ctx, f.defaultChannel, f.chaincode, batchPinMethodName, signingKey, operationID.String(), batchPinPrefixItems, input)
+	f.fireflyMux.Lock()
+	chaincode := f.fireflyChaincode
+	f.fireflyMux.Unlock()
+	return f.invokeContractMethod(ctx, f.defaultChannel, chaincode, batchPinMethodName, signingKey, operationID.String(), batchPinPrefixItems, input)
 }
 
 func (f *Fabric) InvokeContract(ctx context.Context, operationID *fftypes.UUID, signingKey string, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}) error {
diff --git a/internal/coremsgs/en_api_translations.go b/internal/coremsgs/en_api_translations.go
index c68b33a8e..ff53723e3 100644
--- a/internal/coremsgs/en_api_translations.go
+++ b/internal/coremsgs/en_api_translations.go
@@ -168,6 +168,7 @@ var (
 	APIEndpointsPutContractAPI                  = ffm("api.endpoints.putContractAPI", "Updates an existing contract API")
 	APIEndpointsPutSubscription                 = ffm("api.endpoints.putSubscription", "Update an existing subscription")
 	APIEndpointsGetContractAPIInterface         = ffm("api.endpoints.getContractAPIInterface", "Gets a contract interface for a contract API")
+	APIEndpointsPostNetworkAction               = ffm("api.endpoints.postNetworkAction", "Notify all nodes in the network of a new governance action")
 
 	APISuccessResponse         = ffm("api.success", "Success")
 	APIRequestTimeoutDesc      = ffm("api.requestTimeout", "Server-side request timeout (millseconds, or set a custom suffix like 10s)")
diff --git a/internal/coremsgs/en_config_descriptions.go b/internal/coremsgs/en_config_descriptions.go
index 4ad572d7e..6200ae7c2 100644
--- a/internal/coremsgs/en_config_descriptions.go
+++ b/internal/coremsgs/en_config_descriptions.go
@@ -70,29 +70,33 @@ var (
 
 	ConfigBlockchainEthereumEthconnectBatchSize    = ffc("config.blockchain.ethereum.ethconnect.batchSize", "The number of events Ethconnect should batch together for delivery to FireFly core. Only applies when automatically creating a new event stream", i18n.IntType)
 	ConfigBlockchainEthereumEthconnectBatchTimeout = ffc("config.blockchain.ethereum.ethconnect.batchTimeout", "How long Ethconnect should wait for new events to arrive and fill a batch, before sending the batch to FireFly core. Only applies when automatically creating a new event stream", i18n.TimeDurationType)
-	ConfigBlockchainEthereumEthconnectInstance     = ffc("config.blockchain.ethereum.ethconnect.instance", "The Ethereum address of the FireFly BatchPin smart contract that has been deployed to the blockchain", "Address "+i18n.StringType)
-	ConfigBlockchainEthereumEthconnectFromBlock    = ffc("config.blockchain.ethereum.ethconnect.fromBlock", "The first event this FireFly instance should listen to from the BatchPin smart contract. Default=0. Only affects initial creation of the event stream", "Address "+i18n.StringType)
+	ConfigBlockchainEthereumEthconnectInstance     = ffc("config.blockchain.ethereum.ethconnect.instance", "The Ethereum address of the FireFly BatchPin smart contract that has been deployed to the blockchain (deprecated - use fireflyContract[].address)", "Address "+i18n.StringType)
+	ConfigBlockchainEthereumEthconnectFromBlock    = ffc("config.blockchain.ethereum.ethconnect.fromBlock", "The first event this FireFly instance should listen to from the BatchPin smart contract. Default=0. Only affects initial creation of the event stream (deprecated - use fireflyContract[].fromBlock)", "Address "+i18n.StringType)
 	ConfigBlockchainEthereumEthconnectPrefixLong   = ffc("config.blockchain.ethereum.ethconnect.prefixLong", "The prefix that will be used for Ethconnect specific HTTP headers when FireFly makes requests to Ethconnect", i18n.StringType)
 	ConfigBlockchainEthereumEthconnectPrefixShort  = ffc("config.blockchain.ethereum.ethconnect.prefixShort", "The prefix that will be used for Ethconnect specific query parameters when FireFly makes requests to Ethconnect", i18n.StringType)
 	ConfigBlockchainEthereumEthconnectTopic        = ffc("config.blockchain.ethereum.ethconnect.topic", "The websocket listen topic that the node should register on, which is important if there are multiple nodes using a single ethconnect", i18n.StringType)
 	ConfigBlockchainEthereumEthconnectURL          = ffc("config.blockchain.ethereum.ethconnect.url", "The URL of the Ethconnect instance", "URL "+i18n.StringType)
+	ConfigBlockchainEthereumEthconnectProxyURL     = ffc("config.blockchain.ethereum.ethconnect.proxy.url", "Optional HTTP proxy server to use when connecting to Ethconnect", "URL "+i18n.StringType)
+
+	ConfigBlockchainEthereumContractAddress   = ffc("config.blockchain.ethereum.fireflyContract[].address", "The Ethereum address of the FireFly BatchPin smart contract that has been deployed to the blockchain", "Address "+i18n.StringType)
+	ConfigBlockchainEthereumContractFromBlock = ffc("config.blockchain.ethereum.fireflyContract[].fromBlock", "The first event this FireFly instance should listen to from the BatchPin smart contract. Default=0. Only affects initial creation of the event stream", "Address "+i18n.StringType)
 
 	ConfigBlockchainEthereumFFTMURL      = ffc("config.blockchain.ethereum.fftm.url", "The URL of the FireFly Transaction Manager runtime, if enabled", i18n.StringType)
 	ConfigBlockchainEthereumFFTMProxyURL = ffc("config.blockchain.ethereum.fftm.proxy.url", "Optional HTTP proxy server to use when connecting to the Transaction Manager", i18n.StringType)
 
-	ConfigBlockchainEthereumEthconnectProxyURL = ffc("config.blockchain.ethereum.ethconnect.proxy.url", "Optional HTTP proxy server to use when connecting to Ethconnect", "URL "+i18n.StringType)
-
 	ConfigBlockchainFabricFabconnectBatchSize    = ffc("config.blockchain.fabric.fabconnect.batchSize", "The number of events Fabconnect should batch together for delivery to FireFly core. Only applies when automatically creating a new event stream", i18n.IntType)
 	ConfigBlockchainFabricFabconnectBatchTimeout = ffc("config.blockchain.fabric.fabconnect.batchTimeout", "The maximum amount of time to wait for a batch to complete", i18n.TimeDurationType)
-	ConfigBlockchainFabricFabconnectChaincode    = ffc("config.blockchain.fabric.fabconnect.chaincode", "The name of the Fabric chaincode that FireFly will use for BatchPin transactions", i18n.StringType)
+	ConfigBlockchainFabricFabconnectChaincode    = ffc("config.blockchain.fabric.fabconnect.chaincode", "The name of the Fabric chaincode that FireFly will use for BatchPin transactions (deprecated - use fireflyContract[].chaincode)", i18n.StringType)
 	ConfigBlockchainFabricFabconnectChannel      = ffc("config.blockchain.fabric.fabconnect.channel", "The Fabric channel that FireFly will use for BatchPin transactions", i18n.StringType)
 	ConfigBlockchainFabricFabconnectPrefixLong   = ffc("config.blockchain.fabric.fabconnect.prefixLong", "The prefix that will be used for Fabconnect specific HTTP headers when FireFly makes requests to Fabconnect", i18n.StringType)
 	ConfigBlockchainFabricFabconnectPrefixShort  = ffc("config.blockchain.fabric.fabconnect.prefixShort", "The prefix that will be used for Fabconnect specific query parameters when FireFly makes requests to Fabconnect", i18n.StringType)
 	ConfigBlockchainFabricFabconnectSigner       = ffc("config.blockchain.fabric.fabconnect.signer", "The Fabric signing key to use when submitting transactions to Fabconnect", i18n.StringType)
 	ConfigBlockchainFabricFabconnectTopic        = ffc("config.blockchain.fabric.fabconnect.topic", "The websocket listen topic that the node should register on, which is important if there are multiple nodes using a single Fabconnect", i18n.StringType)
 	ConfigBlockchainFabricFabconnectURL          = ffc("config.blockchain.fabric.fabconnect.url", "The URL of the Fabconnect instance", "URL "+i18n.StringType)
+	ConfigBlockchainFabricFabconnectProxyURL     = ffc("config.blockchain.fabric.fabconnect.proxy.url", "Optional HTTP proxy server to use when connecting to Fabconnect", "URL "+i18n.StringType)
 
-	ConfigBlockchainFabricFabconnectProxyURL = ffc("config.blockchain.fabric.fabconnect.proxy.url", "Optional HTTP proxy server to use when connecting to Fabconnect", "URL "+i18n.StringType)
+	ConfigBlockchainFabricContractChaincode = ffc("config.blockchain.fabric.fireflyContract[].chaincode", "The name of the Fabric chaincode that FireFly will use for BatchPin transactions", i18n.StringType)
+	ConfigBlockchainFabricContractFromBlock = ffc("config.blockchain.fabric.fireflyContract[].fromBlock", "The first event this FireFly instance should listen to from the BatchPin chaincode. Default=0. Only affects initial creation of the event stream", "Address "+i18n.StringType)
 
 	ConfigPluginDatabase     = ffc("config.plugins.database", "The list of configured Database plugins", i18n.StringType)
 	ConfigPluginDatabaseName = ffc("config.plugins.database[].name", "The name of the Database plugin", i18n.StringType)
@@ -137,23 +141,27 @@ var (
 	ConfigPluginBlockchainEthereumEthconnectPrefixShort  = ffc("config.plugins.blockchain[].ethereum.ethconnect.prefixShort", "The prefix that will be used for Ethconnect specific query parameters when FireFly makes requests to Ethconnect", i18n.StringType)
 	ConfigPluginBlockchainEthereumEthconnectTopic        = ffc("config.plugins.blockchain[].ethereum.ethconnect.topic", "The websocket listen topic that the node should register on, which is important if there are multiple nodes using a single ethconnect", i18n.StringType)
 	ConfigPluginBlockchainEthereumEthconnectURL          = ffc("config.plugins.blockchain[].ethereum.ethconnect.url", "The URL of the Ethconnect instance", "URL "+i18n.StringType)
+	ConfigPluginBlockchainEthereumEthconnectProxyURL     = ffc("config.plugins.blockchain[].ethereum.ethconnect.proxy.url", "Optional HTTP proxy server to use when connecting to Ethconnect", "URL "+i18n.StringType)
+
+	ConfigPluginBlockchainEthereumContractAddress   = ffc("config.plugins.blockchain[].ethereum.fireflyContract[].address", "The Ethereum address of the FireFly BatchPin smart contract that has been deployed to the blockchain", "Address "+i18n.StringType)
+	ConfigPluginBlockchainEthereumContractFromBlock = ffc("config.plugins.blockchain[].ethereum.fireflyContract[].fromBlock", "The first event this FireFly instance should listen to from the BatchPin smart contract. Default=0. Only affects initial creation of the event stream", "Address "+i18n.StringType)
 
 	ConfigPluginBlockchainEthereumFFTMURL      = ffc("config.plugins.blockchain[].ethereum.fftm.url", "The URL of the FireFly Transaction Manager runtime, if enabled", i18n.StringType)
 	ConfigPluginBlockchainEthereumFFTMProxyURL = ffc("config.plugins.blockchain[].ethereum.fftm.proxy.url", "Optional HTTP proxy server to use when connecting to the Transaction Manager", i18n.StringType)
 
-	ConfigPluginBlockchainEthereumEthconnectProxyURL = ffc("config.plugins.blockchain[].ethereum.ethconnect.proxy.url", "Optional HTTP proxy server to use when connecting to Ethconnect", "URL "+i18n.StringType)
-
 	ConfigPluginBlockchainFabricFabconnectBatchSize    = ffc("config.plugins.blockchain[].fabric.fabconnect.batchSize", "The number of events Fabconnect should batch together for delivery to FireFly core. Only applies when automatically creating a new event stream", i18n.IntType)
 	ConfigPluginBlockchainFabricFabconnectBatchTimeout = ffc("config.plugins.blockchain[].fabric.fabconnect.batchTimeout", "The maximum amount of time to wait for a batch to complete", i18n.TimeDurationType)
-	ConfigPluginBlockchainFabricFabconnectChaincode    = ffc("config.plugins.blockchain[].fabric.fabconnect.chaincode", "The name of the Fabric chaincode that FireFly will use for BatchPin transactions", i18n.StringType)
+	ConfigPluginBlockchainFabricFabconnectChaincode    = ffc("config.plugins.blockchain[].fabric.fabconnect.chaincode", "The name of the Fabric chaincode that FireFly will use for BatchPin transactions (deprecated - use fireflyContract[].chaincode)", i18n.StringType)
 	ConfigPluginBlockchainFabricFabconnectChannel      = ffc("config.plugins.blockchain[].fabric.fabconnect.channel", "The Fabric channel that FireFly will use for BatchPin transactions", i18n.StringType)
 	ConfigPluginBlockchainFabricFabconnectPrefixLong   = ffc("config.plugins.blockchain[].fabric.fabconnect.prefixLong", "The prefix that will be used for Fabconnect specific HTTP headers when FireFly makes requests to Fabconnect", i18n.StringType)
 	ConfigPluginBlockchainFabricFabconnectPrefixShort  = ffc("config.plugins.blockchain[].fabric.fabconnect.prefixShort", "The prefix that will be used for Fabconnect specific query parameters when FireFly makes requests to Fabconnect", i18n.StringType)
 	ConfigPluginBlockchainFabricFabconnectSigner       = ffc("config.plugins.blockchain[].fabric.fabconnect.signer", "The Fabric signing key to use when submitting transactions to Fabconnect", i18n.StringType)
 	ConfigPluginBlockchainFabricFabconnectTopic        = ffc("config.plugins.blockchain[].fabric.fabconnect.topic", "The websocket listen topic that the node should register on, which is important if there are multiple nodes using a single Fabconnect", i18n.StringType)
 	ConfigPluginBlockchainFabricFabconnectURL          = ffc("config.plugins.blockchain[].fabric.fabconnect.url", "The URL of the Fabconnect instance", "URL "+i18n.StringType)
+	ConfigPluginBlockchainFabricFabconnectProxyURL     = ffc("config.plugins.blockchain[].fabric.fabconnect.proxy.url", "Optional HTTP proxy server to use when connecting to Fabconnect", "URL "+i18n.StringType)
 
-	ConfigPluginBlockchainFabricFabconnectProxyURL = ffc("config.plugins.blockchain[].fabric.fabconnect.proxy.url", "Optional HTTP proxy server to use when connecting to Fabconnect", "URL "+i18n.StringType)
+	ConfigPluginBlockchainFabricContractChaincode = ffc("config.plugins.blockchain[].fabric.fireflyContract[].chaincode", "The name of the Fabric chaincode that FireFly will use for BatchPin transactions", i18n.StringType)
+	ConfigPluginBlockchainFabricContractFromBlock = ffc("config.plugins.blockchain[].fabric.fireflyContract[].fromBlock", "The first event this FireFly instance should listen to from the BatchPin chaincode. Default=0. Only affects initial creation of the event stream", "Address "+i18n.StringType)
 
 	ConfigBroadcastBatchAgentTimeout = ffc("config.broadcast.batch.agentTimeout", "How long to keep around a batching agent for a sending identity before disposal", i18n.StringType)
 	ConfigBroadcastBatchPayloadLimit = ffc("config.broadcast.batch.payloadLimit", "The maximum payload size of a batch for broadcast messages", i18n.ByteSizeType)
diff --git a/internal/coremsgs/en_error_messages.go b/internal/coremsgs/en_error_messages.go
index 0a2d19f94..0b73b35fa 100644
--- a/internal/coremsgs/en_error_messages.go
+++ b/internal/coremsgs/en_error_messages.go
@@ -242,4 +242,6 @@ var (
 	MsgNamespaceGatewayInvalidPlugins     = ffe("FF10393", "Invalid %s gateway namespace configuration - cannot specify dataexchange or shared storage plugins")
 	MsgNamespaceGatewayMultiplePluginType = ffe("FF10394", "Invalid %s namespace configuration - multiple %s plugins provided")
 	MsgDuplicatePluginName                = ffe("FF10395", "Invalid plugin configuration - plugin with name %s already exists")
+	MsgInvalidFireFlyContractIndex        = ffe("FF10396", "No configuration found for FireFly contract at %s")
+	MsgUnrecognizedNetworkAction          = ffe("FF10397", "Unrecognized network action: %s", 400)
 )
diff --git a/internal/coremsgs/en_struct_descriptions.go b/internal/coremsgs/en_struct_descriptions.go
index d70d2ba8e..0b57a2649 100644
--- a/internal/coremsgs/en_struct_descriptions.go
+++ b/internal/coremsgs/en_struct_descriptions.go
@@ -371,12 +371,19 @@ var (
 	VerifierCreated   = ffm("Verifier.created", "The time this verifier was created on this node")
 
 	// Namespace field descriptions
-	NamespaceID          = ffm("Namespace.id", "The UUID of the namespace. For locally established namespaces will be different on each node in the network. For broadcast namespaces, will be the same on every node")
-	NamespaceMessage     = ffm("Namespace.message", "The UUID of broadcast message used to establish the namespace. Unset for local namespaces")
-	NamespaceName        = ffm("Namespace.name", "The namespace name")
-	NamespaceDescription = ffm("Namespace.description", "A description of the namespace")
-	NamespaceType        = ffm("Namespace.type", "The type of the namespace")
-	NamespaceCreated     = ffm("Namespace.created", "The time the namespace was created")
+	NamespaceID                = ffm("Namespace.id", "The UUID of the namespace. For locally established namespaces will be different on each node in the network. For broadcast namespaces, will be the same on every node")
+	NamespaceMessage           = ffm("Namespace.message", "The UUID of broadcast message used to establish the namespace. Unset for local namespaces")
+	NamespaceName              = ffm("Namespace.name", "The namespace name")
+	NamespaceDescription       = ffm("Namespace.description", "A description of the namespace")
+	NamespaceType              = ffm("Namespace.type", "The type of the namespace")
+	NamespaceCreated           = ffm("Namespace.created", "The time the namespace was created")
+	NamespaceContract          = ffm("Namespace.fireflyContract", "Info on the FireFly smart contract configured for this namespace")
+	FireFlyContractsActive     = ffm("FireFlyContracts.active", "The currently active FireFly smart contract")
+	FireFlyContractsTerminated = ffm("FireFlyContracts.terminated", "Previously-terminated FireFly smart contracts")
+	FireFlyContractIndex       = ffm("FireFlyContractInfo.index", "The index of this contract in the config file")
+	FireFlyContractFinalEvent  = ffm("FireFlyContractInfo.finalEvent", "The identifier for the final blockchain event received from this contract before termination")
+	FireFlyContractInfo        = ffm("FireFlyContractInfo.info", "Blockchain-specific info on the contract, such as its location on chain")
+	NetworkActionType          = ffm("NetworkAction.type", "The action to be performed")
 
 	// NodeStatus field descriptions
 	NodeStatusNode = ffm("NodeStatus.node", "Details of the local node")
diff --git a/internal/database/sqlcommon/namespace_sql.go b/internal/database/sqlcommon/namespace_sql.go
index 18c8c086e..7a3c2623e 100644
--- a/internal/database/sqlcommon/namespace_sql.go
+++ b/internal/database/sqlcommon/namespace_sql.go
@@ -37,6 +37,7 @@ var (
 		"name",
 		"description",
 		"created",
+		"firefly_contracts",
 	}
 	namespaceFilterFieldMap = map[string]string{
 		"message": "message_id",
@@ -90,6 +91,7 @@ func (s *SQLCommon) UpsertNamespace(ctx context.Context, namespace *core.Namespa
 				Set("name", namespace.Name).
 				Set("description", namespace.Description).
 				Set("created", namespace.Created).
+				Set("firefly_contracts", namespace.Contracts).
 				Where(sq.Eq{"name": namespace.Name}),
 			func() {
 				s.callbacks.UUIDCollectionEvent(database.CollectionNamespaces, core.ChangeEventTypeUpdated, namespace.ID)
@@ -112,6 +114,7 @@ func (s *SQLCommon) UpsertNamespace(ctx context.Context, namespace *core.Namespa
 					namespace.Name,
 					namespace.Description,
 					namespace.Created,
+					namespace.Contracts,
 				),
 			func() {
 				s.callbacks.UUIDCollectionEvent(database.CollectionNamespaces, core.ChangeEventTypeCreated, namespace.ID)
@@ -133,6 +136,7 @@ func (s *SQLCommon) namespaceResult(ctx context.Context, row *sql.Rows) (*core.N
 		&namespace.Name,
 		&namespace.Description,
 		&namespace.Created,
+		&namespace.Contracts,
 	)
 	if err != nil {
 		return nil, i18n.WrapError(ctx, err, coremsgs.MsgDBReadErr, namespacesTable)
diff --git a/internal/events/blockchain_event.go b/internal/events/blockchain_event.go
index 62a5c52d7..561a66ba6 100644
--- a/internal/events/blockchain_event.go
+++ b/internal/events/blockchain_event.go
@@ -117,7 +117,7 @@ func (em *eventManager) emitBlockchainEventMetric(event *blockchain.Event) {
 }
 
 func (em *eventManager) BlockchainEvent(event *blockchain.EventWithSubscription) error {
-	return em.retry.Do(em.ctx, "persist contract event", func(attempt int) (bool, error) {
+	return em.retry.Do(em.ctx, "persist blockchain event", func(attempt int) (bool, error) {
 		err := em.database.RunAsGroup(em.ctx, func(ctx context.Context) error {
 			sub, err := em.getChainListenerByProtocolIDCached(ctx, event.Subscription)
 			if err != nil {
diff --git a/internal/events/event_manager.go b/internal/events/event_manager.go
index b74080fa5..300dec4b0 100644
--- a/internal/events/event_manager.go
+++ b/internal/events/event_manager.go
@@ -66,6 +66,7 @@ type EventManager interface {
 	// Bound blockchain callbacks
 	BatchPinComplete(bi blockchain.Plugin, batch *blockchain.BatchPin, signingKey *core.VerifierRef) error
 	BlockchainEvent(event *blockchain.EventWithSubscription) error
+	BlockchainNetworkAction(bi blockchain.Plugin, action string, event *blockchain.Event, signingKey *core.VerifierRef) error
 
 	// Bound dataexchange callbacks
 	DXEvent(dx dataexchange.Plugin, event dataexchange.DXEvent)
diff --git a/internal/events/network_action.go b/internal/events/network_action.go
new file mode 100644
index 000000000..205465f77
--- /dev/null
+++ b/internal/events/network_action.go
@@ -0,0 +1,71 @@
+// Copyright © 2022 Kaleido, Inc.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// 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 events
+
+import (
+	"context"
+
+	"github.com/hyperledger/firefly-common/pkg/log"
+	"github.com/hyperledger/firefly/pkg/blockchain"
+	"github.com/hyperledger/firefly/pkg/core"
+)
+
+func (em *eventManager) actionTerminate(bi blockchain.Plugin, event *blockchain.Event) error {
+	return em.database.RunAsGroup(em.ctx, func(ctx context.Context) error {
+		ns, err := em.database.GetNamespace(ctx, core.SystemNamespace)
+		if err != nil {
+			return err
+		}
+		if err := bi.TerminateContract(ctx, &ns.Contracts, event); err != nil {
+			return err
+		}
+		return em.database.UpsertNamespace(ctx, ns, true)
+	})
+}
+
+func (em *eventManager) BlockchainNetworkAction(bi blockchain.Plugin, action string, event *blockchain.Event, signingKey *core.VerifierRef) error {
+	return em.retry.Do(em.ctx, "handle network action", func(attempt int) (retry bool, err error) {
+		// Verify that the action came from a registered root org
+		resolvedAuthor, err := em.identity.FindIdentityForVerifier(em.ctx, []core.IdentityType{core.IdentityTypeOrg}, core.SystemNamespace, signingKey)
+		if err != nil {
+			return true, err
+		}
+		if resolvedAuthor == nil {
+			log.L(em.ctx).Errorf("Ignoring network action %s from unknown identity %s", action, signingKey.Value)
+			return false, nil
+		}
+		if resolvedAuthor.Parent != nil {
+			log.L(em.ctx).Errorf("Ignoring network action %s from non-root identity %s", action, signingKey.Value)
+			return false, nil
+		}
+
+		if action == core.NetworkActionTerminate.String() {
+			err = em.actionTerminate(bi, event)
+		} else {
+			log.L(em.ctx).Errorf("Ignoring unrecognized network action: %s", action)
+			return false, nil
+		}
+
+		if err == nil {
+			chainEvent := buildBlockchainEvent(core.SystemNamespace, nil, event, &core.BlockchainTransactionRef{
+				BlockchainID: event.BlockchainTXID,
+			})
+			err = em.maybePersistBlockchainEvent(em.ctx, chainEvent)
+		}
+		return true, err
+	})
+}
diff --git a/internal/orchestrator/bound_callbacks.go b/internal/orchestrator/bound_callbacks.go
index 9b9f011fe..4c6205120 100644
--- a/internal/orchestrator/bound_callbacks.go
+++ b/internal/orchestrator/bound_callbacks.go
@@ -59,6 +59,10 @@ func (bc *boundCallbacks) BatchPinComplete(batch *blockchain.BatchPin, signingKe
 	return bc.ei.BatchPinComplete(bc.bi, batch, signingKey)
 }
 
+func (bc *boundCallbacks) BlockchainNetworkAction(action string, event *blockchain.Event, signingKey *core.VerifierRef) error {
+	return bc.ei.BlockchainNetworkAction(bc.bi, action, event, signingKey)
+}
+
 func (bc *boundCallbacks) DXEvent(event dataexchange.DXEvent) {
 	switch event.Type() {
 	case dataexchange.DXEventTypeTransferResult:
diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go
index 5b41be9da..e13f1a69b 100644
--- a/internal/orchestrator/orchestrator.go
+++ b/internal/orchestrator/orchestrator.go
@@ -140,6 +140,9 @@ type Orchestrator interface {
 
 	// Message Routing
 	RequestReply(ctx context.Context, ns string, msg *core.MessageInOut) (reply *core.MessageInOut, err error)
+
+	// Network Operations
+	SubmitNetworkAction(ctx context.Context, action *core.NetworkAction) error
 }
 
 type orchestrator struct {
@@ -223,12 +226,22 @@ func (or *orchestrator) Start() (err error) {
 	if err == nil {
 		err = or.batch.Start()
 	}
+	var ns *core.Namespace
+	if err == nil {
+		ns, err = or.database.GetNamespace(or.ctx, core.SystemNamespace)
+	}
 	if err == nil {
 		for _, el := range or.blockchains {
+			if err = el.ConfigureContract(or.ctx, &ns.Contracts); err != nil {
+				break
+			}
 			if err = el.Start(); err != nil {
 				break
 			}
 		}
+		if err == nil {
+			err = or.database.UpsertNamespace(or.ctx, ns, true)
+		}
 	}
 	if err == nil {
 		err = or.events.Start()
@@ -874,3 +887,14 @@ func (or *orchestrator) initNamespaces(ctx context.Context) (err error) {
 	}
 	return or.namespace.Init(ctx, or.database)
 }
+
+func (or *orchestrator) SubmitNetworkAction(ctx context.Context, action *core.NetworkAction) error {
+	verifier, err := or.identity.GetNodeOwnerBlockchainKey(ctx)
+	if err != nil {
+		return err
+	}
+	if action.Type != core.NetworkActionTerminate {
+		return i18n.NewError(ctx, coremsgs.MsgUnrecognizedNetworkAction, action.Type)
+	}
+	return or.blockchain.SubmitNetworkAction(ctx, fftypes.NewUUID(), verifier.Value, action.Type)
+}
diff --git a/mocks/blockchainmocks/callbacks.go b/mocks/blockchainmocks/callbacks.go
index 5144272be..f26f550bf 100644
--- a/mocks/blockchainmocks/callbacks.go
+++ b/mocks/blockchainmocks/callbacks.go
@@ -44,6 +44,20 @@ func (_m *Callbacks) BlockchainEvent(event *blockchain.EventWithSubscription) er
 	return r0
 }
 
+// BlockchainNetworkAction provides a mock function with given fields: action, event, signingKey
+func (_m *Callbacks) BlockchainNetworkAction(action string, event *blockchain.Event, signingKey *core.VerifierRef) error {
+	ret := _m.Called(action, event, signingKey)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(string, *blockchain.Event, *core.VerifierRef) error); ok {
+		r0 = rf(action, event, signingKey)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
 // BlockchainOpUpdate provides a mock function with given fields: plugin, operationID, txState, blockchainTXID, errorMessage, opOutput
 func (_m *Callbacks) BlockchainOpUpdate(plugin blockchain.Plugin, operationID *fftypes.UUID, txState core.OpStatus, blockchainTXID string, errorMessage string, opOutput fftypes.JSONObject) {
 	_m.Called(plugin, operationID, txState, blockchainTXID, errorMessage, opOutput)
diff --git a/mocks/blockchainmocks/plugin.go b/mocks/blockchainmocks/plugin.go
index efc2270f5..9ac59f56d 100644
--- a/mocks/blockchainmocks/plugin.go
+++ b/mocks/blockchainmocks/plugin.go
@@ -52,6 +52,20 @@ func (_m *Plugin) Capabilities() *blockchain.Capabilities {
 	return r0
 }
 
+// ConfigureContract provides a mock function with given fields: ctx, contracts
+func (_m *Plugin) ConfigureContract(ctx context.Context, contracts *core.FireFlyContracts) error {
+	ret := _m.Called(ctx, contracts)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *core.FireFlyContracts) error); ok {
+		r0 = rf(ctx, contracts)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
 // DeleteContractListener provides a mock function with given fields: ctx, subscription
 func (_m *Plugin) DeleteContractListener(ctx context.Context, subscription *core.ContractListener) error {
 	ret := _m.Called(ctx, subscription)
@@ -268,6 +282,34 @@ func (_m *Plugin) SubmitBatchPin(ctx context.Context, operationID *fftypes.UUID,
 	return r0
 }
 
+// SubmitNetworkAction provides a mock function with given fields: ctx, operationID, signingKey, action
+func (_m *Plugin) SubmitNetworkAction(ctx context.Context, operationID *fftypes.UUID, signingKey string, action core.FFEnum) error {
+	ret := _m.Called(ctx, operationID, signingKey, action)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *fftypes.UUID, string, core.FFEnum) error); ok {
+		r0 = rf(ctx, operationID, signingKey, action)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// TerminateContract provides a mock function with given fields: ctx, contracts, termination
+func (_m *Plugin) TerminateContract(ctx context.Context, contracts *core.FireFlyContracts, termination *blockchain.Event) error {
+	ret := _m.Called(ctx, contracts, termination)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *core.FireFlyContracts, *blockchain.Event) error); ok {
+		r0 = rf(ctx, contracts, termination)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
 // VerifierType provides a mock function with given fields:
 func (_m *Plugin) VerifierType() core.FFEnum {
 	ret := _m.Called()
diff --git a/mocks/eventmocks/event_manager.go b/mocks/eventmocks/event_manager.go
index 04df8e4a9..82c759cbc 100644
--- a/mocks/eventmocks/event_manager.go
+++ b/mocks/eventmocks/event_manager.go
@@ -69,6 +69,20 @@ func (_m *EventManager) BlockchainEvent(event *blockchain.EventWithSubscription)
 	return r0
 }
 
+// BlockchainNetworkAction provides a mock function with given fields: bi, action, event, signingKey
+func (_m *EventManager) BlockchainNetworkAction(bi blockchain.Plugin, action string, event *blockchain.Event, signingKey *core.VerifierRef) error {
+	ret := _m.Called(bi, action, event, signingKey)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(blockchain.Plugin, string, *blockchain.Event, *core.VerifierRef) error); ok {
+		r0 = rf(bi, action, event, signingKey)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
 // CreateUpdateDurableSubscription provides a mock function with given fields: ctx, subDef, mustNew
 func (_m *EventManager) CreateUpdateDurableSubscription(ctx context.Context, subDef *core.Subscription, mustNew bool) error {
 	ret := _m.Called(ctx, subDef, mustNew)
diff --git a/mocks/orchestratormocks/orchestrator.go b/mocks/orchestratormocks/orchestrator.go
index 1a5e68d3e..48be922cc 100644
--- a/mocks/orchestratormocks/orchestrator.go
+++ b/mocks/orchestratormocks/orchestrator.go
@@ -1347,6 +1347,20 @@ func (_m *Orchestrator) Start() error {
 	return r0
 }
 
+// SubmitNetworkAction provides a mock function with given fields: ctx, action
+func (_m *Orchestrator) SubmitNetworkAction(ctx context.Context, action *core.NetworkAction) error {
+	ret := _m.Called(ctx, action)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *core.NetworkAction) error); ok {
+		r0 = rf(ctx, action)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
 // WaitStop provides a mock function with given fields:
 func (_m *Orchestrator) WaitStop() {
 	_m.Called()
diff --git a/pkg/blockchain/plugin.go b/pkg/blockchain/plugin.go
index 7a203a8b0..9b6198dc4 100644
--- a/pkg/blockchain/plugin.go
+++ b/pkg/blockchain/plugin.go
@@ -33,9 +33,19 @@ type Plugin interface {
 	InitConfig(config config.Section)
 
 	// Init initializes the plugin, with configuration
-	// Returns the supported featureset of the interface
 	Init(ctx context.Context, config config.Section, callbacks Callbacks, metrics metrics.Manager) error
 
+	// ConfigureContract initializes the subscription to the FireFly contract
+	// - Checks the provided contract info against the plugin's configuration, and updates it as needed
+	// - Initializes the contract info for performing BatchPin transactions, and initializes subscriptions for BatchPin events
+	ConfigureContract(ctx context.Context, contracts *core.FireFlyContracts) (err error)
+
+	// TerminateContract marks the given event as the last one to be parsed on the current FireFly contract
+	// - Validates that the event came from the currently active FireFly contract
+	// - Re-initializes the plugin against the next configured FireFly contract
+	// - Updates the provided contract info to record the point of termination and the newly active contract
+	TerminateContract(ctx context.Context, contracts *core.FireFlyContracts, termination *Event) (err error)
+
 	// Blockchain interface must not deliver any events until start is called
 	Start() error
 
@@ -53,6 +63,9 @@ type Plugin interface {
 	// SubmitBatchPin sequences a batch of message globally to all viewers of a given ledger
 	SubmitBatchPin(ctx context.Context, operationID *fftypes.UUID, signingKey string, batch *BatchPin) error
 
+	// SubmitNetworkAction writes a special "BatchPin" event which signals the plugin to take an action
+	SubmitNetworkAction(ctx context.Context, operationID *fftypes.UUID, signingKey string, action core.NetworkActionType) error
+
 	// InvokeContract submits a new transaction to be executed by custom on-chain logic
 	InvokeContract(ctx context.Context, operationID *fftypes.UUID, signingKey string, location *fftypes.JSONAny, method *core.FFIMethod, input map[string]interface{}) error
 
@@ -78,6 +91,8 @@ type Plugin interface {
 	GenerateEventSignature(ctx context.Context, event *core.FFIEventDefinition) string
 }
 
+const FireFlyActionPrefix = "firefly:"
+
 // Callbacks is the interface provided to the blockchain plugin, to allow it to pass events back to firefly.
 //
 // Events must be delivered sequentially, such that event 2 is not delivered until the callback invoked for event 1
@@ -89,16 +104,19 @@ type Callbacks interface {
 	// opOutput can be used to add opaque protocol specific JSON from the plugin (protocol transaction ID etc.)
 	// Note this is an optional hook information, and stored separately to the confirmation of the actual event that was being submitted/sequenced.
 	// Only the party submitting the transaction will see this data.
-	//
-	// Error should will only be returned in shutdown scenarios
 	BlockchainOpUpdate(plugin Plugin, operationID *fftypes.UUID, txState TransactionStatus, blockchainTXID, errorMessage string, opOutput fftypes.JSONObject)
 
 	// BatchPinComplete notifies on the arrival of a sequenced batch of messages, which might have been
 	// submitted by us, or by any other authorized party in the network.
 	//
-	// Error should will only be returned in shutdown scenarios
+	// Error should only be returned in shutdown scenarios
 	BatchPinComplete(batch *BatchPin, signingKey *core.VerifierRef) error
 
+	// BlockchainNetworkAction notifies on the arrival of a network operator action
+	//
+	// Error should only be returned in shutdown scenarios
+	BlockchainNetworkAction(action string, event *Event, signingKey *core.VerifierRef) error
+
 	// BlockchainEvent notifies on the arrival of any event from a user-created subscription.
 	BlockchainEvent(event *EventWithSubscription) error
 }
@@ -160,7 +178,7 @@ type Event struct {
 	// Name is a short name for the event
 	Name string
 
-	// ProtocolID is a protocol-specific identifier for the event
+	// ProtocolID is an alphanumerically sortable string that represents this event uniquely on the blockchain
 	ProtocolID string
 
 	// Output is the raw output data from the event
diff --git a/pkg/core/namespace.go b/pkg/core/namespace.go
index 2c2cee297..da1cecb6d 100644
--- a/pkg/core/namespace.go
+++ b/pkg/core/namespace.go
@@ -19,6 +19,8 @@ package core
 import (
 	"context"
 	"crypto/sha256"
+	"database/sql/driver"
+	"encoding/json"
 
 	"github.com/hyperledger/firefly-common/pkg/fftypes"
 	"github.com/hyperledger/firefly-common/pkg/i18n"
@@ -39,12 +41,36 @@ var (
 // Namespace is a isolate set of named resources, to allow multiple applications to co-exist in the same network, with the same named objects.
 // Can be used for use case segregation, or multi-tenancy.
 type Namespace struct {
-	ID          *fftypes.UUID   `ffstruct:"Namespace" json:"id" ffexcludeinput:"true"`
-	Message     *fftypes.UUID   `ffstruct:"Namespace" json:"message,omitempty" ffexcludeinput:"true"`
-	Name        string          `ffstruct:"Namespace" json:"name"`
-	Description string          `ffstruct:"Namespace" json:"description"`
-	Type        NamespaceType   `ffstruct:"Namespace" json:"type" ffenum:"namespacetype" ffexcludeinput:"true"`
-	Created     *fftypes.FFTime `ffstruct:"Namespace" json:"created" ffexcludeinput:"true"`
+	ID          *fftypes.UUID    `ffstruct:"Namespace" json:"id" ffexcludeinput:"true"`
+	Message     *fftypes.UUID    `ffstruct:"Namespace" json:"message,omitempty" ffexcludeinput:"true"`
+	Name        string           `ffstruct:"Namespace" json:"name"`
+	Description string           `ffstruct:"Namespace" json:"description"`
+	Type        NamespaceType    `ffstruct:"Namespace" json:"type" ffenum:"namespacetype" ffexcludeinput:"true"`
+	Created     *fftypes.FFTime  `ffstruct:"Namespace" json:"created" ffexcludeinput:"true"`
+	Contracts   FireFlyContracts `ffstruct:"Namespace" json:"fireflyContract" ffexcludeinput:"true"`
+}
+
+type FireFlyContracts struct {
+	Active     FireFlyContractInfo   `ffstruct:"FireFlyContracts" json:"active"`
+	Terminated []FireFlyContractInfo `ffstruct:"FireFlyContracts" json:"terminated,omitempty"`
+}
+
+type FireFlyContractInfo struct {
+	Index      int                `ffstruct:"FireFlyContractInfo" json:"index"`
+	FinalEvent string             `ffstruct:"FireFlyContractInfo" json:"finalEvent,omitempty"`
+	Info       fftypes.JSONObject `ffstruct:"FireFlyContractInfo" json:"info,omitempty"`
+}
+
+// NetworkActionType is a type of action to perform
+type NetworkActionType = FFEnum
+
+var (
+	// NetworkActionTerminate request all network members to stop using the current contract and move to the next one configured
+	NetworkActionTerminate = ffEnum("networkactiontype", "terminate")
+)
+
+type NetworkAction struct {
+	Type NetworkActionType `ffstruct:"NetworkAction" json:"type" ffenum:"networkactiontype"`
 }
 
 func (ns *Namespace) Validate(ctx context.Context, existing bool) (err error) {
@@ -79,3 +105,23 @@ func (ns *Namespace) Topic() string {
 func (ns *Namespace) SetBroadcastMessage(msgID *fftypes.UUID) {
 	ns.Message = msgID
 }
+
+// Scan implements sql.Scanner
+func (fc *FireFlyContracts) Scan(src interface{}) error {
+	switch src := src.(type) {
+	case []byte:
+		if len(src) == 0 {
+			return nil
+		}
+		return json.Unmarshal(src, fc)
+	case string:
+		return fc.Scan([]byte(src))
+	default:
+		return i18n.NewError(context.Background(), i18n.MsgTypeRestoreFailed, src, fc)
+	}
+}
+
+// Value implements sql.Valuer
+func (fc FireFlyContracts) Value() (driver.Value, error) {
+	return json.Marshal(fc)
+}
diff --git a/pkg/database/plugin.go b/pkg/database/plugin.go
index f8c45aca8..0316cdb58 100644
--- a/pkg/database/plugin.go
+++ b/pkg/database/plugin.go
@@ -67,13 +67,13 @@ type iNamespaceCollection interface {
 	DeleteNamespace(ctx context.Context, id *fftypes.UUID) (err error)
 
 	// GetNamespace - Get an namespace by name
-	GetNamespace(ctx context.Context, name string) (offset *core.Namespace, err error)
+	GetNamespace(ctx context.Context, name string) (namespace *core.Namespace, err error)
 
 	// GetNamespaceByID - Get a namespace by ID
-	GetNamespaceByID(ctx context.Context, id *fftypes.UUID) (offset *core.Namespace, err error)
+	GetNamespaceByID(ctx context.Context, id *fftypes.UUID) (namespace *core.Namespace, err error)
 
 	// GetNamespaces - Get namespaces
-	GetNamespaces(ctx context.Context, filter Filter) (offset []*core.Namespace, res *FilterResult, err error)
+	GetNamespaces(ctx context.Context, filter Filter) (namespaces []*core.Namespace, res *FilterResult, err error)
 }
 
 type iMessageCollection interface {


Test patch:
diff --git a/internal/apiserver/route_post_network_action_test.go b/internal/apiserver/route_post_network_action_test.go
new file mode 100644
index 000000000..fe401d258
--- /dev/null
+++ b/internal/apiserver/route_post_network_action_test.go
@@ -0,0 +1,43 @@
+// Copyright © 2022 Kaleido, Inc.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// 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 apiserver
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/hyperledger/firefly/pkg/core"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestPostNetworkAction(t *testing.T) {
+	o, r := newTestAPIServer()
+	input := core.NetworkAction{}
+	var buf bytes.Buffer
+	json.NewEncoder(&buf).Encode(&input)
+	req := httptest.NewRequest("POST", "/api/v1/network/action", &buf)
+	req.Header.Set("Content-Type", "application/json; charset=utf-8")
+	res := httptest.NewRecorder()
+
+	o.On("SubmitNetworkAction", mock.Anything, mock.AnythingOfType("*core.NetworkAction")).Return(nil)
+	r.ServeHTTP(res, req)
+
+	assert.Equal(t, 202, res.Result().StatusCode)
+}
diff --git a/internal/blockchain/ethereum/ethereum_test.go b/internal/blockchain/ethereum/ethereum_test.go
index 16746d337..264308deb 100644
--- a/internal/blockchain/ethereum/ethereum_test.go
+++ b/internal/blockchain/ethereum/ethereum_test.go
@@ -68,9 +68,8 @@ func testFFIMethod() *core.FFIMethod {
 	}
 }
 
-func resetConf() {
+func resetConf(e *Ethereum) {
 	coreconfig.Reset()
-	e := &Ethereum{}
 	e.InitConfig(utConfig)
 }
 
@@ -83,15 +82,15 @@ func newTestEthereum() (*Ethereum, func()) {
 	mm.On("BlockchainTransaction", mock.Anything, mock.Anything).Return(nil)
 	mm.On("BlockchainQuery", mock.Anything, mock.Anything).Return(nil)
 	e := &Ethereum{
-		ctx:          ctx,
-		client:       resty.New().SetBaseURL("http://localhost:12345"),
-		instancePath: "/instances/0x12345",
-		topic:        "topic1",
-		prefixShort:  defaultPrefixShort,
-		prefixLong:   defaultPrefixLong,
-		callbacks:    em,
-		wsconn:       wsm,
-		metrics:      mm,
+		ctx:             ctx,
+		client:          resty.New().SetBaseURL("http://localhost:12345"),
+		fireflyContract: "/instances/0x12345",
+		topic:           "topic1",
+		prefixShort:     defaultPrefixShort,
+		prefixLong:      defaultPrefixLong,
+		callbacks:       em,
+		wsconn:          wsm,
+		metrics:         mm,
 	}
 	return e, func() {
 		cancel()
@@ -105,7 +104,7 @@ func newTestEthereum() (*Ethereum, func()) {
 func TestInitMissingURL(t *testing.T) {
 	e, cancel := newTestEthereum()
 	defer cancel()
-	resetConf()
+	resetConf(e)
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
 	assert.Regexp(t, "FF10138.*url", err)
 }
@@ -113,35 +112,24 @@ func TestInitMissingURL(t *testing.T) {
 func TestInitBadAddressResolver(t *testing.T) {
 	e, cancel := newTestEthereum()
 	defer cancel()
-	resetConf()
+	resetConf(e)
 	utAddressResolverConf.Set(AddressResolverURLTemplate, "{{unclosed}")
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
 	assert.Regexp(t, "FF10337.*urlTemplate", err)
 }
 
-func TestInitMissingInstance(t *testing.T) {
-	e, cancel := newTestEthereum()
-	defer cancel()
-	resetConf()
-	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
-	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
-
-	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
-	assert.Regexp(t, "FF10138.*instance", err)
-}
-
 func TestInitMissingTopic(t *testing.T) {
 	e, cancel := newTestEthereum()
 	defer cancel()
-	resetConf()
+	resetConf(e)
 	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
-	utEthconnectConf.Set(EthconnectConfigInstancePath, "/instances/0x12345")
+	utEthconnectConf.Set(EthconnectConfigInstanceDeprecated, "/instances/0x12345")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
 	assert.Regexp(t, "FF10138.*topic", err)
 }
 
-func TestInitAllNewStreamsAndWSEventWithFFTM(t *testing.T) {
+func TestInitAndStartWithFFTM(t *testing.T) {
 
 	log.SetLevel("trace")
 	e, cancel := newTestEthereum()
@@ -172,10 +160,10 @@ func TestInitAllNewStreamsAndWSEventWithFFTM(t *testing.T) {
 			return httpmock.NewJsonResponderOrPanic(200, subscription{ID: "sub12345"})(req)
 		})
 
-	resetConf()
+	resetConf(e)
 	utEthconnectConf.Set(ffresty.HTTPConfigURL, httpURL)
 	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utEthconnectConf.Set(EthconnectConfigInstancePath, "/instances/0x12345")
+	utEthconnectConf.Set(EthconnectConfigInstanceDeprecated, "/instances/0x71C7656EC7ab88b098defB751B7401B5f6d8976F")
 	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
 	utFFTMConf.Set(ffresty.HTTPConfigURL, "http://fftm.example.com:12345")
 
@@ -185,6 +173,10 @@ func TestInitAllNewStreamsAndWSEventWithFFTM(t *testing.T) {
 
 	assert.Equal(t, "ethereum", e.Name())
 	assert.Equal(t, core.VerifierTypeEthAddress, e.VerifierType())
+
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.NoError(t, err)
+
 	assert.Equal(t, 4, httpmock.GetTotalCallCount())
 	assert.Equal(t, "es12345", e.initInfo.stream.ID)
 	assert.Equal(t, "sub12345", e.initInfo.sub.ID)
@@ -213,9 +205,9 @@ func TestWSInitFail(t *testing.T) {
 	e, cancel := newTestEthereum()
 	defer cancel()
 
-	resetConf()
+	resetConf(e)
 	utEthconnectConf.Set(ffresty.HTTPConfigURL, "!!!://")
-	utEthconnectConf.Set(EthconnectConfigInstancePath, "/instances/0x12345")
+	utEthconnectConf.Set(EthconnectConfigInstanceDeprecated, "/instances/0x71C7656EC7ab88b098defB751B7401B5f6d8976F")
 	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
@@ -223,17 +215,30 @@ func TestWSInitFail(t *testing.T) {
 
 }
 
-func TestWSConnectFail(t *testing.T) {
+func TestInitMissingInstance(t *testing.T) {
 
-	wsm := &wsmocks.WSClient{}
-	e := &Ethereum{
-		ctx:    context.Background(),
-		wsconn: wsm,
-	}
-	wsm.On("Connect").Return(fmt.Errorf("pop"))
+	e, cancel := newTestEthereum()
+	defer cancel()
+
+	mockedClient := &http.Client{}
+	httpmock.ActivateNonDefault(mockedClient)
+	defer httpmock.DeactivateAndReset()
+
+	httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, []eventStream{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345"}))
+
+	resetConf(e)
+	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
+	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
+	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
+
+	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.Regexp(t, "FF10138.*instance", err)
 
-	err := e.Start()
-	assert.EqualError(t, err, "pop")
 }
 
 func TestInitAllExistingStreams(t *testing.T) {
@@ -249,31 +254,32 @@ func TestInitAllExistingStreams(t *testing.T) {
 		httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", WebSocket: eventStreamWebsocket{Topic: "topic1"}}}))
 	httpmock.RegisterResponder("GET", "http://localhost:12345/subscriptions",
 		httpmock.NewJsonResponderOrPanic(200, []subscription{
-			{ID: "sub12345", Stream: "es12345", Name: "BatchPin_30783132333435e3" /* this is the subname for our combo of instance path and BatchPin */},
+			{ID: "sub12345", Stream: "es12345", Name: "BatchPin_3078373163373635" /* this is the subname for our combo of instance path and BatchPin */},
 		}))
 	httpmock.RegisterResponder("PATCH", "http://localhost:12345/eventstreams/es12345",
 		httpmock.NewJsonResponderOrPanic(200, &eventStream{ID: "es12345", WebSocket: eventStreamWebsocket{Topic: "topic1"}}))
 
-	resetConf()
+	resetConf(e)
 	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
 	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utEthconnectConf.Set(EthconnectConfigInstancePath, "0x12345")
+	utEthconnectConf.Set(EthconnectConfigInstanceDeprecated, "0x71C7656EC7ab88b098defB751B7401B5f6d8976F")
 	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.NoError(t, err)
 
 	assert.Equal(t, 3, httpmock.GetTotalCallCount())
 	assert.Equal(t, "es12345", e.initInfo.stream.ID)
 	assert.Equal(t, "sub12345", e.initInfo.sub.ID)
 
-	assert.NoError(t, err)
-
 }
 
 func TestInitOldInstancePathContracts(t *testing.T) {
 	e, cancel := newTestEthereum()
 	defer cancel()
-	resetConf()
+	resetConf(e)
 
 	mockedClient := &http.Client{}
 	httpmock.ActivateNonDefault(mockedClient)
@@ -295,7 +301,7 @@ func TestInitOldInstancePathContracts(t *testing.T) {
 	httpmock.RegisterResponder("GET", "http://localhost:12345/contracts/firefly",
 		httpmock.NewJsonResponderOrPanic(200, map[string]string{
 			"created":      "2022-02-08T22:10:10Z",
-			"address":      "12345",
+			"address":      "0x71C7656EC7ab88b098defB751B7401B5f6d8976F",
 			"path":         "/contracts/firefly",
 			"abi":          "fc49dec3-0660-4dc7-61af-65af4c3ac456",
 			"openapi":      "/contracts/firefly?swagger",
@@ -303,21 +309,24 @@ func TestInitOldInstancePathContracts(t *testing.T) {
 		}),
 	)
 
-	resetConf()
+	resetConf(e)
 	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
 	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utEthconnectConf.Set(EthconnectConfigInstancePath, "/contracts/firefly")
+	utEthconnectConf.Set(EthconnectConfigInstanceDeprecated, "/contracts/firefly")
 	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
 	assert.NoError(t, err)
-	assert.Equal(t, e.instancePath, "0x12345")
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.NoError(t, err)
+
+	assert.Equal(t, e.fireflyContract, "0x71c7656ec7ab88b098defb751b7401b5f6d8976f")
 }
 
 func TestInitOldInstancePathInstances(t *testing.T) {
 	e, cancel := newTestEthereum()
 	defer cancel()
-	resetConf()
+	resetConf(e)
 
 	mockedClient := &http.Client{}
 	httpmock.ActivateNonDefault(mockedClient)
@@ -337,21 +346,24 @@ func TestInitOldInstancePathInstances(t *testing.T) {
 			return httpmock.NewJsonResponderOrPanic(200, subscription{ID: "sub12345"})(req)
 		})
 
-	resetConf()
+	resetConf(e)
 	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
 	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utEthconnectConf.Set(EthconnectConfigInstancePath, "/instances/0x12345")
+	utEthconnectConf.Set(EthconnectConfigInstanceDeprecated, "/instances/0x71C7656EC7ab88b098defB751B7401B5f6d8976F")
 	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
 	assert.NoError(t, err)
-	assert.Equal(t, e.instancePath, "0x12345")
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.NoError(t, err)
+
+	assert.Equal(t, e.fireflyContract, "0x71c7656ec7ab88b098defb751b7401b5f6d8976f")
 }
 
 func TestInitOldInstancePathError(t *testing.T) {
 	e, cancel := newTestEthereum()
 	defer cancel()
-	resetConf()
+	resetConf(e)
 
 	mockedClient := &http.Client{}
 	httpmock.ActivateNonDefault(mockedClient)
@@ -374,15 +386,238 @@ func TestInitOldInstancePathError(t *testing.T) {
 		httpmock.NewJsonResponderOrPanic(500, "pop"),
 	)
 
-	resetConf()
+	resetConf(e)
 	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
 	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utEthconnectConf.Set(EthconnectConfigInstancePath, "/contracts/firefly")
+	utEthconnectConf.Set(EthconnectConfigInstanceDeprecated, "/contracts/firefly")
 	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
-	assert.Regexp(t, "FF10111", err)
-	assert.Regexp(t, "pop", err)
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.Regexp(t, "FF10111.*pop", err)
+}
+
+func TestInitNewConfig(t *testing.T) {
+	e, cancel := newTestEthereum()
+	defer cancel()
+	resetConf(e)
+
+	mockedClient := &http.Client{}
+	httpmock.ActivateNonDefault(mockedClient)
+	defer httpmock.DeactivateAndReset()
+
+	httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, []eventStream{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345"}))
+	httpmock.RegisterResponder("GET", "http://localhost:12345/subscriptions",
+		httpmock.NewJsonResponderOrPanic(200, []subscription{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/subscriptions",
+		httpmock.NewJsonResponderOrPanic(200, subscription{}))
+
+	resetConf(e)
+	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
+	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
+	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
+	utConfig.AddKnownKey(FireFlyContractConfigKey+".0."+FireFlyContractAddress, "0x71C7656EC7ab88b098defB751B7401B5f6d8976F")
+
+	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.NoError(t, err)
+
+	assert.Equal(t, e.fireflyContract, "0x71c7656ec7ab88b098defb751b7401b5f6d8976f")
+}
+
+func TestInitNewConfigError(t *testing.T) {
+	e, cancel := newTestEthereum()
+	defer cancel()
+	resetConf(e)
+
+	mockedClient := &http.Client{}
+	httpmock.ActivateNonDefault(mockedClient)
+	defer httpmock.DeactivateAndReset()
+
+	httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, []eventStream{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345"}))
+
+	resetConf(e)
+	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
+	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
+	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
+	utConfig.AddKnownKey(FireFlyContractConfigKey+".0."+FireFlyContractAddress, "")
+
+	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.Regexp(t, "FF10138", err)
+}
+
+func TestInitNewConfigBadIndex(t *testing.T) {
+	e, cancel := newTestEthereum()
+	defer cancel()
+	resetConf(e)
+
+	mockedClient := &http.Client{}
+	httpmock.ActivateNonDefault(mockedClient)
+	defer httpmock.DeactivateAndReset()
+
+	httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, []eventStream{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345"}))
+
+	resetConf(e)
+	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
+	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
+	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
+	utConfig.AddKnownKey(FireFlyContractConfigKey+".0."+FireFlyContractAddress, "")
+
+	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{
+		Active: core.FireFlyContractInfo{Index: 1},
+	})
+	assert.Regexp(t, "FF10396", err)
+}
+
+func TestInitTerminateContract(t *testing.T) {
+	e, _ := newTestEthereum()
+
+	contracts := &core.FireFlyContracts{}
+	event := &blockchain.Event{
+		ProtocolID: "000000000011/000000/000050",
+		Info: fftypes.JSONObject{
+			"address": "0x1C197604587F046FD40684A8f21f4609FB811A7b",
+		},
+	}
+
+	mockedClient := &http.Client{}
+	httpmock.ActivateNonDefault(mockedClient)
+	defer httpmock.DeactivateAndReset()
+
+	httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, []eventStream{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345"}))
+	httpmock.RegisterResponder("GET", "http://localhost:12345/subscriptions",
+		httpmock.NewJsonResponderOrPanic(200, []subscription{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/subscriptions",
+		httpmock.NewJsonResponderOrPanic(200, subscription{ID: "sb-1"}))
+
+	resetConf(e)
+	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
+	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
+	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
+	utConfig.AddKnownKey(FireFlyContractConfigKey+".0."+FireFlyContractAddress, "0x1C197604587F046FD40684A8f21f4609FB811A7b")
+	utConfig.AddKnownKey(FireFlyContractConfigKey+".1."+FireFlyContractAddress, "0x71C7656EC7ab88b098defB751B7401B5f6d8976F")
+
+	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, contracts)
+	assert.NoError(t, err)
+
+	httpmock.RegisterResponder("POST", "http://localhost:12345/subscriptions",
+		httpmock.NewJsonResponderOrPanic(200, subscription{ID: "sb-2"}))
+
+	err = e.TerminateContract(e.ctx, contracts, event)
+	assert.NoError(t, err)
+
+	assert.Equal(t, 1, contracts.Active.Index)
+	assert.Equal(t, fftypes.JSONObject{
+		"address":      "0x71c7656ec7ab88b098defb751b7401b5f6d8976f",
+		"fromBlock":    "oldest",
+		"subscription": "sb-2",
+	}, contracts.Active.Info)
+	assert.Len(t, contracts.Terminated, 1)
+	assert.Equal(t, 0, contracts.Terminated[0].Index)
+	assert.Equal(t, fftypes.JSONObject{
+		"address":      "0x1c197604587f046fd40684a8f21f4609fb811a7b",
+		"fromBlock":    "oldest",
+		"subscription": "sb-1",
+	}, contracts.Terminated[0].Info)
+	assert.Equal(t, event.ProtocolID, contracts.Terminated[0].FinalEvent)
+}
+
+func TestInitTerminateContractIgnore(t *testing.T) {
+	e, _ := newTestEthereum()
+
+	contracts := &core.FireFlyContracts{}
+	event := &blockchain.Event{
+		ProtocolID: "000000000011/000000/000050",
+		Info: fftypes.JSONObject{
+			"address": "0x1C197604587F046FD40684A8f21f4609FB811A7b",
+		},
+	}
+
+	mockedClient := &http.Client{}
+	httpmock.ActivateNonDefault(mockedClient)
+	defer httpmock.DeactivateAndReset()
+
+	httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, []eventStream{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345"}))
+	httpmock.RegisterResponder("GET", "http://localhost:12345/subscriptions",
+		httpmock.NewJsonResponderOrPanic(200, []subscription{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/subscriptions",
+		httpmock.NewJsonResponderOrPanic(200, subscription{}))
+
+	resetConf(e)
+	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
+	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
+	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
+	utConfig.AddKnownKey(FireFlyContractConfigKey+".0."+FireFlyContractAddress, "0x71C7656EC7ab88b098defB751B7401B5f6d8976F")
+
+	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, contracts)
+	assert.NoError(t, err)
+
+	err = e.TerminateContract(e.ctx, contracts, event)
+	assert.NoError(t, err)
+}
+
+func TestInitTerminateContractBadEvent(t *testing.T) {
+	e, _ := newTestEthereum()
+
+	contracts := &core.FireFlyContracts{}
+	event := &blockchain.Event{
+		ProtocolID: "000000000011/000000/000050",
+		Info: fftypes.JSONObject{
+			"address": "bad",
+		},
+	}
+
+	mockedClient := &http.Client{}
+	httpmock.ActivateNonDefault(mockedClient)
+	defer httpmock.DeactivateAndReset()
+
+	httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, []eventStream{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345"}))
+	httpmock.RegisterResponder("GET", "http://localhost:12345/subscriptions",
+		httpmock.NewJsonResponderOrPanic(200, []subscription{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/subscriptions",
+		httpmock.NewJsonResponderOrPanic(200, subscription{}))
+
+	resetConf(e)
+	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
+	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
+	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
+	utConfig.AddKnownKey(FireFlyContractConfigKey+".0."+FireFlyContractAddress, "0x71C7656EC7ab88b098defB751B7401B5f6d8976F")
+
+	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, contracts)
+	assert.NoError(t, err)
+
+	err = e.TerminateContract(e.ctx, contracts, event)
+	assert.Regexp(t, "FF10141", err)
 }
 
 func TestStreamQueryError(t *testing.T) {
@@ -397,17 +632,15 @@ func TestStreamQueryError(t *testing.T) {
 	httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams",
 		httpmock.NewStringResponder(500, `pop`))
 
-	resetConf()
+	resetConf(e)
 	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
 	utEthconnectConf.Set(ffresty.HTTPConfigRetryEnabled, false)
 	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utEthconnectConf.Set(EthconnectConfigInstancePath, "/instances/0x12345")
+	utEthconnectConf.Set(EthconnectConfigInstanceDeprecated, "/instances/0x71C7656EC7ab88b098defB751B7401B5f6d8976F")
 	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
-
-	assert.Regexp(t, "FF10111", err)
-	assert.Regexp(t, "pop", err)
+	assert.Regexp(t, "FF10111.*pop", err)
 
 }
 
@@ -425,17 +658,15 @@ func TestStreamCreateError(t *testing.T) {
 	httpmock.RegisterResponder("POST", "http://localhost:12345/eventstreams",
 		httpmock.NewStringResponder(500, `pop`))
 
-	resetConf()
+	resetConf(e)
 	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
 	utEthconnectConf.Set(ffresty.HTTPConfigRetryEnabled, false)
 	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utEthconnectConf.Set(EthconnectConfigInstancePath, "/instances/0x12345")
+	utEthconnectConf.Set(EthconnectConfigInstanceDeprecated, "/instances/0x71C7656EC7ab88b098defB751B7401B5f6d8976F")
 	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
-
-	assert.Regexp(t, "FF10111", err)
-	assert.Regexp(t, "pop", err)
+	assert.Regexp(t, "FF10111.*pop", err)
 
 }
 
@@ -453,17 +684,15 @@ func TestStreamUpdateError(t *testing.T) {
 	httpmock.RegisterResponder("PATCH", "http://localhost:12345/eventstreams/es12345",
 		httpmock.NewStringResponder(500, `pop`))
 
-	resetConf()
+	resetConf(e)
 	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
 	utEthconnectConf.Set(ffresty.HTTPConfigRetryEnabled, false)
 	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utEthconnectConf.Set(EthconnectConfigInstancePath, "/instances/0x12345")
+	utEthconnectConf.Set(EthconnectConfigInstanceDeprecated, "/instances/0x71C7656EC7ab88b098defB751B7401B5f6d8976F")
 	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
-
-	assert.Regexp(t, "FF10111", err)
-	assert.Regexp(t, "pop", err)
+	assert.Regexp(t, "FF10111.*pop", err)
 
 }
 
@@ -483,17 +712,17 @@ func TestSubQueryError(t *testing.T) {
 	httpmock.RegisterResponder("GET", "http://localhost:12345/subscriptions",
 		httpmock.NewStringResponder(500, `pop`))
 
-	resetConf()
+	resetConf(e)
 	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
 	utEthconnectConf.Set(ffresty.HTTPConfigRetryEnabled, false)
 	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utEthconnectConf.Set(EthconnectConfigInstancePath, "/instances/0x12345")
+	utEthconnectConf.Set(EthconnectConfigInstanceDeprecated, "/instances/0x71C7656EC7ab88b098defB751B7401B5f6d8976F")
 	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
-
-	assert.Regexp(t, "FF10111", err)
-	assert.Regexp(t, "pop", err)
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.Regexp(t, "FF10111.*pop", err)
 
 }
 
@@ -515,17 +744,17 @@ func TestSubQueryCreateError(t *testing.T) {
 	httpmock.RegisterResponder("POST", "http://localhost:12345/subscriptions",
 		httpmock.NewStringResponder(500, `pop`))
 
-	resetConf()
+	resetConf(e)
 	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
 	utEthconnectConf.Set(ffresty.HTTPConfigRetryEnabled, false)
 	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utEthconnectConf.Set(EthconnectConfigInstancePath, "/instances/0x12345")
+	utEthconnectConf.Set(EthconnectConfigInstanceDeprecated, "/instances/0x71C7656EC7ab88b098defB751B7401B5f6d8976F")
 	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
-
-	assert.Regexp(t, "FF10111", err)
-	assert.Regexp(t, "pop", err)
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.Regexp(t, "FF10111.*pop", err)
 
 }
 
@@ -802,6 +1031,50 @@ func TestHandleMessageBatchPinOK(t *testing.T) {
 
 }
 
+func TestHandleMessageBatchPinMissingAuthor(t *testing.T) {
+	data := fftypes.JSONAnyPtr(`
+[
+  {
+		"address": "0x1C197604587F046FD40684A8f21f4609FB811A7b",
+		"blockNumber": "38011",
+		"transactionIndex": "0x0",
+		"transactionHash": "0xc26df2bf1a733e9249372d61eb11bd8662d26c8129df76890b1beb2f6fa72628",
+		"data": {
+			"author": "",
+			"namespace": "ns1",
+			"uuids": "0xe19af8b390604051812d7597d19adfb9847d3bfd074249efb65d3fed15f5b0a6",
+			"batchHash": "0xd71eb138d74c229a388eb0e1abc03f4c7cbb21d4fc4b839fbf0ec73e4263f6be",
+			"payloadRef": "Qmf412jQZiuVUtdgnB36FXFX7xg5V6KEbSJ4dpQuhkLyfD",
+			"contexts": [
+				"0x68e4da79f805bca5b912bcda9c63d03e6e867108dabb9b944109aea541ef522a",
+				"0x19b82093de5ce92a01e333048e877e2374354bf846dd034864ef6ffbd6438771"
+			]
+    },
+		"subId": "sb-b5b97a4e-a317-4053-6400-1474650efcb5",
+		"signature": "BatchPin(address,uint256,string,bytes32,bytes32,string,bytes32[])",
+		"logIndex": "50",
+		"timestamp": "1620576488"
+  }
+]`)
+
+	em := &blockchainmocks.Callbacks{}
+	e := &Ethereum{
+		callbacks: em,
+	}
+	e.initInfo.sub = &subscription{
+		ID: "sb-b5b97a4e-a317-4053-6400-1474650efcb5",
+	}
+
+	var events []interface{}
+	err := json.Unmarshal(data.Bytes(), &events)
+	assert.NoError(t, err)
+	err = e.handleMessageBatch(context.Background(), events)
+	assert.NoError(t, err)
+
+	em.AssertExpectations(t)
+
+}
+
 func TestHandleMessageEmptyPayloadRef(t *testing.T) {
 	data := fftypes.JSONAnyPtr(`
 [
@@ -959,6 +1232,7 @@ func TestHandleMessageBatchPinBadTransactionID(t *testing.T) {
 		ID: "sb-b5b97a4e-a317-4053-6400-1474650efcb5",
 	}
 	data := fftypes.JSONAnyPtr(`[{
+		"address": "0x1C197604587F046FD40684A8f21f4609FB811A7b",
 		"subId": "sb-b5b97a4e-a317-4053-6400-1474650efcb5",
 		"signature": "BatchPin(address,uint256,string,bytes32,bytes32,string,bytes32[])",
 		"blockNumber": "38011",
@@ -992,6 +1266,7 @@ func TestHandleMessageBatchPinBadIDentity(t *testing.T) {
 		ID: "sb-b5b97a4e-a317-4053-6400-1474650efcb5",
 	}
 	data := fftypes.JSONAnyPtr(`[{
+		"address": "0x1C197604587F046FD40684A8f21f4609FB811A7b",
 		"subId": "sb-b5b97a4e-a317-4053-6400-1474650efcb5",
 		"signature": "BatchPin(address,uint256,string,bytes32,bytes32,string,bytes32[])",
 		"blockNumber": "38011",
@@ -1025,6 +1300,7 @@ func TestHandleMessageBatchPinBadBatchHash(t *testing.T) {
 		ID: "sb-b5b97a4e-a317-4053-6400-1474650efcb5",
 	}
 	data := fftypes.JSONAnyPtr(`[{
+		"address": "0x1C197604587F046FD40684A8f21f4609FB811A7b",
 		"subId": "sb-b5b97a4e-a317-4053-6400-1474650efcb5",
 		"signature": "BatchPin(address,uint256,string,bytes32,bytes32,string,bytes32[])",
 		"blockNumber": "38011",
@@ -1058,6 +1334,7 @@ func TestHandleMessageBatchPinBadPin(t *testing.T) {
 		ID: "sb-b5b97a4e-a317-4053-6400-1474650efcb5",
 	}
 	data := fftypes.JSONAnyPtr(`[{
+		"address": "0x1C197604587F046FD40684A8f21f4609FB811A7b",
 		"subId": "sb-b5b97a4e-a317-4053-6400-1474650efcb5",
 		"signature": "BatchPin(address,uint256,string,bytes32,bytes32,string,bytes32[])",
 		"blockNumber": "38011",
@@ -1228,7 +1505,7 @@ func TestHandleBadPayloadsAndThenReceiptFailure(t *testing.T) {
 	<-done
 }
 
-func TestHandleMsgBatchBadDAta(t *testing.T) {
+func TestHandleMsgBatchBadData(t *testing.T) {
 	em := &blockchainmocks.Callbacks{}
 	wsm := &wsmocks.WSClient{}
 	e := &Ethereum{
@@ -1525,41 +1802,6 @@ func TestHandleMessageContractEvent(t *testing.T) {
 	em.AssertExpectations(t)
 }
 
-func TestHandleMessageContractEventNoTimestamp(t *testing.T) {
-	data := fftypes.JSONAnyPtr(`
-[
-  {
-		"address": "0x1C197604587F046FD40684A8f21f4609FB811A7b",
-		"blockNumber": "38011",
-		"transactionIndex": "0x0",
-		"transactionHash": "0xc26df2bf1a733e9249372d61eb11bd8662d26c8129df76890b1beb2f6fa72628",
-		"data": {
-			"from": "0x91D2B4381A4CD5C7C0F27565A7D4B829844C8635",
-			"value": "1"
-    },
-		"subId": "sub2",
-		"signature": "Changed(address,uint256)",
-		"logIndex": "50"
-  }
-]`)
-
-	em := &blockchainmocks.Callbacks{}
-	e := &Ethereum{
-		callbacks: em,
-	}
-	e.initInfo.sub = &subscription{
-		ID: "sb-b5b97a4e-a317-4053-6400-1474650efcb5",
-	}
-
-	em.On("BlockchainEvent", mock.Anything).Return(nil)
-
-	var events []interface{}
-	err := json.Unmarshal(data.Bytes(), &events)
-	assert.NoError(t, err)
-	err = e.handleMessageBatch(context.Background(), events)
-	assert.Regexp(t, "FF00136", err)
-}
-
 func TestHandleMessageContractEventError(t *testing.T) {
 	data := fftypes.JSONAnyPtr(`
 [
@@ -1858,10 +2100,10 @@ func TestGetContractAddressBadJSON(t *testing.T) {
 		httpmock.NewBytesResponder(200, []byte("{not json!")),
 	)
 
-	resetConf()
+	resetConf(e)
 	utEthconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
 	utEthconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utEthconnectConf.Set(EthconnectConfigInstancePath, "0x12345")
+	utEthconnectConf.Set(EthconnectConfigInstanceDeprecated, "0x12345")
 	utEthconnectConf.Set(EthconnectConfigTopic, "topic1")
 
 	e.client = ffresty.New(e.ctx, utEthconnectConf)
@@ -2507,3 +2749,72 @@ func TestGenerateEventSignatureInvalid(t *testing.T) {
 	signature := e.GenerateEventSignature(context.Background(), event)
 	assert.Equal(t, "", signature)
 }
+
+func TestSubmitNetworkAction(t *testing.T) {
+	e, _ := newTestEthereum()
+	httpmock.ActivateNonDefault(e.client.GetClient())
+	defer httpmock.DeactivateAndReset()
+	httpmock.RegisterResponder("POST", `http://localhost:12345/`,
+		func(req *http.Request) (*http.Response, error) {
+			var body map[string]interface{}
+			json.NewDecoder(req.Body).Decode(&body)
+			params := body["params"].([]interface{})
+			headers := body["headers"].(map[string]interface{})
+			assert.Equal(t, "SendTransaction", headers["type"])
+			assert.Equal(t, "0x0000000000000000000000000000000000000000000000000000000000000000", params[1])
+			assert.Equal(t, "0x0000000000000000000000000000000000000000000000000000000000000000", params[2])
+			assert.Equal(t, "", params[3])
+			return httpmock.NewJsonResponderOrPanic(200, "")(req)
+		})
+
+	err := e.SubmitNetworkAction(context.Background(), fftypes.NewUUID(), "0x123", core.NetworkActionTerminate)
+	assert.NoError(t, err)
+}
+
+func TestHandleNetworkAction(t *testing.T) {
+	data := fftypes.JSONAnyPtr(`
+[
+  {
+		"address": "0x1C197604587F046FD40684A8f21f4609FB811A7b",
+		"blockNumber": "38011",
+		"transactionIndex": "0x0",
+		"transactionHash": "0xc26df2bf1a733e9249372d61eb11bd8662d26c8129df76890b1beb2f6fa72628",
+		"data": {
+			"author": "0X91D2B4381A4CD5C7C0F27565A7D4B829844C8635",
+			"namespace": "firefly:terminate",
+			"uuids": "0x0000000000000000000000000000000000000000000000000000000000000000",
+			"batchHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
+			"payloadRef": "",
+			"contexts": []
+    },
+		"subId": "sb-b5b97a4e-a317-4053-6400-1474650efcb5",
+		"signature": "BatchPin(address,uint256,string,bytes32,bytes32,string,bytes32[])",
+		"logIndex": "50",
+		"timestamp": "1620576488"
+  }
+]`)
+
+	em := &blockchainmocks.Callbacks{}
+	e := &Ethereum{
+		callbacks: em,
+	}
+	e.initInfo.sub = &subscription{
+		ID: "sb-b5b97a4e-a317-4053-6400-1474650efcb5",
+	}
+
+	expectedSigningKeyRef := &core.VerifierRef{
+		Type:  core.VerifierTypeEthAddress,
+		Value: "0x91d2b4381a4cd5c7c0f27565a7d4b829844c8635",
+	}
+
+	em.On("BlockchainNetworkAction", "terminate", mock.Anything, expectedSigningKeyRef).Return(nil)
+
+	var events []interface{}
+	err := json.Unmarshal(data.Bytes(), &events)
+	assert.NoError(t, err)
+	err = e.handleMessageBatch(context.Background(), events)
+	assert.NoError(t, err)
+
+	em.AssertExpectations(t)
+
+}
diff --git a/internal/blockchain/fabric/fabric_test.go b/internal/blockchain/fabric/fabric_test.go
index dfe1ff753..1ca27875a 100644
--- a/internal/blockchain/fabric/fabric_test.go
+++ b/internal/blockchain/fabric/fabric_test.go
@@ -46,9 +46,8 @@ var utConfig = config.RootSection("fab_unit_tests")
 var utFabconnectConf = utConfig.SubSection(FabconnectConfigKey)
 var signer = "orgMSP::x509::CN=signer001,OU=client::CN=fabric-ca"
 
-func resetConf() {
+func resetConf(e *Fabric) {
 	coreconfig.Reset()
-	e := &Fabric{}
 	e.InitConfig(utConfig)
 }
 
@@ -57,15 +56,15 @@ func newTestFabric() (*Fabric, func()) {
 	em := &blockchainmocks.Callbacks{}
 	wsm := &wsmocks.WSClient{}
 	e := &Fabric{
-		ctx:            ctx,
-		client:         resty.New().SetBaseURL("http://localhost:12345"),
-		defaultChannel: "firefly",
-		chaincode:      "firefly",
-		topic:          "topic1",
-		prefixShort:    defaultPrefixShort,
-		prefixLong:     defaultPrefixLong,
-		callbacks:      em,
-		wsconn:         wsm,
+		ctx:              ctx,
+		client:           resty.New().SetBaseURL("http://localhost:12345"),
+		defaultChannel:   "firefly",
+		fireflyChaincode: "firefly",
+		topic:            "topic1",
+		prefixShort:      defaultPrefixShort,
+		prefixLong:       defaultPrefixLong,
+		callbacks:        em,
+		wsconn:           wsm,
 	}
 	return e, func() {
 		cancel()
@@ -105,28 +104,17 @@ func testFFIMethod() *core.FFIMethod {
 func TestInitMissingURL(t *testing.T) {
 	e, cancel := newTestFabric()
 	defer cancel()
-	resetConf()
+	resetConf(e)
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
 	assert.Regexp(t, "FF10138.*url", err)
 }
 
-func TestInitMissingChaincode(t *testing.T) {
-	e, cancel := newTestFabric()
-	defer cancel()
-	resetConf()
-	utFabconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
-	utFabconnectConf.Set(FabconnectConfigTopic, "topic1")
-
-	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
-	assert.Regexp(t, "FF10138.*chaincode", err)
-}
-
 func TestInitMissingTopic(t *testing.T) {
 	e, cancel := newTestFabric()
 	defer cancel()
-	resetConf()
+	resetConf(e)
 	utFabconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
-	utFabconnectConf.Set(FabconnectConfigChaincode, "Firefly")
+	utFabconnectConf.Set(FabconnectConfigChaincodeDeprecated, "Firefly")
 	utFabconnectConf.Set(FabconnectConfigSigner, "signer001")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
@@ -165,10 +153,10 @@ func TestInitAllNewStreamsAndWSEvent(t *testing.T) {
 			return httpmock.NewJsonResponderOrPanic(200, subscription{ID: "sub12345"})(req)
 		})
 
-	resetConf()
+	resetConf(e)
 	utFabconnectConf.Set(ffresty.HTTPConfigURL, httpURL)
 	utFabconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utFabconnectConf.Set(FabconnectConfigChaincode, "firefly")
+	utFabconnectConf.Set(FabconnectConfigChaincodeDeprecated, "firefly")
 	utFabconnectConf.Set(FabconnectConfigSigner, "signer001")
 	utFabconnectConf.Set(FabconnectConfigTopic, "topic1")
 
@@ -177,14 +165,17 @@ func TestInitAllNewStreamsAndWSEvent(t *testing.T) {
 
 	assert.Equal(t, "fabric", e.Name())
 	assert.Equal(t, core.VerifierTypeMSPIdentity, e.VerifierType())
+
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.NoError(t, err)
+	err = e.Start()
+	assert.NoError(t, err)
+
 	assert.Equal(t, 4, httpmock.GetTotalCallCount())
 	assert.Equal(t, "es12345", e.initInfo.stream.ID)
 	assert.Equal(t, "sub12345", e.initInfo.sub.ID)
 	assert.NotNil(t, e.Capabilities())
 
-	err = e.Start()
-	assert.NoError(t, err)
-
 	startupMessage := <-toServer
 	assert.Equal(t, `{"type":"listen","topic":"topic1"}`, startupMessage)
 	startupMessage = <-toServer
@@ -205,9 +196,9 @@ func TestWSInitFail(t *testing.T) {
 	e, cancel := newTestFabric()
 	defer cancel()
 
-	resetConf()
+	resetConf(e)
 	utFabconnectConf.Set(ffresty.HTTPConfigURL, "!!!://")
-	utFabconnectConf.Set(FabconnectConfigChaincode, "firefly")
+	utFabconnectConf.Set(FabconnectConfigChaincodeDeprecated, "firefly")
 	utFabconnectConf.Set(FabconnectConfigSigner, "signer001")
 	utFabconnectConf.Set(FabconnectConfigTopic, "topic1")
 
@@ -216,17 +207,30 @@ func TestWSInitFail(t *testing.T) {
 
 }
 
-func TestWSConnectFail(t *testing.T) {
+func TestInitMissingInstance(t *testing.T) {
 
-	wsm := &wsmocks.WSClient{}
-	e := &Fabric{
-		ctx:    context.Background(),
-		wsconn: wsm,
-	}
-	wsm.On("Connect").Return(fmt.Errorf("pop"))
+	e, cancel := newTestFabric()
+	defer cancel()
+
+	mockedClient := &http.Client{}
+	httpmock.ActivateNonDefault(mockedClient)
+	defer httpmock.DeactivateAndReset()
+
+	httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, []eventStream{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345"}))
+
+	resetConf(e)
+	utFabconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
+	utFabconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
+	utFabconnectConf.Set(FabconnectConfigTopic, "topic1")
+
+	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.Regexp(t, "FF10138.*chaincode", err)
 
-	err := e.Start()
-	assert.EqualError(t, err, "pop")
 }
 
 func TestInitAllExistingStreams(t *testing.T) {
@@ -245,23 +249,217 @@ func TestInitAllExistingStreams(t *testing.T) {
 			{ID: "sub12345", Stream: "es12345", Name: "BatchPin"},
 		}))
 
-	resetConf()
+	resetConf(e)
+	utFabconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
+	utFabconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
+	utFabconnectConf.Set(FabconnectConfigChaincodeDeprecated, "firefly")
+	utFabconnectConf.Set(FabconnectConfigSigner, "signer001")
+	utFabconnectConf.Set(FabconnectConfigTopic, "topic1")
+
+	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.NoError(t, err)
+
+	assert.Equal(t, 2, httpmock.GetTotalCallCount())
+	assert.Equal(t, "es12345", e.initInfo.stream.ID)
+	assert.Equal(t, "sub12345", e.initInfo.sub.ID)
+
+}
+
+func TestInitNewConfig(t *testing.T) {
+
+	e, cancel := newTestFabric()
+	defer cancel()
+
+	mockedClient := &http.Client{}
+	httpmock.ActivateNonDefault(mockedClient)
+	defer httpmock.DeactivateAndReset()
+
+	httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", WebSocket: eventStreamWebsocket{Topic: "topic1"}}}))
+	httpmock.RegisterResponder("GET", "http://localhost:12345/subscriptions",
+		httpmock.NewJsonResponderOrPanic(200, []subscription{
+			{ID: "sub12345", Stream: "es12345", Name: "BatchPin"},
+		}))
+
+	resetConf(e)
 	utFabconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
 	utFabconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utFabconnectConf.Set(FabconnectConfigChaincode, "firefly")
 	utFabconnectConf.Set(FabconnectConfigSigner, "signer001")
 	utFabconnectConf.Set(FabconnectConfigTopic, "topic1")
+	utConfig.AddKnownKey(FireFlyContractConfigKey+".0."+FireFlyContractChaincode, "firefly")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.NoError(t, err)
 
 	assert.Equal(t, 2, httpmock.GetTotalCallCount())
 	assert.Equal(t, "es12345", e.initInfo.stream.ID)
 	assert.Equal(t, "sub12345", e.initInfo.sub.ID)
 
+}
+
+func TestInitNewConfigError(t *testing.T) {
+
+	e, cancel := newTestFabric()
+	defer cancel()
+
+	mockedClient := &http.Client{}
+	httpmock.ActivateNonDefault(mockedClient)
+	defer httpmock.DeactivateAndReset()
+
+	httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", WebSocket: eventStreamWebsocket{Topic: "topic1"}}}))
+
+	resetConf(e)
+	utFabconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
+	utFabconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
+	utFabconnectConf.Set(FabconnectConfigSigner, "signer001")
+	utFabconnectConf.Set(FabconnectConfigTopic, "topic1")
+	utConfig.AddKnownKey(FireFlyContractConfigKey+".0."+FireFlyContractChaincode, "")
+
+	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.Regexp(t, "FF10138", err)
+
+}
+
+func TestInitNewConfigBadIndex(t *testing.T) {
+
+	e, cancel := newTestFabric()
+	defer cancel()
+
+	mockedClient := &http.Client{}
+	httpmock.ActivateNonDefault(mockedClient)
+	defer httpmock.DeactivateAndReset()
+
+	httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", WebSocket: eventStreamWebsocket{Topic: "topic1"}}}))
+
+	resetConf(e)
+	utFabconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
+	utFabconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
+	utFabconnectConf.Set(FabconnectConfigSigner, "signer001")
+	utFabconnectConf.Set(FabconnectConfigTopic, "topic1")
+	utConfig.AddKnownKey(FireFlyContractConfigKey+".0."+FireFlyContractChaincode, "")
+
+	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
 	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{
+		Active: core.FireFlyContractInfo{Index: 1},
+	})
+	assert.Regexp(t, "FF10396", err)
 
 }
 
+func TestInitTerminateContract(t *testing.T) {
+	e, _ := newTestFabric()
+
+	contracts := &core.FireFlyContracts{}
+	event := &blockchain.Event{
+		ProtocolID: "000000000011/000000/000050",
+		Info: fftypes.JSONObject{
+			"chaincodeId": "firefly",
+		},
+	}
+
+	mockedClient := &http.Client{}
+	httpmock.ActivateNonDefault(mockedClient)
+	defer httpmock.DeactivateAndReset()
+
+	httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, []eventStream{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345"}))
+	httpmock.RegisterResponder("GET", "http://localhost:12345/subscriptions",
+		httpmock.NewJsonResponderOrPanic(200, []subscription{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/subscriptions",
+		httpmock.NewJsonResponderOrPanic(200, subscription{ID: "sb-1"}))
+
+	resetConf(e)
+	utFabconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
+	utFabconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
+	utFabconnectConf.Set(FabconnectConfigTopic, "topic1")
+	utConfig.AddKnownKey(FireFlyContractConfigKey+".0."+FireFlyContractChaincode, "firefly")
+	utConfig.AddKnownKey(FireFlyContractConfigKey+".1."+FireFlyContractChaincode, "firefly2")
+
+	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, contracts)
+	assert.NoError(t, err)
+
+	httpmock.RegisterResponder("POST", "http://localhost:12345/subscriptions",
+		httpmock.NewJsonResponderOrPanic(200, subscription{ID: "sb-2"}))
+
+	err = e.TerminateContract(e.ctx, contracts, event)
+	assert.NoError(t, err)
+
+	assert.Equal(t, 1, contracts.Active.Index)
+	assert.Equal(t, fftypes.JSONObject{
+		"chaincode":    "firefly2",
+		"fromBlock":    "oldest",
+		"subscription": "sb-2",
+	}, contracts.Active.Info)
+	assert.Len(t, contracts.Terminated, 1)
+	assert.Equal(t, 0, contracts.Terminated[0].Index)
+	assert.Equal(t, fftypes.JSONObject{
+		"chaincode":    "firefly",
+		"fromBlock":    "oldest",
+		"subscription": "sb-1",
+	}, contracts.Terminated[0].Info)
+	assert.Equal(t, event.ProtocolID, contracts.Terminated[0].FinalEvent)
+}
+
+func TestInitTerminateContractIgnore(t *testing.T) {
+	e, _ := newTestFabric()
+
+	contracts := &core.FireFlyContracts{}
+	event := &blockchain.Event{
+		ProtocolID: "000000000011/000000/000050",
+		Info: fftypes.JSONObject{
+			"chaincodeId": "firefly2",
+		},
+	}
+
+	mockedClient := &http.Client{}
+	httpmock.ActivateNonDefault(mockedClient)
+	defer httpmock.DeactivateAndReset()
+
+	httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, []eventStream{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/eventstreams",
+		httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345"}))
+	httpmock.RegisterResponder("GET", "http://localhost:12345/subscriptions",
+		httpmock.NewJsonResponderOrPanic(200, []subscription{}))
+	httpmock.RegisterResponder("POST", "http://localhost:12345/subscriptions",
+		httpmock.NewJsonResponderOrPanic(200, subscription{ID: "sb-1"}))
+
+	resetConf(e)
+	utFabconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
+	utFabconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
+	utFabconnectConf.Set(FabconnectConfigTopic, "topic1")
+	utConfig.AddKnownKey(FireFlyContractConfigKey+".0."+FireFlyContractChaincode, "firefly")
+
+	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, contracts)
+	assert.NoError(t, err)
+
+	err = e.TerminateContract(e.ctx, contracts, event)
+	assert.NoError(t, err)
+
+	assert.Equal(t, 0, contracts.Active.Index)
+	assert.Equal(t, fftypes.JSONObject{
+		"chaincode":    "firefly",
+		"fromBlock":    "oldest",
+		"subscription": "sb-1",
+	}, contracts.Active.Info)
+	assert.Len(t, contracts.Terminated, 0)
+}
+
 func TestStreamQueryError(t *testing.T) {
 
 	e, cancel := newTestFabric()
@@ -274,18 +472,16 @@ func TestStreamQueryError(t *testing.T) {
 	httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams",
 		httpmock.NewStringResponder(500, `pop`))
 
-	resetConf()
+	resetConf(e)
 	utFabconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
 	utFabconnectConf.Set(ffresty.HTTPConfigRetryEnabled, false)
 	utFabconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utFabconnectConf.Set(FabconnectConfigChaincode, "firefly")
+	utFabconnectConf.Set(FabconnectConfigChaincodeDeprecated, "firefly")
 	utFabconnectConf.Set(FabconnectConfigSigner, "signer001")
 	utFabconnectConf.Set(FabconnectConfigTopic, "topic1")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
-
-	assert.Regexp(t, "FF10284", err)
-	assert.Regexp(t, "pop", err)
+	assert.Regexp(t, "FF10284.*pop", err)
 
 }
 
@@ -303,18 +499,16 @@ func TestStreamCreateError(t *testing.T) {
 	httpmock.RegisterResponder("POST", "http://localhost:12345/eventstreams",
 		httpmock.NewStringResponder(500, `pop`))
 
-	resetConf()
+	resetConf(e)
 	utFabconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
 	utFabconnectConf.Set(ffresty.HTTPConfigRetryEnabled, false)
 	utFabconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utFabconnectConf.Set(FabconnectConfigChaincode, "firefly")
+	utFabconnectConf.Set(FabconnectConfigChaincodeDeprecated, "firefly")
 	utFabconnectConf.Set(FabconnectConfigSigner, "signer001")
 	utFabconnectConf.Set(FabconnectConfigTopic, "topic1")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
-
-	assert.Regexp(t, "FF10284", err)
-	assert.Regexp(t, "pop", err)
+	assert.Regexp(t, "FF10284.*pop", err)
 
 }
 
@@ -334,18 +528,18 @@ func TestSubQueryError(t *testing.T) {
 	httpmock.RegisterResponder("GET", "http://localhost:12345/subscriptions",
 		httpmock.NewStringResponder(500, `pop`))
 
-	resetConf()
+	resetConf(e)
 	utFabconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
 	utFabconnectConf.Set(ffresty.HTTPConfigRetryEnabled, false)
 	utFabconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utFabconnectConf.Set(FabconnectConfigChaincode, "firefly")
+	utFabconnectConf.Set(FabconnectConfigChaincodeDeprecated, "firefly")
 	utFabconnectConf.Set(FabconnectConfigSigner, "signer001")
 	utFabconnectConf.Set(FabconnectConfigTopic, "topic1")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
-
-	assert.Regexp(t, "FF10284", err)
-	assert.Regexp(t, "pop", err)
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.Regexp(t, "FF10284.*pop", err)
 
 }
 
@@ -367,18 +561,18 @@ func TestSubQueryCreateError(t *testing.T) {
 	httpmock.RegisterResponder("POST", "http://localhost:12345/subscriptions",
 		httpmock.NewStringResponder(500, `pop`))
 
-	resetConf()
+	resetConf(e)
 	utFabconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345")
 	utFabconnectConf.Set(ffresty.HTTPConfigRetryEnabled, false)
 	utFabconnectConf.Set(ffresty.HTTPCustomClient, mockedClient)
-	utFabconnectConf.Set(FabconnectConfigChaincode, "firefly")
+	utFabconnectConf.Set(FabconnectConfigChaincodeDeprecated, "firefly")
 	utFabconnectConf.Set(FabconnectConfigSigner, "signer001")
 	utFabconnectConf.Set(FabconnectConfigTopic, "topic1")
 
 	err := e.Init(e.ctx, utConfig, &blockchainmocks.Callbacks{}, &metricsmocks.Manager{})
-
-	assert.Regexp(t, "FF10284", err)
-	assert.Regexp(t, "pop", err)
+	assert.NoError(t, err)
+	err = e.ConfigureContract(e.ctx, &core.FireFlyContracts{})
+	assert.Regexp(t, "FF10284.*pop", err)
 
 }
 
@@ -1202,8 +1396,7 @@ func TestAddSubscriptionFail(t *testing.T) {
 
 	err := e.AddContractListener(context.Background(), sub)
 
-	assert.Regexp(t, "FF10284", err)
-	assert.Regexp(t, "pop", err)
+	assert.Regexp(t, "FF10284.*pop", err)
 }
 
 func TestDeleteSubscription(t *testing.T) {
@@ -1253,8 +1446,7 @@ func TestDeleteSubscriptionFail(t *testing.T) {
 
 	err := e.DeleteContractListener(context.Background(), sub)
 
-	assert.Regexp(t, "FF10284", err)
-	assert.Regexp(t, "pop", err)
+	assert.Regexp(t, "FF10284.*pop", err)
 }
 
 func TestHandleMessageContractEvent(t *testing.T) {
@@ -1684,3 +1876,68 @@ func TestGenerateEventSignature(t *testing.T) {
 	signature := e.GenerateEventSignature(context.Background(), &core.FFIEventDefinition{Name: "Changed"})
 	assert.Equal(t, "Changed", signature)
 }
+
+func TestSubmitNetworkAction(t *testing.T) {
+
+	e, cancel := newTestFabric()
+	defer cancel()
+	httpmock.ActivateNonDefault(e.client.GetClient())
+	defer httpmock.DeactivateAndReset()
+
+	signer := "signer001"
+
+	httpmock.RegisterResponder("POST", `http://localhost:12345/transactions`,
+		func(req *http.Request) (*http.Response, error) {
+			var body map[string]interface{}
+			json.NewDecoder(req.Body).Decode(&body)
+			assert.Equal(t, signer, (body["headers"].(map[string]interface{}))["signer"])
+			assert.Equal(t, "0x0000000000000000000000000000000000000000000000000000000000000000", (body["args"].(map[string]interface{}))["uuids"])
+			assert.Equal(t, "0x0000000000000000000000000000000000000000000000000000000000000000", (body["args"].(map[string]interface{}))["batchHash"])
+			assert.Equal(t, "", (body["args"].(map[string]interface{}))["payloadRef"])
+			return httpmock.NewJsonResponderOrPanic(200, "")(req)
+		})
+
+	err := e.SubmitNetworkAction(context.Background(), nil, signer, core.NetworkActionTerminate)
+	assert.NoError(t, err)
+
+}
+
+func TestHandleNetworkAction(t *testing.T) {
+	data := []byte(`
+[
+  {
+		"chaincodeId": "firefly",
+		"blockNumber": 91,
+		"transactionId": "ce79343000e851a0c742f63a733ce19a5f8b9ce1c719b6cecd14f01bcf81fff2",
+		"transactionIndex": 2,
+		"eventIndex": 50,
+		"eventName": "BatchPin",
+		"payload": "eyJzaWduZXIiOiJ1MHZnd3U5czAwLXg1MDk6OkNOPXVzZXIyLE9VPWNsaWVudDo6Q049ZmFicmljLWNhLXNlcnZlciIsInRpbWVzdGFtcCI6eyJzZWNvbmRzIjoxNjMwMDMxNjY3LCJuYW5vcyI6NzkxNDk5MDAwfSwibmFtZXNwYWNlIjoiZmlyZWZseTp0ZXJtaW5hdGUiLCJ1dWlkcyI6IjB4MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImJhdGNoSGFzaCI6IjB4MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsInBheWxvYWRSZWYiOiIiLCJjb250ZXh0cyI6W119",
+		"subId": "sb-0910f6a8-7bd6-4ced-453e-2db68149ce8e"
+  }
+]`)
+
+	em := &blockchainmocks.Callbacks{}
+	e := &Fabric{
+		callbacks: em,
+	}
+	e.initInfo.sub = &subscription{
+		ID: "sb-0910f6a8-7bd6-4ced-453e-2db68149ce8e",
+	}
+
+	expectedSigningKeyRef := &core.VerifierRef{
+		Type:  core.VerifierTypeMSPIdentity,
+		Value: "u0vgwu9s00-x509::CN=user2,OU=client::CN=fabric-ca-server",
+	}
+
+	em.On("BlockchainNetworkAction", "terminate", mock.Anything, expectedSigningKeyRef).Return(nil)
+
+	var events []interface{}
+	err := json.Unmarshal(data, &events)
+	assert.NoError(t, err)
+	err = e.handleMessageBatch(context.Background(), events)
+	assert.NoError(t, err)
+
+	em.AssertExpectations(t)
+
+}
diff --git a/internal/database/sqlcommon/namespace_sql_test.go b/internal/database/sqlcommon/namespace_sql_test.go
index 5861f0f27..b930de9a0 100644
--- a/internal/database/sqlcommon/namespace_sql_test.go
+++ b/internal/database/sqlcommon/namespace_sql_test.go
@@ -45,6 +45,11 @@ func TestNamespacesE2EWithDB(t *testing.T) {
 		Type:    core.NamespaceTypeLocal,
 		Name:    "namespace1",
 		Created: fftypes.Now(),
+		Contracts: core.FireFlyContracts{
+			Active: core.FireFlyContractInfo{
+				Index: 1,
+			},
+		},
 	}
 
 	s.callbacks.On("UUIDCollectionEvent", database.CollectionNamespaces, core.ChangeEventTypeCreated, mock.Anything, mock.Anything).Return()
@@ -246,7 +251,7 @@ func TestGetNamespaceByIDSuccess(t *testing.T) {
 		Description: "foo",
 		Created:     currTime,
 	}
-	mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{"id", "message", "type", "name", "description", "created"}).AddRow(nsID.String(), msgID.String(), core.NamespaceTypeLocal, "ns1", "foo", currTime.String()))
+	mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{"id", "message", "type", "name", "description", "created", "firefly_contracts"}).AddRow(nsID.String(), msgID.String(), core.NamespaceTypeLocal, "ns1", "foo", currTime.String(), ""))
 	ns, err := s.GetNamespaceByID(context.Background(), nsID)
 	assert.NoError(t, err)
 	assert.Equal(t, nsMock, ns)
diff --git a/internal/events/network_action_test.go b/internal/events/network_action_test.go
new file mode 100644
index 000000000..b27bd673d
--- /dev/null
+++ b/internal/events/network_action_test.go
@@ -0,0 +1,185 @@
+// Copyright © 2021 Kaleido, Inc.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// 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 events
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/hyperledger/firefly-common/pkg/fftypes"
+	"github.com/hyperledger/firefly/mocks/blockchainmocks"
+	"github.com/hyperledger/firefly/mocks/databasemocks"
+	"github.com/hyperledger/firefly/mocks/identitymanagermocks"
+	"github.com/hyperledger/firefly/mocks/txcommonmocks"
+	"github.com/hyperledger/firefly/pkg/blockchain"
+	"github.com/hyperledger/firefly/pkg/core"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestNetworkAction(t *testing.T) {
+	em, cancel := newTestEventManager(t)
+	defer cancel()
+
+	event := &blockchain.Event{ProtocolID: "0001"}
+	verifier := &core.VerifierRef{
+		Type:  core.VerifierTypeEthAddress,
+		Value: "0x1234",
+	}
+
+	mbi := &blockchainmocks.Plugin{}
+	mdi := em.database.(*databasemocks.Plugin)
+	mth := em.txHelper.(*txcommonmocks.Helper)
+	mii := em.identity.(*identitymanagermocks.Manager)
+
+	mii.On("FindIdentityForVerifier", em.ctx, []core.IdentityType{core.IdentityTypeOrg}, "ff_system", verifier).Return(&core.Identity{}, nil)
+	mdi.On("GetBlockchainEventByProtocolID", em.ctx, "ff_system", (*fftypes.UUID)(nil), "0001").Return(nil, nil)
+	mth.On("InsertBlockchainEvent", em.ctx, mock.MatchedBy(func(be *core.BlockchainEvent) bool {
+		return be.ProtocolID == "0001"
+	})).Return(nil)
+	mdi.On("InsertEvent", em.ctx, mock.Anything).Return(nil)
+	mdi.On("GetNamespace", em.ctx, "ff_system").Return(&core.Namespace{}, nil)
+	mdi.On("UpsertNamespace", em.ctx, mock.AnythingOfType("*core.Namespace"), true).Return(nil)
+	mbi.On("TerminateContract", em.ctx, mock.AnythingOfType("*core.FireFlyContracts"), mock.AnythingOfType("*blockchain.Event")).Return(nil)
+
+	err := em.BlockchainNetworkAction(mbi, "terminate", event, verifier)
+	assert.NoError(t, err)
+
+	mbi.AssertExpectations(t)
+	mdi.AssertExpectations(t)
+	mth.AssertExpectations(t)
+	mii.AssertExpectations(t)
+}
+
+func TestNetworkActionUnknownIdentity(t *testing.T) {
+	em, cancel := newTestEventManager(t)
+	defer cancel()
+
+	verifier := &core.VerifierRef{
+		Type:  core.VerifierTypeEthAddress,
+		Value: "0x1234",
+	}
+
+	mbi := &blockchainmocks.Plugin{}
+	mii := em.identity.(*identitymanagermocks.Manager)
+
+	mii.On("FindIdentityForVerifier", em.ctx, []core.IdentityType{core.IdentityTypeOrg}, "ff_system", verifier).Return(nil, fmt.Errorf("pop")).Once()
+	mii.On("FindIdentityForVerifier", em.ctx, []core.IdentityType{core.IdentityTypeOrg}, "ff_system", verifier).Return(nil, nil).Once()
+
+	err := em.BlockchainNetworkAction(mbi, "terminate", &blockchain.Event{}, verifier)
+	assert.NoError(t, err)
+
+	mbi.AssertExpectations(t)
+	mii.AssertExpectations(t)
+}
+
+func TestNetworkActionNonRootIdentity(t *testing.T) {
+	em, cancel := newTestEventManager(t)
+	defer cancel()
+
+	verifier := &core.VerifierRef{
+		Type:  core.VerifierTypeEthAddress,
+		Value: "0x1234",
+	}
+
+	mbi := &blockchainmocks.Plugin{}
+	mii := em.identity.(*identitymanagermocks.Manager)
+
+	mii.On("FindIdentityForVerifier", em.ctx, []core.IdentityType{core.IdentityTypeOrg}, "ff_system", verifier).Return(&core.Identity{
+		IdentityBase: core.IdentityBase{
+			Parent: fftypes.NewUUID(),
+		},
+	}, nil)
+
+	err := em.BlockchainNetworkAction(mbi, "terminate", &blockchain.Event{}, verifier)
+	assert.NoError(t, err)
+
+	mbi.AssertExpectations(t)
+	mii.AssertExpectations(t)
+}
+
+func TestNetworkActionUnknown(t *testing.T) {
+	em, cancel := newTestEventManager(t)
+	defer cancel()
+
+	verifier := &core.VerifierRef{
+		Type:  core.VerifierTypeEthAddress,
+		Value: "0x1234",
+	}
+
+	mbi := &blockchainmocks.Plugin{}
+	mii := em.identity.(*identitymanagermocks.Manager)
+
+	mii.On("FindIdentityForVerifier", em.ctx, []core.IdentityType{core.IdentityTypeOrg}, "ff_system", verifier).Return(&core.Identity{}, nil)
+
+	err := em.BlockchainNetworkAction(mbi, "bad", &blockchain.Event{}, verifier)
+	assert.NoError(t, err)
+
+	mbi.AssertExpectations(t)
+	mii.AssertExpectations(t)
+}
+
+func TestActionTerminateQueryFail(t *testing.T) {
+	em, cancel := newTestEventManager(t)
+	defer cancel()
+
+	mbi := &blockchainmocks.Plugin{}
+	mdi := em.database.(*databasemocks.Plugin)
+
+	mdi.On("GetNamespace", em.ctx, "ff_system").Return(nil, fmt.Errorf("pop"))
+
+	err := em.actionTerminate(mbi, &blockchain.Event{})
+	assert.EqualError(t, err, "pop")
+
+	mbi.AssertExpectations(t)
+	mdi.AssertExpectations(t)
+}
+
+func TestActionTerminateFail(t *testing.T) {
+	em, cancel := newTestEventManager(t)
+	defer cancel()
+
+	mbi := &blockchainmocks.Plugin{}
+	mdi := em.database.(*databasemocks.Plugin)
+
+	mdi.On("GetNamespace", em.ctx, "ff_system").Return(&core.Namespace{}, nil)
+	mbi.On("TerminateContract", em.ctx, mock.AnythingOfType("*core.FireFlyContracts"), mock.AnythingOfType("*blockchain.Event")).Return(fmt.Errorf("pop"))
+
+	err := em.actionTerminate(mbi, &blockchain.Event{})
+	assert.EqualError(t, err, "pop")
+
+	mbi.AssertExpectations(t)
+	mdi.AssertExpectations(t)
+}
+
+func TestActionTerminateUpsertFail(t *testing.T) {
+	em, cancel := newTestEventManager(t)
+	defer cancel()
+
+	mbi := &blockchainmocks.Plugin{}
+	mdi := em.database.(*databasemocks.Plugin)
+
+	mdi.On("GetNamespace", em.ctx, "ff_system").Return(&core.Namespace{}, nil)
+	mdi.On("UpsertNamespace", em.ctx, mock.AnythingOfType("*core.Namespace"), true).Return(fmt.Errorf("pop"))
+	mbi.On("TerminateContract", em.ctx, mock.AnythingOfType("*core.FireFlyContracts"), mock.AnythingOfType("*blockchain.Event")).Return(nil)
+
+	err := em.actionTerminate(mbi, &blockchain.Event{})
+	assert.EqualError(t, err, "pop")
+
+	mbi.AssertExpectations(t)
+	mdi.AssertExpectations(t)
+}
diff --git a/internal/orchestrator/bound_callbacks_test.go b/internal/orchestrator/bound_callbacks_test.go
index 665c5066a..853f7650e 100644
--- a/internal/orchestrator/bound_callbacks_test.go
+++ b/internal/orchestrator/bound_callbacks_test.go
@@ -50,6 +50,7 @@ func TestBoundCallbacks(t *testing.T) {
 	pool := &tokens.TokenPool{}
 	transfer := &tokens.TokenTransfer{}
 	approval := &tokens.TokenApproval{}
+	event := &blockchain.Event{}
 	hash := fftypes.NewRandB32()
 	opID := fftypes.NewUUID()
 
@@ -57,6 +58,10 @@ func TestBoundCallbacks(t *testing.T) {
 	err := bc.BatchPinComplete(batch, &core.VerifierRef{Value: "0x12345", Type: core.VerifierTypeEthAddress})
 	assert.EqualError(t, err, "pop")
 
+	mei.On("BlockchainNetworkAction", mbi, "terminate", event, &core.VerifierRef{Value: "0x12345", Type: core.VerifierTypeEthAddress}).Return(fmt.Errorf("pop"))
+	err = bc.BlockchainNetworkAction("terminate", event, &core.VerifierRef{Value: "0x12345", Type: core.VerifierTypeEthAddress})
+	assert.EqualError(t, err, "pop")
+
 	mom.On("SubmitOperationUpdate", mock.Anything, &operations.OperationUpdate{
 		ID:             opID,
 		Status:         core.OpStatusFailed,
diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go
index cf704956d..c2fbb1321 100644
--- a/internal/orchestrator/orchestrator_test.go
+++ b/internal/orchestrator/orchestrator_test.go
@@ -991,6 +991,9 @@ func TestStartTokensFail(t *testing.T) {
 	coreconfig.Reset()
 	or := newTestOrchestrator()
 	defer or.cleanup(t)
+	or.database = or.mdi
+	or.mdi.On("GetNamespace", mock.Anything, "ff_system").Return(&core.Namespace{}, nil)
+	or.mbi.On("ConfigureContract", mock.Anything, &core.FireFlyContracts{}).Return(nil)
 	or.mbi.On("Start").Return(nil)
 	or.mba.On("Start").Return(nil)
 	or.mem.On("Start").Return(nil)
@@ -999,6 +1002,7 @@ func TestStartTokensFail(t *testing.T) {
 	or.msd.On("Start").Return(nil)
 	or.mom.On("Start").Return(nil)
 	or.mti.On("Start").Return(fmt.Errorf("pop"))
+	or.mdi.On("UpsertNamespace", mock.Anything, mock.Anything, true).Return(nil)
 	err := or.Start()
 	assert.EqualError(t, err, "pop")
 }
@@ -1007,16 +1011,34 @@ func TestStartBlockchainsFail(t *testing.T) {
 	coreconfig.Reset()
 	or := newTestOrchestrator()
 	defer or.cleanup(t)
+	or.database = or.mdi
+	or.mdi.On("GetNamespace", mock.Anything, "ff_system").Return(&core.Namespace{}, nil)
+	or.mbi.On("ConfigureContract", mock.Anything, &core.FireFlyContracts{}).Return(nil)
 	or.mbi.On("Start").Return(fmt.Errorf("pop"))
 	or.mba.On("Start").Return(nil)
 	err := or.Start()
 	assert.EqualError(t, err, "pop")
 }
 
+func TestStartBlockchainsConfigureFail(t *testing.T) {
+	coreconfig.Reset()
+	or := newTestOrchestrator()
+	defer or.cleanup(t)
+	or.database = or.mdi
+	or.mdi.On("GetNamespace", mock.Anything, "ff_system").Return(&core.Namespace{}, nil)
+	or.mbi.On("ConfigureContract", mock.Anything, &core.FireFlyContracts{}).Return(fmt.Errorf("pop"))
+	or.mba.On("Start").Return(nil)
+	err := or.Start()
+	assert.EqualError(t, err, "pop")
+}
+
 func TestStartStopOk(t *testing.T) {
 	coreconfig.Reset()
 	or := newTestOrchestrator()
 	defer or.cleanup(t)
+	or.database = or.mdi
+	or.mdi.On("GetNamespace", mock.Anything, "ff_system").Return(&core.Namespace{}, nil)
+	or.mbi.On("ConfigureContract", mock.Anything, &core.FireFlyContracts{}).Return(nil)
 	or.mbi.On("Start").Return(nil)
 	or.mba.On("Start").Return(nil)
 	or.mem.On("Start").Return(nil)
@@ -1032,6 +1054,7 @@ func TestStartStopOk(t *testing.T) {
 	or.msd.On("WaitStop").Return(nil)
 	or.mom.On("WaitStop").Return(nil)
 	or.mae.On("WaitStop").Return(nil)
+	or.mdi.On("UpsertNamespace", mock.Anything, mock.Anything, true).Return(nil)
 	err := or.Start()
 	assert.NoError(t, err)
 	or.WaitStop()
@@ -1124,3 +1147,28 @@ func TestInitDataExchangeWithNodes(t *testing.T) {
 	err := or.initDataExchange(or.ctx)
 	assert.NoError(t, err)
 }
+
+func TestNetworkAction(t *testing.T) {
+	or := newTestOrchestrator()
+	or.blockchain = or.mbi
+	verifier := &core.VerifierRef{Value: "0x123"}
+	or.mim.On("GetNodeOwnerBlockchainKey", context.Background()).Return(verifier, nil)
+	or.mbi.On("SubmitNetworkAction", context.Background(), mock.Anything, "0x123", core.NetworkActionTerminate).Return(nil)
+	err := or.SubmitNetworkAction(context.Background(), &core.NetworkAction{Type: core.NetworkActionTerminate})
+	assert.NoError(t, err)
+}
+
+func TestNetworkActionBadKey(t *testing.T) {
+	or := newTestOrchestrator()
+	or.mim.On("GetNodeOwnerBlockchainKey", context.Background()).Return(nil, fmt.Errorf("pop"))
+	err := or.SubmitNetworkAction(context.Background(), &core.NetworkAction{Type: core.NetworkActionTerminate})
+	assert.EqualError(t, err, "pop")
+}
+
+func TestNetworkActionBadType(t *testing.T) {
+	or := newTestOrchestrator()
+	verifier := &core.VerifierRef{Value: "0x123"}
+	or.mim.On("GetNodeOwnerBlockchainKey", context.Background()).Return(verifier, nil)
+	err := or.SubmitNetworkAction(context.Background(), &core.NetworkAction{Type: "bad"})
+	assert.Regexp(t, "FF10397", err)
+}
diff --git a/pkg/core/namespace_test.go b/pkg/core/namespace_test.go
index c225584a7..769fbecd3 100644
--- a/pkg/core/namespace_test.go
+++ b/pkg/core/namespace_test.go
@@ -51,3 +51,41 @@ func TestNamespaceValidation(t *testing.T) {
 	assert.NotNil(t, ns.Message)
 
 }
+
+func TestFireFlyContractsDatabaseSerialization(t *testing.T) {
+	contracts1 := &FireFlyContracts{
+		Active: FireFlyContractInfo{
+			Index: 1,
+			Info:  fftypes.JSONObject{"address": "0x1234"},
+		},
+		Terminated: []FireFlyContractInfo{
+			{
+				Index:      0,
+				Info:       fftypes.JSONObject{"address": "0x0000"},
+				FinalEvent: "50",
+			},
+		},
+	}
+
+	// Verify it serializes as bytes to the database
+	val1, err := contracts1.Value()
+	assert.NoError(t, err)
+	assert.Equal(t, `{"active":{"index":1,"info":{"address":"0x1234"}},"terminated":[{"index":0,"finalEvent":"50","info":{"address":"0x0000"}}]}`, string(val1.([]byte)))
+
+	// Verify it restores ok
+	contracts2 := &FireFlyContracts{}
+	err = contracts2.Scan(val1)
+	assert.NoError(t, err)
+	assert.Equal(t, 1, contracts2.Active.Index)
+	assert.Equal(t, fftypes.JSONObject{"address": "0x1234"}, contracts2.Active.Info)
+	assert.Len(t, contracts2.Terminated, 1)
+
+	// Verify it ignores a blank string
+	err = contracts2.Scan("")
+	assert.NoError(t, err)
+	assert.Equal(t, 1, contracts2.Active.Index)
+
+	// Out of luck with anything else
+	err = contracts2.Scan(false)
+	assert.Regexp(t, "FF00105", err)
+}