投稿日
Corda Tokens SDK and Accounts
もくじ
はじめに
こんにちは。ブロックチェーン推進室の鈴木です。
私の所属するブロックチェーン推進室では、企業間取引に特化したエンタープライズブロックチェーンプラットフォーム:Cordaに関して機能調査を行っております。
Cordaは世界で350社を超える金融機関、規制当局、中央銀行、業界団体、システム・インテグレーターやソフトウェアベンダーにより構成されるR3エコシステムから、エンドユーザー目線で設計・開発されています。
また、4半期に1度のバージョンアップにより機能追加がされていますが、その機能がお客様に活用できるものなのか、ドキュメントだけではわからないことも多く、ブロックチェーン推進室独自で調査を進めています。
このブログでは、そういったCordaの機能調査をした結果を機能の使い方含めて紹介していきます。
Cordaの導入方法についてはこちらのドキュメントをご参照ください。
今後以下の内容で紹介しようと思います。
(以降、バージョンアップの都度、新機能を紹介予定)
第3回目は「Corda Tokens SDK and Accounts」について紹介します。
概要
今回、ブロックチェーン初心者およびCorda初心者がCordaのTokens SDKとAccounts機能を利用し、トークンを交換するアプリを作成しました。
作成するにあたり、躓いたり、気づいたりした部分がありましたので記事にいたします。初心者なりに考え悩んだことを備忘録として記載しております。
一部、読みづらい部分や誤りなどがあるかもしれませんが、あくまで参考までにと読んでいただけたら幸いです。
検証構成
OS : Windows 10
Corda : version 4.5(2021年9月時点で4.8が最新)
token-sdk : version 1.2
accounts : version 1.1
通貨に見立てたトークンを取引するアプリを作成してみた
今回、Cordaのtoken-sdkとAccounts機能の検証を兼ねてアカウント間で通貨に見立てたトークンを取引できるアプリを作成してみました。
token-sdkとは、トークンの開発を容易にするライブラリのことで、トークンの発行や移動・償還といった各種APIが提供されています。
Accounts機能は、ノード内にアカウントを作成し、アカウント単位で取引ができるようになります。
例えば、Aノード内にアリスというアカウントとボブというアカウントを作成するとアリスとボブで取引が可能になります。もちろん、他ノードのアカウントとも取引も可能です。
本題に入りますが、実装にあたって、こちらのサンプルアプリを参考にしました。
このアプリのユースケースは、ワールドカップのチケットの販売がモデルとなっています。アカウント間でチケットトークンと通貨に見立てたトークンを交換するという内容です。
今回、シンプルな実装にしたかったので、チケット関係の処理を削除して通貨に見立てたトークンの取引のみのアプリを作成しました。
その結果、トークンを自ノードのアカウント宛に送る用と他ノードのアカウント宛に送る用の2つのFlowが出来上がりました。
▼トークンを自ノードのアカウント宛に送るフロー
@InitiatingFlow
@StartableByRPC
class MoveTokenOnSameNode(private val holderAccountName:String,
private val recipientAccountName:String,
private val amount: Long,
private val currency: String) : FlowLogic<String>() {
@Suspendable
override fun call():String {
//Token holder Account
val holderInfo = accountService.accountInfo(holderAccountName)[0].state.data
val holderAccount = subFlow(RequestKeyForAccount(holderInfo))
//Token recipient Account
val recipientInfo = accountService.accountInfo(recipientAccountName).single().state.data
val recipientAccount = subFlow(RequestKeyForAccount(recipientInfo))
// notary
val notary = serviceHub.networkMapCache.notaryIdentities.single()
// METHOD 1
// making transaction
val txbuilder = TransactionBuilder(notary)
// getting current token
val amount = Amount(amount, getInstance(currency))
val partyAndAmount = PartyAndAmount(recipientAccount, amount)
val payMoneyCriteria = QueryCriteria.VaultQueryCriteria(externalIds = listOf(holderInfo.identifier.id),
status = Vault.StateStatus.UNCONSUMED
)
//call utility function to move the fungible token from holder to recipient account
addMoveFungibleTokens(txbuilder, serviceHub, listOf(partyAndAmount), holderAccount, payMoneyCriteria)
//self sign the transaction. note : the host party will first self sign the transaction.
val selfSignedTx = serviceHub.signInitialTransaction(txbuilder, listOf(ourIdentity.owningKey))
//establish sessions with holder and recipient. to establish session get the host name from accountinfo object
val holderSession = initiateFlow(holderInfo.host)
val recipientSession = initiateFlow(recipientInfo.host)
//Note: though buyer and seller are on the same node still
//we will have to call CollectSignaturesFlow as the signer is not a Party but an account.
val fulySignedTx = subFlow(CollectSignaturesFlow(selfSignedTx, listOf(holderSession, recipientSession)))
//call ObserverAwareFinalityFlow for finality
val stx = subFlow<SignedTransaction>(ObserverAwareFinalityFlow(fulySignedTx, listOf(holderSession, recipientSession)))
return ("Tokens have moved to $holderAccountName"+ "ntxID: " + stx.id)
}
}
@InitiatedBy(MoveTokenOnSameNode::class)
class MoveTokenOnSameNodeResponder(val counterpartySession: FlowSession) : FlowLogic<SignedTransaction>() {
@Suspendable
override fun call():SignedTransaction {subFlow(object : SignTransactionFlow(counterpartySession) {
@Throws(FlowException::class)
override fun checkTransaction(stx: SignedTransaction) {
// Custom Logic to validate transaction.
}
})
return subFlow(ReceiveFinalityFlow(counterpartySession))
}
}
▼トークンを他ノードのアカウント宛に移動するフロー
@InitiatingFlow
@StartableByRPC
class MoveTokenToOtherNode(private val holderAccountName:String,
private val recipientAccountName:String,
private val amount: Long,
private val currency: String) : FlowLogic<String>() {@Suspendable
override fun call():String {
//holder Account
val holderInfo = accountService.accountInfo(holderAccountName)[0].state.data
val holderAccount = subFlow(RequestKeyForAccount(holderInfo))
//recipient Account
val recipientInfo = accountService.accountInfo(recipientAccountName).single().state.data
val recipientAccount = subFlow(RequestKeyForAccount(recipientInfo))
// making recipient session
val recipientSession = initiateFlow(recipientInfo.host)
recipientSession.send(holderAccountName)
recipientSession.send(recipientAccountName)
//holder will create generate a move tokens state and send this state
val amount = Amount(amount, getInstance(currency))
//holder Query for token balance.
val queryCriteria = heldTokenAmountCriteria(getInstance(currency), holderAccount).and(sumTokenCriteria())
val sum = serviceHub.vaultService.queryBy(FungibleToken::class.java, queryCriteria).component5()
if (sum.size == 0) throw FlowException(
"$holderAccountName has 0 token balance. Please ask the Bank to issue some cash.")
else {
val tokenBalance = sum[0] as Long
if (tokenBalance < this.amount) throw FlowException(
"Available token balance of $holderAccountName is less than you have.
Please ask the Bank to issue some cash if you wish to move tokens ")
}
//the tokens to move to new account which is the recipient account
val partyAndAmount:Pair<AbstractParty, Amount<TokenType>> = Pair(recipientAccount, amount)
//let's use the DatabaseTokenSelection to get the tokens from the db
val tokenSelection = DatabaseTokenSelection(serviceHub,
MAX_RETRIES_DEFAULT,
RETRY_SLEEP_DEFAULT,
RETRY_CAP_DEFAULT,
PAGE_SIZE_DEFAULT
)
val inputsAndOutputs = tokenSelection.generateMove(Arrays.asList(partyAndAmount),
holderAccount,
TokenQueryBy(),
runId.uuid
)
//send the generated inputsAndOutputs to the recipient
subFlow(SendStateAndRefFlow(recipientSession, inputsAndOutputs.first))
recipientSession.send(inputsAndOutputs.second)
//created by the above token move method for the holder.
val signers: MutableList<AbstractParty> = ArrayList()
signers.add(holderAccount)
signers.add(recipientAccount)val inputs = inputsAndOutputs.first
for ((state) in inputs) {
signers.add(state.data.holder)
}
//Sync our associated keys with the conterparties.
subFlow(SyncKeyMappingFlow(recipientSession, signers))
//this is the handler for synckeymapping called by seller. seller must also have created some keys not known to us
subFlow(SyncKeyMappingFlowHandler(recipientSession))
//recieve the data from counter session in tx formatt.
subFlow(object : SignTransactionFlow(recipientSession) {
@Throws(FlowException::class)
override fun checkTransaction(stx: SignedTransaction) {
// Custom Logic to validate transaction.
}
})
val stx = subFlow(ReceiveFinalityFlow(recipientSession))
return ("Tokens have moved to $holderAccountName"+ "ntxID: "+stx.id)
}
}
@InitiatedBy(MoveTokenToOtherNode::class)
class MoveTokenToOtherNodeResponder(val otherSide: FlowSession) : FlowLogic<SignedTransaction>() {
@Suspendable
override fun call():SignedTransaction {
//get all the details from the recipient
val holderAccountName: String = otherSide.receive(String::class.java).unwrap { it }
val recipientAccountName: String = otherSide.receive(String::class.java).unwrap{ it }
val inputs = subFlow(ReceiveStateAndRefFlow<FungibleToken>(otherSide))
val moneyReceived: List<FungibleToken> = otherSide.receive(List::class.java).unwrap{ it } as List<FungibleToken>
//call SyncKeyMappingHandler for SyncKey Mapping called at holder side
subFlow(SyncKeyMappingFlowHandler(otherSide))
//Get holder and recipient account info
val holderAccountInfo = accountService.accountInfo(holderAccountName)[0].state.data
val recipientAccountInfo = accountService.accountInfo(recipientAccountName)[0].state.data
//Generate new keys for buyers and sellers
val holderAccount = subFlow(RequestKeyForAccount(holderAccountInfo))
val recipientAccount = subFlow(RequestKeyForAccount(recipientAccountInfo))
val notary = serviceHub.networkMapCache.notaryIdentities.single()val txBuilder = TransactionBuilder(notary)
addMoveTokens(txBuilder, inputs, moneyReceived)
//add signers
val signers: MutableList<AbstractParty> = ArrayList()
signers.add(holderAccount)
signers.add(recipientAccount)
for ((state) in inputs) {
signers.add(state.data.holder)
}
//sync keys with holder, again sync for similar members
subFlow(SyncKeyMappingFlow(otherSide, signers))
val commandWithPartiesList: List<CommandWithParties<CommandData>> = txBuilder.toLedgerTransaction(serviceHub).commands
val mySigners: MutableList<PublicKey> = ArrayList()
commandWithPartiesList.map {
val signer = (serviceHub.keyManagementService.filterMyKeys(it.signers) as ArrayList<PublicKey>)
if(signer.size >0){
mySigners.add(signer[0]) }
}
val selfSignedTransaction = serviceHub.signInitialTransaction(txBuilder, mySigners)
val fullySignedTx = subFlow(CollectSignaturesFlow(selfSignedTransaction, listOf(otherSide), mySigners))
//call FinalityFlow for finality
return subFlow(FinalityFlow(fullySignedTx, Arrays.asList(otherSide)))
}
}
違和感を抱いた点
実装してみたところ以下の点が気になりました。
- 自ノードのアカウント宛と他ノードのアカウント宛のFlowが分かれている。
- 自ノードのアカウント宛の取引と他ノードのアカウント宛の取引でトランザクションの作り方が違う。
特に違和感を抱いたのが、他ノードのアカウント宛のFlowではトランザクションがResponderFlowで生成されている点です。
今まで見てきた全てのアプリ(サンプル含む)はトランザクションをInitiatorFlowで生成していたので、なぜこのような構成になっているのか疑問に思いました。
サンプルソースコードをリファクタリングしてみた
上記の通り、「自ノード宛て」、「他ノード宛てでFlowが異なる」、「トランザクションがResponderFlowで生成されている」など違和感を抱いた為、サンプルソースコードを今まで実装してきたアプリの作り方で作り直してみました。
最初に、トランザクションが生成される箇所をResponderFlowからInitiatorFlowに移動してみました。
その結果、やはり一発ではうまく行かず、以下のエラーに悩まされました。
- トランザクションにイニシエータの署名がなされていない。
- CollectSignaturesFlowでどのセッションを渡せば良いかという問題。
- SignedTransactionを正常に取得できない問題。
- オブザーバーを追加すると処理が異常終了する問題。
それぞれのエラーに対してどのように対処していったかを記載します。
〇トランザクションにイニシエータの署名がなされていない
まず最初に躓いたのは、署名でした。
CollectSignaturesFlowが呼び出された際に、以下のエラーが発生。
java.lang.IllegalArgumentException: The Initiator of CollectSignaturesFlow must have signed the transaction.
(CollectSignaturesFlow のイニシエータは、トランザクションに署名していなければならない)
▼エラー原因
CollectSignaturesFlowの第3引数であるmyOptionalKeysでノードが所有するトランザクション内の鍵の集合を渡す必要ありましたが、該当する鍵の集合を渡していなかったために発生したエラーでした。
これまでのCordapp(ノード間の取引)では第3引数を指定することはありませんでしたが、サンプルアプリでは引数を渡していました。
しかし、仕様を理解していなかったため、単純にコピー&ペーストした状態で使用しており、かつコピー&ペーストによるミスで鍵を集めるロジックが一部誤っていたため、空の状態で引数に渡していました。
myOptionalKeysを空の状態で渡すと、CollectSignaturesFlowで取引相手に送信する前のセルフチェックにて送信者自身の鍵が見つからず、それが原因でエラーが発生していました。
また、なぜ第3引数に鍵が渡されないとエラーが発生するかというと、CollectSignaturesFlowで取引相手に送信する前に自分の署名が正しいかというセルフチェックに引っ掛かってしまうためです。
▼対処法
ノードが所有するトランザクション内の鍵の集合を渡せば解消します。(今回の場合は、トークン保有者のアカウントの鍵)鍵のリストを生成し、イニシエータのアカウントの鍵を追加して引数に渡すと問題は解決します。
次に挑戦したリファクタリングの内容は、自ノードのアカウント宛、他ノードのアカウント宛で分かれているフローを1つに統合できるかです。
ここでも問題が発生し、解決に時間が掛かりました。
〇CollectSignaturesFlowでどのセッションを渡せば良いかという問題
必要な署名が集まらずエラーが発生しておりました。
▼エラー原因
自ノードのアカウント宛と他ノードのアカウント宛で必要なセッションが異なるため発生しておりました。
▼対処法
取引先に応じて処理の分岐を実施します。
自ノードのアカウント宛の場合、トークンの保有者とトークンの受領者のセッションを引数として渡します。
従来であれば、自ノード内のやり取りではCollectSignaturesFlowの第二引数は空で渡しますが今回は逆になっていました。
他ノードのアカウント宛の場合、従来通りトークンの受領者のセッションのみ渡すことで正常に処理が完了します。
取引相手によって必要な署名者を見つけるのに時間が掛かりました。
以下が対処したソースコードです。
val isSameNode = recipientSession.counterparty.equals(ourIdentity)
val stx = when(isSameNode){
true -> {
val fullySignedTx = subFlow(CollectSignaturesFlow(selfSignedTx,
listOf(holderSession,recipientSession),
signers)
)
subFlow<SignedTransaction>(ObserverAwareFinalityFlow(fullySignedTx,
listOf(recipientSession,observerSession)
)
)
}
else -> {
val fullySignedTx = subFlow(CollectSignaturesFlow(selfSignedTx,
listOf(recipientSession),
signers
)
)
subFlow<SignedTransaction>(ObserverAwareFinalityFlow(fullySignedTx,
listOf(recipientSession,observerSession)
)
)listOf(recipientSession))
)
}
}
次に実施したことは、FinalityFlowをObserverAwareFinalityFlowいうtoken-sdkで提供されているAPIに置き換えてみることです。
このAPIの処理内容は、取引相手が関係者(トランザクション署名者)かオブザーバーかを識別し、TransactionRoleをセッションに送信後、FinalityFlowを呼び出しますがここでもエラーが発生しました。
〇SignedTransactionを正常に取得できない問題
想定外のデータを受信し、エラーが発生しておりました。
Caused by: java.io.NotSerializableException: Described type with descriptor net.corda:vlc3i8lJnO7K1i2g0g0+aA== was expected to be of type class net.corda.core.transactions.SignedTransaction but was class com.r3.corda.lib.tokens.workflows.internal.flows.finality.TransactionRole
▼原因
SignedTransactionを受信するべき箇所でTransactionRoleを受け取ってしまうためエラーが発生しておりました。
ObserverAwareFinalityFlowを使用すると取引相手が関係者(トランザクション署名者)かオブザーバーかを識別し、TransactionRoleをセッションに送信します。
しかし受信側にはSignedTransactionの受信処理しか記載がないため、その結果、SignedTransactionを受信するべき箇所でTransactionRoleを受け取ってしまっておりました。
▼対処方法
ObserverAwareFinalityFlowHandlerを使用することで解消しました。
サンプルアプリではObserverAwareFinalityFlowをしていましたがこのHandlerを使用していなかったため、存在自体に気が付くのに時間が掛かりました。
このエラーの解決過程で、TransactionRoleというものを初めて知りました。また、「セッション」についても知識が足りなかったと実感しました。
最後に、トークンの取引内容をオブザーバーに送信するという処理を実装してみました。
従来はオブザーバーへの送信は専用のFlowを作成していましたが、ObserverAwareFinalityFlowでは引数にオブザーバーを渡すことが可能ですので試しました。
■オブザーバーを追加すると処理が異常終了する問題
引数に渡しただけでは先ほどのSignedTransactionと同じエラーが発生します。
Caused by: java.io.NotSerializableException: Described type with descriptor net.corda:vlc3i8lJnO7K1i2g0g0+aA== was expected to be of type class net.corda.core.transactions.SignedTransaction but was class com.r3.corda.lib.tokens.workflows.internal.flows.finality.TransactionRole
このエラーも解決するのに時間が掛かりました。
▼原因
オブザーバー側で実行される受信用のFlowには、署名用のFlow(※CollectSignaturesFlowの受信側)が含まれているためです。オブザーバーのノードへは署名のリクエストが送られてこないのに、Flowの実装上は署名しようとしてしまうことが原因となります。
エラー分析を行うため書き出したイメージ(実際のクラス構造とは異なる)
▼対処法
対処法は2つあります。
- ResponderFlowを処理するノードが参加者(署名者)かオブザーバーかによって署名の処理を制御します。具体的には、InitiatorFlowで署名を求められたとき、参加者の場合は署名の処理を実施し、オブザーバーの場合は署名しないよう処理を分岐させます。そうすることで、本当に必要な署名だけを集めることができるからです。
- 従来通りの方法を使います。ObserverAwareFinalityFlowを使わずFinalityFlowを呼び、その後にオブザーバーへ情報を送信するロジックを別個で記述します。こちらを参考にすると実装することができます。
結局どのような実装が正しいのか?
今回、アカウント間で通貨に見立てたトークンを移動するアプリを作成する際に参考にしたサンプルの実装に違和感を抱き検証を進めてきました。
結果として、自ノード、他ノードのアカウント宛のトークンの移動は1フローで実現でき、トランザクションも一般的な方法(InitiatorFlowで生成)で実装できることを確認しました。
では、なぜサンプルアプリではトランザクションをResponderFlow側で生成していたのか?
それは「誰がトークンを知っているか」によって適切な実装が変わると思われます。
通貨に見立てたトークンの移動の場合、保有者は自身の保有するトークンを知っています。そのため、トランザクションをInitiatorFlowで生成できます。
しかしサンプルアプリ(ワールドカップのチケットの販売)の場合、チケットトークンは販売者しか知りません。
よって、買い手がチケットを購入する場合、自身の保有する通貨に見立てたトークンを相手に送り、受け手(販売者)でトランザクションを生成し、販売者しかしらないチケットトークンを埋め込み、それを買い手に送り返すという処理の流れになります。
結局のところ、ユースケースによってはResponderFlow側でトランザクションを生成する実装はおかしくないという結論に至りました。
最後に個人的な感想ですが、今回の検証でCordaの仕様をより理解する必要性を感じました。
特に、署名関連の知識を増やす必要があると痛感しました。
また、トランザクションはInitiatorFlow側だけではなくResponderFlow側でも生成できると知ることができたので、ユースケースに応じて柔軟に対応していこうと思います。
今回は以上になります。