mirror of
https://github.com/corda/corda.git
synced 2025-04-07 19:34:41 +00:00
Major: introduce TransactionGroup for verifying subgraphs.
This commit is contained in:
parent
636279ced9
commit
a4aef06e41
@ -25,7 +25,7 @@ fun ContractState.hash(): SecureHash = SecureHash.sha256((serialize()))
|
||||
* A stateref is a pointer to a state, this is an equivalent of an "outpoint" in Bitcoin. It records which transaction
|
||||
* defined the state and where in that transaction it was.
|
||||
*/
|
||||
data class ContractStateRef(val txhash: SecureHash.SHA256, val index: Int) : SerializeableWithKryo
|
||||
data class ContractStateRef(val txhash: SecureHash, val index: Int) : SerializeableWithKryo
|
||||
|
||||
/** A StateAndRef is simply a (state, ref) pair. For instance, a wallet (which holds available assets) contains these. */
|
||||
data class StateAndRef<T : ContractState>(val state: T, val ref: ContractStateRef)
|
||||
|
58
src/core/TransactionGroup.kt
Normal file
58
src/core/TransactionGroup.kt
Normal file
@ -0,0 +1,58 @@
|
||||
package core
|
||||
|
||||
import java.util.*
|
||||
|
||||
class TransactionResolutionException(val hash: SecureHash) : Exception()
|
||||
class TransactionConflictException(val conflictRef: ContractStateRef, val tx1: LedgerTransaction, val tx2: LedgerTransaction) : Exception()
|
||||
|
||||
/**
|
||||
* A TransactionGroup defines a directed acyclic graph of transactions that can be resolved with each other and then
|
||||
* verified. Successful verification does not imply the non-existence of other conflicting transactions: simply that
|
||||
* this subgraph does not contain conflicts and is accepted by the involved contracts.
|
||||
*
|
||||
* The inputs of the provided transactions must be resolvable either within the [transactions] set, or from the
|
||||
* [nonVerifiedRoots] set. Transactions in the non-verified set are ignored other than for looking up input states.
|
||||
*/
|
||||
class TransactionGroup(val transactions: Set<LedgerTransaction>, val nonVerifiedRoots: Set<LedgerTransaction>) {
|
||||
|
||||
/**
|
||||
* Verifies the group and returns the set of resolved transactions.
|
||||
*/
|
||||
fun verify(programMap: Map<SecureHash, Contract>): Set<TransactionForVerification> {
|
||||
// Check that every input can be resolved to an output.
|
||||
// Check that no output is referenced by more than one input.
|
||||
// Cycles should be impossible due to the use of hashes as pointers.
|
||||
check(transactions.intersect(nonVerifiedRoots).isEmpty())
|
||||
|
||||
val hashToTXMap: Map<SecureHash, List<LedgerTransaction>> = (transactions + nonVerifiedRoots).groupBy { it.hash }
|
||||
val refToConsumingTXMap = hashMapOf<ContractStateRef, LedgerTransaction>()
|
||||
|
||||
val resolved = HashSet<TransactionForVerification>(transactions.size)
|
||||
for (tx in transactions) {
|
||||
val inputs = ArrayList<ContractState>(tx.inStateRefs.size)
|
||||
for (ref in tx.inStateRefs) {
|
||||
val conflict = refToConsumingTXMap[ref]
|
||||
if (conflict != null)
|
||||
throw TransactionConflictException(ref, tx, conflict)
|
||||
refToConsumingTXMap[ref] = tx
|
||||
|
||||
// Look up the connecting transaction.
|
||||
val ltx = hashToTXMap[ref.txhash]?.single() ?: throw TransactionResolutionException(ref.txhash)
|
||||
// Look up the output in that transaction by index.
|
||||
inputs.add(ltx.outStates[ref.index])
|
||||
}
|
||||
resolved.add(TransactionForVerification(inputs, tx.outStates, tx.commands, tx.time, tx.hash))
|
||||
}
|
||||
|
||||
for (tx in resolved) {
|
||||
try {
|
||||
tx.verify(programMap)
|
||||
} catch(e: Exception) {
|
||||
println(tx)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
}
|
@ -170,7 +170,7 @@ data class TimestampedWireTransaction(
|
||||
*
|
||||
* TODO: When converting LedgerTransaction into TransactionForVerification, make sure to check for duped inputs.
|
||||
*/
|
||||
class LedgerTransaction(
|
||||
data class LedgerTransaction(
|
||||
/** The input states which will be consumed/invalidated by the execution of this transaction. */
|
||||
val inStateRefs: List<ContractStateRef>,
|
||||
/** The states that will be generated by the execution of this transaction. */
|
||||
|
@ -198,6 +198,8 @@ fun createKryo(): Kryo {
|
||||
register(Collections.singletonList(null).javaClass)
|
||||
register(Collections.singletonMap(1, 2).javaClass)
|
||||
register(ArrayList::class.java)
|
||||
register(emptyList<Any>().javaClass)
|
||||
register(Arrays.asList(1,3).javaClass)
|
||||
|
||||
// These JDK classes use a very minimal custom serialization format and are written to defend against malicious
|
||||
// streams, so we can just kick it over to java serialization. We get ECPublicKeyImpl/ECPrivteKeyImpl via an
|
||||
@ -223,6 +225,7 @@ fun createKryo(): Kryo {
|
||||
registerDataClass<Cash.State>()
|
||||
register(Cash.Commands.Move.javaClass)
|
||||
registerDataClass<Cash.Commands.Exit>()
|
||||
registerDataClass<Cash.Commands.Issue>()
|
||||
registerDataClass<CommercialPaper.State>()
|
||||
register(CommercialPaper.Commands.Move.javaClass)
|
||||
register(CommercialPaper.Commands.Redeem.javaClass)
|
||||
|
100
tests/core/TransactionGroupTests.kt
Normal file
100
tests/core/TransactionGroupTests.kt
Normal file
@ -0,0 +1,100 @@
|
||||
package core
|
||||
|
||||
import contracts.Cash
|
||||
import core.testutils.*
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotEquals
|
||||
|
||||
class TransactionGroupTests {
|
||||
val A_THOUSAND_POUNDS = Cash.State(InstitutionReference(MINI_CORP, OpaqueBytes.of(1, 2, 3)), 1000.POUNDS, MINI_CORP_KEY)
|
||||
|
||||
@Test
|
||||
fun success() {
|
||||
transactionGroup {
|
||||
roots {
|
||||
transaction(A_THOUSAND_POUNDS label "£1000")
|
||||
}
|
||||
|
||||
transaction {
|
||||
input("£1000")
|
||||
output("alice's £1000") { A_THOUSAND_POUNDS `owned by` ALICE }
|
||||
arg(MINI_CORP_KEY) { Cash.Commands.Move }
|
||||
}
|
||||
|
||||
transaction {
|
||||
input("alice's £1000")
|
||||
arg(ALICE) { Cash.Commands.Move }
|
||||
arg(MINI_CORP_KEY) { Cash.Commands.Exit(1000.POUNDS) }
|
||||
}
|
||||
|
||||
verify()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conflict() {
|
||||
transactionGroup {
|
||||
val t = transaction {
|
||||
output("cash") { A_THOUSAND_POUNDS }
|
||||
arg(MINI_CORP_KEY) { Cash.Commands.Issue() }
|
||||
}
|
||||
|
||||
val conflict1 = transaction {
|
||||
input("cash")
|
||||
val HALF = A_THOUSAND_POUNDS.copy(amount = 500.POUNDS) `owned by` BOB
|
||||
output { HALF }
|
||||
output { HALF }
|
||||
arg(MINI_CORP_KEY) { Cash.Commands.Move }
|
||||
}
|
||||
|
||||
verify()
|
||||
|
||||
// Alice tries to double spend back to herself.
|
||||
val conflict2 = transaction {
|
||||
input("cash")
|
||||
val HALF = A_THOUSAND_POUNDS.copy(amount = 500.POUNDS) `owned by` ALICE
|
||||
output { HALF }
|
||||
output { HALF }
|
||||
arg(MINI_CORP_KEY) { Cash.Commands.Move }
|
||||
}
|
||||
|
||||
assertNotEquals(conflict1, conflict2)
|
||||
|
||||
val e = assertFailsWith(TransactionConflictException::class) {
|
||||
verify()
|
||||
}
|
||||
assertEquals(ContractStateRef(t.hash, 0), e.conflictRef)
|
||||
assertEquals(setOf(conflict1, conflict2), setOf(e.tx1, e.tx2))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disconnected() {
|
||||
// Check that if we have a transaction in the group that doesn't connect to anything else, it's rejected.
|
||||
val tg = transactionGroup {
|
||||
transaction {
|
||||
output("cash") { A_THOUSAND_POUNDS }
|
||||
arg(MINI_CORP_KEY) { Cash.Commands.Issue() }
|
||||
}
|
||||
|
||||
transaction {
|
||||
input("cash")
|
||||
output { A_THOUSAND_POUNDS `owned by` BOB }
|
||||
}
|
||||
}
|
||||
|
||||
// We have to do this manually without the DSL because transactionGroup { } won't let us create a tx that
|
||||
// points nowhere.
|
||||
val ref = ContractStateRef(SecureHash.randomSHA256(), 0)
|
||||
tg.txns.add(LedgerTransaction(
|
||||
listOf(ref), listOf(A_THOUSAND_POUNDS), listOf(AuthenticatedObject(listOf(BOB), emptyList(), Cash.Commands.Move)), TEST_TX_TIME, SecureHash.randomSHA256())
|
||||
)
|
||||
|
||||
val e = assertFailsWith(TransactionResolutionException::class) {
|
||||
tg.verify()
|
||||
}
|
||||
assertEquals(e.hash, ref.txhash)
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ import core.*
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.PublicKey
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
import kotlin.test.fail
|
||||
|
||||
object TestUtils {
|
||||
@ -17,6 +18,8 @@ val MEGA_CORP_KEY = DummyPublicKey("mini")
|
||||
val MINI_CORP_KEY = DummyPublicKey("mega")
|
||||
val DUMMY_PUBKEY_1 = DummyPublicKey("x1")
|
||||
val DUMMY_PUBKEY_2 = DummyPublicKey("x2")
|
||||
val ALICE = DummyPublicKey("alice")
|
||||
val BOB = DummyPublicKey("bob")
|
||||
val MEGA_CORP = Institution("MegaCorp", MEGA_CORP_KEY)
|
||||
val MINI_CORP = Institution("MiniCorp", MINI_CORP_KEY)
|
||||
|
||||
@ -38,7 +41,7 @@ val TEST_PROGRAM_MAP: Map<SecureHash, Contract> = mapOf(
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// DSL for building pseudo-transactions (not the same as the wire protocol) for testing purposes.
|
||||
// Defines a simple DSL for building pseudo-transactions (not the same as the wire protocol) for testing purposes.
|
||||
//
|
||||
// Define a transaction like this:
|
||||
//
|
||||
@ -57,37 +60,41 @@ val TEST_PROGRAM_MAP: Map<SecureHash, Contract> = mapOf(
|
||||
//
|
||||
// TODO: Make it impossible to forget to test either a failure or an accept for each transaction{} block
|
||||
|
||||
// Corresponds to the args to Contract.verify
|
||||
class TransactionForTest() {
|
||||
private val inStates = arrayListOf<ContractState>()
|
||||
infix fun Cash.State.`owned by`(owner: PublicKey) = this.copy(owner = owner)
|
||||
|
||||
class LabeledOutput(val label: String?, val state: ContractState) {
|
||||
override fun toString() = state.toString() + (if (label != null) " ($label)" else "")
|
||||
override fun equals(other: Any?) = other is LabeledOutput && state.equals(other.state)
|
||||
override fun hashCode(): Int = state.hashCode()
|
||||
}
|
||||
private val outStates = arrayListOf<LabeledOutput>()
|
||||
class LabeledOutput(val label: String?, val state: ContractState) {
|
||||
override fun toString() = state.toString() + (if (label != null) " ($label)" else "")
|
||||
override fun equals(other: Any?) = other is LabeledOutput && state.equals(other.state)
|
||||
override fun hashCode(): Int = state.hashCode()
|
||||
}
|
||||
|
||||
private val commands: MutableList<AuthenticatedObject<Command>> = arrayListOf()
|
||||
infix fun ContractState.label(label: String) = LabeledOutput(label, this)
|
||||
|
||||
constructor(inStates: List<ContractState>, outStates: List<ContractState>, commands: List<AuthenticatedObject<Command>>) : this() {
|
||||
this.inStates.addAll(inStates)
|
||||
this.outStates.addAll(outStates.map { LabeledOutput(null, it) })
|
||||
this.commands.addAll(commands)
|
||||
}
|
||||
abstract class AbstractTransactionForTest {
|
||||
protected val outStates = ArrayList<LabeledOutput>()
|
||||
protected val commands = ArrayList<AuthenticatedObject<Command>>()
|
||||
|
||||
open fun output(label: String? = null, s: () -> ContractState) = LabeledOutput(label, s()).apply { outStates.add(this) }
|
||||
|
||||
fun input(s: () -> ContractState) = inStates.add(s())
|
||||
fun output(label: String? = null, s: () -> ContractState) = outStates.add(LabeledOutput(label, s()))
|
||||
fun arg(vararg key: PublicKey, c: () -> Command) {
|
||||
val keys = listOf(*key)
|
||||
commands.add(AuthenticatedObject(keys, keys.mapNotNull { TEST_KEYS_TO_CORP_MAP[it] }, c()))
|
||||
}
|
||||
|
||||
private fun run(time: Instant) = TransactionForVerification(inStates, outStates.map { it.state }, commands, time, SecureHash.randomSHA256()).verify(TEST_PROGRAM_MAP)
|
||||
// Forbid patterns like: transaction { ... transaction { ... } }
|
||||
@Deprecated("Cannot nest transactions, use tweak", level = DeprecationLevel.ERROR)
|
||||
fun transaction(body: TransactionForTest.() -> Unit) {}
|
||||
}
|
||||
|
||||
infix fun `fails requirement`(msg: String) = rejects(msg)
|
||||
// which is uglier?? :)
|
||||
fun fails_requirement(msg: String) = this.`fails requirement`(msg)
|
||||
// Corresponds to the args to Contract.verify
|
||||
open class TransactionForTest : AbstractTransactionForTest() {
|
||||
private val inStates = arrayListOf<ContractState>()
|
||||
fun input(s: () -> ContractState) = inStates.add(s())
|
||||
|
||||
protected fun run(time: Instant) {
|
||||
val tx = TransactionForVerification(inStates, outStates.map { it.state }, commands, time, SecureHash.randomSHA256())
|
||||
tx.verify(TEST_PROGRAM_MAP)
|
||||
}
|
||||
|
||||
fun accepts(time: Instant = TEST_TX_TIME) = run(time)
|
||||
fun rejects(withMessage: String? = null, time: Instant = TEST_TX_TIME) {
|
||||
@ -105,15 +112,9 @@ class TransactionForTest() {
|
||||
if (!r) throw AssertionError("Expected exception but didn't get one")
|
||||
}
|
||||
|
||||
// Allow customisation of partial transactions.
|
||||
fun tweak(body: TransactionForTest.() -> Unit): TransactionForTest {
|
||||
val tx = TransactionForTest()
|
||||
tx.inStates.addAll(inStates)
|
||||
tx.outStates.addAll(outStates)
|
||||
tx.commands.addAll(commands)
|
||||
tx.body()
|
||||
return tx
|
||||
}
|
||||
// which is uglier?? :)
|
||||
infix fun `fails requirement`(msg: String) = rejects(msg)
|
||||
fun fails_requirement(msg: String) = this.`fails requirement`(msg)
|
||||
|
||||
// Use this to create transactions where the output of this transaction is automatically used as an input of
|
||||
// the next.
|
||||
@ -131,6 +132,16 @@ class TransactionForTest() {
|
||||
return tx
|
||||
}
|
||||
|
||||
// Allow customisation of partial transactions.
|
||||
fun tweak(body: TransactionForTest.() -> Unit): TransactionForTest {
|
||||
val tx = TransactionForTest()
|
||||
tx.inStates.addAll(inStates)
|
||||
tx.outStates.addAll(outStates)
|
||||
tx.commands.addAll(commands)
|
||||
tx.body()
|
||||
return tx
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return """transaction {
|
||||
inputs: $inStates
|
||||
@ -149,8 +160,67 @@ class TransactionForTest() {
|
||||
}
|
||||
}
|
||||
|
||||
fun transaction(body: TransactionForTest.() -> Unit): TransactionForTest {
|
||||
val tx = TransactionForTest()
|
||||
tx.body()
|
||||
return tx
|
||||
fun transaction(body: TransactionForTest.() -> Unit) = TransactionForTest().apply { body() }
|
||||
|
||||
class TransactionGroupForTest {
|
||||
open inner class LedgerTransactionForTest : AbstractTransactionForTest() {
|
||||
private val inputs = ArrayList<ContractStateRef>()
|
||||
|
||||
fun input(label: String) {
|
||||
inputs.add(labelToRefs[label] ?: throw IllegalArgumentException("Unknown label \"$label\""))
|
||||
}
|
||||
|
||||
fun toLedgerTransaction(time: Instant): LedgerTransaction {
|
||||
val wireCmds = commands.map { WireCommand(it.value, it.signers) }
|
||||
return WireTransaction(inputs, outStates.map { it.state }, wireCmds).toLedgerTransaction(time, TEST_KEYS_TO_CORP_MAP)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class InternalLedgerTransactionForTest : LedgerTransactionForTest() {
|
||||
fun finaliseAndInsertLabels(time: Instant): LedgerTransaction {
|
||||
val ltx = toLedgerTransaction(time)
|
||||
for ((index, state) in outStates.withIndex()) {
|
||||
if (state.label != null)
|
||||
labelToRefs[state.label] = ContractStateRef(ltx.hash, index)
|
||||
}
|
||||
return ltx
|
||||
}
|
||||
}
|
||||
|
||||
private val rootTxns = ArrayList<LedgerTransaction>()
|
||||
private val labelToRefs = HashMap<String, ContractStateRef>()
|
||||
inner class Roots {
|
||||
fun transaction(vararg outputStates: LabeledOutput) {
|
||||
val outs = outputStates.map { it.state }
|
||||
val wtx = WireTransaction(emptyList(), outs, emptyList())
|
||||
val ltx = wtx.toLedgerTransaction(TEST_TX_TIME, TEST_KEYS_TO_CORP_MAP)
|
||||
outputStates.forEachIndexed { index, labeledOutput -> labelToRefs[labeledOutput.label!!] = ContractStateRef(ltx.hash, index) }
|
||||
rootTxns.add(ltx)
|
||||
}
|
||||
|
||||
@Deprecated("Does not nest ", level = DeprecationLevel.ERROR)
|
||||
fun roots(body: Roots.() -> Unit) {}
|
||||
}
|
||||
fun roots(body: Roots.() -> Unit) = Roots().apply { body() }
|
||||
|
||||
val txns = ArrayList<LedgerTransaction>()
|
||||
|
||||
fun transaction(time: Instant = TEST_TX_TIME, body: LedgerTransactionForTest.() -> Unit): LedgerTransaction {
|
||||
val forTest = InternalLedgerTransactionForTest()
|
||||
forTest.body()
|
||||
val ltx = forTest.finaliseAndInsertLabels(time)
|
||||
txns.add(ltx)
|
||||
return ltx
|
||||
}
|
||||
|
||||
@Deprecated("Does not nest ", level = DeprecationLevel.ERROR)
|
||||
fun transactionGroup(body: TransactionGroupForTest.() -> Unit) {}
|
||||
|
||||
fun verify() {
|
||||
toTransactionGroup().verify(TEST_PROGRAM_MAP)
|
||||
}
|
||||
|
||||
fun toTransactionGroup() = TransactionGroup(txns.map { it }.toSet(), rootTxns.toSet())
|
||||
}
|
||||
|
||||
fun transactionGroup(body: TransactionGroupForTest.() -> Unit) = TransactionGroupForTest().apply { this.body() }
|
||||
|
Loading…
x
Reference in New Issue
Block a user