Corda Oracle

TIS Blockchain Promotion Office
31 min readAug 2, 2021

--

・はじめに

今回の記事はCordaのKeyConceptの一つであるOracleについてです。Oracleは要求に応じて、特定の事実(たとえば、ある時刻の為替レートなど)を提供します。Time-Windowと同様にKeyConceptの1つでありながら、概要は知っているが実装方法や使い方はよく知らないという方も多いのではないかと思います。今回、Oracleがどのように外部データを取得して、どのように利用するか調査いたしました。 本記事ではOracleを用いたSampleアプリを作成したのでそちらをもとに実装方法や注意点を紹介します。

・概要

Transactionを生成するノードが外部データを参照するときにOracleはそのデータを提供する窓口になります。Oracleは Corda ネットワーク上の一つのノードとして動き、ネットワーク上の他のノードと同様にフローを使って、通信することができます。
また、OracleはTransactionを検証して署名をします。これは、Oracleが提供したデータなどが改ざんされたものでないかを確認するためです。
Oracleを利用する際の流れは図1の通りです

図1:Oracleの流れ

・実装方法

Oracleを利用して公正な取引を行うために、少なくとも以下の2つの機能を実装する必要があります。

〇データ提供
ノードはOracleに対してデータの問い合わせを行い、Oracleはデータを取得し対象のデータを提供します。

〇署名
Transactionを検証して署名をします。

実際にSampleアプリをもとに解説していきます。

▼アプリの仕様
このアプリは通貨を交換するアプリです。
PartyAが各Partyに対してJPYトークンを発行します。
各PartyはOracleから為替レートを取得し、そのレートを元にJPYトークンをUSDトークンに交換します。例として図2では 、PartyAがPartyBに対して300JPY発行(tx1)し、PartyBはそのうちの100JPYをOracleから取得した為替レートをもとにUSDに変換(tx2)する場合を表しています。その際、PartyBは Flow実行時に最低交換レートを参照し、Oracleから取得したレートがそのレート以下であれば実際には交換は行いません。
このアプリではToken SDKを利用しています。なお、Token SDKのAPIについては説明を割愛しますのでToken SDKのドキュメントを参照してください。

図2:Sampleアプリの仕様

▼Oracleの流れ

・データ提供(query)

PartyBがOracleに対して為替レートを要求し、Oracleがそれに対して為替レートを提供するといった基本的な構成になっています。為替レートは取得する時間によって値が変わってしまうため、今回は外部から取得した為替レートをStateとしてOracle自身のvaultに登録しています。これはOracleの検証時に同じ値のレートを比較して検証できるようにするためです。特に決まった実装はなく、要件に依るところが大きいので正しく取得、検証できるような実装にする必要があります。

・署名(sign)

PartyBは取得したデータが正しいがどうか証明するためにOracleの署名をもらいます。その際に、取得したデータを含むTransactionをOracleに渡す必要があります。しかし、この取引と直接は関係のないOracleに取引内容を知られてしまうのはプライバシーの観点から問題があります。そのため、見せる必要のない情報(input stateなど)をFilteringしてからOracleに渡します。今回の場合、PartyBはOracleから取得したデータをコマンドに挿入します。その後、Transactionにそのコマンドを追加し、そのコマンドのみ表示するようにFilterをかけ、Oracleに送ります。Oracleはデータ提供時に登録したデータとコマンドに含まれているデータが正しいかどうか検証します。正しければOracleの署名をPartyBに返します。

図3はSampleアプリでのOracleに着目したデータの流れです。

図3:SampleアプリでのOracleの流れ

Oracleに大きく関与する以下4つを中心に解説していきます。

(1)CoinTradeFlow.kt・・・所持している通貨を交換するFlow

(2)QueryRate.kt・・・Oracleに為替レートを依頼するFlow

(3)SignRate.kt・・・Oracleに署名を依頼するFlow

(4)Oracle.kt・・・データ取得と署名のための検証が記述されたクラス

特筆すべき箇所のみ解説を行い、ソースコード一式は記事の最後の付録に掲載しています。

(1) CoinTradeFlow.kt

このFlowは自身が持っているJPYをUSDに交換するFlowです。交換時のレートをOracleから取得します。Responder側(CoinTradeFlowHandler)では送られてきたTaransactionに対して、追加のチェックや署名、コミットする処理を記述します。
以下、CoinTradeFlowの流れです。
1.為替レートを取得(QueryRate)し、為替レートをコマンドに挿入
2.Transaction生成
3.USDに変換するJPYの償還
4.USDTokenの発行
5.Transactionの検証、自身の署名
6.署名を取得(SignRate)し、Transactionに追加
7.関係者へ署名を集め、コミット
今回は、データ取得と署名に関与する1と6の解説します。

・1.為替レートを取得(QueryRate)し、為替レートをコマンドに挿入

 //Oracleから為替レートを取得しコマンドを設定
val oracle = getOracle(serviceHub)
     //Oracleからデータを取得
val rateOfferedFromOracle =
subFlow(QueryRate(oracle,symbol))

     //指定したレート以下なら例外発生
require
(rateOfferedFromOracle["open"] as Double > rate)

     //取得したデータをコマンドに挿入
     val command = Command(
CoinContract.CoinTrade(rateOfferedFromOracle),
listOf(ourIdentity.owningKey, oracle.owningKey)
)

OracleのPartyを取得し、QueryRateを呼び出して為替レートを取得します。取得したレートとパラメータのレートを比較して、パラメータのレート以下であれば例外を発生させて処理を終了させます。パラメータのレートを超えていれば 、コマンドを生成して取得したレート情報と署名者リストを挿入します。TransactionBuilderを生成し、上記で作成したコマンドを追加します。

・6.署名を取得(SignRate)し、Transactionに追加

//TransactionをFilteringする
val filteredtx = ptx.buildFilteredTransaction(Predicate {
when (it) {
is Command<*> -> oracle.owningKey in it.signers
               && it.value is CoinContract.CoinTrade
else -> false
}
})
//Oracleの署名を取得
val oracleSignature =
subFlow(SignRate(oracle,
filteredtx,
rateOfferedFromOracle["txid"]
as SecureHash))
//Oracleの署名をTransactionに追加
val stx = ptx.withAdditionalSignature(oracleSignature)

Oracleの署名をもらうために、TransactionをOracleに送信 する必要があります。今回はデータを挿入したCoinTradeの箇所でFilteringして、Oracleに送信します。Oracleから署名を受け取り、Transactionに追加します。

・CoinTradeFlowHandler

SampleアプリではTokenを利用しているのでTokenの発行者にも署名を集める必要があります。実装において取引相手(responder)は正しくOracleによって検証されているか確認するべきです。取引相手は送られてきたTransactionにOracleの署名が入っているか、または取引相手が再度、Oracleに対して検証をしてもらう必要があります。今回は前者の方で確認を行って います。

(2)QueryRate.kt

このFlowはOracleから外部データを取得するためのFlowです。Initiator側(QueryRate)でOracleとの接続を確立して、データを要求します。
Responder側(QueryRateHandler)はOracleノードでの処理で、OracleServiceを用いて為替レートを取得し、vaultへ登録後、Initiator側に取得した為替レートを送ります。

・QueryRate

Oracleへのセッションを開きます。為替レートを取得するための情報をsendし、為替レートをreceiveします。

・QueryRateHandler

@Suspendable
override fun call() {
    //QueryRateから送られた通貨情報を受け取る
val request = session.receive<String>().unwrap { it }
val response = try {
//為替レートを受け取る
val response =       
serviceHub.cordaService(Oracle::class.java)
                 .query(request)
//為替レートをvaultに登録する
val stx = subFlow(OracleRateRegistFlow(response))
response["txid"] = stx.id
response
} catch (e: Exception) {
throw FlowException(e)
}
//QueryRate側のセッションに為替レートを送る
session.send(response)
}
}

Initiator側からデータをreceiveし、OracleServiceを用いて、為替レートを取得します。検証するために、取得した為替レートをOracleRateRegistFlow でStateとしてvaultに登録します。今回は、取得した外部データをmap型にしているので、登録した際のTransactionIdをこのmapにいれておきます。その後、Initiator側にこのデータをsendします。

(3)SignRate.kt

このFlowはOracleから署名を取得するためのFlowです。Initiator側(SignRate)でOracleとの接続を確立して、署名を要求します。Responder側(SignRateHandler)はOracleノードでの処理で、OracleServiceを用いてTransactionを検証し、Initiator側に署名を送ります。

・SignRate

Oracleとのセッションを開き、txidとフィルタリングされたTransactionをsendします。検証が正しければ、Oracleの署名を受け取り、呼び出しもと(今回の場合だとCoinTradeFlow)へ返します。

・SignRateHandler

@Suspendable
override fun call() {
//SignRateからFilterTransactionとTransactionIdを受け取る
val request = session.receive<FilteredTransaction>()
                  .unwrap { it }
val txId = session.receive<SecureHash>().unwrap { it }
val response = try { //Oracleの署名を受け取る
serviceHub.cordaService(Oracle::class.java)
           .sign(request, txId)
} catch (e: Exception) {
throw FlowException(e)
}
//SignRate側のsessionにOracleの署名を送る
session.send(response)
}

Initiator側からデータをreceive し、OracleServiceを用いて、署名を受け取ります。その後、Initiator側にその署名をsendします。

(4)Oracle.kt

データ取得のためのロジックと署名をするための検証ロジックを記述するクラスです。Oracleクラスには「@CordaService」を付与し、SingletonSerializeAsToken()を継承します。

・query
データ取得のためのロジックが記述された関数です。JavaのAPIなどを利用して外部からデータを取得します。その後、取得したデータを必要な形に 整えて返します。

fun query(symbol: String): Map<String, Any> {
val requestUrl = "リクエストするURL"
//URLオブジェクトを生成
val url = URL(requestUrl)
//URLを指定してデータを取得し返す
return client.newCall(
Request.Builder()
.url(url)
.build()
).execute().body!!.let {
val json = it.string()
val tree = mapper.readTree(json)
val rate = //JSONオブジェクトを取得
// 取得したデータをマップに入れる
val map = linkedMapOf<String, Any>()
map["open"] = rate["open"].asDouble()
map["currencyPairCode"] = rate["currencyPairCode"].asText()
map
}
}

clientはOkHttpClientのインスタンスで、これを利用して外部データ(為替レート)を取得します。mapperはObjectMapperのインスタンスでオブジェクトとJSONデータをマッピングします。ObjectMapperを使ってそれぞれをmapへ入れていきそのmapを返します。

・sign
署名のためのロジックが記述された関数です。Transactionやデータを検証して正しければ、署名を作成し返します。

fun sign(ftx: FilteredTransaction, 
     txid: SecureHash): TransactionSignature {
//部分的なマークルツリーをチェック
ftx.verify()
val rateState = //Query時に登録したStateを取得する fun isCommandWithCorrectRateAndIAmSigner(elem: Any) =
when {
elem is Command<*>
&& elem.value is CoinContract.CoinTrade ->
{
val cmdData = elem.value as CoinContract.CoinTrade
myKey in elem.signers
&& rateState.state.data.open
                           == cmdData.map["open"]
}
else -> false
}
//正しいデータかチェック
val isValidMerkleTree =    
       ftx.checkWithFun(::isCommandWithCorrectRateAndIAmSigner)
//他のコマンドがオラクルによる署名を必要としないことを確認
ftx.checkCommandVisibility(
                  services.myInfo
                       .legalIdentities
                       .first()
                       .owningKey)
if (isValidMerkleTree) {
return services.createSignature(ftx, myKey)
} else {
throw IllegalArgumentException(
        "Oracle signature requested over invalid transaction.")
}
}
}

verify()でFilteringされたTransactionが正しいかの検証を行います。Query時に登録したStateを検索し、パラメータのtxidで対象のStateを抽出します。
checkWithFun()でFilteringされたTransactionのコマンドが対象のコマンドかどうか、含まれているデータが正しい値か、Oracleは署名者として設定されているかの検証を行います。
checkCommandVisibility()ではOracleによる署名が必要なコマンドがすべて表示されているかを確認します。TransactionをFilteringする際は『Oracleの署名を必要とするコマンドすべて』でFilteringする必要があります。Transactionに設定された必要な署名者リストは、Filteringでは非表示にされず、signers component groupに残っています。 今回の場合、CoinTradeコマンド以外の他のコマンドの必要な署名者リストはOracleから見える状態になっており、TransactionをFilteringする際のCoinTradeコマンド以外に『Oracleが必要な署名者リストとして設定されているコマンド』がもしあれば、checkCommandVisibilityにより例外が発生されます。

全ての検証が正しければ、自身の署名を返します。

・まとめ

Oracleを実装し分かったことを以下にまとめます。

▼外部データ取得

Http通信やCSVファイル、データベースなどから外部データを取得するにはjava標準のAPIやjavaアプリケーション用のAPIを使ってデータを取得します。その後、取得したデータをオブジェクトにマッピングし利用します。これは自分自身でコーディングを行う必要があります。

▼署名

verify()、checkWithFun()、checkCommandVisibility()の3つを行い、検証します。コマンドに挿入されたデータの検証や署名者の検証は自分自身でコーディングを行う必要があります。データの検証のためのデータ取得として、再度同じ値を返す正しいQueryを実行してデータを受けとるか、または一度目のQuery時に対象のデータをデータベースに登録し、検証時に検索してデータを受け取ることが必要です。

▼注意点

