Implement a simple transaction graph visualiser tool.

This commit is contained in:
Mike Hearn 2015-12-01 19:45:55 +00:00
parent 31ca78533b
commit a95cd056ea
26 changed files with 467 additions and 17 deletions

View File

@ -33,9 +33,9 @@ dependencies {
compile "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" compile "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
compile "com.google.guava:guava:18.0" compile "com.google.guava:guava:18.0"
compile "com.esotericsoftware:kryo:3.0.3" compile "com.esotericsoftware:kryo:3.0.3"
}
sourceSets { // For visualisation
main.java.srcDirs += 'src' compile "org.graphstream:gs-core:1.3"
test.java.srcDirs += 'tests' compile "org.graphstream:gs-ui:1.3"
compile "com.intellij:forms_rt:7.0.3"
} }

View File

@ -94,7 +94,7 @@ class CommercialPaperTests {
// Generate a trade lifecycle with various parameters. // Generate a trade lifecycle with various parameters.
private fun trade(redemptionTime: Instant = TEST_TX_TIME + 8.days, private fun trade(redemptionTime: Instant = TEST_TX_TIME + 8.days,
aliceGetsBack: Amount = 1000.DOLLARS, aliceGetsBack: Amount = 1000.DOLLARS,
destroyPaperAtRedemption: Boolean = true): TransactionGroupForTest<CommercialPaper.State> { destroyPaperAtRedemption: Boolean = true): TransactionGroupDSL<CommercialPaper.State> {
val someProfits = 1200.DOLLARS val someProfits = 1200.DOLLARS
return transactionGroupFor() { return transactionGroupFor() {
roots { roots {

View File

@ -50,7 +50,7 @@ class CrowdFundTests {
raiseFunds().verify() raiseFunds().verify()
} }
private fun raiseFunds(): TransactionGroupForTest<CrowdFund.State> { private fun raiseFunds(): TransactionGroupDSL<CrowdFund.State> {
return transactionGroupFor<CrowdFund.State> { return transactionGroupFor<CrowdFund.State> {
roots { roots {
transaction(1000.DOLLARS.CASH `owned by` ALICE label "alice's $1000") transaction(1000.DOLLARS.CASH `owned by` ALICE label "alice's $1000")
@ -79,7 +79,7 @@ class CrowdFundTests {
} }
// 3. Close the opportunity, assuming the target has been met // 3. Close the opportunity, assuming the target has been met
transaction(TEST_TX_TIME + 8.days) { transaction(time = TEST_TX_TIME + 8.days) {
input ("pledged opportunity") input ("pledged opportunity")
output ("funded and closed") { "pledged opportunity".output.copy(closed = true) } output ("funded and closed") { "pledged opportunity".output.copy(closed = true) }
arg(MINI_CORP_PUBKEY) { CrowdFund.Commands.Funded() } arg(MINI_CORP_PUBKEY) { CrowdFund.Commands.Funded() }

View File

@ -210,8 +210,8 @@ open class TransactionForTest : AbstractTransactionForTest() {
fun transaction(body: TransactionForTest.() -> Unit) = TransactionForTest().apply { body() } fun transaction(body: TransactionForTest.() -> Unit) = TransactionForTest().apply { body() }
class TransactionGroupForTest<out T : ContractState>(private val stateType: Class<T>) { class TransactionGroupDSL<T : ContractState>(private val stateType: Class<T>) {
open inner class LedgerTransactionForTest : AbstractTransactionForTest() { open inner class LedgerTransactionDSL : AbstractTransactionForTest() {
private val inStates = ArrayList<ContractStateRef>() private val inStates = ArrayList<ContractStateRef>()
fun input(label: String) { fun input(label: String) {
@ -231,7 +231,7 @@ class TransactionGroupForTest<out T : ContractState>(private val stateType: Clas
val String.output: T get() = labelToOutputs[this] ?: throw IllegalArgumentException("State with label '$this' was not found") val String.output: T get() = labelToOutputs[this] ?: throw IllegalArgumentException("State with label '$this' was not found")
private inner class InternalLedgerTransactionForTest : LedgerTransactionForTest() { private inner class InternalLedgerTransactionDSL : LedgerTransactionDSL() {
fun finaliseAndInsertLabels(time: Instant): LedgerTransaction { fun finaliseAndInsertLabels(time: Instant): LedgerTransaction {
val ltx = toLedgerTransaction(time) val ltx = toLedgerTransaction(time)
for ((index, labelledState) in outStates.withIndex()) { for ((index, labelledState) in outStates.withIndex()) {
@ -240,6 +240,7 @@ class TransactionGroupForTest<out T : ContractState>(private val stateType: Clas
if (stateType.isInstance(labelledState.state)) { if (stateType.isInstance(labelledState.state)) {
labelToOutputs[labelledState.label] = labelledState.state as T labelToOutputs[labelledState.label] = labelledState.state as T
} }
outputsToLabels[labelledState.state] = labelledState.label
} }
} }
return ltx return ltx
@ -249,34 +250,47 @@ class TransactionGroupForTest<out T : ContractState>(private val stateType: Clas
private val rootTxns = ArrayList<LedgerTransaction>() private val rootTxns = ArrayList<LedgerTransaction>()
private val labelToRefs = HashMap<String, ContractStateRef>() private val labelToRefs = HashMap<String, ContractStateRef>()
private val labelToOutputs = HashMap<String, T>() private val labelToOutputs = HashMap<String, T>()
private val outputsToLabels = HashMap<ContractState, String>()
fun labelForState(state: T): String? = outputsToLabels[state]
inner class Roots { inner class Roots {
fun transaction(vararg outputStates: LabeledOutput) { fun transaction(vararg outputStates: LabeledOutput) {
val outs = outputStates.map { it.state } val outs = outputStates.map { it.state }
val wtx = WireTransaction(emptyList(), outs, emptyList()) val wtx = WireTransaction(emptyList(), outs, emptyList())
val ltx = wtx.toLedgerTransaction(TEST_TX_TIME, TEST_KEYS_TO_CORP_MAP, SecureHash.randomSHA256()) val ltx = wtx.toLedgerTransaction(TEST_TX_TIME, TEST_KEYS_TO_CORP_MAP, SecureHash.randomSHA256())
outputStates.forEachIndexed { index, labeledOutput -> labelToRefs[labeledOutput.label!!] = ContractStateRef(ltx.hash, index) } for ((index, state) in outputStates.withIndex()) {
val label = state.label!!
labelToRefs[label] = ContractStateRef(ltx.hash, index)
outputsToLabels[state.state] = label
}
rootTxns.add(ltx) rootTxns.add(ltx)
} }
@Deprecated("Does not nest ", level = DeprecationLevel.ERROR) @Deprecated("Does not nest ", level = DeprecationLevel.ERROR)
fun roots(body: Roots.() -> Unit) {} fun roots(body: Roots.() -> Unit) {}
@Deprecated("Use the vararg form of transaction inside roots", level = DeprecationLevel.ERROR) @Deprecated("Use the vararg form of transaction inside roots", level = DeprecationLevel.ERROR)
fun transaction(time: Instant = TEST_TX_TIME, body: LedgerTransactionForTest.() -> Unit) {} fun transaction(time: Instant = TEST_TX_TIME, body: LedgerTransactionDSL.() -> Unit) {}
} }
fun roots(body: Roots.() -> Unit) = Roots().apply { body() } fun roots(body: Roots.() -> Unit) = Roots().apply { body() }
val txns = ArrayList<LedgerTransaction>() val txns = ArrayList<LedgerTransaction>()
private val txnToLabelMap = HashMap<LedgerTransaction, String>()
fun transaction(time: Instant = TEST_TX_TIME, body: LedgerTransactionForTest.() -> Unit): LedgerTransaction { fun transaction(label: String? = null, time: Instant = TEST_TX_TIME, body: LedgerTransactionDSL.() -> Unit): LedgerTransaction {
val forTest = InternalLedgerTransactionForTest() val forTest = InternalLedgerTransactionDSL()
forTest.body() forTest.body()
val ltx = forTest.finaliseAndInsertLabels(time) val ltx = forTest.finaliseAndInsertLabels(time)
txns.add(ltx) txns.add(ltx)
if (label != null)
txnToLabelMap[ltx] = label
return ltx return ltx
} }
fun labelForTransaction(ltx: LedgerTransaction): String? = txnToLabelMap[ltx]
@Deprecated("Does not nest ", level = DeprecationLevel.ERROR) @Deprecated("Does not nest ", level = DeprecationLevel.ERROR)
fun transactionGroup(body: TransactionGroupForTest<T>.() -> Unit) {} fun transactionGroup(body: TransactionGroupDSL<T>.() -> Unit) {}
fun toTransactionGroup() = TransactionGroup(txns.map { it }.toSet(), rootTxns.toSet()) fun toTransactionGroup() = TransactionGroup(txns.map { it }.toSet(), rootTxns.toSet())
@ -304,5 +318,5 @@ class TransactionGroupForTest<out T : ContractState>(private val stateType: Clas
} }
} }
inline fun <reified T : ContractState> transactionGroupFor(body: TransactionGroupForTest<T>.() -> Unit) = TransactionGroupForTest<T>(T::class.java).apply { this.body() } inline fun <reified T : ContractState> transactionGroupFor(body: TransactionGroupDSL<T>.() -> Unit) = TransactionGroupDSL<T>(T::class.java).apply { this.body() }
fun transactionGroup(body: TransactionGroupForTest<ContractState>.() -> Unit) = TransactionGroupForTest(ContractState::class.java).apply { this.body() } fun transactionGroup(body: TransactionGroupDSL<ContractState>.() -> Unit) = TransactionGroupDSL(ContractState::class.java).apply { this.body() }

View File

@ -0,0 +1,95 @@
/*
* Copyright 2015, R3 CEV. All rights reserved.
*/
package core.visualiser
import org.graphstream.graph.Edge
import org.graphstream.graph.Element
import org.graphstream.graph.Graph
import org.graphstream.graph.Node
import org.graphstream.graph.implementations.SingleGraph
import org.graphstream.ui.layout.Layout
import org.graphstream.ui.layout.springbox.implementations.SpringBox
import org.graphstream.ui.swingViewer.DefaultView
import org.graphstream.ui.view.Viewer
import org.graphstream.ui.view.ViewerListener
import java.util.*
import javax.swing.JFrame
import kotlin.reflect.KProperty
// Some utilities to make the GraphStream API a bit nicer to work with. For some reason GS likes to use a non-type safe
// string->value map type API for configuring common things. We fix it up here:
class GSPropertyDelegate<T>(private val prefix: String) {
operator fun getValue(thisRef: Element, property: KProperty<*>): T = thisRef.getAttribute("$prefix.${property.name}")
operator fun setValue(thisRef: Element, property: KProperty<*>, value: T) = thisRef.setAttribute("$prefix.${property.name}", value)
}
var Node.label: String by GSPropertyDelegate<String>("ui")
var Graph.stylesheet: String by GSPropertyDelegate<String>("ui")
var Edge.weight: Double by GSPropertyDelegate<Double>("layout")
// Do this one by hand as 'class' is a reserved word.
var Node.styleClass: String
set(value) = setAttribute("ui.class", value)
get() = getAttribute("ui.class")
fun createGraph(name: String, styles: String): SingleGraph {
System.setProperty("org.graphstream.ui.renderer", "org.graphstream.ui.j2dviewer.J2DGraphRenderer");
return SingleGraph(name).apply {
stylesheet = styles
setAttribute("ui.quality")
setAttribute("ui.antialias")
setAttribute("layout.quality", 0)
setAttribute("layout.force", 0.9)
}
}
class MyViewer(graph: Graph) : Viewer(graph, Viewer.ThreadingModel.GRAPH_IN_ANOTHER_THREAD) {
override fun enableAutoLayout(layoutAlgorithm: Layout) {
super.enableAutoLayout(layoutAlgorithm)
// Setting shortNap to 1 stops things bouncing around horribly at the start.
optLayout.setNaps(50, 1)
}
}
fun runGraph(graph: SingleGraph, nodeOnClick: (Node) -> Unit) {
// Use a bit of custom code here instead of calling graph.display() so we can maximize the window.
val viewer = MyViewer(graph)
val view: DefaultView = object : DefaultView(viewer, Viewer.DEFAULT_VIEW_ID, Viewer.newGraphRenderer()) {
override fun openInAFrame(on: Boolean) {
super.openInAFrame(on)
if (frame != null) {
frame.extendedState = frame.extendedState or JFrame.MAXIMIZED_BOTH
}
}
}
viewer.addView(view)
var loop: Boolean = true
val viewerPipe = viewer.newViewerPipe()
viewerPipe.addViewerListener(object : ViewerListener {
override fun buttonPushed(id: String?) {
}
override fun buttonReleased(id: String?) {
val node = graph.getNode<Node>(id)
nodeOnClick(node)
}
override fun viewClosed(viewName: String?) {
loop = false
}
})
view.openInAFrame(true)
// Seed determined through trial and error: it gives a reasonable layout for the Wednesday demo.
val springBox = SpringBox(false, Random(-103468310429824593L))
viewer.enableAutoLayout(springBox)
while (loop) {
viewerPipe.blockingPump()
}
}

View File

@ -0,0 +1,82 @@
/*
* Copyright 2015, R3 CEV. All rights reserved.
*/
@file:Suppress("CAST_NEVER_SUCCEEDS")
package core.visualiser
import contracts.Cash
import contracts.CommercialPaper
import core.Amount
import core.ContractState
import core.DOLLARS
import core.days
import core.testutils.*
import java.time.Instant
import kotlin.reflect.memberProperties
val PAPER_1 = CommercialPaper.State(
issuance = MEGA_CORP.ref(123),
owner = MEGA_CORP_PUBKEY,
faceValue = 1000.DOLLARS,
maturityDate = TEST_TX_TIME + 7.days
)
private fun trade(redemptionTime: Instant = TEST_TX_TIME + 8.days,
aliceGetsBack: Amount = 1000.DOLLARS,
destroyPaperAtRedemption: Boolean = true): TransactionGroupDSL<ContractState> {
val someProfits = 1200.DOLLARS
return transactionGroupFor<CommercialPaper.State>() {
roots {
transaction(900.DOLLARS.CASH `owned by` ALICE label "alice's $900")
transaction(someProfits.CASH `owned by` MEGA_CORP_PUBKEY label "some profits")
}
// Some CP is issued onto the ledger by MegaCorp.
transaction("Issuance") {
output("paper") { PAPER_1 }
arg(MEGA_CORP_PUBKEY) { CommercialPaper.Commands.Issue() }
}
// The CP is sold to alice for her $900, $100 less than the face value. At 10% interest after only 7 days,
// that sounds a bit too good to be true!
transaction("Trade") {
input("paper")
input("alice's $900")
output("borrowed $900") { 900.DOLLARS.CASH `owned by` MEGA_CORP_PUBKEY }
output("alice's paper") { "paper".output `owned by` ALICE }
arg(ALICE) { Cash.Commands.Move() }
arg(MEGA_CORP_PUBKEY) { CommercialPaper.Commands.Move() }
}
// Time passes, and Alice redeem's her CP for $1000, netting a $100 profit. MegaCorp has received $1200
// as a single payment from somewhere and uses it to pay Alice off, keeping the remaining $200 as change.
transaction("Redemption", redemptionTime) {
input("alice's paper")
input("some profits")
output("Alice's profit") { aliceGetsBack.CASH `owned by` ALICE }
output("Change") { (someProfits - aliceGetsBack).CASH `owned by` MEGA_CORP_PUBKEY }
if (!destroyPaperAtRedemption)
output { "paper".output }
arg(MEGA_CORP_PUBKEY) { Cash.Commands.Move() }
arg(ALICE) { CommercialPaper.Commands.Redeem() }
}
} as TransactionGroupDSL<ContractState>
}
fun main(args: Array<String>) {
val tg = trade()
val graph = GraphConverter(tg).convert()
runGraph(graph, nodeOnClick = { node ->
val state: ContractState? = node.getAttribute("state")
if (state != null) {
val props: List<Pair<String, Any?>> = state.javaClass.kotlin.memberProperties.map { it.name to it.getter.call(state) }
StateViewer.show(props)
}
})
}

View File

@ -0,0 +1,68 @@
/*
* Copyright 2015, R3 CEV. All rights reserved.
*/
package core.visualiser
import core.Command
import core.ContractState
import core.SecureHash
import core.testutils.TransactionGroupDSL
import org.graphstream.graph.Edge
import org.graphstream.graph.Node
import org.graphstream.graph.implementations.SingleGraph
class GraphConverter(val dsl: TransactionGroupDSL<in ContractState>) {
companion object {
val css = GraphConverter::class.java.getResourceAsStream("graph.css").bufferedReader().readText()
}
fun convert(): SingleGraph {
val tg = dsl.toTransactionGroup()
val graph = createGraph("Transaction group", css)
// Map all the transactions, including the bogus non-verified ones (with no inputs) to graph nodes.
for ((txIndex, tx) in (tg.transactions + tg.nonVerifiedRoots).withIndex()) {
val txNode = graph.addNode<Node>("tx$txIndex")
if (tx !in tg.nonVerifiedRoots)
txNode.label = dsl.labelForTransaction(tx).let { it ?: "TX ${tx.hash.prefixChars()}" }
txNode.styleClass = "tx"
// Now create a vertex for each output state.
for (outIndex in tx.outStates.indices) {
val node = graph.addNode<Node>(tx.outRef<ContractState>(outIndex).ref.toString())
val state = tx.outStates[outIndex]
node.label = stateToLabel(state)
node.styleClass = stateToCSSClass(state) + ",state"
node.setAttribute("state", state)
val edge = graph.addEdge<Edge>("tx$txIndex-out$outIndex", txNode, node, true)
edge.weight = 0.7
}
// And a vertex for each command.
for ((index, cmd) in tx.commands.withIndex()) {
val node = graph.addNode<Node>(SecureHash.randomSHA256().prefixChars())
node.label = commandToTypeName(cmd.value)
node.styleClass = "command"
val edge = graph.addEdge<Edge>("tx$txIndex-cmd-$index", node, txNode)
edge.weight = 0.4
}
}
// And now all states and transactions were mapped to graph nodes, hook up the input edges.
for ((txIndex, tx) in tg.transactions.withIndex()) {
for ((inputIndex, ref) in tx.inStateRefs.withIndex()) {
val edge = graph.addEdge<Edge>("tx$txIndex-in$inputIndex", ref.toString(), "tx$txIndex", true)
edge.weight = 1.2
}
}
return graph
}
private fun stateToLabel(state: ContractState): String {
return dsl.labelForState(state) ?: stateToTypeName(state)
}
private fun commandToTypeName(state: Command) = state.javaClass.canonicalName.removePrefix("contracts.").replace('$', '.')
private fun stateToTypeName(state: ContractState) = state.javaClass.canonicalName.removePrefix("contracts.").removeSuffix(".State")
private fun stateToCSSClass(state: ContractState) = stateToTypeName(state).replace('.', '_').toLowerCase()
}

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<form xmlns="http://www.intellij.com/uidesigner/form/" version="1" bind-to-class="core.visualiser.StateViewer">
<grid id="27dc6" binding="root" layout-manager="BorderLayout" hgap="15" vgap="15">
<constraints>
<xy x="20" y="20" width="500" height="400"/>
</constraints>
<properties>
<background color="-1"/>
</properties>
<border type="empty">
<size top="15" left="15" bottom="15" right="15"/>
</border>
<children>
<component id="c1614" class="javax.swing.JLabel">
<constraints border-constraint="North"/>
<properties>
<font style="1"/>
<text value="State viewer"/>
</properties>
</component>
<scrollpane id="2974d">
<constraints border-constraint="Center"/>
<properties/>
<border type="none"/>
<children>
<component id="8f1af" class="javax.swing.JTable" binding="propsTable">
<constraints/>
<properties>
<autoResizeMode value="3"/>
<showHorizontalLines value="false"/>
</properties>
</component>
</children>
</scrollpane>
</children>
</grid>
</form>

View File

@ -0,0 +1,113 @@
/*
* Copyright 2015, R3 CEV. All rights reserved.
*/
package core.visualiser;
import kotlin.*;
import javax.swing.*;
import javax.swing.table.*;
import java.awt.*;
import java.util.*;
import java.util.List;
public class StateViewer {
private JPanel root;
private JTable propsTable;
public static void main(String[] args) {
JFrame frame = new JFrame("StateViewer");
List<Pair<String, Object>> props = new ArrayList<>();
props.add(new Pair<>("a", 123));
props.add(new Pair<>("things", "bar"));
frame.setContentPane(new StateViewer(props).root);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.pack();
frame.setVisible(true);
frame.setSize(800, 600);
}
public static void show(List<Pair<String, Object>> props) {
JFrame frame = new JFrame("StateViewer");
StateViewer viewer = new StateViewer(props);
frame.setContentPane(viewer.root);
frame.pack();
frame.setSize(600, 300);
viewer.propsTable.getColumnModel().getColumn(0).setMinWidth(150);
viewer.propsTable.getColumnModel().getColumn(0).setMaxWidth(150);
frame.setVisible(true);
}
public StateViewer(List<Pair<String, Object>> props) {
propsTable.setModel(new AbstractTableModel() {
@Override
public int getRowCount() {
return props.size();
}
@Override
public int getColumnCount() {
return 2;
}
@Override
public String getColumnName(int column) {
if (column == 0)
return "Attribute";
else if (column == 1)
return "Value";
else
return "?";
}
@Override
public Object getValueAt(int rowIndex, int columnIndex) {
if (columnIndex == 0)
return props.get(rowIndex).getFirst();
else
return props.get(rowIndex).getSecond();
}
});
}
{
// GUI initializer generated by IntelliJ IDEA GUI Designer
// >>> IMPORTANT!! <<<
// DO NOT EDIT OR ADD ANY CODE HERE!
$$$setupUI$$$();
}
/**
* Method generated by IntelliJ IDEA GUI Designer
* >>> IMPORTANT!! <<<
* DO NOT edit this method OR call it in your code!
*
* @noinspection ALL
*/
private void $$$setupUI$$$() {
root = new JPanel();
root.setLayout(new BorderLayout(15, 15));
root.setBackground(new Color(-1));
root.setBorder(BorderFactory.createTitledBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15), null));
final JLabel label1 = new JLabel();
label1.setFont(new Font(label1.getFont().getName(), Font.BOLD, label1.getFont().getSize()));
label1.setText("State viewer");
root.add(label1, BorderLayout.NORTH);
final JScrollPane scrollPane1 = new JScrollPane();
root.add(scrollPane1, BorderLayout.CENTER);
propsTable = new JTable();
propsTable.setAutoResizeMode(3);
propsTable.setShowHorizontalLines(false);
scrollPane1.setViewportView(propsTable);
}
/**
* @noinspection ALL
*/
public JComponent $$$getRootComponent$$$() {
return root;
}
}

View File

@ -0,0 +1,41 @@
node.tx {
size: 10px;
fill-color: blue;
shape: rounded-box;
z-index: 4;
}
node.state {
size: 25px;
fill-color: beige;
stroke-width: 2px;
stroke-color: black;
stroke-mode: plain;
}
node {
text-background-mode: rounded-box;
text-background-color: darkslategrey;
text-padding: 5px;
text-offset: 10px;
text-color: white;
text-alignment: under;
text-size: 16;
}
node.command {
text-size: 12;
size: 8px;
fill-color: white;
stroke-width: 2px;
stroke-color: black;
stroke-mode: plain;
}
node.cash {
fill-color: red;
}
graph {
padding: 100px;
}