From bf9b6c4252c28749a65263d572614a90ab70d864 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Thu, 30 May 2024 00:34:58 -0400 Subject: [PATCH 01/43] Add WIP cardano support Signed-off-by: Simon Gellis --- internal/blockchain/bifactory/factory.go | 2 + internal/blockchain/cardano/cardano.go | 354 +++++++++++++++++++++++ internal/blockchain/cardano/config.go | 36 +++ internal/coremsgs/en_error_messages.go | 2 + internal/networkmap/did.go | 11 + manifest.json | 4 + pkg/core/verifier.go | 2 + 7 files changed, 411 insertions(+) create mode 100644 internal/blockchain/cardano/cardano.go create mode 100644 internal/blockchain/cardano/config.go diff --git a/internal/blockchain/bifactory/factory.go b/internal/blockchain/bifactory/factory.go index 7171210be7..40a835ca5e 100644 --- a/internal/blockchain/bifactory/factory.go +++ b/internal/blockchain/bifactory/factory.go @@ -21,6 +21,7 @@ import ( "github.com/hyperledger/firefly-common/pkg/config" "github.com/hyperledger/firefly-common/pkg/i18n" + "github.com/hyperledger/firefly/internal/blockchain/cardano" "github.com/hyperledger/firefly/internal/blockchain/ethereum" "github.com/hyperledger/firefly/internal/blockchain/fabric" "github.com/hyperledger/firefly/internal/blockchain/tezos" @@ -30,6 +31,7 @@ import ( ) var pluginsByType = map[string]func() blockchain.Plugin{ + (*cardano.Cardano)(nil).Name(): func() blockchain.Plugin { return &cardano.Cardano{} }, (*ethereum.Ethereum)(nil).Name(): func() blockchain.Plugin { return ðereum.Ethereum{} }, (*fabric.Fabric)(nil).Name(): func() blockchain.Plugin { return &fabric.Fabric{} }, (*tezos.Tezos)(nil).Name(): func() blockchain.Plugin { return &tezos.Tezos{} }, diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go new file mode 100644 index 0000000000..c9748d3dbc --- /dev/null +++ b/internal/blockchain/cardano/cardano.go @@ -0,0 +1,354 @@ +// Copyright © 2024 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 cardano + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/go-resty/resty/v2" + "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/ffresty" + "github.com/hyperledger/firefly-common/pkg/fftypes" + "github.com/hyperledger/firefly-common/pkg/i18n" + "github.com/hyperledger/firefly-common/pkg/log" + "github.com/hyperledger/firefly-common/pkg/wsclient" + "github.com/hyperledger/firefly/internal/blockchain/common" + "github.com/hyperledger/firefly/internal/cache" + "github.com/hyperledger/firefly/internal/coremsgs" + "github.com/hyperledger/firefly/internal/metrics" + "github.com/hyperledger/firefly/pkg/blockchain" + "github.com/hyperledger/firefly/pkg/core" +) + +const ( + cardanoTxStatusPending string = "Pending" +) + +const ( + ReceiptTransactionSuccess string = "TransactionSuccess" + ReceiptTransactionFailed string = "TransactionFailed" +) + +type Cardano struct { + ctx context.Context + cancelCtx context.CancelFunc + pluginTopic string + metrics metrics.Manager + capabilities *blockchain.Capabilities + callbacks common.BlockchainCallbacks + client *resty.Client + wsconn wsclient.WSClient + cardanoconnectConf config.Section + subs common.FireflySubscriptions +} + +type cardanoWSCommandPayload struct { + Type string `json:"type"` + Topic string `json:"topic,omitempty"` +} + +func (c *Cardano) Name() string { + return "cardano" +} + +func (c *Cardano) VerifierType() core.VerifierType { + return core.VerifierTypeCardanoAddress +} + +func (c *Cardano) Init(ctx context.Context, cancelCtx context.CancelFunc, conf config.Section, metrics metrics.Manager, cacheManager cache.Manager) (err error) { + c.InitConfig(conf) + cardanoconnectConf := c.cardanoconnectConf + + c.ctx = log.WithLogField(ctx, "proto", "cardano") + c.cancelCtx = cancelCtx + c.metrics = metrics + c.capabilities = &blockchain.Capabilities{} + c.callbacks = common.NewBlockchainCallbacks() + c.subs = common.NewFireflySubscriptions() + + wsConfig, err := wsclient.GenerateConfig(ctx, cardanoconnectConf) + if err == nil { + c.client, err = ffresty.New(c.ctx, cardanoconnectConf) + } + + if err != nil { + return err + } + + c.pluginTopic = cardanoconnectConf.GetString(CardanoconnectConfigTopic) + if c.pluginTopic == "" { + return i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "topic", "blockchain.cardano.cardanoconnect") + } + + if wsConfig.WSKeyPath == "" { + wsConfig.WSKeyPath = "/ws" + } + c.wsconn, err = wsclient.New(ctx, wsConfig, nil, c.afterConnect) + if err != nil { + return err + } + + go c.eventLoop() + + return nil +} + +func (c *Cardano) StartNamespace(ctx context.Context, namespace string) (err error) { + // TODO: Implement + return nil +} + +func (c *Cardano) StopNamespace(ctx context.Context, namespace string) (err error) { + // TODO: Implement + return nil +} + +func (c *Cardano) SetHandler(namespace string, handler blockchain.Callbacks) { + c.callbacks.SetHandler(namespace, handler) +} + +func (c *Cardano) SetOperationHandler(namespace string, handler core.OperationCallbacks) { + c.callbacks.SetOperationalHandler(namespace, handler) +} + +func (c *Cardano) Start() (err error) { + return c.wsconn.Connect() +} + +func (c *Cardano) Capabilities() *blockchain.Capabilities { + return c.capabilities +} + +func (c *Cardano) AddFireflySubscription(ctx context.Context, namespace *core.Namespace, contract *blockchain.MultipartyContract) (string, error) { + return "", errors.New("AddFireflySubscription not supported") +} + +func (c *Cardano) RemoveFireflySubscription(ctx context.Context, subID string) { + c.subs.RemoveSubscription(ctx, subID) +} + +func (c *Cardano) ResolveSigningKey(ctx context.Context, key string, intent blockchain.ResolveKeyIntent) (resolved string, err error) { + if key == "" { + return "", i18n.NewError(ctx, coremsgs.MsgNodeMissingBlockchainKey) + } + resolved, err = formatCardanoAddress(ctx, key) + return resolved, err +} + +func (c *Cardano) SubmitBatchPin(ctx context.Context, nsOpID, networkNamespace, signingKey string, batch *blockchain.BatchPin, location *fftypes.JSONAny) error { + return errors.New("SubmitBatchPin not supported") +} + +func (c *Cardano) SubmitNetworkAction(ctx context.Context, nsOpID string, signingKey string, action core.NetworkActionType, location *fftypes.JSONAny) error { + return errors.New("SubmitNetworkAction not supported") +} + +func (c *Cardano) DeployContract(ctx context.Context, nsOpID, signingKey string, definition, contract *fftypes.JSONAny, input []interface{}, options map[string]interface{}) (submissionRejected bool, err error) { + return true, errors.New("DeployContract not supported") +} + +func (c *Cardano) ValidateInvokeRequest(ctx context.Context, parsedMethod interface{}, input map[string]interface{}, hasMessage bool) error { + return errors.New("ValidateInvokeRequest not supported") +} + +func (c *Cardano) InvokeContract(ctx context.Context, nsOpID string, signingKey string, location *fftypes.JSONAny, parsedMethod interface{}, input map[string]interface{}, options map[string]interface{}, batch *blockchain.BatchPin) (bool, error) { + return true, errors.New("InvokeContract not supported") +} + +func (c *Cardano) QueryContract(ctx context.Context, signingKey string, location *fftypes.JSONAny, parsedMethod interface{}, input map[string]interface{}, options map[string]interface{}) (interface{}, error) { + return nil, errors.New("QueryContract not supported") +} + +func (c *Cardano) ParseInterface(ctx context.Context, method *fftypes.FFIMethod, errorz []*fftypes.FFIError) (interface{}, error) { + return nil, errors.New("ParseInterface not supported") +} + +func (c *Cardano) NormalizeContractLocation(ctx context.Context, ntype blockchain.NormalizeType, location *fftypes.JSONAny) (result *fftypes.JSONAny, err error) { + return nil, errors.New("NormalizeContractLocation not supported") +} + +func (c *Cardano) AddContractListener(ctx context.Context, listener *core.ContractListener) (err error) { + return errors.New("AddContractListener not supported") +} + +func (c *Cardano) DeleteContractListener(ctx context.Context, subscription *core.ContractListener, okNotFound bool) error { + return errors.New("DeleteContractListener not supported") +} + +func (c *Cardano) GetContractListenerStatus(ctx context.Context, namespace, subID string, okNotFound bool) (found bool, detail interface{}, status core.ContractListenerStatus, err error) { + return false, nil, core.ContractListenerStatusUnknown, errors.New("GetContractListenerStatus not supported") +} + +func (c *Cardano) GetFFIParamValidator(ctx context.Context) (fftypes.FFIParamValidator, error) { + // Cardanoconnect does not require any additional validation beyond "JSON Schema correctness" at this time + return nil, nil +} + +func (c *Cardano) GenerateEventSignature(ctx context.Context, event *fftypes.FFIEventDefinition) string { + return event.Name +} + +func (c *Cardano) GenerateErrorSignature(ctx context.Context, event *fftypes.FFIErrorDefinition) string { + // TODO: impl + return "" +} + +func (c *Cardano) GenerateFFI(ctx context.Context, generationRequest *fftypes.FFIGenerationRequest) (*fftypes.FFI, error) { + return nil, i18n.NewError(ctx, coremsgs.MsgFFIGenerationUnsupported) +} + +func (c *Cardano) GetNetworkVersion(ctx context.Context, location *fftypes.JSONAny) (version int, err error) { + // Part of the FIR-12. https://github.com/hyperledger/firefly-fir/pull/12 + // Cardano doesn't support any of this yet, so just pretend we're on the new hotness + return 2, nil +} + +func (c *Cardano) GetAndConvertDeprecatedContractConfig(ctx context.Context) (location *fftypes.JSONAny, fromBlock string, err error) { + return nil, "", nil +} + +func (c *Cardano) GetTransactionStatus(ctx context.Context, operation *core.Operation) (interface{}, error) { + txnID := (&core.PreparedOperation{ID: operation.ID, Namespace: operation.Namespace}).NamespacedIDString() + + transactionRequestPath := fmt.Sprintf("/transactions/%s", txnID) + client := c.client + var resErr common.BlockchainRESTError + var statusResponse fftypes.JSONObject + res, err := client.R(). + SetContext(ctx). + SetError(&resErr). + SetResult(&statusResponse). + Get(transactionRequestPath) + if err != nil || !res.IsSuccess() { + if res.StatusCode() == 404 { + return nil, nil + } + return nil, common.WrapRESTError(ctx, &resErr, res, err, coremsgs.MsgCardanoconnectRESTErr) + } + + receiptInfo := statusResponse.GetObject("receipt") + txStatus := statusResponse.GetString("status") + + if txStatus != "" { + var replyType string + if txStatus == "Succeeded" { + replyType = ReceiptTransactionSuccess + } else { + replyType = ReceiptTransactionFailed + } + // If the status has changed, mock up blockchain receipt as if we'd received it + // as a web socket notification + if (operation.Status == core.OpStatusPending || operation.Status == core.OpStatusInitialized) && txStatus != cardanoTxStatusPending { + receipt := &common.BlockchainReceiptNotification{ + Headers: common.BlockchainReceiptHeaders{ + ReceiptID: statusResponse.GetString("id"), + ReplyType: replyType, + }, + TxHash: statusResponse.GetString("transactionHash"), + Message: statusResponse.GetString("errorMessage"), + ProtocolID: receiptInfo.GetString("protocolId")} + err := common.HandleReceipt(ctx, c, receipt, c.callbacks) + if err != nil { + log.L(ctx).Warnf("Failed to handle receipt") + } + } + } else { + // Don't expect to get here so issue a warning + log.L(ctx).Warnf("Transaction status didn't include txStatus information") + } + + return statusResponse, nil +} + +func (c *Cardano) afterConnect(ctx context.Context, w wsclient.WSClient) error { + // Send a subscribe to our topic after each connect/reconnect + b, _ := json.Marshal(&cardanoWSCommandPayload{ + Type: "listen", + Topic: c.pluginTopic, + }) + err := w.Send(ctx, b) + if err == nil { + b, _ = json.Marshal(&cardanoWSCommandPayload{ + Type: "listenreplies", + }) + err = w.Send(ctx, b) + } + return err +} + +func (c *Cardano) eventLoop() { + defer c.wsconn.Close() + l := log.L(c.ctx).WithField("role", "event-loop") + ctx := log.WithLogger(c.ctx, l) + for { + select { + case <-ctx.Done(): + l.Debugf("Event loop exiting (context cancelled)") + return + case msgBytes, ok := <-c.wsconn.Receive(): + if !ok { + l.Debugf("Event loop exiting (receive channel closed). Terminating server!") + c.cancelCtx() + return + } + + var msgParsed interface{} + err := json.Unmarshal(msgBytes, &msgParsed) + if err != nil { + l.Errorf("Message cannot be parsed as JSON: %s\n%s", err, string(msgBytes)) + continue // Swallow this and move on + } + switch msgTyped := msgParsed.(type) { + case []interface{}: + // TODO: handle this + ack, _ := json.Marshal(&cardanoWSCommandPayload{ + Type: "ack", + Topic: c.pluginTopic, + }) + err = c.wsconn.Send(ctx, ack) + case map[string]interface{}: + var receipt common.BlockchainReceiptNotification + _ = json.Unmarshal(msgBytes, &receipt) + + err := common.HandleReceipt(ctx, c, &receipt, c.callbacks) + if err != nil { + l.Errorf("Failed to process receipt: %+v", msgTyped) + } + default: + l.Errorf("Message unexpected: %+v", msgTyped) + continue + } + + if err != nil { + l.Errorf("Event loop exiting (%s). Terminating server!", err) + c.cancelCtx() + return + } + } + } +} + +func formatCardanoAddress(ctx context.Context, key string) (string, error) { + // TODO: this could be much stricter validation + if key != "" { + return key, nil + } + return "", i18n.NewError(ctx, coremsgs.MsgInvalidCardanoAddress) +} diff --git a/internal/blockchain/cardano/config.go b/internal/blockchain/cardano/config.go new file mode 100644 index 0000000000..bc2a4fbb77 --- /dev/null +++ b/internal/blockchain/cardano/config.go @@ -0,0 +1,36 @@ +// Copyright © 2024 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 cardano + +import ( + "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/wsclient" +) + +const ( + // CardanoconnectConfigKey is a sub-key in the config to contain all the cardanoconnect specific config + CardanoconnectConfigKey = "cardanoconnect" + // CardanoconnectConfigTopic is the websocket listen topic that the node should register on, which is important if there are multiple + // nodes using a single cardanoconnect + CardanoconnectConfigTopic = "topic" +) + +func (c *Cardano) InitConfig(config config.Section) { + c.cardanoconnectConf = config.SubSection(CardanoconnectConfigKey) + wsclient.InitConfig(c.cardanoconnectConf) + c.cardanoconnectConf.AddKnownKey(CardanoconnectConfigTopic) +} diff --git a/internal/coremsgs/en_error_messages.go b/internal/coremsgs/en_error_messages.go index 8cccd5c344..0416ca661b 100644 --- a/internal/coremsgs/en_error_messages.go +++ b/internal/coremsgs/en_error_messages.go @@ -59,6 +59,7 @@ var ( MsgSerializationFailed = ffe("FF10137", "Serialization failed") MsgMissingPluginConfig = ffe("FF10138", "Missing configuration '%s' for %s") MsgMissingDataHashIndex = ffe("FF10139", "Missing data hash for index '%d' in message", 400) + MsgInvalidCardanoAddress = ffe("FF10140", "Supplied cardano address is invalid", 400) MsgInvalidEthAddress = ffe("FF10141", "Supplied ethereum address is invalid", 400) MsgInvalidTezosAddress = ffe("FF10142", "Supplied tezos address is invalid", 400) Msg404NoResult = ffe("FF10143", "No result found", 404) @@ -146,6 +147,7 @@ var ( MsgAuthorOrgSigningKeyMismatch = ffe("FF10279", "Author organization '%s' is not associated with signing key '%s'") MsgCannotTransferToSelf = ffe("FF10280", "From and to addresses must be different", 400) MsgLocalOrgNotSet = ffe("FF10281", "Unable to resolve the local root org. Please ensure org.name is configured", 500) + MsgCardanoconnectRESTErr = ffe("FF10282", "Error from cardano connector: %s") MsgTezosconnectRESTErr = ffe("FF10283", "Error from tezos connector: %s") MsgFabconnectRESTErr = ffe("FF10284", "Error from fabconnect: %s") MsgInvalidIdentity = ffe("FF10285", "Supplied Fabric signer identity is invalid", 400) diff --git a/internal/networkmap/did.go b/internal/networkmap/did.go index af1244ccab..544a9aa2f4 100644 --- a/internal/networkmap/did.go +++ b/internal/networkmap/did.go @@ -75,6 +75,8 @@ func (nm *networkMap) generateDIDDocument(ctx context.Context, identity *core.Id func (nm *networkMap) generateDIDAuthentication(ctx context.Context, identity *core.Identity, verifier *core.Verifier) *VerificationMethod { switch verifier.Type { + case core.VerifierTypeCardanoAddress: + return nm.generateCardanoAddressVerifier(identity, verifier) case core.VerifierTypeEthAddress: return nm.generateEthAddressVerifier(identity, verifier) case core.VerifierTypeTezosAddress: @@ -89,6 +91,15 @@ func (nm *networkMap) generateDIDAuthentication(ctx context.Context, identity *c } } +func (nm *networkMap) generateCardanoAddressVerifier(identity *core.Identity, verifier *core.Verifier) *VerificationMethod { + return &VerificationMethod{ + ID: verifier.Hash.String(), + Type: "PaymentVerificationKeyShelley_ed25519", // hope that it's safe to assume we always use Shelley + Controller: identity.DID, + BlockchainAccountID: verifier.Value, + } +} + func (nm *networkMap) generateEthAddressVerifier(identity *core.Identity, verifier *core.Verifier) *VerificationMethod { return &VerificationMethod{ ID: verifier.Hash.String(), diff --git a/manifest.json b/manifest.json index a6285d4da1..8b82e7b7c2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,4 +1,8 @@ { + "cardanoconnect": { + "image": "cardanoconnect", + "local": true + }, "ethconnect": { "image": "ghcr.io/hyperledger/firefly-ethconnect", "tag": "v3.3.2", diff --git a/pkg/core/verifier.go b/pkg/core/verifier.go index 0a589474f9..5d2387a82d 100644 --- a/pkg/core/verifier.go +++ b/pkg/core/verifier.go @@ -26,6 +26,8 @@ import ( type VerifierType = fftypes.FFEnum var ( + // VerifierTypeCardanoAddress is a Cardano address string + VerifierTypeCardanoAddress = fftypes.FFEnumValue("verifiertype", "cardano_address") // VerifierTypeEthAddress is an Ethereum (secp256k1) address string VerifierTypeEthAddress = fftypes.FFEnumValue("verifiertype", "ethereum_address") // VerifierTypeTezosAddress is a Tezos (ed25519) address string From 0b474961a2a3f7daf4f3f24667002e4344cf5ec9 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Sun, 2 Jun 2024 01:08:29 -0400 Subject: [PATCH 02/43] Fix WS connection to cardano connector Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 20 +++- internal/blockchain/cardano/config.go | 11 ++ internal/blockchain/cardano/eventstream.go | 114 +++++++++++++++++++++ 3 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 internal/blockchain/cardano/eventstream.go diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index c9748d3dbc..04fbebc8c0 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -54,6 +54,7 @@ type Cardano struct { capabilities *blockchain.Capabilities callbacks common.BlockchainCallbacks client *resty.Client + streams *streamManager wsconn wsclient.WSClient cardanoconnectConf config.Section subs common.FireflySubscriptions @@ -83,6 +84,10 @@ func (c *Cardano) Init(ctx context.Context, cancelCtx context.CancelFunc, conf c c.callbacks = common.NewBlockchainCallbacks() c.subs = common.NewFireflySubscriptions() + if cardanoconnectConf.GetString(ffresty.HTTPConfigURL) == "" { + return i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "url", cardanoconnectConf) + } + wsConfig, err := wsclient.GenerateConfig(ctx, cardanoconnectConf) if err == nil { c.client, err = ffresty.New(c.ctx, cardanoconnectConf) @@ -105,9 +110,18 @@ func (c *Cardano) Init(ctx context.Context, cancelCtx context.CancelFunc, conf c return err } + c.streams = newStreamManager(c.client, c.cardanoconnectConf.GetUint(CardanoconnectConfigBatchSize), uint(c.cardanoconnectConf.GetDuration(CardanoconnectConfigBatchTimeout).Milliseconds())) + + stream, err := c.streams.ensureEventStream(c.ctx, c.pluginTopic) + if err != nil { + return err + } + + log.L(c.ctx).Infof("Event stream: %s (topic=%s)", stream.ID, c.pluginTopic) + go c.eventLoop() - return nil + return c.wsconn.Connect() } func (c *Cardano) StartNamespace(ctx context.Context, namespace string) (err error) { @@ -128,10 +142,6 @@ func (c *Cardano) SetOperationHandler(namespace string, handler core.OperationCa c.callbacks.SetOperationalHandler(namespace, handler) } -func (c *Cardano) Start() (err error) { - return c.wsconn.Connect() -} - func (c *Cardano) Capabilities() *blockchain.Capabilities { return c.capabilities } diff --git a/internal/blockchain/cardano/config.go b/internal/blockchain/cardano/config.go index bc2a4fbb77..d7e4a1ee95 100644 --- a/internal/blockchain/cardano/config.go +++ b/internal/blockchain/cardano/config.go @@ -21,16 +21,27 @@ import ( "github.com/hyperledger/firefly-common/pkg/wsclient" ) +const ( + defaultBatchSize = 50 + defaultBatchTimeout = 500 +) + const ( // CardanoconnectConfigKey is a sub-key in the config to contain all the cardanoconnect specific config CardanoconnectConfigKey = "cardanoconnect" // CardanoconnectConfigTopic is the websocket listen topic that the node should register on, which is important if there are multiple // nodes using a single cardanoconnect CardanoconnectConfigTopic = "topic" + // CardanoconnectConfigBatchSize is the batch size to configure on event streams, when auto-defining them + CardanoconnectConfigBatchSize = "batchSize" + // CardanoconnectConfigBatchTimeout is the batch timeout to configure on event streams, when auto-defining them + CardanoconnectConfigBatchTimeout = "batchTimeout" ) func (c *Cardano) InitConfig(config config.Section) { c.cardanoconnectConf = config.SubSection(CardanoconnectConfigKey) wsclient.InitConfig(c.cardanoconnectConf) c.cardanoconnectConf.AddKnownKey(CardanoconnectConfigTopic) + c.cardanoconnectConf.AddKnownKey(CardanoconnectConfigBatchSize, defaultBatchSize) + c.cardanoconnectConf.AddKnownKey(CardanoconnectConfigBatchTimeout, defaultBatchTimeout) } diff --git a/internal/blockchain/cardano/eventstream.go b/internal/blockchain/cardano/eventstream.go new file mode 100644 index 0000000000..5696361648 --- /dev/null +++ b/internal/blockchain/cardano/eventstream.go @@ -0,0 +1,114 @@ +// Copyright © 2024 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 cardano + +import ( + "context" + + "github.com/go-resty/resty/v2" + "github.com/hyperledger/firefly-common/pkg/ffresty" + "github.com/hyperledger/firefly/internal/coremsgs" +) + +type streamManager struct { + client *resty.Client + batchSize uint + batchTimeout uint +} + +type eventStream struct { + ID string `json:"id"` + Name string `json:"name"` + ErrorHandling string `json:"errorHandling"` + BatchSize uint `json:"batchSize"` + BatchTimeoutMS uint `json:"batchTimeoutMS"` + Type string `json:"type"` + Timestamps bool `json:"timestamps"` +} + +func newStreamManager(client *resty.Client, batchSize, batchTimeout uint) *streamManager { + return &streamManager{ + client: client, + batchSize: batchSize, + batchTimeout: batchTimeout, + } +} + +func (s *streamManager) getEventStreams(ctx context.Context) (streams []*eventStream, err error) { + res, err := s.client.R(). + SetContext(ctx). + SetResult(&streams). + Get("/eventstreams") + if err != nil || !res.IsSuccess() { + return nil, ffresty.WrapRestErr(ctx, res, err, coremsgs.MsgCardanoconnectRESTErr) + } + return streams, nil +} + +func buildEventStream(topic string, batchSize, batchTimeout uint) *eventStream { + return &eventStream{ + Name: topic, + ErrorHandling: "block", + BatchSize: batchSize, + BatchTimeoutMS: batchTimeout, + Type: "websocket", + Timestamps: true, + } +} + +func (s *streamManager) createEventStream(ctx context.Context, topic string) (*eventStream, error) { + stream := buildEventStream(topic, s.batchSize, s.batchTimeout) + res, err := s.client.R(). + SetContext(ctx). + SetBody(stream). + SetResult(stream). + Post("/eventstreams") + if err != nil || !res.IsSuccess() { + return nil, ffresty.WrapRestErr(ctx, res, err, coremsgs.MsgCardanoconnectRESTErr) + } + return stream, nil +} + +func (s *streamManager) updateEventStream(ctx context.Context, topic string, batchSize, batchTimeout uint, eventStreamID string) (*eventStream, error) { + stream := buildEventStream(topic, batchSize, batchTimeout) + res, err := s.client.R(). + SetContext(ctx). + SetBody(stream). + SetResult(stream). + Patch("/eventstreams/" + eventStreamID) + if err != nil || !res.IsSuccess() { + return nil, ffresty.WrapRestErr(ctx, res, err, coremsgs.MsgCardanoconnectRESTErr) + } + return stream, nil +} + +func (s *streamManager) ensureEventStream(ctx context.Context, topic string) (*eventStream, error) { + existingStreams, err := s.getEventStreams(ctx) + if err != nil { + return nil, err + } + for _, stream := range existingStreams { + if stream.Name == topic { + stream, err = s.updateEventStream(ctx, topic, s.batchSize, s.batchTimeout, stream.ID) + if err != nil { + return nil, err + } + return stream, nil + } + } + return s.createEventStream(ctx, topic) +} From 7b9e934e7dc0b970126b68cf052be4103c1eec4c Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Sun, 2 Jun 2024 01:21:34 -0400 Subject: [PATCH 03/43] Pass-through FFI validation Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index 04fbebc8c0..2e06bf4861 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -60,6 +60,11 @@ type Cardano struct { subs common.FireflySubscriptions } +type ffiMethodAndErrors struct { + method *fftypes.FFIMethod + errors []*fftypes.FFIError +} + type cardanoWSCommandPayload struct { Type string `json:"type"` Topic string `json:"topic,omitempty"` @@ -175,7 +180,9 @@ func (c *Cardano) DeployContract(ctx context.Context, nsOpID, signingKey string, } func (c *Cardano) ValidateInvokeRequest(ctx context.Context, parsedMethod interface{}, input map[string]interface{}, hasMessage bool) error { - return errors.New("ValidateInvokeRequest not supported") + // No additional validation beyond what is enforced by Contract Manager + _, _, err := c.recoverFFI(ctx, parsedMethod) + return err } func (c *Cardano) InvokeContract(ctx context.Context, nsOpID string, signingKey string, location *fftypes.JSONAny, parsedMethod interface{}, input map[string]interface{}, options map[string]interface{}, batch *blockchain.BatchPin) (bool, error) { @@ -186,8 +193,11 @@ func (c *Cardano) QueryContract(ctx context.Context, signingKey string, location return nil, errors.New("QueryContract not supported") } -func (c *Cardano) ParseInterface(ctx context.Context, method *fftypes.FFIMethod, errorz []*fftypes.FFIError) (interface{}, error) { - return nil, errors.New("ParseInterface not supported") +func (c *Cardano) ParseInterface(ctx context.Context, method *fftypes.FFIMethod, errors []*fftypes.FFIError) (interface{}, error) { + return &ffiMethodAndErrors{ + method: method, + errors: errors, + }, nil } func (c *Cardano) NormalizeContractLocation(ctx context.Context, ntype blockchain.NormalizeType, location *fftypes.JSONAny) (result *fftypes.JSONAny, err error) { @@ -303,6 +313,14 @@ func (c *Cardano) afterConnect(ctx context.Context, w wsclient.WSClient) error { return err } +func (c *Cardano) recoverFFI(ctx context.Context, parsedMethod interface{}) (*fftypes.FFIMethod, []*fftypes.FFIError, error) { + methodInfo, ok := parsedMethod.(*ffiMethodAndErrors) + if !ok || methodInfo.method == nil { + return nil, nil, i18n.NewError(ctx, coremsgs.MsgUnexpectedInterfaceType, parsedMethod) + } + return methodInfo.method, methodInfo.errors, nil +} + func (c *Cardano) eventLoop() { defer c.wsconn.Close() l := log.L(c.ctx).WithField("role", "event-loop") From fb574c6036657d0baf765691b0928aa16e012827 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Thu, 31 Oct 2024 16:05:16 -0400 Subject: [PATCH 04/43] Fix signature of cardano plugin Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index 2e06bf4861..da0904f6fe 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -151,7 +151,7 @@ func (c *Cardano) Capabilities() *blockchain.Capabilities { return c.capabilities } -func (c *Cardano) AddFireflySubscription(ctx context.Context, namespace *core.Namespace, contract *blockchain.MultipartyContract) (string, error) { +func (c *Cardano) AddFireflySubscription(ctx context.Context, namespace *core.Namespace, contract *blockchain.MultipartyContract, lastProtocolID string) (string, error) { return "", errors.New("AddFireflySubscription not supported") } @@ -200,11 +200,15 @@ func (c *Cardano) ParseInterface(ctx context.Context, method *fftypes.FFIMethod, }, nil } +func (c *Cardano) CheckOverlappingLocations(ctx context.Context, left *fftypes.JSONAny, right *fftypes.JSONAny) (bool, error) { + return true, errors.New("CheckOverlappingLocations not supported") +} + func (c *Cardano) NormalizeContractLocation(ctx context.Context, ntype blockchain.NormalizeType, location *fftypes.JSONAny) (result *fftypes.JSONAny, err error) { return nil, errors.New("NormalizeContractLocation not supported") } -func (c *Cardano) AddContractListener(ctx context.Context, listener *core.ContractListener) (err error) { +func (c *Cardano) AddContractListener(ctx context.Context, listener *core.ContractListener, lastProtocolID string) (err error) { return errors.New("AddContractListener not supported") } @@ -221,8 +225,12 @@ func (c *Cardano) GetFFIParamValidator(ctx context.Context) (fftypes.FFIParamVal return nil, nil } -func (c *Cardano) GenerateEventSignature(ctx context.Context, event *fftypes.FFIEventDefinition) string { - return event.Name +func (c *Cardano) GenerateEventSignature(ctx context.Context, event *fftypes.FFIEventDefinition) (string, error) { + return "", errors.New("GenerateEventSignature not supported") +} + +func (c *Cardano) GenerateEventSignatureWithLocation(ctx context.Context, event *fftypes.FFIEventDefinition, location *fftypes.JSONAny) (string, error) { + return "", errors.New("GenerateEventSignatureWithLocation not supported") } func (c *Cardano) GenerateErrorSignature(ctx context.Context, event *fftypes.FFIErrorDefinition) string { From b9b766ab082eb01bf8f7351c0a037496c2f99bff Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Thu, 31 Oct 2024 17:34:25 -0400 Subject: [PATCH 05/43] Update tags for cardano images Signed-off-by: Simon Gellis --- manifest.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index 8b82e7b7c2..a62e223368 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,11 @@ { "cardanoconnect": { - "image": "cardanoconnect", - "local": true + "image": "sundaeswap/firefly-cardanoconnect", + "tag": "main" + }, + "cardanosigner": { + "image": "sundaeswap/firefly-cardanosigner", + "tag": "main" }, "ethconnect": { "image": "ghcr.io/hyperledger/firefly-ethconnect", From 0238a58591c3c88e7d94b85c63cd657a2d68e7b0 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Wed, 6 Nov 2024 15:40:58 -0500 Subject: [PATCH 06/43] feat: implement invoking cardano contracts Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 90 ++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 4 deletions(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index da0904f6fe..ed6c26f239 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -21,6 +21,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "github.com/go-resty/resty/v2" "github.com/hyperledger/firefly-common/pkg/config" @@ -70,6 +71,18 @@ type cardanoWSCommandPayload struct { Topic string `json:"topic,omitempty"` } +type Location struct { + Address string `json:"address"` +} + +type cardanoInvokeContractPayload struct { + ID string `json:"id"` + From string `json:"from"` + Address string `json:"address"` + Method *fftypes.FFIMethod `json:"method"` + Params []interface{} `json:"params"` +} + func (c *Cardano) Name() string { return "cardano" } @@ -186,7 +199,41 @@ func (c *Cardano) ValidateInvokeRequest(ctx context.Context, parsedMethod interf } func (c *Cardano) InvokeContract(ctx context.Context, nsOpID string, signingKey string, location *fftypes.JSONAny, parsedMethod interface{}, input map[string]interface{}, options map[string]interface{}, batch *blockchain.BatchPin) (bool, error) { - return true, errors.New("InvokeContract not supported") + cardanoLocation, err := c.parseContractLocation(ctx, location) + if err != nil { + return true, err + } + + methodInfo, ok := parsedMethod.(*ffiMethodAndErrors) + if !ok || methodInfo.method == nil { + return true, i18n.NewError(ctx, coremsgs.MsgUnexpectedInterfaceType, parsedMethod) + } + method := methodInfo.method + params := make([]interface{}, 0) + for _, param := range method.Params { + params = append(params, input[param.Name]) + } + + body := map[string]interface{}{ + "id": nsOpID, + "address": cardanoLocation.Address, + "method": method, + "params": params, + } + if signingKey != "" { + body["from"] = signingKey + } + + var resErr common.BlockchainRESTError + res, err := c.client.R(). + SetContext(ctx). + SetBody(body). + SetError(&resErr). + Post("/contracts/invoke") + if err != nil || !res.IsSuccess() { + return resErr.SubmissionRejected, common.WrapRESTError(ctx, &resErr, res, err, coremsgs.MsgCardanoconnectRESTErr) + } + return false, nil } func (c *Cardano) QueryContract(ctx context.Context, signingKey string, location *fftypes.JSONAny, parsedMethod interface{}, input map[string]interface{}, options map[string]interface{}) (interface{}, error) { @@ -200,12 +247,47 @@ func (c *Cardano) ParseInterface(ctx context.Context, method *fftypes.FFIMethod, }, nil } +func (c *Cardano) NormalizeContractLocation(ctx context.Context, ntype blockchain.NormalizeType, location *fftypes.JSONAny) (result *fftypes.JSONAny, err error) { + return location, nil +} + func (c *Cardano) CheckOverlappingLocations(ctx context.Context, left *fftypes.JSONAny, right *fftypes.JSONAny) (bool, error) { - return true, errors.New("CheckOverlappingLocations not supported") + if left == nil || right == nil { + // No location on either side so overlapping + return true, nil + } + + parsedLeft, err := c.parseContractLocation(ctx, left) + if err != nil { + return false, err + } + + parsedRight, err := c.parseContractLocation(ctx, right) + if err != nil { + return false, err + } + + // For cardano just compare addresses + return strings.EqualFold(parsedLeft.Address, parsedRight.Address), nil } -func (c *Cardano) NormalizeContractLocation(ctx context.Context, ntype blockchain.NormalizeType, location *fftypes.JSONAny) (result *fftypes.JSONAny, err error) { - return nil, errors.New("NormalizeContractLocation not supported") +func (c *Cardano) parseContractLocation(ctx context.Context, location *fftypes.JSONAny) (*Location, error) { + cardanoLocation := Location{} + if err := json.Unmarshal(location.Bytes(), &cardanoLocation); err != nil { + return nil, i18n.NewError(ctx, coremsgs.MsgContractLocationInvalid, err) + } + if cardanoLocation.Address == "" { + return nil, i18n.NewError(ctx, coremsgs.MsgContractLocationInvalid, "'address' not set") + } + return &cardanoLocation, nil +} + +func (c *Cardano) encodeContractLocation(ctx context.Context, location *Location) (result *fftypes.JSONAny, err error) { + normalized, err := json.Marshal(location) + if err == nil { + result = fftypes.JSONAnyPtrBytes(normalized) + } + return result, err } func (c *Cardano) AddContractListener(ctx context.Context, listener *core.ContractListener, lastProtocolID string) (err error) { From 2e9baf21dded53d060709edc039540b67cfd7783 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Fri, 6 Dec 2024 10:16:30 -0500 Subject: [PATCH 07/43] feat: support DeployContract Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index ed6c26f239..f3af830fd0 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -189,7 +189,21 @@ func (c *Cardano) SubmitNetworkAction(ctx context.Context, nsOpID string, signin } func (c *Cardano) DeployContract(ctx context.Context, nsOpID, signingKey string, definition, contract *fftypes.JSONAny, input []interface{}, options map[string]interface{}) (submissionRejected bool, err error) { - return true, errors.New("DeployContract not supported") + body := map[string]interface{}{ + "id": nsOpID, + "contract": contract, + "definition": definition, + } + var resErr common.BlockchainRESTError + res, err := c.client.R(). + SetContext(ctx). + SetBody(body). + SetError(&resErr). + Post("/contracts/deploy") + if err != nil || !res.IsSuccess() { + return resErr.SubmissionRejected, common.WrapRESTError(ctx, &resErr, res, err, coremsgs.MsgCardanoconnectRESTErr) + } + return false, nil } func (c *Cardano) ValidateInvokeRequest(ctx context.Context, parsedMethod interface{}, input map[string]interface{}, hasMessage bool) error { From 67c8b1cf08e58ae4ab16a08f5dbaff42decdbcbc Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Thu, 6 Feb 2025 17:22:11 -0500 Subject: [PATCH 08/43] fix: pass namespace to HandleReceipt Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index f3af830fd0..5d9caac9a9 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -388,7 +388,7 @@ func (c *Cardano) GetTransactionStatus(ctx context.Context, operation *core.Oper TxHash: statusResponse.GetString("transactionHash"), Message: statusResponse.GetString("errorMessage"), ProtocolID: receiptInfo.GetString("protocolId")} - err := common.HandleReceipt(ctx, c, receipt, c.callbacks) + err := common.HandleReceipt(ctx, operation.Namespace, c, receipt, c.callbacks) if err != nil { log.L(ctx).Warnf("Failed to handle receipt") } @@ -459,7 +459,7 @@ func (c *Cardano) eventLoop() { var receipt common.BlockchainReceiptNotification _ = json.Unmarshal(msgBytes, &receipt) - err := common.HandleReceipt(ctx, c, &receipt, c.callbacks) + err := common.HandleReceipt(ctx, "", c, &receipt, c.callbacks) if err != nil { l.Errorf("Failed to process receipt: %+v", msgTyped) } From ef68f8ec53a96934c3eb6292f07199df82b6c264 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Fri, 7 Feb 2025 18:40:05 -0500 Subject: [PATCH 09/43] feat: support contract listeners Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 186 ++++++++++++++++++--- internal/blockchain/cardano/eventstream.go | 70 ++++++++ 2 files changed, 236 insertions(+), 20 deletions(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index 5d9caac9a9..ed841826cd 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -56,6 +56,7 @@ type Cardano struct { callbacks common.BlockchainCallbacks client *resty.Client streams *streamManager + streamID string wsconn wsclient.WSClient cardanoconnectConf config.Section subs common.FireflySubscriptions @@ -67,8 +68,10 @@ type ffiMethodAndErrors struct { } type cardanoWSCommandPayload struct { - Type string `json:"type"` - Topic string `json:"topic,omitempty"` + Type string `json:"type"` + Topic string `json:"topic,omitempty"` + BatchNumber int64 `json:"batchNumber,omitempty"` + Message string `json:"message,omitempty"` } type Location struct { @@ -136,6 +139,7 @@ func (c *Cardano) Init(ctx context.Context, cancelCtx context.CancelFunc, conf c } log.L(c.ctx).Infof("Event stream: %s (topic=%s)", stream.ID, c.pluginTopic) + c.streamID = stream.ID go c.eventLoop() @@ -262,7 +266,11 @@ func (c *Cardano) ParseInterface(ctx context.Context, method *fftypes.FFIMethod, } func (c *Cardano) NormalizeContractLocation(ctx context.Context, ntype blockchain.NormalizeType, location *fftypes.JSONAny) (result *fftypes.JSONAny, err error) { - return location, nil + parsed, err := c.parseContractLocation(ctx, location) + if err != nil { + return nil, err + } + return c.encodeContractLocation(ctx, parsed) } func (c *Cardano) CheckOverlappingLocations(ctx context.Context, left *fftypes.JSONAny, right *fftypes.JSONAny) (bool, error) { @@ -297,6 +305,10 @@ func (c *Cardano) parseContractLocation(ctx context.Context, location *fftypes.J } func (c *Cardano) encodeContractLocation(ctx context.Context, location *Location) (result *fftypes.JSONAny, err error) { + location.Address, err = formatCardanoAddress(ctx, location.Address) + if err != nil { + return nil, err + } normalized, err := json.Marshal(location) if err == nil { result = fftypes.JSONAnyPtrBytes(normalized) @@ -305,15 +317,27 @@ func (c *Cardano) encodeContractLocation(ctx context.Context, location *Location } func (c *Cardano) AddContractListener(ctx context.Context, listener *core.ContractListener, lastProtocolID string) (err error) { - return errors.New("AddContractListener not supported") + subName := fmt.Sprintf("ff-sub-%s-%s", listener.Namespace, listener.ID) + firstEvent := string(core.SubOptsFirstEventNewest) + if listener.Options != nil { + firstEvent = listener.Options.FirstEvent + } + + result, err := c.streams.createListener(ctx, c.streamID, subName, firstEvent, &listener.Filters) + listener.BackendID = result.ID + return err } func (c *Cardano) DeleteContractListener(ctx context.Context, subscription *core.ContractListener, okNotFound bool) error { - return errors.New("DeleteContractListener not supported") + return c.streams.deleteListener(ctx, c.streamID, subscription.BackendID) } func (c *Cardano) GetContractListenerStatus(ctx context.Context, namespace, subID string, okNotFound bool) (found bool, detail interface{}, status core.ContractListenerStatus, err error) { - return false, nil, core.ContractListenerStatusUnknown, errors.New("GetContractListenerStatus not supported") + l, err := c.streams.getListener(ctx, c.streamID, subID) + if err != nil || l == nil { + return false, nil, core.ContractListenerStatusUnknown, err + } + return true, nil, core.ContractListenerStatusUnknown, nil } func (c *Cardano) GetFFIParamValidator(ctx context.Context) (fftypes.FFIParamValidator, error) { @@ -322,11 +346,25 @@ func (c *Cardano) GetFFIParamValidator(ctx context.Context) (fftypes.FFIParamVal } func (c *Cardano) GenerateEventSignature(ctx context.Context, event *fftypes.FFIEventDefinition) (string, error) { - return "", errors.New("GenerateEventSignature not supported") + params := []string{} + for _, param := range event.Params { + params = append(params, param.Schema.JSONObject().GetString("type")) + } + return fmt.Sprintf("%s(%s)", event.Name, strings.Join(params, ",")), nil } func (c *Cardano) GenerateEventSignatureWithLocation(ctx context.Context, event *fftypes.FFIEventDefinition, location *fftypes.JSONAny) (string, error) { - return "", errors.New("GenerateEventSignatureWithLocation not supported") + eventSignature, _ := c.GenerateEventSignature(ctx, event) + + if location == nil { + return fmt.Sprintf("*:%s", eventSignature), nil + } + + parsed, err := c.parseContractLocation(ctx, location) + if err != nil { + return "", err + } + return fmt.Sprintf("%s:%s", parsed.Address, eventSignature), nil } func (c *Cardano) GenerateErrorSignature(ctx context.Context, event *fftypes.FFIErrorDefinition) string { @@ -449,19 +487,45 @@ func (c *Cardano) eventLoop() { } switch msgTyped := msgParsed.(type) { case []interface{}: - // TODO: handle this - ack, _ := json.Marshal(&cardanoWSCommandPayload{ - Type: "ack", - Topic: c.pluginTopic, - }) - err = c.wsconn.Send(ctx, ack) + err = c.handleMessageBatch(ctx, 0, msgTyped) + if err == nil { + ack, _ := json.Marshal(&cardanoWSCommandPayload{ + Type: "ack", + Topic: c.pluginTopic, + }) + err = c.wsconn.Send(ctx, ack) + } case map[string]interface{}: - var receipt common.BlockchainReceiptNotification - _ = json.Unmarshal(msgBytes, &receipt) - - err := common.HandleReceipt(ctx, "", c, &receipt, c.callbacks) - if err != nil { - l.Errorf("Failed to process receipt: %+v", msgTyped) + isBatch := false + if batchNumber, ok := msgTyped["batchNumber"].(float64); ok { + if events, ok := msgTyped["events"].([]interface{}); ok { + // FFTM delivery with a batch number to use in the ack + isBatch = true + err = c.handleMessageBatch(ctx, (int64)(batchNumber), events) + // Errors processing messages are converted into nacks + ackOrNack := &cardanoWSCommandPayload{ + Topic: c.pluginTopic, + BatchNumber: int64(batchNumber), + } + if err == nil { + ackOrNack.Type = "ack" + } else { + log.L(ctx).Errorf("Rejecting batch due error: %s", err) + ackOrNack.Type = "error" + ackOrNack.Message = err.Error() + } + b, _ := json.Marshal(&ackOrNack) + err = c.wsconn.Send(ctx, b) + } + } + if !isBatch { + var receipt common.BlockchainReceiptNotification + _ = json.Unmarshal(msgBytes, &receipt) + + err := common.HandleReceipt(ctx, "", c, &receipt, c.callbacks) + if err != nil { + l.Errorf("Failed to process receipt: %+v", msgTyped) + } } default: l.Errorf("Message unexpected: %+v", msgTyped) @@ -477,6 +541,88 @@ func (c *Cardano) eventLoop() { } } +func (c *Cardano) handleMessageBatch(ctx context.Context, batchID int64, messages []interface{}) error { + events := make(common.EventsToDispatch) + count := len(messages) + for i, msgI := range messages { + msgMap, ok := msgI.(map[string]interface{}) + if !ok { + log.L(ctx).Errorf("Message cannot be parsed as JSON: %+v", msgI) + return nil + } + msgJSON := fftypes.JSONObject(msgMap) + + signature := msgJSON.GetString("signature") + + logger := log.L(ctx) + logger.Infof("[Cardano:%d:%d/%d]: '%s'", batchID, i+1, count, signature) + logger.Tracef("Message: %+v", msgJSON) + if err := c.processContractEvent(ctx, events, msgJSON); err != nil { + return err + } + } + + // Dispatch all the events from this patch that were successfully parsed and routed to namespaces + // (could be zero - that's ok) + return c.callbacks.DispatchBlockchainEvents(ctx, events) +} + +func (c *Cardano) processContractEvent(ctx context.Context, events common.EventsToDispatch, msgJSON fftypes.JSONObject) error { + listenerID := msgJSON.GetString("listenerId") + listener, err := c.streams.getListener(ctx, c.streamID, listenerID) + if err != nil { + return err + } + namespace := common.GetNamespaceFromSubName(listener.Name) + event := c.parseBlockchainEvent(ctx, msgJSON) + if event != nil { + c.callbacks.PrepareBlockchainEvent(ctx, events, namespace, &blockchain.EventForListener{ + Event: event, + ListenerID: listenerID, + }) + } + return nil +} + +func (c *Cardano) 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") + 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("Blockchain event is not valid - missing timestamp: %+v", msgJSON) + return nil // move on + } + + 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: c.Name(), + Name: name, + ProtocolID: fmt.Sprintf("%.12d/%.6d/%.6d", blockNumber, txIndex, logIndex), + Output: dataJSON, + Info: msgJSON, + Timestamp: timestamp, + Location: c.buildEventLocationString(msgJSON), + Signature: signature, + } +} + +func (c *Cardano) buildEventLocationString(msgJSON fftypes.JSONObject) string { + return fmt.Sprintf("address=%s", msgJSON.GetString("address")) +} + func formatCardanoAddress(ctx context.Context, key string) (string, error) { // TODO: this could be much stricter validation if key != "" { diff --git a/internal/blockchain/cardano/eventstream.go b/internal/blockchain/cardano/eventstream.go index 5696361648..8aace7bead 100644 --- a/internal/blockchain/cardano/eventstream.go +++ b/internal/blockchain/cardano/eventstream.go @@ -18,10 +18,12 @@ package cardano import ( "context" + "fmt" "github.com/go-resty/resty/v2" "github.com/hyperledger/firefly-common/pkg/ffresty" "github.com/hyperledger/firefly/internal/coremsgs" + "github.com/hyperledger/firefly/pkg/core" ) type streamManager struct { @@ -40,6 +42,20 @@ type eventStream struct { Timestamps bool `json:"timestamps"` } +type listener struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` +} + +type filter struct { + Event eventfilter `json:"event"` +} + +type eventfilter struct { + Contract string `json:"contract"` + EventPath string `json:"eventPath"` +} + func newStreamManager(client *resty.Client, batchSize, batchTimeout uint) *streamManager { return &streamManager{ client: client, @@ -112,3 +128,57 @@ func (s *streamManager) ensureEventStream(ctx context.Context, topic string) (*e } return s.createEventStream(ctx, topic) } + +func (s *streamManager) getListener(ctx context.Context, streamID string, listenerID string) (listener *listener, err error) { + res, err := s.client.R(). + SetContext(ctx). + SetResult(&listener). + Get(fmt.Sprintf("/eventstreams/%s/listeners/%s", streamID, listenerID)) + if err != nil || !res.IsSuccess() { + return nil, ffresty.WrapRestErr(ctx, res, err, coremsgs.MsgCardanoconnectRESTErr) + } + return listener, nil +} + +func (s *streamManager) createListener(ctx context.Context, streamID, name, lastEvent string, filters *core.ListenerFilters) (listener *listener, err error) { + cardanoFilters := []filter{} + for _, f := range *filters { + address := f.Location.JSONObject().GetString("address") + cardanoFilters = append(cardanoFilters, filter{ + Event: eventfilter{ + Contract: address, + EventPath: f.Event.Name, + }, + }) + } + + body := map[string]interface{}{ + "name": name, + "type": "events", + "fromBlock": lastEvent, + "filters": cardanoFilters, + } + + res, err := s.client.R(). + SetContext(ctx). + SetBody(body). + SetResult(&listener). + Post(fmt.Sprintf("/eventstreams/%s/listeners", streamID)) + + if err != nil || !res.IsSuccess() { + return nil, ffresty.WrapRestErr(ctx, res, err, coremsgs.MsgCardanoconnectRESTErr) + } + + return listener, nil +} + +func (s *streamManager) deleteListener(ctx context.Context, streamID, listenerID string) error { + res, err := s.client.R(). + SetContext(ctx). + Delete(fmt.Sprintf("/eventstreams/%s/listeners/%s", streamID, listenerID)) + + if err != nil || !res.IsSuccess() { + return ffresty.WrapRestErr(ctx, res, err, coremsgs.MsgCardanoconnectRESTErr) + } + return nil +} From 89c1a24f3525959ccaa52bcb75449f9ab80ba46f Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Fri, 7 Feb 2025 19:04:13 -0500 Subject: [PATCH 10/43] fix: correct linter error Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index ed841826cd..0af5a9b239 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -78,14 +78,6 @@ type Location struct { Address string `json:"address"` } -type cardanoInvokeContractPayload struct { - ID string `json:"id"` - From string `json:"from"` - Address string `json:"address"` - Method *fftypes.FFIMethod `json:"method"` - Params []interface{} `json:"params"` -} - func (c *Cardano) Name() string { return "cardano" } From 1e5f445b19079dbcb099a1f2a5529888d2f19036 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Fri, 7 Feb 2025 19:16:06 -0500 Subject: [PATCH 11/43] fix: correct image paths Signed-off-by: Simon Gellis --- manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index a62e223368..4fde46e6ad 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ { "cardanoconnect": { - "image": "sundaeswap/firefly-cardanoconnect", + "image": "hyperledger/firefly-cardanoconnect", "tag": "main" }, "cardanosigner": { - "image": "sundaeswap/firefly-cardanosigner", + "image": "hyperledger/firefly-cardanosigner", "tag": "main" }, "ethconnect": { From a95c7aff3ea6369ffec978af8ca83b8d50c08add Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Fri, 7 Feb 2025 19:24:11 -0500 Subject: [PATCH 12/43] fix: correct copyright year Signed-off-by: Simon Gellis --- internal/blockchain/bifactory/factory.go | 2 +- internal/blockchain/cardano/cardano.go | 2 +- internal/blockchain/cardano/config.go | 2 +- internal/blockchain/cardano/eventstream.go | 2 +- internal/coremsgs/en_error_messages.go | 2 +- internal/networkmap/did.go | 2 +- pkg/core/verifier.go | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/blockchain/bifactory/factory.go b/internal/blockchain/bifactory/factory.go index 40a835ca5e..a26cbb1bb4 100644 --- a/internal/blockchain/bifactory/factory.go +++ b/internal/blockchain/bifactory/factory.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Kaleido, Inc. +// Copyright © 2025 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index 0af5a9b239..69c33f12ca 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Kaleido, Inc. +// Copyright © 2025 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // diff --git a/internal/blockchain/cardano/config.go b/internal/blockchain/cardano/config.go index d7e4a1ee95..445a226cd3 100644 --- a/internal/blockchain/cardano/config.go +++ b/internal/blockchain/cardano/config.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Kaleido, Inc. +// Copyright © 2025 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // diff --git a/internal/blockchain/cardano/eventstream.go b/internal/blockchain/cardano/eventstream.go index 8aace7bead..63e081344e 100644 --- a/internal/blockchain/cardano/eventstream.go +++ b/internal/blockchain/cardano/eventstream.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Kaleido, Inc. +// Copyright © 2025 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // diff --git a/internal/coremsgs/en_error_messages.go b/internal/coremsgs/en_error_messages.go index 0416ca661b..6811a05027 100644 --- a/internal/coremsgs/en_error_messages.go +++ b/internal/coremsgs/en_error_messages.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Kaleido, Inc. +// Copyright © 2025 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // diff --git a/internal/networkmap/did.go b/internal/networkmap/did.go index 544a9aa2f4..0b4e829f7e 100644 --- a/internal/networkmap/did.go +++ b/internal/networkmap/did.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Kaleido, Inc. +// Copyright © 2025 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // diff --git a/pkg/core/verifier.go b/pkg/core/verifier.go index 5d2387a82d..5af0ca010a 100644 --- a/pkg/core/verifier.go +++ b/pkg/core/verifier.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Kaleido, Inc. +// Copyright © 2025 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // From 07f4dbde2cde05ff9c15a3526ea0f4924414459d Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Fri, 7 Feb 2025 19:36:52 -0500 Subject: [PATCH 13/43] fix: fix documentation Signed-off-by: Simon Gellis --- doc-site/docs/reference/config.md | 76 +++++++++++++++++++++ doc-site/docs/reference/types/verifier.md | 2 +- doc-site/docs/swagger/swagger.yaml | 18 +++++ internal/coremsgs/en_config_descriptions.go | 7 +- 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/doc-site/docs/reference/config.md b/doc-site/docs/reference/config.md index 89c794f420..6ff2294da0 100644 --- a/doc-site/docs/reference/config.md +++ b/doc-site/docs/reference/config.md @@ -596,6 +596,82 @@ title: Configuration Reference |name|The name of the configured Blockchain plugin|`string`|`` |type|The type of the configured Blockchain Connector plugin|`string`|`` +## plugins.blockchain[].cardano.cardanoconnect + +|Key|Description|Type|Default Value| +|---|-----------|----|-------------| +|batchSize|The number of events Cardanoconnect should batch together for delivery to FireFly core. Only applies when automatically creating a new event stream|`int`|`50` +|batchTimeout|How long Cardanoconnect 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` +|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` +|maxConnsPerHost|The max number of connections, per unique hostname. Zero means no limit|`int`|`0` +|maxIdleConns|The max number of idle connections to hold pooled|`int`|`100` +|maxIdleConnsPerHost|The max number of idle connections, per unique hostname. Zero means net/http uses the default of only 2.|`int`|`100` +|passthroughHeadersEnabled|Enable passing through the set of allowed HTTP request headers|`boolean`|`false` +|requestTimeout|The maximum amount of time that a request is allowed to remain open|[`time.Duration`](https://pkg.go.dev/time#Duration)|`30s` +|tlsHandshakeTimeout|The maximum amount of time to wait for a successful TLS handshake|[`time.Duration`](https://pkg.go.dev/time#Duration)|`10s` +|topic|The websocket listen topic that the node should register on, which is important if there are multiple nodes using a single cardanoconnect|`string`|`` +|url|The URL of the Cardanoconnect instance|URL `string`|`` + +## plugins.blockchain[].cardano.cardanoconnect.auth + +|Key|Description|Type|Default Value| +|---|-----------|----|-------------| +|password|Password|`string`|`` +|username|Username|`string`|`` + +## plugins.blockchain[].cardano.cardanoconnect.proxy + +|Key|Description|Type|Default Value| +|---|-----------|----|-------------| +|url|Optional HTTP proxy server to connect through|`string`|`` + +## plugins.blockchain[].cardano.cardanoconnect.retry + +|Key|Description|Type|Default Value| +|---|-----------|----|-------------| +|count|The maximum number of times to retry|`int`|`5` +|enabled|Enables retries|`boolean`|`false` +|errorStatusCodeRegex|The regex that the error response status code must match to trigger retry|`string`|`` +|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[].cardano.cardanoconnect.throttle + +|Key|Description|Type|Default Value| +|---|-----------|----|-------------| +|burst|The maximum number of requests that can be made in a short period of time before the throttling kicks in.|`int`|`` +|requestsPerSecond|The average rate at which requests are allowed to pass through over time.|`int`|`` + +## plugins.blockchain[].cardano.cardanoconnect.tls + +|Key|Description|Type|Default Value| +|---|-----------|----|-------------| +|ca|The TLS certificate authority in PEM format (this option is ignored if caFile is also set)|`string`|`` +|caFile|The path to the CA file for TLS on this API|`string`|`` +|cert|The TLS certificate in PEM format (this option is ignored if certFile is also set)|`string`|`` +|certFile|The path to the certificate file for TLS on this API|`string`|`` +|clientAuth|Enables or disables client auth for TLS on this API|`string`|`` +|enabled|Enables or disables TLS on this API|`boolean`|`false` +|insecureSkipHostVerify|When to true in unit test development environments to disable TLS verification. Use with extreme caution|`boolean`|`` +|key|The TLS certificate key in PEM format (this option is ignored if keyFile is also set)|`string`|`` +|keyFile|The path to the private key file for TLS on this API|`string`|`` +|requiredDNAttributes|A set of required subject DN attributes. Each entry is a regular expression, and the subject certificate must have a matching attribute of the specified type (CN, C, O, OU, ST, L, STREET, POSTALCODE, SERIALNUMBER are valid attributes)|`map[string]string`|`` + +## plugins.blockchain[].cardano.cardanoconnect.ws + +|Key|Description|Type|Default Value| +|---|-----------|----|-------------| +|connectionTimeout|The amount of time to wait while establishing a connection (or auto-reconnection)|[`time.Duration`](https://pkg.go.dev/time#Duration)|`45s` +|heartbeatInterval|The amount of time to wait between heartbeat signals on the WebSocket connection|[`time.Duration`](https://pkg.go.dev/time#Duration)|`30s` +|initialConnectAttempts|The number of attempts FireFly will make to connect to the WebSocket when starting up, before failing|`int`|`5` +|path|The WebSocket sever URL to which FireFly should connect|WebSocket URL `string`|`` +|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` +|url|URL to use for WebSocket - overrides url one level up (in the HTTP config)|`string`|`` +|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[].ethereum.addressResolver |Key|Description|Type|Default Value| diff --git a/doc-site/docs/reference/types/verifier.md b/doc-site/docs/reference/types/verifier.md index 09e7975b83..6c901f3063 100644 --- a/doc-site/docs/reference/types/verifier.md +++ b/doc-site/docs/reference/types/verifier.md @@ -22,7 +22,7 @@ title: Verifier | `hash` | Hash used as a globally consistent identifier for this namespace + type + value combination on every node in the network | `Bytes32` | | `identity` | The UUID of the parent identity that has claimed this verifier | [`UUID`](simpletypes.md#uuid) | | `namespace` | The namespace of the verifier | `string` | -| `type` | The type of the verifier | `FFEnum`:
`"ethereum_address"`
`"tezos_address"`
`"fabric_msp_id"`
`"dx_peer_id"` | +| `type` | The type of the verifier | `FFEnum`:
`"cardano_address"`
`"ethereum_address"`
`"tezos_address"`
`"fabric_msp_id"`
`"dx_peer_id"` | | `value` | The verifier string, such as an Ethereum address, or Fabric MSP identifier | `string` | | `created` | The time this verifier was created on this node | [`FFTime`](simpletypes.md#fftime) | diff --git a/doc-site/docs/swagger/swagger.yaml b/doc-site/docs/swagger/swagger.yaml index 60a2e7b24d..d209ff6e33 100644 --- a/doc-site/docs/swagger/swagger.yaml +++ b/doc-site/docs/swagger/swagger.yaml @@ -9396,6 +9396,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -10098,6 +10099,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -22859,6 +22861,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -23596,6 +23599,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -26379,6 +26383,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -26514,6 +26519,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -28617,6 +28623,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -35331,6 +35338,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -35400,6 +35408,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -35442,6 +35451,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -35461,6 +35471,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -35792,6 +35803,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -35920,6 +35932,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -37925,6 +37938,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -44421,6 +44435,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -44483,6 +44498,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -44518,6 +44534,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -44537,6 +44554,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id diff --git a/internal/coremsgs/en_config_descriptions.go b/internal/coremsgs/en_config_descriptions.go index 1015b75710..72f538f099 100644 --- a/internal/coremsgs/en_config_descriptions.go +++ b/internal/coremsgs/en_config_descriptions.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Kaleido, Inc. +// Copyright © 2025 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // @@ -195,6 +195,11 @@ var ( ConfigPluginBlockchainTezosAddressResolverURL = ffc("config.plugins.blockchain[].tezos.addressResolver.url", "The URL of the Address Resolver", i18n.StringType) ConfigPluginBlockchainTezosAddressResolverURLTemplate = ffc("config.plugins.blockchain[].tezos.addressResolver.urlTemplate", "The URL Go template string to use when calling the Address Resolver. The template input contains '.Key' and '.Intent' string variables.", i18n.GoTemplateType) + ConfigPluginBlockchainCardanoCardanoconnectBatchSize = ffc("config.plugins.blockchain[].cardano.cardanoconnect.batchSize", "The number of events Cardanoconnect should batch together for delivery to FireFly core. Only applies when automatically creating a new event stream", i18n.IntType) + ConfigPluginBlockchainCardanoCardanoconnectBatchTimeout = ffc("config.plugins.blockchain[].cardano.cardanoconnect.batchTimeout", "How long Cardanoconnect 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) + ConfigPluginBlockchainCardanoCardanoconnectTopic = ffc("config.plugins.blockchain[].cardano.cardanoconnect.topic", "The websocket listen topic that the node should register on, which is important if there are multiple nodes using a single cardanoconnect", i18n.StringType) + ConfigPluginBlockchainCardanoCardanoconnectURL = ffc("config.plugins.blockchain[].cardano.cardanoconnect.url", "The URL of the Cardanoconnect instance", urlStringType) + ConfigPluginBlockchainTezosTezosconnectBackgroundStart = ffc("config.plugins.blockchain[].tezos.tezosconnect.backgroundStart.enabled", "Start the Tezosconnect plugin in the background and enter retry loop if failed to start", i18n.BooleanType) ConfigPluginBlockchainTezosTezosconnectBackgroundStartInitialDelay = ffc("config.plugins.blockchain[].tezos.tezosconnect.backgroundStart.initialDelay", "Delay between restarts in the case where we retry to restart the tezos plugin", i18n.TimeDurationType) ConfigPluginBlockchainTezosTezosconnectBackgroundStartMaxDelay = ffc("config.plugins.blockchain[].tezos.tezosconnect.backgroundStart.maxDelay", "Max delay between restarts in the case where we retry to restart the tezos plugin", i18n.TimeDurationType) From dba31c7c0a9e118ef07a1bbb7dc19d4e4d3dde82 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Mon, 10 Feb 2025 17:07:10 -0500 Subject: [PATCH 14/43] test: add coverage Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 55 +- internal/blockchain/cardano/cardano_test.go | 1504 +++++++++++++++++++ internal/blockchain/cardano/eventstream.go | 55 +- internal/networkmap/did_test.go | 17 + 4 files changed, 1605 insertions(+), 26 deletions(-) create mode 100644 internal/blockchain/cardano/cardano_test.go diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index 69c33f12ca..f9fa9d8927 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -21,6 +21,7 @@ import ( "encoding/json" "errors" "fmt" + "regexp" "strings" "github.com/go-resty/resty/v2" @@ -57,6 +58,7 @@ type Cardano struct { client *resty.Client streams *streamManager streamID string + closed chan struct{} wsconn wsclient.WSClient cardanoconnectConf config.Section subs common.FireflySubscriptions @@ -78,6 +80,8 @@ type Location struct { Address string `json:"address"` } +var addressVerify = regexp.MustCompile("^addr1|^addr_test1|^stake1|^stake_test1") + func (c *Cardano) Name() string { return "cardano" } @@ -133,6 +137,7 @@ func (c *Cardano) Init(ctx context.Context, cancelCtx context.CancelFunc, conf c log.L(c.ctx).Infof("Event stream: %s (topic=%s)", stream.ID, c.pluginTopic) c.streamID = stream.ID + c.closed = make(chan struct{}) go c.eventLoop() return c.wsconn.Connect() @@ -161,7 +166,20 @@ func (c *Cardano) Capabilities() *blockchain.Capabilities { } func (c *Cardano) AddFireflySubscription(ctx context.Context, namespace *core.Namespace, contract *blockchain.MultipartyContract, lastProtocolID string) (string, error) { - return "", errors.New("AddFireflySubscription not supported") + location, err := c.parseContractLocation(ctx, contract.Location) + if err != nil { + return "", err + } + + version, _ := c.GetNetworkVersion(ctx, contract.Location) + + l, err := c.streams.ensureFireFlyListener(ctx, namespace.Name, version, location.Address, contract.FirstEvent, c.streamID) + if err != nil { + return "", err + } + + c.subs.AddSubscription(ctx, namespace, version, l.ID, nil) + return l.ID, nil } func (c *Cardano) RemoveFireflySubscription(ctx context.Context, subID string) { @@ -215,7 +233,7 @@ func (c *Cardano) InvokeContract(ctx context.Context, nsOpID string, signingKey } methodInfo, ok := parsedMethod.(*ffiMethodAndErrors) - if !ok || methodInfo.method == nil { + if !ok || methodInfo.method == nil || methodInfo.method.Name == "" { return true, i18n.NewError(ctx, coremsgs.MsgUnexpectedInterfaceType, parsedMethod) } method := methodInfo.method @@ -296,11 +314,7 @@ func (c *Cardano) parseContractLocation(ctx context.Context, location *fftypes.J return &cardanoLocation, nil } -func (c *Cardano) encodeContractLocation(ctx context.Context, location *Location) (result *fftypes.JSONAny, err error) { - location.Address, err = formatCardanoAddress(ctx, location.Address) - if err != nil { - return nil, err - } +func (c *Cardano) encodeContractLocation(_ context.Context, location *Location) (result *fftypes.JSONAny, err error) { normalized, err := json.Marshal(location) if err == nil { result = fftypes.JSONAnyPtrBytes(normalized) @@ -315,7 +329,21 @@ func (c *Cardano) AddContractListener(ctx context.Context, listener *core.Contra firstEvent = listener.Options.FirstEvent } - result, err := c.streams.createListener(ctx, c.streamID, subName, firstEvent, &listener.Filters) + filters := make([]filter, len(listener.Filters)) + for _, f := range listener.Filters { + location, err := c.parseContractLocation(ctx, f.Location) + if err != nil { + return err + } + filters = append(filters, filter{ + eventfilter{ + Contract: location.Address, + EventPath: f.Event.Name, + }, + }) + } + + result, err := c.streams.createListener(ctx, c.streamID, subName, firstEvent, filters) listener.BackendID = result.ID return err } @@ -325,7 +353,7 @@ func (c *Cardano) DeleteContractListener(ctx context.Context, subscription *core } func (c *Cardano) GetContractListenerStatus(ctx context.Context, namespace, subID string, okNotFound bool) (found bool, detail interface{}, status core.ContractListenerStatus, err error) { - l, err := c.streams.getListener(ctx, c.streamID, subID) + l, err := c.streams.getListener(ctx, c.streamID, subID, okNotFound) if err != nil || l == nil { return false, nil, core.ContractListenerStatusUnknown, err } @@ -449,7 +477,7 @@ func (c *Cardano) afterConnect(ctx context.Context, w wsclient.WSClient) error { func (c *Cardano) recoverFFI(ctx context.Context, parsedMethod interface{}) (*fftypes.FFIMethod, []*fftypes.FFIError, error) { methodInfo, ok := parsedMethod.(*ffiMethodAndErrors) - if !ok || methodInfo.method == nil { + if !ok || methodInfo.method == nil || methodInfo.method.Name == "" { return nil, nil, i18n.NewError(ctx, coremsgs.MsgUnexpectedInterfaceType, parsedMethod) } return methodInfo.method, methodInfo.errors, nil @@ -457,6 +485,7 @@ func (c *Cardano) recoverFFI(ctx context.Context, parsedMethod interface{}) (*ff func (c *Cardano) eventLoop() { defer c.wsconn.Close() + defer close(c.closed) l := log.L(c.ctx).WithField("role", "event-loop") ctx := log.WithLogger(c.ctx, l) for { @@ -561,7 +590,7 @@ func (c *Cardano) handleMessageBatch(ctx context.Context, batchID int64, message func (c *Cardano) processContractEvent(ctx context.Context, events common.EventsToDispatch, msgJSON fftypes.JSONObject) error { listenerID := msgJSON.GetString("listenerId") - listener, err := c.streams.getListener(ctx, c.streamID, listenerID) + listener, err := c.streams.getListener(ctx, c.streamID, listenerID, false) if err != nil { return err } @@ -616,8 +645,8 @@ func (c *Cardano) buildEventLocationString(msgJSON fftypes.JSONObject) string { } func formatCardanoAddress(ctx context.Context, key string) (string, error) { - // TODO: this could be much stricter validation - if key != "" { + // TODO: could check for valid bech32, instead of just a conventional HRP + if addressVerify.MatchString(key) { return key, nil } return "", i18n.NewError(ctx, coremsgs.MsgInvalidCardanoAddress) diff --git a/internal/blockchain/cardano/cardano_test.go b/internal/blockchain/cardano/cardano_test.go new file mode 100644 index 0000000000..9599a6137b --- /dev/null +++ b/internal/blockchain/cardano/cardano_test.go @@ -0,0 +1,1504 @@ +// Copyright © 2024 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 cardano + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "testing" + + "github.com/go-resty/resty/v2" + "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/ffresty" + "github.com/hyperledger/firefly-common/pkg/fftls" + "github.com/hyperledger/firefly-common/pkg/fftypes" + "github.com/hyperledger/firefly-common/pkg/wsclient" + "github.com/hyperledger/firefly/internal/blockchain/common" + "github.com/hyperledger/firefly/internal/coreconfig" + "github.com/hyperledger/firefly/mocks/blockchainmocks" + "github.com/hyperledger/firefly/mocks/cachemocks" + "github.com/hyperledger/firefly/mocks/coremocks" + "github.com/hyperledger/firefly/mocks/wsmocks" + "github.com/hyperledger/firefly/pkg/blockchain" + "github.com/hyperledger/firefly/pkg/core" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +var utConfig = config.RootSection("cardano_unit_tests") +var utCardanoconnectConf = utConfig.SubSection(CardanoconnectConfigKey) + +func testFFIMethod() *fftypes.FFIMethod { + return &fftypes.FFIMethod{ + Name: "testFunc", + Params: []*fftypes.FFIParam{ + { + Name: "varString", + Schema: fftypes.JSONAnyPtr(`{"type": "string"}`), + }, + }, + } +} + +func resetConf(c *Cardano) { + coreconfig.Reset() + c.InitConfig(utConfig) +} + +func newTestCardano() (*Cardano, func()) { + ctx, cancel := context.WithCancel(context.Background()) + c := &Cardano{ + ctx: ctx, + cancelCtx: cancel, + callbacks: common.NewBlockchainCallbacks(), + client: resty.New().SetBaseURL("http://localhost:12345"), + pluginTopic: "topic1", + wsconn: &wsmocks.WSClient{}, + } + return c, func() { + cancel() + if c.closed != nil { + // We've init'd, wait to close + <-c.closed + } + } +} + +func TestInitMissingURL(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + cmi := &cachemocks.Manager{} + + err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, cmi) + assert.Regexp(t, "FF10138.*url", err) +} + +func TestBadTLSConfig(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + resetConf(c) + utCardanoconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345") + + tlsConf := utCardanoconnectConf.SubSection("tls") + tlsConf.Set(fftls.HTTPConfTLSEnabled, true) + tlsConf.Set(fftls.HTTPConfTLSCAFile, "!!!!!badness") + + err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.Regexp(t, "FF00153", err) +} + +func TestInitMissingTopic(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + resetConf(c) + utCardanoconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345") + + err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.Regexp(t, "FF10138.*topic", err) +} + +func TestInitWithCardanoConnect(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + toServer, fromServer, wsURL, done := wsclient.NewTestWSServer(nil) + defer done() + + mockedClient := &http.Client{} + httpmock.ActivateNonDefault(mockedClient) + defer httpmock.DeactivateAndReset() + + u, _ := url.Parse(wsURL) + u.Scheme = "http" + httpURL := u.String() + + httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams", httpURL), + httpmock.NewJsonResponderOrPanic(200, []eventStream{})) + httpmock.RegisterResponder("POST", fmt.Sprintf("%s/eventstreams", httpURL), + httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345"})) + + resetConf(c) + utCardanoconnectConf.Set(ffresty.HTTPConfigURL, httpURL) + utCardanoconnectConf.Set(ffresty.HTTPCustomClient, mockedClient) + utCardanoconnectConf.Set(CardanoconnectConfigTopic, "topic1") + + err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.NoError(t, err) + + assert.Equal(t, "cardano", c.Name()) + assert.Equal(t, core.VerifierTypeCardanoAddress, c.VerifierType()) + + assert.Equal(t, 2, httpmock.GetTotalCallCount()) + assert.Equal(t, "es12345", c.streamID) + assert.NotNil(t, c.Capabilities()) + + startupMessage := <-toServer + assert.Equal(t, `{"type":"listen","topic":"topic1"}`, startupMessage) + startupMessage = <-toServer + assert.Equal(t, `{"type":"listenreplies"}`, startupMessage) + fromServer <- `{"bad":"receipt"}` // will be ignored - no ack + fromServer <- `[]` // empty batch, will be ignored, but acked + reply := <-toServer + assert.Equal(t, `{"type":"ack","topic":"topic1"}`, reply) + fromServer <- `["different kind of bad batch"]` + fromServer <- `[{}]` // bad batch + + // Bad data will be ignored + fromServer <- `!json` + fromServer <- `{"not": "a reply"}` + fromServer <- `42` +} + +func TestInitWSFail(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + resetConf(c) + utCardanoconnectConf.Set(ffresty.HTTPConfigURL, "!!!://") + utCardanoconnectConf.Set(CardanoconnectConfigTopic, "topic1") + + err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.Regexp(t, "FF00149", err) +} + +func TestStreamQueryError(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + mockedClient := &http.Client{} + httpmock.ActivateNonDefault(mockedClient) + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams", + httpmock.NewStringResponder(500, `pop`)) + + resetConf(c) + utCardanoconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345") + utCardanoconnectConf.Set(ffresty.HTTPConfigRetryEnabled, false) + utCardanoconnectConf.Set(ffresty.HTTPCustomClient, mockedClient) + utCardanoconnectConf.Set(CardanoconnectConfigTopic, "topic1") + + err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.Regexp(t, "FF10282.*pop", err) +} + +func TestStreamCreateError(t *testing.T) { + c, cancel := newTestCardano() + 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.NewStringResponder(500, "pop")) + + resetConf(c) + utCardanoconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345") + utCardanoconnectConf.Set(ffresty.HTTPConfigRetryEnabled, false) + utCardanoconnectConf.Set(ffresty.HTTPCustomClient, mockedClient) + utCardanoconnectConf.Set(CardanoconnectConfigTopic, "topic1") + + err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.Regexp(t, "FF10282.*pop", err) +} + +func TestStreamUpdateError(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + mockedClient := &http.Client{} + httpmock.ActivateNonDefault(mockedClient) + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams", + httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", Name: "topic1"}})) + httpmock.RegisterResponder("PATCH", "http://localhost:12345/eventstreams/es12345", + httpmock.NewStringResponder(500, "pop")) + + resetConf(c) + utCardanoconnectConf.Set(ffresty.HTTPConfigURL, "http://localhost:12345") + utCardanoconnectConf.Set(ffresty.HTTPConfigRetryEnabled, false) + utCardanoconnectConf.Set(ffresty.HTTPCustomClient, mockedClient) + utCardanoconnectConf.Set(CardanoconnectConfigTopic, "topic1") + + err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.Regexp(t, "FF10282.*pop", err) +} + +func TestStartNamespace(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + err := c.StartNamespace(context.Background(), "ns1") + assert.NoError(t, err) +} + +func TestStopNamespace(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + err := c.StopNamespace(context.Background(), "ns1") + assert.NoError(t, err) +} + +func TestVerifyCardanoAddress(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + _, err := c.ResolveSigningKey(context.Background(), "", blockchain.ResolveKeyIntentSign) + assert.Regexp(t, "FF10354", err) + + _, err = c.ResolveSigningKey(context.Background(), "baddr1cafed00d", blockchain.ResolveKeyIntentSign) + assert.Regexp(t, "FF10140", err) + + key, err := c.ResolveSigningKey(context.Background(), "addr1qx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgse35a3x", blockchain.ResolveKeyIntentSign) + assert.NoError(t, err) + assert.Equal(t, "addr1qx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgse35a3x", key) + + key, err = c.ResolveSigningKey(context.Background(), "addr_test1vqeux7xwusdju9dvsj8h7mca9aup2k439kfmwy773xxc2hcu7zy99", blockchain.ResolveKeyIntentSign) + assert.NoError(t, err) + assert.Equal(t, "addr_test1vqeux7xwusdju9dvsj8h7mca9aup2k439kfmwy773xxc2hcu7zy99", key) +} + +func TestEventLoopContextCancelled(t *testing.T) { + c, cancel := newTestCardano() + cancel() + r := make(<-chan []byte) + wsm := c.wsconn.(*wsmocks.WSClient) + wsm.On("Receive").Return(r) + wsm.On("Close").Return() + c.closed = make(chan struct{}) + c.eventLoop() + wsm.AssertExpectations(t) +} + +func TestEventLoopReceiveClosed(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + r := make(chan []byte) + wsm := c.wsconn.(*wsmocks.WSClient) + close(r) + wsm.On("Receive").Return((<-chan []byte)(r)) + wsm.On("Close").Return() + c.closed = make(chan struct{}) + c.eventLoop() + wsm.AssertExpectations(t) +} + +func TestEventLoopSendFailed(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + r := make(chan []byte) + wsm := c.wsconn.(*wsmocks.WSClient) + wsm.On("Receive").Return((<-chan []byte)(r)) + wsm.On("Close").Return() + wsm.On("Send", mock.Anything, mock.Anything).Return(errors.New("Send error")) + c.closed = make(chan struct{}) + + go c.eventLoop() + r <- []byte(`{"batchNumber":9001,"events":["none"]}`) + <-c.closed +} + +func TestEventLoopBadMessage(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + r := make(chan []byte) + wsm := c.wsconn.(*wsmocks.WSClient) + wsm.On("Receive").Return((<-chan []byte)(r)) + wsm.On("Close").Return() + c.closed = make(chan struct{}) + + go c.eventLoop() + r <- []byte(`!badjson`) // ignored bad json + r <- []byte(`"not an object"`) // ignored wrong type +} + +func TestEventLoopReceiveBatch(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + mockedClient := &http.Client{} + httpmock.ActivateNonDefault(mockedClient) + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams/es12345/listeners/lst12345", + httpmock.NewJsonResponderOrPanic(200, listener{ID: "lst12345", Name: "ff-sub-default-12345"})) + + r := make(chan []byte) + s := make(chan []byte) + wsm := c.wsconn.(*wsmocks.WSClient) + wsm.On("Receive").Return((<-chan []byte)(r)) + wsm.On("Send", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + bytes, _ := args.Get(1).([]byte) + s <- bytes + }).Return(nil) + wsm.On("Close").Return() + c.streamID = "es12345" + c.closed = make(chan struct{}) + + client := resty.NewWithClient(mockedClient) + client.SetBaseURL("http://localhost:12345") + c.streams = &streamManager{ + client: client, + batchSize: 500, + batchTimeout: 10000, + } + + data := []byte(`{ + "batchNumber": 1337, + "events": [ + { + "listenerId": "lst12345", + "blockHash": "fcb0504f47abf2cc52cd6d509036d512fd6cbec19d0e1bbaaf21f0699882de7b", + "blockNumber": 11466734, + "signature": "TransactionFinalized(string)", + "timestamp": "2025-02-10T12:00:00.000000000+00:00", + "transactionIndex": 0, + "transactionHash": "cc76904959438e05aaa83078bbdc81af5685a8e28ea4fcfcfd741df7df1e596d", + "logIndex": 0, + "data": { + "transactionId": "bdae5f48cd7eec76938f62c648a1972907e24b4abb374b64609710792959e4fa" + } + } + ] + }`) + + em := &blockchainmocks.Callbacks{} + c.SetHandler("default", em) + em.On("BlockchainEventBatch", mock.MatchedBy(func(events []*blockchain.EventToDispatch) bool { + return len(events) == 1 && + events[0].Type == blockchain.EventTypeForListener && + events[0].ForListener.ListenerID == "lst12345" + })).Return(nil) + + go c.eventLoop() + + r <- data + response := <-s + var parsed cardanoWSCommandPayload + err := json.Unmarshal(response, &parsed) + assert.NoError(t, err) + assert.Equal(t, c.pluginTopic, parsed.Topic) + assert.Equal(t, int64(1337), parsed.BatchNumber) + assert.Equal(t, "ack", parsed.Type) +} + +func TestEventLoopReceiveBadBatch(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + mockedClient := &http.Client{} + httpmock.ActivateNonDefault(mockedClient) + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams/es12345/listeners/lst12345", + httpmock.NewJsonResponderOrPanic(200, listener{ID: "lst12345", Name: "ff-sub-default-12345"})) + client := resty.NewWithClient(mockedClient) + client.SetBaseURL("http://localhost:12345") + c.streams = &streamManager{ + client: client, + batchSize: 500, + batchTimeout: 10000, + } + + r := make(chan []byte) + s := make(chan []byte) + wsm := c.wsconn.(*wsmocks.WSClient) + wsm.On("Receive").Return((<-chan []byte)(r)) + wsm.On("Send", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + bytes, _ := args.Get(1).([]byte) + s <- bytes + }).Return(nil) + wsm.On("Close").Return() + c.streamID = "es12345" + c.closed = make(chan struct{}) + + data := []byte(`{ + "batchNumber": 1337, + "events": [ + { + "listenerId": "lst12345", + "blockHash": "fcb0504f47abf2cc52cd6d509036d512fd6cbec19d0e1bbaaf21f0699882de7b", + "blockNumber": 11466734, + "signature": "TransactionFinalized(string)", + "timestamp": "2025-02-10T12:00:00.000000000+00:00", + "transactionIndex": 0, + "transactionHash": "cc76904959438e05aaa83078bbdc81af5685a8e28ea4fcfcfd741df7df1e596d", + "logIndex": 0, + "data": { + "transactionId": "bdae5f48cd7eec76938f62c648a1972907e24b4abb374b64609710792959e4fa" + } + } + ] + }`) + + em := &blockchainmocks.Callbacks{} + c.SetHandler("default", em) + em.On("BlockchainEventBatch", mock.MatchedBy(func(events []*blockchain.EventToDispatch) bool { + return len(events) == 1 && + events[0].Type == blockchain.EventTypeForListener && + events[0].ForListener.ListenerID == "lst12345" + })).Return(errors.New("My Error Message")) + + go c.eventLoop() + + r <- data + response := <-s + var parsed cardanoWSCommandPayload + err := json.Unmarshal(response, &parsed) + assert.NoError(t, err) + assert.Equal(t, c.pluginTopic, parsed.Topic) + assert.Equal(t, int64(1337), parsed.BatchNumber) + assert.Equal(t, "error", parsed.Type) + assert.Equal(t, "My Error Message", parsed.Message) +} + +func TestEventLoopReceiveMalformedBatch(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + mockedClient := &http.Client{} + httpmock.ActivateNonDefault(mockedClient) + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams/es12345/listeners/lst12345", + httpmock.NewJsonResponderOrPanic(200, listener{ID: "lst12345", Name: "ff-sub-default-12345"})) + httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams/es12345/listeners/", + httpmock.NewStringResponder(404, "Not Found")) + client := resty.NewWithClient(mockedClient) + client.SetBaseURL("http://localhost:12345") + c.streams = &streamManager{ + client: client, + batchSize: 500, + batchTimeout: 10000, + } + + r := make(chan []byte) + s := make(chan []byte) + wsm := c.wsconn.(*wsmocks.WSClient) + wsm.On("Receive").Return((<-chan []byte)(r)) + wsm.On("Send", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + bytes, _ := args.Get(1).([]byte) + s <- bytes + }).Return(nil) + wsm.On("Close").Return() + c.streamID = "es12345" + c.closed = make(chan struct{}) + + go c.eventLoop() + + r <- []byte(`{ + "batchNumber": 1337, + "events": [{}] + }`) + response := <-s + var parsed cardanoWSCommandPayload + err := json.Unmarshal(response, &parsed) + assert.NoError(t, err) + assert.Equal(t, c.pluginTopic, parsed.Topic) + assert.Equal(t, int64(1337), parsed.BatchNumber) + assert.Equal(t, "error", parsed.Type) + assert.Regexp(t, "FF10282.*Not Found", parsed.Message) + + r <- []byte(`{ + "batchNumber": 1338, + "events": [ + { + "listenerId": "lst12345", + "blockNumber": 1337, + "transactionHash": "cafed00d" + }, + { + "listenerId": "lst12345", + "blockNumber": 1337, + "timestamp": "2025-02-10T12:00:00.000000000+00:00" + }, + { + "listenerId": "lst12345", + "timestamp": "2025-02-10T12:00:00.000000000+00:00", + "transactionHash": "cafed00d" + } + ] + }`) + response = <-s + err = json.Unmarshal(response, &parsed) + assert.NoError(t, err) + assert.Equal(t, c.pluginTopic, parsed.Topic) + assert.Equal(t, int64(1338), parsed.BatchNumber) + assert.Equal(t, "ack", parsed.Type) +} +func TestSubmitBatchPinNotSupported(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + err := c.SubmitBatchPin(c.ctx, "", "", "", nil, nil) + assert.Regexp(t, "SubmitBatchPin not supported", err) +} + +func TestAddContractListener(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + c.streamID = "es-1" + c.streams = &streamManager{ + client: c.client, + } + + sub := &core.ContractListener{ + Filters: []*core.ListenerFilter{ + { + Event: &core.FFISerializedEvent{ + FFIEventDefinition: fftypes.FFIEventDefinition{ + Name: "Changed", + }, + }, + Location: fftypes.JSONAnyPtr(fftypes.JSONObject{ + "address": "submit-tx", + }.String()), + }, + }, + Options: &core.ContractListenerOptions{ + FirstEvent: string(core.SubOptsFirstEventOldest), + }, + } + + httpmock.RegisterResponder("POST", "http://localhost:12345/eventstreams/es-1/listeners", + httpmock.NewJsonResponderOrPanic(200, &listener{ID: "new-id"})) + + err := c.AddContractListener(context.Background(), sub, "") + assert.NoError(t, err) + assert.Equal(t, "new-id", sub.BackendID) +} + +func TestAddContractListenerBadLocation(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + c.streamID = "es-1" + c.streams = &streamManager{ + client: c.client, + } + + sub := &core.ContractListener{ + Filters: []*core.ListenerFilter{ + { + Event: &core.FFISerializedEvent{ + FFIEventDefinition: fftypes.FFIEventDefinition{ + Name: "Changed", + }, + }, + Location: fftypes.JSONAnyPtr("42"), + }, + }, + Options: &core.ContractListenerOptions{ + FirstEvent: string(core.SubOptsFirstEventOldest), + }, + } + + httpmock.RegisterResponder("POST", "http://localhost:12345/eventstreams/es-1/listeners", + httpmock.NewJsonResponderOrPanic(200, &listener{ID: "new-id"})) + + err := c.AddContractListener(context.Background(), sub, "") + assert.Regexp(t, "10310", err) +} + +func TestDeleteContractListener(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + + c.streamID = "es-1" + c.streams = &streamManager{ + client: c.client, + } + + sub := &core.ContractListener{ + BackendID: "sb-1", + } + + httpmock.RegisterResponder("DELETE", `http://localhost:12345/eventstreams/es-1/listeners/sb-1`, + httpmock.NewStringResponder(204, "")) + + err := c.DeleteContractListener(context.Background(), sub, true) + assert.NoError(t, err) +} + +func TestDeleteContractListenerFail(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + + c.streamID = "es-1" + c.streams = &streamManager{ + client: c.client, + } + + sub := &core.ContractListener{ + BackendID: "sb-1", + } + + httpmock.RegisterResponder("DELETE", `http://localhost:12345/eventstreams/es-1/listeners/sb-1`, + httpmock.NewStringResponder(500, "oops")) + + err := c.DeleteContractListener(context.Background(), sub, true) + assert.Regexp(t, "FF10282", err) +} + +func TestGetContractListenerStatus(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + + c.streamID = "es-1" + c.streams = &streamManager{ + client: c.client, + } + + httpmock.RegisterResponder("GET", `http://localhost:12345/eventstreams/es-1/listeners/sb-1`, + httpmock.NewJsonResponderOrPanic(200, &listener{ID: "sb-1", Name: "something"})) + + found, _, status, err := c.GetContractListenerStatus(context.Background(), "ns1", "sb-1", true) + assert.NoError(t, err) + assert.Equal(t, core.ContractListenerStatusUnknown, status) + assert.True(t, found) +} + +func TestGetContractListenerStatusNotFound(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + + c.streamID = "es-1" + c.streams = &streamManager{ + client: c.client, + } + + httpmock.RegisterResponder("GET", `http://localhost:12345/eventstreams/es-1/listeners/sb-1`, + httpmock.NewStringResponder(404, "no")) + + found, _, status, err := c.GetContractListenerStatus(context.Background(), "ns1", "sb-1", true) + assert.NoError(t, err) + assert.Equal(t, core.ContractListenerStatusUnknown, status) + assert.False(t, found) +} + +func TestGetContractListenerErrorNotFound(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + + c.streamID = "es-1" + c.streams = &streamManager{ + client: c.client, + } + + httpmock.RegisterResponder("GET", `http://localhost:12345/eventstreams/es-1/listeners/sb-1`, + httpmock.NewStringResponder(404, "no")) + + _, _, _, err := c.GetContractListenerStatus(context.Background(), "ns1", "sb-1", false) + assert.Regexp(t, "FF10282", err) +} + +func TestGetTransactionStatusSuccess(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + + op := &core.Operation{ + Namespace: "ns1", + ID: fftypes.MustParseUUID("9ffc50ff-6bfe-4502-adc7-93aea54cc059"), + Status: "Pending", + } + + httpmock.RegisterResponder("GET", "http://localhost:12345/transactions/ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059", + func(req *http.Request) (*http.Response, error) { + transactionStatus := make(map[string]interface{}) + transactionStatus["id"] = "ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059" + transactionStatus["status"] = "Succeeded" + transactionStatus["transactionHash"] = "txHash" + return httpmock.NewJsonResponderOrPanic(200, transactionStatus)(req) + }) + + tm := &coremocks.OperationCallbacks{} + c.SetOperationHandler("ns1", tm) + tm.On("OperationUpdate", mock.MatchedBy(func(update *core.OperationUpdate) bool { + return update.NamespacedOpID == "ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059" && + update.Status == core.OpStatusSucceeded && + update.BlockchainTXID == "txHash" && + update.Plugin == "cardano" + })).Return(errors.New("won't stop processing")) + + status, err := c.GetTransactionStatus(context.Background(), op) + assert.NoError(t, err) + assert.NotNil(t, status) + tm.AssertExpectations(t) +} + +func TestGetTransactionStatusFailure(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + + op := &core.Operation{ + Namespace: "ns1", + ID: fftypes.MustParseUUID("9ffc50ff-6bfe-4502-adc7-93aea54cc059"), + Status: "Pending", + } + + httpmock.RegisterResponder("GET", "http://localhost:12345/transactions/ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059", + func(req *http.Request) (*http.Response, error) { + transactionStatus := make(map[string]interface{}) + transactionStatus["id"] = "ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059" + transactionStatus["status"] = "Failed" + transactionStatus["errorMessage"] = "Something went wrong" + return httpmock.NewJsonResponderOrPanic(200, transactionStatus)(req) + }) + + tm := &coremocks.OperationCallbacks{} + c.SetOperationHandler("ns1", tm) + tm.On("OperationUpdate", mock.MatchedBy(func(update *core.OperationUpdate) bool { + return update.NamespacedOpID == "ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059" && + update.Status == core.OpStatusFailed && + update.ErrorMessage == "Something went wrong" && + update.Plugin == "cardano" + })).Return(errors.New("won't stop processing")) + + status, err := c.GetTransactionStatus(context.Background(), op) + assert.NoError(t, err) + assert.NotNil(t, status) + tm.AssertExpectations(t) +} + +func TestGetTransactionStatusEmptyObject(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + + op := &core.Operation{ + Namespace: "ns1", + ID: fftypes.MustParseUUID("9ffc50ff-6bfe-4502-adc7-93aea54cc059"), + Status: "Pending", + } + + httpmock.RegisterResponder("GET", "http://localhost:12345/transactions/ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059", + func(req *http.Request) (*http.Response, error) { + transactionStatus := make(map[string]interface{}) + return httpmock.NewJsonResponderOrPanic(200, transactionStatus)(req) + }) + + status, err := c.GetTransactionStatus(context.Background(), op) + assert.NoError(t, err) + assert.NotNil(t, status) +} + +func TestGetTransactionStatusNotFound(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + + op := &core.Operation{ + Namespace: "ns1", + ID: fftypes.MustParseUUID("9ffc50ff-6bfe-4502-adc7-93aea54cc059"), + Status: "Pending", + } + + httpmock.RegisterResponder("GET", "http://localhost:12345/transactions/ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059", + httpmock.NewStringResponder(404, "nah")) + + status, err := c.GetTransactionStatus(context.Background(), op) + assert.NoError(t, err) + assert.Nil(t, status) +} + +func TestGetTransactionStatusError(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + + op := &core.Operation{ + Namespace: "ns1", + ID: fftypes.MustParseUUID("9ffc50ff-6bfe-4502-adc7-93aea54cc059"), + Status: "Pending", + } + + httpmock.RegisterResponder("GET", "http://localhost:12345/transactions/ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059", + httpmock.NewStringResponder(500, "uh oh")) + + _, err := c.GetTransactionStatus(context.Background(), op) + assert.Regexp(t, "FF10282", err) +} + +func TestGetTransactionStatusHandleReceipt(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + + op := &core.Operation{ + Namespace: "ns1", + ID: fftypes.MustParseUUID("9ffc50ff-6bfe-4502-adc7-93aea54cc059"), + Status: "Pending", + } + + httpmock.RegisterResponder("GET", "http://localhost:12345/transactions/ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059", + func(req *http.Request) (*http.Response, error) { + transactionStatus := make(map[string]interface{}) + transactionStatus["status"] = "Succeeded" + return httpmock.NewJsonResponderOrPanic(200, transactionStatus)(req) + }) + + status, err := c.GetTransactionStatus(context.Background(), op) + assert.NoError(t, err) + assert.NotNil(t, status) +} + +func TestSubmitNetworkActionNotSupported(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + err := c.SubmitNetworkAction(c.ctx, "", "", core.NetworkActionTerminate, nil) + assert.Regexp(t, "SubmitNetworkAction not supported", err) +} + +func TestAddFireflySubscriptionBadLocation(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + mockedClient := &http.Client{} + httpmock.ActivateNonDefault(mockedClient) + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams/es12345/listeners", + httpmock.NewJsonResponderOrPanic(200, &[]listener{})) + client := resty.NewWithClient(mockedClient) + client.SetBaseURL("http://localhost:12345") + c.streamID = "es12345" + c.streams = &streamManager{ + client: client, + batchSize: 500, + batchTimeout: 10000, + } + + location := fftypes.JSONAnyPtr(fftypes.JSONObject{ + "bad": "bad", + }.String()) + contract := &blockchain.MultipartyContract{ + Location: location, + FirstEvent: "oldest", + } + + ns := &core.Namespace{Name: "ns1", NetworkName: "ns1"} + _, err := c.AddFireflySubscription(c.ctx, ns, contract, "") + assert.Regexp(t, "FF10310", err) +} + +func TestAddAndRemoveFireflySubscription(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + toServer, _, wsURL, done := wsclient.NewTestWSServer(nil) + defer done() + + mockedClient := &http.Client{} + httpmock.ActivateNonDefault(mockedClient) + defer httpmock.DeactivateAndReset() + + u, _ := url.Parse(wsURL) + u.Scheme = "http" + httpURL := u.String() + + httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams", httpURL), + httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", Name: c.pluginTopic}})) + httpmock.RegisterResponder("PATCH", fmt.Sprintf("%s/eventstreams/es12345", httpURL), + httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345", Name: c.pluginTopic})) + httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams/es12345/listeners", httpURL), + httpmock.NewJsonResponderOrPanic(200, []listener{})) + httpmock.RegisterResponder("POST", fmt.Sprintf("%s/eventstreams/es12345/listeners", httpURL), + httpmock.NewJsonResponderOrPanic(200, listener{ID: "lst12345", Name: "ns1_2_BatchPin"})) + + resetConf(c) + utCardanoconnectConf.Set(ffresty.HTTPConfigURL, httpURL) + utCardanoconnectConf.Set(ffresty.HTTPCustomClient, mockedClient) + utCardanoconnectConf.Set(CardanoconnectConfigTopic, "topic1") + + err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.NoError(t, err) + startupMessage := <-toServer + assert.Equal(t, `{"type":"listen","topic":"topic1"}`, startupMessage) + startupMessage = <-toServer + assert.Equal(t, `{"type":"listenreplies"}`, startupMessage) + + location := fftypes.JSONAnyPtr(fftypes.JSONObject{ + "address": "submit-tx", + }.String()) + contract := &blockchain.MultipartyContract{ + Location: location, + FirstEvent: "oldest", + } + + ns := &core.Namespace{Name: "ns1", NetworkName: "ns1"} + subID, err := c.AddFireflySubscription(c.ctx, ns, contract, "") + assert.NoError(t, err) + assert.NotNil(t, c.subs.GetSubscription(subID)) + + c.RemoveFireflySubscription(c.ctx, subID) + assert.Nil(t, c.subs.GetSubscription(subID)) +} + +func TestAddFireflySubscriptionListError(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + toServer, _, wsURL, done := wsclient.NewTestWSServer(nil) + defer done() + + mockedClient := &http.Client{} + httpmock.ActivateNonDefault(mockedClient) + defer httpmock.DeactivateAndReset() + + u, _ := url.Parse(wsURL) + u.Scheme = "http" + httpURL := u.String() + + httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams", httpURL), + httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", Name: c.pluginTopic}})) + httpmock.RegisterResponder("PATCH", fmt.Sprintf("%s/eventstreams/es12345", httpURL), + httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345", Name: c.pluginTopic})) + httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams/es12345/listeners", httpURL), + httpmock.NewStringResponder(500, "whoopsies")) + + resetConf(c) + utCardanoconnectConf.Set(ffresty.HTTPConfigURL, httpURL) + utCardanoconnectConf.Set(ffresty.HTTPCustomClient, mockedClient) + utCardanoconnectConf.Set(CardanoconnectConfigTopic, "topic1") + + err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.NoError(t, err) + startupMessage := <-toServer + assert.Equal(t, `{"type":"listen","topic":"topic1"}`, startupMessage) + startupMessage = <-toServer + assert.Equal(t, `{"type":"listenreplies"}`, startupMessage) + + location := fftypes.JSONAnyPtr(fftypes.JSONObject{ + "address": "submit-tx", + }.String()) + contract := &blockchain.MultipartyContract{ + Location: location, + FirstEvent: "oldest", + } + + ns := &core.Namespace{Name: "ns1", NetworkName: "ns1"} + _, err = c.AddFireflySubscription(c.ctx, ns, contract, "") + assert.Regexp(t, "FF10282", err) +} + +func TestAddFireflySubscriptionAlreadyExists(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + toServer, _, wsURL, done := wsclient.NewTestWSServer(nil) + defer done() + + mockedClient := &http.Client{} + httpmock.ActivateNonDefault(mockedClient) + defer httpmock.DeactivateAndReset() + + u, _ := url.Parse(wsURL) + u.Scheme = "http" + httpURL := u.String() + + httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams", httpURL), + httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", Name: c.pluginTopic}})) + httpmock.RegisterResponder("PATCH", fmt.Sprintf("%s/eventstreams/es12345", httpURL), + httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345", Name: c.pluginTopic})) + httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams/es12345/listeners", httpURL), + httpmock.NewJsonResponderOrPanic(200, []listener{{ID: "lst12345", Name: "ns1_2_BatchPin"}})) + + resetConf(c) + utCardanoconnectConf.Set(ffresty.HTTPConfigURL, httpURL) + utCardanoconnectConf.Set(ffresty.HTTPCustomClient, mockedClient) + utCardanoconnectConf.Set(CardanoconnectConfigTopic, "topic1") + + err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.NoError(t, err) + startupMessage := <-toServer + assert.Equal(t, `{"type":"listen","topic":"topic1"}`, startupMessage) + startupMessage = <-toServer + assert.Equal(t, `{"type":"listenreplies"}`, startupMessage) + + location := fftypes.JSONAnyPtr(fftypes.JSONObject{ + "address": "submit-tx", + }.String()) + contract := &blockchain.MultipartyContract{ + Location: location, + FirstEvent: "oldest", + } + + ns := &core.Namespace{Name: "ns1", NetworkName: "ns1"} + subID, err := c.AddFireflySubscription(c.ctx, ns, contract, "") + assert.NoError(t, err) + assert.Equal(t, "lst12345", subID) +} + +func TestAddFireflySubscriptionCreateError(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + toServer, _, wsURL, done := wsclient.NewTestWSServer(nil) + defer done() + + mockedClient := &http.Client{} + httpmock.ActivateNonDefault(mockedClient) + defer httpmock.DeactivateAndReset() + + u, _ := url.Parse(wsURL) + u.Scheme = "http" + httpURL := u.String() + + httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams", httpURL), + httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", Name: c.pluginTopic}})) + httpmock.RegisterResponder("PATCH", fmt.Sprintf("%s/eventstreams/es12345", httpURL), + httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345", Name: c.pluginTopic})) + httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams/es12345/listeners", httpURL), + httpmock.NewJsonResponderOrPanic(200, []listener{})) + httpmock.RegisterResponder("POST", fmt.Sprintf("%s/eventstreams/es12345/listeners", httpURL), + httpmock.NewStringResponder(500, "whoopsies")) + + resetConf(c) + utCardanoconnectConf.Set(ffresty.HTTPConfigURL, httpURL) + utCardanoconnectConf.Set(ffresty.HTTPCustomClient, mockedClient) + utCardanoconnectConf.Set(CardanoconnectConfigTopic, "topic1") + + err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.NoError(t, err) + startupMessage := <-toServer + assert.Equal(t, `{"type":"listen","topic":"topic1"}`, startupMessage) + startupMessage = <-toServer + assert.Equal(t, `{"type":"listenreplies"}`, startupMessage) + + location := fftypes.JSONAnyPtr(fftypes.JSONObject{ + "address": "submit-tx", + }.String()) + contract := &blockchain.MultipartyContract{ + Location: location, + FirstEvent: "oldest", + } + + ns := &core.Namespace{Name: "ns1", NetworkName: "ns1"} + _, err = c.AddFireflySubscription(c.ctx, ns, contract, "") + assert.Regexp(t, "FF10282", err) +} + +func TestInvokeContractOK(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + location := &Location{ + Address: "simple-tx", + } + options := map[string]interface{}{ + "customOption": "customValue", + } + signingKey := "signingKey" + method := testFFIMethod() + params := map[string]interface{}{ + "varString": "str", + } + locationBytes, err := json.Marshal(location) + assert.NoError(t, err) + + httpmock.RegisterResponder("POST", "http://localhost:12345/contracts/invoke", func(req *http.Request) (*http.Response, error) { + var body map[string]interface{} + json.NewDecoder(req.Body).Decode(&body) + params := body["params"].([]interface{}) + assert.Equal(t, "opId", body["id"]) + assert.Equal(t, "simple-tx", body["address"]) + assert.Equal(t, "testFunc", body["method"].(map[string]interface{})["name"]) + assert.Equal(t, 1, len(params)) + assert.Equal(t, signingKey, body["from"]) + return httpmock.NewJsonResponderOrPanic(200, "")(req) + }) + + parsedMethod, err := c.ParseInterface(context.Background(), method, nil) + assert.NoError(t, err) + + _, err = c.InvokeContract(context.Background(), "opId", signingKey, fftypes.JSONAnyPtrBytes(locationBytes), parsedMethod, params, options, nil) + assert.NoError(t, err) +} + +func TestInvokeContractAddressNotSet(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + location := &Location{} + options := map[string]interface{}{ + "customOption": "customValue", + } + signingKey := "signingKey" + method := testFFIMethod() + params := map[string]interface{}{ + "varString": "str", + } + locationBytes, err := json.Marshal(location) + assert.NoError(t, err) + + parsedMethod, err := c.ParseInterface(context.Background(), method, nil) + assert.NoError(t, err) + + _, err = c.InvokeContract(context.Background(), "", signingKey, fftypes.JSONAnyPtrBytes(locationBytes), parsedMethod, params, options, nil) + assert.Regexp(t, "FF10310", err) +} + +func TestInvokeContractBadMethod(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + location := &Location{ + Address: "simple-tx", + } + options := map[string]interface{}{ + "customOption": "customValue", + } + signingKey := "signingKey" + method := &fftypes.FFIMethod{} + params := map[string]interface{}{ + "varString": "str", + } + locationBytes, err := json.Marshal(location) + assert.NoError(t, err) + + parsedMethod, err := c.ParseInterface(context.Background(), method, nil) + assert.NoError(t, err) + + _, err = c.InvokeContract(context.Background(), "", signingKey, fftypes.JSONAnyPtrBytes(locationBytes), parsedMethod, params, options, nil) + assert.Regexp(t, "FF10457", err) +} + +func TestInvokeContractConnectorError(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + location := &Location{ + Address: "simple-tx", + } + options := map[string]interface{}{ + "customOption": "customValue", + } + signingKey := "signingKey" + method := testFFIMethod() + params := map[string]interface{}{ + "varString": "str", + } + locationBytes, err := json.Marshal(location) + assert.NoError(t, err) + + httpmock.RegisterResponder("POST", "http://localhost:12345/contracts/invoke", func(req *http.Request) (*http.Response, error) { + var body map[string]interface{} + json.NewDecoder(req.Body).Decode(&body) + params := body["params"].([]interface{}) + assert.Equal(t, "opId", body["id"]) + assert.Equal(t, "simple-tx", body["address"]) + assert.Equal(t, "testFunc", body["method"].(map[string]interface{})["name"]) + assert.Equal(t, 1, len(params)) + assert.Equal(t, signingKey, body["from"]) + return httpmock.NewJsonResponderOrPanic(500, &common.BlockchainRESTError{ + Error: "something went wrong", + SubmissionRejected: true, + })(req) + }) + + parsedMethod, err := c.ParseInterface(context.Background(), method, nil) + assert.NoError(t, err) + + rejected, err := c.InvokeContract(context.Background(), "opId", signingKey, fftypes.JSONAnyPtrBytes(locationBytes), parsedMethod, params, options, nil) + assert.True(t, rejected) + assert.Regexp(t, "FF10282", err) +} + +func TestQueryContractNotSupported(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + _, err := c.QueryContract(context.Background(), "", nil, nil, nil, nil) + assert.Regexp(t, "QueryContract not supported", err) +} + +func TestDeployContractOK(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + nsOpId := "ns1:opId" + signingKey := "signingKey" + definition := fftypes.JSONAnyPtr("{}") + contract := fftypes.JSONAnyPtr("\"cafed00d\"") + + httpmock.RegisterResponder("POST", "http://localhost:12345/contracts/deploy", + httpmock.NewStringResponder(202, "")) + + _, err := c.DeployContract(context.Background(), nsOpId, signingKey, definition, contract, nil, nil) + assert.NoError(t, err) +} + +func TestDeployContractConnectorError(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + nsOpId := "ns1:opId" + signingKey := "signingKey" + definition := fftypes.JSONAnyPtr("{}") + contract := fftypes.JSONAnyPtr("\"cafed00d\"") + + httpmock.RegisterResponder("POST", "http://localhost:12345/contracts/deploy", + httpmock.NewJsonResponderOrPanic(500, &common.BlockchainRESTError{ + Error: "oh no", + SubmissionRejected: true, + })) + + rejected, err := c.DeployContract(context.Background(), nsOpId, signingKey, definition, contract, nil, nil) + assert.True(t, rejected) + assert.Regexp(t, "FF10282", err) +} + +func TestGetFFIParamValidator(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + _, err := c.GetFFIParamValidator(context.Background()) + assert.NoError(t, err) +} + +func TestValidateInvokeRequest(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + err := c.ValidateInvokeRequest(context.Background(), &ffiMethodAndErrors{ + method: testFFIMethod(), + }, nil, false) + assert.NoError(t, err) +} + +func TestValidateInvokeRequestInvalidMethod(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + err := c.ValidateInvokeRequest(context.Background(), &ffiMethodAndErrors{ + method: &fftypes.FFIMethod{}, + }, nil, false) + assert.Regexp(t, "FF10457", err) +} + +func TestGenerateFFINotSupported(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + _, err := c.GenerateFFI(context.Background(), nil) + assert.Regexp(t, "FF10347", err) +} + +func TestConvertDeprecatedContractConfig(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + _, _, err := c.GetAndConvertDeprecatedContractConfig(c.ctx) + assert.NoError(t, err) +} + +func TestNormalizeContractLocation(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + location := &Location{ + Address: "submit-tx", + } + locationBytes, err := json.Marshal(location) + assert.NoError(t, err) + _, err = c.NormalizeContractLocation(context.Background(), blockchain.NormalizeCall, fftypes.JSONAnyPtrBytes(locationBytes)) + assert.NoError(t, err) +} + +func TestNormalizeInvalidContractLocation(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + location := fftypes.JSONAnyPtr("not valid") + _, err := c.NormalizeContractLocation(context.Background(), blockchain.NormalizeCall, location) + assert.Regexp(t, "10310", err) +} + +func TestGenerateEventSignature(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + event := &fftypes.FFIEventDefinition{ + Name: "TransactionFinalized", + Params: fftypes.FFIParams{ + &fftypes.FFIParam{ + Name: "transactionId", + Schema: fftypes.JSONAnyPtr(`{"type": "string"}`), + }, + }, + } + signature, err := c.GenerateEventSignature(context.Background(), event) + assert.NoError(t, err) + assert.Equal(t, "TransactionFinalized(string)", signature) +} + +func TestGenerateEventSignatureWithLocation(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + event := &fftypes.FFIEventDefinition{ + Name: "TransactionFinalized", + Params: fftypes.FFIParams{ + &fftypes.FFIParam{ + Name: "transactionId", + Schema: fftypes.JSONAnyPtr(`{"type": "string"}`), + }, + }, + } + location := fftypes.JSONAnyPtr(`{"address":"submit-tx"}`) + signature, err := c.GenerateEventSignatureWithLocation(context.Background(), event, location) + assert.NoError(t, err) + assert.Equal(t, "submit-tx:TransactionFinalized(string)", signature) +} + +func TestGenerateEventSignatureWithNilLocation(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + event := &fftypes.FFIEventDefinition{ + Name: "TransactionFinalized", + Params: fftypes.FFIParams{ + &fftypes.FFIParam{ + Name: "transactionId", + Schema: fftypes.JSONAnyPtr(`{"type": "string"}`), + }, + }, + } + signature, err := c.GenerateEventSignatureWithLocation(context.Background(), event, nil) + assert.NoError(t, err) + assert.Equal(t, "*:TransactionFinalized(string)", signature) +} + +func TestGenerateEventSignatureWithInvalidLocation(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + event := &fftypes.FFIEventDefinition{ + Name: "TransactionFinalized", + Params: fftypes.FFIParams{ + &fftypes.FFIParam{ + Name: "transactionId", + Schema: fftypes.JSONAnyPtr(`{"type": "string"}`), + }, + }, + } + location := fftypes.JSONAnyPtr(`{"address":""}`) + _, err := c.GenerateEventSignatureWithLocation(context.Background(), event, location) + assert.Regexp(t, "FF10310", err) +} + +func TestGenerateErrorSignature(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + signature := c.GenerateErrorSignature(context.Background(), nil) + assert.Equal(t, "", signature) +} + +func TestCheckOverlappingLocationsEmpty(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + location := &Location{} + locationBytes, err := json.Marshal(location) + assert.NoError(t, err) + overlapping, err := c.CheckOverlappingLocations(context.Background(), nil, fftypes.JSONAnyPtrBytes(locationBytes)) + assert.NoError(t, err) + assert.True(t, overlapping) +} + +func TestCheckOverlappingLocationsBadLocation(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + location := &Location{} + locationBytes, err := json.Marshal(location) + assert.NoError(t, err) + _, err = c.CheckOverlappingLocations(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), fftypes.JSONAnyPtrBytes(locationBytes)) + assert.Error(t, err) + assert.Regexp(t, "FF10310", err.Error()) +} + +func TestCheckOverlappingLocationsOneLocation(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + location := &Location{ + Address: "3081D84FD367044F4ED453F2024709242470388C", + } + locationBytes, err := json.Marshal(location) + assert.NoError(t, err) + + location2 := &Location{} + location2Bytes, err := json.Marshal(location2) + assert.NoError(t, err) + _, err = c.CheckOverlappingLocations(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), fftypes.JSONAnyPtrBytes(location2Bytes)) + assert.Error(t, err) + assert.Regexp(t, "FF10310", err.Error()) +} + +func TestCheckOverlappingLocationsSameLocation(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + location := &Location{ + Address: "3081D84FD367044F4ED453F2024709242470388C", + } + locationBytes, err := json.Marshal(location) + assert.NoError(t, err) + result, err := c.CheckOverlappingLocations(context.Background(), fftypes.JSONAnyPtrBytes(locationBytes), fftypes.JSONAnyPtrBytes(locationBytes)) + assert.NoError(t, err) + assert.True(t, result) +} diff --git a/internal/blockchain/cardano/eventstream.go b/internal/blockchain/cardano/eventstream.go index 63e081344e..b768fa2df0 100644 --- a/internal/blockchain/cardano/eventstream.go +++ b/internal/blockchain/cardano/eventstream.go @@ -22,8 +22,8 @@ import ( "github.com/go-resty/resty/v2" "github.com/hyperledger/firefly-common/pkg/ffresty" + "github.com/hyperledger/firefly-common/pkg/log" "github.com/hyperledger/firefly/internal/coremsgs" - "github.com/hyperledger/firefly/pkg/core" ) type streamManager struct { @@ -129,34 +129,37 @@ func (s *streamManager) ensureEventStream(ctx context.Context, topic string) (*e return s.createEventStream(ctx, topic) } -func (s *streamManager) getListener(ctx context.Context, streamID string, listenerID string) (listener *listener, err error) { +func (s *streamManager) getListener(ctx context.Context, streamID string, listenerID string, okNotFound bool) (listener *listener, err error) { res, err := s.client.R(). SetContext(ctx). SetResult(&listener). Get(fmt.Sprintf("/eventstreams/%s/listeners/%s", streamID, listenerID)) if err != nil || !res.IsSuccess() { + if okNotFound && res.StatusCode() == 404 { + return nil, nil + } return nil, ffresty.WrapRestErr(ctx, res, err, coremsgs.MsgCardanoconnectRESTErr) } return listener, nil } -func (s *streamManager) createListener(ctx context.Context, streamID, name, lastEvent string, filters *core.ListenerFilters) (listener *listener, err error) { - cardanoFilters := []filter{} - for _, f := range *filters { - address := f.Location.JSONObject().GetString("address") - cardanoFilters = append(cardanoFilters, filter{ - Event: eventfilter{ - Contract: address, - EventPath: f.Event.Name, - }, - }) +func (s *streamManager) getListeners(ctx context.Context, streamID string) (listeners *[]listener, err error) { + res, err := s.client.R(). + SetContext(ctx). + SetResult(&listeners). + Get(fmt.Sprintf("/eventstreams/%s/listeners", streamID)) + if err != nil || !res.IsSuccess() { + return nil, ffresty.WrapRestErr(ctx, res, err, coremsgs.MsgCardanoconnectRESTErr) } + return listeners, nil +} +func (s *streamManager) createListener(ctx context.Context, streamID, name, lastEvent string, filters []filter) (listener *listener, err error) { body := map[string]interface{}{ "name": name, "type": "events", "fromBlock": lastEvent, - "filters": cardanoFilters, + "filters": filters, } res, err := s.client.R(). @@ -182,3 +185,29 @@ func (s *streamManager) deleteListener(ctx context.Context, streamID, listenerID } return nil } + +func (s *streamManager) ensureFireFlyListener(ctx context.Context, namespace string, version int, address, firstEvent, streamID string) (l *listener, err error) { + existingListeners, err := s.getListeners(ctx, streamID) + if err != nil { + return nil, err + } + + name := fmt.Sprintf("%s_%d_BatchPin", namespace, version) + for _, l := range *existingListeners { + if l.Name == name { + return &l, nil + } + } + + filters := []filter{{ + eventfilter{ + Contract: address, + EventPath: "BatchPin", + }, + }} + if l, err = s.createListener(ctx, streamID, name, firstEvent, filters); err != nil { + return nil, err + } + log.L(ctx).Infof("BatchPin subscription: %s", l.ID) + return l, nil +} diff --git a/internal/networkmap/did_test.go b/internal/networkmap/did_test.go index 2040861eaf..8ac97b2cce 100644 --- a/internal/networkmap/did_test.go +++ b/internal/networkmap/did_test.go @@ -34,6 +34,15 @@ func TestDIDGenerationOK(t *testing.T) { org1 := testOrg("org1") + verifierCardano := (&core.Verifier{ + Identity: org1.ID, + Namespace: org1.Namespace, + VerifierRef: core.VerifierRef{ + Type: core.VerifierTypeCardanoAddress, + Value: "addr_test1vqhkukz0285zvk0xrwk9jlq0075tx6furuzcjvzpnhtgelsuhhqc4", + }, + Created: fftypes.Now(), + }) verifierEth := (&core.Verifier{ Identity: org1.ID, Namespace: org1.Namespace, @@ -83,6 +92,7 @@ func TestDIDGenerationOK(t *testing.T) { mdi := nm.database.(*databasemocks.Plugin) mdi.On("GetIdentityByID", nm.ctx, "ns1", mock.Anything).Return(org1, nil) mdi.On("GetVerifiers", nm.ctx, "ns1", mock.Anything).Return([]*core.Verifier{ + verifierCardano, verifierEth, verifierTezos, verifierMSP, @@ -99,6 +109,12 @@ func TestDIDGenerationOK(t *testing.T) { }, ID: org1.DID, VerificationMethods: []*VerificationMethod{ + { + ID: verifierCardano.Hash.String(), + Type: "PaymentVerificationKeyShelley_ed25519", + Controller: org1.DID, + BlockchainAccountID: verifierCardano.Value, + }, { ID: verifierEth.Hash.String(), Type: "EcdsaSecp256k1VerificationKey2019", @@ -125,6 +141,7 @@ func TestDIDGenerationOK(t *testing.T) { }, }, Authentication: []string{ + fmt.Sprintf("#%s", verifierCardano.Hash.String()), fmt.Sprintf("#%s", verifierEth.Hash.String()), fmt.Sprintf("#%s", verifierTezos.Hash.String()), fmt.Sprintf("#%s", verifierMSP.Hash.String()), From 89dd513c58f31d424f2116f10842fa1d9053a305 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Mon, 10 Feb 2025 17:08:06 -0500 Subject: [PATCH 15/43] fix: use newer curl version Signed-off-by: Simon Gellis --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4c857ea475..7904ce56ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ ARG GIT_REF RUN apk add make=4.4.1-r2 \ gcc=13.2.1_git20231014-r0 \ build-base=0.5-r3 \ - curl=8.11.1-r0 \ + curl=8.12.0-r0 \ git=2.43.6-r0 WORKDIR /firefly RUN chgrp -R 0 /firefly \ @@ -76,7 +76,7 @@ ARG UI_RELEASE RUN apk add --update --no-cache \ sqlite=3.44.2-r0 \ postgresql16-client=16.6-r0 \ - curl=8.11.1-r0 \ + curl=8.12.0-r0 \ jq=1.7.1-r0 WORKDIR /firefly RUN chgrp -R 0 /firefly \ From 8a1372f073577a2e24ac87c7651a802552a722fd Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Mon, 10 Feb 2025 17:11:59 -0500 Subject: [PATCH 16/43] fix: use same curl version everywhere Signed-off-by: Simon Gellis --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7904ce56ee..6dedddb81c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -64,7 +64,7 @@ RUN mkdir -p build/contracts \ FROM alpine:3.19 AS sbom WORKDIR / ADD . /SBOM -RUN apk add --no-cache curl +RUN apk add --no-cache curl=8.12.0-r0 RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.48.3 RUN trivy fs --format spdx-json --output /sbom.spdx.json /SBOM RUN trivy sbom /sbom.spdx.json --severity UNKNOWN,HIGH,CRITICAL --db-repository public.ecr.aws/aquasecurity/trivy-db --exit-code 1 From e992174378371b3686666c39fca1347bce9cd33b Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Mon, 10 Feb 2025 17:17:32 -0500 Subject: [PATCH 17/43] fix: downgrade to older curl version everywhere Signed-off-by: Simon Gellis --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6dedddb81c..6f8e7d60b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ ARG GIT_REF RUN apk add make=4.4.1-r2 \ gcc=13.2.1_git20231014-r0 \ build-base=0.5-r3 \ - curl=8.12.0-r0 \ + curl=8.11.1-r1 \ git=2.43.6-r0 WORKDIR /firefly RUN chgrp -R 0 /firefly \ @@ -64,7 +64,7 @@ RUN mkdir -p build/contracts \ FROM alpine:3.19 AS sbom WORKDIR / ADD . /SBOM -RUN apk add --no-cache curl=8.12.0-r0 +RUN apk add --no-cache curl=8.11.1-r1 RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.48.3 RUN trivy fs --format spdx-json --output /sbom.spdx.json /SBOM RUN trivy sbom /sbom.spdx.json --severity UNKNOWN,HIGH,CRITICAL --db-repository public.ecr.aws/aquasecurity/trivy-db --exit-code 1 @@ -76,7 +76,7 @@ ARG UI_RELEASE RUN apk add --update --no-cache \ sqlite=3.44.2-r0 \ postgresql16-client=16.6-r0 \ - curl=8.12.0-r0 \ + curl=8.11.1-r1 \ jq=1.7.1-r0 WORKDIR /firefly RUN chgrp -R 0 /firefly \ From b4e295fdb4b1dc47ce5b46c80c03b6c6505ccd96 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Mon, 10 Feb 2025 17:28:49 -0500 Subject: [PATCH 18/43] test: add test on invalid input to reach 100% coverage Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano_test.go | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/internal/blockchain/cardano/cardano_test.go b/internal/blockchain/cardano/cardano_test.go index 9599a6137b..0c25165cc3 100644 --- a/internal/blockchain/cardano/cardano_test.go +++ b/internal/blockchain/cardano/cardano_test.go @@ -831,6 +831,32 @@ func TestGetTransactionStatusEmptyObject(t *testing.T) { assert.NotNil(t, status) } +func TestGetTransactionStatusInvalidTx(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + + op := &core.Operation{ + Namespace: "", + ID: fftypes.MustParseUUID("9ffc50ff-6bfe-4502-adc7-93aea54cc059"), + Status: "Pending", + } + + httpmock.RegisterResponder("GET", "http://localhost:12345/transactions/:9ffc50ff-6bfe-4502-adc7-93aea54cc059", + func(req *http.Request) (*http.Response, error) { + transactionStatus := make(map[string]interface{}) + transactionStatus["status"] = "Failed" + transactionStatus["errorMessage"] = "Something went wrong" + return httpmock.NewJsonResponderOrPanic(200, transactionStatus)(req) + }) + + status, err := c.GetTransactionStatus(context.Background(), op) + assert.NoError(t, err) + assert.NotNil(t, status) +} + func TestGetTransactionStatusNotFound(t *testing.T) { c, cancel := newTestCardano() defer cancel() From 1f4d2f9fe6fc2b12f437a16a372fce112ecd52a5 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Tue, 11 Feb 2025 17:54:33 -0500 Subject: [PATCH 19/43] feat: use different websockets for different namespaces Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 148 ++++---- internal/blockchain/cardano/cardano_test.go | 358 +++++++++++++------- 2 files changed, 322 insertions(+), 184 deletions(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index f9fa9d8927..cd79a2f76f 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -57,9 +57,10 @@ type Cardano struct { callbacks common.BlockchainCallbacks client *resty.Client streams *streamManager - streamID string - closed chan struct{} - wsconn wsclient.WSClient + streamIDs map[string]string + closed map[string]chan struct{} + wsconns map[string]wsclient.WSClient + wsConfig *wsclient.WSConfig cardanoconnectConf config.Section subs common.FireflySubscriptions } @@ -105,7 +106,7 @@ func (c *Cardano) Init(ctx context.Context, cancelCtx context.CancelFunc, conf c return i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "url", cardanoconnectConf) } - wsConfig, err := wsclient.GenerateConfig(ctx, cardanoconnectConf) + c.wsConfig, err = wsclient.GenerateConfig(ctx, cardanoconnectConf) if err == nil { c.client, err = ffresty.New(c.ctx, cardanoconnectConf) } @@ -119,37 +120,74 @@ func (c *Cardano) Init(ctx context.Context, cancelCtx context.CancelFunc, conf c return i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "topic", "blockchain.cardano.cardanoconnect") } - if wsConfig.WSKeyPath == "" { - wsConfig.WSKeyPath = "/ws" + if c.wsConfig.WSKeyPath == "" { + c.wsConfig.WSKeyPath = "/ws" } - c.wsconn, err = wsclient.New(ctx, wsConfig, nil, c.afterConnect) + + c.streamIDs = make(map[string]string) + c.closed = make(map[string]chan struct{}) + c.wsconns = make(map[string]wsclient.WSClient) + c.streams = newStreamManager(c.client, c.cardanoconnectConf.GetUint(CardanoconnectConfigBatchSize), uint(c.cardanoconnectConf.GetDuration(CardanoconnectConfigBatchTimeout).Milliseconds())) + + return nil +} + +func (c *Cardano) getTopic(namespace string) string { + return fmt.Sprintf("%s/%s", c.pluginTopic, namespace) +} + +func (c *Cardano) StartNamespace(ctx context.Context, namespace string) (err error) { + logger := log.L(c.ctx) + logger.Debugf("Starting namespace: %s", namespace) + topic := c.getTopic(namespace) + + c.wsconns[namespace], err = wsclient.New(ctx, c.wsConfig, nil, func(ctx context.Context, w wsclient.WSClient) error { + b, _ := json.Marshal(&cardanoWSCommandPayload{ + Type: "listen", + Topic: topic, + }) + err := w.Send(ctx, b) + if err == nil { + b, _ = json.Marshal(&cardanoWSCommandPayload{ + Type: "listenreplies", + }) + err = w.Send(ctx, b) + } + return err + }) if err != nil { return err } - c.streams = newStreamManager(c.client, c.cardanoconnectConf.GetUint(CardanoconnectConfigBatchSize), uint(c.cardanoconnectConf.GetDuration(CardanoconnectConfigBatchTimeout).Milliseconds())) - - stream, err := c.streams.ensureEventStream(c.ctx, c.pluginTopic) + // Ensure that our event stream is in place + stream, err := c.streams.ensureEventStream(ctx, topic) if err != nil { return err } + logger.Infof("Event stream: %s (topic=%s)", stream.ID, topic) + c.streamIDs[namespace] = stream.ID - log.L(c.ctx).Infof("Event stream: %s (topic=%s)", stream.ID, c.pluginTopic) - c.streamID = stream.ID + err = c.wsconns[namespace].Connect() + if err != nil { + return err + } - c.closed = make(chan struct{}) - go c.eventLoop() + c.closed[namespace] = make(chan struct{}) - return c.wsconn.Connect() -} + go c.eventLoop(namespace) -func (c *Cardano) StartNamespace(ctx context.Context, namespace string) (err error) { - // TODO: Implement return nil } func (c *Cardano) StopNamespace(ctx context.Context, namespace string) (err error) { - // TODO: Implement + wsconn, ok := c.wsconns[namespace] + if ok { + wsconn.Close() + } + delete(c.wsconns, namespace) + delete(c.streamIDs, namespace) + delete(c.closed, namespace) + return nil } @@ -173,7 +211,7 @@ func (c *Cardano) AddFireflySubscription(ctx context.Context, namespace *core.Na version, _ := c.GetNetworkVersion(ctx, contract.Location) - l, err := c.streams.ensureFireFlyListener(ctx, namespace.Name, version, location.Address, contract.FirstEvent, c.streamID) + l, err := c.streams.ensureFireFlyListener(ctx, namespace.Name, version, location.Address, contract.FirstEvent, c.streamIDs[namespace.Name]) if err != nil { return "", err } @@ -323,7 +361,13 @@ func (c *Cardano) encodeContractLocation(_ context.Context, location *Location) } func (c *Cardano) AddContractListener(ctx context.Context, listener *core.ContractListener, lastProtocolID string) (err error) { - subName := fmt.Sprintf("ff-sub-%s-%s", listener.Namespace, listener.ID) + namespace := listener.Namespace + + if len(listener.Filters) == 0 { + return i18n.NewError(ctx, coremsgs.MsgFiltersEmpty, listener.Name) + } + + subName := fmt.Sprintf("ff-sub-%s-%s", namespace, listener.ID) firstEvent := string(core.SubOptsFirstEventNewest) if listener.Options != nil { firstEvent = listener.Options.FirstEvent @@ -343,17 +387,17 @@ func (c *Cardano) AddContractListener(ctx context.Context, listener *core.Contra }) } - result, err := c.streams.createListener(ctx, c.streamID, subName, firstEvent, filters) + result, err := c.streams.createListener(ctx, c.streamIDs[namespace], subName, firstEvent, filters) listener.BackendID = result.ID return err } func (c *Cardano) DeleteContractListener(ctx context.Context, subscription *core.ContractListener, okNotFound bool) error { - return c.streams.deleteListener(ctx, c.streamID, subscription.BackendID) + return c.streams.deleteListener(ctx, c.streamIDs[subscription.Namespace], subscription.BackendID) } func (c *Cardano) GetContractListenerStatus(ctx context.Context, namespace, subID string, okNotFound bool) (found bool, detail interface{}, status core.ContractListenerStatus, err error) { - l, err := c.streams.getListener(ctx, c.streamID, subID, okNotFound) + l, err := c.streams.getListener(ctx, c.streamIDs[namespace], subID, okNotFound) if err != nil || l == nil { return false, nil, core.ContractListenerStatusUnknown, err } @@ -459,22 +503,6 @@ func (c *Cardano) GetTransactionStatus(ctx context.Context, operation *core.Oper return statusResponse, nil } -func (c *Cardano) afterConnect(ctx context.Context, w wsclient.WSClient) error { - // Send a subscribe to our topic after each connect/reconnect - b, _ := json.Marshal(&cardanoWSCommandPayload{ - Type: "listen", - Topic: c.pluginTopic, - }) - err := w.Send(ctx, b) - if err == nil { - b, _ = json.Marshal(&cardanoWSCommandPayload{ - Type: "listenreplies", - }) - err = w.Send(ctx, b) - } - return err -} - func (c *Cardano) recoverFFI(ctx context.Context, parsedMethod interface{}) (*fftypes.FFIMethod, []*fftypes.FFIError, error) { methodInfo, ok := parsedMethod.(*ffiMethodAndErrors) if !ok || methodInfo.method == nil || methodInfo.method.Name == "" { @@ -483,9 +511,13 @@ func (c *Cardano) recoverFFI(ctx context.Context, parsedMethod interface{}) (*ff return methodInfo.method, methodInfo.errors, nil } -func (c *Cardano) eventLoop() { - defer c.wsconn.Close() - defer close(c.closed) +func (c *Cardano) eventLoop(namespace string) { + topic := c.getTopic(namespace) + wsconn := c.wsconns[namespace] + closed := c.closed[namespace] + + defer wsconn.Close() + defer close(closed) l := log.L(c.ctx).WithField("role", "event-loop") ctx := log.WithLogger(c.ctx, l) for { @@ -493,7 +525,7 @@ func (c *Cardano) eventLoop() { case <-ctx.Done(): l.Debugf("Event loop exiting (context cancelled)") return - case msgBytes, ok := <-c.wsconn.Receive(): + case msgBytes, ok := <-wsconn.Receive(): if !ok { l.Debugf("Event loop exiting (receive channel closed). Terminating server!") c.cancelCtx() @@ -508,13 +540,13 @@ func (c *Cardano) eventLoop() { } switch msgTyped := msgParsed.(type) { case []interface{}: - err = c.handleMessageBatch(ctx, 0, msgTyped) + err = c.handleMessageBatch(ctx, namespace, 0, msgTyped) if err == nil { ack, _ := json.Marshal(&cardanoWSCommandPayload{ Type: "ack", - Topic: c.pluginTopic, + Topic: topic, }) - err = c.wsconn.Send(ctx, ack) + err = wsconn.Send(ctx, ack) } case map[string]interface{}: isBatch := false @@ -522,10 +554,10 @@ func (c *Cardano) eventLoop() { if events, ok := msgTyped["events"].([]interface{}); ok { // FFTM delivery with a batch number to use in the ack isBatch = true - err = c.handleMessageBatch(ctx, (int64)(batchNumber), events) + err = c.handleMessageBatch(ctx, namespace, (int64)(batchNumber), events) // Errors processing messages are converted into nacks ackOrNack := &cardanoWSCommandPayload{ - Topic: c.pluginTopic, + Topic: topic, BatchNumber: int64(batchNumber), } if err == nil { @@ -536,14 +568,14 @@ func (c *Cardano) eventLoop() { ackOrNack.Message = err.Error() } b, _ := json.Marshal(&ackOrNack) - err = c.wsconn.Send(ctx, b) + err = wsconn.Send(ctx, b) } } if !isBatch { var receipt common.BlockchainReceiptNotification _ = json.Unmarshal(msgBytes, &receipt) - err := common.HandleReceipt(ctx, "", c, &receipt, c.callbacks) + err := common.HandleReceipt(ctx, namespace, c, &receipt, c.callbacks) if err != nil { l.Errorf("Failed to process receipt: %+v", msgTyped) } @@ -562,7 +594,7 @@ func (c *Cardano) eventLoop() { } } -func (c *Cardano) handleMessageBatch(ctx context.Context, batchID int64, messages []interface{}) error { +func (c *Cardano) handleMessageBatch(ctx context.Context, namespace string, batchID int64, messages []interface{}) error { events := make(common.EventsToDispatch) count := len(messages) for i, msgI := range messages { @@ -578,9 +610,7 @@ func (c *Cardano) handleMessageBatch(ctx context.Context, batchID int64, message logger := log.L(ctx) logger.Infof("[Cardano:%d:%d/%d]: '%s'", batchID, i+1, count, signature) logger.Tracef("Message: %+v", msgJSON) - if err := c.processContractEvent(ctx, events, msgJSON); err != nil { - return err - } + c.processContractEvent(ctx, namespace, events, msgJSON) } // Dispatch all the events from this patch that were successfully parsed and routed to namespaces @@ -588,13 +618,8 @@ func (c *Cardano) handleMessageBatch(ctx context.Context, batchID int64, message return c.callbacks.DispatchBlockchainEvents(ctx, events) } -func (c *Cardano) processContractEvent(ctx context.Context, events common.EventsToDispatch, msgJSON fftypes.JSONObject) error { +func (c *Cardano) processContractEvent(ctx context.Context, namespace string, events common.EventsToDispatch, msgJSON fftypes.JSONObject) { listenerID := msgJSON.GetString("listenerId") - listener, err := c.streams.getListener(ctx, c.streamID, listenerID, false) - if err != nil { - return err - } - namespace := common.GetNamespaceFromSubName(listener.Name) event := c.parseBlockchainEvent(ctx, msgJSON) if event != nil { c.callbacks.PrepareBlockchainEvent(ctx, events, namespace, &blockchain.EventForListener{ @@ -602,7 +627,6 @@ func (c *Cardano) processContractEvent(ctx context.Context, events common.Events ListenerID: listenerID, }) } - return nil } func (c *Cardano) parseBlockchainEvent(ctx context.Context, msgJSON fftypes.JSONObject) *blockchain.Event { diff --git a/internal/blockchain/cardano/cardano_test.go b/internal/blockchain/cardano/cardano_test.go index 0c25165cc3..9f76d901c3 100644 --- a/internal/blockchain/cardano/cardano_test.go +++ b/internal/blockchain/cardano/cardano_test.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Kaleido, Inc. +// Copyright © 2025 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // @@ -66,19 +66,27 @@ func resetConf(c *Cardano) { func newTestCardano() (*Cardano, func()) { ctx, cancel := context.WithCancel(context.Background()) + r := resty.New().SetBaseURL("http://localhost:12345") c := &Cardano{ ctx: ctx, cancelCtx: cancel, callbacks: common.NewBlockchainCallbacks(), - client: resty.New().SetBaseURL("http://localhost:12345"), + client: r, pluginTopic: "topic1", - wsconn: &wsmocks.WSClient{}, + streamIDs: make(map[string]string), + closed: make(map[string]chan struct{}), + wsconns: make(map[string]wsclient.WSClient), + streams: &streamManager{ + client: r, + }, } return c, func() { cancel() if c.closed != nil { // We've init'd, wait to close - <-c.closed + for _, cls := range c.closed { + <-cls + } } } } @@ -119,7 +127,7 @@ func TestInitMissingTopic(t *testing.T) { assert.Regexp(t, "FF10138.*topic", err) } -func TestInitWithCardanoConnect(t *testing.T) { +func TestInitAndStartWithCardanoConnect(t *testing.T) { c, cancel := newTestCardano() defer cancel() @@ -150,18 +158,21 @@ func TestInitWithCardanoConnect(t *testing.T) { assert.Equal(t, "cardano", c.Name()) assert.Equal(t, core.VerifierTypeCardanoAddress, c.VerifierType()) + err = c.StartNamespace(c.ctx, "ns1") + assert.NoError(t, err) + assert.Equal(t, 2, httpmock.GetTotalCallCount()) - assert.Equal(t, "es12345", c.streamID) + assert.Equal(t, "es12345", c.streamIDs["ns1"]) assert.NotNil(t, c.Capabilities()) startupMessage := <-toServer - assert.Equal(t, `{"type":"listen","topic":"topic1"}`, startupMessage) + assert.Equal(t, `{"type":"listen","topic":"topic1/ns1"}`, startupMessage) startupMessage = <-toServer assert.Equal(t, `{"type":"listenreplies"}`, startupMessage) fromServer <- `{"bad":"receipt"}` // will be ignored - no ack fromServer <- `[]` // empty batch, will be ignored, but acked reply := <-toServer - assert.Equal(t, `{"type":"ack","topic":"topic1"}`, reply) + assert.Equal(t, `{"type":"ack","topic":"topic1/ns1"}`, reply) fromServer <- `["different kind of bad batch"]` fromServer <- `[{}]` // bad batch @@ -171,7 +182,7 @@ func TestInitWithCardanoConnect(t *testing.T) { fromServer <- `42` } -func TestInitWSFail(t *testing.T) { +func TestStartNamespaceWSFail(t *testing.T) { c, cancel := newTestCardano() defer cancel() @@ -180,10 +191,13 @@ func TestInitWSFail(t *testing.T) { utCardanoconnectConf.Set(CardanoconnectConfigTopic, "topic1") err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.NoError(t, err) + + err = c.StartNamespace(c.ctx, "ns1") assert.Regexp(t, "FF00149", err) } -func TestStreamQueryError(t *testing.T) { +func TestStartNamespaceStreamQueryError(t *testing.T) { c, cancel := newTestCardano() defer cancel() @@ -201,10 +215,13 @@ func TestStreamQueryError(t *testing.T) { utCardanoconnectConf.Set(CardanoconnectConfigTopic, "topic1") err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.NoError(t, err) + + err = c.StartNamespace(c.ctx, "ns1") assert.Regexp(t, "FF10282.*pop", err) } -func TestStreamCreateError(t *testing.T) { +func TestStartNamespaceStreamCreateError(t *testing.T) { c, cancel := newTestCardano() defer cancel() @@ -224,10 +241,13 @@ func TestStreamCreateError(t *testing.T) { utCardanoconnectConf.Set(CardanoconnectConfigTopic, "topic1") err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.NoError(t, err) + + err = c.StartNamespace(c.ctx, "ns1") assert.Regexp(t, "FF10282.*pop", err) } -func TestStreamUpdateError(t *testing.T) { +func TestStartNamespaceStreamUpdateError(t *testing.T) { c, cancel := newTestCardano() defer cancel() @@ -236,7 +256,7 @@ func TestStreamUpdateError(t *testing.T) { defer httpmock.DeactivateAndReset() httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams", - httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", Name: "topic1"}})) + httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", Name: "topic1/ns1"}})) httpmock.RegisterResponder("PATCH", "http://localhost:12345/eventstreams/es12345", httpmock.NewStringResponder(500, "pop")) @@ -247,13 +267,76 @@ func TestStreamUpdateError(t *testing.T) { utCardanoconnectConf.Set(CardanoconnectConfigTopic, "topic1") err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.NoError(t, err) + + err = c.StartNamespace(c.ctx, "ns1") assert.Regexp(t, "FF10282.*pop", err) } -func TestStartNamespace(t *testing.T) { +func TestStartNamespaceWSConnectFail(t *testing.T) { c, cancel := newTestCardano() defer cancel() - err := c.StartNamespace(context.Background(), "ns1") + + mockedClient := &http.Client{} + httpmock.ActivateNonDefault(mockedClient) + defer httpmock.DeactivateAndReset() + + httpURL := "http://localhost:12345" + + httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams", httpURL), + httpmock.NewJsonResponderOrPanic(200, []eventStream{})) + httpmock.RegisterResponder("POST", fmt.Sprintf("%s/eventstreams", httpURL), + httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345"})) + httpmock.RegisterResponder("GET", fmt.Sprintf("%s/ws", httpURL), + httpmock.NewJsonResponderOrPanic(500, "{}")) + + resetConf(c) + utCardanoconnectConf.Set(ffresty.HTTPConfigURL, httpURL) + utCardanoconnectConf.Set(ffresty.HTTPCustomClient, mockedClient) + utCardanoconnectConf.Set(CardanoconnectConfigTopic, "topic1") + utCardanoconnectConf.Set(wsclient.WSConfigKeyInitialConnectAttempts, 1) + + err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.NoError(t, err) + + err = c.StartNamespace(c.ctx, "ns1") + assert.Regexp(t, "FF00148", err) +} + +func TestStartStopNamespace(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + toServer, _, wsURL, done := wsclient.NewTestWSServer(nil) + defer done() + + mockedClient := &http.Client{} + httpmock.ActivateNonDefault(mockedClient) + defer httpmock.DeactivateAndReset() + + u, _ := url.Parse(wsURL) + u.Scheme = "http" + httpURL := u.String() + + httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams", httpURL), + httpmock.NewJsonResponderOrPanic(200, []eventStream{})) + httpmock.RegisterResponder("POST", fmt.Sprintf("%s/eventstreams", httpURL), + httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345"})) + + resetConf(c) + utCardanoconnectConf.Set(ffresty.HTTPConfigURL, httpURL) + utCardanoconnectConf.Set(ffresty.HTTPCustomClient, mockedClient) + utCardanoconnectConf.Set(CardanoconnectConfigTopic, "topic1") + + err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) + assert.NoError(t, err) + + err = c.StartNamespace(c.ctx, "ns1") + assert.NoError(t, err) + + <-toServer + + err = c.StopNamespace(c.ctx, "ns1") assert.NoError(t, err) } @@ -287,11 +370,12 @@ func TestEventLoopContextCancelled(t *testing.T) { c, cancel := newTestCardano() cancel() r := make(<-chan []byte) - wsm := c.wsconn.(*wsmocks.WSClient) + wsm := &wsmocks.WSClient{} + c.wsconns["ns1"] = wsm wsm.On("Receive").Return(r) wsm.On("Close").Return() - c.closed = make(chan struct{}) - c.eventLoop() + c.closed["ns1"] = make(chan struct{}) + c.eventLoop("ns1") wsm.AssertExpectations(t) } @@ -300,12 +384,13 @@ func TestEventLoopReceiveClosed(t *testing.T) { defer cancel() r := make(chan []byte) - wsm := c.wsconn.(*wsmocks.WSClient) + wsm := &wsmocks.WSClient{} + c.wsconns["ns1"] = wsm close(r) wsm.On("Receive").Return((<-chan []byte)(r)) wsm.On("Close").Return() - c.closed = make(chan struct{}) - c.eventLoop() + c.closed["ns1"] = make(chan struct{}) + c.eventLoop("ns1") wsm.AssertExpectations(t) } @@ -314,15 +399,16 @@ func TestEventLoopSendFailed(t *testing.T) { defer cancel() r := make(chan []byte) - wsm := c.wsconn.(*wsmocks.WSClient) + wsm := &wsmocks.WSClient{} + c.wsconns["ns1"] = wsm wsm.On("Receive").Return((<-chan []byte)(r)) wsm.On("Close").Return() wsm.On("Send", mock.Anything, mock.Anything).Return(errors.New("Send error")) - c.closed = make(chan struct{}) + c.closed["ns1"] = make(chan struct{}) - go c.eventLoop() + go c.eventLoop("ns1") r <- []byte(`{"batchNumber":9001,"events":["none"]}`) - <-c.closed + <-c.closed["ns1"] } func TestEventLoopBadMessage(t *testing.T) { @@ -330,12 +416,13 @@ func TestEventLoopBadMessage(t *testing.T) { defer cancel() r := make(chan []byte) - wsm := c.wsconn.(*wsmocks.WSClient) + wsm := &wsmocks.WSClient{} + c.wsconns["ns1"] = wsm wsm.On("Receive").Return((<-chan []byte)(r)) wsm.On("Close").Return() - c.closed = make(chan struct{}) + c.closed["ns1"] = make(chan struct{}) - go c.eventLoop() + go c.eventLoop("ns1") r <- []byte(`!badjson`) // ignored bad json r <- []byte(`"not an object"`) // ignored wrong type } @@ -353,15 +440,16 @@ func TestEventLoopReceiveBatch(t *testing.T) { r := make(chan []byte) s := make(chan []byte) - wsm := c.wsconn.(*wsmocks.WSClient) + wsm := &wsmocks.WSClient{} + c.wsconns["ns1"] = wsm wsm.On("Receive").Return((<-chan []byte)(r)) wsm.On("Send", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { bytes, _ := args.Get(1).([]byte) s <- bytes }).Return(nil) wsm.On("Close").Return() - c.streamID = "es12345" - c.closed = make(chan struct{}) + c.streamIDs["ns1"] = "es12345" + c.closed["ns1"] = make(chan struct{}) client := resty.NewWithClient(mockedClient) client.SetBaseURL("http://localhost:12345") @@ -391,21 +479,21 @@ func TestEventLoopReceiveBatch(t *testing.T) { }`) em := &blockchainmocks.Callbacks{} - c.SetHandler("default", em) + c.SetHandler("ns1", em) em.On("BlockchainEventBatch", mock.MatchedBy(func(events []*blockchain.EventToDispatch) bool { return len(events) == 1 && events[0].Type == blockchain.EventTypeForListener && events[0].ForListener.ListenerID == "lst12345" })).Return(nil) - go c.eventLoop() + go c.eventLoop("ns1") r <- data response := <-s var parsed cardanoWSCommandPayload err := json.Unmarshal(response, &parsed) assert.NoError(t, err) - assert.Equal(t, c.pluginTopic, parsed.Topic) + assert.Equal(t, "topic1/ns1", parsed.Topic) assert.Equal(t, int64(1337), parsed.BatchNumber) assert.Equal(t, "ack", parsed.Type) } @@ -430,15 +518,16 @@ func TestEventLoopReceiveBadBatch(t *testing.T) { r := make(chan []byte) s := make(chan []byte) - wsm := c.wsconn.(*wsmocks.WSClient) + wsm := &wsmocks.WSClient{} + c.wsconns["ns1"] = wsm wsm.On("Receive").Return((<-chan []byte)(r)) wsm.On("Send", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { bytes, _ := args.Get(1).([]byte) s <- bytes }).Return(nil) wsm.On("Close").Return() - c.streamID = "es12345" - c.closed = make(chan struct{}) + c.streamIDs["ns1"] = "es12345" + c.closed["ns1"] = make(chan struct{}) data := []byte(`{ "batchNumber": 1337, @@ -460,21 +549,21 @@ func TestEventLoopReceiveBadBatch(t *testing.T) { }`) em := &blockchainmocks.Callbacks{} - c.SetHandler("default", em) + c.SetHandler("ns1", em) em.On("BlockchainEventBatch", mock.MatchedBy(func(events []*blockchain.EventToDispatch) bool { return len(events) == 1 && events[0].Type == blockchain.EventTypeForListener && events[0].ForListener.ListenerID == "lst12345" })).Return(errors.New("My Error Message")) - go c.eventLoop() + go c.eventLoop("ns1") r <- data response := <-s var parsed cardanoWSCommandPayload err := json.Unmarshal(response, &parsed) assert.NoError(t, err) - assert.Equal(t, c.pluginTopic, parsed.Topic) + assert.Equal(t, "topic1/ns1", parsed.Topic) assert.Equal(t, int64(1337), parsed.BatchNumber) assert.Equal(t, "error", parsed.Type) assert.Equal(t, "My Error Message", parsed.Message) @@ -484,48 +573,20 @@ func TestEventLoopReceiveMalformedBatch(t *testing.T) { c, cancel := newTestCardano() defer cancel() - mockedClient := &http.Client{} - httpmock.ActivateNonDefault(mockedClient) - defer httpmock.DeactivateAndReset() - - httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams/es12345/listeners/lst12345", - httpmock.NewJsonResponderOrPanic(200, listener{ID: "lst12345", Name: "ff-sub-default-12345"})) - httpmock.RegisterResponder("GET", "http://localhost:12345/eventstreams/es12345/listeners/", - httpmock.NewStringResponder(404, "Not Found")) - client := resty.NewWithClient(mockedClient) - client.SetBaseURL("http://localhost:12345") - c.streams = &streamManager{ - client: client, - batchSize: 500, - batchTimeout: 10000, - } - r := make(chan []byte) s := make(chan []byte) - wsm := c.wsconn.(*wsmocks.WSClient) + wsm := &wsmocks.WSClient{} + c.wsconns["ns1"] = wsm wsm.On("Receive").Return((<-chan []byte)(r)) wsm.On("Send", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { bytes, _ := args.Get(1).([]byte) s <- bytes }).Return(nil) wsm.On("Close").Return() - c.streamID = "es12345" - c.closed = make(chan struct{}) + c.streamIDs["ns1"] = "es12345" + c.closed["ns1"] = make(chan struct{}) - go c.eventLoop() - - r <- []byte(`{ - "batchNumber": 1337, - "events": [{}] - }`) - response := <-s - var parsed cardanoWSCommandPayload - err := json.Unmarshal(response, &parsed) - assert.NoError(t, err) - assert.Equal(t, c.pluginTopic, parsed.Topic) - assert.Equal(t, int64(1337), parsed.BatchNumber) - assert.Equal(t, "error", parsed.Type) - assert.Regexp(t, "FF10282.*Not Found", parsed.Message) + go c.eventLoop("ns1") r <- []byte(`{ "batchNumber": 1338, @@ -547,13 +608,59 @@ func TestEventLoopReceiveMalformedBatch(t *testing.T) { } ] }`) - response = <-s - err = json.Unmarshal(response, &parsed) + response := <-s + var parsed cardanoWSCommandPayload + err := json.Unmarshal(response, &parsed) assert.NoError(t, err) - assert.Equal(t, c.pluginTopic, parsed.Topic) + assert.Equal(t, "topic1/ns1", parsed.Topic) assert.Equal(t, int64(1338), parsed.BatchNumber) assert.Equal(t, "ack", parsed.Type) } + +func TestEventLoopReceiveReceipt(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + r := make(chan []byte) + s := make(chan []byte) + wsm := &wsmocks.WSClient{} + c.wsconns["ns1"] = wsm + wsm.On("Receive").Return((<-chan []byte)(r)) + wsm.On("Send", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + bytes, _ := args.Get(1).([]byte) + s <- bytes + }).Return(nil) + wsm.On("Close").Return() + c.streamIDs["ns1"] = "es12345" + c.closed["ns1"] = make(chan struct{}) + + tm := &coremocks.OperationCallbacks{} + c.SetOperationHandler("ns1", tm) + tm.On("OperationUpdate", mock.MatchedBy(func(update *core.OperationUpdate) bool { + return update.NamespacedOpID == "ns1:5678" && + update.Status == core.OpStatusSucceeded && + update.BlockchainTXID == "txHash" && + update.Plugin == "cardano" + })).Return(nil) + + go c.eventLoop("ns1") + + // start by sending an invalid receipt, which it should ignore + r <- []byte(`{ + "headers": { + "requestId": "ns1:1234" + } + }`) + // then sand a valid receipt, which it should acknowledge + r <- []byte(`{ + "headers": { + "requestId": "ns1:5678", + "replyType": "TransactionSuccess" + }, + "transactionHash": "txHash" + }`) +} + func TestSubmitBatchPinNotSupported(t *testing.T) { c, cancel := newTestCardano() defer cancel() @@ -567,12 +674,11 @@ func TestAddContractListener(t *testing.T) { defer cancel() httpmock.ActivateNonDefault(c.client.GetClient()) defer httpmock.DeactivateAndReset() - c.streamID = "es-1" - c.streams = &streamManager{ - client: c.client, - } + c.streamIDs["ns1"] = "es-1" sub := &core.ContractListener{ + Name: "sample", + Namespace: "ns1", Filters: []*core.ListenerFilter{ { Event: &core.FFISerializedEvent{ @@ -598,17 +704,36 @@ func TestAddContractListener(t *testing.T) { assert.Equal(t, "new-id", sub.BackendID) } -func TestAddContractListenerBadLocation(t *testing.T) { +func TestAddContractListenerNoFilter(t *testing.T) { c, cancel := newTestCardano() defer cancel() httpmock.ActivateNonDefault(c.client.GetClient()) defer httpmock.DeactivateAndReset() - c.streamID = "es-1" - c.streams = &streamManager{ - client: c.client, + c.streamIDs["ns1"] = "es-1" + + sub := &core.ContractListener{ + Name: "sample", + Namespace: "ns1", + Filters: []*core.ListenerFilter{}, + Options: &core.ContractListenerOptions{ + FirstEvent: string(core.SubOptsFirstEventOldest), + }, } + err := c.AddContractListener(context.Background(), sub, "") + assert.Regexp(t, "FF10475", err) +} + +func TestAddContractListenerBadLocation(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + c.streamIDs["ns1"] = "es-1" + sub := &core.ContractListener{ + Name: "Sample", + Namespace: "ns1", Filters: []*core.ListenerFilter{ { Event: &core.FFISerializedEvent{ @@ -637,12 +762,10 @@ func TestDeleteContractListener(t *testing.T) { httpmock.ActivateNonDefault(c.client.GetClient()) defer httpmock.DeactivateAndReset() - c.streamID = "es-1" - c.streams = &streamManager{ - client: c.client, - } + c.streamIDs["ns1"] = "es-1" sub := &core.ContractListener{ + Namespace: "ns1", BackendID: "sb-1", } @@ -659,12 +782,10 @@ func TestDeleteContractListenerFail(t *testing.T) { httpmock.ActivateNonDefault(c.client.GetClient()) defer httpmock.DeactivateAndReset() - c.streamID = "es-1" - c.streams = &streamManager{ - client: c.client, - } + c.streamIDs["ns1"] = "es-1" sub := &core.ContractListener{ + Namespace: "ns1", BackendID: "sb-1", } @@ -681,10 +802,7 @@ func TestGetContractListenerStatus(t *testing.T) { httpmock.ActivateNonDefault(c.client.GetClient()) defer httpmock.DeactivateAndReset() - c.streamID = "es-1" - c.streams = &streamManager{ - client: c.client, - } + c.streamIDs["ns1"] = "es-1" httpmock.RegisterResponder("GET", `http://localhost:12345/eventstreams/es-1/listeners/sb-1`, httpmock.NewJsonResponderOrPanic(200, &listener{ID: "sb-1", Name: "something"})) @@ -701,10 +819,7 @@ func TestGetContractListenerStatusNotFound(t *testing.T) { httpmock.ActivateNonDefault(c.client.GetClient()) defer httpmock.DeactivateAndReset() - c.streamID = "es-1" - c.streams = &streamManager{ - client: c.client, - } + c.streamIDs["ns1"] = "es-1" httpmock.RegisterResponder("GET", `http://localhost:12345/eventstreams/es-1/listeners/sb-1`, httpmock.NewStringResponder(404, "no")) @@ -721,10 +836,7 @@ func TestGetContractListenerErrorNotFound(t *testing.T) { httpmock.ActivateNonDefault(c.client.GetClient()) defer httpmock.DeactivateAndReset() - c.streamID = "es-1" - c.streams = &streamManager{ - client: c.client, - } + c.streamIDs["ns1"] = "es-1" httpmock.RegisterResponder("GET", `http://localhost:12345/eventstreams/es-1/listeners/sb-1`, httpmock.NewStringResponder(404, "no")) @@ -943,12 +1055,7 @@ func TestAddFireflySubscriptionBadLocation(t *testing.T) { httpmock.NewJsonResponderOrPanic(200, &[]listener{})) client := resty.NewWithClient(mockedClient) client.SetBaseURL("http://localhost:12345") - c.streamID = "es12345" - c.streams = &streamManager{ - client: client, - batchSize: 500, - batchTimeout: 10000, - } + c.streamIDs["ns1"] = "es12345" location := fftypes.JSONAnyPtr(fftypes.JSONObject{ "bad": "bad", @@ -979,9 +1086,9 @@ func TestAddAndRemoveFireflySubscription(t *testing.T) { httpURL := u.String() httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams", httpURL), - httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", Name: c.pluginTopic}})) + httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", Name: "topic1/ns1"}})) httpmock.RegisterResponder("PATCH", fmt.Sprintf("%s/eventstreams/es12345", httpURL), - httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345", Name: c.pluginTopic})) + httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345", Name: "topic1/ns1"})) httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams/es12345/listeners", httpURL), httpmock.NewJsonResponderOrPanic(200, []listener{})) httpmock.RegisterResponder("POST", fmt.Sprintf("%s/eventstreams/es12345/listeners", httpURL), @@ -994,8 +1101,10 @@ func TestAddAndRemoveFireflySubscription(t *testing.T) { err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) assert.NoError(t, err) + err = c.StartNamespace(c.ctx, "ns1") + assert.NoError(t, err) startupMessage := <-toServer - assert.Equal(t, `{"type":"listen","topic":"topic1"}`, startupMessage) + assert.Equal(t, `{"type":"listen","topic":"topic1/ns1"}`, startupMessage) startupMessage = <-toServer assert.Equal(t, `{"type":"listenreplies"}`, startupMessage) @@ -1032,9 +1141,9 @@ func TestAddFireflySubscriptionListError(t *testing.T) { httpURL := u.String() httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams", httpURL), - httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", Name: c.pluginTopic}})) + httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", Name: "topic1/ns1"}})) httpmock.RegisterResponder("PATCH", fmt.Sprintf("%s/eventstreams/es12345", httpURL), - httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345", Name: c.pluginTopic})) + httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345", Name: "topic1/ns1"})) httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams/es12345/listeners", httpURL), httpmock.NewStringResponder(500, "whoopsies")) @@ -1045,8 +1154,10 @@ func TestAddFireflySubscriptionListError(t *testing.T) { err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) assert.NoError(t, err) + err = c.StartNamespace(c.ctx, "ns1") + assert.NoError(t, err) startupMessage := <-toServer - assert.Equal(t, `{"type":"listen","topic":"topic1"}`, startupMessage) + assert.Equal(t, `{"type":"listen","topic":"topic1/ns1"}`, startupMessage) startupMessage = <-toServer assert.Equal(t, `{"type":"listenreplies"}`, startupMessage) @@ -1079,9 +1190,9 @@ func TestAddFireflySubscriptionAlreadyExists(t *testing.T) { httpURL := u.String() httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams", httpURL), - httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", Name: c.pluginTopic}})) + httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", Name: "topic1/ns1"}})) httpmock.RegisterResponder("PATCH", fmt.Sprintf("%s/eventstreams/es12345", httpURL), - httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345", Name: c.pluginTopic})) + httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345", Name: "topic1/ns1"})) httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams/es12345/listeners", httpURL), httpmock.NewJsonResponderOrPanic(200, []listener{{ID: "lst12345", Name: "ns1_2_BatchPin"}})) @@ -1092,8 +1203,9 @@ func TestAddFireflySubscriptionAlreadyExists(t *testing.T) { err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) assert.NoError(t, err) + err = c.StartNamespace(c.ctx, "ns1") startupMessage := <-toServer - assert.Equal(t, `{"type":"listen","topic":"topic1"}`, startupMessage) + assert.Equal(t, `{"type":"listen","topic":"topic1/ns1"}`, startupMessage) startupMessage = <-toServer assert.Equal(t, `{"type":"listenreplies"}`, startupMessage) @@ -1127,9 +1239,9 @@ func TestAddFireflySubscriptionCreateError(t *testing.T) { httpURL := u.String() httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams", httpURL), - httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", Name: c.pluginTopic}})) + httpmock.NewJsonResponderOrPanic(200, []eventStream{{ID: "es12345", Name: "topic1/ns1"}})) httpmock.RegisterResponder("PATCH", fmt.Sprintf("%s/eventstreams/es12345", httpURL), - httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345", Name: c.pluginTopic})) + httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345", Name: "topic1/ns1"})) httpmock.RegisterResponder("GET", fmt.Sprintf("%s/eventstreams/es12345/listeners", httpURL), httpmock.NewJsonResponderOrPanic(200, []listener{})) httpmock.RegisterResponder("POST", fmt.Sprintf("%s/eventstreams/es12345/listeners", httpURL), @@ -1142,8 +1254,10 @@ func TestAddFireflySubscriptionCreateError(t *testing.T) { err := c.Init(c.ctx, c.cancelCtx, utConfig, c.metrics, &cachemocks.Manager{}) assert.NoError(t, err) + err = c.StartNamespace(c.ctx, "ns1") + assert.NoError(t, err) startupMessage := <-toServer - assert.Equal(t, `{"type":"listen","topic":"topic1"}`, startupMessage) + assert.Equal(t, `{"type":"listen","topic":"topic1/ns1"}`, startupMessage) startupMessage = <-toServer assert.Equal(t, `{"type":"listenreplies"}`, startupMessage) From 4efbdd8deb725857a181139fb083d357e0ade952 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Tue, 11 Feb 2025 17:57:10 -0500 Subject: [PATCH 20/43] feat: copy event signature logic to error signatures Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 7 +++++-- internal/blockchain/cardano/cardano_test.go | 14 +++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index cd79a2f76f..a80acd42cc 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -432,8 +432,11 @@ func (c *Cardano) GenerateEventSignatureWithLocation(ctx context.Context, event } func (c *Cardano) GenerateErrorSignature(ctx context.Context, event *fftypes.FFIErrorDefinition) string { - // TODO: impl - return "" + params := []string{} + for _, param := range event.Params { + params = append(params, param.Schema.JSONObject().GetString("type")) + } + return fmt.Sprintf("%s(%s)", event.Name, strings.Join(params, ",")) } func (c *Cardano) GenerateFFI(ctx context.Context, generationRequest *fftypes.FFIGenerationRequest) (*fftypes.FFI, error) { diff --git a/internal/blockchain/cardano/cardano_test.go b/internal/blockchain/cardano/cardano_test.go index 9f76d901c3..16d61036c6 100644 --- a/internal/blockchain/cardano/cardano_test.go +++ b/internal/blockchain/cardano/cardano_test.go @@ -1586,9 +1586,17 @@ func TestGenerateEventSignatureWithInvalidLocation(t *testing.T) { func TestGenerateErrorSignature(t *testing.T) { c, cancel := newTestCardano() defer cancel() - - signature := c.GenerateErrorSignature(context.Background(), nil) - assert.Equal(t, "", signature) + event := &fftypes.FFIErrorDefinition{ + Name: "TransactionFailed", + Params: fftypes.FFIParams{ + &fftypes.FFIParam{ + Name: "transactionId", + Schema: fftypes.JSONAnyPtr(`{"type": "string"}`), + }, + }, + } + signature := c.GenerateErrorSignature(context.Background(), event) + assert.Equal(t, "TransactionFailed(string)", signature) } func TestCheckOverlappingLocationsEmpty(t *testing.T) { From 581623f5090fbe1f6a3ff4ca6dca89a02f1a7acd Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Tue, 11 Feb 2025 18:05:16 -0500 Subject: [PATCH 21/43] fix: use proper i18n errors Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 10 ++++++---- internal/blockchain/cardano/cardano_test.go | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index a80acd42cc..f740f90817 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -19,7 +19,6 @@ package cardano import ( "context" "encoding/json" - "errors" "fmt" "regexp" "strings" @@ -233,11 +232,13 @@ func (c *Cardano) ResolveSigningKey(ctx context.Context, key string, intent bloc } func (c *Cardano) SubmitBatchPin(ctx context.Context, nsOpID, networkNamespace, signingKey string, batch *blockchain.BatchPin, location *fftypes.JSONAny) error { - return errors.New("SubmitBatchPin not supported") + log.L(ctx).Warn("SubmitBatchPin is not supported") + return i18n.NewError(ctx, coremsgs.MsgNotSupportedByBlockchainPlugin) } func (c *Cardano) SubmitNetworkAction(ctx context.Context, nsOpID string, signingKey string, action core.NetworkActionType, location *fftypes.JSONAny) error { - return errors.New("SubmitNetworkAction not supported") + log.L(ctx).Warn("SubmitNetworkAction is not supported") + return i18n.NewError(ctx, coremsgs.MsgNotSupportedByBlockchainPlugin) } func (c *Cardano) DeployContract(ctx context.Context, nsOpID, signingKey string, definition, contract *fftypes.JSONAny, input []interface{}, options map[string]interface{}) (submissionRejected bool, err error) { @@ -303,7 +304,8 @@ func (c *Cardano) InvokeContract(ctx context.Context, nsOpID string, signingKey } func (c *Cardano) QueryContract(ctx context.Context, signingKey string, location *fftypes.JSONAny, parsedMethod interface{}, input map[string]interface{}, options map[string]interface{}) (interface{}, error) { - return nil, errors.New("QueryContract not supported") + log.L(ctx).Warn("QueryContract is not supported") + return nil, i18n.NewError(ctx, coremsgs.MsgNotSupportedByBlockchainPlugin) } func (c *Cardano) ParseInterface(ctx context.Context, method *fftypes.FFIMethod, errors []*fftypes.FFIError) (interface{}, error) { diff --git a/internal/blockchain/cardano/cardano_test.go b/internal/blockchain/cardano/cardano_test.go index 16d61036c6..dfb9ab4a65 100644 --- a/internal/blockchain/cardano/cardano_test.go +++ b/internal/blockchain/cardano/cardano_test.go @@ -666,7 +666,7 @@ func TestSubmitBatchPinNotSupported(t *testing.T) { defer cancel() err := c.SubmitBatchPin(c.ctx, "", "", "", nil, nil) - assert.Regexp(t, "SubmitBatchPin not supported", err) + assert.Regexp(t, "FF10429", err) } func TestAddContractListener(t *testing.T) { @@ -1040,7 +1040,7 @@ func TestSubmitNetworkActionNotSupported(t *testing.T) { defer cancel() err := c.SubmitNetworkAction(c.ctx, "", "", core.NetworkActionTerminate, nil) - assert.Regexp(t, "SubmitNetworkAction not supported", err) + assert.Regexp(t, "FF10429", err) } func TestAddFireflySubscriptionBadLocation(t *testing.T) { @@ -1409,7 +1409,7 @@ func TestQueryContractNotSupported(t *testing.T) { defer cancel() _, err := c.QueryContract(context.Background(), "", nil, nil, nil, nil) - assert.Regexp(t, "QueryContract not supported", err) + assert.Regexp(t, "FF10429", err) } func TestDeployContractOK(t *testing.T) { From d219791d9773f3b9fcfe982e4980151e6ef872e3 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Thu, 20 Feb 2025 12:18:04 -0500 Subject: [PATCH 22/43] fix: remove unneeded dockerfile change Signed-off-by: Simon Gellis --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e567cf79dc..53ab003210 100644 --- a/Dockerfile +++ b/Dockerfile @@ -64,7 +64,7 @@ RUN mkdir -p build/contracts \ FROM alpine:3.19 AS sbom WORKDIR / ADD . /SBOM -RUN apk add --no-cache curl=8.11.1-r1 +RUN apk add --no-cache curl RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.48.3 RUN trivy fs --format spdx-json --output /sbom.spdx.json /SBOM RUN trivy sbom /sbom.spdx.json --severity UNKNOWN,HIGH,CRITICAL --db-repository public.ecr.aws/aquasecurity/trivy-db --exit-code 1 From 5bf71938a4d1c7f5c37f9c0224eb5c085af691ae Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Tue, 25 Feb 2025 20:18:35 -0500 Subject: [PATCH 23/43] fix: correct OperationUpdate mock Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/blockchain/cardano/cardano_test.go b/internal/blockchain/cardano/cardano_test.go index dfb9ab4a65..4c3cdd9987 100644 --- a/internal/blockchain/cardano/cardano_test.go +++ b/internal/blockchain/cardano/cardano_test.go @@ -636,7 +636,7 @@ func TestEventLoopReceiveReceipt(t *testing.T) { tm := &coremocks.OperationCallbacks{} c.SetOperationHandler("ns1", tm) - tm.On("OperationUpdate", mock.MatchedBy(func(update *core.OperationUpdate) bool { + tm.On("OperationUpdate", mock.MatchedBy(func(update *core.OperationUpdateAsync) bool { return update.NamespacedOpID == "ns1:5678" && update.Status == core.OpStatusSucceeded && update.BlockchainTXID == "txHash" && @@ -869,7 +869,7 @@ func TestGetTransactionStatusSuccess(t *testing.T) { tm := &coremocks.OperationCallbacks{} c.SetOperationHandler("ns1", tm) - tm.On("OperationUpdate", mock.MatchedBy(func(update *core.OperationUpdate) bool { + tm.On("OperationUpdate", mock.MatchedBy(func(update *core.OperationUpdateAsync) bool { return update.NamespacedOpID == "ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059" && update.Status == core.OpStatusSucceeded && update.BlockchainTXID == "txHash" && @@ -906,7 +906,7 @@ func TestGetTransactionStatusFailure(t *testing.T) { tm := &coremocks.OperationCallbacks{} c.SetOperationHandler("ns1", tm) - tm.On("OperationUpdate", mock.MatchedBy(func(update *core.OperationUpdate) bool { + tm.On("OperationUpdate", mock.MatchedBy(func(update *core.OperationUpdateAsync) bool { return update.NamespacedOpID == "ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059" && update.Status == core.OpStatusFailed && update.ErrorMessage == "Something went wrong" && From 96531e3800201f7ce89fbeef749ff580e8182fa8 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Tue, 11 Mar 2025 10:34:11 -0400 Subject: [PATCH 24/43] feat: remove fake websocket code from GetTransactionStatus Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 40 --------------------- internal/blockchain/cardano/cardano_test.go | 20 ----------- internal/blockchain/common/common.go | 6 ++++ internal/blockchain/ethereum/ethereum.go | 11 ++---- internal/blockchain/tezos/tezos.go | 11 ++---- 5 files changed, 12 insertions(+), 76 deletions(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index f740f90817..4773ed03b9 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -38,15 +38,6 @@ import ( "github.com/hyperledger/firefly/pkg/core" ) -const ( - cardanoTxStatusPending string = "Pending" -) - -const ( - ReceiptTransactionSuccess string = "TransactionSuccess" - ReceiptTransactionFailed string = "TransactionFailed" -) - type Cardano struct { ctx context.Context cancelCtx context.CancelFunc @@ -474,37 +465,6 @@ func (c *Cardano) GetTransactionStatus(ctx context.Context, operation *core.Oper return nil, common.WrapRESTError(ctx, &resErr, res, err, coremsgs.MsgCardanoconnectRESTErr) } - receiptInfo := statusResponse.GetObject("receipt") - txStatus := statusResponse.GetString("status") - - if txStatus != "" { - var replyType string - if txStatus == "Succeeded" { - replyType = ReceiptTransactionSuccess - } else { - replyType = ReceiptTransactionFailed - } - // If the status has changed, mock up blockchain receipt as if we'd received it - // as a web socket notification - if (operation.Status == core.OpStatusPending || operation.Status == core.OpStatusInitialized) && txStatus != cardanoTxStatusPending { - receipt := &common.BlockchainReceiptNotification{ - Headers: common.BlockchainReceiptHeaders{ - ReceiptID: statusResponse.GetString("id"), - ReplyType: replyType, - }, - TxHash: statusResponse.GetString("transactionHash"), - Message: statusResponse.GetString("errorMessage"), - ProtocolID: receiptInfo.GetString("protocolId")} - err := common.HandleReceipt(ctx, operation.Namespace, c, receipt, c.callbacks) - if err != nil { - log.L(ctx).Warnf("Failed to handle receipt") - } - } - } else { - // Don't expect to get here so issue a warning - log.L(ctx).Warnf("Transaction status didn't include txStatus information") - } - return statusResponse, nil } diff --git a/internal/blockchain/cardano/cardano_test.go b/internal/blockchain/cardano/cardano_test.go index 4c3cdd9987..2b6142d028 100644 --- a/internal/blockchain/cardano/cardano_test.go +++ b/internal/blockchain/cardano/cardano_test.go @@ -867,19 +867,9 @@ func TestGetTransactionStatusSuccess(t *testing.T) { return httpmock.NewJsonResponderOrPanic(200, transactionStatus)(req) }) - tm := &coremocks.OperationCallbacks{} - c.SetOperationHandler("ns1", tm) - tm.On("OperationUpdate", mock.MatchedBy(func(update *core.OperationUpdateAsync) bool { - return update.NamespacedOpID == "ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059" && - update.Status == core.OpStatusSucceeded && - update.BlockchainTXID == "txHash" && - update.Plugin == "cardano" - })).Return(errors.New("won't stop processing")) - status, err := c.GetTransactionStatus(context.Background(), op) assert.NoError(t, err) assert.NotNil(t, status) - tm.AssertExpectations(t) } func TestGetTransactionStatusFailure(t *testing.T) { @@ -904,19 +894,9 @@ func TestGetTransactionStatusFailure(t *testing.T) { return httpmock.NewJsonResponderOrPanic(200, transactionStatus)(req) }) - tm := &coremocks.OperationCallbacks{} - c.SetOperationHandler("ns1", tm) - tm.On("OperationUpdate", mock.MatchedBy(func(update *core.OperationUpdateAsync) bool { - return update.NamespacedOpID == "ns1:9ffc50ff-6bfe-4502-adc7-93aea54cc059" && - update.Status == core.OpStatusFailed && - update.ErrorMessage == "Something went wrong" && - update.Plugin == "cardano" - })).Return(errors.New("won't stop processing")) - status, err := c.GetTransactionStatus(context.Background(), op) assert.NoError(t, err) assert.NotNil(t, status) - tm.AssertExpectations(t) } func TestGetTransactionStatusEmptyObject(t *testing.T) { diff --git a/internal/blockchain/common/common.go b/internal/blockchain/common/common.go index 0393b45b74..0eef61c5e2 100644 --- a/internal/blockchain/common/common.go +++ b/internal/blockchain/common/common.go @@ -116,6 +116,12 @@ type BlockchainReceiptNotification struct { ContractLocation *fftypes.JSONAny `json:"contractLocation,omitempty"` } +// possible values of BlockchainReceiptHeaders.ReplyType +const ( + ReceiptTransactionSuccess string = "TransactionSuccess" + ReceiptTransactionFailed string = "TransactionFailed" +) + type BlockchainRESTError struct { Error string `json:"error,omitempty"` // See https://github.com/hyperledger/firefly-transaction-manager/blob/main/pkg/ffcapi/submission_error.go diff --git a/internal/blockchain/ethereum/ethereum.go b/internal/blockchain/ethereum/ethereum.go index 60207fd60e..14262179ce 100644 --- a/internal/blockchain/ethereum/ethereum.go +++ b/internal/blockchain/ethereum/ethereum.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Kaleido, Inc. +// Copyright © 2025 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // @@ -52,11 +52,6 @@ const ( ethTxStatusPending string = "Pending" ) -const ( - ReceiptTransactionSuccess string = "TransactionSuccess" - ReceiptTransactionFailed string = "TransactionFailed" -) - type Ethereum struct { ctx context.Context cancelCtx context.CancelFunc @@ -1209,9 +1204,9 @@ func (e *Ethereum) GetTransactionStatus(ctx context.Context, operation *core.Ope if txStatus != "" { var replyType string if txStatus == "Succeeded" { - replyType = ReceiptTransactionSuccess + replyType = common.ReceiptTransactionSuccess } else { - replyType = ReceiptTransactionFailed + replyType = common.ReceiptTransactionFailed } // If the status has changed, mock up blockchain receipt as if we'd received it // as a web socket notification diff --git a/internal/blockchain/tezos/tezos.go b/internal/blockchain/tezos/tezos.go index d37eb0fe40..c56e6df9a4 100644 --- a/internal/blockchain/tezos/tezos.go +++ b/internal/blockchain/tezos/tezos.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Kaleido, Inc. +// Copyright © 2025 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // @@ -44,11 +44,6 @@ const ( tezosTxStatusPending string = "Pending" ) -const ( - ReceiptTransactionSuccess string = "TransactionSuccess" - ReceiptTransactionFailed string = "TransactionFailed" -) - type Tezos struct { ctx context.Context cancelCtx context.CancelFunc @@ -576,9 +571,9 @@ func (t *Tezos) GetTransactionStatus(ctx context.Context, operation *core.Operat if txStatus != "" { var replyType string if txStatus == "Succeeded" { - replyType = ReceiptTransactionSuccess + replyType = common.ReceiptTransactionSuccess } else { - replyType = ReceiptTransactionFailed + replyType = common.ReceiptTransactionFailed } // If the status has changed, mock up blockchain receipt as if we'd received it // as a web socket notification From 710b94e9df49f8158ebea4b1c7eea5b0c9521143 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Tue, 11 Mar 2025 17:51:51 -0400 Subject: [PATCH 25/43] feat: expect receipts to arrive in batches Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 38 +++++++++++-------- internal/blockchain/cardano/cardano_test.go | 41 +++++++++++++++------ 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index 4773ed03b9..10b5ac9e91 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -514,11 +514,9 @@ func (c *Cardano) eventLoop(namespace string) { err = wsconn.Send(ctx, ack) } case map[string]interface{}: - isBatch := false if batchNumber, ok := msgTyped["batchNumber"].(float64); ok { if events, ok := msgTyped["events"].([]interface{}); ok { // FFTM delivery with a batch number to use in the ack - isBatch = true err = c.handleMessageBatch(ctx, namespace, (int64)(batchNumber), events) // Errors processing messages are converted into nacks ackOrNack := &cardanoWSCommandPayload{ @@ -535,15 +533,8 @@ func (c *Cardano) eventLoop(namespace string) { b, _ := json.Marshal(&ackOrNack) err = wsconn.Send(ctx, b) } - } - if !isBatch { - var receipt common.BlockchainReceiptNotification - _ = json.Unmarshal(msgBytes, &receipt) - - err := common.HandleReceipt(ctx, namespace, c, &receipt, c.callbacks) - if err != nil { - l.Errorf("Failed to process receipt: %+v", msgTyped) - } + } else { + l.Errorf("Message unexpected: %+v", msgTyped) } default: l.Errorf("Message unexpected: %+v", msgTyped) @@ -570,12 +561,27 @@ func (c *Cardano) handleMessageBatch(ctx context.Context, namespace string, batc } msgJSON := fftypes.JSONObject(msgMap) - signature := msgJSON.GetString("signature") + switch msgJSON.GetString("type") { + case "ContractEvent": + signature := msgJSON.GetString("signature") + + logger := log.L(ctx) + logger.Infof("[Cardano:%d:%d/%d]: '%s'", batchID, i+1, count, signature) + logger.Tracef("Message: %+v", msgJSON) + c.processContractEvent(ctx, namespace, events, msgJSON) + case "Receipt": + var receipt common.BlockchainReceiptNotification + msgBytes, _ := json.Marshal(msgMap) + _ = json.Unmarshal(msgBytes, &receipt) + + err := common.HandleReceipt(ctx, namespace, c, &receipt, c.callbacks) + if err != nil { + log.L(ctx).Errorf("Failed to process receipt: %+v", msgMap) + } + default: + log.L(ctx).Errorf("Unexpected message in batch: %+v", msgMap) + } - logger := log.L(ctx) - logger.Infof("[Cardano:%d:%d/%d]: '%s'", batchID, i+1, count, signature) - logger.Tracef("Message: %+v", msgJSON) - c.processContractEvent(ctx, namespace, events, msgJSON) } // Dispatch all the events from this patch that were successfully parsed and routed to namespaces diff --git a/internal/blockchain/cardano/cardano_test.go b/internal/blockchain/cardano/cardano_test.go index 2b6142d028..25c8779512 100644 --- a/internal/blockchain/cardano/cardano_test.go +++ b/internal/blockchain/cardano/cardano_test.go @@ -463,6 +463,7 @@ func TestEventLoopReceiveBatch(t *testing.T) { "batchNumber": 1337, "events": [ { + "type": "ContractEvent", "listenerId": "lst12345", "blockHash": "fcb0504f47abf2cc52cd6d509036d512fd6cbec19d0e1bbaaf21f0699882de7b", "blockNumber": 11466734, @@ -533,6 +534,7 @@ func TestEventLoopReceiveBadBatch(t *testing.T) { "batchNumber": 1337, "events": [ { + "type": "ContractEvent", "listenerId": "lst12345", "blockHash": "fcb0504f47abf2cc52cd6d509036d512fd6cbec19d0e1bbaaf21f0699882de7b", "blockNumber": 11466734, @@ -592,16 +594,19 @@ func TestEventLoopReceiveMalformedBatch(t *testing.T) { "batchNumber": 1338, "events": [ { + "type": "ContractEvent", "listenerId": "lst12345", "blockNumber": 1337, "transactionHash": "cafed00d" }, { + "type": "ContractEvent", "listenerId": "lst12345", "blockNumber": 1337, "timestamp": "2025-02-10T12:00:00.000000000+00:00" }, { + "type": "ContractEvent", "listenerId": "lst12345", "timestamp": "2025-02-10T12:00:00.000000000+00:00", "transactionHash": "cafed00d" @@ -645,20 +650,32 @@ func TestEventLoopReceiveReceipt(t *testing.T) { go c.eventLoop("ns1") - // start by sending an invalid receipt, which it should ignore r <- []byte(`{ - "headers": { - "requestId": "ns1:1234" - } - }`) - // then sand a valid receipt, which it should acknowledge - r <- []byte(`{ - "headers": { - "requestId": "ns1:5678", - "replyType": "TransactionSuccess" - }, - "transactionHash": "txHash" + "batchNumber": 1339, + "events": [ + { + "type": "Receipt", + "headers": { + "requestId": "ns1:1234" + } + }, + { + "type": "Receipt", + "headers": { + "requestId": "ns1:5678", + "replyType": "TransactionSuccess" + }, + "transactionHash": "txHash" + } + ] }`) + response := <-s + var parsed cardanoWSCommandPayload + err := json.Unmarshal(response, &parsed) + assert.NoError(t, err) + assert.Equal(t, "topic1/ns1", parsed.Topic) + assert.Equal(t, int64(1339), parsed.BatchNumber) + assert.Equal(t, "ack", parsed.Type) } func TestSubmitBatchPinNotSupported(t *testing.T) { From 1649d7c24619e959ca62fcd619c1fc63b0366331 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Tue, 11 Mar 2025 17:57:32 -0400 Subject: [PATCH 26/43] fix: remove redundant Cardano in logs Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index 10b5ac9e91..6d637583c5 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -566,7 +566,7 @@ func (c *Cardano) handleMessageBatch(ctx context.Context, namespace string, batc signature := msgJSON.GetString("signature") logger := log.L(ctx) - logger.Infof("[Cardano:%d:%d/%d]: '%s'", batchID, i+1, count, signature) + logger.Infof("[%d:%d/%d]: '%s'", batchID, i+1, count, signature) logger.Tracef("Message: %+v", msgJSON) c.processContractEvent(ctx, namespace, events, msgJSON) case "Receipt": From 3b39818f601ebab0f9a892a8fc99ba0976ad51fc Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Tue, 11 Mar 2025 17:58:56 -0400 Subject: [PATCH 27/43] fix: correct copyright on new files Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 2 +- internal/blockchain/cardano/cardano_test.go | 2 +- internal/blockchain/cardano/config.go | 2 +- internal/blockchain/cardano/eventstream.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index 6d637583c5..c0ee72b0c2 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Kaleido, Inc. +// Copyright © 2025 IOG Singapore and SundaeSwap, Inc. // // SPDX-License-Identifier: Apache-2.0 // diff --git a/internal/blockchain/cardano/cardano_test.go b/internal/blockchain/cardano/cardano_test.go index 25c8779512..b54d0e9203 100644 --- a/internal/blockchain/cardano/cardano_test.go +++ b/internal/blockchain/cardano/cardano_test.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Kaleido, Inc. +// Copyright © 2025 IOG Singapore and SundaeSwap, Inc. // // SPDX-License-Identifier: Apache-2.0 // diff --git a/internal/blockchain/cardano/config.go b/internal/blockchain/cardano/config.go index 445a226cd3..797efc20ec 100644 --- a/internal/blockchain/cardano/config.go +++ b/internal/blockchain/cardano/config.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Kaleido, Inc. +// Copyright © 2025 IOG Singapore and SundaeSwap, Inc. // // SPDX-License-Identifier: Apache-2.0 // diff --git a/internal/blockchain/cardano/eventstream.go b/internal/blockchain/cardano/eventstream.go index b768fa2df0..0f9d950570 100644 --- a/internal/blockchain/cardano/eventstream.go +++ b/internal/blockchain/cardano/eventstream.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Kaleido, Inc. +// Copyright © 2025 IOG Singapore and SundaeSwap, Inc. // // SPDX-License-Identifier: Apache-2.0 // From 976a0b1f1183bdd83cef0b418aa7a490f5baa44f Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Wed, 12 Mar 2025 16:55:30 -0400 Subject: [PATCH 28/43] fix: fix overflow warning Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 2 +- internal/blockchain/cardano/eventstream.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index c0ee72b0c2..a31433bf5d 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -117,7 +117,7 @@ func (c *Cardano) Init(ctx context.Context, cancelCtx context.CancelFunc, conf c c.streamIDs = make(map[string]string) c.closed = make(map[string]chan struct{}) c.wsconns = make(map[string]wsclient.WSClient) - c.streams = newStreamManager(c.client, c.cardanoconnectConf.GetUint(CardanoconnectConfigBatchSize), uint(c.cardanoconnectConf.GetDuration(CardanoconnectConfigBatchTimeout).Milliseconds())) + c.streams = newStreamManager(c.client, c.cardanoconnectConf.GetUint(CardanoconnectConfigBatchSize), c.cardanoconnectConf.GetDuration(CardanoconnectConfigBatchTimeout).Milliseconds()) return nil } diff --git a/internal/blockchain/cardano/eventstream.go b/internal/blockchain/cardano/eventstream.go index 0f9d950570..5a27011eb0 100644 --- a/internal/blockchain/cardano/eventstream.go +++ b/internal/blockchain/cardano/eventstream.go @@ -29,7 +29,7 @@ import ( type streamManager struct { client *resty.Client batchSize uint - batchTimeout uint + batchTimeout int64 } type eventStream struct { @@ -37,7 +37,7 @@ type eventStream struct { Name string `json:"name"` ErrorHandling string `json:"errorHandling"` BatchSize uint `json:"batchSize"` - BatchTimeoutMS uint `json:"batchTimeoutMS"` + BatchTimeoutMS int64 `json:"batchTimeoutMS"` Type string `json:"type"` Timestamps bool `json:"timestamps"` } @@ -56,7 +56,7 @@ type eventfilter struct { EventPath string `json:"eventPath"` } -func newStreamManager(client *resty.Client, batchSize, batchTimeout uint) *streamManager { +func newStreamManager(client *resty.Client, batchSize uint, batchTimeout int64) *streamManager { return &streamManager{ client: client, batchSize: batchSize, @@ -75,7 +75,7 @@ func (s *streamManager) getEventStreams(ctx context.Context) (streams []*eventSt return streams, nil } -func buildEventStream(topic string, batchSize, batchTimeout uint) *eventStream { +func buildEventStream(topic string, batchSize uint, batchTimeout int64) *eventStream { return &eventStream{ Name: topic, ErrorHandling: "block", @@ -99,7 +99,7 @@ func (s *streamManager) createEventStream(ctx context.Context, topic string) (*e return stream, nil } -func (s *streamManager) updateEventStream(ctx context.Context, topic string, batchSize, batchTimeout uint, eventStreamID string) (*eventStream, error) { +func (s *streamManager) updateEventStream(ctx context.Context, topic string, batchSize uint, batchTimeout int64, eventStreamID string) (*eventStream, error) { stream := buildEventStream(topic, batchSize, batchTimeout) res, err := s.client.R(). SetContext(ctx). From 995e2bfb821a09f7a1dc0f14862b1304d80a25a1 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Wed, 12 Mar 2025 23:05:40 -0400 Subject: [PATCH 29/43] feat: update manifest images Signed-off-by: Simon Gellis --- manifest.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/manifest.json b/manifest.json index 0103a34ef2..7b1897a9e5 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,13 @@ { "cardanoconnect": { - "image": "hyperledger/firefly-cardanoconnect", - "tag": "main" + "image": "ghcr.io/hyperledger/firefly-cardanoconnect", + "tag": "v0.2.1", + "sha": "de880330e10faee733a30306add75139ccdf248329724f0b1292cf62eb65619f" }, "cardanosigner": { - "image": "hyperledger/firefly-cardanosigner", - "tag": "main" + "image": "ghcr.io/hyperledger/firefly-cardanosigner", + "tag": "v0.2.1", + "sha": "d277bb99de78ed0fafd8b6a83f78a70efaa493a2b2c8259a4c46508f6987b0d5" }, "ethconnect": { "image": "ghcr.io/hyperledger/firefly-ethconnect", From 0d3cc672059dcceeb694e83976730a526a2ce13b Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Wed, 12 Mar 2025 23:05:46 -0400 Subject: [PATCH 30/43] docs: add docs Signed-off-by: Simon Gellis --- doc-site/docs/tutorials/chains/cardano.md | 90 +++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 doc-site/docs/tutorials/chains/cardano.md diff --git a/doc-site/docs/tutorials/chains/cardano.md b/doc-site/docs/tutorials/chains/cardano.md new file mode 100644 index 0000000000..fe15d50f32 --- /dev/null +++ b/doc-site/docs/tutorials/chains/cardano.md @@ -0,0 +1,90 @@ +--- +title: Cardano +--- + +This guide will walk you through the steps to create a local FireFly development environment running against the preview node. + +## Previous steps: Install the FireFly CLI + +If you haven't set up the FireFly CLI already, please go back to the Getting Started guide and read the section on how to [Install the FireFly CLI](../../gettingstarted/firefly_cli.md). + +[← ① Install the FireFly CLI](../../gettingstarted/firefly_cli.md){: .md-button .md-button--primary} + +## Create the stack + +A Cardano stack can be run in two different ways; with a firefly + +### Option 1: Use a local Cardano node + +> **NOTE**: The cardano-node communicates over a Unix socket, so this will not work on Windows. + +Start a local cardano node. The fastest way to do this is to [use mithril](https://mithril.network/doc/manual/getting-started/bootstrap-cardano-node/) to bootstrap the node. + +For an example of how to bootstrap and run the cardano node in docker, see [the firefly-cardano repo](https://github.com/hyperledger/firefly-cardano/blob/1be3b08d301d6d6eeb5b79e40cf3dbf66181c3de/infra/docker-compose.node.yaml#L4). + +The cardano-node exposes a Unix socket named `node.socket`. Pass that to firefly-cli. The example below uses `firefly-cli` to + - Create a new Cardano-based stack named `dev` with 1 member. + - Disable `multiparty` mode. + - Connect to the local Cardano node, which is running in the [preview network](https://preview.cexplorer.io/). + +```sh +ff init cardano dev 1 \ + --multiparty false \ + --network preview \ + --socket /path/to/ipc/node.socket +``` + +### Option 2: Use Blockfrost + +The Cardano connector can also use the [paid Blockfrost API](https://blockfrost.io/) in place of a local Cardano node. + +The example below uses firefly-cli to + - Create a new Cardano-based stack named `dev` with 1 member. + - Disable `multiparty` mode. + - Use the given block + +```sh +ff init cardano dev 1 \ + --multiparty false \ + --network preview \ + --blockfrost-key previewXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +``` + +## Start the stack + +Now you should be able to start your stack by running: + +```sh +ff start dev +``` + +After some time it should print out the following: + +``` +Web UI for member '0': http://127.0.0.1:5000/ui +Sandbox UI for member '0': http://127.0.0.1:5109 + + +To see logs for your stack run: + +ff logs dev +``` + +## Get some ADA + +Now that you have a stack, you need some seed funds to get started. Your stack was created with a wallet already (these are free to create in Cardano). To get the address, you can run +```sh +ff accounts list dev +``` + +The response will look like +```json +[ + { + "address": "addr_test1...", + "privateKey": "..." + } +] +``` + +If you're developing against a testnet such as preview, you can receive funds from the [testnet faucet](https://docs.cardano.org/cardano-testnets/tools/faucet). Pass the `address` from that response to the faucet. From a6d26e7bc2888bb59baf8540279fda61e3bcd468 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Thu, 13 Mar 2025 16:36:27 -0400 Subject: [PATCH 31/43] feat: support QueryContract Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 40 ++++- internal/blockchain/cardano/cardano_test.go | 157 +++++++++++++++++++- manifest.json | 8 +- manifestgen.sh | 16 +- 4 files changed, 211 insertions(+), 10 deletions(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index a31433bf5d..77ed894abb 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -295,8 +295,44 @@ func (c *Cardano) InvokeContract(ctx context.Context, nsOpID string, signingKey } func (c *Cardano) QueryContract(ctx context.Context, signingKey string, location *fftypes.JSONAny, parsedMethod interface{}, input map[string]interface{}, options map[string]interface{}) (interface{}, error) { - log.L(ctx).Warn("QueryContract is not supported") - return nil, i18n.NewError(ctx, coremsgs.MsgNotSupportedByBlockchainPlugin) + cardanoLocation, err := c.parseContractLocation(ctx, location) + if err != nil { + return nil, err + } + + methodInfo, ok := parsedMethod.(*ffiMethodAndErrors) + if !ok || methodInfo.method == nil || methodInfo.method.Name == "" { + return nil, i18n.NewError(ctx, coremsgs.MsgUnexpectedInterfaceType, parsedMethod) + } + method := methodInfo.method + params := make([]interface{}, 0) + for _, param := range method.Params { + params = append(params, input[param.Name]) + } + + body := map[string]interface{}{ + "address": cardanoLocation.Address, + "method": method, + "params": params, + } + if signingKey != "" { + body["from"] = signingKey + } + + var resErr common.BlockchainRESTError + res, err := c.client.R(). + SetContext(ctx). + SetBody(body). + SetError(&resErr). + Post("/contracts/query") + if err != nil || !res.IsSuccess() { + return nil, common.WrapRESTError(ctx, &resErr, res, err, coremsgs.MsgCardanoconnectRESTErr) + } + var output interface{} + if err = json.Unmarshal(res.Body(), &output); err != nil { + return nil, err + } + return output, nil } func (c *Cardano) ParseInterface(ctx context.Context, method *fftypes.FFIMethod, errors []*fftypes.FFIError) (interface{}, error) { diff --git a/internal/blockchain/cardano/cardano_test.go b/internal/blockchain/cardano/cardano_test.go index b54d0e9203..5b35cbc75e 100644 --- a/internal/blockchain/cardano/cardano_test.go +++ b/internal/blockchain/cardano/cardano_test.go @@ -1401,12 +1401,163 @@ func TestInvokeContractConnectorError(t *testing.T) { assert.Regexp(t, "FF10282", err) } -func TestQueryContractNotSupported(t *testing.T) { +func TestQueryContractOK(t *testing.T) { c, cancel := newTestCardano() defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + location := &Location{ + Address: "simple-tx", + } + options := map[string]interface{}{ + "customOption": "customValue", + } + signingKey := "signingKey" + method := testFFIMethod() + params := map[string]interface{}{ + "varString": "str", + } + locationBytes, err := json.Marshal(location) + assert.NoError(t, err) - _, err := c.QueryContract(context.Background(), "", nil, nil, nil, nil) - assert.Regexp(t, "FF10429", err) + httpmock.RegisterResponder("POST", "http://localhost:12345/contracts/query", func(req *http.Request) (*http.Response, error) { + var body map[string]interface{} + json.NewDecoder(req.Body).Decode(&body) + params := body["params"].([]interface{}) + assert.Equal(t, "simple-tx", body["address"]) + assert.Equal(t, "testFunc", body["method"].(map[string]interface{})["name"]) + assert.Equal(t, 1, len(params)) + assert.Equal(t, signingKey, body["from"]) + res := map[string]interface{}{ + "foo": "bar", + } + return httpmock.NewJsonResponderOrPanic(200, res)(req) + }) + + parsedMethod, err := c.ParseInterface(context.Background(), method, nil) + assert.NoError(t, err) + + res, err := c.QueryContract(context.Background(), signingKey, fftypes.JSONAnyPtrBytes(locationBytes), parsedMethod, params, options) + assert.NoError(t, err) + assert.Equal(t, map[string]interface{}{"foo": "bar"}, res) +} + +func TestQueryContractAddressNotSet(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + location := &Location{} + options := map[string]interface{}{ + "customOption": "customValue", + } + signingKey := "signingKey" + method := testFFIMethod() + params := map[string]interface{}{ + "varString": "str", + } + locationBytes, err := json.Marshal(location) + assert.NoError(t, err) + + parsedMethod, err := c.ParseInterface(context.Background(), method, nil) + assert.NoError(t, err) + + _, err = c.QueryContract(context.Background(), signingKey, fftypes.JSONAnyPtrBytes(locationBytes), parsedMethod, params, options) + assert.Regexp(t, "FF10310", err) +} + +func TestQueryContractBadMethod(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + location := &Location{ + Address: "simple-tx", + } + options := map[string]interface{}{ + "customOption": "customValue", + } + signingKey := "signingKey" + method := &fftypes.FFIMethod{} + params := map[string]interface{}{ + "varString": "str", + } + locationBytes, err := json.Marshal(location) + assert.NoError(t, err) + + parsedMethod, err := c.ParseInterface(context.Background(), method, nil) + assert.NoError(t, err) + + _, err = c.QueryContract(context.Background(), signingKey, fftypes.JSONAnyPtrBytes(locationBytes), parsedMethod, params, options) + assert.Regexp(t, "FF10457", err) +} + +func TestQueryContractConnectorError(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + location := &Location{ + Address: "simple-tx", + } + options := map[string]interface{}{ + "customOption": "customValue", + } + signingKey := "signingKey" + method := testFFIMethod() + params := map[string]interface{}{ + "varString": "str", + } + locationBytes, err := json.Marshal(location) + assert.NoError(t, err) + + httpmock.RegisterResponder("POST", "http://localhost:12345/contracts/invoke", func(req *http.Request) (*http.Response, error) { + var body map[string]interface{} + json.NewDecoder(req.Body).Decode(&body) + params := body["params"].([]interface{}) + assert.Equal(t, "opId", body["id"]) + assert.Equal(t, "simple-tx", body["address"]) + assert.Equal(t, "testFunc", body["method"].(map[string]interface{})["name"]) + assert.Equal(t, 1, len(params)) + assert.Equal(t, signingKey, body["from"]) + return httpmock.NewJsonResponderOrPanic(500, &common.BlockchainRESTError{ + Error: "something went wrong", + })(req) + }) + + parsedMethod, err := c.ParseInterface(context.Background(), method, nil) + assert.NoError(t, err) + + _, err = c.QueryContract(context.Background(), signingKey, fftypes.JSONAnyPtrBytes(locationBytes), parsedMethod, params, options) + assert.Regexp(t, "FF10282", err) +} + +func TestQueryContractInvalidJson(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + httpmock.ActivateNonDefault(c.client.GetClient()) + defer httpmock.DeactivateAndReset() + location := &Location{ + Address: "simple-tx", + } + options := map[string]interface{}{ + "customOption": "customValue", + } + signingKey := "signingKey" + method := testFFIMethod() + params := map[string]interface{}{ + "varString": "str", + } + locationBytes, err := json.Marshal(location) + assert.NoError(t, err) + + httpmock.RegisterResponder("POST", "http://localhost:12345/contracts/query", httpmock.NewStringResponder(200, "\"whoops forgot a quote")) + + parsedMethod, err := c.ParseInterface(context.Background(), method, nil) + assert.NoError(t, err) + + _, err = c.QueryContract(context.Background(), signingKey, fftypes.JSONAnyPtrBytes(locationBytes), parsedMethod, params, options) + assert.Error(t, err) } func TestDeployContractOK(t *testing.T) { diff --git a/manifest.json b/manifest.json index 7b1897a9e5..7ef36ddc33 100644 --- a/manifest.json +++ b/manifest.json @@ -1,13 +1,13 @@ { "cardanoconnect": { "image": "ghcr.io/hyperledger/firefly-cardanoconnect", - "tag": "v0.2.1", - "sha": "de880330e10faee733a30306add75139ccdf248329724f0b1292cf62eb65619f" + "tag": "v0.3.0", + "sha": "3980f1d549a6cb1f397f8e66f6ed5bdd29a91580c68fd413ace8cc011dab9178" }, "cardanosigner": { "image": "ghcr.io/hyperledger/firefly-cardanosigner", - "tag": "v0.2.1", - "sha": "d277bb99de78ed0fafd8b6a83f78a70efaa493a2b2c8259a4c46508f6987b0d5" + "tag": "v0.3.0", + "sha": "ac5ee089513f734c0673f3faa136ac04b5918f3a27a5bd57b840e63a74984e32" }, "ethconnect": { "image": "ghcr.io/hyperledger/firefly-ethconnect", diff --git a/manifestgen.sh b/manifestgen.sh index 7533c6f415..319efed5fa 100755 --- a/manifestgen.sh +++ b/manifestgen.sh @@ -41,7 +41,21 @@ CLI_SECTION=$(cat manifest.json | jq .cli) rm -f manifest.json +function repository_url() { + service=$1 + case $service in + cardano*) + echo "https://api.github.com/repos/hyperledger/firefly-cardano" + ;; + *) + echo "https://api.github.com/repos/hyperledger/firefly-$service" + ;; + esac +} + SERVICES=( + "cardanoconnect" + "cardanosigner" "ethconnect" "evmconnect" "fabconnect" @@ -62,7 +76,7 @@ do if [ $USE_HEAD = false ] ; then # Query GitHub API the latest release version - TAG=$(curl https://api.github.com/repos/hyperledger/firefly-${SERVICES[$i]}/releases/latest -s | jq .tag_name -r) + TAG=$(curl "$(repository_url "${SERVICES[$i]}")/releases/latest" -s | jq .tag_name -r) else # Otherwise, pull the newest built image straight off the main branch TAG="head" From 17bab491a541cbcd43adf4e60c152ea97789d529 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Fri, 14 Mar 2025 17:49:11 -0400 Subject: [PATCH 32/43] fix: handle unchecked error Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index 77ed894abb..8969d06c55 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -417,7 +417,9 @@ func (c *Cardano) AddContractListener(ctx context.Context, listener *core.Contra } result, err := c.streams.createListener(ctx, c.streamIDs[namespace], subName, firstEvent, filters) - listener.BackendID = result.ID + if result != nil { + listener.BackendID = result.ID + } return err } From c177dcb3d5ffa8686e5fe5079ef128e0fa69d70c Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Fri, 14 Mar 2025 18:26:45 -0400 Subject: [PATCH 33/43] fix: pass full signature in listener Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index 8969d06c55..19472ba8d4 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -408,10 +408,14 @@ func (c *Cardano) AddContractListener(ctx context.Context, listener *core.Contra if err != nil { return err } + signature, err := c.GenerateEventSignature(ctx, &f.Event.FFIEventDefinition) + if err != nil { + return err + } filters = append(filters, filter{ eventfilter{ Contract: location.Address, - EventPath: f.Event.Name, + EventPath: signature, }, }) } From f424f721f9a4d2263d74a3d0d53850f59f728ff2 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Fri, 14 Mar 2025 22:27:47 -0400 Subject: [PATCH 34/43] feat: use synchronous bulk operation updates for receipt handling Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano.go | 14 ++-- internal/blockchain/cardano/cardano_test.go | 66 +++++++++++++++++-- internal/blockchain/common/common.go | 49 ++++++++++++++ internal/blockchain/common/common_test.go | 72 +++++++++++++++++++++ 4 files changed, 190 insertions(+), 11 deletions(-) diff --git a/internal/blockchain/cardano/cardano.go b/internal/blockchain/cardano/cardano.go index 19472ba8d4..14d8e46d21 100644 --- a/internal/blockchain/cardano/cardano.go +++ b/internal/blockchain/cardano/cardano.go @@ -408,10 +408,7 @@ func (c *Cardano) AddContractListener(ctx context.Context, listener *core.Contra if err != nil { return err } - signature, err := c.GenerateEventSignature(ctx, &f.Event.FFIEventDefinition) - if err != nil { - return err - } + signature, _ := c.GenerateEventSignature(ctx, &f.Event.FFIEventDefinition) filters = append(filters, filter{ eventfilter{ Contract: location.Address, @@ -594,6 +591,7 @@ func (c *Cardano) eventLoop(namespace string) { func (c *Cardano) handleMessageBatch(ctx context.Context, namespace string, batchID int64, messages []interface{}) error { events := make(common.EventsToDispatch) + updates := make([]*core.OperationUpdate, 0) count := len(messages) for i, msgI := range messages { msgMap, ok := msgI.(map[string]interface{}) @@ -616,7 +614,7 @@ func (c *Cardano) handleMessageBatch(ctx context.Context, namespace string, batc msgBytes, _ := json.Marshal(msgMap) _ = json.Unmarshal(msgBytes, &receipt) - err := common.HandleReceipt(ctx, namespace, c, &receipt, c.callbacks) + err := common.AddReceiptToBatch(ctx, namespace, c, &receipt, &updates) if err != nil { log.L(ctx).Errorf("Failed to process receipt: %+v", msgMap) } @@ -626,6 +624,12 @@ func (c *Cardano) handleMessageBatch(ctx context.Context, namespace string, batc } + if len(updates) > 0 { + err := c.callbacks.BulkOperationUpdates(ctx, namespace, updates) + if err != nil { + return err + } + } // Dispatch all the events from this patch that were successfully parsed and routed to namespaces // (could be zero - that's ok) return c.callbacks.DispatchBlockchainEvents(ctx, events) diff --git a/internal/blockchain/cardano/cardano_test.go b/internal/blockchain/cardano/cardano_test.go index 5b35cbc75e..f9d7a2ced2 100644 --- a/internal/blockchain/cardano/cardano_test.go +++ b/internal/blockchain/cardano/cardano_test.go @@ -641,11 +641,11 @@ func TestEventLoopReceiveReceipt(t *testing.T) { tm := &coremocks.OperationCallbacks{} c.SetOperationHandler("ns1", tm) - tm.On("OperationUpdate", mock.MatchedBy(func(update *core.OperationUpdateAsync) bool { - return update.NamespacedOpID == "ns1:5678" && - update.Status == core.OpStatusSucceeded && - update.BlockchainTXID == "txHash" && - update.Plugin == "cardano" + tm.On("BulkOperationUpdates", mock.Anything, mock.MatchedBy(func(updates []*core.OperationUpdate) bool { + return updates[0].NamespacedOpID == "ns1:5678" && + updates[0].Status == core.OpStatusSucceeded && + updates[0].BlockchainTXID == "txHash" && + updates[0].Plugin == "cardano" })).Return(nil) go c.eventLoop("ns1") @@ -653,6 +653,9 @@ func TestEventLoopReceiveReceipt(t *testing.T) { r <- []byte(`{ "batchNumber": 1339, "events": [ + { + "type": "Nonsense" + }, { "type": "Receipt", "headers": { @@ -663,7 +666,7 @@ func TestEventLoopReceiveReceipt(t *testing.T) { "type": "Receipt", "headers": { "requestId": "ns1:5678", - "replyType": "TransactionSuccess" + "type": "TransactionSuccess" }, "transactionHash": "txHash" } @@ -678,6 +681,57 @@ func TestEventLoopReceiveReceipt(t *testing.T) { assert.Equal(t, "ack", parsed.Type) } +func TestEventLoopReceiveReceiptBulkOperationUpdateFail(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + r := make(chan []byte) + s := make(chan []byte) + wsm := &wsmocks.WSClient{} + c.wsconns["ns1"] = wsm + wsm.On("Receive").Return((<-chan []byte)(r)) + wsm.On("Send", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + bytes, _ := args.Get(1).([]byte) + s <- bytes + }).Return(nil) + wsm.On("Close").Return() + c.streamIDs["ns1"] = "es12345" + c.closed["ns1"] = make(chan struct{}) + + tm := &coremocks.OperationCallbacks{} + c.SetOperationHandler("ns1", tm) + tm.On("BulkOperationUpdates", mock.Anything, mock.MatchedBy(func(updates []*core.OperationUpdate) bool { + return updates[0].NamespacedOpID == "ns1:5678" && + updates[0].Status == core.OpStatusSucceeded && + updates[0].BlockchainTXID == "txHash" && + updates[0].Plugin == "cardano" + })).Return(errors.New("whoops")) + + go c.eventLoop("ns1") + + r <- []byte(`{ + "batchNumber": 1339, + "events": [ + { + "type": "Receipt", + "headers": { + "requestId": "ns1:5678", + "type": "TransactionSuccess" + }, + "transactionHash": "txHash" + } + ] + }`) + response := <-s + var parsed cardanoWSCommandPayload + err := json.Unmarshal(response, &parsed) + assert.NoError(t, err) + assert.Equal(t, "topic1/ns1", parsed.Topic) + assert.Equal(t, int64(1339), parsed.BatchNumber) + assert.Equal(t, "error", parsed.Type) + assert.Equal(t, "whoops", parsed.Message) +} + func TestSubmitBatchPinNotSupported(t *testing.T) { c, cancel := newTestCardano() defer cancel() diff --git a/internal/blockchain/common/common.go b/internal/blockchain/common/common.go index 0eef61c5e2..b465da9758 100644 --- a/internal/blockchain/common/common.go +++ b/internal/blockchain/common/common.go @@ -456,6 +456,55 @@ func HandleReceipt(ctx context.Context, namespace string, plugin core.Named, rep return nil } +// Common function for synchronously handling receipts from blockchain connectors. +// This won't actually handle the receipt, but will rather collect it into updates. +// The caller will call BulkOperationUpdates with the batch later. +func AddReceiptToBatch(ctx context.Context, namespace string, plugin core.Named, reply *BlockchainReceiptNotification, updates *[]*core.OperationUpdate) error { + l := log.L(ctx) + + if namespace != "" { + opNamespace, _, _ := core.ParseNamespacedOpID(ctx, reply.Headers.ReceiptID) + if opNamespace != namespace { + l.Debugf("Ignoring operation update from other namespace: request=%s tx=%s message=%s", reply.Headers.ReceiptID, reply.TxHash, reply.Message) + return nil + } + } + + if reply.Headers.ReceiptID == "" || reply.Headers.ReplyType == "" { + return fmt.Errorf("reply cannot be processed - missing fields: %+v", reply) + } + + var updateType core.OpStatus + switch reply.Headers.ReplyType { + case "TransactionSuccess": + updateType = core.OpStatusSucceeded + case "TransactionUpdate": + updateType = core.OpStatusPending + default: + updateType = core.OpStatusFailed + } + + // Slightly ugly conversion from ReceiptFromBlockchain -> JSONObject which the generic OperationUpdate() function requires + var output fftypes.JSONObject + obj, err := json.Marshal(reply) + if err != nil { + return fmt.Errorf("reply cannot be processed - marshalling error: %+v", reply) + } + _ = json.Unmarshal(obj, &output) + + l.Infof("Received operation update: status=%s request=%s tx=%s message=%s", updateType, reply.Headers.ReceiptID, reply.TxHash, reply.Message) + *updates = append(*updates, &core.OperationUpdate{ + Plugin: plugin.Name(), + NamespacedOpID: reply.Headers.ReceiptID, + Status: updateType, + BlockchainTXID: reply.TxHash, + ErrorMessage: reply.Message, + Output: output, + }) + + return nil +} + func WrapRESTError(ctx context.Context, errRes *BlockchainRESTError, res *resty.Response, err error, defMsgKey i18n.ErrorMessageKey) error { if errRes != nil && errRes.Error != "" { if res != nil && res.StatusCode() == http.StatusConflict { diff --git a/internal/blockchain/common/common_test.go b/internal/blockchain/common/common_test.go index b59b27a4c4..d56d69d435 100644 --- a/internal/blockchain/common/common_test.go +++ b/internal/blockchain/common/common_test.go @@ -397,6 +397,78 @@ func TestWrongNamespaceReceipt(t *testing.T) { assert.NoError(t, err) } +type MockPlugin struct{} + +func (m *MockPlugin) Name() string { + return "Mock" +} + +func TestGoodSuccessReceiptBatch(t *testing.T) { + var plugin MockPlugin + var reply BlockchainReceiptNotification + reply.Headers.ReceiptID = "ID" + reply.Headers.ReplyType = "TransactionSuccess" + reply.ProtocolID = "123456/098765453" + + updates := []*core.OperationUpdate{} + + err := AddReceiptToBatch(context.Background(), "", &plugin, &reply, &updates) + assert.NoError(t, err) + assert.Equal(t, 1, len(updates)) + + reply.Headers.ReplyType = "TransactionUpdate" + err = AddReceiptToBatch(context.Background(), "", &plugin, &reply, &updates) + assert.NoError(t, err) + assert.Equal(t, 2, len(updates)) + + reply.Headers.ReplyType = "TransactionFailed" + err = AddReceiptToBatch(context.Background(), "", &plugin, &reply, &updates) + assert.NoError(t, err) + assert.Equal(t, 3, len(updates)) +} + +func TestReceiptMarshallingErrorBatch(t *testing.T) { + var plugin MockPlugin + var reply BlockchainReceiptNotification + reply.Headers.ReceiptID = "ID" + reply.Headers.ReplyType = "force-marshall-error" + reply.ProtocolID = "123456/098765453" + + updates := []*core.OperationUpdate{} + + err := AddReceiptToBatch(context.Background(), "", &plugin, &reply, &updates) + assert.Error(t, err) + assert.Regexp(t, ".*[^n]marshalling error.*", err) + assert.Equal(t, 0, len(updates)) +} + +func TestBadReceiptBatch(t *testing.T) { + var plugin MockPlugin + var reply BlockchainReceiptNotification + data := fftypes.JSONAnyPtr(`{}`) + err := json.Unmarshal(data.Bytes(), &reply) + assert.NoError(t, err) + + updates := []*core.OperationUpdate{} + + err = AddReceiptToBatch(context.Background(), "", &plugin, &reply, &updates) + assert.Error(t, err) + assert.Equal(t, 0, len(updates)) +} + +func TestWrongNamespaceReceiptBatch(t *testing.T) { + var plugin MockPlugin + var reply BlockchainReceiptNotification + data := fftypes.JSONAnyPtr(`{}`) + err := json.Unmarshal(data.Bytes(), &reply) + assert.NoError(t, err) + updates := []*core.OperationUpdate{} + + err = AddReceiptToBatch(context.Background(), "wrong", &plugin, &reply, &updates) + assert.NoError(t, err) + assert.Equal(t, 0, len(updates)) +} + func TestErrorWrappingConflict(t *testing.T) { ctx := context.Background() res := &resty.Response{ From 34390c65f620e6e80a4552525ac50ff4d6b5d05e Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Fri, 14 Mar 2025 22:28:09 -0400 Subject: [PATCH 35/43] docs: document contract creation Signed-off-by: Simon Gellis --- .../tutorials/custom_contracts/cardano.md | 540 ++++++++++++++++++ 1 file changed, 540 insertions(+) create mode 100644 doc-site/docs/tutorials/custom_contracts/cardano.md diff --git a/doc-site/docs/tutorials/custom_contracts/cardano.md b/doc-site/docs/tutorials/custom_contracts/cardano.md new file mode 100644 index 0000000000..5a3cf2d483 --- /dev/null +++ b/doc-site/docs/tutorials/custom_contracts/cardano.md @@ -0,0 +1,540 @@ +--- +title: Cardano +--- + +# Work with Cardano dApps + +This guide describes the steps to author and deploy a Cardano dApp through FireFly. + +## What is a Cardano dApp? + +Cardano dApps typically have two components: off-chain and on-chain. + + - The off-chain component is written in an ordinary programming language using a Cardano-specific library. It builds transactions, signs them with a private key, and submits them to be published to the chain. The FireFly Cardano connector uses a framework called [Balius](https://github.com/txpipe/balius) for off-chain code. This lets you write transaction-building logic in Rust, which is compiled to WebAssembly and run in response to HTTP requests or new blocks reaching the chain. + + - The on-chain component is a "validator script". Validator scripts are written in domain-specific languages such as [Aiken](https://aiken-lang.org/), and compiled to a bytecode called [UPLC](https://aiken-lang.org/uplc). They take a transaction as input, and return true or false to indicate whether that transaction is valid. ADA and native takens can be locked at the address of one of these scripts; if they are, then they can only be spent by transactions which the script considers valid. + +## Writing a dApp + +First, decide on the contract which your dApp will satisfy. FireFly uses [JSON schema](https://json-schema.org/) to describe its contracts. Create a file named `contract.json`. An example is below: + +### Contract + +```json +{ + "name": "sample-contract", + "description": "Simple TX submission contract", + "networkName": "sample-contract", + "version": "0.1.0", + "errors": [], + "methods": [ + { + "description": "Sends ADA to a wallet", + "details": {}, + "name": "send_ada", + "params": [ + { + "name": "fromAddress", + "schema": { + "type": "string" + } + }, + { + "name": "toAddress", + "schema": { + "type": "string" + } + }, + { + "name": "amount", + "schema": { + "type": "integer" + } + } + ], + "returns": [] + } + ], + "events": [ + { + "name": "TransactionAccepted", + "description": "", + "params": [ + { + "name": "transactionId", + "schema": { + "type": "string" + } + } + ] + }, + { + "name": "TransactionRolledBack", + "description": "", + "params": [ + { + "name": "transactionId", + "schema": { + "type": "string" + } + } + ] + }, + { + "name": "TransactionFinalized", + "description": "", + "params": [ + { + "name": "transactionId", + "schema": { + "type": "string" + } + } + ] + } + ] +} +``` + +This is describing a contract with a single method, named `send_ada`. This method takes three parameters: a `fromAddress`, a `toAddress`, and an `amount`. + +It also emits three events: + - `TransactionAccepted(string)` is emitted when the transaction is included in a block. + - `TransactionRolledBack(string)` is emitted if the transaction was included in a block, and that block got rolled back. This happens maybe once or twice a day on the Cardano network, so it is unlikely that you will ever see it in practice (but more likely than some other chains). + - `TransactionFinalized(string)` is emitted when the transaction has been on the chain for "long enough" that it is effectively immutable. It is up to your tolerance risk. + +These three events are all automatically handled by the connector. + +### The dApp itself + +The Balius framework requires you to write your dApp in Rust, and compile it to WebAssembly. +Set up a new Rust project with the contents below: + +`cargo.toml`: +```toml +[package] +name = "sample-contract" +version = "0.1.0" +edition = "2021" + +[dependencies] +# The version of firefly-balius should match the version of firefly-cardano which you are using. +firefly-balius = { git = "https://github.com/hyperledger/firefly-cardano", rev = "0.3.1" } +pallas-addresses = "0.32" +serde = { version = "1", features = ["derive"] } + +[lib] +crate-type = ["cdylib"] +``` + +Code for a sample contract is below: + +`src/lib.rs`: +```rust +use std::collections::HashSet; + +use balius_sdk::{ + txbuilder::{ + AddressPattern, BuildError, FeeChangeReturn, OutputBuilder, TxBuilder, UtxoPattern, + UtxoSource, + }, + Ack, Config, FnHandler, NewTx, Params, Worker, WorkerResult, +}; +use firefly_balius::{ + balius_sdk::{self, Json}, kv, CoinSelectionInput, FinalityMonitor, FinalizationCondition, SubmittedTx, WorkerExt as _ +}; +use pallas_addresses::Address; +use serde::{Deserialize, Serialize}; + +// For each method, define a struct with all its parameters. +// Don't forget the "rename_all = camelCase" annotation. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SendAdaRequest { + pub from_address: String, + pub to_address: String, + pub amount: u64, +} + +#[derive(Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct CurrentState { + submitted_txs: HashSet, +} + +/// This function builds a transaction to send ADA from one address to another. +fn send_ada(_: Config<()>, req: Params) -> WorkerResult { + let from_address = + Address::from_bech32(&req.from_address).map_err(|_| BuildError::MalformedAddress)?; + + // Build an "address source" describing where the funds to transfer are coming from. + let address_source = UtxoSource::Search(UtxoPattern { + address: Some(AddressPattern { + exact_address: from_address.to_vec(), + }), + ..UtxoPattern::default() + }); + + // In Cardano, addresses don't hold ADA or native tokens directly. + // Instead, they control uTXOS (unspent transaction outputs), + // and those uTXOs contain some amount of ADA and native tokens. + // You can't spent part of a uTXO in a transaction; instead, transactions + // include inputs with more funds than they need, and a "change" output + // to give any excess funds back to the original sender. + + // Build a transaction with + // - One or more inputs containing at least `amount` ADA at the address `from_address` + // - One output containing exactly `amount` ADA at the address `to_address` + // - One output containing any change at the address `from_address` + let tx = TxBuilder::new() + .with_input(CoinSelectionInput(address_source.clone(), req.amount)) + .with_output( + OutputBuilder::new() + .address(req.to_address.clone()) + .with_value(req.amount), + ) + .with_output(FeeChangeReturn(address_source)); + + // Return that TX. The framework will sign and submit it. + Ok(NewTx(Box::new(tx))) +} + +/// This function is called when a TX produced by this contract is submitted to the blockchain, but before it has reached a block. +fn handle_submit(_: Config<()>, tx: SubmittedTx) -> WorkerResult { + // Tell the framework that we want it to monitor this transaction. + // This enables the TransactionApproved, TransactionRolledBack, and TransactionFinalized events from before. + // Note that we decide the transaction has been finalized after 4 blocks have reached the chain. + FinalityMonitor.monitor_tx(&tx.hash, FinalizationCondition::AfterBlocks(4))?; + + // Keep track of which TXs have been submitted. + let mut state: CurrentState = kv::get("current_state")?.unwrap_or_default(); + state.submitted_txs.insert(tx.hash); + kv::set("current_state", &state)?; + + Ok(Ack) +} + +fn query_current_state(_: Config<()>, _: Params<()>) -> WorkerResult> { + Ok(Json(kv::get("current_state")?.unwrap_or_default())) +} + +#[balius_sdk::main] +fn main() -> Worker { + Worker::new() + .with_request_handler("send_ada", FnHandler::from(send_ada)) + .with_request_handler("current_state", FnHandler::from(query_current_state)) + .with_tx_submitted_handler(handle_submit) +} + +``` + +## Deploying the dApp + +You can use the `firefly-cardano-deploy` tool to deploy this dApp to your running FireFly instance. +This tool will + - Compile your dApp to WebAssembly + - Deploy that WebAssembly to a running FireFly node + - Deploy your interface to that FireFly node + - Create an API for that interface + +```sh +# The version here should match the version of firefly-cardano which you are using. +cargo install --git https://github.com/hyperledger/firefly-cardano --version 0.3.1 firefly-cardano-deploy + +CONTRACT_PATH="/path/to/your/dapp" +FIREFLY_URL="http://localhost:5000" +firefly-cardano-deploy --contract-path "$CONTRACT_PATH" --firefly-url "$FIREFLY_URL" +``` + +After this runs, you should see output like the following: +``` +Contract location: {"address":"sample-contract@0.1.0"} +Interface: {"id":"120d061e-bcda-4c2f-a296-018d7cd62a04"} +API available at http://127.0.0.1:5000/api/v1/namespaces/default/apis/sample-contract-0.1.0 +Swagger UI at http://127.0.0.1:5000/api/v1/namespaces/default/apis/sample-contract-0.1.0/api +``` + +## Invoking the dApp + +Now that we've set everything up, let's prove it works by sending 1 ADA back to the faucet. + +### Request + +`POST` `http://localhost:5000/api/v1/namespaces/default/apis/simple-storage-0.1.0/invoke/send_ada` + +```json +{ + "input": { + "fromAddress": "", + "toAddress": "addr_test1vqeux7xwusdju9dvsj8h7mca9aup2k439kfmwy773xxc2hcu7zy99", + "amount": 1000000 + } +} +``` + +### Response +```json +{ + "id": "d191e6ab-3e9c-4a67-99df-8b96b7026e89" +} +``` + +## Create a blockchain event listener + +Now that we've seen how to submit transactions, let's look at how to receive blockchain events so we know when things are happening in realtime. + +Remember that this contract is emitting events when transactions are accepted, rolled back, or finalized. In order to receive these events, we first need to instruct FireFly to listen for this specific type of blockchain event. To do this, we create an **Event Listener**. The `/contracts/listeners` endpoint is RESTful so there are `POST`, `GET`, and `DELETE` methods available on it. To create a new listener, we will make a `POST` request. We are going to tell FireFly to listen to events with name `"TransactionAccepted"`, `"TransactionRolledBack"`, or `"TransactionFinalized"` from the FireFly Interface we defined earlier, referenced by its ID. We will also tell FireFly which contract address we expect to emit these events, and the topic to assign these events to. You can specify multiple filters for a listener, in this case we specify one for each event. Topics are a way for applications to subscribe to events they are interested in. + +### Request + +```json +{ + "filters": [ + { + "interface": {"id":"120d061e-bcda-4c2f-a296-018d7cd62a04"}, + "location": {"address":"sample-contract@0.1.0"}, + "eventPath": "TransactionAccepted" + }, + { + "interface": {"id":"120d061e-bcda-4c2f-a296-018d7cd62a04"}, + "location": {"address":"sample-contract@0.1.0"}, + "eventPath": "TransactionRolledBack" + }, + { + "interface": {"id":"120d061e-bcda-4c2f-a296-018d7cd62a04"}, + "location": {"address":"sample-contract@0.1.0"}, + "eventPath": "TransactionFinalized" + } + ], + "options": { + "firstEvent": "newest" + }, + "topic": "sample-contract" +} +``` + +### Response + +```json +{ + "id": "b314d8af-2641-4bf2-b386-2e658f3e76a5", + "interface": { + "id": "120d061e-bcda-4c2f-a296-018d7cd62a04" + }, + "namespace": "default", + "name": "01JPB97KWQ1ZBPWQDNDMEYDMT2", + "backendId": "01JPB97KWQ1ZBPWQDNDMEYDMT2", + "location": { + "address": "sample-contract@0.1.0" + }, + "created": "2025-03-14T21:33:44.308362312Z", + "event": { + "name": "TransactionAccepted", + "description": "", + "params": [ + { + "name": "transactionId", + "schema": { + "type": "string" + } + } + ] + }, + "signature": "sample-contract@0.1.0:TransactionAccepted(string);sample-contract@0.1.0:TransactionRolledBack(string);sample-contract@0.1.0:TransactionRFinalized(string)", + "topic": "sample-contract", + "options": { + "firstEvent": "newest" + }, + "filters": [ + { + "event": { + "name": "TransactionAccepted", + "description": "", + "params": [ + { + "name": "transactionId", + "schema": { + "type": "string" + } + } + ] + }, + "location": { + "address": "sample-contract@0.1.0" + }, + "interface": { + "id": "120d061e-bcda-4c2f-a296-018d7cd62a04" + }, + "signature": "sample-contract@0.1.0:TransactionAccepted(string)" + }, + { + "event": { + "name": "TransactionRolledBack", + "description": "", + "params": [ + { + "name": "transactionId", + "schema": { + "type": "string" + } + } + ] + }, + "location": { + "address": "sample-contract@0.1.0" + }, + "interface": { + "id": "120d061e-bcda-4c2f-a296-018d7cd62a04" + }, + "signature": "sample-contract@0.1.0:TransactionRolledBack(string)" + } + { + "event": { + "name": "TransactionFinalized", + "description": "", + "params": [ + { + "name": "transactionId", + "schema": { + "type": "string" + } + } + ] + }, + "location": { + "address": "sample-contract@0.1.0" + }, + "interface": { + "id": "120d061e-bcda-4c2f-a296-018d7cd62a04" + }, + "signature": "sample-contract@0.1.0:TransactionFinalized(string)" + } + ] +} +``` + +We can see in the response, that FireFly pulls all the schema information from the FireFly Interface that we broadcasted earlier and creates the listener with that schema. This is useful so that we don't have to enter all of that data again. + +## Subscribe to events from our contract + +Now that we've told FireFly that it should listen for specific events on the blockchain, we can set up a **Subscription** for FireFly to send events to our app. To set up our subscription, we will make a `POST` to the `/subscriptions` endpoint. + +We will set a friendly name `sample-contract` to identify the Subscription when we are connecting to it in the next step. + +We're also going to set up a filter to only send events blockchain events from our listener that we created in the previous step. To do that, we'll **copy the listener ID** from the step above (`b314d8af-2641-4bf2-b386-2e658f3e76a5`) and set that as the value of the `listener` field in the example below: + +### Request + +`POST` `http://localhost:5000/api/v1/namespaces/default/subscriptions` + +```json +{ + "namespace": "default", + "name": "sample-contract", + "transport": "websockets", + "filter": { + "events": "blockchain_event_received", + "blockchainevent": { + "listener": "b314d8af-2641-4bf2-b386-2e658f3e76a5" + } + }, + "options": { + "firstEvent": "oldest" + } +} +``` + +### Response + +```json +{ + "id": "f826269c-65ed-4634-b24c-4f399ec53a32", + "namespace": "default", + "name": "sample-contract", + "transport": "websockets", + "filter": { + "events": "blockchain_event_received", + "message": {}, + "transaction": {}, + "blockchainevent": { + "listener": "b314d8af-2641-4bf2-b386-2e658f3e76a5" + } + }, + "options": { + "firstEvent": "-1", + "withData": false + }, + "created": "2025-03-15T17:35:30.131698921Z", + "updated": null +} +``` + +## Receive custom smart contract events + +The last step is to connect a WebSocket client to FireFly to receive the event. You can use any WebSocket client you like, such as [Postman](https://www.postman.com/) or a command line app like [`websocat`](https://github.com/vi/websocat). + +Connect your WebSocket client to `ws://localhost:5000/ws`. + +After connecting the WebSocket client, send a message to tell FireFly to: + +- Start sending events +- For the Subscription named `sample-contract` +- On the `default` namespace +- Automatically "ack" each event which will let FireFly immediately send the next event when available + +```json +{ + "type": "start", + "name": "sample-contract", + "namespace": "default", + "autoack": true +} +``` + +### WebSocket event + +After creating the subscription, you should see an event arrive on the connected WebSocket client that looks something like this: + +```json +{ + "id": "0f4a31d6-9743-4537-82df-5a9c76ccbd1e", + "sequence": 24, + "type": "blockchain_event_received", + "namespace": "default", + "reference": "dd3e1554-c832-47a8-898e-f1ee406bea41", + "created": "2025-03-15T17:32:27.824417878Z", + "blockchainevent": { + "id": "dd3e1554-c832-47a8-898e-f1ee406bea41", + "sequence": 7, + "source": "cardano", + "namespace": "default", + "name": "TransactionAccepted", + "listener": "1bfa3b0f-3d90-403e-94a4-af978d8c5b14", + "protocolId": "000000000010/000000/000000", + "output": { + "transactionId": "2fad3b4e560b562d32b2e54e25495d11ed342dafe7eba76bc1c4632bbc23d468" + }, + "info": { + "address": "0xa5ea5d0a6b2eaf194716f0cc73981939dca26da1", + "blockNumber": "10", + "logIndex": "0", + "signature": "TransactionAccepted(string)", + "subId": "sb-724b8416-786d-4e67-4cd3-5bae4a26eb0e", + "timestamp": "1647365460", + "transactionHash": "2fad3b4e560b562d32b2e54e25495d11ed342dafe7eba76bc1c4632bbc23d468", + "transactionIndex": "0x0" + }, + "timestamp": "2025-03-15T17:31:00Z", + "tx": { + "type": "" + } + }, + "subscription": { + "id": "f826269c-65ed-4634-b24c-4f399ec53a32", + "namespace": "default", + "name": "sample-contract" + } +} +``` + +You can see in the event received over the WebSocket connection, the blockchain event that was emitted from our first transaction, which happened in the past. We received this event, because when we set up both the Listener, and the Subscription, we specified the `"firstEvent"` as `"oldest"`. This tells FireFly to look for this event from the beginning of the blockchain, and that your app is interested in FireFly events since the beginning of FireFly's event history. From 6ef9ba7d1c39f93e574704da25a093c773bb2910 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Fri, 14 Mar 2025 23:03:09 -0400 Subject: [PATCH 36/43] fix: address captialization issues Signed-off-by: Simon Gellis --- doc-site/docs/tutorials/chains/cardano.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc-site/docs/tutorials/chains/cardano.md b/doc-site/docs/tutorials/chains/cardano.md index fe15d50f32..9dc70fda66 100644 --- a/doc-site/docs/tutorials/chains/cardano.md +++ b/doc-site/docs/tutorials/chains/cardano.md @@ -18,9 +18,9 @@ A Cardano stack can be run in two different ways; with a firefly > **NOTE**: The cardano-node communicates over a Unix socket, so this will not work on Windows. -Start a local cardano node. The fastest way to do this is to [use mithril](https://mithril.network/doc/manual/getting-started/bootstrap-cardano-node/) to bootstrap the node. +Start a local Cardano node. The fastest way to do this is to [use mithril](https://mithril.network/doc/manual/getting-started/bootstrap-cardano-node/) to bootstrap the node. -For an example of how to bootstrap and run the cardano node in docker, see [the firefly-cardano repo](https://github.com/hyperledger/firefly-cardano/blob/1be3b08d301d6d6eeb5b79e40cf3dbf66181c3de/infra/docker-compose.node.yaml#L4). +For an example of how to bootstrap and run the Cardano node in Docker, see [the firefly-cardano repo](https://github.com/hyperledger/firefly-cardano/blob/1be3b08d301d6d6eeb5b79e40cf3dbf66181c3de/infra/docker-compose.node.yaml#L4). The cardano-node exposes a Unix socket named `node.socket`. Pass that to firefly-cli. The example below uses `firefly-cli` to - Create a new Cardano-based stack named `dev` with 1 member. From e19609db8af798fd40d34df6131fbc030b3356c2 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Sat, 15 Mar 2025 00:05:33 -0400 Subject: [PATCH 37/43] fix: update cardano images Signed-off-by: Simon Gellis --- manifest.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/manifest.json b/manifest.json index 493d42cde6..bb5f484796 100644 --- a/manifest.json +++ b/manifest.json @@ -1,13 +1,13 @@ { "cardanoconnect": { "image": "ghcr.io/hyperledger/firefly-cardanoconnect", - "tag": "v0.3.0", - "sha": "3980f1d549a6cb1f397f8e66f6ed5bdd29a91580c68fd413ace8cc011dab9178" + "tag": "v0.4.0", + "sha": "d691cb0efe996b14b56ca90e88062ba79902a9d3a00c42324a6f2a6f4c769de7" }, "cardanosigner": { "image": "ghcr.io/hyperledger/firefly-cardanosigner", - "tag": "v0.3.0", - "sha": "ac5ee089513f734c0673f3faa136ac04b5918f3a27a5bd57b840e63a74984e32" + "tag": "v0.4.0", + "sha": "d5d391a25525f33ef30d9c1a21c109c6221e7aeba6afb4bf3a11f0933f594044" }, "ethconnect": { "image": "ghcr.io/hyperledger/firefly-ethconnect", @@ -66,7 +66,7 @@ }, "ui": { "tag": "v1.3.1", - "release": "v1.3.1" + "release": "v1.3.1" }, "cli": { "tag": "v1.3.3-rc.1" From 0c170a773d4e2a2453e2828e5c372021db314996 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Mon, 17 Mar 2025 13:56:06 -0400 Subject: [PATCH 38/43] fix: finish sentences in docs Signed-off-by: Simon Gellis --- doc-site/docs/tutorials/chains/cardano.md | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/doc-site/docs/tutorials/chains/cardano.md b/doc-site/docs/tutorials/chains/cardano.md index 9dc70fda66..1e2ccb5fa3 100644 --- a/doc-site/docs/tutorials/chains/cardano.md +++ b/doc-site/docs/tutorials/chains/cardano.md @@ -12,7 +12,7 @@ If you haven't set up the FireFly CLI already, please go back to the Getting Sta ## Create the stack -A Cardano stack can be run in two different ways; with a firefly +A Cardano stack can be run in two different ways; using a local Cardano node, or a remote Blockfrost address. ### Option 1: Use a local Cardano node @@ -23,13 +23,11 @@ Start a local Cardano node. The fastest way to do this is to [use mithril](https For an example of how to bootstrap and run the Cardano node in Docker, see [the firefly-cardano repo](https://github.com/hyperledger/firefly-cardano/blob/1be3b08d301d6d6eeb5b79e40cf3dbf66181c3de/infra/docker-compose.node.yaml#L4). The cardano-node exposes a Unix socket named `node.socket`. Pass that to firefly-cli. The example below uses `firefly-cli` to - - Create a new Cardano-based stack named `dev` with 1 member. - - Disable `multiparty` mode. + - Create a new Cardano-based stack named `dev`. - Connect to the local Cardano node, which is running in the [preview network](https://preview.cexplorer.io/). ```sh -ff init cardano dev 1 \ - --multiparty false \ +ff init cardano dev \ --network preview \ --socket /path/to/ipc/node.socket ``` @@ -39,13 +37,11 @@ ff init cardano dev 1 \ The Cardano connector can also use the [paid Blockfrost API](https://blockfrost.io/) in place of a local Cardano node. The example below uses firefly-cli to - - Create a new Cardano-based stack named `dev` with 1 member. - - Disable `multiparty` mode. - - Use the given block + - Create a new Cardano-based stack named `dev` + - Use the given blockfrost key for the preview network. ```sh -ff init cardano dev 1 \ - --multiparty false \ +ff init cardano dev \ --network preview \ --blockfrost-key previewXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ``` From 725f7069064a7312ab62f20f65f7f6c9ae42245d Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Mon, 17 Mar 2025 13:56:26 -0400 Subject: [PATCH 39/43] fix: correct timing-out test Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/blockchain/cardano/cardano_test.go b/internal/blockchain/cardano/cardano_test.go index f9d7a2ced2..b1a4bbb658 100644 --- a/internal/blockchain/cardano/cardano_test.go +++ b/internal/blockchain/cardano/cardano_test.go @@ -173,8 +173,12 @@ func TestInitAndStartWithCardanoConnect(t *testing.T) { fromServer <- `[]` // empty batch, will be ignored, but acked reply := <-toServer assert.Equal(t, `{"type":"ack","topic":"topic1/ns1"}`, reply) - fromServer <- `["different kind of bad batch"]` - fromServer <- `[{}]` // bad batch + fromServer <- `["different kind of bad batch"]` // bad batch, will be ignored but acked + reply = <-toServer + assert.Equal(t, `{"type":"ack","topic":"topic1/ns1"}`, reply) + fromServer <- `[{}]` // bad batch, will be ignored but acked + reply = <-toServer + assert.Equal(t, `{"type":"ack","topic":"topic1/ns1"}`, reply) // Bad data will be ignored fromServer <- `!json` From 05bab1bf981b4a691fada839baf7e46ff7bbfbe3 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Fri, 21 Mar 2025 16:45:51 -0400 Subject: [PATCH 40/43] feat: update docs and connector images Signed-off-by: Simon Gellis --- .../tutorials/custom_contracts/cardano.md | 32 ++++++++++--------- manifest.json | 8 ++--- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/doc-site/docs/tutorials/custom_contracts/cardano.md b/doc-site/docs/tutorials/custom_contracts/cardano.md index 5a3cf2d483..f5d60d7c4d 100644 --- a/doc-site/docs/tutorials/custom_contracts/cardano.md +++ b/doc-site/docs/tutorials/custom_contracts/cardano.md @@ -100,7 +100,7 @@ This is describing a contract with a single method, named `send_ada`. This metho It also emits three events: - `TransactionAccepted(string)` is emitted when the transaction is included in a block. - - `TransactionRolledBack(string)` is emitted if the transaction was included in a block, and that block got rolled back. This happens maybe once or twice a day on the Cardano network, so it is unlikely that you will ever see it in practice (but more likely than some other chains). + - `TransactionRolledBack(string)` is emitted if the transaction was included in a block, and that block got rolled back. This happens maybe once or twice a day on the Cardano network, which is more likely than some other chains, so your code must be able to gracefully handle rollbacks. - `TransactionFinalized(string)` is emitted when the transaction has been on the chain for "long enough" that it is effectively immutable. It is up to your tolerance risk. These three events are all automatically handled by the connector. @@ -119,7 +119,7 @@ edition = "2021" [dependencies] # The version of firefly-balius should match the version of firefly-cardano which you are using. -firefly-balius = { git = "https://github.com/hyperledger/firefly-cardano", rev = "0.3.1" } +firefly-balius = { git = "https://github.com/hyperledger/firefly-cardano", rev = "0.4.1" } pallas-addresses = "0.32" serde = { version = "1", features = ["derive"] } @@ -138,10 +138,11 @@ use balius_sdk::{ AddressPattern, BuildError, FeeChangeReturn, OutputBuilder, TxBuilder, UtxoPattern, UtxoSource, }, - Ack, Config, FnHandler, NewTx, Params, Worker, WorkerResult, + Ack, Config, FnHandler, Params, Worker, WorkerResult, }; use firefly_balius::{ - balius_sdk::{self, Json}, kv, CoinSelectionInput, FinalityMonitor, FinalizationCondition, SubmittedTx, WorkerExt as _ + balius_sdk::{self, Json}, + kv, CoinSelectionInput, FinalizationCondition, NewMonitoredTx, SubmittedTx, WorkerExt as _, }; use pallas_addresses::Address; use serde::{Deserialize, Serialize}; @@ -163,7 +164,7 @@ struct CurrentState { } /// This function builds a transaction to send ADA from one address to another. -fn send_ada(_: Config<()>, req: Params) -> WorkerResult { +fn send_ada(_: Config<()>, req: Params) -> WorkerResult { let from_address = Address::from_bech32(&req.from_address).map_err(|_| BuildError::MalformedAddress)?; @@ -176,9 +177,9 @@ fn send_ada(_: Config<()>, req: Params) -> WorkerResult { }); // In Cardano, addresses don't hold ADA or native tokens directly. - // Instead, they control uTXOS (unspent transaction outputs), - // and those uTXOs contain some amount of ADA and native tokens. - // You can't spent part of a uTXO in a transaction; instead, transactions + // Instead, they control UTxOs (unspent transaction outputs), + // and those UTxOs contain some amount of ADA and native tokens. + // You can't spent part of a UTxO in a transaction; instead, transactions // include inputs with more funds than they need, and a "change" output // to give any excess funds back to the original sender. @@ -195,17 +196,18 @@ fn send_ada(_: Config<()>, req: Params) -> WorkerResult { ) .with_output(FeeChangeReturn(address_source)); - // Return that TX. The framework will sign and submit it. - Ok(NewTx(Box::new(tx))) + // Return that TX. The framework will sign, submit, and monitor it. + // By returning a `NewMonitoredTx`, we tell the framework that we want it to monitor this transaction. + // This enables the TransactionApproved, TransactionRolledBack, and TransactionFinalized events from before. + // Note that we decide the transaction has been finalized after 4 blocks have reached the chain. + Ok(NewMonitoredTx( + Box::new(tx), + FinalizationCondition::AfterBlocks(4), + )) } /// This function is called when a TX produced by this contract is submitted to the blockchain, but before it has reached a block. fn handle_submit(_: Config<()>, tx: SubmittedTx) -> WorkerResult { - // Tell the framework that we want it to monitor this transaction. - // This enables the TransactionApproved, TransactionRolledBack, and TransactionFinalized events from before. - // Note that we decide the transaction has been finalized after 4 blocks have reached the chain. - FinalityMonitor.monitor_tx(&tx.hash, FinalizationCondition::AfterBlocks(4))?; - // Keep track of which TXs have been submitted. let mut state: CurrentState = kv::get("current_state")?.unwrap_or_default(); state.submitted_txs.insert(tx.hash); diff --git a/manifest.json b/manifest.json index bb5f484796..68ff26f9e5 100644 --- a/manifest.json +++ b/manifest.json @@ -1,13 +1,13 @@ { "cardanoconnect": { "image": "ghcr.io/hyperledger/firefly-cardanoconnect", - "tag": "v0.4.0", - "sha": "d691cb0efe996b14b56ca90e88062ba79902a9d3a00c42324a6f2a6f4c769de7" + "tag": "v0.4.1", + "sha": "78b1008bd62892f6eda197b5047d94e61621d0f06b299422ff8ed9b34ee5ce50" }, "cardanosigner": { "image": "ghcr.io/hyperledger/firefly-cardanosigner", - "tag": "v0.4.0", - "sha": "d5d391a25525f33ef30d9c1a21c109c6221e7aeba6afb4bf3a11f0933f594044" + "tag": "v0.4.1", + "sha": "d0b76613ccc70ff63e68b137766eb009d589489631cee6aabf2b45e33a1ca5d3" }, "ethconnect": { "image": "ghcr.io/hyperledger/firefly-ethconnect", From 8cc7e0069929191282b360ddd1664c3590c6f174 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Wed, 30 Apr 2025 20:11:29 -0400 Subject: [PATCH 41/43] Update custom contracts docs Signed-off-by: Simon Gellis --- doc-site/docs/tutorials/custom_contracts/cardano.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/doc-site/docs/tutorials/custom_contracts/cardano.md b/doc-site/docs/tutorials/custom_contracts/cardano.md index f5d60d7c4d..2f13e96ae7 100644 --- a/doc-site/docs/tutorials/custom_contracts/cardano.md +++ b/doc-site/docs/tutorials/custom_contracts/cardano.md @@ -16,7 +16,9 @@ Cardano dApps typically have two components: off-chain and on-chain. ## Writing a dApp -First, decide on the contract which your dApp will satisfy. FireFly uses [JSON schema](https://json-schema.org/) to describe its contracts. Create a file named `contract.json`. An example is below: +> **NOTE:** The source code to this dApp is also available [on GitHub](https://github.com/hyperledger/firefly-cardano/tree/main/wasm/simple-tx). + +First, decide on the contract which your dApp will satisfy. FireFly uses [FireFly Interface Format](https://hyperledger.github.io/firefly/latest/reference/firefly_interface_format/) to describe its contracts. Create a file named `contract.json`. An example is below: ### Contract @@ -493,6 +495,8 @@ After connecting the WebSocket client, send a message to tell FireFly to: } ``` +> **NOTE:** Do not use `autoack` in production, as it can cause your application to miss events. For resilience, your app should instead respond with an "ack" message to each incoming event. For more details, see the [Websockets documentation](../../reference/types/subscription/#using-start-and-ack-explicitly). + ### WebSocket event After creating the subscription, you should see an event arrive on the connected WebSocket client that looks something like this: From 71673fde1513132cd09aae48f63ffcbdcadb8df5 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Wed, 30 Apr 2025 20:13:30 -0400 Subject: [PATCH 42/43] Remove comment Signed-off-by: Simon Gellis --- internal/networkmap/did.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/networkmap/did.go b/internal/networkmap/did.go index 0b4e829f7e..b52f290cdb 100644 --- a/internal/networkmap/did.go +++ b/internal/networkmap/did.go @@ -94,7 +94,7 @@ func (nm *networkMap) generateDIDAuthentication(ctx context.Context, identity *c func (nm *networkMap) generateCardanoAddressVerifier(identity *core.Identity, verifier *core.Verifier) *VerificationMethod { return &VerificationMethod{ ID: verifier.Hash.String(), - Type: "PaymentVerificationKeyShelley_ed25519", // hope that it's safe to assume we always use Shelley + Type: "PaymentVerificationKeyShelley_ed25519", Controller: identity.DID, BlockchainAccountID: verifier.Value, } From a5d6e4cd2ef7ee97dc43bf130b35c89933638b3a Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Thu, 1 May 2025 10:41:33 -0400 Subject: [PATCH 43/43] Use fresh error codes for cardano errors Signed-off-by: Simon Gellis --- internal/blockchain/cardano/cardano_test.go | 24 ++++++++++----------- internal/coremsgs/en_error_messages.go | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/blockchain/cardano/cardano_test.go b/internal/blockchain/cardano/cardano_test.go index b1a4bbb658..4ca5fc0cf1 100644 --- a/internal/blockchain/cardano/cardano_test.go +++ b/internal/blockchain/cardano/cardano_test.go @@ -222,7 +222,7 @@ func TestStartNamespaceStreamQueryError(t *testing.T) { assert.NoError(t, err) err = c.StartNamespace(c.ctx, "ns1") - assert.Regexp(t, "FF10282.*pop", err) + assert.Regexp(t, "FF10484.*pop", err) } func TestStartNamespaceStreamCreateError(t *testing.T) { @@ -248,7 +248,7 @@ func TestStartNamespaceStreamCreateError(t *testing.T) { assert.NoError(t, err) err = c.StartNamespace(c.ctx, "ns1") - assert.Regexp(t, "FF10282.*pop", err) + assert.Regexp(t, "FF10484.*pop", err) } func TestStartNamespaceStreamUpdateError(t *testing.T) { @@ -274,7 +274,7 @@ func TestStartNamespaceStreamUpdateError(t *testing.T) { assert.NoError(t, err) err = c.StartNamespace(c.ctx, "ns1") - assert.Regexp(t, "FF10282.*pop", err) + assert.Regexp(t, "FF10484.*pop", err) } func TestStartNamespaceWSConnectFail(t *testing.T) { @@ -359,7 +359,7 @@ func TestVerifyCardanoAddress(t *testing.T) { assert.Regexp(t, "FF10354", err) _, err = c.ResolveSigningKey(context.Background(), "baddr1cafed00d", blockchain.ResolveKeyIntentSign) - assert.Regexp(t, "FF10140", err) + assert.Regexp(t, "FF10483", err) key, err := c.ResolveSigningKey(context.Background(), "addr1qx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgse35a3x", blockchain.ResolveKeyIntentSign) assert.NoError(t, err) @@ -868,7 +868,7 @@ func TestDeleteContractListenerFail(t *testing.T) { httpmock.NewStringResponder(500, "oops")) err := c.DeleteContractListener(context.Background(), sub, true) - assert.Regexp(t, "FF10282", err) + assert.Regexp(t, "FF10484", err) } func TestGetContractListenerStatus(t *testing.T) { @@ -917,7 +917,7 @@ func TestGetContractListenerErrorNotFound(t *testing.T) { httpmock.NewStringResponder(404, "no")) _, _, _, err := c.GetContractListenerStatus(context.Background(), "ns1", "sb-1", false) - assert.Regexp(t, "FF10282", err) + assert.Regexp(t, "FF10484", err) } func TestGetTransactionStatusSuccess(t *testing.T) { @@ -1062,7 +1062,7 @@ func TestGetTransactionStatusError(t *testing.T) { httpmock.NewStringResponder(500, "uh oh")) _, err := c.GetTransactionStatus(context.Background(), op) - assert.Regexp(t, "FF10282", err) + assert.Regexp(t, "FF10484", err) } func TestGetTransactionStatusHandleReceipt(t *testing.T) { @@ -1226,7 +1226,7 @@ func TestAddFireflySubscriptionListError(t *testing.T) { ns := &core.Namespace{Name: "ns1", NetworkName: "ns1"} _, err = c.AddFireflySubscription(c.ctx, ns, contract, "") - assert.Regexp(t, "FF10282", err) + assert.Regexp(t, "FF10484", err) } func TestAddFireflySubscriptionAlreadyExists(t *testing.T) { @@ -1326,7 +1326,7 @@ func TestAddFireflySubscriptionCreateError(t *testing.T) { ns := &core.Namespace{Name: "ns1", NetworkName: "ns1"} _, err = c.AddFireflySubscription(c.ctx, ns, contract, "") - assert.Regexp(t, "FF10282", err) + assert.Regexp(t, "FF10484", err) } func TestInvokeContractOK(t *testing.T) { @@ -1456,7 +1456,7 @@ func TestInvokeContractConnectorError(t *testing.T) { rejected, err := c.InvokeContract(context.Background(), "opId", signingKey, fftypes.JSONAnyPtrBytes(locationBytes), parsedMethod, params, options, nil) assert.True(t, rejected) - assert.Regexp(t, "FF10282", err) + assert.Regexp(t, "FF10484", err) } func TestQueryContractOK(t *testing.T) { @@ -1587,7 +1587,7 @@ func TestQueryContractConnectorError(t *testing.T) { assert.NoError(t, err) _, err = c.QueryContract(context.Background(), signingKey, fftypes.JSONAnyPtrBytes(locationBytes), parsedMethod, params, options) - assert.Regexp(t, "FF10282", err) + assert.Regexp(t, "FF10484", err) } func TestQueryContractInvalidJson(t *testing.T) { @@ -1653,7 +1653,7 @@ func TestDeployContractConnectorError(t *testing.T) { rejected, err := c.DeployContract(context.Background(), nsOpId, signingKey, definition, contract, nil, nil) assert.True(t, rejected) - assert.Regexp(t, "FF10282", err) + assert.Regexp(t, "FF10484", err) } func TestGetFFIParamValidator(t *testing.T) { diff --git a/internal/coremsgs/en_error_messages.go b/internal/coremsgs/en_error_messages.go index 03034c60f4..dd37050db8 100644 --- a/internal/coremsgs/en_error_messages.go +++ b/internal/coremsgs/en_error_messages.go @@ -59,7 +59,6 @@ var ( MsgSerializationFailed = ffe("FF10137", "Serialization failed") MsgMissingPluginConfig = ffe("FF10138", "Missing configuration '%s' for %s") MsgMissingDataHashIndex = ffe("FF10139", "Missing data hash for index '%d' in message", 400) - MsgInvalidCardanoAddress = ffe("FF10140", "Supplied cardano address is invalid", 400) MsgInvalidEthAddress = ffe("FF10141", "Supplied ethereum address is invalid", 400) MsgInvalidTezosAddress = ffe("FF10142", "Supplied tezos address is invalid", 400) Msg404NoResult = ffe("FF10143", "No result found", 404) @@ -147,7 +146,6 @@ var ( MsgAuthorOrgSigningKeyMismatch = ffe("FF10279", "Author organization '%s' is not associated with signing key '%s'") MsgCannotTransferToSelf = ffe("FF10280", "From and to addresses must be different", 400) MsgLocalOrgNotSet = ffe("FF10281", "Unable to resolve the local root org. Please ensure org.name is configured", 500) - MsgCardanoconnectRESTErr = ffe("FF10282", "Error from cardano connector: %s") MsgTezosconnectRESTErr = ffe("FF10283", "Error from tezos connector: %s") MsgFabconnectRESTErr = ffe("FF10284", "Error from fabconnect: %s") MsgInvalidIdentity = ffe("FF10285", "Supplied Fabric signer identity is invalid", 400) @@ -323,4 +321,6 @@ var ( MsgInvalidIdentityPatch = ffe("FF10480", "A profile must be provided when updating an identity", 400) MsgNodeNotProvidedForCheck = ffe("FF10481", "Node not provided for check", 500) MsgNodeMissingProfile = ffe("FF10482", "Node provided for check does not have a profile", 500) + MsgInvalidCardanoAddress = ffe("FF10483", "Supplied cardano address is invalid", 400) + MsgCardanoconnectRESTErr = ffe("FF10484", "Error from cardano connector: %s") )