From a05a211eb6a406c9cf19a5cf971bc5be94f15241 Mon Sep 17 00:00:00 2001 From: Marcus Vinicius Girolneto Sousa Date: Mon, 30 Sep 2024 15:17:16 -0300 Subject: [PATCH 1/2] feat: adding nft-fee-transaction example --- examples/nft-fee-transaction/.gitignore | 37 ++++ examples/nft-fee-transaction/README.md | 134 ++++++++++++++ examples/nft-fee-transaction/build.sbt | 105 +++++++++++ .../nft_fee_transactions/data_l1/Main.scala | 172 ++++++++++++++++++ .../com/my/nft_fee_transactions/l0/Main.scala | 172 ++++++++++++++++++ .../l0/custom_routes/CustomRoutes.scala | 127 +++++++++++++ .../com/my/nft_fee_transactions/l1/Main.scala | 17 ++ .../LifecycleSharedFunctions.scala | 87 +++++++++ .../shared_data/Utils.scala | 31 ++++ .../calculated_state/CalculatedState.scala | 12 ++ .../CalculatedStateService.scala | 71 ++++++++ .../shared_data/combiners/Combiners.scala | 80 ++++++++ .../deserializers/Deserializers.scala | 37 ++++ .../shared_data/errors/Errors.scala | 76 ++++++++ .../shared_data/serializers/Serializers.scala | 49 +++++ .../shared_data/types/Types.scala | 94 ++++++++++ .../validations/TypeValidators.scala | 112 ++++++++++++ .../shared_data/validations/Validations.scala | 90 +++++++++ .../project/Dependencies.scala | 44 +++++ .../project/build.properties | 1 + .../nft-fee-transaction/project/plugins.sbt | 11 ++ .../nft-fee-transaction/scripts/package.json | 17 ++ .../scripts/send_data_transaction.js | 160 ++++++++++++++++ .../src/main/resources/application.conf | 2 +- 24 files changed, 1737 insertions(+), 1 deletion(-) create mode 100755 examples/nft-fee-transaction/.gitignore create mode 100755 examples/nft-fee-transaction/README.md create mode 100755 examples/nft-fee-transaction/build.sbt create mode 100755 examples/nft-fee-transaction/modules/data_l1/src/main/scala/com/my/nft_fee_transactions/data_l1/Main.scala create mode 100755 examples/nft-fee-transaction/modules/l0/src/main/scala/com/my/nft_fee_transactions/l0/Main.scala create mode 100755 examples/nft-fee-transaction/modules/l0/src/main/scala/com/my/nft_fee_transactions/l0/custom_routes/CustomRoutes.scala create mode 100755 examples/nft-fee-transaction/modules/l1/src/main/scala/com/my/nft_fee_transactions/l1/Main.scala create mode 100755 examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/LifecycleSharedFunctions.scala create mode 100755 examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/Utils.scala create mode 100755 examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/calculated_state/CalculatedState.scala create mode 100755 examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/calculated_state/CalculatedStateService.scala create mode 100755 examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/combiners/Combiners.scala create mode 100755 examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/deserializers/Deserializers.scala create mode 100755 examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/errors/Errors.scala create mode 100755 examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/serializers/Serializers.scala create mode 100755 examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/types/Types.scala create mode 100755 examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/validations/TypeValidators.scala create mode 100755 examples/nft-fee-transaction/modules/shared_data/src/main/scala/com/my/nft_fee_transactions/shared_data/validations/Validations.scala create mode 100755 examples/nft-fee-transaction/project/Dependencies.scala create mode 100755 examples/nft-fee-transaction/project/build.properties create mode 100755 examples/nft-fee-transaction/project/plugins.sbt create mode 100644 examples/nft-fee-transaction/scripts/package.json create mode 100644 examples/nft-fee-transaction/scripts/send_data_transaction.js 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..9ddb75d --- /dev/null +++ b/examples/nft-fee-transaction/build.sbt @@ -0,0 +1,105 @@ +import Dependencies.* +import sbt.* + +ThisBuild / organization := "com.my.nft" +ThisBuild / scalaVersion := "2.13.10" +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 => + 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.tessellationNodeShared + ) + ) +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.tessellationCurrencyL1 + ) + ) + +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.tessellationCurrencyL0 + ) + ) + +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.tessellationCurrencyL1 + ) + ) 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..8b67bc5 --- /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.URL +import scala.util.Try + +object Utils { + def isValidURL(url: String): Boolean = + Try(new URL(url).toURI).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..85eaa77 --- /dev/null +++ b/examples/nft-fee-transaction/project/Dependencies.scala @@ -0,0 +1,44 @@ +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 { + val tessellationNodeShared = tessellation("node-shared") + val tessellationCurrencyL0 = tessellation("currency-l0") + val tessellationCurrencyL1 = tessellation("currency-l1") + 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.2").cross(CrossVersion.full) + ) + + val semanticDB = compilerPlugin( + ("org.scalameta" % "semanticdb-scalac" % "4.7.1").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..8b9a0b0 --- /dev/null +++ b/examples/nft-fee-transaction/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.8.0 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 diff --git a/examples/social/modules/shared_data/src/main/resources/application.conf b/examples/social/modules/shared_data/src/main/resources/application.conf index 92e9e2f..a8da502 100755 --- a/examples/social/modules/shared_data/src/main/resources/application.conf +++ b/examples/social/modules/shared_data/src/main/resources/application.conf @@ -1,5 +1,5 @@ postgres-database { - url = "jdbc:postgresql://postgres-container:5432/social" + url = "jdbc:postgresql://localhost:5432/social" user = "social" password = "social" } \ No newline at end of file From 3a406b0294dd02d38b9a3b5f74959a1824a5ade9 Mon Sep 17 00:00:00 2001 From: OttoBot Date: Mon, 9 Feb 2026 20:16:29 -0600 Subject: [PATCH 2/2] fix(nft-fee-transaction): update for tessellation develop compatibility Changes: - Update sbt from 1.8.0 to 1.9.8 - Update Scala from 2.13.10 to 2.13.18 (match tessellation) - Use tessellation-sdk instead of individual modules (node-shared, currency-l0, currency-l1) - Update kind-projector from 0.13.2 to 0.13.4 - Update semanticdb-scalac from 4.7.1 to 4.14.2 - Fix deprecated URL constructor for Java 21 (use URI.create().toURL) - Add OSGI-INF/MANIFEST.MF assembly merge strategy Tested with tessellation/develop branch using Euclid SDK. All metagraph JARs build successfully. --- examples/nft-fee-transaction/build.sbt | 11 ++++++----- .../my/nft_fee_transactions/shared_data/Utils.scala | 4 ++-- .../nft-fee-transaction/project/Dependencies.scala | 9 ++++----- examples/nft-fee-transaction/project/build.properties | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/examples/nft-fee-transaction/build.sbt b/examples/nft-fee-transaction/build.sbt index 9ddb75d..982ccc4 100755 --- a/examples/nft-fee-transaction/build.sbt +++ b/examples/nft-fee-transaction/build.sbt @@ -2,7 +2,7 @@ import Dependencies.* import sbt.* ThisBuild / organization := "com.my.nft" -ThisBuild / scalaVersion := "2.13.10" +ThisBuild / scalaVersion := "2.13.18" ThisBuild / evictionErrorLevel := Level.Warn ThisBuild / assemblyMergeStrategy := { @@ -10,6 +10,7 @@ ThisBuild / assemblyMergeStrategy := { 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) @@ -36,7 +37,7 @@ lazy val sharedData = (project in file("modules/shared_data")) CompilerPlugin.kindProjector, CompilerPlugin.betterMonadicFor, CompilerPlugin.semanticDB, - Libraries.tessellationNodeShared + Libraries.tessellationSdk ) ) lazy val currencyL1 = (project in file("modules/l1")) @@ -55,7 +56,7 @@ lazy val currencyL1 = (project in file("modules/l1")) CompilerPlugin.kindProjector, CompilerPlugin.betterMonadicFor, CompilerPlugin.semanticDB, - Libraries.tessellationCurrencyL1 + Libraries.tessellationSdk ) ) @@ -79,7 +80,7 @@ lazy val currencyL0 = (project in file("modules/l0")) Libraries.declineRefined, Libraries.declineCore, Libraries.declineEffect, - Libraries.tessellationCurrencyL0 + Libraries.tessellationSdk ) ) @@ -100,6 +101,6 @@ lazy val dataL1 = (project in file("modules/data_l1")) CompilerPlugin.kindProjector, CompilerPlugin.betterMonadicFor, CompilerPlugin.semanticDB, - Libraries.tessellationCurrencyL1 + Libraries.tessellationSdk ) ) 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 index 8b67bc5..98da241 100755 --- 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 @@ -8,12 +8,12 @@ import io.constellationnetwork.schema.address.Address import io.constellationnetwork.security.SecurityProvider import io.constellationnetwork.security.signature.signature.SignatureProof -import java.net.URL +import java.net.URI import scala.util.Try object Utils { def isValidURL(url: String): Boolean = - Try(new URL(url).toURI).isSuccess + Try(URI.create(url).toURL).isSuccess def getAllAddressesFromProofs[F[_] : Async : SecurityProvider]( proofs: NonEmptySet[SignatureProof] diff --git a/examples/nft-fee-transaction/project/Dependencies.scala b/examples/nft-fee-transaction/project/Dependencies.scala index 85eaa77..a68676d 100755 --- a/examples/nft-fee-transaction/project/Dependencies.scala +++ b/examples/nft-fee-transaction/project/Dependencies.scala @@ -15,9 +15,8 @@ object Dependencies { } % V.decline object Libraries { - val tessellationNodeShared = tessellation("node-shared") - val tessellationCurrencyL0 = tessellation("currency-l0") - val tessellationCurrencyL1 = tessellation("currency-l1") + // 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") @@ -34,11 +33,11 @@ object Dependencies { ) val kindProjector = compilerPlugin( - ("org.typelevel" % "kind-projector" % "0.13.2").cross(CrossVersion.full) + ("org.typelevel" % "kind-projector" % "0.13.4").cross(CrossVersion.full) ) val semanticDB = compilerPlugin( - ("org.scalameta" % "semanticdb-scalac" % "4.7.1").cross(CrossVersion.full) + ("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 index 8b9a0b0..abbbce5 100755 --- a/examples/nft-fee-transaction/project/build.properties +++ b/examples/nft-fee-transaction/project/build.properties @@ -1 +1 @@ -sbt.version=1.8.0 +sbt.version=1.9.8