From 49daedf030993f637d2b196afaf24ac41f312299 Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 23 Sep 2025 22:30:16 +0200 Subject: [PATCH] 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") } } }