diff --git a/doc-site/docs/reference/config.md b/doc-site/docs/reference/config.md index f34c16c81f..e38fc9fb71 100644 --- a/doc-site/docs/reference/config.md +++ b/doc-site/docs/reference/config.md @@ -636,6 +636,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 e56fed9a17..d9f1517f80 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 @@ -35325,6 +35332,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -35394,6 +35402,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -35436,6 +35445,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -35455,6 +35465,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -35786,6 +35797,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -35914,6 +35926,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -37919,6 +37932,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -44409,6 +44423,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -44471,6 +44486,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -44506,6 +44522,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id @@ -44525,6 +44542,7 @@ paths: type: description: The type of the verifier enum: + - cardano_address - ethereum_address - tezos_address - fabric_msp_id diff --git a/doc-site/docs/tutorials/chains/cardano.md b/doc-site/docs/tutorials/chains/cardano.md new file mode 100644 index 0000000000..1e2ccb5fa3 --- /dev/null +++ b/doc-site/docs/tutorials/chains/cardano.md @@ -0,0 +1,86 @@ +--- +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; using a local Cardano node, or a remote Blockfrost address. + +### 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`. + - Connect to the local Cardano node, which is running in the [preview network](https://preview.cexplorer.io/). + +```sh +ff init cardano dev \ + --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` + - Use the given blockfrost key for the preview network. + +```sh +ff init cardano dev \ + --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. 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..f5d60d7c4d --- /dev/null +++ b/doc-site/docs/tutorials/custom_contracts/cardano.md @@ -0,0 +1,542 @@ +--- +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, 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. + +### 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.4.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, Params, Worker, WorkerResult, +}; +use firefly_balius::{ + balius_sdk::{self, Json}, + kv, CoinSelectionInput, FinalizationCondition, NewMonitoredTx, 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, 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 { + // 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. diff --git a/internal/blockchain/bifactory/factory.go b/internal/blockchain/bifactory/factory.go index 7171210be7..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 // @@ -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..14d8e46d21 --- /dev/null +++ b/internal/blockchain/cardano/cardano.go @@ -0,0 +1,694 @@ +// Copyright © 2025 IOG Singapore and SundaeSwap, 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" + "fmt" + "regexp" + "strings" + + "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" +) + +type Cardano struct { + ctx context.Context + cancelCtx context.CancelFunc + pluginTopic string + metrics metrics.Manager + capabilities *blockchain.Capabilities + callbacks common.BlockchainCallbacks + client *resty.Client + streams *streamManager + streamIDs map[string]string + closed map[string]chan struct{} + wsconns map[string]wsclient.WSClient + wsConfig *wsclient.WSConfig + cardanoconnectConf config.Section + subs common.FireflySubscriptions +} + +type ffiMethodAndErrors struct { + method *fftypes.FFIMethod + errors []*fftypes.FFIError +} + +type cardanoWSCommandPayload struct { + Type string `json:"type"` + Topic string `json:"topic,omitempty"` + BatchNumber int64 `json:"batchNumber,omitempty"` + Message string `json:"message,omitempty"` +} + +type Location struct { + Address string `json:"address"` +} + +var addressVerify = regexp.MustCompile("^addr1|^addr_test1|^stake1|^stake_test1") + +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() + + if cardanoconnectConf.GetString(ffresty.HTTPConfigURL) == "" { + return i18n.NewError(ctx, coremsgs.MsgMissingPluginConfig, "url", cardanoconnectConf) + } + + c.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 c.wsConfig.WSKeyPath == "" { + c.wsConfig.WSKeyPath = "/ws" + } + + 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), 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 + } + + // 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 + + err = c.wsconns[namespace].Connect() + if err != nil { + return err + } + + c.closed[namespace] = make(chan struct{}) + + go c.eventLoop(namespace) + + return nil +} + +func (c *Cardano) StopNamespace(ctx context.Context, namespace string) (err error) { + wsconn, ok := c.wsconns[namespace] + if ok { + wsconn.Close() + } + delete(c.wsconns, namespace) + delete(c.streamIDs, namespace) + delete(c.closed, namespace) + + 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) Capabilities() *blockchain.Capabilities { + return c.capabilities +} + +func (c *Cardano) AddFireflySubscription(ctx context.Context, namespace *core.Namespace, contract *blockchain.MultipartyContract, lastProtocolID string) (string, error) { + 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.streamIDs[namespace.Name]) + 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) { + 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 { + 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 { + 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) { + 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 { + // 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) { + cardanoLocation, err := c.parseContractLocation(ctx, location) + if err != nil { + return true, err + } + + methodInfo, ok := parsedMethod.(*ffiMethodAndErrors) + if !ok || methodInfo.method == nil || methodInfo.method.Name == "" { + 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) { + 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) { + 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) { + 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) { + 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) 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(_ 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) { + 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 + } + + filters := make([]filter, len(listener.Filters)) + for _, f := range listener.Filters { + location, err := c.parseContractLocation(ctx, f.Location) + if err != nil { + return err + } + signature, _ := c.GenerateEventSignature(ctx, &f.Event.FFIEventDefinition) + filters = append(filters, filter{ + eventfilter{ + Contract: location.Address, + EventPath: signature, + }, + }) + } + + result, err := c.streams.createListener(ctx, c.streamIDs[namespace], subName, firstEvent, filters) + if result != nil { + 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.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.streamIDs[namespace], subID, okNotFound) + 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) { + // 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, error) { + 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) { + 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 { + 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) { + 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) + } + + return statusResponse, nil +} + +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 == "" { + return nil, nil, i18n.NewError(ctx, coremsgs.MsgUnexpectedInterfaceType, parsedMethod) + } + return methodInfo.method, methodInfo.errors, nil +} + +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 { + select { + case <-ctx.Done(): + l.Debugf("Event loop exiting (context cancelled)") + return + case msgBytes, ok := <-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{}: + err = c.handleMessageBatch(ctx, namespace, 0, msgTyped) + if err == nil { + ack, _ := json.Marshal(&cardanoWSCommandPayload{ + Type: "ack", + Topic: topic, + }) + err = wsconn.Send(ctx, ack) + } + case map[string]interface{}: + 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 + err = c.handleMessageBatch(ctx, namespace, (int64)(batchNumber), events) + // Errors processing messages are converted into nacks + ackOrNack := &cardanoWSCommandPayload{ + Topic: topic, + 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 = wsconn.Send(ctx, b) + } + } else { + l.Errorf("Message unexpected: %+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 (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{}) + if !ok { + log.L(ctx).Errorf("Message cannot be parsed as JSON: %+v", msgI) + return nil + } + msgJSON := fftypes.JSONObject(msgMap) + + switch msgJSON.GetString("type") { + case "ContractEvent": + signature := msgJSON.GetString("signature") + + logger := log.L(ctx) + logger.Infof("[%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.AddReceiptToBatch(ctx, namespace, c, &receipt, &updates) + if err != nil { + log.L(ctx).Errorf("Failed to process receipt: %+v", msgMap) + } + default: + log.L(ctx).Errorf("Unexpected message in batch: %+v", msgMap) + } + + } + + 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) +} + +func (c *Cardano) processContractEvent(ctx context.Context, namespace string, events common.EventsToDispatch, msgJSON fftypes.JSONObject) { + listenerID := msgJSON.GetString("listenerId") + event := c.parseBlockchainEvent(ctx, msgJSON) + if event != nil { + c.callbacks.PrepareBlockchainEvent(ctx, events, namespace, &blockchain.EventForListener{ + Event: event, + ListenerID: listenerID, + }) + } +} + +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: 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..b1a4bbb658 --- /dev/null +++ b/internal/blockchain/cardano/cardano_test.go @@ -0,0 +1,1858 @@ +// Copyright © 2025 IOG Singapore and SundaeSwap, 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()) + r := resty.New().SetBaseURL("http://localhost:12345") + c := &Cardano{ + ctx: ctx, + cancelCtx: cancel, + callbacks: common.NewBlockchainCallbacks(), + client: r, + pluginTopic: "topic1", + 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 + for _, cls := range c.closed { + <-cls + } + } + } +} + +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 TestInitAndStartWithCardanoConnect(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()) + + err = c.StartNamespace(c.ctx, "ns1") + assert.NoError(t, err) + + assert.Equal(t, 2, httpmock.GetTotalCallCount()) + assert.Equal(t, "es12345", c.streamIDs["ns1"]) + assert.NotNil(t, c.Capabilities()) + + startupMessage := <-toServer + 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/ns1"}`, reply) + 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` + fromServer <- `{"not": "a reply"}` + fromServer <- `42` +} + +func TestStartNamespaceWSFail(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.NoError(t, err) + + err = c.StartNamespace(c.ctx, "ns1") + assert.Regexp(t, "FF00149", err) +} + +func TestStartNamespaceStreamQueryError(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.NoError(t, err) + + err = c.StartNamespace(c.ctx, "ns1") + assert.Regexp(t, "FF10282.*pop", err) +} + +func TestStartNamespaceStreamCreateError(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.NoError(t, err) + + err = c.StartNamespace(c.ctx, "ns1") + assert.Regexp(t, "FF10282.*pop", err) +} + +func TestStartNamespaceStreamUpdateError(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/ns1"}})) + 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.NoError(t, err) + + err = c.StartNamespace(c.ctx, "ns1") + assert.Regexp(t, "FF10282.*pop", err) +} + +func TestStartNamespaceWSConnectFail(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + 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) +} + +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 := &wsmocks.WSClient{} + c.wsconns["ns1"] = wsm + wsm.On("Receive").Return(r) + wsm.On("Close").Return() + c.closed["ns1"] = make(chan struct{}) + c.eventLoop("ns1") + wsm.AssertExpectations(t) +} + +func TestEventLoopReceiveClosed(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + r := make(chan []byte) + wsm := &wsmocks.WSClient{} + c.wsconns["ns1"] = wsm + close(r) + wsm.On("Receive").Return((<-chan []byte)(r)) + wsm.On("Close").Return() + c.closed["ns1"] = make(chan struct{}) + c.eventLoop("ns1") + wsm.AssertExpectations(t) +} + +func TestEventLoopSendFailed(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + r := make(chan []byte) + 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["ns1"] = make(chan struct{}) + + go c.eventLoop("ns1") + r <- []byte(`{"batchNumber":9001,"events":["none"]}`) + <-c.closed["ns1"] +} + +func TestEventLoopBadMessage(t *testing.T) { + c, cancel := newTestCardano() + defer cancel() + + r := make(chan []byte) + wsm := &wsmocks.WSClient{} + c.wsconns["ns1"] = wsm + wsm.On("Receive").Return((<-chan []byte)(r)) + wsm.On("Close").Return() + c.closed["ns1"] = make(chan struct{}) + + go c.eventLoop("ns1") + 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 := &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{}) + + client := resty.NewWithClient(mockedClient) + client.SetBaseURL("http://localhost:12345") + c.streams = &streamManager{ + client: client, + batchSize: 500, + batchTimeout: 10000, + } + + data := []byte(`{ + "batchNumber": 1337, + "events": [ + { + "type": "ContractEvent", + "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("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("ns1") + + r <- data + 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(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 := &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{}) + + data := []byte(`{ + "batchNumber": 1337, + "events": [ + { + "type": "ContractEvent", + "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("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("ns1") + + r <- data + 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(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() + + 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{}) + + go c.eventLoop("ns1") + + r <- []byte(`{ + "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" + } + ] + }`) + 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(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("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") + + r <- []byte(`{ + "batchNumber": 1339, + "events": [ + { + "type": "Nonsense" + }, + { + "type": "Receipt", + "headers": { + "requestId": "ns1:1234" + } + }, + { + "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, "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() + + err := c.SubmitBatchPin(c.ctx, "", "", "", nil, nil) + assert.Regexp(t, "FF10429", err) +} + +func TestAddContractListener(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{ + 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 TestAddContractListenerNoFilter(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{}, + 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{ + 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.streamIDs["ns1"] = "es-1" + + sub := &core.ContractListener{ + Namespace: "ns1", + 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.streamIDs["ns1"] = "es-1" + + sub := &core.ContractListener{ + Namespace: "ns1", + 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.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"})) + + 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.streamIDs["ns1"] = "es-1" + + 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.streamIDs["ns1"] = "es-1" + + 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) + }) + + status, err := c.GetTransactionStatus(context.Background(), op) + assert.NoError(t, err) + assert.NotNil(t, status) +} + +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) + }) + + status, err := c.GetTransactionStatus(context.Background(), op) + assert.NoError(t, err) + assert.NotNil(t, status) +} + +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 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() + + 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, "FF10429", 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.streamIDs["ns1"] = "es12345" + + 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: "topic1/ns1"}})) + httpmock.RegisterResponder("PATCH", fmt.Sprintf("%s/eventstreams/es12345", httpURL), + 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), + 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) + err = c.StartNamespace(c.ctx, "ns1") + assert.NoError(t, err) + startupMessage := <-toServer + assert.Equal(t, `{"type":"listen","topic":"topic1/ns1"}`, 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: "topic1/ns1"}})) + httpmock.RegisterResponder("PATCH", fmt.Sprintf("%s/eventstreams/es12345", httpURL), + httpmock.NewJsonResponderOrPanic(200, eventStream{ID: "es12345", Name: "topic1/ns1"})) + 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) + err = c.StartNamespace(c.ctx, "ns1") + assert.NoError(t, err) + startupMessage := <-toServer + assert.Equal(t, `{"type":"listen","topic":"topic1/ns1"}`, 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: "topic1/ns1"}})) + httpmock.RegisterResponder("PATCH", fmt.Sprintf("%s/eventstreams/es12345", httpURL), + 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"}})) + + 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") + startupMessage := <-toServer + assert.Equal(t, `{"type":"listen","topic":"topic1/ns1"}`, 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: "topic1/ns1"}})) + httpmock.RegisterResponder("PATCH", fmt.Sprintf("%s/eventstreams/es12345", httpURL), + 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), + 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) + err = c.StartNamespace(c.ctx, "ns1") + assert.NoError(t, err) + startupMessage := <-toServer + assert.Equal(t, `{"type":"listen","topic":"topic1/ns1"}`, 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 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) + + 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) { + 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() + 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) { + 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/config.go b/internal/blockchain/cardano/config.go new file mode 100644 index 0000000000..797efc20ec --- /dev/null +++ b/internal/blockchain/cardano/config.go @@ -0,0 +1,47 @@ +// Copyright © 2025 IOG Singapore and SundaeSwap, 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 ( + 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..5a27011eb0 --- /dev/null +++ b/internal/blockchain/cardano/eventstream.go @@ -0,0 +1,213 @@ +// Copyright © 2025 IOG Singapore and SundaeSwap, 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" + "fmt" + + "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" +) + +type streamManager struct { + client *resty.Client + batchSize uint + batchTimeout int64 +} + +type eventStream struct { + ID string `json:"id"` + Name string `json:"name"` + ErrorHandling string `json:"errorHandling"` + BatchSize uint `json:"batchSize"` + BatchTimeoutMS int64 `json:"batchTimeoutMS"` + Type string `json:"type"` + 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 uint, batchTimeout int64) *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 uint, batchTimeout int64) *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 uint, batchTimeout int64, 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) +} + +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) 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": filters, + } + + 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 +} + +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/blockchain/common/common.go b/internal/blockchain/common/common.go index 0393b45b74..b465da9758 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 @@ -450,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{ diff --git a/internal/blockchain/ethereum/ethereum.go b/internal/blockchain/ethereum/ethereum.go index bb3a86ee77..dce31df1e7 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 // @@ -53,11 +53,6 @@ const ( ethTxStatusPending string = "Pending" ) -const ( - ReceiptTransactionSuccess string = "TransactionSuccess" - ReceiptTransactionFailed string = "TransactionFailed" -) - type Ethereum struct { ctx context.Context cancelCtx context.CancelFunc @@ -1210,9 +1205,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 a591b5658f..d641847eb7 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 // @@ -45,11 +45,6 @@ const ( tezosTxStatusPending string = "Pending" ) -const ( - ReceiptTransactionSuccess string = "TransactionSuccess" - ReceiptTransactionFailed string = "TransactionFailed" -) - type Tezos struct { ctx context.Context cancelCtx context.CancelFunc @@ -577,9 +572,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 diff --git a/internal/coremsgs/en_config_descriptions.go b/internal/coremsgs/en_config_descriptions.go index 3995499d93..67c0e07eb5 100644 --- a/internal/coremsgs/en_config_descriptions.go +++ b/internal/coremsgs/en_config_descriptions.go @@ -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) diff --git a/internal/coremsgs/en_error_messages.go b/internal/coremsgs/en_error_messages.go index 78243541a2..03034c60f4 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..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 // @@ -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/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()), diff --git a/manifest.json b/manifest.json index ce32e9f3b9..68ff26f9e5 100644 --- a/manifest.json +++ b/manifest.json @@ -1,4 +1,14 @@ { + "cardanoconnect": { + "image": "ghcr.io/hyperledger/firefly-cardanoconnect", + "tag": "v0.4.1", + "sha": "78b1008bd62892f6eda197b5047d94e61621d0f06b299422ff8ed9b34ee5ce50" + }, + "cardanosigner": { + "image": "ghcr.io/hyperledger/firefly-cardanosigner", + "tag": "v0.4.1", + "sha": "d0b76613ccc70ff63e68b137766eb009d589489631cee6aabf2b45e33a1ca5d3" + }, "ethconnect": { "image": "ghcr.io/hyperledger/firefly-ethconnect", "tag": "v3.3.3", @@ -56,7 +66,7 @@ }, "ui": { "tag": "v1.3.1", - "release": "v1.3.1" + "release": "v1.3.1" }, "cli": { "tag": "v1.3.3-rc.1" 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" diff --git a/pkg/core/verifier.go b/pkg/core/verifier.go index 0a589474f9..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 // @@ -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