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 @@ +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 @@ +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`|`` |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`|`` +|instance|The Ethereum address of the FireFly BatchPin smart contract that has been deployed to the blockchain (deprecated - use fireflyContract[].address)|Address `string`|`` |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`|`` +|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`|`` + ## 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`|`` +|chaincode|The name of the Fabric chaincode that FireFly will use for BatchPin transactions (deprecated - use fireflyContract[].chaincode)|`string`|`` |channel|The Fabric channel that FireFly will use for BatchPin transactions|`string`|`` |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`|`` +|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`|`` + ## 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`|`` +|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`|`` + ## 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`|`` +|chaincode|The name of the Fabric chaincode that FireFly will use for BatchPin transactions (deprecated - use fireflyContract[].chaincode)|`string`|`` |channel|The Fabric channel that FireFly will use for BatchPin transactions|`string`|`` |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`|`` +|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`|`` + ## 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`:
`"local"`
`"broadcast"`
`"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/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/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/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/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/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/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/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/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/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.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/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.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/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/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/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) +} 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 {