mirror of
https://github.com/corda/corda.git
synced 2025-03-11 06:54:04 +00:00
Pass a FilteredTransaction instead of a Signed transaction to a non-validating notary flow to preserve privacy.
This also means that the non-validating notary can service requests on the network without loading any custom plugins.
This commit is contained in:
parent
98c30f6432
commit
a19dd55257
@ -5,11 +5,9 @@ import com.google.common.util.concurrent.ListenableFuture
|
|||||||
import net.corda.core.contracts.Contract
|
import net.corda.core.contracts.Contract
|
||||||
import net.corda.core.crypto.CompositeKey
|
import net.corda.core.crypto.CompositeKey
|
||||||
import net.corda.core.crypto.Party
|
import net.corda.core.crypto.Party
|
||||||
import net.corda.core.messaging.MessageRecipients
|
|
||||||
import net.corda.core.messaging.MessagingService
|
import net.corda.core.messaging.MessagingService
|
||||||
import net.corda.core.messaging.SingleMessageRecipient
|
import net.corda.core.messaging.SingleMessageRecipient
|
||||||
import net.corda.core.node.NodeInfo
|
import net.corda.core.node.NodeInfo
|
||||||
import net.corda.core.node.ServiceEntry
|
|
||||||
import net.corda.core.randomOrNull
|
import net.corda.core.randomOrNull
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
|
||||||
@ -73,6 +71,7 @@ interface NetworkMapCache {
|
|||||||
|
|
||||||
/** Look up the node info for a specific peer key. */
|
/** Look up the node info for a specific peer key. */
|
||||||
fun getNodeByLegalIdentityKey(compositeKey: CompositeKey): NodeInfo?
|
fun getNodeByLegalIdentityKey(compositeKey: CompositeKey): NodeInfo?
|
||||||
|
|
||||||
/** Look up all nodes advertising the service owned by [compositeKey] */
|
/** Look up all nodes advertising the service owned by [compositeKey] */
|
||||||
fun getNodesByAdvertisedServiceIdentityKey(compositeKey: CompositeKey): List<NodeInfo> {
|
fun getNodesByAdvertisedServiceIdentityKey(compositeKey: CompositeKey): List<NodeInfo> {
|
||||||
return partyNodes.filter { it.advertisedServices.any { it.identity.owningKey == compositeKey } }
|
return partyNodes.filter { it.advertisedServices.any { it.identity.owningKey == compositeKey } }
|
||||||
@ -108,6 +107,11 @@ interface NetworkMapCache {
|
|||||||
/** Checks whether a given party is an advertised notary identity */
|
/** Checks whether a given party is an advertised notary identity */
|
||||||
fun isNotary(party: Party): Boolean = notaryNodes.any { it.notaryIdentity == party }
|
fun isNotary(party: Party): Boolean = notaryNodes.any { it.notaryIdentity == party }
|
||||||
|
|
||||||
|
/** Checks whether a given party is an advertised validating notary identity */
|
||||||
|
fun isValidatingNotary(party: Party): Boolean {
|
||||||
|
return notaryNodes.any { it.notaryIdentity == party && it.advertisedServices.any { it.info.type.isValidatingNotary() }}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a network map service; fetches a copy of the latest map from the service and subscribes to any further
|
* Add a network map service; fetches a copy of the latest map from the service and subscribes to any further
|
||||||
* updates.
|
* updates.
|
||||||
|
@ -43,6 +43,7 @@ sealed class ServiceType(val id: String) {
|
|||||||
|
|
||||||
fun isSubTypeOf(superType: ServiceType) = (id == superType.id) || id.startsWith(superType.id + ".")
|
fun isSubTypeOf(superType: ServiceType) = (id == superType.id) || id.startsWith(superType.id + ".")
|
||||||
fun isNotary() = isSubTypeOf(notary)
|
fun isNotary() = isSubTypeOf(notary)
|
||||||
|
fun isValidatingNotary() = isNotary() && id.contains(".validating")
|
||||||
|
|
||||||
override fun hashCode(): Int = id.hashCode()
|
override fun hashCode(): Int = id.hashCode()
|
||||||
override fun toString(): String = id.toString()
|
override fun toString(): String = id.toString()
|
||||||
|
@ -0,0 +1,29 @@
|
|||||||
|
package net.corda.flows
|
||||||
|
|
||||||
|
import co.paralleluniverse.fibers.Suspendable
|
||||||
|
import net.corda.core.crypto.Party
|
||||||
|
import net.corda.core.node.services.TimestampChecker
|
||||||
|
import net.corda.core.node.services.UniquenessProvider
|
||||||
|
import net.corda.core.transactions.FilteredTransaction
|
||||||
|
import net.corda.core.utilities.unwrap
|
||||||
|
|
||||||
|
class NonValidatingNotaryFlow(otherSide: Party,
|
||||||
|
timestampChecker: TimestampChecker,
|
||||||
|
uniquenessProvider: UniquenessProvider) : NotaryFlow.Service(otherSide, timestampChecker, uniquenessProvider) {
|
||||||
|
/**
|
||||||
|
* The received transaction is not checked for contract-validity, as that would require fully
|
||||||
|
* resolving it into a [TransactionForVerification], for which the caller would have to reveal the whole transaction
|
||||||
|
* history chain.
|
||||||
|
* As a result, the Notary _will commit invalid transactions_ as well, but as it also records the identity of
|
||||||
|
* the caller, it is possible to raise a dispute and verify the validity of the transaction and subsequently
|
||||||
|
* undo the commit of the input states (the exact mechanism still needs to be worked out).
|
||||||
|
*/
|
||||||
|
@Suspendable
|
||||||
|
override fun receiveAndVerifyTx(): NotaryFlow.Service.TransactionParts {
|
||||||
|
val ftx = receive<FilteredTransaction>(otherSide).unwrap {
|
||||||
|
it.verify()
|
||||||
|
it
|
||||||
|
}
|
||||||
|
return TransactionParts(ftx.rootHash, ftx.filteredLeaves.inputs, ftx.filteredLeaves.timestamp)
|
||||||
|
}
|
||||||
|
}
|
@ -1,10 +1,9 @@
|
|||||||
package net.corda.flows
|
package net.corda.flows
|
||||||
|
|
||||||
import co.paralleluniverse.fibers.Suspendable
|
import co.paralleluniverse.fibers.Suspendable
|
||||||
import net.corda.core.crypto.DigitalSignature
|
import net.corda.core.contracts.StateRef
|
||||||
import net.corda.core.crypto.Party
|
import net.corda.core.contracts.Timestamp
|
||||||
import net.corda.core.crypto.SignedData
|
import net.corda.core.crypto.*
|
||||||
import net.corda.core.crypto.signWithECDSA
|
|
||||||
import net.corda.core.flows.FlowException
|
import net.corda.core.flows.FlowException
|
||||||
import net.corda.core.flows.FlowLogic
|
import net.corda.core.flows.FlowLogic
|
||||||
import net.corda.core.node.services.TimestampChecker
|
import net.corda.core.node.services.TimestampChecker
|
||||||
@ -12,13 +11,12 @@ import net.corda.core.node.services.UniquenessException
|
|||||||
import net.corda.core.node.services.UniquenessProvider
|
import net.corda.core.node.services.UniquenessProvider
|
||||||
import net.corda.core.serialization.serialize
|
import net.corda.core.serialization.serialize
|
||||||
import net.corda.core.transactions.SignedTransaction
|
import net.corda.core.transactions.SignedTransaction
|
||||||
import net.corda.core.transactions.WireTransaction
|
|
||||||
import net.corda.core.utilities.ProgressTracker
|
import net.corda.core.utilities.ProgressTracker
|
||||||
import net.corda.core.utilities.unwrap
|
import net.corda.core.utilities.unwrap
|
||||||
|
|
||||||
object NotaryFlow {
|
object NotaryFlow {
|
||||||
/**
|
/**
|
||||||
* A flow to be used for obtaining a signature from a [NotaryService] ascertaining the transaction
|
* A flow to be used by a party for obtaining a signature from a [NotaryService] ascertaining the transaction
|
||||||
* timestamp is correct and none of its inputs have been used in another completed transaction.
|
* timestamp is correct and none of its inputs have been used in another completed transaction.
|
||||||
*
|
*
|
||||||
* @throws NotaryException in case the any of the inputs to the transaction have been consumed
|
* @throws NotaryException in case the any of the inputs to the transaction have been consumed
|
||||||
@ -52,8 +50,14 @@ object NotaryFlow {
|
|||||||
throw NotaryException(NotaryError.SignaturesMissing(ex))
|
throw NotaryException(NotaryError.SignaturesMissing(ex))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val payload: Any = if (serviceHub.networkMapCache.isValidatingNotary(notaryParty)) {
|
||||||
|
stx
|
||||||
|
} else {
|
||||||
|
wtx.buildFilteredTransaction { it is StateRef || it is Timestamp }
|
||||||
|
}
|
||||||
|
|
||||||
val response = try {
|
val response = try {
|
||||||
sendAndReceive<DigitalSignature.WithKey>(notaryParty, SignRequest(stx))
|
sendAndReceive<DigitalSignature.WithKey>(notaryParty, payload)
|
||||||
} catch (e: NotaryException) {
|
} catch (e: NotaryException) {
|
||||||
if (e.error is NotaryError.Conflict) {
|
if (e.error is NotaryError.Conflict) {
|
||||||
e.error.conflict.verified()
|
e.error.conflict.verified()
|
||||||
@ -73,64 +77,64 @@ object NotaryFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks that the timestamp command is valid (if present) and commits the input state, or returns a conflict
|
* A flow run by a notary service that handles notarisation requests.
|
||||||
|
*
|
||||||
|
* It checks that the timestamp command is valid (if present) and commits the input state, or returns a conflict
|
||||||
* if any of the input states have been previously committed.
|
* if any of the input states have been previously committed.
|
||||||
*
|
*
|
||||||
* Extend this class, overriding _beforeCommit_ to add custom transaction processing/validation logic.
|
* Additional transaction validation logic can be added when implementing [receiveAndVerifyTx].
|
||||||
*
|
|
||||||
* TODO: the notary service should only be able to see timestamp commands and inputs
|
|
||||||
*/
|
*/
|
||||||
open class Service(val otherSide: Party,
|
abstract class Service(val otherSide: Party,
|
||||||
val timestampChecker: TimestampChecker,
|
val timestampChecker: TimestampChecker,
|
||||||
val uniquenessProvider: UniquenessProvider) : FlowLogic<Unit>() {
|
val uniquenessProvider: UniquenessProvider) : FlowLogic<Unit>() {
|
||||||
|
|
||||||
@Suspendable
|
@Suspendable
|
||||||
override fun call() {
|
override fun call() {
|
||||||
val stx = receive<SignRequest>(otherSide).unwrap { it.tx }
|
val (id, inputs, timestamp) = receiveAndVerifyTx()
|
||||||
val wtx = stx.tx
|
validateTimestamp(timestamp)
|
||||||
|
commitInputStates(inputs, id)
|
||||||
|
signAndSendResponse(id)
|
||||||
|
}
|
||||||
|
|
||||||
validateTimestamp(wtx)
|
/**
|
||||||
beforeCommit(stx)
|
* Implement custom logic to receive the transaction to notarise, and perform verification based on validity and
|
||||||
commitInputStates(wtx)
|
* privacy requirements.
|
||||||
val sig = sign(stx.id.bytes)
|
*/
|
||||||
|
@Suspendable
|
||||||
|
abstract fun receiveAndVerifyTx(): TransactionParts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minimum amount of information needed to notarise a transaction. Note that this does not include
|
||||||
|
* any sensitive transaction details.
|
||||||
|
*/
|
||||||
|
data class TransactionParts(val id: SecureHash, val inputs: List<StateRef>, val timestamp: Timestamp?)
|
||||||
|
|
||||||
|
@Suspendable
|
||||||
|
private fun signAndSendResponse(txId: SecureHash) {
|
||||||
|
val sig = sign(txId.bytes)
|
||||||
send(otherSide, sig)
|
send(otherSide, sig)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validateTimestamp(tx: WireTransaction) {
|
private fun validateTimestamp(t: Timestamp?) {
|
||||||
if (tx.timestamp != null
|
if (t != null && !timestampChecker.isValid(t))
|
||||||
&& !timestampChecker.isValid(tx.timestamp))
|
|
||||||
throw NotaryException(NotaryError.TimestampInvalid())
|
throw NotaryException(NotaryError.TimestampInvalid())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* No pre-commit processing is done. Transaction is not checked for contract-validity, as that would require fully
|
|
||||||
* resolving it into a [TransactionForVerification], for which the caller would have to reveal the whole transaction
|
|
||||||
* history chain.
|
|
||||||
* As a result, the Notary _will commit invalid transactions_ as well, but as it also records the identity of
|
|
||||||
* the caller, it is possible to raise a dispute and verify the validity of the transaction and subsequently
|
|
||||||
* undo the commit of the input states (the exact mechanism still needs to be worked out).
|
|
||||||
*/
|
|
||||||
@Suspendable
|
|
||||||
open fun beforeCommit(stx: SignedTransaction) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A NotaryException is thrown if any of the states have been consumed by a different transaction. Note that
|
* A NotaryException is thrown if any of the states have been consumed by a different transaction. Note that
|
||||||
* this method does not throw an exception when input states are present multiple times within the transaction.
|
* this method does not throw an exception when input states are present multiple times within the transaction.
|
||||||
*/
|
*/
|
||||||
private fun commitInputStates(tx: WireTransaction) {
|
private fun commitInputStates(inputs: List<StateRef>, txId: SecureHash) {
|
||||||
try {
|
try {
|
||||||
uniquenessProvider.commit(tx.inputs, tx.id, otherSide)
|
uniquenessProvider.commit(inputs, txId, otherSide)
|
||||||
} catch (e: UniquenessException) {
|
} catch (e: UniquenessException) {
|
||||||
val conflicts = tx.inputs.filterIndexed { i, stateRef ->
|
val conflicts = inputs.filterIndexed { i, stateRef ->
|
||||||
val consumingTx = e.error.stateHistory[stateRef]
|
val consumingTx = e.error.stateHistory[stateRef]
|
||||||
consumingTx != null && consumingTx != UniquenessProvider.ConsumingTx(tx.id, i, otherSide)
|
consumingTx != null && consumingTx != UniquenessProvider.ConsumingTx(txId, i, otherSide)
|
||||||
}
|
}
|
||||||
if (conflicts.isNotEmpty()) {
|
if (conflicts.isNotEmpty()) {
|
||||||
// TODO: Create a new UniquenessException that only contains the conflicts filtered above.
|
// TODO: Create a new UniquenessException that only contains the conflicts filtered above.
|
||||||
throw notaryException(tx, e)
|
throw notaryException(txId, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -140,14 +144,12 @@ object NotaryFlow {
|
|||||||
return mySigningKey.signWithECDSA(bits)
|
return mySigningKey.signWithECDSA(bits)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notaryException(tx: WireTransaction, e: UniquenessException): NotaryException {
|
private fun notaryException(txId: SecureHash, e: UniquenessException): NotaryException {
|
||||||
val conflictData = e.error.serialize()
|
val conflictData = e.error.serialize()
|
||||||
val signedConflict = SignedData(conflictData, sign(conflictData.bytes))
|
val signedConflict = SignedData(conflictData, sign(conflictData.bytes))
|
||||||
return NotaryException(NotaryError.Conflict(tx, signedConflict))
|
return NotaryException(NotaryError.Conflict(txId, signedConflict))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class SignRequest(val tx: SignedTransaction)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class NotaryException(val error: NotaryError) : FlowException() {
|
class NotaryException(val error: NotaryError) : FlowException() {
|
||||||
@ -155,15 +157,15 @@ class NotaryException(val error: NotaryError) : FlowException() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sealed class NotaryError {
|
sealed class NotaryError {
|
||||||
class Conflict(val tx: WireTransaction, val conflict: SignedData<UniquenessProvider.Conflict>) : NotaryError() {
|
class Conflict(val txId: SecureHash, val conflict: SignedData<UniquenessProvider.Conflict>) : NotaryError() {
|
||||||
override fun toString() = "One or more input states for transaction ${tx.id} have been used in another transaction"
|
override fun toString() = "One or more input states for transaction $txId have been used in another transaction"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Thrown if the time specified in the timestamp command is outside the allowed tolerance */
|
/** Thrown if the time specified in the timestamp command is outside the allowed tolerance */
|
||||||
class TimestampInvalid : NotaryError()
|
class TimestampInvalid : NotaryError()
|
||||||
|
|
||||||
class TransactionInvalid(val msg: String) : NotaryError()
|
class TransactionInvalid(val msg: String) : NotaryError()
|
||||||
class SignaturesInvalid(val msg: String): NotaryError()
|
class SignaturesInvalid(val msg: String) : NotaryError()
|
||||||
|
|
||||||
class SignaturesMissing(val cause: SignedTransaction.SignaturesMissingException) : NotaryError() {
|
class SignaturesMissing(val cause: SignedTransaction.SignaturesMissingException) : NotaryError() {
|
||||||
override fun toString() = cause.toString()
|
override fun toString() = cause.toString()
|
||||||
|
@ -7,6 +7,7 @@ import net.corda.core.node.services.TimestampChecker
|
|||||||
import net.corda.core.node.services.UniquenessProvider
|
import net.corda.core.node.services.UniquenessProvider
|
||||||
import net.corda.core.transactions.SignedTransaction
|
import net.corda.core.transactions.SignedTransaction
|
||||||
import net.corda.core.transactions.WireTransaction
|
import net.corda.core.transactions.WireTransaction
|
||||||
|
import net.corda.core.utilities.unwrap
|
||||||
import java.security.SignatureException
|
import java.security.SignatureException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -19,21 +20,18 @@ class ValidatingNotaryFlow(otherSide: Party,
|
|||||||
timestampChecker: TimestampChecker,
|
timestampChecker: TimestampChecker,
|
||||||
uniquenessProvider: UniquenessProvider) :
|
uniquenessProvider: UniquenessProvider) :
|
||||||
NotaryFlow.Service(otherSide, timestampChecker, uniquenessProvider) {
|
NotaryFlow.Service(otherSide, timestampChecker, uniquenessProvider) {
|
||||||
|
/**
|
||||||
|
* The received transaction is checked for contract-validity, which requires fully resolving it into a
|
||||||
|
* [TransactionForVerification], for which the caller also has to to reveal the whole transaction
|
||||||
|
* dependency chain.
|
||||||
|
*/
|
||||||
@Suspendable
|
@Suspendable
|
||||||
override fun beforeCommit(stx: SignedTransaction) {
|
override fun receiveAndVerifyTx(): TransactionParts {
|
||||||
try {
|
val stx = receive<SignedTransaction>(otherSide).unwrap { it }
|
||||||
checkSignatures(stx)
|
checkSignatures(stx)
|
||||||
val wtx = stx.tx
|
val wtx = stx.tx
|
||||||
resolveTransaction(wtx)
|
validateTransaction(wtx)
|
||||||
wtx.toLedgerTransaction(serviceHub).verify()
|
return TransactionParts(wtx.id, wtx.inputs, wtx.timestamp)
|
||||||
} catch (e: Exception) {
|
|
||||||
when (e) {
|
|
||||||
is TransactionVerificationException -> NotaryException(NotaryError.TransactionInvalid(e.toString()))
|
|
||||||
is SignatureException -> throw NotaryException(NotaryError.SignaturesInvalid(e.toString()))
|
|
||||||
else -> throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkSignatures(stx: SignedTransaction) {
|
private fun checkSignatures(stx: SignedTransaction) {
|
||||||
@ -45,7 +43,19 @@ class ValidatingNotaryFlow(otherSide: Party,
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Suspendable
|
@Suspendable
|
||||||
private fun resolveTransaction(wtx: WireTransaction) {
|
fun validateTransaction(wtx: WireTransaction) {
|
||||||
subFlow(ResolveTransactionsFlow(wtx, otherSide))
|
try {
|
||||||
|
resolveTransaction(wtx)
|
||||||
|
wtx.toLedgerTransaction(serviceHub).verify()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw when (e) {
|
||||||
|
is TransactionVerificationException -> NotaryException(NotaryError.TransactionInvalid(e.toString()))
|
||||||
|
is SignatureException -> NotaryException(NotaryError.SignaturesInvalid(e.toString()))
|
||||||
|
else -> e
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suspendable
|
||||||
|
private fun resolveTransaction(wtx: WireTransaction) = subFlow(ResolveTransactionsFlow(wtx, otherSide))
|
||||||
}
|
}
|
||||||
|
@ -53,7 +53,7 @@ class RaftNotaryServiceTests : NodeBasedTest() {
|
|||||||
|
|
||||||
val ex = assertFailsWith(NotaryException::class) { secondSpend.resultFuture.getOrThrow() }
|
val ex = assertFailsWith(NotaryException::class) { secondSpend.resultFuture.getOrThrow() }
|
||||||
val error = ex.error as NotaryError.Conflict
|
val error = ex.error as NotaryError.Conflict
|
||||||
assertEquals(error.tx, secondSpendTx.tx)
|
assertEquals(error.txId, secondSpendTx.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun issueState(node: AbstractNode, notary: Party, notaryKey: KeyPair): StateAndRef<*> {
|
private fun issueState(node: AbstractNode, notary: Party, notaryKey: KeyPair): StateAndRef<*> {
|
||||||
|
@ -4,6 +4,7 @@ import net.corda.core.crypto.Party
|
|||||||
import net.corda.core.node.services.ServiceType
|
import net.corda.core.node.services.ServiceType
|
||||||
import net.corda.core.node.services.TimestampChecker
|
import net.corda.core.node.services.TimestampChecker
|
||||||
import net.corda.core.node.services.UniquenessProvider
|
import net.corda.core.node.services.UniquenessProvider
|
||||||
|
import net.corda.flows.NonValidatingNotaryFlow
|
||||||
import net.corda.flows.NotaryFlow
|
import net.corda.flows.NotaryFlow
|
||||||
import net.corda.node.services.api.ServiceHubInternal
|
import net.corda.node.services.api.ServiceHubInternal
|
||||||
|
|
||||||
@ -16,6 +17,6 @@ class SimpleNotaryService(services: ServiceHubInternal,
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun createFlow(otherParty: Party): NotaryFlow.Service {
|
override fun createFlow(otherParty: Party): NotaryFlow.Service {
|
||||||
return NotaryFlow.Service(otherParty, timestampChecker, uniquenessProvider)
|
return NonValidatingNotaryFlow(otherParty, timestampChecker, uniquenessProvider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -128,7 +128,7 @@ class NotaryServiceTests {
|
|||||||
|
|
||||||
val ex = assertFailsWith(NotaryException::class) { future.resultFuture.getOrThrow() }
|
val ex = assertFailsWith(NotaryException::class) { future.resultFuture.getOrThrow() }
|
||||||
val notaryError = ex.error as NotaryError.Conflict
|
val notaryError = ex.error as NotaryError.Conflict
|
||||||
assertEquals(notaryError.tx, stx2.tx)
|
assertEquals(notaryError.txId, stx2.id)
|
||||||
notaryError.conflict.verified()
|
notaryError.conflict.verified()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user