今回、上記で作成した方法はほんの一例です。各Partyが自身で外部からデータを取得してそれをOracleに検証してもらう実装やあるノードがレジャー上のデータを提供し、Oracleがそのデータを分析しそのノードに提供するような実装も可能です。
Oracleの一番の目的は外部データの正しさを証明することです。したがって実運用上ではOracleは信頼できる第三者であることが重要です。また、取引の際にOracleノードが落ちてしまっていると応答待ちになり処理が止まってしまうことにも注意が必要です。

・おわりに

Oracleを実装するにあたり、様々なSampleアプリを確認しました。特に決まった実装がなく、自由度が高いがゆえにどのように実装することがベストなのかとても悩みました。TransactionをFilteringするためにマークルツリーの知識やTransaction tear-offについての理解もある程度必要になります。この記事を通して、Oracleとはどのようなものかを理解し、実装する上での一つの実装方法としてこの記事が参考になったら幸いです。

今回は以上になります。

お問い合わせ: bc_prom@ml.tis.co.jp
記:TIS Blockchain Promotion Office (Hikaru Suzuki)
Thanks to Kiyotaka Yamasaki and Hideki Nakachi

付録

・CoinTradeFlow.kt

@InitiatingFlow
@StartableByRPC
class CoinTradeFlow(val symbol: String, //取得する通貨単位
val quantity: Double, //交換するJPYの量
val rate: Double, //交換する最低レート
val issuer : Party //所持しているTokenの発行者
) : FlowLogic<SignedTransaction>() {

@Suspendable
override fun call(): SignedTransaction {
//Notaryを取得
val notary =     
          serviceHub.networkMapCache.notaryIdentities.first()
//Oracleから為替レート取得しコマンドを設定
val oracle = getOracle(serviceHub)
     //Oracleからデータを取得
val rateOfferedFromOracle =
                 subFlow(QueryRate(oracle, symbol))
//指定したレート以下なら例外発生
require(rateOfferedFromOracle["open"] as Double > rate)
//取得したデータをコマンドに挿入
val command = Command(
CoinContract.CoinTrade(rateOfferedFromOracle),
listOf(ourIdentity.owningKey, oracle.owningKey)
)

//txの生成
val txBuilder =  
            TransactionBuilder(notary).addCommand(command)

//USDに変換するJPYの償還
val JPYTokenType = TokenType("JPY", 3)
val amount = amount(quantity, JPYTokenType)
val queryCriteria = tokenAmountWithHolderCriteria(
                        JPYTokenType,
                        ourIdentity
                  )

addFungibleTokensToRedeem(txBuilder,
                     serviceHub,
                     amount,
issuer,
ourIdentity,
queryCriteria)

//USDTokenの発行
val USDTokenType = TokenType("USD", 3)
val issuedTokenType = USDTokenType issuedBy issuer
val amountUSD = amount.quantity
.times(amount.displayTokenSize.toDouble())
.div(rateOfferedFromOracle["open"] as Double)
val fungibleToken = FungibleToken(
amount(amountUSD, issuedTokenType),
ourIdentity
)

addIssueTokens(txBuilder, fungibleToken)

txBuilder.verify(serviceHub)
val ptx = serviceHub.signInitialTransaction(txBuilder)

//TransactionをFilteringする
val filtertx = ptx.buildFilteredTransaction(Predicate {
when (it) {
is Command<*> -> oracle.owningKey in it.signers
&& it.value is CoinContract.CoinTrade
else -> false
}
})
//Oracleの署名を取得
val oracleSignature =
subFlow(
SignRate(
oracle,
filtertx,
rateOfferedFromOracle["txid"]
as SecureHash
)
)
//Oracleの署名をTransactionに追加
val stx :SignedTransaction =
ptx.withAdditionalSignature(oracleSignature)

val issuerSession = initiateFlow(issuer)
val ftx = subFlow(CollectSignaturesFlow(
stx, listOf(issuerSession)))

return subFlow(FinalityFlow(ftx, listOf(issuerSession)))
}
}

@InitiatedBy(CoinTradeFlow::class)
class CoinTradeFlowHandler(
val otherPartySession: FlowSession)
: FlowLogic<Unit>() {
@Suspendable
override fun call() {
val oracle = getOracle(serviceHub)
val signTransactionFlow = object
: SignTransactionFlow(otherPartySession) {
override fun checkTransaction(stx: SignedTransaction) =
requireThat {
        //TransactionにOracleの署名が含まれている
require(stx.sigs.map{it.by}.contains(oracle.owningKey))

}
}
subFlow(signTransactionFlow)

subFlow(
ReceiveFinalityFlow(
otherPartySession,
statesToRecord =
StatesToRecord.ONLY_RELEVANT
)
)

}
}
//Oracleを取得するためのプライベート関数
private fun getOracle(serviceHub: ServiceHub): Party {
val oracleName = CordaX500Name("Oracle", "New York", "US")
return serviceHub.networkMapCache
.getNodeByLegalName(oracleName)?
.legalIdentities?
.first()
?: throw IllegalArgumentException(
"Requested oracle $oracleName not found on network.")
}

