diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 5cd3aa02f4..09964f0fcd 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -7,6 +7,8 @@ + + diff --git a/experimental/avalanche/Readme.md b/experimental/avalanche/Readme.md new file mode 100644 index 0000000000..e41775f225 --- /dev/null +++ b/experimental/avalanche/Readme.md @@ -0,0 +1,24 @@ +# Avalanche Simulation + +Experimental simulation of the Avalanche protocol by Team Rocket. This +implementation is incomplete. + +The paper: [Snowflake to Avalanche: A Novel Metastable Consensus Protocol Family for + Cryptocurrencies](https://ipfs.io/ipfs/QmUy4jh5mGNZvLkjies1RWM4YuvJh5o2FYopNPVYwrRVGV). + +## Running the Simulation +``` +./gradlew shadowJar +java -jar build/libs/avalanche-all.jar --dump-dags +``` + +### Visualising the DAGs +``` +for f in node-0-*.dot; do dot -Tpng -O $f; done +``` +The above command generates a number of PNG files `node-0-*.png`, showing the +evolution of the DAG. The nodes are labeled with the ID of the spent state, +the chit and confidence values. The prefered transaction of a conflict set is +labelled with a star. Accepted transactions are blue. + +![DAG](./images/node-0-003.dot.png) diff --git a/experimental/avalanche/build.gradle b/experimental/avalanche/build.gradle new file mode 100644 index 0000000000..ea6e2a4632 --- /dev/null +++ b/experimental/avalanche/build.gradle @@ -0,0 +1,42 @@ +buildscript { + ext.kotlin_version = '1.2.40' + + repositories { + mavenCentral() + jcenter() + } + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath 'com.github.jengelman.gradle.plugins:shadow:2.0.3' + } +} + +plugins { + id "org.jetbrains.kotlin.jvm" + id 'com.github.johnrengelman.shadow' version '2.0.3' + id 'java' + id 'application' +} +repositories { + mavenCentral() +} +dependencies { + compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + compile group: 'info.picocli', name: 'picocli', version: '3.0.1' + testCompile group: 'junit', name: 'junit', version: '4.12' +} +compileKotlin { + kotlinOptions { + jvmTarget = "1.8" + } +} +compileTestKotlin { + kotlinOptions { + jvmTarget = "1.8" + } +} +mainClassName = "net.corda.avalanche.MainKt" +shadowJar { + baseName = "avalanche" +} diff --git a/experimental/avalanche/images/node-0-003.dot.png b/experimental/avalanche/images/node-0-003.dot.png new file mode 100644 index 0000000000..65c8bf911a Binary files /dev/null and b/experimental/avalanche/images/node-0-003.dot.png differ diff --git a/experimental/avalanche/src/main/kotlin/net/corda/avalanche/Main.kt b/experimental/avalanche/src/main/kotlin/net/corda/avalanche/Main.kt new file mode 100644 index 0000000000..3d265d78e7 --- /dev/null +++ b/experimental/avalanche/src/main/kotlin/net/corda/avalanche/Main.kt @@ -0,0 +1,231 @@ +package net.corda.avalanche + +import picocli.CommandLine +import java.io.File +import java.util.* +import kotlin.collections.LinkedHashMap + +fun main(args: Array) { + + val parameters = Parameters() + CommandLine(parameters).parse(*args) + if (parameters.helpRequested) { + CommandLine.usage(Parameters(), System.out) + return + } + + val network = Network(parameters) + val n1 = network.nodes[0] + val c1 = mutableListOf() + val c2 = mutableListOf() + + repeat(parameters.nrTransactions) { + val n = network.nodes.shuffled(network.rng).first() + c1.add(n.onGenerateTx(it)) + if (network.rng.nextDouble() < parameters.doubleSpendRatio) { + val d = network.rng.nextInt(it) + println("double spend of $d") + val n2 = network.nodes.shuffled(network.rng).first() + c2.add(n2.onGenerateTx(d)) + } + + network.run() + + if (parameters.dumpDags) { + n1.dumpDag(File("node-0-${String.format("%03d", it)}.dot")) + } + println("$it: " + String.format("%.3f", fractionAccepted(n1))) + } + + val conflictSets = (c1 + c2).groupBy { it.data }.filterValues { it.size > 1 } + conflictSets.forEach { v, txs -> + val acceptance = txs.map { t -> network.nodes.map { it.isAccepted(t) }.any { it } } + require(acceptance.filter { it }.size < 2) { "More than one transaction of the conflict set of $v got accepted." } + } +} + +fun fractionAccepted(n: Node): Double { + val accepted = n.transactions.values.filter { n.isAccepted(it) }.size + return accepted.toDouble() / n.transactions.size +} + +data class Transaction( + val id: UUID, + val data: Int, + val parents: List, + var chit: Int = 0, + var confidence: Int = 0) { + override fun toString(): String { + return "T(id=${id.toString().take(5)}, data=$data, parents=[${parents.map {it.toString().take(5) }}, chit=$chit, confidence=$confidence)" + } +} + +data class ConflictSet( + var pref: Transaction, + var last: Transaction, + var count: Int, + var size: Int +) + +class Network(val parameters: Parameters) { + val rng = Random(parameters.seed) + val tx = Transaction(UUID.randomUUID(), -1, emptyList(), 1) + val nodes = (0..parameters.nrNodes).map { Node(it, parameters, tx.copy(),this, rng) } + fun run() { + nodes.forEach { it.avalancheLoop() } + } +} + +class Node(val id: Int, parameters: Parameters, val genesisTx: Transaction, val network: Network, val rng: Random) { + + val alpha = parameters.alpha + val k = parameters.k + val beta1 = parameters.beta1 + val beta2 = parameters.beta2 + + val transactions = LinkedHashMap(mapOf(genesisTx.id to genesisTx)) + val queried = mutableSetOf(genesisTx.id) + val conflicts = mutableMapOf(genesisTx.data to ConflictSet(genesisTx, genesisTx, 0, 1)) + + val accepted = mutableSetOf(genesisTx.id) + val parentSets = mutableMapOf>() + + fun onGenerateTx(data: Int): Transaction { + val edges = parentSelection() + val t = Transaction(UUID.randomUUID(), data, edges.map { it.id }) + onReceiveTx(this, t) + return t + } + + fun onReceiveTx(sender: Node, tx: Transaction) { + if (transactions.contains(tx.id)) return + tx.chit = 0 + tx.confidence = 0 + + tx.parents.forEach { + if (!transactions.contains(it)) { + val t = sender.onSendTx(it) + onReceiveTx(sender, t) + } + } + + if (!conflicts.contains(tx.data)) { + conflicts[tx.data] = ConflictSet(tx, tx, 0, 1) + } else { + conflicts[tx.data]!!.size++ + } + + transactions[tx.id] = tx + } + + fun onSendTx(id: UUID): Transaction { + return transactions[id]!!.copy() + } + + fun onQuery(sender: Node, tx: Transaction): Int { + onReceiveTx(sender, tx) + return if (isStronglyPreferred(tx)) 1 + else 0 + } + + fun avalancheLoop() { + val txs = transactions.values.filterNot { queried.contains(it.id) } + txs.forEach { tx -> + val sample = network.nodes.filterNot { it == this }.shuffled(rng).take(k) + val res = sample.map { + val txCopy = tx.copy() + it.onQuery(this, txCopy) + }.sum() + if (res >= alpha * k) { + tx.chit = 1 + // Update the preference for ancestors. + parentSet(tx).forEach { p -> + p.confidence += 1 + } + parentSet(tx).forEach { p-> + val cs = conflicts[p.data]!! + if (p.confidence > cs.pref.confidence) { + cs.pref = p + } + if (p != cs.last) { + cs.last = p + cs.count = 0 + } else { + cs.count++ + } + } + } + queried.add(tx.id) + } + } + + fun isPreferred(tx: Transaction): Boolean { + return conflicts[tx.data]!!.pref == tx + } + + fun isStronglyPreferred(tx: Transaction): Boolean { + return parentSet(tx).map { isPreferred(it) }.all { it } + } + + fun isAccepted(tx: Transaction): Boolean { + if (accepted.contains(tx.id)) return true + if (!queried.contains(tx.id)) return false + + val cs = conflicts[tx.data]!! + val parentsAccepted = tx.parents.map { accepted.contains(it) }.all { it } + val isAccepted = (parentsAccepted && cs.size == 1 && tx.confidence > beta1) || + (cs.pref == tx && cs.count > beta2) + if (isAccepted) accepted.add(tx.id) + return isAccepted + } + + fun parentSet(tx: Transaction): Set { + + if (parentSets.contains(tx.id)) return parentSets[tx.id]!! + + val parents = mutableSetOf() + var ps = tx.parents.toSet() + while (ps.isNotEmpty()) { + ps.forEach { + if (transactions.contains(it)) parents.add(transactions[it]!!) + } + ps = ps.flatMap { + if (transactions.contains(it)) { + transactions[it]!!.parents + } else { + emptyList() + } + }.toSet() + } + parentSets[tx.id] = parents + return parents + } + + fun parentSelection(): List { + val eps0 = transactions.values.filter { isStronglyPreferred(it) } + val eps1 = eps0.filter { conflicts[it.data]!!.size == 1 || it.confidence > 0 } + val parents = eps1.flatMap { parentSet(it) }.toSet().filterNot { eps1.contains(it) } + val fallback = if (transactions.size == 1) listOf(genesisTx) + else transactions.values.reversed().take(10).filter { !isAccepted(it) && conflicts[it.data]!!.size == 1 }.shuffled(network.rng).take(3) + require(parents.isNotEmpty() || fallback.isNotEmpty()) { "Unable to select parents." } + return if (parents.isEmpty()) return fallback else parents + } + + fun dumpDag(f: File) { + f.printWriter().use { out -> + out.println("digraph G {") + transactions.values.forEach { + val color = if (isAccepted(it)) "color=lightblue; style=filled;" else "" + val pref = if (conflicts[it.data]!!.size > 1 && isPreferred(it)) "*" else "" + val chit = if (queried.contains(it.id)) it.chit.toString() else "?" + out.println("\"${it.id}\" [$color label=\"${it.data}$pref, $chit, ${it.confidence}\"];") + } + transactions.values.forEach { + it.parents.forEach { p-> + out.println("\"${it.id}\" -> \"$p\";") + } + } + out.println("}") + } + } +} diff --git a/experimental/avalanche/src/main/kotlin/net/corda/avalanche/Parameters.kt b/experimental/avalanche/src/main/kotlin/net/corda/avalanche/Parameters.kt new file mode 100644 index 0000000000..8d13fcf799 --- /dev/null +++ b/experimental/avalanche/src/main/kotlin/net/corda/avalanche/Parameters.kt @@ -0,0 +1,38 @@ +package net.corda.avalanche + +import picocli.CommandLine + +class Parameters { + @CommandLine.Option(names = ["-n", "--num-transactions"], description = ["How many transactions to generate (default: 20)"]) + var nrTransactions: Int = 20 + + @CommandLine.Option(names = ["-d", "--double-spend-ratio"], description = ["The double spend ratio (default: 0.02)"]) + var doubleSpendRatio: Double = 0.02 + + @CommandLine.Option(names = ["-a", "--alpha"], description = ["The alpha parameter (default: 0.8)"]) + var alpha = 0.8 + + @CommandLine.Option(names = ["--num-nodes"], description = ["The number of nodes (default: 50)"]) + var nrNodes = 50 + + @CommandLine.Option(names = ["-k", "--sample-size"], description = ["The sample size (default `1 + nrNodes / 10`)"]) + var k = 1 + nrNodes / 10 + + @CommandLine.Option(names = ["--beta1"], description = ["The beta1 parameter (default: 5)"]) + var beta1 = 5 + + @CommandLine.Option(names = ["--beta2"], description = ["The beta1 parameter (default: 5)"]) + var beta2 = 5 + + @CommandLine.Option(names = ["-h", "--help"], usageHelp = true, description = ["Display help and exit"]) + var helpRequested = false + + @CommandLine.Option(names = ["--seed"], description = ["The RNG seed (default: 23)"]) + var seed = 23L + + @CommandLine.Option(names = ["--dump-dags"], description = ["Dump DAGs in dot format (default: false)"]) + var dumpDags = false + + @CommandLine.Option(names = ["-v", "--verbose"], description=["Verbose mode (default: false)"]) + var verbose = false +} diff --git a/settings.gradle b/settings.gradle index a597018e24..d841212cb4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -16,6 +16,7 @@ include 'client:rpc' include 'webserver' include 'webserver:webcapsule' include 'experimental' +include 'experimental:avalanche' include 'experimental:behave' include 'experimental:sandbox' include 'experimental:quasar-hook'