diff --git a/examples/nft-fee-transaction/.gitignore b/examples/nft-fee-transaction/.gitignore new file mode 100755 index 0000000..10ef86e --- /dev/null +++ b/examples/nft-fee-transaction/.gitignore @@ -0,0 +1,37 @@ +project/boot +target +.ensime +.ensime_lucene +.ensime_cache +.ensime_snapshot +ensime.sbt +TAGS +\#*# +*~ +.#* +.lib +.history +.*.swp +.idea +.idea/* +.idea_modules +.run/ +out/ +.vscode/ +.DS_Store +.sbtrc +*.sublime-project +*.sublime-workspace +tests.iml +# Auto-copied by sbt-microsites +docs/src/main/tut/contributing.md +.ignore +result* +metals.sbt +.bloop/ +.metals/ +site/ +.bsp/ +/logs/ +.envrc +.direnv/ \ No newline at end of file diff --git a/examples/nft-fee-transaction/README.md b/examples/nft-fee-transaction/README.md new file mode 100755 index 0000000..a49af2f --- /dev/null +++ b/examples/nft-fee-transaction/README.md @@ -0,0 +1,134 @@ +# Example NFT Metagraph using the Data API and Fee Transactions + +This example builds on the [NFT Metagraph example](https://github.com/Constellation-Labs/metagraph-examples/tree/main/examples/nft). To fully understand the flow and mechanics, please refer to the README in the original [NFT Metagraph example](https://github.com/Constellation-Labs/metagraph-examples/tree/main/examples/nft). + +## Fee Transactions + +This example introduces the new FeeTransaction feature, allowing you to assign fees per transaction type. It adds two new lifecycle functions: estimateFee and validateFee. These functions are optional but necessary if you want to enable fee transactions in your metagraph. + +### Estimating Fees + +The estimateFee endpoint allows users to check the required fees before sending an update. +For example, when minting a new collection, you can send a `POST` request to `/data/estimate-fee` with the following body: +```json +{ + "MintCollection": { + "name": "MyCollection" + } +} +``` +The response will look something like this: +```json +{ + "fee": 10000, + "address": "DAG88C9WDSKH451sisyEP3hAkgCKn5DN72fuwjfQ", + "updateHash": "2782eace743eadafcb36e9c3aadab598dd5aae58de50a81f5b193c517acca763" +} +``` +Here's what each field represents: + +- **fee**: The fee amount required for the transaction. + +- **address**: The destination address where the fees should be sent. + +- **updateHash**: The hash of the update, which you need to reference in the dataUpdateRef field of the fee transaction (explained in the next section). + +The response is generated using this function as base: +```scala +override def estimateFee(gsOrdinal: SnapshotOrdinal)(update: NFTUpdate)(implicit context: L1NodeContext[IO], A: Applicative[IO]): IO[EstimatedFee] = { + update match { + case _: MintCollection => IO.pure(EstimatedFee(Amount(NonNegLong(10000)), Address("DAG88C9WDSKH451sisyEP3hAkgCKn5DN72fuwjfQ"))) + case _: MintNFT => IO.pure(EstimatedFee(Amount(NonNegLong(110000)), Address("DAG88C9WDSKH451sisyEP3hAkgCKn5DN72fuwjfQ"))) + case _: TransferCollection => IO.pure(EstimatedFee(Amount(NonNegLong(120000)), Address("DAG88C9WDSKH451sisyEP3hAkgCKn5DN72fuwjfQ"))) + case _: TransferNFT => IO.pure(EstimatedFee(Amount(NonNegLong(130000)), Address("DAG88C9WDSKH451sisyEP3hAkgCKn5DN72fuwjfQ"))) + } +} +``` + +### Fee Validation + +The validateFee function checks if a fee transaction is provided for the data update. You can decide if a fee transaction is required for each update type. Here’s how it works: +```scala +override def validateFee( + gsOrdinal: SnapshotOrdinal +)(dataUpdate: Signed[NFTUpdate], maybeFeeTransaction: Option[Signed[FeeTransaction]])( + implicit context: L0NodeContext[IO], A: Applicative[IO] +): IO[DataApplicationValidationErrorOr[Unit]] = { + maybeFeeTransaction match { + case Some(feeTransaction) => + dataUpdate.value match { + case _: MintCollection => + if (feeTransaction.value.amount.value.value < 10000) + NotEnoughFee.invalidNec[Unit].pure[IO] + else + ().validNec[DataApplicationValidationError].pure[IO] + case _ => + ().validNec[DataApplicationValidationError].pure[IO] + } + case None => + MissingFeeTransaction.invalidNec[Unit].pure[IO] + } +} +``` + +In this function: + +- For the MintCollection update, if the fee is less than 10000 tokens, the transaction will be rejected. + +- For other updates, such as MintNFT, TransferNFT, and TransferCollection, fees are optional, so the transaction may be accepted without a fee transaction. + +This ensures flexible fee validation based on the type of update being processed. + +### Sending a Fee Transaction + +After estimating the fee, you can submit the update along with the corresponding fee transaction. Here’s an example of the body to send: +```json +{ + "data": { + "value": { + "MintCollection": { + "name": "MyCollection" + } + }, + "proofs": [ + { + "id": "db2faf200159ca3c47924bf5f3bda4f45d681a39f9490053ecf98d788122f7a7973693570bd242e10ab670748e86139847eb682a53c7c5c711b832517ce34860", + "signature": ":data_signature" + } + ] + }, + "fee": { + "value": { + "source": "DAG6t89ps7G8bfS2WuTcNUAy9Pg8xWqiEHjrrLAZ", + "destination": "DAG88C9WDSKH451sisyEP3hAkgCKn5DN72fuwjfQ", + "amount": 10000, + "dataUpdateRef": "2782eace743eadafcb36e9c3aadab598dd5aae58de50a81f5b193c517acca763" + }, + "proofs": [ + { + "id": "db2faf200159ca3c47924bf5f3bda4f45d681a39f9490053ecf98d788122f7a7973693570bd242e10ab670748e86139847eb682a53c7c5c711b832517ce34860", + "signature": ":fee_transaction_signature" + } + ] + } +} +``` +In this JSON: + +- **destination**, **amount**, and **dataUpdateRef** are filled in with values returned from the estimateFee endpoint. + +- The **source** field must match the wallet that signed the transaction; otherwise, validation will fail. + +This schema introduces fee transactions, but if you don't want to use them, you can simply send the data part of the schema. Fee transactions are optional, and backward compatibility is maintained with older schemas. + +A successful response will look like this: +```json +{ + "feeHash": "1cfe66f2590e1838ae3ae59ed2f22465b1b36839707b177392be61efe6f2e682", + "hash": "2782eace743eadafcb36e9c3aadab598dd5aae58de50a81f5b193c517acca763" +} +``` +In this example, we’re minting a new collection called `MyCollection` and paying a fee of `10000` tokens to the destination address `DAG88C9WDSKH451sisyEP3hAkgCKn5DN72fuwjfQ`. + +You can find the scripts directory where you can send the transactions with fees to your metagraph. Please take a look at the file: +`scripts/send_data_transaction.js` \ No newline at end of file diff --git a/examples/nft-fee-transaction/build.sbt b/examples/nft-fee-transaction/build.sbt new file mode 100755 index 0000000..982ccc4 --- /dev/null +++ b/examples/nft-fee-transaction/build.sbt @@ -0,0 +1,106 @@ +import Dependencies.* +import sbt.* + +ThisBuild / organization := "com.my.nft" +ThisBuild / scalaVersion := "2.13.18" +ThisBuild / evictionErrorLevel := Level.Warn + +ThisBuild / assemblyMergeStrategy := { + case "logback.xml" => MergeStrategy.first + case x if x.contains("io.netty.versions.properties") => MergeStrategy.discard + case PathList(xs@_*) if xs.last == "module-info.class" => MergeStrategy.first + case x if x.contains("rally-version.properties") => MergeStrategy.concat + case x if x.contains("OSGI-INF/MANIFEST.MF") => MergeStrategy.first + case x => + val oldStrategy = (assembly / assemblyMergeStrategy).value + oldStrategy(x) +} + +lazy val root = (project in file(".")). + settings( + name := "nft_fee_transactions" + ).aggregate(sharedData, currencyL0, currencyL1, dataL1) + +lazy val sharedData = (project in file("modules/shared_data")) + .enablePlugins(AshScriptPlugin) + .enablePlugins(BuildInfoPlugin) + .enablePlugins(JavaAppPackaging) + .settings( + name := "nft_fee_transactions-shared_data", + scalacOptions ++= List("-Ymacro-annotations", "-Yrangepos", "-Wconf:cat=unused:info", "-language:reflectiveCalls"), + buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion), + buildInfoPackage := "com.my.nft.shared_data", + resolvers += Resolver.mavenLocal, + resolvers += Resolver.githubPackages("abankowski", "http-request-signer"), + Defaults.itSettings, + libraryDependencies ++= Seq( + CompilerPlugin.kindProjector, + CompilerPlugin.betterMonadicFor, + CompilerPlugin.semanticDB, + Libraries.tessellationSdk + ) + ) +lazy val currencyL1 = (project in file("modules/l1")) + .enablePlugins(AshScriptPlugin) + .enablePlugins(BuildInfoPlugin) + .enablePlugins(JavaAppPackaging) + .settings( + name := "nft_fee_transactions-currency-l1", + scalacOptions ++= List("-Ymacro-annotations", "-Yrangepos", "-Wconf:cat=unused:info", "-language:reflectiveCalls"), + buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion), + buildInfoPackage := "com.my.nft.l1", + resolvers += Resolver.mavenLocal, + resolvers += Resolver.githubPackages("abankowski", "http-request-signer"), + Defaults.itSettings, + libraryDependencies ++= Seq( + CompilerPlugin.kindProjector, + CompilerPlugin.betterMonadicFor, + CompilerPlugin.semanticDB, + Libraries.tessellationSdk + ) + ) + +lazy val currencyL0 = (project in file("modules/l0")) + .enablePlugins(AshScriptPlugin) + .enablePlugins(BuildInfoPlugin) + .enablePlugins(JavaAppPackaging) + .dependsOn(sharedData) + .settings( + name := "nft_fee_transactions-currency-l0", + scalacOptions ++= List("-Ymacro-annotations", "-Yrangepos", "-Wconf:cat=unused:info", "-language:reflectiveCalls"), + buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion), + buildInfoPackage := "com.my.nft.l0", + resolvers += Resolver.mavenLocal, + resolvers += Resolver.githubPackages("abankowski", "http-request-signer"), + Defaults.itSettings, + libraryDependencies ++= Seq( + CompilerPlugin.kindProjector, + CompilerPlugin.betterMonadicFor, + CompilerPlugin.semanticDB, + Libraries.declineRefined, + Libraries.declineCore, + Libraries.declineEffect, + Libraries.tessellationSdk + ) + ) + +lazy val dataL1 = (project in file("modules/data_l1")) + .enablePlugins(AshScriptPlugin) + .enablePlugins(BuildInfoPlugin) + .enablePlugins(JavaAppPackaging) + .dependsOn(sharedData) + .settings( + name := "nft_fee_transactions-data_l1", + scalacOptions ++= List("-Ymacro-annotations", "-Yrangepos", "-Wconf:cat=unused:info", "-language:reflectiveCalls"), + buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion), + buildInfoPackage := "com.my.nft.data_l1", + resolvers += Resolver.mavenLocal, + resolvers += Resolver.githubPackages("abankowski", "http-request-signer"), + Defaults.itSettings, + libraryDependencies ++= Seq( + CompilerPlugin.kindProjector, + CompilerPlugin.betterMonadicFor, + CompilerPlugin.semanticDB, + Libraries.tessellationSdk + ) + ) diff --git a/examples/nft-fee-transaction/modules/data_l1/src/main/scala/com/my/nft_fee_transactions/data_l1/Main.scala b/examples/nft-fee-transaction/modules/data_l1/src/main/scala/com/my/nft_fee_transactions/data_l1/Main.scala new file mode 100755 index 0000000..73ad048 --- /dev/null +++ b/examples/nft-fee-transaction/modules/data_l1/src/main/scala/com/my/nft_fee_transactions/data_l1/Main.scala @@ -0,0 +1,172 @@ +package com.my.nft_fee_transactions.data_l1 + +import cats.Applicative +import cats.effect.{IO, Resource} +import cats.syntax.all._ +import cats.syntax.option.catsSyntaxOptionId +import com.my.nft_fee_transactions.shared_data.types.Types._ +import com.my.nft_fee_transactions.shared_data.LifecycleSharedFunctions +import com.my.nft_fee_transactions.shared_data.deserializers.Deserializers +import com.my.nft_fee_transactions.shared_data.serializers.Serializers +import eu.timepit.refined.auto._ +import eu.timepit.refined.types.numeric.NonNegLong +import io.circe.{Decoder, Encoder} +import org.http4s._ +import org.http4s.circe.CirceEntityCodec.circeEntityDecoder +import io.constellationnetwork.BuildInfo +import io.constellationnetwork.currency.dataApplication.Errors.{MissingFeeTransaction, NotEnoughFee} +import io.constellationnetwork.currency.dataApplication._ +import io.constellationnetwork.currency.dataApplication.dataApplication._ +import io.constellationnetwork.currency.l1.CurrencyL1App +import io.constellationnetwork.currency.schema.EstimatedFee +import io.constellationnetwork.ext.cats.effect.ResourceIO +import io.constellationnetwork.schema.SnapshotOrdinal +import io.constellationnetwork.schema.address.Address +import io.constellationnetwork.schema.balance.Amount +import io.constellationnetwork.schema.cluster.ClusterId +import io.constellationnetwork.schema.semver.{MetagraphVersion, TessellationVersion} +import io.constellationnetwork.security.signature.Signed + +import java.util.UUID + +object Main + extends CurrencyL1App( + "currency-data_l1", + "currency data L1 node", + ClusterId(UUID.fromString("517c3a05-9219-471b-a54c-21b7d72f4ae5")), + metagraphVersion = MetagraphVersion.unsafeFrom(BuildInfo.version), + tessellationVersion = TessellationVersion.unsafeFrom(BuildInfo.version) + ) { + private def makeBaseDataApplicationL1Service: BaseDataApplicationL1Service[IO] = BaseDataApplicationL1Service(new DataApplicationL1Service[IO, NFTUpdate, NFTUpdatesState, NFTUpdatesCalculatedState] { + override def validateUpdate( + update: NFTUpdate + )(implicit context: L1NodeContext[IO]): IO[DataApplicationValidationErrorOr[Unit]] = + LifecycleSharedFunctions.validateUpdate[IO](update) + + override def serializeState( + state: NFTUpdatesState + ): IO[Array[Byte]] = + IO(Serializers.serializeState(state)) + + override def serializeUpdate( + update: NFTUpdate + ): IO[Array[Byte]] = + IO(Serializers.serializeUpdate(update)) + + override def serializeBlock( + block: Signed[DataApplicationBlock] + ): IO[Array[Byte]] = + IO(Serializers.serializeBlock(block)(dataEncoder.asInstanceOf[Encoder[DataUpdate]])) + + override def deserializeState( + bytes: Array[Byte] + ): IO[Either[Throwable, NFTUpdatesState]] = + IO(Deserializers.deserializeState(bytes)) + + override def deserializeUpdate( + bytes: Array[Byte] + ): IO[Either[Throwable, NFTUpdate]] = + IO(Deserializers.deserializeUpdate(bytes)) + + override def deserializeBlock( + bytes: Array[Byte] + ): IO[Either[Throwable, Signed[DataApplicationBlock]]] = + IO(Deserializers.deserializeBlock(bytes)(dataDecoder.asInstanceOf[Decoder[DataUpdate]])) + + override def dataEncoder: Encoder[NFTUpdate] = + implicitly[Encoder[NFTUpdate]] + + override def dataDecoder: Decoder[NFTUpdate] = + implicitly[Decoder[NFTUpdate]] + + override def calculatedStateEncoder: Encoder[NFTUpdatesCalculatedState] = + implicitly[Encoder[NFTUpdatesCalculatedState]] + + override def calculatedStateDecoder: Decoder[NFTUpdatesCalculatedState] = + implicitly[Decoder[NFTUpdatesCalculatedState]] + + override def routes(implicit context: L1NodeContext[IO]): HttpRoutes[IO] = + HttpRoutes.empty + + override def signedDataEntityDecoder: EntityDecoder[IO, Signed[NFTUpdate]] = + circeEntityDecoder + + override def serializeCalculatedState( + state: NFTUpdatesCalculatedState + ): IO[Array[Byte]] = + IO(Serializers.serializeCalculatedState(state)) + + override def deserializeCalculatedState( + bytes: Array[Byte] + ): IO[Either[Throwable, NFTUpdatesCalculatedState]] = + IO(Deserializers.deserializeCalculatedState(bytes)) + + /** + * Estimates the fee for an `NFTUpdate` transaction. + * + * Based on the type of `NFTUpdate`, this method returns a predefined fee and destination address. + * Supported transaction types include: + * - `MintCollection`: 10000 tokens (datum) + * - `MintNFT`: 110000 tokens (datum) + * - `TransferCollection`: 120000 tokens (datum) + * - `TransferNFT`: 130000 tokens (datum) + * + * @param gsOrdinal The current global snapshot ordinal. + * @param update The `NFTUpdate` transaction. + * @param context The node's L1 context. + * @param A Applicative instance for IO. + * @return An `IO` containing the estimated fee and destination address. + */ + override def estimateFee(gsOrdinal: SnapshotOrdinal)(update: NFTUpdate)(implicit context: L1NodeContext[IO], A: Applicative[IO]): IO[EstimatedFee] = { + update match { + case _: MintCollection => IO.pure(EstimatedFee(Amount(NonNegLong(10000)), Address("DAG88C9WDSKH451sisyEP3hAkgCKn5DN72fuwjfQ"))) + case _: MintNFT => IO.pure(EstimatedFee(Amount(NonNegLong(110000)), Address("DAG88C9WDSKH451sisyEP3hAkgCKn5DN72fuwjfQ"))) + case _: TransferCollection => IO.pure(EstimatedFee(Amount(NonNegLong(120000)), Address("DAG88C9WDSKH451sisyEP3hAkgCKn5DN72fuwjfQ"))) + case _: TransferNFT => IO.pure(EstimatedFee(Amount(NonNegLong(130000)), Address("DAG88C9WDSKH451sisyEP3hAkgCKn5DN72fuwjfQ"))) + } + } + + /** + * Validates the fees for the incoming `dataUpdate` and optional `feeTransaction`. + * + * In this template, all `dataUpdate` transactions are accepted without fees, except for `MintCollection`. + * If a `MintCollection` request is sent without a fee, the validation will fail. Similarly, if the + * `feeTransaction` has an amount less than 10000 tokens, the validation will also fail, and the transaction will be rejected. + * + * The error types `NotEnoughFee` and `MissingFeeTransaction` are used to handle these cases, and they are part of the + * Tessellation codebase. + * + * @param gsOrdinal The global snapshot ordinal. + * @param dataUpdate The provided `dataUpdate` to be validated. + * @param maybeFeeTransaction The optional `feeTransaction` associated with the `dataUpdate`. + * + * @return An `IO` containing whether the `dataUpdate` and optional `feeTransaction` are valid. + */ + override def validateFee( + gsOrdinal: SnapshotOrdinal + )(dataUpdate: Signed[NFTUpdate], maybeFeeTransaction: Option[Signed[FeeTransaction]])( + implicit context: L1NodeContext[IO], A: Applicative[IO] + ): IO[DataApplicationValidationErrorOr[Unit]] = { + maybeFeeTransaction match { + case Some(feeTransaction) => + dataUpdate.value match { + case _: MintCollection => + if (feeTransaction.value.amount.value.value < 10000) + NotEnoughFee.invalidNec[Unit].pure[IO] + else + ().validNec[DataApplicationValidationError].pure[IO] + case _ => + ().validNec[DataApplicationValidationError].pure[IO] + } + case None => + MissingFeeTransaction.invalidNec[Unit].pure[IO] + } + } + }) + + private def makeL1Service: IO[BaseDataApplicationL1Service[IO]] = + IO.delay(makeBaseDataApplicationL1Service) + + override def dataApplication: Option[Resource[IO, BaseDataApplicationL1Service[IO]]] = + makeL1Service.asResource.some +} diff --git a/examples/nft-fee-transaction/modules/l0/src/main/scala/com/my/nft_fee_transactions/l0/Main.scala b/examples/nft-fee-transaction/modules/l0/src/main/scala/com/my/nft_fee_transactions/l0/Main.scala new file mode 100755 index 0000000..6a86ac4 --- /dev/null +++ b/examples/nft-fee-transaction/modules/l0/src/main/scala/com/my/nft_fee_transactions/l0/Main.scala @@ -0,0 +1,172 @@ +package com.my.nft_fee_transactions.l0 + +import cats.Applicative +import cats.data.NonEmptyList +import cats.effect.{IO, Resource} +import cats.syntax.all._ +import cats.syntax.applicative.catsSyntaxApplicativeId +import cats.syntax.option.catsSyntaxOptionId +import com.my.nft_fee_transactions.l0.custom_routes.CustomRoutes +import com.my.nft_fee_transactions.shared_data.LifecycleSharedFunctions +import com.my.nft_fee_transactions.shared_data.calculated_state.CalculatedStateService +import com.my.nft_fee_transactions.shared_data.deserializers.Deserializers +import com.my.nft_fee_transactions.shared_data.serializers.Serializers +import com.my.nft_fee_transactions.shared_data.types.Types._ +import io.circe.{Decoder, Encoder} +import org.http4s.circe.CirceEntityCodec.circeEntityDecoder +import org.http4s.{EntityDecoder, HttpRoutes} +import io.constellationnetwork.BuildInfo +import io.constellationnetwork.currency.dataApplication.Errors.{MissingFeeTransaction, NotEnoughFee} +import io.constellationnetwork.currency.dataApplication._ +import io.constellationnetwork.currency.dataApplication.dataApplication._ +import io.constellationnetwork.currency.l0.CurrencyL0App +import io.constellationnetwork.ext.cats.effect.ResourceIO +import io.constellationnetwork.schema.SnapshotOrdinal +import io.constellationnetwork.schema.cluster.ClusterId +import io.constellationnetwork.schema.semver.{MetagraphVersion, TessellationVersion} +import io.constellationnetwork.security.hash.Hash +import io.constellationnetwork.security.signature.Signed + +import java.util.UUID + +object Main + extends CurrencyL0App( + "currency-l0", + "currency L0 node", + ClusterId(UUID.fromString("517c3a05-9219-471b-a54c-21b7d72f4ae5")), + metagraphVersion = MetagraphVersion.unsafeFrom(BuildInfo.version), + tessellationVersion = TessellationVersion.unsafeFrom(BuildInfo.version) + ) { + private def makeBaseDataApplicationL0Service( + calculatedStateService: CalculatedStateService[IO] + ): BaseDataApplicationL0Service[IO] = BaseDataApplicationL0Service(new DataApplicationL0Service[IO, NFTUpdate, NFTUpdatesState, NFTUpdatesCalculatedState] { + override def genesis: DataState[NFTUpdatesState, NFTUpdatesCalculatedState] = + DataState(NFTUpdatesState(List.empty), NFTUpdatesCalculatedState(Map.empty)) + + override def validateData( + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState], + updates: NonEmptyList[Signed[NFTUpdate]] + )(implicit context: L0NodeContext[IO]): IO[DataApplicationValidationErrorOr[Unit]] = + LifecycleSharedFunctions.validateData[IO](state, updates) + + override def combine( + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState], + updates: List[Signed[NFTUpdate]] + )(implicit context: L0NodeContext[IO]): IO[DataState[NFTUpdatesState, NFTUpdatesCalculatedState]] = + LifecycleSharedFunctions.combine[IO](state, updates) + + override def serializeState( + state: NFTUpdatesState + ): IO[Array[Byte]] = + IO(Serializers.serializeState(state)) + + override def serializeUpdate( + update: NFTUpdate + ): IO[Array[Byte]] = + IO(Serializers.serializeUpdate(update)) + + override def serializeBlock( + block: Signed[DataApplicationBlock] + ): IO[Array[Byte]] = IO(Serializers.serializeBlock(block)(dataEncoder.asInstanceOf[Encoder[DataUpdate]])) + + override def deserializeState( + bytes: Array[Byte] + ): IO[Either[Throwable, NFTUpdatesState]] = + IO(Deserializers.deserializeState(bytes)) + + override def deserializeUpdate( + bytes: Array[Byte] + ): IO[Either[Throwable, NFTUpdate]] = + IO(Deserializers.deserializeUpdate(bytes)) + + override def deserializeBlock( + bytes: Array[Byte] + ): IO[Either[Throwable, Signed[DataApplicationBlock]]] = + IO(Deserializers.deserializeBlock(bytes)(dataDecoder.asInstanceOf[Decoder[DataUpdate]])) + + override def dataEncoder: Encoder[NFTUpdate] = + implicitly[Encoder[NFTUpdate]] + + override def dataDecoder: Decoder[NFTUpdate] = + implicitly[Decoder[NFTUpdate]] + + override def calculatedStateEncoder: Encoder[NFTUpdatesCalculatedState] = + implicitly[Encoder[NFTUpdatesCalculatedState]] + + override def calculatedStateDecoder: Decoder[NFTUpdatesCalculatedState] = + implicitly[Decoder[NFTUpdatesCalculatedState]] + + override def routes(implicit context: L0NodeContext[IO]): HttpRoutes[IO] = + CustomRoutes[IO](calculatedStateService).public + + override def signedDataEntityDecoder: EntityDecoder[IO, Signed[NFTUpdate]] = + circeEntityDecoder + + override def getCalculatedState(implicit context: L0NodeContext[IO]): IO[(SnapshotOrdinal, NFTUpdatesCalculatedState)] = + calculatedStateService.getCalculatedState.map(calculatedState => (calculatedState.ordinal, calculatedState.state)) + + override def setCalculatedState( + ordinal: SnapshotOrdinal, + state : NFTUpdatesCalculatedState + )(implicit context: L0NodeContext[IO]): IO[Boolean] = + calculatedStateService.setCalculatedState(ordinal, state) + + override def hashCalculatedState( + state: NFTUpdatesCalculatedState + )(implicit context: L0NodeContext[IO]): IO[Hash] = + calculatedStateService.hashCalculatedState(state) + + override def serializeCalculatedState( + state: NFTUpdatesCalculatedState + ): IO[Array[Byte]] = + IO(Serializers.serializeCalculatedState(state)) + + override def deserializeCalculatedState( + bytes: Array[Byte] + ): IO[Either[Throwable, NFTUpdatesCalculatedState]] = + IO(Deserializers.deserializeCalculatedState(bytes)) + + /** + * Validates the fees for the incoming `dataUpdate` and optional `feeTransaction`. + * + * In this template, all `dataUpdate` transactions are accepted without fees, except for `MintCollection`. + * If a `MintCollection` request is sent without a fee, the validation will fail. Similarly, if the + * `feeTransaction` has an amount less than 10000 tokens, the validation will also fail, and the transaction will be rejected. + * + * The error types `NotEnoughFee` and `MissingFeeTransaction` are used to handle these cases, and they are part of the + * Tessellation codebase. + * + * @param gsOrdinal The global snapshot ordinal. + * @param dataUpdate The provided `dataUpdate` to be validated. + * @param maybeFeeTransaction The optional `feeTransaction` associated with the `dataUpdate`. + * + * @return An `IO` containing whether the `dataUpdate` and optional `feeTransaction` are valid. + */ + override def validateFee( + gsOrdinal: SnapshotOrdinal + )(dataUpdate: Signed[NFTUpdate], maybeFeeTransaction: Option[Signed[FeeTransaction]])( + implicit context: L0NodeContext[IO], A: Applicative[IO] + ): IO[DataApplicationValidationErrorOr[Unit]] = { + maybeFeeTransaction match { + case Some(feeTransaction) => + dataUpdate.value match { + case _: MintCollection => + if (feeTransaction.value.amount.value.value < 10000) + NotEnoughFee.invalidNec[Unit].pure[IO] + else + ().validNec[DataApplicationValidationError].pure[IO] + case _ => + ().validNec[DataApplicationValidationError].pure[IO] + } + case None => + MissingFeeTransaction.invalidNec[Unit].pure[IO] + } + } + }) + + private def makeL0Service: IO[BaseDataApplicationL0Service[IO]] = + CalculatedStateService.make[IO].map(makeBaseDataApplicationL0Service) + + override def dataApplication: Option[Resource[IO, BaseDataApplicationL0Service[IO]]] = + makeL0Service.asResource.some +} diff --git a/examples/nft-fee-transaction/modules/l0/src/main/scala/com/my/nft_fee_transactions/l0/custom_routes/CustomRoutes.scala b/examples/nft-fee-transaction/modules/l0/src/main/scala/com/my/nft_fee_transactions/l0/custom_routes/CustomRoutes.scala new file mode 100755 index 0000000..f4592dd --- /dev/null +++ b/examples/nft-fee-transaction/modules/l0/src/main/scala/com/my/nft_fee_transactions/l0/custom_routes/CustomRoutes.scala @@ -0,0 +1,127 @@ +package com.my.nft_fee_transactions.l0.custom_routes + +import cats.effect.Async +import cats.syntax.flatMap.toFlatMapOps +import cats.syntax.functor.toFunctorOps +import com.my.nft_fee_transactions.shared_data.calculated_state.CalculatedStateService +import com.my.nft_fee_transactions.shared_data.types.Types._ +import eu.timepit.refined.auto._ +import org.http4s._ +import org.http4s.circe.CirceEntityCodec.circeEntityEncoder +import org.http4s.dsl.Http4sDsl +import org.http4s.server.middleware.CORS +import io.constellationnetwork.ext.http4s.AddressVar +import io.constellationnetwork.routes.internal.{InternalUrlPrefix, PublicRoutes} +import io.constellationnetwork.schema.address.Address + +case class CustomRoutes[F[_] : Async](calculatedStateService: CalculatedStateService[F]) extends Http4sDsl[F] with PublicRoutes[F] { + + private def formatToCollectionResponse( + collection: Collection + ): CollectionResponse = + CollectionResponse( + collection.id, + collection.owner, + collection.name, + collection.creationDateTimestamp, + collection.nfts.size.toLong + ) + + private def formatToNFTResponse( + nft: NFT + ): NFTResponse = { + NFTResponse( + nft.id, + nft.collectionId, + nft.owner, nft.uri, + nft.name, + nft.description, + nft.creationDateTimestamp, + nft.metadata + ) + } + + private def getState: F[NFTUpdatesCalculatedState] = + calculatedStateService.getCalculatedState.map(_.state) + + private def getAllCollections: F[Response[F]] = { + getState.flatMap { state => + val allCollectionsResponse = state.collections.map { case (_, collection) => formatToCollectionResponse(collection) }.toList + Ok(allCollectionsResponse) + } + } + + private def getCollectionById( + collectionId: String + ): F[Response[F]] = { + getState.flatMap { state => + state.collections.get(collectionId).map { value => + Ok(formatToCollectionResponse(value)) + }.getOrElse(NotFound()) + } + } + + private def getCollectionNFTs( + collectionId: String + ): F[Response[F]] = { + getState.flatMap { state => + state.collections.get(collectionId).map { value => + Ok(value.nfts.map { case (_, nft) => formatToNFTResponse(nft) }.toList) + }.getOrElse(NotFound()) + } + } + + private def getCollectionNFTById( + collectionId: String, + nftId : Long + ): F[Response[F]] = { + getState.flatMap { state => + state.collections.get(collectionId).flatMap { collection => + collection.nfts.get(nftId).map { nft => Ok(formatToNFTResponse(nft)) } + }.getOrElse(NotFound()) + } + } + + private def getAllCollectionsOfAddress( + address: Address + ): F[Response[F]] = { + getState.flatMap { state => + val addressCollections = state.collections.filter { case (_, collection) => + collection.owner == address + } + Ok(addressCollections.map { case (_, collection) => formatToCollectionResponse(collection) }) + } + } + + private def getAllNFTsOfAddress( + address: Address + ): F[Response[F]] = { + getState.flatMap { state => + val allAddressNFTs = state.collections.flatMap { + case (_, collection) => + val addressNFTs = collection.nfts.filter { case (_, nft) => + nft.owner == address + } + addressNFTs + } + Ok(allAddressNFTs.map { case (_, nft) => formatToNFTResponse(nft) }) + } + } + + private val routes: HttpRoutes[F] = HttpRoutes.of[F] { + case GET -> Root / "collections" => getAllCollections + case GET -> Root / "collections" / collectionId => getCollectionById(collectionId) + case GET -> Root / "collections" / collectionId / "nfts" => getCollectionNFTs(collectionId) + case GET -> Root / "collections" / collectionId / "nfts" / nftId => getCollectionNFTById(collectionId, nftId.toLong) + case GET -> Root / "addresses" / AddressVar(address) / "collections" => getAllCollectionsOfAddress(address) + case GET -> Root / "addresses" / AddressVar(address) / "nfts" => getAllNFTsOfAddress(address) + } + + val public: HttpRoutes[F] = + CORS + .policy + .withAllowCredentials(false) + .httpRoutes(routes) + + override protected def prefixPath: InternalUrlPrefix = "/" +} \ No newline at end of file diff --git a/examples/nft-fee-transaction/modules/l1/src/main/scala/com/my/nft_fee_transactions/l1/Main.scala b/examples/nft-fee-transaction/modules/l1/src/main/scala/com/my/nft_fee_transactions/l1/Main.scala new file mode 100755 index 0000000..afc6894 --- /dev/null +++ b/examples/nft-fee-transaction/modules/l1/src/main/scala/com/my/nft_fee_transactions/l1/Main.scala @@ -0,0 +1,17 @@ +package com.my.nft_fee_transactions.l1 + +import io.constellationnetwork.BuildInfo +import io.constellationnetwork.currency.l1.CurrencyL1App +import io.constellationnetwork.schema.cluster.ClusterId +import io.constellationnetwork.schema.semver.{MetagraphVersion, TessellationVersion} + +import java.util.UUID + +object Main + extends CurrencyL1App( + "currency-l1", + "currency L1 node", + ClusterId(UUID.fromString("517c3a05-9219-471b-a54c-21b7d72f4ae5")), + metagraphVersion = MetagraphVersion.unsafeFrom(BuildInfo.version), + tessellationVersion = TessellationVersion.unsafeFrom(BuildInfo.version) + ) {} diff --git a/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/LifecycleSharedFunctions.scala b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/LifecycleSharedFunctions.scala new file mode 100755 index 0000000..94ac3c3 --- /dev/null +++ b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/LifecycleSharedFunctions.scala @@ -0,0 +1,87 @@ +package com.my.nft_fee_transactions.shared_data + +import cats.data.NonEmptyList +import cats.effect.Async +import cats.syntax.all._ +import Utils._ +import com.my.nft_fee_transactions.shared_data.combiners.Combiners._ +import com.my.nft_fee_transactions.shared_data.types.Types._ +import com.my.nft_fee_transactions.shared_data.validations.Validations._ +import io.constellationnetwork.currency.dataApplication.dataApplication.DataApplicationValidationErrorOr +import io.constellationnetwork.currency.dataApplication.{DataState, L0NodeContext} +import io.constellationnetwork.security.SecurityProvider +import io.constellationnetwork.security.signature.Signed +import org.typelevel.log4cats.SelfAwareStructuredLogger +import org.typelevel.log4cats.slf4j.Slf4jLogger + +object LifecycleSharedFunctions { + private def logger[F[_] : Async]: SelfAwareStructuredLogger[F] = Slf4jLogger.getLoggerFromName[F]("ClusterApi") + + def validateUpdate[F[_] : Async]( + update: NFTUpdate + ): F[DataApplicationValidationErrorOr[Unit]] = Async[F].delay { + update match { + case mintCollection: MintCollection => + mintCollectionValidations(mintCollection, None) + case mintNFT: MintNFT => + mintNFTValidations(mintNFT, None) + case transferCollection: TransferCollection => + transferCollectionValidations(transferCollection, None) + case transferNFT: TransferNFT => + transferNFTValidations(transferNFT, None) + } + } + + def validateData[F[_] : Async]( + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState], + updates: NonEmptyList[Signed[NFTUpdate]] + )(implicit context: L0NodeContext[F]): F[DataApplicationValidationErrorOr[Unit]] = { + implicit val sp: SecurityProvider[F] = context.securityProvider + updates.traverse { signedUpdate => + getAllAddressesFromProofs(signedUpdate.proofs) + .flatMap { addresses => + Async[F].delay { + signedUpdate.value match { + case mintCollection: MintCollection => + mintCollectionValidations(mintCollection, state.some) + case mintNFT: MintNFT => + mintNFTValidationsWithSignature(mintNFT, state) + case transferCollection: TransferCollection => + transferCollectionValidationsWithSignature(transferCollection, addresses, state) + case transferNFT: TransferNFT => + transferNFTValidationsWithSignature(transferNFT, addresses, state) + } + } + } + }.map(_.reduce) + } + + def combine[F[_] : Async]( + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState], + updates: List[Signed[NFTUpdate]] + )(implicit context: L0NodeContext[F]): F[DataState[NFTUpdatesState, NFTUpdatesCalculatedState]] = { + val newStateF = DataState(NFTUpdatesState(List.empty), state.calculated).pure[F] + + if (updates.isEmpty) { + logger.info("Snapshot without any updates, updating the state to empty updates") >> newStateF + } else { + implicit val sp: SecurityProvider[F] = context.securityProvider + newStateF.flatMap(newState => { + updates.foldLeftM(newState) { (acc, signedUpdate) => { + signedUpdate.value match { + case mintCollection: MintCollection => + getFirstAddressFromProofs(signedUpdate.proofs) + .map(combineMintCollection(mintCollection, acc, _)) + case mintNFT: MintNFT => + Async[F].delay(combineMintNFT(mintNFT, acc)) + case transferCollection: TransferCollection => + Async[F].delay(combineTransferCollection(transferCollection, acc)) + case transferNFT: TransferNFT => + Async[F].delay(combineTransferNFT(transferNFT, acc)) + } + } + } + }) + } + } +} \ No newline at end of file diff --git a/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/Utils.scala b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/Utils.scala new file mode 100755 index 0000000..98da241 --- /dev/null +++ b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/Utils.scala @@ -0,0 +1,31 @@ +package com.my.nft_fee_transactions.shared_data + +import cats.data.NonEmptySet +import cats.effect.Async +import cats.syntax.foldable.toFoldableOps +import cats.syntax.traverse.toTraverseOps +import io.constellationnetwork.schema.address.Address +import io.constellationnetwork.security.SecurityProvider +import io.constellationnetwork.security.signature.signature.SignatureProof + +import java.net.URI +import scala.util.Try + +object Utils { + def isValidURL(url: String): Boolean = + Try(URI.create(url).toURL).isSuccess + + def getAllAddressesFromProofs[F[_] : Async : SecurityProvider]( + proofs: NonEmptySet[SignatureProof] + ): F[List[Address]] = + proofs + .map(_.id) + .toList + .traverse(_.toAddress[F]) + + def getFirstAddressFromProofs[F[_] : Async : SecurityProvider]( + proofs: NonEmptySet[SignatureProof] + ): F[Address] = + proofs.head.id.toAddress[F] +} + diff --git a/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/calculated_state/CalculatedState.scala b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/calculated_state/CalculatedState.scala new file mode 100755 index 0000000..1173d15 --- /dev/null +++ b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/calculated_state/CalculatedState.scala @@ -0,0 +1,12 @@ +package com.my.nft_fee_transactions.shared_data.calculated_state + +import com.my.nft_fee_transactions.shared_data.types.Types._ +import eu.timepit.refined.types.all.NonNegLong +import io.constellationnetwork.schema.SnapshotOrdinal + +case class CalculatedState(ordinal: SnapshotOrdinal, state: NFTUpdatesCalculatedState) + +object CalculatedState { + def empty: CalculatedState = + CalculatedState(SnapshotOrdinal(NonNegLong(0L)), NFTUpdatesCalculatedState(Map.empty)) +} diff --git a/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/calculated_state/CalculatedStateService.scala b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/calculated_state/CalculatedStateService.scala new file mode 100755 index 0000000..429ed37 --- /dev/null +++ b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/calculated_state/CalculatedStateService.scala @@ -0,0 +1,71 @@ +package com.my.nft_fee_transactions.shared_data.calculated_state + +import cats.effect.Ref +import cats.effect.kernel.Async +import cats.syntax.functor.toFunctorOps +import com.my.nft_fee_transactions.shared_data.types.Types._ +import io.circe.Json +import io.circe.syntax.EncoderOps +import io.constellationnetwork.schema.SnapshotOrdinal +import io.constellationnetwork.security.hash.Hash + +import java.nio.charset.StandardCharsets + +trait CalculatedStateService[F[_]] { + def getCalculatedState: F[CalculatedState] + + def setCalculatedState( + snapshotOrdinal: SnapshotOrdinal, + state : NFTUpdatesCalculatedState + ): F[Boolean] + + def hashCalculatedState( + state: NFTUpdatesCalculatedState + ): F[Hash] +} + +object CalculatedStateService { + def make[F[_] : Async]: F[CalculatedStateService[F]] = { + Ref.of[F, CalculatedState](CalculatedState.empty).map { stateRef => + new CalculatedStateService[F] { + override def getCalculatedState: F[CalculatedState] = stateRef.get + + override def setCalculatedState( + snapshotOrdinal: SnapshotOrdinal, + state : NFTUpdatesCalculatedState + ): F[Boolean] = + stateRef.update { currentState => + val currentVoteCalculatedState = currentState.state + val updatedCollections = state.collections.foldLeft(currentVoteCalculatedState.collections) { + case (acc, (address, value)) => + acc.updated(address, value) + } + + CalculatedState(snapshotOrdinal, NFTUpdatesCalculatedState(updatedCollections)) + }.as(true) + + + override def hashCalculatedState( + state: NFTUpdatesCalculatedState + ): F[Hash] = Async[F].delay { + def removeKey(json: Json, keyToRemove: String): Json = + json.mapObject { obj => + obj.filterKeys(_ != keyToRemove).mapValues { + case objValue: Json => removeKey(objValue, keyToRemove) + case other => other + } + }.mapArray { arr => + arr.map(removeKey(_, keyToRemove)) + } + + + val stateAsString = removeKey(state.asJson, "creationDateTimestamp") + .deepDropNullValues + .noSpaces + + Hash.fromBytes(stateAsString.getBytes(StandardCharsets.UTF_8)) + } + } + } + } +} diff --git a/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/combiners/Combiners.scala b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/combiners/Combiners.scala new file mode 100755 index 0000000..c55660a --- /dev/null +++ b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/combiners/Combiners.scala @@ -0,0 +1,80 @@ +package com.my.nft_fee_transactions.shared_data.combiners + +import com.my.nft_fee_transactions.shared_data.types.Types._ +import com.my.nft_fee_transactions.shared_data.serializers.Serializers +import monocle.Monocle.toAppliedFocusOps +import io.constellationnetwork.currency.dataApplication.DataState +import io.constellationnetwork.schema.address.Address +import io.constellationnetwork.security.hash.Hash + +object Combiners { + def combineMintCollection( + update : MintCollection, + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState], + collectionOwner: Address + ): DataState[NFTUpdatesState, NFTUpdatesCalculatedState] = { + val collectionId = Hash.fromBytes(Serializers.serializeUpdate(update)).toString + val nowInTime = System.currentTimeMillis() + val newState = Collection(collectionId, collectionOwner, update.name, nowInTime, Map.empty) + + val newUpdatesList = state.onChain.updates :+ update + val newCalculatedState = state.calculated.focus(_.collections).modify(_.updated(collectionId, newState)) + + DataState(NFTUpdatesState(newUpdatesList), newCalculatedState) + } + + def combineMintNFT( + update: MintNFT, + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState] + ): DataState[NFTUpdatesState, NFTUpdatesCalculatedState] = { + val nowInTime = System.currentTimeMillis() + val newNFT = NFT(update.nftId, update.collectionId, update.owner, update.uri, update.name, update.description, nowInTime, update.metadata) + + val collection = state.calculated.collections(update.collectionId) + val collectionNFTs = collection.nfts + (update.nftId -> newNFT) + val newState = Collection(collection.id, collection.owner, collection.name, collection.creationDateTimestamp, collectionNFTs) + + val newUpdatesList = state.onChain.updates :+ update + val newCalculatedState = state.calculated.focus(_.collections).modify(_.updated(update.collectionId, newState)) + + DataState(NFTUpdatesState(newUpdatesList), newCalculatedState) + } + + def combineTransferCollection( + update: TransferCollection, + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState] + ): DataState[NFTUpdatesState, NFTUpdatesCalculatedState] = + state.calculated.collections + .get(update.collectionId) + .fold(state) { collection => + val newState = collection.copy(owner = update.toAddress) + + val newUpdatesList = state.onChain.updates :+ update + val newCalculatedState = state.calculated.focus(_.collections).modify(_.updated(update.collectionId, newState)) + + DataState(NFTUpdatesState(newUpdatesList), newCalculatedState) + } + + def combineTransferNFT( + update: TransferNFT, + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState] + ): DataState[NFTUpdatesState, NFTUpdatesCalculatedState] = + state.calculated.collections + .get(update.collectionId) + .fold(state) { collection => + collection.nfts + .get(update.nftId) + .fold(state) { nft => + val updatedNFT = NFT(nft.id, nft.collectionId, update.toAddress, nft.uri, nft.name, nft.description, nft.creationDateTimestamp, nft.metadata) + val collectionNFTs = collection.nfts + (nft.id -> updatedNFT) + val newState = Collection(collection.id, collection.owner, collection.name, collection.creationDateTimestamp, collectionNFTs) + + val newUpdatesList = state.onChain.updates :+ update + val newCalculatedState = state.calculated + .focus(_.collections) + .modify(_.updated(update.collectionId, newState)) + + DataState(NFTUpdatesState(newUpdatesList), newCalculatedState) + } + } +} \ No newline at end of file diff --git a/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/deserializers/Deserializers.scala b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/deserializers/Deserializers.scala new file mode 100755 index 0000000..de67da8 --- /dev/null +++ b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/deserializers/Deserializers.scala @@ -0,0 +1,37 @@ +package com.my.nft_fee_transactions.shared_data.deserializers + +import com.my.nft_fee_transactions.shared_data.types.Types._ +import io.circe.Decoder +import io.circe.jawn.decode +import io.constellationnetwork.currency.dataApplication.DataUpdate +import io.constellationnetwork.currency.dataApplication.dataApplication.DataApplicationBlock +import io.constellationnetwork.security.signature.Signed + +import java.nio.charset.StandardCharsets + +object Deserializers { + private def deserialize[A: Decoder]( + bytes: Array[Byte] + ): Either[Throwable, A] = + decode[A](new String(bytes, StandardCharsets.UTF_8)) + + def deserializeUpdate( + bytes: Array[Byte] + ): Either[Throwable, NFTUpdate] = + deserialize[NFTUpdate](bytes) + + def deserializeState( + bytes: Array[Byte] + ): Either[Throwable, NFTUpdatesState] = + deserialize[NFTUpdatesState](bytes) + + def deserializeBlock( + bytes: Array[Byte] + )(implicit e: Decoder[DataUpdate]): Either[Throwable, Signed[DataApplicationBlock]] = + deserialize[Signed[DataApplicationBlock]](bytes) + + def deserializeCalculatedState( + bytes: Array[Byte] + ): Either[Throwable, NFTUpdatesCalculatedState] = + deserialize[NFTUpdatesCalculatedState](bytes) +} \ No newline at end of file diff --git a/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/errors/Errors.scala b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/errors/Errors.scala new file mode 100755 index 0000000..e0baa9c --- /dev/null +++ b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/errors/Errors.scala @@ -0,0 +1,76 @@ +package com.my.nft_fee_transactions.shared_data.errors + +import cats.syntax.validated.catsSyntaxValidatedIdBinCompat0 +import io.constellationnetwork.currency.dataApplication.DataApplicationValidationError +import io.constellationnetwork.currency.dataApplication.dataApplication.DataApplicationValidationErrorOr + +object Errors { + type DataApplicationValidationType = DataApplicationValidationErrorOr[Unit] + + val valid: DataApplicationValidationType = + ().validNec[DataApplicationValidationError] + + implicit class DataApplicationValidationTypeOps[E <: DataApplicationValidationError](err: E) { + def invalid: DataApplicationValidationType = + err.invalidNec[Unit] + + def unlessA( + cond: Boolean + ): DataApplicationValidationType = + if (cond) valid else invalid + + def whenA( + cond: Boolean + ): DataApplicationValidationType = + if (cond) invalid else valid + } + + case object DuplicatedCollection extends DataApplicationValidationError { + val message = "Duplicated collection" + } + + case object InvalidNFTUri extends DataApplicationValidationError { + val message = "NFT URI is invalid" + } + + case object NFTAlreadyExists extends DataApplicationValidationError { + val message = "NFT already exists" + } + + case object NFTUriAlreadyExists extends DataApplicationValidationError { + val message = "NFT URI already exists" + } + + case object CollectionNotExists extends DataApplicationValidationError { + val message = "Collection not exists" + } + + case object NFTNotExists extends DataApplicationValidationError { + val message = "NFT does not exists" + } + + case object CollectionDoesNotBelongsToProvidedAddress extends DataApplicationValidationError { + val message = "Collection does not belongs to provided address" + } + + case object NFTDoesNotBelongsToProvidedAddress extends DataApplicationValidationError { + val message = "NFT does not belongs to provided address" + } + + case object CouldNotGetLatestCurrencySnapshot extends DataApplicationValidationError { + val message = "Could not get latest currency snapshot!" + } + + case object CouldNotGetLatestState extends DataApplicationValidationError { + val message = "Could not get latest state!" + } + + case object InvalidAddress extends DataApplicationValidationError { + val message = "Provided address different than proof" + } + + case class InvalidFieldSize(fieldName: String, maxSize: Long) extends DataApplicationValidationError { + val message = s"Invalid field size: $fieldName, maxSize: $maxSize" + } +} + diff --git a/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/serializers/Serializers.scala b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/serializers/Serializers.scala new file mode 100755 index 0000000..ba90fea --- /dev/null +++ b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/serializers/Serializers.scala @@ -0,0 +1,49 @@ +package com.my.nft_fee_transactions.shared_data.serializers + +import com.my.nft_fee_transactions.shared_data.types.Types._ +import io.circe.Encoder +import io.circe.syntax.EncoderOps +import io.constellationnetwork.currency.dataApplication.DataUpdate +import io.constellationnetwork.currency.dataApplication.dataApplication.DataApplicationBlock +import io.constellationnetwork.security.signature.Signed + +import java.nio.charset.StandardCharsets +import java.util.Base64 + +object Serializers { + private def serialize[A: Encoder]( + serializableData: A + ): Array[Byte] = { + serializableData.asJson.deepDropNullValues.noSpaces.getBytes(StandardCharsets.UTF_8) + } + + def serializeUpdate( + update: NFTUpdate + ): Array[Byte] = { + val encoder = Base64.getEncoder + val data_sign_prefix = "\u0019Constellation Signed Data:\n" + + val updateBytes = update.asJson.deepDropNullValues.noSpaces.getBytes(StandardCharsets.UTF_8) + val encodedBytes = encoder.encode(updateBytes) + + val encodedString = new String(encodedBytes, "UTF-8") + val completeString = s"$data_sign_prefix${encodedString.length}\n$encodedString" + + completeString.getBytes(StandardCharsets.UTF_8) + } + + def serializeState( + state: NFTUpdatesState + ): Array[Byte] = + serialize[NFTUpdatesState](state) + + def serializeBlock( + state: Signed[DataApplicationBlock] + )(implicit e: Encoder[DataUpdate]): Array[Byte] = + serialize[Signed[DataApplicationBlock]](state) + + def serializeCalculatedState( + state: NFTUpdatesCalculatedState + ): Array[Byte] = + serialize[NFTUpdatesCalculatedState](state) +} \ No newline at end of file diff --git a/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/types/Types.scala b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/types/Types.scala new file mode 100755 index 0000000..086ec45 --- /dev/null +++ b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/types/Types.scala @@ -0,0 +1,94 @@ +package com.my.nft_fee_transactions.shared_data.types + +import derevo.circe.magnolia.{decoder, encoder} +import derevo.derive +import io.constellationnetwork.currency.dataApplication.{DataCalculatedState, DataOnChainState, DataUpdate} +import io.constellationnetwork.schema.address.Address + +object Types { + @derive(decoder, encoder) + case class NFT( + id : Long, + collectionId : String, + owner : Address, + uri : String, + name : String, + description : String, + creationDateTimestamp: Long, + metadata : Map[String, String] + ) + + @derive(decoder, encoder) + case class Collection( + id : String, + owner : Address, + name : String, + creationDateTimestamp: Long, + nfts : Map[Long, NFT] + ) + + @derive(decoder, encoder) + sealed trait NFTUpdate extends DataUpdate + + @derive(decoder, encoder) + case class MintCollection( + name: String + ) extends NFTUpdate + + @derive(decoder, encoder) + case class MintNFT( + owner : Address, + collectionId: String, + nftId : Long, + uri : String, + name : String, + description : String, + metadata : Map[String, String] + ) extends NFTUpdate + + @derive(decoder, encoder) + case class TransferCollection( + fromAddress : Address, + toAddress : Address, + collectionId: String + ) extends NFTUpdate + + @derive(decoder, encoder) + case class TransferNFT( + fromAddress : Address, + toAddress : Address, + collectionId: String, + nftId : Long + ) extends NFTUpdate + + @derive(decoder, encoder) + case class NFTUpdatesState( + updates: List[NFTUpdate] + ) extends DataOnChainState + + @derive(decoder, encoder) + case class NFTUpdatesCalculatedState( + collections: Map[String, Collection] + ) extends DataCalculatedState + + @derive(decoder, encoder) + case class CollectionResponse( + id : String, + owner : Address, + name : String, + creationDateTimestamp: Long, + numberOfNFTs : Long + ) + + @derive(decoder, encoder) + case class NFTResponse( + id : Long, + collectionId : String, + owner : Address, + uri : String, + name : String, + description : String, + creationDateTimestamp: Long, + metadata : Map[String, String] + ) +} diff --git a/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/validations/TypeValidators.scala b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/validations/TypeValidators.scala new file mode 100755 index 0000000..141726c --- /dev/null +++ b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/validations/TypeValidators.scala @@ -0,0 +1,112 @@ +package com.my.nft_fee_transactions.shared_data.validations + +import com.my.nft_fee_transactions.shared_data.Utils.isValidURL +import com.my.nft_fee_transactions.shared_data.errors.Errors._ +import com.my.nft_fee_transactions.shared_data.types.Types._ +import com.my.nft_fee_transactions.shared_data.serializers.Serializers +import io.constellationnetwork.currency.dataApplication.DataState +import io.constellationnetwork.currency.dataApplication.dataApplication.DataApplicationValidationErrorOr +import io.constellationnetwork.schema.address.Address +import io.constellationnetwork.security.hash.Hash + +object TypeValidators { + private def getCollectionById( + collectionId: String, + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState] + ): Option[Collection] = { + state.calculated.collections + .get(collectionId) + } + + def validateIfCollectionIsUnique( + update: MintCollection, + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState] + ): DataApplicationValidationErrorOr[Unit] = { + val collectionId = Hash.fromBytes(Serializers.serializeUpdate(update)).toString + DuplicatedCollection.whenA(state.calculated.collections.contains(collectionId)) + } + + def validateIfNFTUriIsValid( + update: MintNFT + ): DataApplicationValidationErrorOr[Unit] = + InvalidNFTUri.unlessA(isValidURL(update.uri)) + + def validateIfNFTUriIsUnique( + update: MintNFT, + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState] + ): DataApplicationValidationErrorOr[Unit] = + getCollectionById(update.collectionId, state) + .map { value => + val uris = value.nfts.map { case (_, value) => value.uri }.toList + NFTUriAlreadyExists.whenA(uris.contains(update.uri)) + } + .getOrElse(CollectionNotExists.invalid) + + def validateIfNFTIdIsUnique( + update: MintNFT, + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState] + ): DataApplicationValidationErrorOr[Unit] = + getCollectionById(update.collectionId, state) + .map { value => + val ids = value.nfts.map { case (_, value) => value.id }.toList + NFTAlreadyExists.whenA(ids.contains(update.nftId)) + } + .getOrElse(CollectionNotExists.invalid) + + def validateIfProvidedNFTExists( + update: TransferNFT, + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState] + ): DataApplicationValidationErrorOr[Unit] = + getCollectionById(update.collectionId, state) + .map { value => + NFTNotExists.unlessA(value.nfts.contains(update.nftId)) + } + .getOrElse(CollectionNotExists.invalid) + + def validateIfFromAddressIsTheNFTOwner( + update: TransferNFT, + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState] + ): DataApplicationValidationErrorOr[Unit] = + getCollectionById(update.collectionId, state) + .map { value => + NFTDoesNotBelongsToProvidedAddress.unlessA(value.nfts.get(update.nftId).exists(_.owner == update.fromAddress)) + } + .getOrElse(CollectionNotExists.invalid) + + def validateIfFromAddressIsTheCollectionOwner( + update: TransferCollection, + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState] + ): DataApplicationValidationErrorOr[Unit] = + getCollectionById(update.collectionId, state) + .map { value => + CollectionDoesNotBelongsToProvidedAddress.unlessA(value.owner == update.fromAddress) + } + .getOrElse(CollectionNotExists.invalid) + + def validateIfProvidedCollectionExists( + update: TransferCollection, + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState] + ): DataApplicationValidationErrorOr[Unit] = + CollectionNotExists.unlessA(state.calculated.collections.contains(update.collectionId)) + + def validateProvidedAddress( + proofAddresses: List[Address], + address : Address + ): DataApplicationValidationErrorOr[Unit] = + InvalidAddress.unlessA(proofAddresses.contains(address)) + + def validateStringMaxSize( + value : String, + maxSize : Long, + fieldName: String + ): DataApplicationValidationErrorOr[Unit] = + InvalidFieldSize(fieldName, maxSize).whenA(value.length > maxSize) + + def validateMapMaxSize( + value : Map[String, String], + maxSize : Long, + fieldName: String + ): DataApplicationValidationErrorOr[Unit] = + InvalidFieldSize(fieldName, maxSize).whenA(value.size > maxSize) +} + diff --git a/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/validations/Validations.scala b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/validations/Validations.scala new file mode 100755 index 0000000..13f9070 --- /dev/null +++ b/examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/validations/Validations.scala @@ -0,0 +1,90 @@ +package com.my.nft_fee_transactions.shared_data.validations + +import cats.syntax.all._ +import cats.syntax.option.catsSyntaxOptionId +import com.my.nft_fee_transactions.shared_data.errors.Errors.valid +import com.my.nft_fee_transactions.shared_data.types.Types._ +import TypeValidators._ +import io.constellationnetwork.currency.dataApplication.DataState +import io.constellationnetwork.currency.dataApplication.dataApplication.DataApplicationValidationErrorOr +import io.constellationnetwork.schema.address.Address + +object Validations { + def mintCollectionValidations( + update : MintCollection, + maybeState: Option[DataState[NFTUpdatesState, NFTUpdatesCalculatedState]] + ): DataApplicationValidationErrorOr[Unit] = + maybeState match { + case Some(state) => + validateIfCollectionIsUnique(update, state) + .productR(validateStringMaxSize(update.name, 64, "name")) + case None => + validateStringMaxSize(update.name, 64, "name") + + } + + def mintNFTValidations( + update : MintNFT, + maybeState: Option[DataState[NFTUpdatesState, NFTUpdatesCalculatedState]] + ): DataApplicationValidationErrorOr[Unit] = + maybeState match { + case Some(state) => + validateIfNFTUriIsValid(update) + .productR(validateIfNFTUriIsUnique(update, state)) + .productR(validateIfNFTIdIsUnique(update, state)) + .productR(validateStringMaxSize(update.name, 64, "name")) + .productR(validateStringMaxSize(update.description, 64, "description")) + .productR(validateMapMaxSize(update.metadata, 15, "metadata")) + case None => + validateIfNFTUriIsValid(update) + .productR(validateStringMaxSize(update.name, 64, "name")) + .productR(validateStringMaxSize(update.description, 64, "description")) + .productR(validateMapMaxSize(update.metadata, 15, "metadata")) + } + + def transferCollectionValidations( + update : TransferCollection, + maybeState: Option[DataState[NFTUpdatesState, NFTUpdatesCalculatedState]] + ): DataApplicationValidationErrorOr[Unit] = + maybeState match { + case None => valid + case Some(state) => + validateIfProvidedCollectionExists(update, state) + .productR(validateIfFromAddressIsTheCollectionOwner(update, state)) + } + + def transferNFTValidations( + update : TransferNFT, + maybeState: Option[DataState[NFTUpdatesState, NFTUpdatesCalculatedState]] + ): DataApplicationValidationErrorOr[Unit] = + maybeState match { + case None => valid + case Some(state) => + validateIfProvidedNFTExists(update, state) + .productR(validateIfFromAddressIsTheNFTOwner(update, state)) + } + + def mintNFTValidationsWithSignature( + update : MintNFT, + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState] + ): DataApplicationValidationErrorOr[Unit] = + mintNFTValidations(update, state.some) + + def transferCollectionValidationsWithSignature( + update : TransferCollection, + addresses: List[Address], + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState] + ): DataApplicationValidationErrorOr[Unit] = + transferCollectionValidations(update, state.some) + .productR(validateProvidedAddress(addresses, update.fromAddress)) + + def transferNFTValidationsWithSignature( + update : TransferNFT, + addresses: List[Address], + state : DataState[NFTUpdatesState, NFTUpdatesCalculatedState] + ): DataApplicationValidationErrorOr[Unit] = { + transferNFTValidations(update, state.some) + .productR(validateProvidedAddress(addresses, update.fromAddress)) + } +} + diff --git a/examples/nft-fee-transaction/project/Dependencies.scala b/examples/nft-fee-transaction/project/Dependencies.scala new file mode 100755 index 0000000..a68676d --- /dev/null +++ b/examples/nft-fee-transaction/project/Dependencies.scala @@ -0,0 +1,43 @@ +import sbt.* + +object Dependencies { + + object V { + val tessellation = "99.99.99" //TODO: Update with the official version with FeeTransactions + val decline = "2.4.1" + } + + def tessellation(artifact: String): ModuleID = "io.constellationnetwork" %% s"tessellation-$artifact" % V.tessellation + + def decline(artifact: String = ""): ModuleID = + "com.monovore" %% { + if (artifact.isEmpty) "decline" else s"decline-$artifact" + } % V.decline + + object Libraries { + // Use SDK as a single dependency that includes all modules + val tessellationSdk = tessellation("sdk") + val declineCore = decline() + val declineEffect = decline("effect") + val declineRefined = decline("refined") + } + + + // Scalafix rules + val organizeImports = "com.github.liancheng" %% "organize-imports" % "0.5.0" + + object CompilerPlugin { + + val betterMonadicFor = compilerPlugin( + "com.olegpy" %% "better-monadic-for" % "0.3.1" + ) + + val kindProjector = compilerPlugin( + ("org.typelevel" % "kind-projector" % "0.13.4").cross(CrossVersion.full) + ) + + val semanticDB = compilerPlugin( + ("org.scalameta" % "semanticdb-scalac" % "4.14.2").cross(CrossVersion.full) + ) + } +} diff --git a/examples/nft-fee-transaction/project/build.properties b/examples/nft-fee-transaction/project/build.properties new file mode 100755 index 0000000..abbbce5 --- /dev/null +++ b/examples/nft-fee-transaction/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.9.8 diff --git a/examples/nft-fee-transaction/project/plugins.sbt b/examples/nft-fee-transaction/project/plugins.sbt new file mode 100755 index 0000000..b255ea0 --- /dev/null +++ b/examples/nft-fee-transaction/project/plugins.sbt @@ -0,0 +1,11 @@ +addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.4.1") +addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") +addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.11") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.10.1") +addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1.5.3") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.2.0") +addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") +addSbtPlugin("com.codecommit" % "sbt-github-packages" % "0.5.3") +addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.6.3") +addDependencyTreePlugin diff --git a/examples/nft-fee-transaction/scripts/package.json b/examples/nft-fee-transaction/scripts/package.json new file mode 100644 index 0000000..ce35c5c --- /dev/null +++ b/examples/nft-fee-transaction/scripts/package.json @@ -0,0 +1,17 @@ +{ + "name": "scripts", + "version": "1.0.0", + "description": "", + "main": "send_data_transaction.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@stardust-collective/dag4": "^2.1.2", + "axios": "^1.4.0", + "js-sha256": "^0.9.0" + } +} diff --git a/examples/nft-fee-transaction/scripts/send_data_transaction.js b/examples/nft-fee-transaction/scripts/send_data_transaction.js new file mode 100644 index 0000000..00db476 --- /dev/null +++ b/examples/nft-fee-transaction/scripts/send_data_transaction.js @@ -0,0 +1,160 @@ +const { dag4 } = require("@stardust-collective/dag4"); +const jsSha256 = require("js-sha256"); +const axios = require("axios"); + +const buildMintCollection = () => { + return { + MintCollection: { + name: "MyCollection", + }, + }; +}; + +const buildMintNFT = () => { + return { + MintNFT: { + owner: "", + collectionId: "", + nftId: 1, + uri: "", + name: "", + description: "", + metadata: { + key: "", + }, + }, + }; +}; + +const buildTransferCollection = () => { + return { + TransferCollection: { + fromAddress: ":fromAddress", + toAddress: ":toAddress", + collectionId: ":collectionId", + }, + }; +}; + +const buildTransferNFT = () => { + return { + TransferNFT: { + fromAddress: ":fromAddress", + toAddress: ":toAddress", + collectionId: ":collectionId", + nftId: ":nftIdAsNumber", + }, + }; +}; + +const buildFeeTransaction = () => { + return { + source: ":source_wallet", + destination: ":destination_wallet", + amount: 10000, + dataUpdateRef: ":data_update_ref" + } +} + +/** Encode message according with serializeUpdate on your template module l1 */ +const getEncoded = (value) => { + const energyValue = JSON.stringify(value); + return energyValue; +}; + +const serialize = (msg) => { + const coded = Buffer.from(msg, 'utf8').toString('hex'); + return coded; +}; + +const generateProofFee = async (message, walletPrivateKey, account) => { + const encoded = getEncoded(message); + console.log(encoded); + + const serializedTx = serialize(encoded); + const hash = jsSha256.sha256(Buffer.from(serializedTx, 'hex')); + const signature = await dag4.keyStore.sign(walletPrivateKey, hash); + + const publicKey = account.publicKey; + const uncompressedPublicKey = + publicKey.length === 128 ? '04' + publicKey : publicKey; + + return { + id: uncompressedPublicKey.substring(2), + signature + }; +}; + +const generateProof = async (message, walletPrivateKey, account) => { + const encodedMessage = Buffer.from(JSON.stringify(message)).toString('base64') + const signature = await dag4.keyStore.dataSign( + walletPrivateKey, + encodedMessage + ); + + const publicKey = account.publicKey; + const uncompressedPublicKey = + publicKey.length === 128 ? '04' + publicKey : publicKey; + + return { + id: uncompressedPublicKey.substring(2), + signature + }; +}; + +const sendDataTransactionsUsingUrls = async ( + globalL0Url, + metagraphL1DataUrl +) => { + const walletPrivateKey = ":wallet_private_key"; + + const account = dag4.createAccount(); + account.loginPrivateKey(walletPrivateKey); + + account.connect({ + networkVersion: "2.0", + l0Url: globalL0Url, + testnet: true, + }); + const message = buildMintCollection() + const fee = buildFeeTransaction() + + const proof = await generateProof(message, walletPrivateKey, account); + const feeProof = await generateProofFee(fee, walletPrivateKey, account); + + const body = { + data: { + value: { + ...message + }, + proofs: [ + proof + ] + }, + fee: { + value: { + ...fee + }, + proofs: [ + feeProof + ] + } + }; + try { + console.log(`Transaction body: ${JSON.stringify(body)}`); + const response = await axios.post(`${metagraphL1DataUrl}/data`, body); + console.log(`Response: ${JSON.stringify(response.data)}`); + } catch (e) { + console.log("Error sending transaction", JSON.stringify(e)); + } + return; +}; + +const sendDataTransaction = async () => { + const globalL0Url = 'http://localhost:9000'; + const metagraphL1DataUrl = 'http://localhost:9400'; + + await sendDataTransactionsUsingUrls(globalL0Url, metagraphL1DataUrl); +}; + +sendDataTransaction(); \ No newline at end of file