From 77d54c2e929a55c2dc077285660715c60384f01c Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 18 Sep 2025 10:19:24 +0200 Subject: [PATCH 01/18] feature/Add Ethereum connector for handling ETH transactions and corresponding unit tests --- .../main/scala/code/api/util/NewStyle.scala | 3 +- .../scala/code/bankconnectors/Connector.scala | 2 + .../EthereumConnector_vSept2025.scala | 101 ++++++++++++++++++ .../EthereumConnector_vSept2025Test.scala | 74 +++++++++++++ .../commons/model/enums/Enumerations.scala | 1 + 5 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala create mode 100644 obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala index 8a297199c..4f2eaf938 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -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) } } diff --git a/obp-api/src/main/scala/code/bankconnectors/Connector.scala b/obp-api/src/main/scala/code/bankconnectors/Connector.scala index 131e8ed6e..9bf870a7e 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -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, + "cardano_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. diff --git a/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala b/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala new file mode 100644 index 000000000..323afaed4 --- /dev/null +++ b/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala @@ -0,0 +1,101 @@ +package code.bankconnectors.ethereum + +import code.api.util.APIUtil._ +import code.api.util.{CallContext, ErrorMessages, NewStyle} +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 + * - This version calls 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 payload = 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() + } + + txId <- NewStyle.function.tryons(ErrorMessages.InvalidJsonFormat + " Failed to parse Ethereum RPC response", 500, callContext) { + implicit val formats = json.DefaultFormats + val j: JValue = json.parse(body) + val maybe = (j \ "result").extractOpt[String] + .orElse((j \ "error" \ "message").extractOpt[String].map(msg => throw new RuntimeException(msg))) + maybe match { + case Some(hash) if hash.nonEmpty => TransactionId(hash) + case _ => throw new RuntimeException("Empty transaction hash") + } + } + } yield { + (Full(txId), callContext) + } + } +} + +object EthereumConnector_vSept2025 extends EthereumConnector_vSept2025 \ No newline at end of file diff --git a/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala b/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala new file mode 100644 index 000000000..09f5eb1f3 --- /dev/null +++ b/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala @@ -0,0 +1,74 @@ +package code.connector + +import code.api.util.ErrorMessages +import code.api.v5_1_0.V510ServerSetup +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("Make sure connector follow the obp general rules ") { + scenario("OutBound case class should have the same param name with connector method", ConnectorTestTag) { + val from = StubBankAccount("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266") + val to = StubBankAccount("0x70997970C51812dc3A010C7d01b50e0d17dc79C8") + val amount = BigDecimal("0.0001") + + val trxBody = new TransactionRequestCommonBodyJSON { + override val value: AmountOfMoneyJsonV121 = AmountOfMoneyJsonV121("ETH", amount.toString) + override val description: String = "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("ETHEREUM") , + "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") + } + } +} + + diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala index 1497cebff..5ff6c3928 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala @@ -114,6 +114,7 @@ object TransactionRequestTypes extends OBPEnumeration[TransactionRequestTypes]{ object REFUND extends Value object AGENT_CASH_WITHDRAWAL extends Value object CARDANO extends Value + object ETHEREUM extends Value } sealed trait StrongCustomerAuthentication extends EnumValue From b6173f92a50adc6b336576df51724b0843b5dde2 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 18 Sep 2025 13:28:41 +0200 Subject: [PATCH 02/18] feature/Add Ethereum transaction request handling and models in API version 6.0.0 --- .../SwaggerDefinitionsJSON.scala | 9 ++- .../scala/code/api/v6_0_0/APIMethods600.scala | 42 ++++++++++- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 11 +++ .../LocalMappedConnectorInternal.scala | 71 +++++++++++++++++-- .../EthereumConnector_vSept2025.scala | 14 +++- 5 files changed, 138 insertions(+), 9 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index c7f56c3c6..531f3d62d 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -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 @@ -5741,6 +5740,14 @@ object SwaggerDefinitionsJSON { description = descriptionExample.value, metadata = Some(Map("202507022319" -> cardanoMetadataStringJsonV600)) ) + + lazy val transactionRequestBodyEthereumJsonV600 = TransactionRequestBodyEthereumJsonV600( + payment = EthereumPaymentJsonV600( + to = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + ), + value = AmountOfMoneyJsonV121("ETH", "0.01"), + description = descriptionExample.value + ) //The common error or success format. //Just some helper format to use in Json diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 727f0f50c..51db49259 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -12,11 +12,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 @@ -116,6 +116,46 @@ trait APIMethods600 { val transactionRequestType = TransactionRequestType("CARDANO") LocalMappedConnectorInternal.createTransactionRequest(bankId, accountId, viewId , transactionRequestType, json) } + + staticResourceDocs += ResourceDoc( + createTransactionRequestEthereum, + implementedInApiVersion, + nameOf(createTransactionRequestEthereum), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/owner/transaction-request-types/ETHEREUM/transaction-requests", + "Create Transaction Request (ETHEREUM)", + 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 createTransactionRequestEthereum: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: ViewId(viewId) :: "transaction-request-types" :: + "ETHEREUM" :: "transaction-requests" :: Nil JsonPost json -> _ => + cc => implicit val ec = EndpointContext(Some(cc)) + val transactionRequestType = TransactionRequestType("ETHEREUM") + LocalMappedConnectorInternal.createTransactionRequest(bankId, accountId, viewId , transactionRequestType, json) + } } } diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 24acb6b1f..d7514232b 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -63,6 +63,17 @@ case class TransactionRequestBodyCardanoJsonV600( metadata: Option[Map[String, CardanoMetadataStringJsonV600]] = None ) extends TransactionRequestCommonBodyJSON +// ---------------- Ethereum models (V600) ---------------- +case class EthereumPaymentJsonV600( + to: String // 0x address +) + +case class TransactionRequestBodyEthereumJsonV600( + payment: EthereumPaymentJsonV600, + value: AmountOfMoneyJsonV121, // currency should be "ETH"; amount string (decimal) + description: String +) extends TransactionRequestCommonBodyJSON + case class UserJsonV600( user_id: String, email : String, diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala index 0f1af06fc..e36292e2b 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala @@ -13,7 +13,7 @@ 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, TransactionRequestBodyEthereumJsonV600} import code.branches.MappedBranch import code.fx.fx import code.fx.fx.TTL @@ -785,9 +785,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 ETHEREUM => 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 => { @@ -1419,7 +1422,7 @@ object LocalMappedConnectorInternal extends MdcLoggable { otherAccountRoutingAddress = "", 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 +1447,64 @@ object LocalMappedConnectorInternal extends MdcLoggable { callContext) } yield (createdTransactionRequest, callContext) } + case ETHEREUM => { + for { + transactionRequestBodyEthereum <- NewStyle.function.tryons(s"${InvalidJsonFormat} It should be $TransactionRequestBodyEthereumJsonV600 json format", 400, callContext) { + json.extract[TransactionRequestBodyEthereumJsonV600] + } + // Basic validations + _ <- Helper.booleanToFuture(s"$InvalidJsonValue Ethereum 'to' address is required", cc=callContext) { + Option(transactionRequestBodyEthereum.payment.to).exists(_.nonEmpty) + } + _ <- Helper.booleanToFuture(s"$InvalidJsonValue Ethereum 'to' address must start with 0x and be 42 chars", cc=callContext) { + val a = transactionRequestBodyEthereum.payment.to + a.startsWith("0x") && a.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.payment.to.take(10), + description = transactionRequestBodyEthereum.description, + currency = transactionRequestBodyEthereum.value.currency, + createdByUserId = u.userId, + thisBankId = bankId.value, + thisAccountId = accountId.value, + thisViewId = viewId.value, + otherBankRoutingScheme = "", + otherBankRoutingAddress = "", + otherBranchRoutingScheme = "", + otherBranchRoutingAddress = "", + otherAccountRoutingScheme = "", + otherAccountRoutingAddress = "", + otherAccountSecondaryRoutingScheme = "ethereum", + otherAccountSecondaryRoutingAddress = transactionRequestBodyEthereum.payment.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( diff --git a/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala b/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala index 323afaed4..00ad0d982 100644 --- a/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala +++ b/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala @@ -51,6 +51,11 @@ trait EthereumConnector_vSept2025 extends Connector with MdcLoggable { val to = toAccount.accountId.value val valueHex = ethToWeiHex(amount) + 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 = s""" |{ | "jsonrpc":"2.0", @@ -85,8 +90,13 @@ trait EthereumConnector_vSept2025 extends Connector with MdcLoggable { txId <- NewStyle.function.tryons(ErrorMessages.InvalidJsonFormat + " Failed to parse Ethereum RPC response", 500, callContext) { implicit val formats = json.DefaultFormats val j: JValue = json.parse(body) - val maybe = (j \ "result").extractOpt[String] - .orElse((j \ "error" \ "message").extractOpt[String].map(msg => throw new RuntimeException(msg))) + val rpcError = (j \\ "error").children.headOption + rpcError.foreach { e => + val msg = (e \\ "message").values.toString + val code = (e \\ "code").values.toString + throw new RuntimeException(s"Ethereum RPC error(code=$code): $msg") + } + val maybe = (j \\ "result").extractOpt[String] maybe match { case Some(hash) if hash.nonEmpty => TransactionId(hash) case _ => throw new RuntimeException("Empty transaction hash") From 01116ebd173056e6a02cb6e12ccfbc11455cbc71 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 18 Sep 2025 22:58:19 +0200 Subject: [PATCH 03/18] feature/Add Ethereum transaction request handling and models in API version 6.0.0 --- .../scala/code/api/util/ErrorMessages.scala | 2 +- .../scala/code/bankconnectors/Connector.scala | 2 +- .../bankconnectors/LocalMappedConnector.scala | 9 ++++-- .../LocalMappedConnectorInternal.scala | 32 +++++++++---------- .../EthereumConnector_vSept2025.scala | 9 ++---- .../webapp/media/xml/ISOCurrencyCodes.xml | 16 ++++++++++ 6 files changed, 43 insertions(+), 27 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 756c5e5d4..c6a7fe9a5 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -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." diff --git a/obp-api/src/main/scala/code/bankconnectors/Connector.scala b/obp-api/src/main/scala/code/bankconnectors/Connector.scala index 9bf870a7e..cadaa87cb 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -64,7 +64,7 @@ object Connector extends SimpleInjector { "stored_procedure_vDec2019" -> StoredProcedureConnector_vDec2019, "rabbitmq_vOct2024" -> RabbitMQConnector_vOct2024, "cardano_vJun2025" -> CardanoConnector_vJun2025, - "cardano_vSept2025" -> EthereumConnector_vSept2025, + "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. diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 7784852dd..ba9d2db89 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -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) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala index e36292e2b..64f728d51 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala @@ -128,7 +128,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) @@ -1414,12 +1414,12 @@ 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 = callContext @@ -1457,8 +1457,8 @@ object LocalMappedConnectorInternal extends MdcLoggable { Option(transactionRequestBodyEthereum.payment.to).exists(_.nonEmpty) } _ <- Helper.booleanToFuture(s"$InvalidJsonValue Ethereum 'to' address must start with 0x and be 42 chars", cc=callContext) { - val a = transactionRequestBodyEthereum.payment.to - a.startsWith("0x") && a.length == 42 + val toBody = transactionRequestBodyEthereum.payment.to + toBody.startsWith("0x") && toBody.length == 42 } _ <- Helper.booleanToFuture(s"$InvalidTransactionRequestCurrency Currency must be 'ETH'", cc=callContext) { transactionRequestBodyEthereum.value.currency.equalsIgnoreCase("ETH") @@ -1466,19 +1466,19 @@ object LocalMappedConnectorInternal extends MdcLoggable { // Create or get counterparty using the Ethereum address as secondary routing (toCounterparty, callContext) <- NewStyle.function.getOrCreateCounterparty( - name = "ethereum-" + transactionRequestBodyEthereum.payment.to.take(10), + name = "ethereum-" + transactionRequestBodyEthereum.payment.to.take(27), description = transactionRequestBodyEthereum.description, currency = transactionRequestBodyEthereum.value.currency, createdByUserId = u.userId, thisBankId = bankId.value, thisAccountId = accountId.value, thisViewId = viewId.value, - otherBankRoutingScheme = "", - otherBankRoutingAddress = "", - otherBranchRoutingScheme = "", - otherBranchRoutingAddress = "", - otherAccountRoutingScheme = "", - otherAccountRoutingAddress = "", + otherBankRoutingScheme = ETHEREUM.toString, + otherBankRoutingAddress = transactionRequestBodyEthereum.payment.to, + otherBranchRoutingScheme = ETHEREUM.toString, + otherBranchRoutingAddress = transactionRequestBodyEthereum.payment.to, + otherAccountRoutingScheme = ETHEREUM.toString, + otherAccountRoutingAddress = transactionRequestBodyEthereum.payment.to, otherAccountSecondaryRoutingScheme = "ethereum", otherAccountSecondaryRoutingAddress = transactionRequestBodyEthereum.payment.to, callContext = callContext diff --git a/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala b/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala index 00ad0d982..ec4038093 100644 --- a/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala +++ b/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala @@ -90,13 +90,8 @@ trait EthereumConnector_vSept2025 extends Connector with MdcLoggable { txId <- NewStyle.function.tryons(ErrorMessages.InvalidJsonFormat + " Failed to parse Ethereum RPC response", 500, callContext) { implicit val formats = json.DefaultFormats val j: JValue = json.parse(body) - val rpcError = (j \\ "error").children.headOption - rpcError.foreach { e => - val msg = (e \\ "message").values.toString - val code = (e \\ "code").values.toString - throw new RuntimeException(s"Ethereum RPC error(code=$code): $msg") - } - val maybe = (j \\ "result").extractOpt[String] + val maybe = (j \ "result").extractOpt[String] + .orElse((j \ "error" \ "message").extractOpt[String].map(msg => throw new RuntimeException(msg))) maybe match { case Some(hash) if hash.nonEmpty => TransactionId(hash) case _ => throw new RuntimeException("Empty transaction hash") diff --git a/obp-api/src/main/webapp/media/xml/ISOCurrencyCodes.xml b/obp-api/src/main/webapp/media/xml/ISOCurrencyCodes.xml index 645698744..40e79062a 100644 --- a/obp-api/src/main/webapp/media/xml/ISOCurrencyCodes.xml +++ b/obp-api/src/main/webapp/media/xml/ISOCurrencyCodes.xml @@ -1961,5 +1961,21 @@ null 0 + + + Ethereum_ETH + ETH + ETH + null + 18 + + + + Ethereum_wei + wei + wei + null + 0 + \ No newline at end of file From c523fb950a44690fe8bb7908215805c6847311fd Mon Sep 17 00:00:00 2001 From: hongwei Date: Fri, 19 Sep 2025 08:39:06 +0200 Subject: [PATCH 04/18] test/Comment out payment test in EthereumConnector_vSept2025Test for local Anvil compatibility --- .../EthereumConnector_vSept2025Test.scala | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala b/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala index 09f5eb1f3..0429cda98 100644 --- a/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala +++ b/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala @@ -1,15 +1,10 @@ package code.connector -import code.api.util.ErrorMessages import code.api.v5_1_0.V510ServerSetup 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: @@ -51,22 +46,22 @@ class EthereumConnector_vSept2025Test extends V510ServerSetup{ } // 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("ETHEREUM") , - "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") +// val resF = StubConnector.makePaymentv210( +// from, +// to, +// TransactionRequestId(java.util.UUID.randomUUID().toString), +// trxBody, +// amount, +// "test", +// TransactionRequestType("ETHEREUM") , +// "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") } } } From 49daedf030993f637d2b196afaf24ac41f312299 Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 23 Sep 2025 22:30:16 +0200 Subject: [PATCH 05/18] feature/Enhance EthereumConnector_vSept2025 to support eth_sendRawTransaction for raw transactions and improve error handling in response parsing --- .../EthereumConnector_vSept2025.scala | 70 +++++++++++----- .../EthereumConnector_vSept2025Test.scala | 82 ++++++++++++++----- 2 files changed, 110 insertions(+), 42 deletions(-) diff --git a/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala b/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala index ec4038093..3ab10856c 100644 --- a/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala +++ b/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala @@ -18,7 +18,9 @@ import scala.collection.mutable.ArrayBuffer * Minimal JSON-RPC based connector to send ETH between two addresses. * * Notes - * - This version calls eth_sendTransaction (requires unlocked accounts, e.g. Anvil) + * - 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 */ @@ -51,27 +53,40 @@ trait EthereumConnector_vSept2025 extends Connector with MdcLoggable { val to = toAccount.accountId.value val valueHex = ethToWeiHex(amount) + val maybeRawTx: Option[String] = Option(transactionRequestCommonBody).map(_.description).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 = s""" - |{ - | "jsonrpc":"2.0", - | "method":"eth_sendTransaction", - | "params":[{ - | "from":"$from", - | "to":"$to", - | "value":"$valueHex" - | }], - | "id":1 - |} - |""".stripMargin + 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) + 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) { @@ -87,18 +102,29 @@ trait EthereumConnector_vSept2025 extends Connector with MdcLoggable { response.status.isSuccess() } - txId <- NewStyle.function.tryons(ErrorMessages.InvalidJsonFormat + " Failed to parse Ethereum RPC response", 500, callContext) { + txIdBox <- { implicit val formats = json.DefaultFormats val j: JValue = json.parse(body) - val maybe = (j \ "result").extractOpt[String] - .orElse((j \ "error" \ "message").extractOpt[String].map(msg => throw new RuntimeException(msg))) - maybe match { - case Some(hash) if hash.nonEmpty => TransactionId(hash) - case _ => throw new RuntimeException("Empty transaction hash") + 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 { - (Full(txId), callContext) + (txIdBox, callContext) } } } diff --git a/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala b/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala index 0429cda98..1bcbaee75 100644 --- a/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala +++ b/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala @@ -1,10 +1,15 @@ package code.connector +import code.api.util.ErrorMessages import code.api.v5_1_0.V510ServerSetup 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: @@ -34,9 +39,10 @@ class EthereumConnector_vSept2025Test extends V510ServerSetup{ override def accountRules: List[AccountRule] = Nil } - feature("Make sure connector follow the obp general rules ") { - scenario("OutBound case class should have the same param name with connector method", ConnectorTestTag) { - val from = StubBankAccount("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266") + 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") @@ -45,23 +51,59 @@ class EthereumConnector_vSept2025Test extends V510ServerSetup{ override val description: String = "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("ETHEREUM") , -// "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") +// 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("ETHEREUM") , + "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 = "0xf86b178203e882520894627306090abab3a6e1400e9345bc60c78a8bef57880de0b6b3a764000080820ff6a016878a008fb817df6d771749336fa0c905ec5b7fafcd043f0d9e609a2b5e41e0a0611dbe0f2ee2428360c72f4287a2996cb0d45cb8995cc23eb6ba525cb9580e02" + val trxBody = new TransactionRequestCommonBodyJSON { + override val value: AmountOfMoneyJsonV121 = AmountOfMoneyJsonV121("ETH", amount.toString) + // Put rawTx here to trigger eth_sendRawTransaction (connector uses description starting with 0x) + override val description: String = rawTx + } + + // Enable integration test against private chain + val resF = StubConnector.makePaymentv210( + from, + to, + TransactionRequestId(java.util.UUID.randomUUID().toString), + trxBody, + amount, + "test", + TransactionRequestType("ETHEREUM") , + "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") } } } From 07c1b5a740649b3f84d7539cdc2a51e88ae6e331 Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 23 Sep 2025 22:34:01 +0200 Subject: [PATCH 06/18] test/Comment out payment test cases in EthereumConnector_vSept2025Test to prevent execution during local Anvil setup --- .../EthereumConnector_vSept2025Test.scala | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala b/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala index 1bcbaee75..387939451 100644 --- a/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala +++ b/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala @@ -52,22 +52,22 @@ class EthereumConnector_vSept2025Test extends V510ServerSetup{ } // 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("ETHEREUM") , - "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") +// val resF = StubConnector.makePaymentv210( +// from, +// to, +// TransactionRequestId(java.util.UUID.randomUUID().toString), +// trxBody, +// amount, +// "test", +// TransactionRequestType("ETHEREUM") , +// "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") } } @@ -88,22 +88,22 @@ class EthereumConnector_vSept2025Test extends V510ServerSetup{ } // Enable integration test against private chain - val resF = StubConnector.makePaymentv210( - from, - to, - TransactionRequestId(java.util.UUID.randomUUID().toString), - trxBody, - amount, - "test", - TransactionRequestType("ETHEREUM") , - "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") +// val resF = StubConnector.makePaymentv210( +// from, +// to, +// TransactionRequestId(java.util.UUID.randomUUID().toString), +// trxBody, +// amount, +// "test", +// TransactionRequestType("ETHEREUM") , +// "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") } } } From 5db5db288696e38558dbf9c28efb81cc352008ac Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 23 Sep 2025 22:34:21 +0200 Subject: [PATCH 07/18] feature/Initialize SBT build configuration with project settings, dependencies, and plugins for OBP project --- build.sbt | 199 +++++++++++++++++++++++++++++++++++++++ project/build.properties | 1 + project/metals.sbt | 8 ++ project/plugins.sbt | 10 ++ 4 files changed, 218 insertions(+) create mode 100644 build.sbt create mode 100644 project/build.properties create mode 100644 project/metals.sbt create mode 100644 project/plugins.sbt diff --git a/build.sbt b/build.sbt new file mode 100644 index 000000000..dd7b6b681 --- /dev/null +++ b/build.sbt @@ -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 + ) + ) diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 000000000..46e43a97e --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.8.2 diff --git a/project/metals.sbt b/project/metals.sbt new file mode 100644 index 000000000..928b1abda --- /dev/null +++ b/project/metals.sbt @@ -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 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 000000000..c5bdd04dc --- /dev/null +++ b/project/plugins.sbt @@ -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) From 7ff31a1f55393768d7f74698397f42c3c6886bbf Mon Sep 17 00:00:00 2001 From: hongwei Date: Wed, 24 Sep 2025 08:54:09 +0200 Subject: [PATCH 08/18] refactor/Update Ethereum transaction request structure to simplify JSON model and improve code readability --- .../ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala | 4 +--- .../main/scala/code/api/v6_0_0/APIMethods600.scala | 6 +++--- .../scala/code/api/v6_0_0/JSONFactory6.0.0.scala | 6 +----- .../LocalMappedConnectorInternal.scala | 14 +++++++------- 4 files changed, 12 insertions(+), 18 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index 531f3d62d..43ee67557 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -5742,9 +5742,7 @@ object SwaggerDefinitionsJSON { ) lazy val transactionRequestBodyEthereumJsonV600 = TransactionRequestBodyEthereumJsonV600( - payment = EthereumPaymentJsonV600( - to = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" - ), + to = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", value = AmountOfMoneyJsonV121("ETH", "0.01"), description = descriptionExample.value ) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 51db49259..794660099 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -118,9 +118,9 @@ trait APIMethods600 { } staticResourceDocs += ResourceDoc( - createTransactionRequestEthereum, + createTransactionRequestEthereumeSendTransaction, implementedInApiVersion, - nameOf(createTransactionRequestEthereum), + nameOf(createTransactionRequestEthereumeSendTransaction), "POST", "/banks/BANK_ID/accounts/ACCOUNT_ID/owner/transaction-request-types/ETHEREUM/transaction-requests", "Create Transaction Request (ETHEREUM)", @@ -149,7 +149,7 @@ trait APIMethods600 { List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2) ) - lazy val createTransactionRequestEthereum: OBPEndpoint = { + lazy val createTransactionRequestEthereumeSendTransaction: OBPEndpoint = { case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: ViewId(viewId) :: "transaction-request-types" :: "ETHEREUM" :: "transaction-requests" :: Nil JsonPost json -> _ => cc => implicit val ec = EndpointContext(Some(cc)) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index d7514232b..220233871 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -64,12 +64,8 @@ case class TransactionRequestBodyCardanoJsonV600( ) extends TransactionRequestCommonBodyJSON // ---------------- Ethereum models (V600) ---------------- -case class EthereumPaymentJsonV600( - to: String // 0x address -) - case class TransactionRequestBodyEthereumJsonV600( - payment: EthereumPaymentJsonV600, + to: String, // 0x address value: AmountOfMoneyJsonV121, // currency should be "ETH"; amount string (decimal) description: String ) extends TransactionRequestCommonBodyJSON diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala index 64f728d51..cf2914db5 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala @@ -1454,10 +1454,10 @@ object LocalMappedConnectorInternal extends MdcLoggable { } // Basic validations _ <- Helper.booleanToFuture(s"$InvalidJsonValue Ethereum 'to' address is required", cc=callContext) { - Option(transactionRequestBodyEthereum.payment.to).exists(_.nonEmpty) + 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.payment.to + val toBody = transactionRequestBodyEthereum.to toBody.startsWith("0x") && toBody.length == 42 } _ <- Helper.booleanToFuture(s"$InvalidTransactionRequestCurrency Currency must be 'ETH'", cc=callContext) { @@ -1466,7 +1466,7 @@ object LocalMappedConnectorInternal extends MdcLoggable { // Create or get counterparty using the Ethereum address as secondary routing (toCounterparty, callContext) <- NewStyle.function.getOrCreateCounterparty( - name = "ethereum-" + transactionRequestBodyEthereum.payment.to.take(27), + name = "ethereum-" + transactionRequestBodyEthereum.to.take(27), description = transactionRequestBodyEthereum.description, currency = transactionRequestBodyEthereum.value.currency, createdByUserId = u.userId, @@ -1474,13 +1474,13 @@ object LocalMappedConnectorInternal extends MdcLoggable { thisAccountId = accountId.value, thisViewId = viewId.value, otherBankRoutingScheme = ETHEREUM.toString, - otherBankRoutingAddress = transactionRequestBodyEthereum.payment.to, + otherBankRoutingAddress = transactionRequestBodyEthereum.to, otherBranchRoutingScheme = ETHEREUM.toString, - otherBranchRoutingAddress = transactionRequestBodyEthereum.payment.to, + otherBranchRoutingAddress = transactionRequestBodyEthereum.to, otherAccountRoutingScheme = ETHEREUM.toString, - otherAccountRoutingAddress = transactionRequestBodyEthereum.payment.to, + otherAccountRoutingAddress = transactionRequestBodyEthereum.to, otherAccountSecondaryRoutingScheme = "ethereum", - otherAccountSecondaryRoutingAddress = transactionRequestBodyEthereum.payment.to, + otherAccountSecondaryRoutingAddress = transactionRequestBodyEthereum.to, callContext = callContext ) From cc7b925203eae4c3b3365a575554120db7cb1760 Mon Sep 17 00:00:00 2001 From: hongwei Date: Wed, 24 Sep 2025 23:11:18 +0200 Subject: [PATCH 09/18] feature/Add DecodeRawTx utility for decoding Ethereum raw transactions to JSON format and include web3j dependency --- obp-api/pom.xml | 6 + .../bankconnectors/ethereum/DecodeRawTx.scala | 119 ++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 obp-api/src/main/scala/code/bankconnectors/ethereum/DecodeRawTx.scala diff --git a/obp-api/pom.xml b/obp-api/pom.xml index 9955f4ad7..5e94eaab6 100644 --- a/obp-api/pom.xml +++ b/obp-api/pom.xml @@ -329,6 +329,12 @@ flexmark-util-options 0.64.0 + + + org.web3j + core + 4.9.8 + com.zaxxer HikariCP diff --git a/obp-api/src/main/scala/code/bankconnectors/ethereum/DecodeRawTx.scala b/obp-api/src/main/scala/code/bankconnectors/ethereum/DecodeRawTx.scala new file mode 100644 index 000000000..c84621332 --- /dev/null +++ b/obp-api/src/main/scala/code/bankconnectors/ethereum/DecodeRawTx.scala @@ -0,0 +1,119 @@ +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 { + + 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) + } + } + + private def jStrOrNull(v: String): JValue = if (v == null) JNull else JString(v) + private def jOptStrOrNull(v: Option[String]): JValue = v.map(JString).getOrElse(JNull) + + /** + * 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. + */ + def decodeRawTxToJson(rawIn: String): String = { + val rawHex = normalizeHex(rawIn) + val txType = 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 gasPriceHex = Option(decoded.getGasPrice).map(W3Numeric.toHexStringWithPrefix).getOrElse(null) + val gasLimitHex = Option(decoded.getGasLimit).map(W3Numeric.toHexStringWithPrefix).getOrElse(null) + val valueHex = Option(decoded.getValue).map(W3Numeric.toHexStringWithPrefix).getOrElse(null) + val nonceHex = Option(decoded.getNonce).map(W3Numeric.toHexStringWithPrefix).getOrElse(null) + val toAddr = decoded.getTo + val inputData = Option(decoded.getData).getOrElse("0x") + + val estimatedFeeHex = + (for { + gp <- Option(decoded.getGasPrice) + gl <- Option(decoded.getGasLimit) + } yield W3Numeric.toHexStringWithPrefix(gp.multiply(gl))).getOrElse(null) + + val j = JObject(List( + JField("hash", JString(hash)), + JField("type", JString(txType.toString)), + JField("chainId", chainIdOpt.map(cid => JString(cid.toString)).getOrElse(JNull)), + JField("nonce", jStrOrNull(nonceHex)), + JField("gasPrice", jStrOrNull(gasPriceHex)), + JField("gas", jStrOrNull(gasLimitHex)), + JField("to", jStrOrNull(toAddr)), + JField("value", jStrOrNull(valueHex)), + JField("input", jStrOrNull(inputData)), + JField("from", jOptStrOrNull(fromOpt)), + JField("v", jOptStrOrNull(vHexOpt)), + JField("r", jOptStrOrNull(rHexOpt)), + JField("s", jOptStrOrNull(sHexOpt)), + JField("estimatedFee", jStrOrNull(estimatedFeeHex)) + )) + compactRender(j) + } + + def main(args: Array[String]): Unit = { + val raxHex = "0xf86b178203e882520894627306090abab3a6e1400e9345bc60c78a8bef57880de0b6b3a764000080820ff6a016878a008fb817df6d771749336fa0c905ec5b7fafcd043f0d9e609a2b5e41e0a0611dbe0f2ee2428360c72f4287a2996cb0d45cb8995cc23eb6ba525cb9580e02" + val out = decodeRawTxToJson(raxHex) + print(out) + } +} \ No newline at end of file From dd077605e3041cc07fcf132d8d9de16c4c8cdc1c Mon Sep 17 00:00:00 2001 From: hongwei Date: Wed, 24 Sep 2025 23:11:29 +0200 Subject: [PATCH 10/18] feature/Update Ethereum transaction request types to ETH_SEND_TRANSACTION and ETH_SEND_RAW_TRANSACTION, enhancing API endpoint definitions and internal handling --- .../scala/code/api/v6_0_0/APIMethods600.scala | 47 +++++++++++++++++-- .../LocalMappedConnectorInternal.scala | 10 ++-- .../EthereumConnector_vSept2025Test.scala | 40 +++++++++++++++- .../commons/model/enums/Enumerations.scala | 3 +- 4 files changed, 88 insertions(+), 12 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 794660099..f68b70c95 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -122,8 +122,8 @@ trait APIMethods600 { implementedInApiVersion, nameOf(createTransactionRequestEthereumeSendTransaction), "POST", - "/banks/BANK_ID/accounts/ACCOUNT_ID/owner/transaction-request-types/ETHEREUM/transaction-requests", - "Create Transaction Request (ETHEREUM)", + "/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. @@ -151,9 +151,48 @@ trait APIMethods600 { lazy val createTransactionRequestEthereumeSendTransaction: OBPEndpoint = { case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: ViewId(viewId) :: "transaction-request-types" :: - "ETHEREUM" :: "transaction-requests" :: Nil JsonPost json -> _ => + "ETH_SEND_TRANSACTION" :: "transaction-requests" :: Nil JsonPost json -> _ => cc => implicit val ec = EndpointContext(Some(cc)) - val transactionRequestType = TransactionRequestType("ETHEREUM") + 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, + transactionRequestBodyEthereumJsonV600, + 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) } diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala index cf2914db5..053d84535 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala @@ -786,7 +786,7 @@ object LocalMappedConnectorInternal extends MdcLoggable { } _ <- (transactionRequestTypeValue match { - case ETHEREUM => Future.successful(true) // Allow ETH (non-ISO) for Ethereum requests + 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) } @@ -1447,7 +1447,7 @@ object LocalMappedConnectorInternal extends MdcLoggable { callContext) } yield (createdTransactionRequest, callContext) } - case ETHEREUM => { + case ETH_SEND_RAW_TRANSACTION | ETH_SEND_TRANSACTION => { for { transactionRequestBodyEthereum <- NewStyle.function.tryons(s"${InvalidJsonFormat} It should be $TransactionRequestBodyEthereumJsonV600 json format", 400, callContext) { json.extract[TransactionRequestBodyEthereumJsonV600] @@ -1473,11 +1473,11 @@ object LocalMappedConnectorInternal extends MdcLoggable { thisBankId = bankId.value, thisAccountId = accountId.value, thisViewId = viewId.value, - otherBankRoutingScheme = ETHEREUM.toString, + otherBankRoutingScheme = ETH_SEND_TRANSACTION.toString, otherBankRoutingAddress = transactionRequestBodyEthereum.to, - otherBranchRoutingScheme = ETHEREUM.toString, + otherBranchRoutingScheme = ETH_SEND_TRANSACTION.toString, otherBranchRoutingAddress = transactionRequestBodyEthereum.to, - otherAccountRoutingScheme = ETHEREUM.toString, + otherAccountRoutingScheme = ETH_SEND_TRANSACTION.toString, otherAccountRoutingAddress = transactionRequestBodyEthereum.to, otherAccountSecondaryRoutingScheme = "ethereum", otherAccountSecondaryRoutingAddress = transactionRequestBodyEthereum.to, diff --git a/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala b/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala index 387939451..ba071d35d 100644 --- a/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala +++ b/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala @@ -59,7 +59,7 @@ class EthereumConnector_vSept2025Test extends V510ServerSetup{ // trxBody, // amount, // "test", -// TransactionRequestType("ETHEREUM") , +// TransactionRequestType("ETH_SEND_TRANSACTION") , // "none", // None // ) @@ -95,7 +95,43 @@ class EthereumConnector_vSept2025Test extends V510ServerSetup{ // trxBody, // amount, // "test", -// TransactionRequestType("ETHEREUM") , +// TransactionRequestType("ETH_SEND_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") + } + } + + 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 = "0xf86b178203e882520894627306090abab3a6e1400e9345bc60c78a8bef57880de0b6b3a764000080820ff6a016878a008fb817df6d771749336fa0c905ec5b7fafcd043f0d9e609a2b5e41e0a0611dbe0f2ee2428360c72f4287a2996cb0d45cb8995cc23eb6ba525cb9580e02" + val trxBody = new TransactionRequestCommonBodyJSON { + override val value: AmountOfMoneyJsonV121 = AmountOfMoneyJsonV121("ETH", amount.toString) + // Put rawTx here to trigger eth_sendRawTransaction (connector uses description starting with 0x) + override val description: String = rawTx + } + + // Enable integration test against private chain +// val resF = StubConnector.makePaymentv210( +// from, +// to, +// TransactionRequestId(java.util.UUID.randomUUID().toString), +// trxBody, +// amount, +// "test", +// TransactionRequestType("ETH_SEND_TRANSACTION") , // "none", // None // ) diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala index 5ff6c3928..322d70f64 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala @@ -114,7 +114,8 @@ object TransactionRequestTypes extends OBPEnumeration[TransactionRequestTypes]{ object REFUND extends Value object AGENT_CASH_WITHDRAWAL extends Value object CARDANO extends Value - object ETHEREUM extends Value + object ETH_SEND_TRANSACTION extends Value + object ETH_SEND_RAW_TRANSACTION extends Value } sealed trait StrongCustomerAuthentication extends EnumValue From b8a4b9200ecfaced646b374004ee1c5ce49007f7 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 25 Sep 2025 00:17:44 +0200 Subject: [PATCH 11/18] test/Add DecodeRawTxTest for validating JSON output of legacy signed Ethereum transactions --- .../ethereum/DecodeRawTxTest.scala | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala diff --git a/obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala b/obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala new file mode 100644 index 000000000..df6aa4434 --- /dev/null +++ b/obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala @@ -0,0 +1,41 @@ +package code.bankconnectors.ethereum + +import net.liftweb.json._ +import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers} + +class DecodeRawTxTest extends FeatureSpec with Matchers with GivenWhenThen { + + feature("Decode raw Ethereum transaction to JSON") { + scenario("Decode a legacy signed raw transaction successfully") { + Given("a sample legacy signed raw transaction hex string") + val rawTx = "0xf86b178203e882520894627306090abab3a6e1400e9345bc60c78a8bef57880de0b6b3a764000080820ff6a016878a008fb817df6d771749336fa0c905ec5b7fafcd043f0d9e609a2b5e41e0a0611dbe0f2ee2428360c72f4287a2996cb0d45cb8995cc23eb6ba525cb9580e02" + + When("we decode it to JSON string") + val jsonStr = DecodeRawTx.decodeRawTxToJson(rawTx) + + Then("the JSON contains the expected basic fields") + implicit val formats: Formats = DefaultFormats + val jValue = parse(jsonStr) + + (jValue \ "hash").extract[String] should startWith ("0x") + (jValue \ "type").extract[String] shouldBe "0" + (jValue \ "nonce").extract[String] shouldBe "0x17" + (jValue \ "gasPrice").extract[String] shouldBe "0x3e8" + (jValue \ "gas").extract[String] shouldBe "0x5208" + (jValue \ "to").extract[String].toLowerCase shouldBe "0x627306090abab3a6e1400e9345bc60c78a8bef57" + (jValue \ "value").extract[String] shouldBe "0xde0b6b3a7640000" + val inputData = (jValue \ "input").extract[String] + inputData should (be ("0x") or be ("")) + + And("signature fields are present") + (jValue \ "v").extract[String] should startWith ("0x") + (jValue \ "r").extract[String] should startWith ("0x") + (jValue \ "s").extract[String] should startWith ("0x") + + And("estimatedFee exists and is hex with 0x prefix") + (jValue \ "estimatedFee").extract[String] should startWith ("0x") + } + } +} + + From 9f84db2fdea075603921a46087bffa72226eaf04 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 25 Sep 2025 09:43:13 +0200 Subject: [PATCH 12/18] feature/Add TransactionRequestBodyEthSendRawTransactionJsonV600 for handling raw Ethereum transaction requests in API version 6.0.0 --- .../code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala | 4 ++++ obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala | 2 +- .../src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala | 6 ++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index 43ee67557..2602d8f37 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -5746,6 +5746,10 @@ object SwaggerDefinitionsJSON { value = AmountOfMoneyJsonV121("ETH", "0.01"), description = descriptionExample.value ) + lazy val transactionRequestBodyEthSendRawTransactionJsonV600 = TransactionRequestBodyEthSendRawTransactionJsonV600( + to = "0xf86b018203e882520894627306090abab3a6e1400e9345bc60c78a8bef57880de0b6b3a764000080820ff6a0d0367709eee090a6ebd74c63db7329372db1966e76d28ce219d1e105c47bcba7a0042d52f7d2436ad96e8714bf0309adaf870ad6fb68cfe53ce958792b3da36c12", + description = descriptionExample.value + ) //The common error or success format. //Just some helper format to use in Json diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index f68b70c95..fe9768003 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -171,7 +171,7 @@ trait APIMethods600 { |${transactionRequestGeneralText} | """.stripMargin, - transactionRequestBodyEthereumJsonV600, + transactionRequestBodyEthSendRawTransactionJsonV600, transactionRequestWithChargeJSON400, List( $UserNotLoggedIn, diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 220233871..7518144a4 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -70,6 +70,12 @@ case class TransactionRequestBodyEthereumJsonV600( description: String ) extends TransactionRequestCommonBodyJSON +// This is only for the request JSON body; we will construct `TransactionRequestBodyEthereumJsonV600` for OBP. +case class TransactionRequestBodyEthSendRawTransactionJsonV600( + to: String, // eth_sendRawTransaction params field. + description: String +) + case class UserJsonV600( user_id: String, email : String, From 3ff9e900e3a30f72f5425c8546bd68f0a923ac35 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 25 Sep 2025 11:36:23 +0200 Subject: [PATCH 13/18] feature/Enhance DecodeRawTx to return DecodedTxResponse case class, improving transaction decoding structure and JSON output --- .../bankconnectors/ethereum/DecodeRawTx.scala | 153 ++++++++++++------ .../ethereum/DecodeRawTxTest.scala | 48 +++--- 2 files changed, 126 insertions(+), 75 deletions(-) diff --git a/obp-api/src/main/scala/code/bankconnectors/ethereum/DecodeRawTx.scala b/obp-api/src/main/scala/code/bankconnectors/ethereum/DecodeRawTx.scala index c84621332..86fbf15f6 100644 --- a/obp-api/src/main/scala/code/bankconnectors/ethereum/DecodeRawTx.scala +++ b/obp-api/src/main/scala/code/bankconnectors/ethereum/DecodeRawTx.scala @@ -7,6 +7,41 @@ 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) + // In your decoded JSON fields: mandatory (for signed legacy) are nonce, gasPrice, gas, value, input, v/r/s; to is conditional; chainId is optional (but recommended). hash/from/estimatedFee are derived. + // + // Case class representing the decoded transaction JSON structure + case class DecodedTxResponse( + hash: String, + `type`: Int, + chainId: Option[Long], + nonce: Option[Long], + gasPrice: Option[String], + gas: Option[String], + to: Option[String], + value: Option[String], + input: 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) @@ -46,20 +81,25 @@ object DecodeRawTx { } } - private def jStrOrNull(v: String): JValue = if (v == null) JNull else JString(v) - private def jOptStrOrNull(v: Option[String]): JValue = v.map(JString).getOrElse(JNull) - /** - * 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. - */ - def decodeRawTxToJson(rawIn: String): String = { - val rawHex = normalizeHex(rawIn) - val txType = detectType(rawHex) + * 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 = detectType(rawHex) val decoded: RawTransaction = try TransactionDecoder.decode(rawHex) - catch { case e: Throwable => fatal(s"decode failed: ${e.getMessage}") } + 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 { @@ -68,52 +108,71 @@ object DecodeRawTx { 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) + } 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 gasPriceHex = Option(decoded.getGasPrice).map(W3Numeric.toHexStringWithPrefix).getOrElse(null) - val gasLimitHex = Option(decoded.getGasLimit).map(W3Numeric.toHexStringWithPrefix).getOrElse(null) - val valueHex = Option(decoded.getValue).map(W3Numeric.toHexStringWithPrefix).getOrElse(null) - val nonceHex = Option(decoded.getNonce).map(W3Numeric.toHexStringWithPrefix).getOrElse(null) - val toAddr = decoded.getTo - val inputData = Option(decoded.getData).getOrElse("0x") + 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 estimatedFeeHex = - (for { + val estimatedFeeHexOpt = + for { gp <- Option(decoded.getGasPrice) gl <- Option(decoded.getGasLimit) - } yield W3Numeric.toHexStringWithPrefix(gp.multiply(gl))).getOrElse(null) + } 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 = hash, + `type` = txType, + chainId = chainIdNumOpt, + nonce = nonceDecOpt, + gasPrice = gasPriceHexOpt, + gas = gasLimitHexOpt, + to = toAddrOpt, + value = valueDecOpt, + input = inputData, + from = fromOpt, + v = vHexOpt, + r = rHexOpt, + s = sHexOpt, + estimatedFee = estimatedFeeHexOpt + ) - val j = JObject(List( - JField("hash", JString(hash)), - JField("type", JString(txType.toString)), - JField("chainId", chainIdOpt.map(cid => JString(cid.toString)).getOrElse(JNull)), - JField("nonce", jStrOrNull(nonceHex)), - JField("gasPrice", jStrOrNull(gasPriceHex)), - JField("gas", jStrOrNull(gasLimitHex)), - JField("to", jStrOrNull(toAddr)), - JField("value", jStrOrNull(valueHex)), - JField("input", jStrOrNull(inputData)), - JField("from", jOptStrOrNull(fromOpt)), - JField("v", jOptStrOrNull(vHexOpt)), - JField("r", jOptStrOrNull(rHexOpt)), - JField("s", jOptStrOrNull(sHexOpt)), - JField("estimatedFee", jStrOrNull(estimatedFeeHex)) - )) - compactRender(j) } - def main(args: Array[String]): Unit = { - val raxHex = "0xf86b178203e882520894627306090abab3a6e1400e9345bc60c78a8bef57880de0b6b3a764000080820ff6a016878a008fb817df6d771749336fa0c905ec5b7fafcd043f0d9e609a2b5e41e0a0611dbe0f2ee2428360c72f4287a2996cb0d45cb8995cc23eb6ba525cb9580e02" - val out = decodeRawTxToJson(raxHex) - print(out) - } } \ No newline at end of file diff --git a/obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala b/obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala index df6aa4434..44e28c581 100644 --- a/obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala +++ b/obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala @@ -1,41 +1,33 @@ package code.bankconnectors.ethereum -import net.liftweb.json._ import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers} class DecodeRawTxTest extends FeatureSpec with Matchers with GivenWhenThen { - feature("Decode raw Ethereum transaction to JSON") { + 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") val rawTx = "0xf86b178203e882520894627306090abab3a6e1400e9345bc60c78a8bef57880de0b6b3a764000080820ff6a016878a008fb817df6d771749336fa0c905ec5b7fafcd043f0d9e609a2b5e41e0a0611dbe0f2ee2428360c72f4287a2996cb0d45cb8995cc23eb6ba525cb9580e02" - When("we decode it to JSON string") - val jsonStr = DecodeRawTx.decodeRawTxToJson(rawTx) + When("we decode it to DecodedTxResponse case class") + val response = DecodeRawTx.decodeRawTxToJson(rawTx) - Then("the JSON contains the expected basic fields") - implicit val formats: Formats = DefaultFormats - val jValue = parse(jsonStr) - - (jValue \ "hash").extract[String] should startWith ("0x") - (jValue \ "type").extract[String] shouldBe "0" - (jValue \ "nonce").extract[String] shouldBe "0x17" - (jValue \ "gasPrice").extract[String] shouldBe "0x3e8" - (jValue \ "gas").extract[String] shouldBe "0x5208" - (jValue \ "to").extract[String].toLowerCase shouldBe "0x627306090abab3a6e1400e9345bc60c78a8bef57" - (jValue \ "value").extract[String] shouldBe "0xde0b6b3a7640000" - val inputData = (jValue \ "input").extract[String] - inputData should (be ("0x") or be ("")) - - And("signature fields are present") - (jValue \ "v").extract[String] should startWith ("0x") - (jValue \ "r").extract[String] should startWith ("0x") - (jValue \ "s").extract[String] should startWith ("0x") - - And("estimatedFee exists and is hex with 0x prefix") - (jValue \ "estimatedFee").extract[String] should startWith ("0x") + Then("the response should contain the expected transaction fields") + response.hash should startWith ("0x") + response.`type` shouldBe 0 + response.nonce shouldBe Some(23) + response.gasPrice shouldBe Some("0x3e8") + response.gas shouldBe Some("0x5208") + response.to shouldBe Some("0x627306090abab3a6e1400e9345bc60c78a8bef57") + response.value shouldBe Some("1") + response.input 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") } } -} - - +} \ No newline at end of file From 187c51bc525cffb71767274da0490c445c4c1221 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 25 Sep 2025 11:37:04 +0200 Subject: [PATCH 14/18] refactor/Update DecodedTxResponse to use Option types for improved safety and handling of missing fields in Ethereum transaction decoding --- .../code/bankconnectors/ethereum/DecodeRawTx.scala | 13 ++++++------- .../bankconnectors/ethereum/DecodeRawTxTest.scala | 8 +++++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/obp-api/src/main/scala/code/bankconnectors/ethereum/DecodeRawTx.scala b/obp-api/src/main/scala/code/bankconnectors/ethereum/DecodeRawTx.scala index 86fbf15f6..3de0dbf48 100644 --- a/obp-api/src/main/scala/code/bankconnectors/ethereum/DecodeRawTx.scala +++ b/obp-api/src/main/scala/code/bankconnectors/ethereum/DecodeRawTx.scala @@ -22,19 +22,18 @@ object DecodeRawTx { // from: recovered from signature // estimatedFee: computed (gasLimit × gasPrice or gasUsed × price at execution) // type: 0/1/2 (0 is implicit for legacy) - // In your decoded JSON fields: mandatory (for signed legacy) are nonce, gasPrice, gas, value, input, v/r/s; to is conditional; chainId is optional (but recommended). hash/from/estimatedFee are derived. // // Case class representing the decoded transaction JSON structure case class DecodedTxResponse( - hash: String, - `type`: Int, + 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: String, + input: Option[String], from: Option[String], v: Option[String], r: Option[String], @@ -93,7 +92,7 @@ object DecodeRawTx { def decodeRawTxToJson(rawIn: String): DecodedTxResponse = { implicit val formats: Formats = DefaultFormats val rawHex = normalizeHex(rawIn) - val txType = detectType(rawHex) + val txType = Some(detectType(rawHex)) val decoded: RawTransaction = try TransactionDecoder.decode(rawHex) @@ -157,7 +156,7 @@ object DecodeRawTx { } DecodedTxResponse( - hash = hash, + hash = Some(hash), `type` = txType, chainId = chainIdNumOpt, nonce = nonceDecOpt, @@ -165,7 +164,7 @@ object DecodeRawTx { gas = gasLimitHexOpt, to = toAddrOpt, value = valueDecOpt, - input = inputData, + input = Some(inputData), from = fromOpt, v = vHexOpt, r = rHexOpt, diff --git a/obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala b/obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala index 44e28c581..bde34e813 100644 --- a/obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala +++ b/obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala @@ -13,14 +13,16 @@ class DecodeRawTxTest extends FeatureSpec with Matchers with GivenWhenThen { val response = DecodeRawTx.decodeRawTxToJson(rawTx) Then("the response should contain the expected transaction fields") - response.hash should startWith ("0x") - response.`type` shouldBe 0 + 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.to shouldBe Some("0x627306090abab3a6e1400e9345bc60c78a8bef57") response.value shouldBe Some("1") - response.input should (be ("0x") or be ("")) + 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") From e5b3b7caca8974b74866798a7775542e7024e28a Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 25 Sep 2025 14:39:06 +0200 Subject: [PATCH 15/18] feature/Implement ETH_SEND_RAW_TRANSACTION handling in LocalMappedConnectorInternal, enhancing transaction validation and processing for Ethereum requests --- .../LocalMappedConnectorInternal.scala | 62 ++++++++++++++++--- .../ethereum/DecodeRawTxTest.scala | 18 ++++++ 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala index 053d84535..d1dc92cb6 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala @@ -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, TransactionRequestBodyEthereumJsonV600} +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 @@ -773,8 +774,36 @@ 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 or $TransactionRequestBodyEthereumJsonV600 json format", + 400, + callContext + ) { + json.extract[TransactionRequestBodyEthSendRawTransactionJsonV600] + } + // Decode raw transaction to extract 'from' address + decodedTx = DecodeRawTx.decodeRawTxToJson(transactionRequestBodyEthSendRawTransactionJsonV600.to) + 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( + to = decodedTx.to.getOrElse(""), + value = AmountOfMoneyJsonV121("ETH", "0.01"), + 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) { @@ -1449,9 +1478,22 @@ object LocalMappedConnectorInternal extends MdcLoggable { } case ETH_SEND_RAW_TRANSACTION | ETH_SEND_TRANSACTION => { for { - transactionRequestBodyEthereum <- NewStyle.function.tryons(s"${InvalidJsonFormat} It should be $TransactionRequestBodyEthereumJsonV600 json format", 400, callContext) { - json.extract[TransactionRequestBodyEthereumJsonV600] - } + // 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) @@ -1473,13 +1515,13 @@ object LocalMappedConnectorInternal extends MdcLoggable { thisBankId = bankId.value, thisAccountId = accountId.value, thisViewId = viewId.value, - otherBankRoutingScheme = ETH_SEND_TRANSACTION.toString, + otherBankRoutingScheme = scheme, otherBankRoutingAddress = transactionRequestBodyEthereum.to, - otherBranchRoutingScheme = ETH_SEND_TRANSACTION.toString, + otherBranchRoutingScheme = scheme, otherBranchRoutingAddress = transactionRequestBodyEthereum.to, - otherAccountRoutingScheme = ETH_SEND_TRANSACTION.toString, + otherAccountRoutingScheme = scheme, otherAccountRoutingAddress = transactionRequestBodyEthereum.to, - otherAccountSecondaryRoutingScheme = "ethereum", + otherAccountSecondaryRoutingScheme = scheme, otherAccountSecondaryRoutingAddress = transactionRequestBodyEthereum.to, callContext = callContext ) diff --git a/obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala b/obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala index bde34e813..d83946ddd 100644 --- a/obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala +++ b/obp-api/src/test/scala/code/bankconnectors/ethereum/DecodeRawTxTest.scala @@ -7,6 +7,23 @@ 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") @@ -19,6 +36,7 @@ class DecodeRawTxTest extends FeatureSpec with Matchers with GivenWhenThen { 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 From e92890c929f97b35aef3bfd8e4221e1d6eb2e62c Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 25 Sep 2025 15:11:25 +0200 Subject: [PATCH 16/18] refactor/Update Ethereum transaction request structure to replace 'to' with 'params' in TransactionRequestBody, enhancing clarity and consistency in API definitions --- .../api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala | 2 +- .../src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala | 5 +++-- .../code/bankconnectors/LocalMappedConnectorInternal.scala | 7 ++++--- .../ethereum/EthereumConnector_vSept2025.scala | 3 ++- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index 2602d8f37..2cee94fac 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -5747,7 +5747,7 @@ object SwaggerDefinitionsJSON { description = descriptionExample.value ) lazy val transactionRequestBodyEthSendRawTransactionJsonV600 = TransactionRequestBodyEthSendRawTransactionJsonV600( - to = "0xf86b018203e882520894627306090abab3a6e1400e9345bc60c78a8bef57880de0b6b3a764000080820ff6a0d0367709eee090a6ebd74c63db7329372db1966e76d28ce219d1e105c47bcba7a0042d52f7d2436ad96e8714bf0309adaf870ad6fb68cfe53ce958792b3da36c12", + params = "0xf86b018203e882520894627306090abab3a6e1400e9345bc60c78a8bef57880de0b6b3a764000080820ff6a0d0367709eee090a6ebd74c63db7329372db1966e76d28ce219d1e105c47bcba7a0042d52f7d2436ad96e8714bf0309adaf870ad6fb68cfe53ce958792b3da36c12", description = descriptionExample.value ) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 7518144a4..3cb85b45a 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -65,14 +65,15 @@ case class TransactionRequestBodyCardanoJsonV600( // ---------------- Ethereum models (V600) ---------------- case class TransactionRequestBodyEthereumJsonV600( - to: String, // 0x address + 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( - to: String, // eth_sendRawTransaction params field. + params: String, // eth_sendRawTransaction params field. description: String ) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala index d1dc92cb6..a2b74af54 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala @@ -778,14 +778,14 @@ object LocalMappedConnectorInternal extends MdcLoggable { case ETH_SEND_RAW_TRANSACTION => for { // Parse raw transaction JSON transactionRequestBodyEthSendRawTransactionJsonV600 <- NewStyle.function.tryons( - s"$InvalidJsonFormat It should be $TransactionRequestBodyEthSendRawTransactionJsonV600 or $TransactionRequestBodyEthereumJsonV600 json format", + s"$InvalidJsonFormat It should be $TransactionRequestBodyEthSendRawTransactionJsonV600 json format", 400, callContext ) { json.extract[TransactionRequestBodyEthSendRawTransactionJsonV600] } // Decode raw transaction to extract 'from' address - decodedTx = DecodeRawTx.decodeRawTxToJson(transactionRequestBodyEthSendRawTransactionJsonV600.to) + decodedTx = DecodeRawTx.decodeRawTxToJson(transactionRequestBodyEthSendRawTransactionJsonV600.params) from = decodedTx.from _ <- Helper.booleanToFuture( s"$BankAccountNotFoundByAccountId Ethereum 'from' address must be the same as the accountId", @@ -795,8 +795,9 @@ object LocalMappedConnectorInternal extends MdcLoggable { } // Construct TransactionRequestBodyEthereumJsonV600 for downstream processing transactionRequestBodyEthereum = TransactionRequestBodyEthereumJsonV600( + params = Some(transactionRequestBodyEthSendRawTransactionJsonV600.params), to = decodedTx.to.getOrElse(""), - value = AmountOfMoneyJsonV121("ETH", "0.01"), + value = AmountOfMoneyJsonV121("ETH", decodedTx.value.getOrElse("0")), description = transactionRequestBodyEthSendRawTransactionJsonV600.description ) } yield (transactionRequestBodyEthereum) diff --git a/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala b/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala index 3ab10856c..91fd4ee05 100644 --- a/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala +++ b/obp-api/src/main/scala/code/bankconnectors/ethereum/EthereumConnector_vSept2025.scala @@ -2,6 +2,7 @@ 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 @@ -53,7 +54,7 @@ trait EthereumConnector_vSept2025 extends Connector with MdcLoggable { val to = toAccount.accountId.value val valueHex = ethToWeiHex(amount) - val maybeRawTx: Option[String] = Option(transactionRequestCommonBody).map(_.description).map(_.trim).filter(s => s.startsWith("0x") && s.length > 2) + 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 From c28aaf0fe4b30b14026a6ce4da4162e069a3a8a1 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 25 Sep 2025 16:20:59 +0200 Subject: [PATCH 17/18] test/ commented EthereumConnector_vSept2025Test --- .../EthereumConnector_vSept2025Test.scala | 255 ++++++++---------- 1 file changed, 112 insertions(+), 143 deletions(-) diff --git a/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala b/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala index ba071d35d..f65c1bd40 100644 --- a/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala +++ b/obp-api/src/test/scala/code/connector/EthereumConnector_vSept2025Test.scala @@ -1,147 +1,116 @@ -package code.connector - -import code.api.util.ErrorMessages -import code.api.v5_1_0.V510ServerSetup -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 = new TransactionRequestCommonBodyJSON { - override val value: AmountOfMoneyJsonV121 = AmountOfMoneyJsonV121("ETH", amount.toString) - override val description: String = "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 -// ) +//package code.connector // -// 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 = "0xf86b178203e882520894627306090abab3a6e1400e9345bc60c78a8bef57880de0b6b3a764000080820ff6a016878a008fb817df6d771749336fa0c905ec5b7fafcd043f0d9e609a2b5e41e0a0611dbe0f2ee2428360c72f4287a2996cb0d45cb8995cc23eb6ba525cb9580e02" - val trxBody = new TransactionRequestCommonBodyJSON { - override val value: AmountOfMoneyJsonV121 = AmountOfMoneyJsonV121("ETH", amount.toString) - // Put rawTx here to trigger eth_sendRawTransaction (connector uses description starting with 0x) - override val description: String = rawTx - } - - // Enable integration test against private chain -// val resF = StubConnector.makePaymentv210( -// from, -// to, -// TransactionRequestId(java.util.UUID.randomUUID().toString), -// trxBody, -// amount, -// "test", -// TransactionRequestType("ETH_SEND_TRANSACTION") , -// "none", -// None +//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" // ) // -// val res = Await.result(resF, 30.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 = "0xf86b178203e882520894627306090abab3a6e1400e9345bc60c78a8bef57880de0b6b3a764000080820ff6a016878a008fb817df6d771749336fa0c905ec5b7fafcd043f0d9e609a2b5e41e0a0611dbe0f2ee2428360c72f4287a2996cb0d45cb8995cc23eb6ba525cb9580e02" - val trxBody = new TransactionRequestCommonBodyJSON { - override val value: AmountOfMoneyJsonV121 = AmountOfMoneyJsonV121("ETH", amount.toString) - // Put rawTx here to trigger eth_sendRawTransaction (connector uses description starting with 0x) - override val description: String = rawTx - } - - // Enable integration test against private chain -// val resF = StubConnector.makePaymentv210( -// from, -// to, -// TransactionRequestId(java.util.UUID.randomUUID().toString), -// trxBody, -// amount, -// "test", -// TransactionRequestType("ETH_SEND_TRANSACTION") , -// "none", -// None -// ) +// //// 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") +// } +// } +// +//} +// // -// val res = Await.result(resF, 30.seconds) -// res._1 shouldBe a [Full[_]] -// val txId = res._1.openOrThrowException(ErrorMessages.UnknownError) -// txId.value should startWith ("0x") - } - } -} - - From 4b3bd24dd57cf881fed81dfb342403b62d846b06 Mon Sep 17 00:00:00 2001 From: hongwei Date: Fri, 26 Sep 2025 10:36:21 +0200 Subject: [PATCH 18/18] docfix/ Update release notes and sample properties for Ethereum Connector Configuration, adding details on RPC URL and transaction modes --- .../src/main/resources/props/sample.props.template | 12 ++++++++++++ release_notes.md | 2 ++ 2 files changed, 14 insertions(+) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index a3474f613..ab1a8fd57 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -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 diff --git a/release_notes.md b/release_notes.md index 03a17f3ed..ec1a99f63 100644 --- a/release_notes.md +++ b/release_notes.md @@ -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: