feature/Enhance EthereumConnector_vSept2025 to support eth_sendRawTransaction for raw transactions and improve error handling in response parsing

This commit is contained in:
hongwei 2025-09-23 22:30:16 +02:00
parent c523fb950a
commit 49daedf030
2 changed files with 110 additions and 42 deletions

View File

@ -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)
}
}
}

View File

@ -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")
}
}
}