・QueryRate.kt

@InitiatingFlow
class QueryRate(val oracle: Party,
val symbol: String)
: FlowLogic<LinkedHashMap<String,Any>>() {

@Suspendable override fun call() = initiateFlow(oracle)
.sendAndReceive<LinkedHashMap<String,Any>>(symbol)
.unwrap { it }

}

@InitiatedBy(QueryRate::class)
class QueryRateHandler(val session: FlowSession)
: FlowLogic<Unit>() {

@Suspendable
override fun call() {
//QueryRateから送られた通貨情報を受け取る
val request = session.receive<String>().unwrap { it }
val response = try {
//為替レートを受け取る

val response =       
serviceHub.cordaService(Oracle::class.java)
                 .query(request)
//為替レートをvaultに登録する
val stx = subFlow(OracleRateRegistFlow(response))
response["txid"] = stx.id
response
} catch (e: Exception) {
throw FlowException(e)
}
//QueryRate側のセッションに為替レートを送る
session.send(response)
}
}

・SignRate

@InitiatingFlow
class SignRate(val oracle: Party,
val ftx: FilteredTransaction,
val txid: SecureHash)
: FlowLogic<TransactionSignature>() {
@Suspendable
override fun call(): TransactionSignature {
val session = initiateFlow(oracle)
session.send(ftx)
return session
.sendAndReceive<TransactionSignature>(txid)
.unwrap { it }
}
}

@InitiatedBy(SignRate::class)
class SignRateHandler(val session: FlowSession)
: FlowLogic<Unit>() {
@Suspendable
override fun call() {
//SignRateからFilterTransactionとTransactionIdを受け取る
val request = session.receive<FilteredTransaction>()
                  .unwrap { it }
val txId = session.receive<SecureHash>().unwrap { it }
val response = try { //Oracleの署名を受け取る
serviceHub.cordaService(Oracle::class.java)
            .sign(request, txId)
} catch (e: Exception) {
throw FlowException(e)
}
//SignRate側のsessionにOracleの署名を送る
session.send(response)
}
}

・Oracle.kt

@CordaService
class Oracle(val services: AppServiceHub) : SingletonSerializeAsToken() {

private var client = OkHttpClient()
private val mapper = ObjectMapper()
private val myKey =
         services.myInfo.legalIdentities.first().owningKey

fun query(symbol: String): LinkedHashMap<String, Any> {
val requestUrl = ”リクエストするURL”

//URLオブジェクトを生成
val url = URL(requestUrl)
//URLを指定してデータを取得し返す
return client.newCall(
Request.Builder()
.url(url)
.build()
).execute().body!!.let {
val json = it.string()
val tree = mapper.readTree(json)
val rate = //JSONオブジェクトを取得
// 取得したデータをマップに入れる
val map = linkedMapOf<String, Any>()
map["open"] = rate["open"].asDouble()
map["currencyPairCode"] =
rate["currencyPairCode"].asText()
map
}

}


fun sign(ftx: FilteredTransaction, txid: SecureHash)
: TransactionSignature {
//部分的なマークルツリーをチェック
ftx.verify()
     //Query時に登録したStateを取得する
val states = services.vaultService
.queryBy<RateState>()
.states
val rateState = states.filter {
it.ref.txhash == txid
}.first()

fun isCommandWithCorrectRateAndIAmSigner(elem: Any) =
when {
elem is Command<*>
&& elem.value is CoinContract.CoinTrade->
{
val cmdData = elem.value
as CoinContract.CoinTrade
myKey in elem.signers
&& rateState.state.data.open
== cmdData.map["open"]
}
else -> false
}

    //正しいデータかチェック
val isValidMerkleTree =
ftx.checkWithFun(::isCommandWithCorrectRateAndIAmSigner)
//他のコマンドがオラクルによる署名を必要としないことを確認
ftx.checkCommandVisibility(
services.myInfo
.legalIdentities
.first()
.owningKey
)

if (isValidMerkleTree) {
return services.createSignature(ftx, myKey)
} else {
throw IllegalArgumentException(
"Oracle signature requested over invalid transaction.")
}
}
}

--

--