diff --git a/core/src/main/kotlin/com/r3corda/core/node/services/Services.kt b/core/src/main/kotlin/com/r3corda/core/node/services/Services.kt index b28c1b97a0..e567c2132d 100644 --- a/core/src/main/kotlin/com/r3corda/core/node/services/Services.kt +++ b/core/src/main/kotlin/com/r3corda/core/node/services/Services.kt @@ -4,6 +4,7 @@ import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.SettableFuture import com.r3corda.core.contracts.* import com.r3corda.core.crypto.Party +import com.r3corda.core.crypto.SecureHash import com.r3corda.core.transactions.TransactionBuilder import com.r3corda.core.transactions.WireTransaction import rx.Observable @@ -35,7 +36,8 @@ val DEFAULT_SESSION_ID = 0L * Active means they haven't been consumed yet (or we don't know about it). * Relevant means they contain at least one of our pubkeys. */ -class Vault(val states: Iterable>) { +class Vault(val states: Iterable>, + val transactionNotes: Map> = emptyMap()) { @Suppress("UNCHECKED_CAST") inline fun statesOfType() = states.filter { it.state.data is T } as List> @@ -164,6 +166,16 @@ interface VaultService { return future } + /** + * Add a note to an existing [LedgerTransaction] given by its unique [SecureHash] id + * Multiple notes may be attached to the same [LedgerTransaction]. + * These are additively and immutably persisted within the node local vault database in a single textual field + * using a semi-colon separator + */ + fun addNoteToTransaction(txnId: SecureHash, noteText: String) + + fun getTransactionNotes(txnId: SecureHash): Iterable + /** * [InsufficientBalanceException] is thrown when a Cash Spending transaction fails because * there is insufficient quantity for a given currency (and optionally set of Issuer Parties). diff --git a/node/src/main/kotlin/com/r3corda/node/internal/ServerRPCOps.kt b/node/src/main/kotlin/com/r3corda/node/internal/ServerRPCOps.kt index 143a6b3c5f..8ff41aa804 100644 --- a/node/src/main/kotlin/com/r3corda/node/internal/ServerRPCOps.kt +++ b/node/src/main/kotlin/com/r3corda/node/internal/ServerRPCOps.kt @@ -4,6 +4,7 @@ import com.r3corda.contracts.asset.Cash import com.r3corda.core.contracts.InsufficientBalanceException import com.r3corda.core.contracts.* import com.r3corda.core.crypto.Party +import com.r3corda.core.crypto.SecureHash import com.r3corda.core.crypto.toStringShort import com.r3corda.core.node.NodeInfo import com.r3corda.core.node.ServiceHub @@ -74,6 +75,18 @@ class ServerRPCOps( } } + override fun addVaultTransactionNote(txnId: SecureHash, txnNote: String) { + return databaseTransaction(database) { + services.vaultService.addNoteToTransaction(txnId, txnNote) + } + } + + override fun getVaultTransactionNotes(txnId: SecureHash): Iterable { + return databaseTransaction(database) { + services.vaultService.getTransactionNotes(txnId) + } + } + // TODO: Make a lightweight protocol that manages this workflow, rather than embedding it directly in the service private fun initiatePayment(req: ClientToServiceCommand.PayCash): TransactionBuildResult { val builder: TransactionBuilder = TransactionType.General.Builder(null) diff --git a/node/src/main/kotlin/com/r3corda/node/services/messaging/CordaRPCOps.kt b/node/src/main/kotlin/com/r3corda/node/services/messaging/CordaRPCOps.kt index 40060b0a07..0f7b616603 100644 --- a/node/src/main/kotlin/com/r3corda/node/services/messaging/CordaRPCOps.kt +++ b/node/src/main/kotlin/com/r3corda/node/services/messaging/CordaRPCOps.kt @@ -3,6 +3,7 @@ package com.r3corda.node.services.messaging import com.r3corda.core.contracts.ClientToServiceCommand import com.r3corda.core.contracts.ContractState import com.r3corda.core.contracts.StateAndRef +import com.r3corda.core.crypto.SecureHash import com.r3corda.core.node.NodeInfo import com.r3corda.core.node.services.NetworkMapCache import com.r3corda.core.node.services.StateMachineTransactionMapping @@ -116,4 +117,14 @@ interface CordaRPCOps : RPCOps { * TODO: The signature of this is weird because it's the remains of an old service call, we should have a call for each command instead. */ fun executeCommand(command: ClientToServiceCommand): TransactionBuildResult + + /* + * Add note(s) to an existing Vault transaction + */ + fun addVaultTransactionNote(txnId: SecureHash, txnNote: String) + + /* + * Retrieve existing note(s) for a given Vault transaction + */ + fun getVaultTransactionNotes(txnId: SecureHash): Iterable } \ No newline at end of file diff --git a/node/src/main/kotlin/com/r3corda/node/services/vault/NodeVaultService.kt b/node/src/main/kotlin/com/r3corda/node/services/vault/NodeVaultService.kt index 883df42e4f..74e6133cf2 100644 --- a/node/src/main/kotlin/com/r3corda/node/services/vault/NodeVaultService.kt +++ b/node/src/main/kotlin/com/r3corda/node/services/vault/NodeVaultService.kt @@ -6,6 +6,7 @@ import com.r3corda.core.ThreadBox import com.r3corda.core.bufferUntilSubscribed import com.r3corda.core.contracts.* import com.r3corda.core.crypto.Party +import com.r3corda.core.crypto.SecureHash import com.r3corda.core.node.ServiceHub import com.r3corda.core.node.services.Vault import com.r3corda.core.node.services.VaultService @@ -14,10 +15,7 @@ import com.r3corda.core.transactions.TransactionBuilder import com.r3corda.core.transactions.WireTransaction import com.r3corda.core.utilities.loggerFor import com.r3corda.core.utilities.trace -import com.r3corda.node.utilities.AbstractJDBCHashSet -import com.r3corda.node.utilities.JDBCHashedTable -import com.r3corda.node.utilities.NODE_DATABASE_PREFIX -import com.r3corda.node.utilities.stateRef +import com.r3corda.node.utilities.* import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.statements.InsertStatement import rx.Observable @@ -46,7 +44,12 @@ class NodeVaultService(private val services: ServiceHub) : SingletonSerializeAsT val stateRef = stateRef("transaction_id", "output_index") } - private val mutex = ThreadBox(object { + private object TransactionNotesTable : JDBCHashedTable("${NODE_DATABASE_PREFIX}vault_txn_notes") { + val txnId = secureHash("txnId") + val notes = text("notes") + } + + private val mutex = ThreadBox(content = object { val unconsumedStates = object : AbstractJDBCHashSet(StatesSetTable) { override fun elementFromRow(row: ResultRow): StateRef = StateRef(row[table.stateRef.txId], row[table.stateRef.index]) @@ -56,6 +59,24 @@ class NodeVaultService(private val services: ServiceHub) : SingletonSerializeAsT } } + val transactionNotes = object : AbstractJDBCHashMap, TransactionNotesTable>(TransactionNotesTable, loadOnInit = false) { + override fun keyFromRow(row: ResultRow): SecureHash { + return row[table.txnId] + } + + override fun valueFromRow(row: ResultRow): Set { + return row[table.notes].split(delimiters = ";").toSet() + } + + override fun addKeyToInsert(insert: InsertStatement, entry: Map.Entry>, finalizables: MutableList<() -> Unit>) { + insert[table.txnId] = entry.key + } + + override fun addValueToInsert(insert: InsertStatement, entry: Map.Entry>, finalizables: MutableList<() -> Unit>) { + insert[table.notes] = entry.value.joinToString(separator = ";") + } + } + val _updatesPublisher = PublishSubject.create() fun allUnconsumedStates(): Iterable> { @@ -79,14 +100,14 @@ class NodeVaultService(private val services: ServiceHub) : SingletonSerializeAsT } }) - override val currentVault: Vault get() = mutex.locked { Vault(allUnconsumedStates()) } + override val currentVault: Vault get() = mutex.locked { Vault(allUnconsumedStates(), transactionNotes) } override val updates: Observable get() = mutex.locked { _updatesPublisher } override fun track(): Pair> { return mutex.locked { - Pair(Vault(allUnconsumedStates()), _updatesPublisher.bufferUntilSubscribed()) + Pair(Vault(allUnconsumedStates(), transactionNotes), _updatesPublisher.bufferUntilSubscribed()) } } @@ -110,6 +131,21 @@ class NodeVaultService(private val services: ServiceHub) : SingletonSerializeAsT return currentVault } + override fun addNoteToTransaction(txnId: SecureHash, noteText: String) { + mutex.locked { + val notes = transactionNotes.getOrPut(key = txnId, defaultValue = { + setOf(noteText) + }) + transactionNotes.put(txnId, notes.plus(noteText)) + } + } + + override fun getTransactionNotes(txnId: SecureHash): Iterable { + mutex.locked { + return transactionNotes.get(txnId)!!.asIterable() + } + } + /** * Generate a transaction that moves an amount of currency to the given pubkey. * @@ -264,5 +300,4 @@ class NodeVaultService(private val services: ServiceHub) : SingletonSerializeAsT false } } - } diff --git a/node/src/test/kotlin/com/r3corda/node/services/ArtemisMessagingTests.kt b/node/src/test/kotlin/com/r3corda/node/services/ArtemisMessagingTests.kt index 9d23a2b21e..5795c940b6 100644 --- a/node/src/test/kotlin/com/r3corda/node/services/ArtemisMessagingTests.kt +++ b/node/src/test/kotlin/com/r3corda/node/services/ArtemisMessagingTests.kt @@ -4,6 +4,7 @@ import com.google.common.net.HostAndPort import com.r3corda.core.contracts.ClientToServiceCommand import com.r3corda.core.contracts.ContractState import com.r3corda.core.contracts.StateAndRef +import com.r3corda.core.crypto.SecureHash import com.r3corda.core.crypto.generateKeyPair import com.r3corda.core.messaging.Message import com.r3corda.core.messaging.createMessage @@ -84,6 +85,13 @@ class ArtemisMessagingTests { throw UnsupportedOperationException("not implemented") //To change body of created functions use File | Settings | File Templates. } + override fun addVaultTransactionNote(txnId: SecureHash, txnNote: String) { + throw UnsupportedOperationException("not implemented") //To change body of created functions use File | Settings | File Templates. + } + + override fun getVaultTransactionNotes(txnId: SecureHash): Iterable { + throw UnsupportedOperationException("not implemented") //To change body of created functions use File | Settings | File Templates. + } } @Before diff --git a/node/src/test/kotlin/com/r3corda/node/services/NodeVaultServiceTest.kt b/node/src/test/kotlin/com/r3corda/node/services/NodeVaultServiceTest.kt index ef314c7648..5d582522a5 100644 --- a/node/src/test/kotlin/com/r3corda/node/services/NodeVaultServiceTest.kt +++ b/node/src/test/kotlin/com/r3corda/node/services/NodeVaultServiceTest.kt @@ -1,7 +1,11 @@ package com.r3corda.node.services +import com.r3corda.contracts.asset.Cash import com.r3corda.contracts.testing.fillWithSomeTestCash import com.r3corda.core.contracts.DOLLARS +import com.r3corda.core.contracts.POUNDS +import com.r3corda.core.contracts.TransactionType +import com.r3corda.core.contracts.`issued by` import com.r3corda.core.node.services.TxWritableStorageService import com.r3corda.core.node.services.VaultService import com.r3corda.core.transactions.SignedTransaction @@ -10,6 +14,8 @@ import com.r3corda.core.utilities.LogHelper import com.r3corda.node.services.vault.NodeVaultService import com.r3corda.node.utilities.configureDatabase import com.r3corda.node.utilities.databaseTransaction +import com.r3corda.testing.MEGA_CORP +import com.r3corda.testing.MEGA_CORP_KEY import com.r3corda.testing.node.MockServices import com.r3corda.testing.node.makeTestDataSourceProperties import org.assertj.core.api.Assertions.assertThat @@ -19,6 +25,7 @@ import org.junit.Before import org.junit.Test import java.io.Closeable import java.util.* +import kotlin.test.assertEquals class NodeVaultServiceTest { lateinit var dataSource: Closeable @@ -75,4 +82,49 @@ class NodeVaultServiceTest { assertThat(w2.states).hasSize(3) } } + + @Test + fun addNoteToTransaction() { + databaseTransaction(database) { + val services = object : MockServices() { + override val vaultService: VaultService = NodeVaultService(this) + + override fun recordTransactions(txs: Iterable) { + for (stx in txs) { + storageService.validatedTransactions.addTransaction(stx) + } + // Refactored to use notifyAll() as we have no other unit test for that method with multiple transactions. + vaultService.notifyAll(txs.map { it.tx }) + } + } + + val freshKey = services.legalIdentityKey + + // Issue a txn to Send us some Money + val usefulTX = TransactionType.General.Builder(null).apply { + Cash().generateIssue(this, 100.DOLLARS `issued by` MEGA_CORP.ref(1), freshKey.public, DUMMY_NOTARY) + signWith(MEGA_CORP_KEY) + }.toSignedTransaction() + + services.recordTransactions(listOf(usefulTX)) + + services.vaultService.addNoteToTransaction(usefulTX.id, "USD Sample Note 1") + services.vaultService.addNoteToTransaction(usefulTX.id, "USD Sample Note 2") + services.vaultService.addNoteToTransaction(usefulTX.id, "USD Sample Note 3") + assertEquals(1, services.vaultService.currentVault.transactionNotes.toList().size) + assertEquals(3, services.vaultService.getTransactionNotes(usefulTX.id).count()) + + // Issue more Money (GBP) + val anotherTX = TransactionType.General.Builder(null).apply { + Cash().generateIssue(this, 200.POUNDS `issued by` MEGA_CORP.ref(1), freshKey.public, DUMMY_NOTARY) + signWith(MEGA_CORP_KEY) + }.toSignedTransaction() + + services.recordTransactions(listOf(anotherTX)) + + services.vaultService.addNoteToTransaction(anotherTX.id, "GPB Sample Note 1") + assertEquals(2, services.vaultService.currentVault.transactionNotes.toList().size) + assertEquals(1, services.vaultService.getTransactionNotes(anotherTX.id).count()) + } + } } \ No newline at end of file