mirror of
https://github.com/OpenBankProject/OBP-API.git
synced 2026-02-06 12:56:51 +00:00
Merge pull request #2620 from hongwei1/develop
feature/OBPv6.0.0 added ETH payments endpoints ETH_SEND_RAW_TRANSACTION and ETH_SEND_TRANSACTION
This commit is contained in:
commit
3b7b31b4f4
199
build.sbt
Normal file
199
build.sbt
Normal file
@ -0,0 +1,199 @@
|
||||
ThisBuild / version := "1.10.1"
|
||||
ThisBuild / scalaVersion := "2.12.20"
|
||||
ThisBuild / organization := "com.tesobe"
|
||||
|
||||
// Java version compatibility
|
||||
ThisBuild / javacOptions ++= Seq("-source", "11", "-target", "11")
|
||||
ThisBuild / scalacOptions ++= Seq(
|
||||
"-unchecked",
|
||||
"-explaintypes",
|
||||
"-target:jvm-1.8",
|
||||
"-Yrangepos"
|
||||
)
|
||||
|
||||
// Enable SemanticDB for Metals
|
||||
ThisBuild / semanticdbEnabled := true
|
||||
ThisBuild / semanticdbVersion := "4.13.9"
|
||||
|
||||
// Fix dependency conflicts
|
||||
ThisBuild / libraryDependencySchemes += "org.scala-lang.modules" %% "scala-xml" % VersionScheme.Always
|
||||
|
||||
lazy val liftVersion = "3.5.0"
|
||||
lazy val akkaVersion = "2.5.32"
|
||||
lazy val jettyVersion = "9.4.50.v20221201"
|
||||
lazy val avroVersion = "1.8.2"
|
||||
|
||||
lazy val commonSettings = Seq(
|
||||
resolvers ++= Seq(
|
||||
"Sonatype OSS Releases" at "https://oss.sonatype.org/content/repositories/releases",
|
||||
"Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots",
|
||||
"Artima Maven Repository" at "https://repo.artima.com/releases",
|
||||
"OpenBankProject M2 Repository" at "https://raw.githubusercontent.com/OpenBankProject/OBP-M2-REPO/master",
|
||||
"jitpack.io" at "https://jitpack.io"
|
||||
)
|
||||
)
|
||||
|
||||
lazy val obpCommons = (project in file("obp-commons"))
|
||||
.settings(
|
||||
commonSettings,
|
||||
name := "obp-commons",
|
||||
libraryDependencies ++= Seq(
|
||||
"net.liftweb" %% "lift-common" % liftVersion,
|
||||
"net.liftweb" %% "lift-util" % liftVersion,
|
||||
"net.liftweb" %% "lift-mapper" % liftVersion,
|
||||
"org.scala-lang" % "scala-reflect" % "2.12.20",
|
||||
"org.scalatest" %% "scalatest" % "3.2.15" % Test,
|
||||
"org.scalactic" %% "scalactic" % "3.2.15",
|
||||
"net.liftweb" %% "lift-json" % liftVersion,
|
||||
"com.alibaba" % "transmittable-thread-local" % "2.11.5",
|
||||
"org.apache.commons" % "commons-lang3" % "3.12.0",
|
||||
"org.apache.commons" % "commons-text" % "1.10.0",
|
||||
"com.google.guava" % "guava" % "32.0.0-jre"
|
||||
)
|
||||
)
|
||||
|
||||
lazy val obpApi = (project in file("obp-api"))
|
||||
.dependsOn(obpCommons)
|
||||
.settings(
|
||||
commonSettings,
|
||||
name := "obp-api",
|
||||
libraryDependencies ++= Seq(
|
||||
// Core dependencies
|
||||
"net.liftweb" %% "lift-mapper" % liftVersion,
|
||||
"net.databinder.dispatch" %% "dispatch-lift-json" % "0.13.1",
|
||||
"ch.qos.logback" % "logback-classic" % "1.2.13",
|
||||
"org.slf4j" % "log4j-over-slf4j" % "1.7.26",
|
||||
"org.slf4j" % "slf4j-ext" % "1.7.26",
|
||||
|
||||
// Security
|
||||
"org.bouncycastle" % "bcpg-jdk15on" % "1.70",
|
||||
"org.bouncycastle" % "bcpkix-jdk15on" % "1.70",
|
||||
"com.nimbusds" % "nimbus-jose-jwt" % "9.37.2",
|
||||
"com.nimbusds" % "oauth2-oidc-sdk" % "9.27",
|
||||
|
||||
// Commons
|
||||
"org.apache.commons" % "commons-lang3" % "3.12.0",
|
||||
"org.apache.commons" % "commons-text" % "1.10.0",
|
||||
"org.apache.commons" % "commons-email" % "1.5",
|
||||
"org.apache.commons" % "commons-compress" % "1.26.0",
|
||||
"org.apache.commons" % "commons-pool2" % "2.11.1",
|
||||
|
||||
// Database
|
||||
"org.postgresql" % "postgresql" % "42.4.4",
|
||||
"com.h2database" % "h2" % "2.2.220" % Runtime,
|
||||
"mysql" % "mysql-connector-java" % "8.0.30",
|
||||
"com.microsoft.sqlserver" % "mssql-jdbc" % "11.2.0.jre11",
|
||||
|
||||
// Web
|
||||
"javax.servlet" % "javax.servlet-api" % "3.1.0" % Provided,
|
||||
"org.eclipse.jetty" % "jetty-server" % jettyVersion % Test,
|
||||
"org.eclipse.jetty" % "jetty-webapp" % jettyVersion % Test,
|
||||
"org.eclipse.jetty" % "jetty-util" % jettyVersion,
|
||||
|
||||
// Akka
|
||||
"com.typesafe.akka" %% "akka-actor" % akkaVersion,
|
||||
"com.typesafe.akka" %% "akka-remote" % akkaVersion,
|
||||
"com.typesafe.akka" %% "akka-slf4j" % akkaVersion,
|
||||
"com.typesafe.akka" %% "akka-http-core" % "10.1.6",
|
||||
|
||||
// Avro
|
||||
"com.sksamuel.avro4s" %% "avro4s-core" % avroVersion,
|
||||
|
||||
// Twitter
|
||||
"com.twitter" %% "chill-akka" % "0.9.1",
|
||||
"com.twitter" %% "chill-bijection" % "0.9.1",
|
||||
|
||||
// Cache
|
||||
"com.github.cb372" %% "scalacache-redis" % "0.9.3",
|
||||
"com.github.cb372" %% "scalacache-guava" % "0.9.3",
|
||||
|
||||
// Utilities
|
||||
"com.github.dwickern" %% "scala-nameof" % "1.0.3",
|
||||
"org.javassist" % "javassist" % "3.25.0-GA",
|
||||
"com.alibaba" % "transmittable-thread-local" % "2.14.2",
|
||||
"org.clapper" %% "classutil" % "1.4.0",
|
||||
"com.github.grumlimited" % "geocalc" % "0.5.7",
|
||||
"com.github.OpenBankProject" % "scala-macros" % "v1.0.0-alpha.3",
|
||||
"org.scalameta" %% "scalameta" % "3.7.4",
|
||||
|
||||
// Akka Adapter - exclude transitive dependency on obp-commons to use local module
|
||||
"com.github.OpenBankProject.OBP-Adapter-Akka-SpringBoot" % "adapter-akka-commons" % "v1.1.0" exclude("com.github.OpenBankProject.OBP-API", "obp-commons"),
|
||||
|
||||
// JSON Schema
|
||||
"com.github.everit-org.json-schema" % "org.everit.json.schema" % "1.6.1",
|
||||
"com.networknt" % "json-schema-validator" % "1.0.87",
|
||||
|
||||
// Swagger
|
||||
"io.swagger.parser.v3" % "swagger-parser" % "2.0.13",
|
||||
|
||||
// Text processing
|
||||
"org.atteo" % "evo-inflector" % "1.2.2",
|
||||
|
||||
// Payment
|
||||
"com.stripe" % "stripe-java" % "12.1.0",
|
||||
"com.twilio.sdk" % "twilio" % "9.2.0",
|
||||
|
||||
// gRPC
|
||||
"com.thesamet.scalapb" %% "scalapb-runtime-grpc" % "0.8.4",
|
||||
"io.grpc" % "grpc-all" % "1.48.1",
|
||||
"io.netty" % "netty-tcnative-boringssl-static" % "2.0.27.Final",
|
||||
"org.asynchttpclient" % "async-http-client" % "2.10.4",
|
||||
|
||||
// Database utilities
|
||||
"org.scalikejdbc" %% "scalikejdbc" % "3.4.0",
|
||||
|
||||
// XML
|
||||
"org.scala-lang.modules" %% "scala-xml" % "1.2.0",
|
||||
|
||||
// IBAN
|
||||
"org.iban4j" % "iban4j" % "3.2.7-RELEASE",
|
||||
|
||||
// JavaScript
|
||||
"org.graalvm.js" % "js" % "22.0.0.2",
|
||||
"org.graalvm.js" % "js-scriptengine" % "22.0.0.2",
|
||||
"ch.obermuhlner" % "java-scriptengine" % "2.0.0",
|
||||
|
||||
// Hydra
|
||||
"sh.ory.hydra" % "hydra-client" % "1.7.0",
|
||||
|
||||
// HTTP
|
||||
"com.squareup.okhttp3" % "okhttp" % "4.12.0",
|
||||
"com.squareup.okhttp3" % "logging-interceptor" % "4.12.0",
|
||||
"org.apache.httpcomponents" % "httpclient" % "4.5.13",
|
||||
|
||||
// RabbitMQ
|
||||
"com.rabbitmq" % "amqp-client" % "5.22.0",
|
||||
"net.liftmodules" %% "amqp_3.1" % "1.5.0",
|
||||
|
||||
// Elasticsearch
|
||||
"org.elasticsearch" % "elasticsearch" % "8.14.0",
|
||||
"com.sksamuel.elastic4s" %% "elastic4s-client-esjava" % "8.5.2",
|
||||
|
||||
// OAuth
|
||||
"oauth.signpost" % "signpost-commonshttp4" % "1.2.1.2",
|
||||
|
||||
// Utilities
|
||||
"cglib" % "cglib" % "3.3.0",
|
||||
"com.sun.activation" % "jakarta.activation" % "1.2.2",
|
||||
"com.nulab-inc" % "zxcvbn" % "1.9.0",
|
||||
|
||||
// Testing - temporarily disabled due to version incompatibility
|
||||
// "org.scalatest" %% "scalatest" % "2.2.6" % Test,
|
||||
|
||||
// Jackson
|
||||
"com.fasterxml.jackson.core" % "jackson-databind" % "2.12.7.1",
|
||||
|
||||
// Flexmark (markdown processing)
|
||||
"com.vladsch.flexmark" % "flexmark-profile-pegdown" % "0.40.8",
|
||||
"com.vladsch.flexmark" % "flexmark-util-options" % "0.64.0",
|
||||
|
||||
// Connection pool
|
||||
"com.zaxxer" % "HikariCP" % "4.0.3",
|
||||
|
||||
// Test dependencies
|
||||
"junit" % "junit" % "4.13.2" % Test,
|
||||
"org.scalatest" %% "scalatest" % "3.2.15" % Test,
|
||||
"org.seleniumhq.selenium" % "htmlunit-driver" % "2.36.0" % Test,
|
||||
"org.testcontainers" % "rabbitmq" % "1.20.3" % Test
|
||||
)
|
||||
)
|
||||
@ -329,6 +329,12 @@
|
||||
<artifactId>flexmark-util-options</artifactId>
|
||||
<version>0.64.0</version>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/org.web3j/core -->
|
||||
<dependency>
|
||||
<groupId>org.web3j</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>4.9.8</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.zaxxer</groupId>
|
||||
<artifactId>HikariCP</artifactId>
|
||||
|
||||
@ -1517,6 +1517,18 @@ regulated_entities = []
|
||||
# super_admin_email=tom@tesobe.com
|
||||
|
||||
|
||||
## Ethereum Connector Configuration
|
||||
## ================================
|
||||
## The Ethereum connector uses JSON-RPC to communicate with Ethereum nodes.
|
||||
## It supports two transaction modes:
|
||||
## 1) eth_sendRawTransaction - for pre-signed transactions (recommended for production)
|
||||
## 2) eth_sendTransaction - for unlocked accounts (development/test environments)
|
||||
##
|
||||
## Ethereum RPC endpoint URL
|
||||
## Default: http://127.0.0.1:8545 (local Ethereum node)
|
||||
ethereum.rpc.url=http://127.0.0.1:8545
|
||||
|
||||
|
||||
# Note: For secure and http only settings for cookies see resources/web.xml which is mentioned in the README.md
|
||||
|
||||
|
||||
|
||||
@ -29,7 +29,6 @@ import com.openbankproject.commons.model._
|
||||
import com.openbankproject.commons.model.enums.TransactionRequestTypes._
|
||||
import com.openbankproject.commons.model.enums.{AttributeCategory, CardAttributeType, ChallengeType, TransactionRequestStatus}
|
||||
import com.openbankproject.commons.util.{ApiVersion, FieldNameApiVersions, ReflectUtils}
|
||||
import net.liftweb.common.Full
|
||||
import net.liftweb.json
|
||||
|
||||
import java.net.URLEncoder
|
||||
@ -5759,6 +5758,16 @@ object SwaggerDefinitionsJSON {
|
||||
description = descriptionExample.value,
|
||||
metadata = Some(Map("202507022319" -> cardanoMetadataStringJsonV600))
|
||||
)
|
||||
|
||||
lazy val transactionRequestBodyEthereumJsonV600 = TransactionRequestBodyEthereumJsonV600(
|
||||
to = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
|
||||
value = AmountOfMoneyJsonV121("ETH", "0.01"),
|
||||
description = descriptionExample.value
|
||||
)
|
||||
lazy val transactionRequestBodyEthSendRawTransactionJsonV600 = TransactionRequestBodyEthSendRawTransactionJsonV600(
|
||||
params = "0xf86b018203e882520894627306090abab3a6e1400e9345bc60c78a8bef57880de0b6b3a764000080820ff6a0d0367709eee090a6ebd74c63db7329372db1966e76d28ce219d1e105c47bcba7a0042d52f7d2436ad96e8714bf0309adaf870ad6fb68cfe53ce958792b3da36c12",
|
||||
description = descriptionExample.value
|
||||
)
|
||||
|
||||
//The common error or success format.
|
||||
//Just some helper format to use in Json
|
||||
|
||||
@ -79,7 +79,7 @@ object ErrorMessages {
|
||||
// General messages (OBP-10XXX)
|
||||
val InvalidJsonFormat = "OBP-10001: Incorrect json format."
|
||||
val InvalidNumber = "OBP-10002: Invalid Number. Could not convert value to a number."
|
||||
val InvalidISOCurrencyCode = """OBP-10003: Invalid Currency Value. Expected a 3-letter ISO Currency Code (e.g., 'USD', 'EUR') or 'lovelace' for Cardano transactions.""".stripMargin
|
||||
val InvalidISOCurrencyCode = """OBP-10003: Invalid Currency Value. Expected a 3-letter ISO Currency Code (e.g., 'USD', 'EUR'), 'lovelace' (Cardano), or 'ETH' (Ethereum). Refer to ISO 4217 currency codes: https://www.iso.org/iso-4217-currency-codes.html""".stripMargin
|
||||
val FXCurrencyCodeCombinationsNotSupported = "OBP-10004: ISO Currency code combination not supported for FX. Please modify the FROM_CURRENCY_CODE or TO_CURRENCY_CODE. "
|
||||
val InvalidDateFormat = "OBP-10005: Invalid Date Format. Could not convert value to a Date."
|
||||
val InvalidCurrency = "OBP-10006: Invalid Currency Value."
|
||||
|
||||
@ -802,7 +802,8 @@ object NewStyle extends MdcLoggable{
|
||||
Failure(s"$failMsg. Details: ${e.getMessage}", Full(e), Empty)
|
||||
}
|
||||
} map {
|
||||
x => unboxFullOrFail(x, callContext, failMsg, failCode)
|
||||
x =>
|
||||
unboxFullOrFail(x, callContext, failMsg, failCode)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,11 +14,11 @@ import code.bankconnectors.LocalMappedConnectorInternal._
|
||||
import code.entitlement.Entitlement
|
||||
import code.views.Views
|
||||
import com.github.dwickern.macros.NameOf.nameOf
|
||||
import com.openbankproject.commons.ExecutionContext.Implicits.global
|
||||
import com.openbankproject.commons.model._
|
||||
import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion}
|
||||
import net.liftweb.common.Full
|
||||
import net.liftweb.http.rest.RestHelper
|
||||
import com.openbankproject.commons.ExecutionContext.Implicits.global
|
||||
|
||||
import scala.collection.immutable.{List, Nil}
|
||||
import scala.collection.mutable.ArrayBuffer
|
||||
@ -160,6 +160,85 @@ trait APIMethods600 {
|
||||
val transactionRequestType = TransactionRequestType("CARDANO")
|
||||
LocalMappedConnectorInternal.createTransactionRequest(bankId, accountId, viewId , transactionRequestType, json)
|
||||
}
|
||||
|
||||
staticResourceDocs += ResourceDoc(
|
||||
createTransactionRequestEthereumeSendTransaction,
|
||||
implementedInApiVersion,
|
||||
nameOf(createTransactionRequestEthereumeSendTransaction),
|
||||
"POST",
|
||||
"/banks/BANK_ID/accounts/ACCOUNT_ID/owner/transaction-request-types/ETH_SEND_TRANSACTION/transaction-requests",
|
||||
"Create Transaction Request (ETH_SEND_TRANSACTION)",
|
||||
s"""
|
||||
|
|
||||
|Send ETH via Ethereum JSON-RPC.
|
||||
|AccountId should hold the 0x address for now.
|
||||
|
|
||||
|${transactionRequestGeneralText}
|
||||
|
|
||||
""".stripMargin,
|
||||
transactionRequestBodyEthereumJsonV600,
|
||||
transactionRequestWithChargeJSON400,
|
||||
List(
|
||||
$UserNotLoggedIn,
|
||||
$BankNotFound,
|
||||
$BankAccountNotFound,
|
||||
InsufficientAuthorisationToCreateTransactionRequest,
|
||||
InvalidTransactionRequestType,
|
||||
InvalidJsonFormat,
|
||||
NotPositiveAmount,
|
||||
InvalidTransactionRequestCurrency,
|
||||
TransactionDisabled,
|
||||
UnknownError
|
||||
),
|
||||
List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2)
|
||||
)
|
||||
|
||||
lazy val createTransactionRequestEthereumeSendTransaction: OBPEndpoint = {
|
||||
case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: ViewId(viewId) :: "transaction-request-types" ::
|
||||
"ETH_SEND_TRANSACTION" :: "transaction-requests" :: Nil JsonPost json -> _ =>
|
||||
cc => implicit val ec = EndpointContext(Some(cc))
|
||||
val transactionRequestType = TransactionRequestType("ETH_SEND_TRANSACTION")
|
||||
LocalMappedConnectorInternal.createTransactionRequest(bankId, accountId, viewId , transactionRequestType, json)
|
||||
}
|
||||
staticResourceDocs += ResourceDoc(
|
||||
createTransactionRequestEthSendRawTransaction,
|
||||
implementedInApiVersion,
|
||||
nameOf(createTransactionRequestEthSendRawTransaction),
|
||||
"POST",
|
||||
"/banks/BANK_ID/accounts/ACCOUNT_ID/owner/transaction-request-types/ETH_SEND_RAW_TRANSACTION/transaction-requests",
|
||||
"CREATE TRANSACTION REQUEST (ETH_SEND_RAW_TRANSACTION )",
|
||||
s"""
|
||||
|
|
||||
|Send ETH via Ethereum JSON-RPC.
|
||||
|AccountId should hold the 0x address for now.
|
||||
|
|
||||
|${transactionRequestGeneralText}
|
||||
|
|
||||
""".stripMargin,
|
||||
transactionRequestBodyEthSendRawTransactionJsonV600,
|
||||
transactionRequestWithChargeJSON400,
|
||||
List(
|
||||
$UserNotLoggedIn,
|
||||
$BankNotFound,
|
||||
$BankAccountNotFound,
|
||||
InsufficientAuthorisationToCreateTransactionRequest,
|
||||
InvalidTransactionRequestType,
|
||||
InvalidJsonFormat,
|
||||
NotPositiveAmount,
|
||||
InvalidTransactionRequestCurrency,
|
||||
TransactionDisabled,
|
||||
UnknownError
|
||||
),
|
||||
List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2)
|
||||
)
|
||||
|
||||
lazy val createTransactionRequestEthSendRawTransaction: OBPEndpoint = {
|
||||
case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: ViewId(viewId) :: "transaction-request-types" ::
|
||||
"ETH_SEND_RAW_TRANSACTION" :: "transaction-requests" :: Nil JsonPost json -> _ =>
|
||||
cc => implicit val ec = EndpointContext(Some(cc))
|
||||
val transactionRequestType = TransactionRequestType("ETH_SEND_RAW_TRANSACTION")
|
||||
LocalMappedConnectorInternal.createTransactionRequest(bankId, accountId, viewId , transactionRequestType, json)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,6 +65,20 @@ case class TransactionRequestBodyCardanoJsonV600(
|
||||
metadata: Option[Map[String, CardanoMetadataStringJsonV600]] = None
|
||||
) extends TransactionRequestCommonBodyJSON
|
||||
|
||||
// ---------------- Ethereum models (V600) ----------------
|
||||
case class TransactionRequestBodyEthereumJsonV600(
|
||||
params: Option[String] = None,// This is for eth_sendRawTransaction
|
||||
to: String, // this is for eth_sendTransaction eg: 0x addressk
|
||||
value: AmountOfMoneyJsonV121, // currency should be "ETH"; amount string (decimal)
|
||||
description: String
|
||||
) extends TransactionRequestCommonBodyJSON
|
||||
|
||||
// This is only for the request JSON body; we will construct `TransactionRequestBodyEthereumJsonV600` for OBP.
|
||||
case class TransactionRequestBodyEthSendRawTransactionJsonV600(
|
||||
params: String, // eth_sendRawTransaction params field.
|
||||
description: String
|
||||
)
|
||||
|
||||
case class UserJsonV600(
|
||||
user_id: String,
|
||||
email : String,
|
||||
|
||||
@ -10,6 +10,7 @@ import code.atmattribute.AtmAttribute
|
||||
import code.bankattribute.BankAttribute
|
||||
import code.bankconnectors.akka.AkkaConnector_vDec2018
|
||||
import code.bankconnectors.cardano.CardanoConnector_vJun2025
|
||||
import code.bankconnectors.ethereum.EthereumConnector_vSept2025
|
||||
import code.bankconnectors.rabbitmq.RabbitMQConnector_vOct2024
|
||||
import code.bankconnectors.rest.RestConnector_vMar2019
|
||||
import code.bankconnectors.storedprocedure.StoredProcedureConnector_vDec2019
|
||||
@ -63,6 +64,7 @@ object Connector extends SimpleInjector {
|
||||
"stored_procedure_vDec2019" -> StoredProcedureConnector_vDec2019,
|
||||
"rabbitmq_vOct2024" -> RabbitMQConnector_vOct2024,
|
||||
"cardano_vJun2025" -> CardanoConnector_vJun2025,
|
||||
"ethereum_vSept2025" -> EthereumConnector_vSept2025,
|
||||
// this proxy connector only for unit test, can set connector=proxy in test.default.props, but never set it in default.props
|
||||
"proxy" -> ConnectorUtils.proxyConnector,
|
||||
// internal is the dynamic connector, the developers can upload the source code and override connector method themselves.
|
||||
|
||||
@ -60,6 +60,7 @@ import code.users.{UserAttribute, UserAttributeProvider, Users}
|
||||
import code.util.Helper
|
||||
import code.util.Helper._
|
||||
import code.views.Views
|
||||
import com.github.dwickern.macros.NameOf.nameOf
|
||||
import com.openbankproject.commons.ExecutionContext.Implicits.global
|
||||
import com.openbankproject.commons.dto.{CustomerAndAttribute, GetProductsParam, ProductCollectionItemsTree}
|
||||
import com.openbankproject.commons.model._
|
||||
@ -165,6 +166,10 @@ object LocalMappedConnector extends Connector with MdcLoggable {
|
||||
isValidCurrencyISOCode(thresholdCurrency) match {
|
||||
case true if((currency.toLowerCase.equals("lovelace")||(currency.toLowerCase.equals("ada")))) =>
|
||||
(Full(AmountOfMoney(currency, "10000000000000")), callContext)
|
||||
case true if(currency.equalsIgnoreCase("ETH")) =>
|
||||
// For ETH, skip FX conversion and return a large threshold in wei-equivalent semantic (string value).
|
||||
// Here we use a high number to effectively avoid challenge for typical dev/testing amounts.
|
||||
(Full(AmountOfMoney("ETH", "10000")), callContext)
|
||||
case true =>
|
||||
fx.exchangeRate(thresholdCurrency, currency, Some(bankId), callContext) match {
|
||||
case rate@Some(_) =>
|
||||
@ -4539,7 +4544,7 @@ object LocalMappedConnector extends Connector with MdcLoggable {
|
||||
|
||||
// Get the threshold for a challenge. i.e. over what value do we require an out of Band security challenge to be sent?
|
||||
(challengeThreshold, callContext) <- Connector.connector.vend.getChallengeThreshold(fromAccount.bankId.value, fromAccount.accountId.value, viewId.value, transactionRequestType.value, transactionRequestCommonBody.value.currency, initiator.userId, initiator.name, callContext) map { i =>
|
||||
(unboxFullOrFail(i._1, callContext, s"$InvalidConnectorResponseForGetChallengeThreshold ", 400), i._2)
|
||||
(unboxFullOrFail(i._1, callContext, s"$InvalidConnectorResponseForGetChallengeThreshold - ${nameOf(getChallengeThreshold _)}", 400), i._2)
|
||||
}
|
||||
challengeThresholdAmount <- NewStyle.function.tryons(s"$InvalidConnectorResponseForGetChallengeThreshold. challengeThreshold amount ${challengeThreshold.amount} not convertible to number", 400, callContext) {
|
||||
BigDecimal(challengeThreshold.amount)
|
||||
@ -4680,7 +4685,7 @@ object LocalMappedConnector extends Connector with MdcLoggable {
|
||||
|
||||
// Get the threshold for a challenge. i.e. over what value do we require an out of Band security challenge to be sent?
|
||||
(challengeThreshold, callContext) <- Connector.connector.vend.getChallengeThreshold(fromAccount.bankId.value, fromAccount.accountId.value, viewId.value, transactionRequestType.value, transactionRequestCommonBody.value.currency, initiator.userId, initiator.name, callContext) map { i =>
|
||||
(unboxFullOrFail(i._1, callContext, s"$InvalidConnectorResponseForGetChallengeThreshold ", 400), i._2)
|
||||
(unboxFullOrFail(i._1, callContext, s"$InvalidConnectorResponseForGetChallengeThreshold - ${nameOf(getChallengeThreshold _)}", 400), i._2)
|
||||
}
|
||||
challengeThresholdAmount <- NewStyle.function.tryons(s"$InvalidConnectorResponseForGetChallengeThreshold. challengeThreshold amount ${challengeThreshold.amount} not convertible to number", 400, callContext) {
|
||||
BigDecimal(challengeThreshold.amount)
|
||||
|
||||
@ -13,7 +13,8 @@ import code.api.util.newstyle.ViewNewStyle
|
||||
import code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140
|
||||
import code.api.v2_1_0._
|
||||
import code.api.v4_0_0._
|
||||
import code.api.v6_0_0.TransactionRequestBodyCardanoJsonV600
|
||||
import code.api.v6_0_0.{TransactionRequestBodyCardanoJsonV600, TransactionRequestBodyEthSendRawTransactionJsonV600, TransactionRequestBodyEthereumJsonV600}
|
||||
import code.bankconnectors.ethereum.DecodeRawTx
|
||||
import code.branches.MappedBranch
|
||||
import code.fx.fx
|
||||
import code.fx.fx.TTL
|
||||
@ -128,7 +129,7 @@ object LocalMappedConnectorInternal extends MdcLoggable {
|
||||
user.name,
|
||||
callContext
|
||||
) map { i =>
|
||||
(unboxFullOrFail(i._1, callContext, s"$InvalidConnectorResponseForGetChallengeThreshold ", 400), i._2)
|
||||
(unboxFullOrFail(i._1, callContext, s"$InvalidConnectorResponseForGetChallengeThreshold - ${nameOf(Connector.connector.vend.getChallengeThreshold _)}", 400), i._2)
|
||||
}
|
||||
challengeThresholdAmount <- NewStyle.function.tryons(s"$InvalidConnectorResponseForGetChallengeThreshold. challengeThreshold amount ${challengeThreshold.amount} not convertible to number", 400, callContext) {
|
||||
BigDecimal(challengeThreshold.amount)
|
||||
@ -773,8 +774,37 @@ object LocalMappedConnectorInternal extends MdcLoggable {
|
||||
}
|
||||
|
||||
// Check the input JSON format, here is just check the common parts of all four types
|
||||
transDetailsJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $TransactionRequestBodyCommonJSON ", 400, callContext) {
|
||||
json.extract[TransactionRequestBodyCommonJSON]
|
||||
transDetailsJson <- transactionRequestTypeValue match {
|
||||
case ETH_SEND_RAW_TRANSACTION => for {
|
||||
// Parse raw transaction JSON
|
||||
transactionRequestBodyEthSendRawTransactionJsonV600 <- NewStyle.function.tryons(
|
||||
s"$InvalidJsonFormat It should be $TransactionRequestBodyEthSendRawTransactionJsonV600 json format",
|
||||
400,
|
||||
callContext
|
||||
) {
|
||||
json.extract[TransactionRequestBodyEthSendRawTransactionJsonV600]
|
||||
}
|
||||
// Decode raw transaction to extract 'from' address
|
||||
decodedTx = DecodeRawTx.decodeRawTxToJson(transactionRequestBodyEthSendRawTransactionJsonV600.params)
|
||||
from = decodedTx.from
|
||||
_ <- Helper.booleanToFuture(
|
||||
s"$BankAccountNotFoundByAccountId Ethereum 'from' address must be the same as the accountId",
|
||||
cc = callContext
|
||||
) {
|
||||
from.getOrElse("") == accountId.value
|
||||
}
|
||||
// Construct TransactionRequestBodyEthereumJsonV600 for downstream processing
|
||||
transactionRequestBodyEthereum = TransactionRequestBodyEthereumJsonV600(
|
||||
params = Some(transactionRequestBodyEthSendRawTransactionJsonV600.params),
|
||||
to = decodedTx.to.getOrElse(""),
|
||||
value = AmountOfMoneyJsonV121("ETH", decodedTx.value.getOrElse("0")),
|
||||
description = transactionRequestBodyEthSendRawTransactionJsonV600.description
|
||||
)
|
||||
} yield (transactionRequestBodyEthereum)
|
||||
case _ =>
|
||||
NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $TransactionRequestBodyCommonJSON ", 400, callContext) {
|
||||
json.extract[TransactionRequestBodyCommonJSON]
|
||||
}
|
||||
}
|
||||
|
||||
transactionAmountNumber <- NewStyle.function.tryons(s"$InvalidNumber Current input is ${transDetailsJson.value.amount} ", 400, callContext) {
|
||||
@ -785,9 +815,12 @@ object LocalMappedConnectorInternal extends MdcLoggable {
|
||||
transactionAmountNumber > BigDecimal("0")
|
||||
}
|
||||
|
||||
_ <- Helper.booleanToFuture(s"${InvalidISOCurrencyCode} Current input is: '${transDetailsJson.value.currency}'", cc=callContext) {
|
||||
APIUtil.isValidCurrencyISOCode(transDetailsJson.value.currency)
|
||||
}
|
||||
_ <- (transactionRequestTypeValue match {
|
||||
case ETH_SEND_RAW_TRANSACTION | ETH_SEND_TRANSACTION => Future.successful(true) // Allow ETH (non-ISO) for Ethereum requests
|
||||
case _ => Helper.booleanToFuture(s"${InvalidISOCurrencyCode} Current input is: '${transDetailsJson.value.currency}'", cc=callContext) {
|
||||
APIUtil.isValidCurrencyISOCode(transDetailsJson.value.currency)
|
||||
}
|
||||
})
|
||||
|
||||
(createdTransactionRequest, callContext) <- transactionRequestTypeValue match {
|
||||
case REFUND => {
|
||||
@ -1411,15 +1444,15 @@ object LocalMappedConnectorInternal extends MdcLoggable {
|
||||
thisBankId = bankId.value,
|
||||
thisAccountId = accountId.value,
|
||||
thisViewId = viewId.value,
|
||||
otherBankRoutingScheme = "",
|
||||
otherBankRoutingAddress = "",
|
||||
otherBranchRoutingScheme = "",
|
||||
otherBranchRoutingAddress = "",
|
||||
otherAccountRoutingScheme = "",
|
||||
otherAccountRoutingAddress = "",
|
||||
otherBankRoutingScheme = CARDANO.toString,
|
||||
otherBankRoutingAddress = transactionRequestBodyCardano.to.address,
|
||||
otherBranchRoutingScheme = CARDANO.toString,
|
||||
otherBranchRoutingAddress = transactionRequestBodyCardano.to.address,
|
||||
otherAccountRoutingScheme = CARDANO.toString,
|
||||
otherAccountRoutingAddress = transactionRequestBodyCardano.to.address,
|
||||
otherAccountSecondaryRoutingScheme = "cardano",
|
||||
otherAccountSecondaryRoutingAddress = transactionRequestBodyCardano.to.address,
|
||||
callContext: Option[CallContext],
|
||||
callContext = callContext
|
||||
)
|
||||
(toAccount, callContext) <- NewStyle.function.getBankAccountFromCounterparty(toCounterparty, true, callContext)
|
||||
// Check we can send money to it.
|
||||
@ -1444,6 +1477,77 @@ object LocalMappedConnectorInternal extends MdcLoggable {
|
||||
callContext)
|
||||
} yield (createdTransactionRequest, callContext)
|
||||
}
|
||||
case ETH_SEND_RAW_TRANSACTION | ETH_SEND_TRANSACTION => {
|
||||
for {
|
||||
// Handle ETH_SEND_RAW_TRANSACTION and ETH_SEND_TRANSACTION types with proper extraction and validation
|
||||
(transactionRequestBodyEthereum, scheme) <-
|
||||
if (transactionRequestTypeValue == ETH_SEND_RAW_TRANSACTION) {
|
||||
Future.successful{(transDetailsJson.asInstanceOf[TransactionRequestBodyEthereumJsonV600], ETH_SEND_RAW_TRANSACTION.toString)}
|
||||
} else {
|
||||
for {
|
||||
transactionRequestBodyEthereum <- NewStyle.function.tryons(
|
||||
s"$InvalidJsonFormat It should be $TransactionRequestBodyEthereumJsonV600 json format",
|
||||
400,
|
||||
callContext
|
||||
) {
|
||||
json.extract[TransactionRequestBodyEthereumJsonV600]
|
||||
}
|
||||
} yield (transactionRequestBodyEthereum, ETH_SEND_TRANSACTION.toString)
|
||||
}
|
||||
|
||||
// Basic validations
|
||||
_ <- Helper.booleanToFuture(s"$InvalidJsonValue Ethereum 'to' address is required", cc=callContext) {
|
||||
Option(transactionRequestBodyEthereum.to).exists(_.nonEmpty)
|
||||
}
|
||||
_ <- Helper.booleanToFuture(s"$InvalidJsonValue Ethereum 'to' address must start with 0x and be 42 chars", cc=callContext) {
|
||||
val toBody = transactionRequestBodyEthereum.to
|
||||
toBody.startsWith("0x") && toBody.length == 42
|
||||
}
|
||||
_ <- Helper.booleanToFuture(s"$InvalidTransactionRequestCurrency Currency must be 'ETH'", cc=callContext) {
|
||||
transactionRequestBodyEthereum.value.currency.equalsIgnoreCase("ETH")
|
||||
}
|
||||
|
||||
// Create or get counterparty using the Ethereum address as secondary routing
|
||||
(toCounterparty, callContext) <- NewStyle.function.getOrCreateCounterparty(
|
||||
name = "ethereum-" + transactionRequestBodyEthereum.to.take(27),
|
||||
description = transactionRequestBodyEthereum.description,
|
||||
currency = transactionRequestBodyEthereum.value.currency,
|
||||
createdByUserId = u.userId,
|
||||
thisBankId = bankId.value,
|
||||
thisAccountId = accountId.value,
|
||||
thisViewId = viewId.value,
|
||||
otherBankRoutingScheme = scheme,
|
||||
otherBankRoutingAddress = transactionRequestBodyEthereum.to,
|
||||
otherBranchRoutingScheme = scheme,
|
||||
otherBranchRoutingAddress = transactionRequestBodyEthereum.to,
|
||||
otherAccountRoutingScheme = scheme,
|
||||
otherAccountRoutingAddress = transactionRequestBodyEthereum.to,
|
||||
otherAccountSecondaryRoutingScheme = scheme,
|
||||
otherAccountSecondaryRoutingAddress = transactionRequestBodyEthereum.to,
|
||||
callContext = callContext
|
||||
)
|
||||
|
||||
(toAccount, callContext) <- NewStyle.function.getBankAccountFromCounterparty(toCounterparty, true, callContext)
|
||||
_ <- Helper.booleanToFuture(s"$CounterpartyBeneficiaryPermit", cc=callContext) { toCounterparty.isBeneficiary }
|
||||
|
||||
chargePolicy = sharedChargePolicy.toString
|
||||
transDetailsSerialized <- NewStyle.function.tryons(UnknownError, 400, callContext) {
|
||||
write(transactionRequestBodyEthereum)(Serialization.formats(NoTypeHints))
|
||||
}
|
||||
(createdTransactionRequest, callContext) <- NewStyle.function.createTransactionRequestv400(u,
|
||||
viewId,
|
||||
fromAccount,
|
||||
toAccount,
|
||||
transactionRequestType,
|
||||
transactionRequestBodyEthereum,
|
||||
transDetailsSerialized,
|
||||
chargePolicy,
|
||||
Some(OBP_TRANSACTION_REQUEST_CHALLENGE),
|
||||
getScaMethodAtInstance(transactionRequestType.value).toOption,
|
||||
None,
|
||||
callContext)
|
||||
} yield (createdTransactionRequest, callContext)
|
||||
}
|
||||
}
|
||||
(challenges, callContext) <- NewStyle.function.getChallengesByTransactionRequestId(createdTransactionRequest.id.value, callContext)
|
||||
(transactionRequestAttributes, callContext) <- NewStyle.function.getTransactionRequestAttributes(
|
||||
|
||||
@ -0,0 +1,177 @@
|
||||
package code.bankconnectors.ethereum
|
||||
|
||||
import java.math.BigInteger
|
||||
import org.web3j.crypto.{Hash, RawTransaction, TransactionDecoder, Sign, SignedRawTransaction}
|
||||
import org.web3j.utils.{Numeric => W3Numeric}
|
||||
import net.liftweb.json._
|
||||
|
||||
object DecodeRawTx {
|
||||
|
||||
// Legacy (type 0)
|
||||
// Mandatory: nonce, gasPrice, gasLimit, value (can be 0), data (can be 0x), v, r, s
|
||||
// Conditional: to (present for transfers/calls; omitted for contract creation where data is init code)
|
||||
// Optional/Recommended: chainId (EIP-155 replay protection; legacy pre‑155 may omit)
|
||||
// EIP-2930 (type 1)
|
||||
// Mandatory: chainId, nonce, gasPrice, gasLimit, accessList (can be empty []), v/r/s (or yParity+r+s)
|
||||
// Conditional: to (omit for contract creation), value (can be 0), data (can be 0x)
|
||||
// EIP-1559 (type 2)
|
||||
// Mandatory: chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, accessList (can be empty []), v/r/s (or yParity+r+s)
|
||||
// Conditional: to (omit for contract creation), value (can be 0), data (can be 0x)
|
||||
// Derived (not part of signed payload)
|
||||
// hash: derived from raw tx
|
||||
// from: recovered from signature
|
||||
// estimatedFee: computed (gasLimit × gasPrice or gasUsed × price at execution)
|
||||
// type: 0/1/2 (0 is implicit for legacy)
|
||||
//
|
||||
// Case class representing the decoded transaction JSON structure
|
||||
case class DecodedTxResponse(
|
||||
hash: Option[String],
|
||||
`type`: Option[Int],
|
||||
chainId: Option[Long],
|
||||
nonce: Option[Long],
|
||||
gasPrice: Option[String],
|
||||
gas: Option[String],
|
||||
to: Option[String],
|
||||
value: Option[String],
|
||||
input: Option[String],
|
||||
from: Option[String],
|
||||
v: Option[String],
|
||||
r: Option[String],
|
||||
s: Option[String],
|
||||
estimatedFee: Option[String]
|
||||
)
|
||||
|
||||
private def fatal(msg: String): Nothing = {
|
||||
Console.err.println(msg)
|
||||
sys.exit(1)
|
||||
}
|
||||
|
||||
// File/stdin helpers removed; input is provided as a function parameter now.
|
||||
|
||||
private def normalizeHex(hex: String): String = {
|
||||
val h = Option(hex).getOrElse("").trim
|
||||
if (!h.startsWith("0x")) fatal("Input must start with 0x")
|
||||
val body = h.drop(2)
|
||||
if (!body.matches("[0-9a-fA-F]+")) fatal("Invalid hex characters in input")
|
||||
if (body.length % 2 != 0) fatal("Hex string length must be even")
|
||||
"0x" + body.toLowerCase
|
||||
}
|
||||
|
||||
private def detectType(hex: String): Int = {
|
||||
val body = hex.stripPrefix("0x")
|
||||
if (body.startsWith("02")) 2
|
||||
else if (body.startsWith("01")) 1
|
||||
else 0
|
||||
}
|
||||
|
||||
// Build EIP-155 v when chainId is available; parity from v-byte (27/28 -> 0/1, otherwise lowest bit)
|
||||
private def vToHex(sig: Sign.SignatureData, chainIdOpt: Option[BigInteger]): String = {
|
||||
val vb: Int = {
|
||||
val arr = sig.getV
|
||||
if (arr != null && arr.length > 0) java.lang.Byte.toUnsignedInt(arr(0)) else 0
|
||||
}
|
||||
chainIdOpt match {
|
||||
case Some(cid) if cid.signum() > 0 =>
|
||||
val parity = if (vb == 27 || vb == 28) vb - 27 else (vb & 1)
|
||||
val v = cid.multiply(BigInteger.valueOf(2)).add(BigInteger.valueOf(35L + parity))
|
||||
"0x" + v.toString(16)
|
||||
case _ =>
|
||||
"0x" + Integer.toHexString(vb)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode raw Ethereum transaction hex and return a JSON string summarizing the fields.
|
||||
* The input must be a 0x-prefixed hex string; no file or stdin reading is performed.
|
||||
*
|
||||
* Response is serialized from DecodedTxResponse case class with types:
|
||||
* - type, chainId, nonce, value are numeric (where available)
|
||||
* - gasPrice, gas, v, r, s, estimatedFee are hex strings with 0x prefix (where available)
|
||||
* - input is always a string
|
||||
*/
|
||||
def decodeRawTxToJson(rawIn: String): DecodedTxResponse = {
|
||||
implicit val formats: Formats = DefaultFormats
|
||||
val rawHex = normalizeHex(rawIn)
|
||||
val txType = Some(detectType(rawHex))
|
||||
|
||||
val decoded: RawTransaction =
|
||||
try TransactionDecoder.decode(rawHex)
|
||||
catch {
|
||||
case e: Throwable => fatal(s"decode failed: ${e.getMessage}")
|
||||
}
|
||||
|
||||
val (fromOpt, chainIdOpt, vHexOpt, rHexOpt, sHexOpt):
|
||||
(Option[String], Option[BigInteger], Option[String], Option[String], Option[String]) = decoded match {
|
||||
case srt: SignedRawTransaction =>
|
||||
val from = Option(srt.getFrom)
|
||||
val cid: Option[BigInteger] = try {
|
||||
val c = srt.getChainId // long in web3j 4.x; -1 if absent
|
||||
if (c > 0L) Some(BigInteger.valueOf(c)) else None
|
||||
} catch {
|
||||
case _: Throwable => None
|
||||
}
|
||||
val sig = srt.getSignatureData
|
||||
val vH = vToHex(sig, cid)
|
||||
val rH = W3Numeric.toHexString(sig.getR)
|
||||
val sH = W3Numeric.toHexString(sig.getS)
|
||||
(from, cid, Some(vH), Some(rH), Some(sH))
|
||||
case _ =>
|
||||
(None, None, None, None, None)
|
||||
}
|
||||
|
||||
val hash = Hash.sha3(rawHex)
|
||||
val gasPriceHexOpt: Option[String] = Option(decoded.getGasPrice).map(W3Numeric.toHexStringWithPrefix)
|
||||
val gasLimitHexOpt: Option[String] = Option(decoded.getGasLimit).map(W3Numeric.toHexStringWithPrefix)
|
||||
// Convert value from WEI (BigInt) to ETH (BigDecimal) with 18 decimals
|
||||
val valueDecOpt: Option[String] = Option(decoded.getValue).map { wei =>
|
||||
(BigDecimal(wei) / BigDecimal("1000000000000000000")).toString()
|
||||
}
|
||||
val nonceDecOpt: Option[Long] = Option(decoded.getNonce).map(_.longValue())
|
||||
val toAddrOpt: Option[String] = Option(decoded.getTo)
|
||||
val inputData = Option(decoded.getData).getOrElse("0x")
|
||||
|
||||
val estimatedFeeHexOpt =
|
||||
for {
|
||||
gp <- Option(decoded.getGasPrice)
|
||||
gl <- Option(decoded.getGasLimit)
|
||||
} yield W3Numeric.toHexStringWithPrefix(gp.multiply(gl))
|
||||
|
||||
// Fallback: derive chainId from v when not provided by decoder (legacy EIP-155)
|
||||
val chainIdNumOpt: Option[Long] = chainIdOpt.map(_.longValue()).orElse {
|
||||
vHexOpt.flatMap { vh =>
|
||||
val hex = vh.stripPrefix("0x")
|
||||
if (hex.nonEmpty) {
|
||||
val vBI = new BigInteger(hex, 16)
|
||||
if (vBI.compareTo(BigInteger.valueOf(35)) >= 0) {
|
||||
val parity = if (vBI.testBit(0)) 1L else 0L
|
||||
Some(
|
||||
vBI
|
||||
.subtract(BigInteger.valueOf(35L + parity))
|
||||
.divide(BigInteger.valueOf(2L))
|
||||
.longValue()
|
||||
)
|
||||
} else None
|
||||
} else None
|
||||
}
|
||||
}
|
||||
|
||||
DecodedTxResponse(
|
||||
hash = Some(hash),
|
||||
`type` = txType,
|
||||
chainId = chainIdNumOpt,
|
||||
nonce = nonceDecOpt,
|
||||
gasPrice = gasPriceHexOpt,
|
||||
gas = gasLimitHexOpt,
|
||||
to = toAddrOpt,
|
||||
value = valueDecOpt,
|
||||
input = Some(inputData),
|
||||
from = fromOpt,
|
||||
v = vHexOpt,
|
||||
r = rHexOpt,
|
||||
s = sHexOpt,
|
||||
estimatedFee = estimatedFeeHexOpt
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
package code.bankconnectors.ethereum
|
||||
|
||||
import code.api.util.APIUtil._
|
||||
import code.api.util.{CallContext, ErrorMessages, NewStyle}
|
||||
import code.api.v6_0_0.TransactionRequestBodyEthereumJsonV600
|
||||
import code.bankconnectors._
|
||||
import code.util.AkkaHttpClient._
|
||||
import code.util.Helper
|
||||
import code.util.Helper.MdcLoggable
|
||||
import com.openbankproject.commons.model._
|
||||
import net.liftweb.common._
|
||||
import net.liftweb.json
|
||||
import net.liftweb.json.JValue
|
||||
|
||||
import scala.collection.mutable.ArrayBuffer
|
||||
|
||||
/**
|
||||
* EthereumConnector_vSept2025
|
||||
* Minimal JSON-RPC based connector to send ETH between two addresses.
|
||||
*
|
||||
* Notes
|
||||
* - Supports two modes:
|
||||
* 1) If transactionRequestCommonBody.description is a 0x-hex string, use eth_sendRawTransaction
|
||||
* 2) Otherwise fallback to eth_sendTransaction (requires unlocked accounts, e.g. Anvil)
|
||||
* - For public RPC providers, prefer locally signed tx + eth_sendRawTransaction
|
||||
* - BankAccount.accountId.value is expected to hold the 0x Ethereum address
|
||||
*/
|
||||
trait EthereumConnector_vSept2025 extends Connector with MdcLoggable {
|
||||
|
||||
implicit override val nameOfConnector = EthereumConnector_vSept2025.toString
|
||||
|
||||
override val messageDocs = ArrayBuffer[MessageDoc]()
|
||||
|
||||
private def rpcUrl: String = getPropsValue("ethereum.rpc.url").getOrElse("http://127.0.0.1:8545")
|
||||
|
||||
private def ethToWeiHex(amountEth: BigDecimal): String = {
|
||||
val wei = amountEth.bigDecimal.movePointRight(18).toBigIntegerExact()
|
||||
"0x" + wei.toString(16)
|
||||
}
|
||||
|
||||
override def makePaymentv210(
|
||||
fromAccount: BankAccount,
|
||||
toAccount: BankAccount,
|
||||
transactionRequestId: TransactionRequestId,
|
||||
transactionRequestCommonBody: TransactionRequestCommonBodyJSON,
|
||||
amount: BigDecimal,
|
||||
description: String,
|
||||
transactionRequestType: TransactionRequestType,
|
||||
chargePolicy: String,
|
||||
callContext: Option[CallContext]
|
||||
): OBPReturnType[Box[TransactionId]] = {
|
||||
|
||||
val from = fromAccount.accountId.value
|
||||
val to = toAccount.accountId.value
|
||||
val valueHex = ethToWeiHex(amount)
|
||||
|
||||
val maybeRawTx: Option[String] = transactionRequestCommonBody.asInstanceOf[TransactionRequestBodyEthereumJsonV600].params.map(_.trim).filter(s => s.startsWith("0x") && s.length > 2)
|
||||
|
||||
val safeFrom = if (from.length > 10) from.take(10) + "..." else from
|
||||
val safeTo = if (to.length > 10) to.take(10) + "..." else to
|
||||
val safeVal = if (valueHex.length > 14) valueHex.take(14) + "..." else valueHex
|
||||
logger.debug(s"EthereumConnector_vSept2025.makePaymentv210 → from=$safeFrom to=$safeTo value=$safeVal url=${rpcUrl}")
|
||||
|
||||
val payload = maybeRawTx match {
|
||||
case Some(raw) =>
|
||||
s"""
|
||||
|{
|
||||
| "jsonrpc":"2.0",
|
||||
| "method":"eth_sendRawTransaction",
|
||||
| "params":["$raw"],
|
||||
| "id":1
|
||||
|}
|
||||
|""".stripMargin
|
||||
case None =>
|
||||
s"""
|
||||
|{
|
||||
| "jsonrpc":"2.0",
|
||||
| "method":"eth_sendTransaction",
|
||||
| "params":[{
|
||||
| "from":"$from",
|
||||
| "to":"$to",
|
||||
| "value":"$valueHex"
|
||||
| }],
|
||||
| "id":1
|
||||
|}
|
||||
|""".stripMargin
|
||||
}
|
||||
|
||||
for {
|
||||
request <- NewStyle.function.tryons(ErrorMessages.UnknownError + " Failed to build HTTP request", 500, callContext) {prepareHttpRequest(rpcUrl, _root_.akka.http.scaladsl.model.HttpMethods.POST, _root_.akka.http.scaladsl.model.HttpProtocol("HTTP/1.1"), payload)
|
||||
}
|
||||
|
||||
response <- NewStyle.function.tryons(ErrorMessages.UnknownError + " Failed to call Ethereum RPC", 500, callContext) {
|
||||
makeHttpRequest(request)
|
||||
}.flatten
|
||||
|
||||
body <- NewStyle.function.tryons(ErrorMessages.UnknownError + " Failed to read Ethereum RPC response", 500, callContext) {
|
||||
response.entity.dataBytes.runFold(_root_.akka.util.ByteString(""))(_ ++ _).map(_.utf8String)
|
||||
}.flatten
|
||||
|
||||
_ <- Helper.booleanToFuture(ErrorMessages.UnknownError + s" Ethereum RPC returned error: ${response.status.value}", 500, callContext) {
|
||||
logger.debug(s"EthereumConnector_vSept2025.makePaymentv210 response: $body")
|
||||
response.status.isSuccess()
|
||||
}
|
||||
|
||||
txIdBox <- {
|
||||
implicit val formats = json.DefaultFormats
|
||||
val j: JValue = json.parse(body)
|
||||
val errorNode = (j \ "error")
|
||||
if (errorNode != json.JNothing && errorNode != json.JNull) {
|
||||
val msg = (errorNode \ "message").extractOpt[String].getOrElse("Unknown Ethereum RPC error")
|
||||
val code = (errorNode \ "code").extractOpt[BigInt].map(_.toString).getOrElse("?")
|
||||
scala.concurrent.Future.successful(Failure(s"Ethereum RPC error(code=$code): $msg"))
|
||||
} else {
|
||||
NewStyle.function.tryons(ErrorMessages.InvalidJsonFormat + " Failed to parse Ethereum RPC response", 500, callContext) {
|
||||
val resultHashOpt: Option[String] =
|
||||
(j \ "result").extractOpt[String]
|
||||
.orElse((j \ "result" \ "hash").extractOpt[String])
|
||||
.orElse((j \ "result" \ "transactionHash").extractOpt[String])
|
||||
resultHashOpt match {
|
||||
case Some(hash) if hash.nonEmpty => TransactionId(hash)
|
||||
case _ => throw new RuntimeException("Empty transaction hash")
|
||||
}
|
||||
}.map(Full(_))
|
||||
}
|
||||
}
|
||||
} yield {
|
||||
(txIdBox, callContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object EthereumConnector_vSept2025 extends EthereumConnector_vSept2025
|
||||
@ -1961,5 +1961,21 @@
|
||||
<CcyNbr>null</CcyNbr>
|
||||
<CcyMnrUnts>0</CcyMnrUnts> <!-- Lovelace is the basic unit, no smaller subdivision -->
|
||||
</CcyNtry>
|
||||
<!-- Ethereum (ETH) -->
|
||||
<CcyNtry>
|
||||
<CtryNm>Ethereum_ETH</CtryNm>
|
||||
<CcyNm>ETH</CcyNm>
|
||||
<Ccy>ETH</Ccy>
|
||||
<CcyNbr>null</CcyNbr>
|
||||
<CcyMnrUnts>18</CcyMnrUnts> <!-- 1 ETH = 10^18 wei -->
|
||||
</CcyNtry>
|
||||
<!-- Wei (the smallest unit of ETH) -->
|
||||
<CcyNtry>
|
||||
<CtryNm>Ethereum_wei</CtryNm>
|
||||
<CcyNm>wei</CcyNm>
|
||||
<Ccy>wei</Ccy>
|
||||
<CcyNbr>null</CcyNbr>
|
||||
<CcyMnrUnts>0</CcyMnrUnts> <!-- wei is base unit -->
|
||||
</CcyNtry>
|
||||
</CcyTbl>
|
||||
</ISO_4217>
|
||||
@ -0,0 +1,53 @@
|
||||
package code.bankconnectors.ethereum
|
||||
|
||||
import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers}
|
||||
|
||||
class DecodeRawTxTest extends FeatureSpec with Matchers with GivenWhenThen {
|
||||
|
||||
feature("Decode raw Ethereum transaction to case class") {
|
||||
scenario("Decode a legacy signed raw transaction successfully") {
|
||||
Given("a sample legacy signed raw transaction hex string")
|
||||
|
||||
// {
|
||||
// "hash": "0x9d1afb5bd997d69a0fb2cb1bf1cf159f3448e7968fa25df1c26b368d9030b0c3",
|
||||
// "type": 0,
|
||||
// "chainId": 2025,
|
||||
// "nonce": 23,
|
||||
// "gasPrice": "0x03e8",
|
||||
// "gas": "0x5208",
|
||||
// "to": "0x627306090abab3a6e1400e9345bc60c78a8bef57",
|
||||
// "value": "0x0de0b6b3a7640000",
|
||||
// "input": "0x",
|
||||
// "from": "0xf17f52151ebef6c7334fad080c5704d77216b732",
|
||||
// "v": "0xff6",
|
||||
// "r": "0x16878a008fb817df6d771749336fa0c905ec5b7fafcd043f0d9e609a2b5e41e0",
|
||||
// "s": "0x611dbe0f2ee2428360c72f4287a2996cb0d45cb8995cc23eb6ba525cb9580e02",
|
||||
// "estimatedFee": "0x01406f40"
|
||||
// }
|
||||
val rawTx = "0xf86b178203e882520894627306090abab3a6e1400e9345bc60c78a8bef57880de0b6b3a764000080820ff6a016878a008fb817df6d771749336fa0c905ec5b7fafcd043f0d9e609a2b5e41e0a0611dbe0f2ee2428360c72f4287a2996cb0d45cb8995cc23eb6ba525cb9580e02"
|
||||
|
||||
When("we decode it to DecodedTxResponse case class")
|
||||
val response = DecodeRawTx.decodeRawTxToJson(rawTx)
|
||||
|
||||
Then("the response should contain the expected transaction fields")
|
||||
response.hash shouldBe defined
|
||||
response.hash.get should startWith ("0x")
|
||||
response.`type` shouldBe Some(0)
|
||||
response.nonce shouldBe Some(23)
|
||||
response.gasPrice shouldBe Some("0x3e8")
|
||||
response.gas shouldBe Some("0x5208")
|
||||
response.from shouldBe Some("0xf17f52151ebef6c7334fad080c5704d77216b732")
|
||||
response.to shouldBe Some("0x627306090abab3a6e1400e9345bc60c78a8bef57")
|
||||
response.value shouldBe Some("1")
|
||||
response.input shouldBe defined
|
||||
response.input.get should (be ("0x") or be (""))
|
||||
response.v shouldBe Some("0xff6")
|
||||
response.r shouldBe defined
|
||||
response.r.get should startWith ("0x")
|
||||
response.s shouldBe defined
|
||||
response.s.get should startWith ("0x")
|
||||
response.estimatedFee shouldBe defined
|
||||
response.estimatedFee.get should startWith ("0x")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,116 @@
|
||||
//package code.connector
|
||||
//
|
||||
//import code.api.util.ErrorMessages
|
||||
//import code.api.v5_1_0.V510ServerSetup
|
||||
//import code.api.v6_0_0.TransactionRequestBodyEthereumJsonV600
|
||||
//import code.bankconnectors.ethereum.EthereumConnector_vSept2025
|
||||
//import com.github.dwickern.macros.NameOf
|
||||
//import com.openbankproject.commons.model._
|
||||
//import net.liftweb.common.Full
|
||||
//import org.scalatest.Tag
|
||||
//
|
||||
//import scala.concurrent.Await
|
||||
//import scala.concurrent.duration._
|
||||
///**
|
||||
// * Minimal unit test to invoke makePaymentv210 against local Anvil.
|
||||
// * Assumptions:
|
||||
// * - ethereum.rpc.url points to http://127.0.0.1:8545
|
||||
// * - The RPC allows eth_sendTransaction (Anvil unlocked accounts)
|
||||
// * - We pass BankAccount stubs with accountId holding 0x addresses
|
||||
// */
|
||||
//class EthereumConnector_vSept2025Test extends V510ServerSetup{
|
||||
//
|
||||
// object ConnectorTestTag extends Tag(NameOf.nameOfType[EthereumConnector_vSept2025Test])
|
||||
//
|
||||
// object StubConnector extends EthereumConnector_vSept2025
|
||||
//
|
||||
// private case class StubBankAccount(id: String) extends BankAccount {
|
||||
// override val accountId: AccountId = AccountId(id)
|
||||
// override val bankId: BankId = BankId("bank-x")
|
||||
// override val accountType: String = "checking"
|
||||
// override val balance: BigDecimal = BigDecimal(0)
|
||||
// override val currency: String = "ETH"
|
||||
// override val name: String = "stub"
|
||||
// override val label: String = "stub"
|
||||
// override val number: String = "stub"
|
||||
// override val lastUpdate: java.util.Date = new java.util.Date()
|
||||
// override val accountHolder: String = "stub"
|
||||
// override val accountRoutings: List[AccountRouting] = Nil
|
||||
// override def branchId: String = "stub"
|
||||
// override def accountRules: List[AccountRule] = Nil
|
||||
// }
|
||||
//
|
||||
// feature("Anvil local Ethereum Node, need to start the Anvil, and set `ethereum.rpc.url=http://127.0.0.1:8545` in props, and prepare the from, to account") {
|
||||
//// setPropsValues("ethereum.rpc.url"-> "https://nkotb.openbankproject.com")
|
||||
// scenario("successful case", ConnectorTestTag) {
|
||||
// val from = StubBankAccount("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266")
|
||||
// val to = StubBankAccount("0x70997970C51812dc3A010C7d01b50e0d17dc79C8")
|
||||
// val amount = BigDecimal("0.0001")
|
||||
//
|
||||
// val trxBody = TransactionRequestBodyEthereumJsonV600(
|
||||
// to = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
|
||||
// value = AmountOfMoneyJsonV121("ETH", amount.toString),
|
||||
// description="test"
|
||||
// )
|
||||
//
|
||||
// //// This is only for testing; you can comment it out when the local Anvil is running.
|
||||
//// val resF = StubConnector.makePaymentv210(
|
||||
//// from,
|
||||
//// to,
|
||||
//// TransactionRequestId(java.util.UUID.randomUUID().toString),
|
||||
//// trxBody,
|
||||
//// amount,
|
||||
//// "test",
|
||||
//// TransactionRequestType("ETH_SEND_TRANSACTION"),
|
||||
//// "none",
|
||||
//// None
|
||||
//// )
|
||||
////
|
||||
//// val res = Await.result(resF, 10.seconds)
|
||||
//// res._1 shouldBe a[Full[_]]
|
||||
//// val txId = res._1.openOrThrowException(ErrorMessages.UnknownError)
|
||||
//// txId.value should startWith("0x")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// feature("need to start the Anvil, and set `ethereum.rpc.url=https://nkotb.openbankproject.com` in props, and prepare the from, to accounts and the rawTx") {
|
||||
//// setPropsValues("ethereum.rpc.url"-> "http://127.0.0.1:8545")
|
||||
// scenario("successful case", ConnectorTestTag) {
|
||||
//
|
||||
// val from = StubBankAccount("0xf17f52151EbEF6C7334FAD080c5704D77216b732")
|
||||
// val to = StubBankAccount("0x627306090abaB3A6e1400e9345bC60c78a8BEf57")
|
||||
// val amount = BigDecimal("0.0001")
|
||||
//
|
||||
// // Use a fixed rawTx variable for testing eth_sendRawTransaction path (no external params)
|
||||
// val rawTx = "0xf86a058203e882520894627306090abab3a6e1400e9345bc60c78a8bef57872386f26fc1000080820ff5a06de864bc825c4e976f5c432d0a57e3b3c9e19ec9843b5bb893a78a1389be650ea04fa063aba3984ff97e6454595f170e17e117f046568960aacf96f223c71ca0e2"
|
||||
//
|
||||
// val trxBody = TransactionRequestBodyEthereumJsonV600(
|
||||
// params= Some(rawTx),
|
||||
// to = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
|
||||
// value = AmountOfMoneyJsonV121("ETH", amount.toString),
|
||||
// description="test"
|
||||
// )
|
||||
////
|
||||
//// // Enable integration test against private chain
|
||||
//// val resF = StubConnector.makePaymentv210(
|
||||
//// from,
|
||||
//// to,
|
||||
//// TransactionRequestId(java.util.UUID.randomUUID().toString),
|
||||
//// trxBody,
|
||||
//// amount,
|
||||
//// "test",
|
||||
//// TransactionRequestType("ETH_SEND_RAW_TRANSACTION") ,
|
||||
//// "none",
|
||||
//// None
|
||||
//// )
|
||||
////
|
||||
//// val res = Await.result(resF, 30.seconds)
|
||||
//// res._1 shouldBe a [Full[_]]
|
||||
//// val txId = res._1.openOrThrowException(ErrorMessages.UnknownError)
|
||||
//// txId.value should startWith ("0x")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//}
|
||||
//
|
||||
//
|
||||
@ -114,6 +114,8 @@ object TransactionRequestTypes extends OBPEnumeration[TransactionRequestTypes]{
|
||||
object REFUND extends Value
|
||||
object AGENT_CASH_WITHDRAWAL extends Value
|
||||
object CARDANO extends Value
|
||||
object ETH_SEND_TRANSACTION extends Value
|
||||
object ETH_SEND_RAW_TRANSACTION extends Value
|
||||
}
|
||||
|
||||
sealed trait StrongCustomerAuthentication extends EnumValue
|
||||
|
||||
1
project/build.properties
Normal file
1
project/build.properties
Normal file
@ -0,0 +1 @@
|
||||
sbt.version=1.8.2
|
||||
8
project/metals.sbt
Normal file
8
project/metals.sbt
Normal file
@ -0,0 +1,8 @@
|
||||
// format: off
|
||||
// DO NOT EDIT! This file is auto-generated.
|
||||
|
||||
// This file enables sbt-bloop to create bloop config files.
|
||||
|
||||
addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "2.0.12")
|
||||
|
||||
// format: on
|
||||
10
project/plugins.sbt
Normal file
10
project/plugins.sbt
Normal file
@ -0,0 +1,10 @@
|
||||
// SBT plugins for OBP project
|
||||
addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1.5.6")
|
||||
addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.10.0-RC1")
|
||||
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.2.0")
|
||||
|
||||
// Scala compiler plugin for macros (equivalent to paradise plugin in Maven)
|
||||
addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.1" cross CrossVersion.full)
|
||||
|
||||
// SemanticDB for Metals support
|
||||
addCompilerPlugin("org.scalameta" % "semanticdb-scalac" % "4.13.9" cross CrossVersion.full)
|
||||
@ -3,6 +3,8 @@
|
||||
### Most recent changes at top of file
|
||||
```
|
||||
Date Commit Action
|
||||
26/09/2025 77d54c2e Added Ethereum Connector Configuration
|
||||
Added props ethereum.rpc.url, default is http://127.0.0.1:8545
|
||||
04/08/2025 d282d266 Enhanced Email Configuration with CommonsEmailWrapper
|
||||
Replaced Lift Mailer with Apache Commons Email for improved email functionality.
|
||||
Added comprehensive SMTP configuration options:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user