commit ef72a5650176b6695595f6c947ad1b96e32b5762 Author: Hellings Date: Sat Sep 12 08:25:10 2026 -0400 branch(main): Initial commit. diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml new file mode 100644 index 0000000..f77de23 --- /dev/null +++ b/.forgejo/workflows/publish.yml @@ -0,0 +1,32 @@ +name: Build and publish + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "25" + - run: ./gradlew test + publish: + if: startsWith(gitea.ref, 'refs/tags/v') + needs: verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "25" + - run: ./gradlew -Pversion="${GITHUB_REF_NAME#v}" publish + env: + ORG_GRADLE_PROJECT_forgejoUsername: ${{ secrets.FORGEJO_USERNAME }} + ORG_GRADLE_PROJECT_forgejoPassword: ${{ secrets.FORGEJO_TOKEN }} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f91f646 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +# Binary files should be left untouched +*.jar binary + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ceb2542 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Ignore Gradle project-specific cache directory +.gradle/ + +# Ignore Gradle build output directory +build/ + +# Ignore Kotlin plugin data +.kotlin/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..39fe54e --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies { + testImplementation(kotlin("test")) +} diff --git a/app/src/main/kotlin/rip/crit/twist/App.kt b/app/src/main/kotlin/rip/crit/twist/App.kt new file mode 100644 index 0000000..e6dad38 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/App.kt @@ -0,0 +1,66 @@ +package rip.crit.twist + +import java.nio.file.Path +import java.util.concurrent.CountDownLatch +import rip.crit.twist.consensus.BasicConsensusEngine +import rip.crit.twist.consensus.ConsensusEngine +import rip.crit.twist.gossip.GossipService +import rip.crit.twist.gossip.InMemoryGossipService +import rip.crit.twist.p2p.PeerId +import rip.crit.twist.p2p.PeerNetwork +import rip.crit.twist.state.FileWorldState +import rip.crit.twist.state.WorldState +import rip.crit.twist.transport.TcpPeerNetwork + +class App( + val network: PeerNetwork, + val gossip: GossipService, + val consensus: ConsensusEngine, + val state: WorldState, +) { + fun start() { + network.start() + gossip.start() + consensus.start() + } + + fun stop() { + consensus.stop() + gossip.stop() + network.stop() + } +} + +fun main(args: Array) { + val root = Path.of(args.firstOrNull { !it.startsWith("--") } ?: "build/node") + val port = args.firstOrNull { it.startsWith("--port=") }?.substringAfter('=')?.toInt() ?: 0 + val network = TcpPeerNetwork(PeerId("node-${root.fileName}"), port) + val app = + App( + network, + InMemoryGossipService(), + BasicConsensusEngine(), + FileWorldState(root.resolve("state")), + ) + app.start() + args + .filter { it.startsWith("--peer=") } + .forEach { option -> + val (host, peerPort) = option.substringAfter('=').split(':', limit = 2) + network.connect(host, peerPort.toInt()) + } + println("Twist node started data=$root port=${network.port}") + if ("--once" in args) { + app.stop() + return + } + val stopped = CountDownLatch(1) + Runtime.getRuntime() + .addShutdownHook( + Thread { + app.stop() + stopped.countDown() + } + ) + stopped.await() +} diff --git a/app/src/main/kotlin/rip/crit/twist/access/AccessList.kt b/app/src/main/kotlin/rip/crit/twist/access/AccessList.kt new file mode 100644 index 0000000..d05dcbd --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/access/AccessList.kt @@ -0,0 +1,9 @@ +package rip.crit.twist.access + +import rip.crit.twist.core.Address + +data class AccessList(val reads: Set
= emptySet(), val writes: Set
= emptySet()) { + fun conflictsWith(other: AccessList): Boolean = + writes.intersect(other.reads + other.writes).isNotEmpty() || + other.writes.intersect(reads).isNotEmpty() +} diff --git a/app/src/main/kotlin/rip/crit/twist/bips/FileStorageContract.kt b/app/src/main/kotlin/rip/crit/twist/bips/FileStorageContract.kt new file mode 100644 index 0000000..1fd9651 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/bips/FileStorageContract.kt @@ -0,0 +1,35 @@ +package rip.crit.twist.bips + +import java.nio.file.Files +import java.nio.file.Path +import rip.crit.twist.core.Hash +import rip.crit.twist.core.Sha256 +import rip.crit.twist.proof.ProofOfStore + +class FileStorageContract(private val root: Path) : StorageContract { + init { + Files.createDirectories(root) + } + + override fun put(content: ByteArray): Hash { + val hash = Sha256.digest(content) + val target = pathFor(hash) + if (Files.notExists(target)) Files.write(target, content) + return hash + } + + override fun get(pointer: Hash): ByteArray? { + val path = pathFor(pointer) + if (Files.notExists(path)) return null + val content = Files.readAllBytes(path) + return content.takeIf { Sha256.digest(it) == pointer } + } + + fun prove(pointer: Hash, challenge: Hash): ProofOfStore? = + get(pointer)?.let { ProofOfStore.create(pointer, challenge) } + + private fun pathFor(hash: Hash): Path { + require(hash.value.matches(Regex("[0-9a-f]{64}"))) { "Invalid content hash" } + return root.resolve(hash.value) + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/bips/StorageContract.kt b/app/src/main/kotlin/rip/crit/twist/bips/StorageContract.kt new file mode 100644 index 0000000..435d2b9 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/bips/StorageContract.kt @@ -0,0 +1,23 @@ +package rip.crit.twist.bips + +import rip.crit.twist.core.Hash + +interface StorageContract { + fun put(content: ByteArray): Hash + + fun get(pointer: Hash): ByteArray? +} + +class FolderStorageContract(private val storage: StorageContract) : StorageContract { + val mounts: MutableMap = mutableMapOf() + + override fun put(content: ByteArray): Hash { + val hash = storage.put(content) + mounts[hash.toString()] = hash + return hash + } + + override fun get(pointer: Hash): ByteArray? { + return storage.get(pointer) + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/burn/BurnPolicy.kt b/app/src/main/kotlin/rip/crit/twist/burn/BurnPolicy.kt new file mode 100644 index 0000000..791b54e --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/burn/BurnPolicy.kt @@ -0,0 +1,7 @@ +package rip.crit.twist.burn + +import rip.crit.twist.core.Amount + +fun interface BurnPolicy { + fun amountFor(fee: Amount): Amount +} diff --git a/app/src/main/kotlin/rip/crit/twist/burn/Policies.kt b/app/src/main/kotlin/rip/crit/twist/burn/Policies.kt new file mode 100644 index 0000000..0eb9703 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/burn/Policies.kt @@ -0,0 +1,18 @@ +package rip.crit.twist.burn + +import java.math.BigInteger +import rip.crit.twist.core.Amount + +class FractionalBurnPolicy(private val numerator: Long, private val denominator: Long) : + BurnPolicy { + init { + require(numerator in 0..denominator && denominator > 0) + } + + override fun amountFor(fee: Amount): Amount = + Amount( + fee.value + .multiply(BigInteger.valueOf(numerator)) + .divide(BigInteger.valueOf(denominator)) + ) +} diff --git a/app/src/main/kotlin/rip/crit/twist/bytecode/TwistBytecode.kt b/app/src/main/kotlin/rip/crit/twist/bytecode/TwistBytecode.kt new file mode 100644 index 0000000..5d00980 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/bytecode/TwistBytecode.kt @@ -0,0 +1,87 @@ +package rip.crit.twist.bytecode + +import java.nio.ByteBuffer + +enum class OpCode(val code: Byte) { + STOP(0), + PUSH32(1), + ADD(2), + SUB(3), + CALLDATA_LOAD(4), + TLOAD(5), + TSTORE(6), + RETURN(7), + MUL(8), + DIV(9), + EQ(10), + LT(11), + GT(12), + AND(13), + OR(14), + NOT(15), + JUMP(16), + JUMPI(17), + DUP(18), + SWAP(19), + LOG(20), + CALL(21), + CREATE(22), + SELFDESTRUCT(23), + REVERT(24), + SLOAD(25), + SSTORE(26), + CALLER(27), + CALLVALUE(28), + TIMESTAMP(29), + BLOCK_NUMBER(30), + BALANCE(31), + SHA256(32), + MOD(33), + XOR(34), + ISZERO(35), + POP(36), + SHL(37), + SHR(38), + BYTE(39), + MLOAD(40), + MSTORE(41), + MSIZE(42), + CALLDATA_SIZE(43), + CODE_SIZE(44), + GAS(45), + ADDRESS(46), + ORIGIN(47), + CHAIN_ID(48), + INVALID(0xFF.toByte()), +} + +data class Instruction(val opCode: OpCode, val immediate: ByteArray = byteArrayOf()) { + init { + require(opCode != OpCode.PUSH32 || immediate.size == 32) + } +} + +object TwistBytecode { + fun encode(instructions: List): ByteArray = + ByteBuffer.allocate(instructions.sumOf { 1 + it.immediate.size }) + .also { output -> + instructions.forEach { + output.put(it.opCode.code) + output.put(it.immediate) + } + } + .array() + + fun decode(code: ByteArray): List { + val input = ByteBuffer.wrap(code) + val output = mutableListOf() + while (input.hasRemaining()) { + val code = input.get() + val op = OpCode.entries.firstOrNull { it.code == code } ?: error("Unknown opcode") + val immediate = + if (op == OpCode.PUSH32) ByteArray(32).also(input::get) else byteArrayOf() + output += Instruction(op, immediate) + } + return output + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/compiler/ContractIr.kt b/app/src/main/kotlin/rip/crit/twist/compiler/ContractIr.kt new file mode 100644 index 0000000..e33d4b8 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/compiler/ContractIr.kt @@ -0,0 +1,100 @@ +package rip.crit.twist.compiler + +import java.math.BigInteger +import rip.crit.twist.bytecode.Instruction +import rip.crit.twist.bytecode.OpCode +import rip.crit.twist.bytecode.TwistBytecode +import rip.crit.twist.tvm.Word256 + +enum class AccessOperation { + READ, + WRITE, + EXECUTE, +} + +data class AccessRule( + val operation: AccessOperation, + val resource: String, + val principals: Set, +) { + init { + require(resource.isNotBlank()) + require(principals.isNotEmpty()) + } +} + +data class IrAccessPolicy(val rules: List = emptyList()) { + fun allows(principal: String, operation: AccessOperation, resource: String): Boolean = + rules.any { + it.operation == operation && + (it.resource == resource || it.resource == "*") && + (principal in it.principals || "*" in it.principals) + } + + fun require(principal: String, operation: AccessOperation, resource: String) { + require(allows(principal, operation, resource)) { + "$principal lacks ${operation.name.lowercase()} access to $resource" + } + } +} + +data class ContractIr( + val name: String, + val functions: List, + val accessPolicy: IrAccessPolicy = IrAccessPolicy(), +) { + fun bytecode(): ByteArray = TwistBytecode.encode(functions.flatMap { it.instructions }) +} + +data class FunctionIr(val name: String, val selector: Int, val instructions: List) + +class ContractBuilder(private val name: String) { + private val functions = mutableListOf() + private val accessRules = mutableListOf() + + fun allow(operation: AccessOperation, resource: String, vararg principals: String) { + accessRules += AccessRule(operation, resource, principals.toSet()) + } + + fun function(name: String, selector: Int, body: FunctionBuilder.() -> Unit) { + require(functions.none { it.selector == selector }) { "Duplicate function selector" } + functions += FunctionIr(name, selector, FunctionBuilder().apply(body).build()) + } + + fun build(): ContractIr = ContractIr(name, functions.toList(), IrAccessPolicy(accessRules)) +} + +class FunctionBuilder { + private val instructions = mutableListOf() + + fun push(value: BigInteger) = emit(OpCode.PUSH32, Word256.of(value).toBytes()) + + fun push(value: Long) = push(BigInteger.valueOf(value)) + + fun op(code: OpCode) = emit(code) + + fun calldataLoad() = op(OpCode.CALLDATA_LOAD) + + fun storageLoad() = op(OpCode.SLOAD) + + fun storageStore() = op(OpCode.SSTORE) + + fun caller() = op(OpCode.CALLER) + + fun callValue() = op(OpCode.CALLVALUE) + + fun emitLog() = op(OpCode.LOG) + + fun returnWord() = op(OpCode.RETURN) + + fun revert() = op(OpCode.REVERT) + + internal fun build(): List = instructions.toList() + + private fun emit(code: OpCode, immediate: ByteArray = byteArrayOf()) { + instructions += Instruction(code, immediate) + } +} + +fun contract(name: String, body: ContractBuilder.() -> Unit): ContractIr = + ContractBuilder(name).apply(body).build() diff --git a/app/src/main/kotlin/rip/crit/twist/compiler/LispIrCompiler.kt b/app/src/main/kotlin/rip/crit/twist/compiler/LispIrCompiler.kt new file mode 100644 index 0000000..5375eea --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/compiler/LispIrCompiler.kt @@ -0,0 +1,166 @@ +package rip.crit.twist.compiler + +import java.math.BigInteger +import rip.crit.twist.bytecode.Instruction +import rip.crit.twist.bytecode.OpCode +import rip.crit.twist.tvm.Word256 + +class LispIrCompiler { + fun parse(source: String): ContractIr { + val forms = Reader(source).readAll() + require(forms.size == 1) { "Expected one contract form" } + val contract = forms.single().list("contract") + require(contract.size >= 2) + val name = contract[1].atom() + val rules = mutableListOf() + val functions = mutableListOf() + contract.drop(2).forEach { form -> + val values = form.list() + when (values.firstOrNull()?.atom()) { + "access" -> rules += parseAccess(values.drop(1)) + "function" -> functions += parseFunction(values) + else -> error("Unknown contract form ${values.firstOrNull()}") + } + } + require(functions.map { it.selector }.distinct().size == functions.size) { + "Duplicate function selector" + } + return ContractIr(name, functions, IrAccessPolicy(rules)) + } + + fun compile(source: String, principal: String): ByteArray { + val ir = parse(source) + ir.functions.forEach { function -> + ir.accessPolicy.require(principal, AccessOperation.EXECUTE, "function:${function.name}") + function.instructions.forEach { instruction -> + when (instruction.opCode) { + OpCode.SLOAD -> + ir.accessPolicy.require(principal, AccessOperation.READ, "storage:*") + + OpCode.SSTORE -> + ir.accessPolicy.require(principal, AccessOperation.WRITE, "storage:*") + + else -> Unit + } + } + } + return ir.bytecode() + } + + private fun parseAccess(forms: List): List = forms.map { form -> + val values = form.list() + require(values.size >= 3) { "Access rule needs an operation, resource, and principal" } + AccessRule( + AccessOperation.valueOf(values[0].atom().uppercase()), + values[1].atom(), + values.drop(2).map(SExpr::atom).toSet(), + ) + } + + private fun parseFunction(values: List): FunctionIr { + require(values.size >= 4) { "Function needs a name, selector, and body" } + val instructions = values.drop(3).map(::instruction) + return FunctionIr(values[1].atom(), values[2].atom().toInt(), instructions) + } + + private fun instruction(form: SExpr): Instruction { + val values = form.list() + val name = values.first().atom().replace('-', '_').uppercase() + val opcode = OpCode.entries.firstOrNull { it.name == name } ?: error("Unknown IR op $name") + return if (opcode == OpCode.PUSH32) { + require(values.size == 2) { "push32 needs one integer" } + Instruction(opcode, Word256.of(BigInteger(values[1].atom())).toBytes()) + } else { + require(values.size == 1) { "$name takes no operands" } + Instruction(opcode) + } + } +} + +private sealed interface SExpr { + data class Atom(val value: String) : SExpr + + data class Form(val values: List) : SExpr + + fun atom(): String = (this as? Atom)?.value ?: error("Expected atom") + + fun list(expectedHead: String? = null): List = + (this as? Form)?.values?.also { + if (expectedHead != null) + require(it.firstOrNull()?.atom() == expectedHead) { "Expected $expectedHead form" } + } ?: error("Expected list") +} + +private class Reader(source: String) { + private val tokens = tokenize(source) + private var position = 0 + + fun readAll(): List = buildList { while (position < tokens.size) add(read()) } + + private fun read(): SExpr { + require(position < tokens.size) { "Unexpected end of input" } + return when (val token = tokens[position++]) { + "(" -> { + val children = mutableListOf() + while (tokens.getOrNull(position) != ")") children += read() + require(position < tokens.size) { "Unclosed list" } + position++ + SExpr.Form(children) + } + + ")" -> error("Unexpected closing parenthesis") + else -> SExpr.Atom(token) + } + } + + private companion object { + fun tokenize(source: String): List { + val output = mutableListOf() + var index = 0 + while (index < source.length) { + when { + source[index].isWhitespace() -> index++ + source[index] == ';' -> { + while (index < source.length && source[index] != '\n') index++ + } + + source[index] == '(' || source[index] == ')' -> + output.add(source[index++].toString()) + + source[index] == '"' -> { + index++ + val value = StringBuilder() + while (index < source.length && source[index] != '"') { + if (source[index] == '\\') { + index++ + require(index < source.length) + value.append( + when (source[index]) { + 'n' -> '\n' + 't' -> '\t' + else -> source[index] + } + ) + } else value.append(source[index]) + index++ + } + require(index < source.length) { "Unclosed string" } + index++ + output += value.toString() + } + + else -> { + val start = index + while ( + index < source.length && + !source[index].isWhitespace() && + source[index] !in "();" + ) index++ + output += source.substring(start, index).removePrefix(":") + } + } + } + return output + } + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/compiler/TwistCompiler.kt b/app/src/main/kotlin/rip/crit/twist/compiler/TwistCompiler.kt new file mode 100644 index 0000000..bcf9b10 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/compiler/TwistCompiler.kt @@ -0,0 +1,43 @@ +package rip.crit.twist.compiler + +import rip.crit.twist.bytecode.Instruction +import rip.crit.twist.bytecode.OpCode +import rip.crit.twist.bytecode.TwistBytecode +import rip.crit.twist.jit.LLVMBuilder +import rip.crit.twist.tvm.Word256 + +object ContractPrelude { + const val SOURCE = + "(pragma twist \"1.0\")\n;; calldata is read with CALLDATA_LOAD and temporary state with TLOAD/TSTORE" +} + +class TwistCompiler { + fun compile(source: String): ByteArray = + TwistBytecode.encode(source.lineSequence().mapNotNull(::instruction).toList()) + + fun lowerToLlvm(source: String, moduleName: String = "contract"): LLVMBuilder = + LLVMBuilder(moduleName, ContractPrelude.SOURCE + "\n" + source) + + private fun instruction(line: String): Instruction? { + val tokens = line.trim().split(Regex("\\s+")) + if (tokens.isEmpty() || tokens[0].isBlank() || tokens[0].startsWith(";")) return null + return when (tokens[0].uppercase()) { + "PUSH32" -> + Instruction( + OpCode.PUSH32, + Word256.of( + java.math.BigInteger( + tokens.getOrElse(1) { error("PUSH32 needs a value") } + ) + ) + .toBytes(), + ) + + else -> + Instruction( + OpCode.entries.firstOrNull { it.name == tokens[0].uppercase() } + ?: error("Unknown instruction ${tokens[0]}") + ) + } + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/consensus/BasicConsensusEngine.kt b/app/src/main/kotlin/rip/crit/twist/consensus/BasicConsensusEngine.kt new file mode 100644 index 0000000..7320286 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/consensus/BasicConsensusEngine.kt @@ -0,0 +1,36 @@ +package rip.crit.twist.consensus + +import rip.crit.twist.core.Block +import rip.crit.twist.core.Hash + +/** Structural consensus gate. Signature and committee policies compose above this layer. */ +class BasicConsensusEngine(private val genesisParent: Hash = Hash("genesis")) : ConsensusEngine { + private var running = false + private var tipHeight = -1L + private var tipHash: Hash? = null + + override fun start() { + running = true + } + + override fun stop() { + running = false + } + + override fun validate(block: Block): ValidationResult = + synchronized(this) { + if (!running) return ValidationResult(false, "Consensus engine is stopped") + if (block.header.height < 0 || block.header.timestampMillis < 0) + return ValidationResult(false, "Invalid header") + if (tipHash == null && block.header.parentHash != genesisParent) + return ValidationResult(false, "Invalid genesis parent") + if ( + tipHash != null && + (block.header.parentHash != tipHash || block.header.height != tipHeight + 1) + ) + return ValidationResult(false, "Block does not extend canonical tip") + tipHash = block.hash + tipHeight = block.header.height + ValidationResult(true) + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/consensus/ConsensusEngine.kt b/app/src/main/kotlin/rip/crit/twist/consensus/ConsensusEngine.kt new file mode 100644 index 0000000..b614b1c --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/consensus/ConsensusEngine.kt @@ -0,0 +1,13 @@ +package rip.crit.twist.consensus + +import rip.crit.twist.core.Block + +interface ConsensusEngine { + fun start() + + fun stop() + + fun validate(block: Block): ValidationResult +} + +data class ValidationResult(val accepted: Boolean, val reason: String? = null) diff --git a/app/src/main/kotlin/rip/crit/twist/core/HmacSha256.kt b/app/src/main/kotlin/rip/crit/twist/core/HmacSha256.kt new file mode 100644 index 0000000..f65f884 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/core/HmacSha256.kt @@ -0,0 +1,10 @@ +package rip.crit.twist.core + +object HmacSha256 { + fun digest(key: ByteArray, message: ByteArray): ByteArray { + val normalized = (if (key.size > 64) Sha256.bytes(key) else key).copyOf(64) + val inner = ByteArray(64) { (normalized[it].toInt() xor 0x36).toByte() } + val outer = ByteArray(64) { (normalized[it].toInt() xor 0x5c).toByte() } + return Sha256.bytes(outer + Sha256.bytes(inner + message)) + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/core/Sha256.kt b/app/src/main/kotlin/rip/crit/twist/core/Sha256.kt new file mode 100644 index 0000000..996a7d0 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/core/Sha256.kt @@ -0,0 +1,170 @@ +package rip.crit.twist.core + +@SmartDoc( + summary = "Pure Kotlin SHA-256 digest API.", + category = "Cryptography", + stability = "stable", +) +object Sha256 { + fun bytes(bytes: ByteArray): ByteArray = Sha256Digest.digest(bytes) + + fun digest(bytes: ByteArray): Hash = Hash(bytes(bytes).toHex()) + + fun digestUtf8(value: String): Hash = digest(value.encodeToByteArray()) + + fun combine(left: Hash, right: Hash): Hash = + digest((left.value + right.value).encodeToByteArray()) + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } +} + +/** Straightforward FIPS 180-4 SHA-256. Written for clarity, not side-channel resistance. */ +object Sha256Digest { + private val initial = + intArrayOf( + 0x6a09e667, + 0xbb67ae85.toInt(), + 0x3c6ef372, + 0xa54ff53a.toInt(), + 0x510e527f, + 0x9b05688c.toInt(), + 0x1f83d9ab, + 0x5be0cd19, + ) + private val constants = + intArrayOf( + 0x428a2f98, + 0x71374491, + 0xb5c0fbcf.toInt(), + 0xe9b5dba5.toInt(), + 0x3956c25b, + 0x59f111f1, + 0x923f82a4.toInt(), + 0xab1c5ed5.toInt(), + 0xd807aa98.toInt(), + 0x12835b01, + 0x243185be, + 0x550c7dc3, + 0x72be5d74, + 0x80deb1fe.toInt(), + 0x9bdc06a7.toInt(), + 0xc19bf174.toInt(), + 0xe49b69c1.toInt(), + 0xefbe4786.toInt(), + 0x0fc19dc6, + 0x240ca1cc, + 0x2de92c6f, + 0x4a7484aa, + 0x5cb0a9dc, + 0x76f988da, + 0x983e5152.toInt(), + 0xa831c66d.toInt(), + 0xb00327c8.toInt(), + 0xbf597fc7.toInt(), + 0xc6e00bf3.toInt(), + 0xd5a79147.toInt(), + 0x06ca6351, + 0x14292967, + 0x27b70a85, + 0x2e1b2138, + 0x4d2c6dfc, + 0x53380d13, + 0x650a7354, + 0x766a0abb, + 0x81c2c92e.toInt(), + 0x92722c85.toInt(), + 0xa2bfe8a1.toInt(), + 0xa81a664b.toInt(), + 0xc24b8b70.toInt(), + 0xc76c51a3.toInt(), + 0xd192e819.toInt(), + 0xd6990624.toInt(), + 0xf40e3585.toInt(), + 0x106aa070, + 0x19a4c116, + 0x1e376c08, + 0x2748774c, + 0x34b0bcb5, + 0x391c0cb3, + 0x4ed8aa4a, + 0x5b9cca4f, + 0x682e6ff3, + 0x748f82ee, + 0x78a5636f, + 0x84c87814.toInt(), + 0x8cc70208.toInt(), + 0x90befffa.toInt(), + 0xa4506ceb.toInt(), + 0xbef9a3f7.toInt(), + 0xc67178f2.toInt(), + ) + + fun digest(message: ByteArray): ByteArray { + val bitLength = message.size.toLong() * 8 + val padding = (56 - (message.size + 1) % 64 + 64) % 64 + val input = ByteArray(message.size + 1 + padding + 8) + message.copyInto(input) + input[message.size] = 0x80.toByte() + for (i in 0..7) input[input.lastIndex - i] = (bitLength ushr (8 * i)).toByte() + val hash = initial.copyOf() + val words = IntArray(64) + for (offset in input.indices step 64) { + for (i in 0 until 16) { + val p = offset + i * 4 + words[i] = + ((input[p].toInt() and 0xff) shl 24) or + ((input[p + 1].toInt() and 0xff) shl 16) or + ((input[p + 2].toInt() and 0xff) shl 8) or + (input[p + 3].toInt() and 0xff) + } + for (i in 16 until 64) { + val s0 = + words[i - 15].rotateRight(7) xor + words[i - 15].rotateRight(18) xor + (words[i - 15] ushr 3) + val s1 = + words[i - 2].rotateRight(17) xor + words[i - 2].rotateRight(19) xor + (words[i - 2] ushr 10) + words[i] = words[i - 16] + s0 + words[i - 7] + s1 + } + var a = hash[0] + var b = hash[1] + var c = hash[2] + var d = hash[3] + var e = hash[4] + var f = hash[5] + var g = hash[6] + var h = hash[7] + for (i in 0 until 64) { + val sum1 = e.rotateRight(6) xor e.rotateRight(11) xor e.rotateRight(25) + val choice = (e and f) xor (e.inv() and g) + val temporary1 = h + sum1 + choice + constants[i] + words[i] + val sum0 = a.rotateRight(2) xor a.rotateRight(13) xor a.rotateRight(22) + val majority = (a and b) xor (a and c) xor (b and c) + val temporary2 = sum0 + majority + h = g + g = f + f = e + e = d + temporary1 + d = c + c = b + b = a + a = temporary1 + temporary2 + } + hash[0] += a + hash[1] += b + hash[2] += c + hash[3] += d + hash[4] += e + hash[5] += f + hash[6] += g + hash[7] += h + } + return ByteArray(32).also { output -> + hash.forEachIndexed { i, value -> + for (j in 0..3) output[i * 4 + j] = (value ushr (24 - j * 8)).toByte() + } + } + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/core/SmartDoc.kt b/app/src/main/kotlin/rip/crit/twist/core/SmartDoc.kt new file mode 100644 index 0000000..57e82ae --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/core/SmartDoc.kt @@ -0,0 +1,10 @@ +package rip.crit.twist.core + +/** Marks a public API for inclusion in generated SmartDoc reference pages. */ +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.SOURCE) +annotation class SmartDoc( + val summary: String, + val category: String, + val stability: String = "experimental", +) diff --git a/app/src/main/kotlin/rip/crit/twist/core/TransactionHasher.kt b/app/src/main/kotlin/rip/crit/twist/core/TransactionHasher.kt new file mode 100644 index 0000000..27fd662 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/core/TransactionHasher.kt @@ -0,0 +1,56 @@ +package rip.crit.twist.core + +import java.nio.ByteBuffer + +object TransactionHasher { + fun hash( + sender: Address, + recipient: Address?, + nonce: Long, + value: Amount, + payload: ByteArray, + ): Hash { + val recipientBytes = recipient?.value?.encodeToByteArray() ?: byteArrayOf() + val senderBytes = sender.value.encodeToByteArray() + val valueBytes = value.value.toByteArray() + val buffer = + ByteBuffer.allocate( + 8 + + 4 + + senderBytes.size + + 4 + + recipientBytes.size + + 4 + + valueBytes.size + + 4 + + payload.size + ) + buffer.putLong(nonce) + buffer.putSized(senderBytes) + buffer.putSized(recipientBytes) + buffer.putSized(valueBytes) + buffer.putSized(payload) + return Sha256.digest(buffer.array()) + } + + fun create( + sender: Address, + recipient: Address?, + nonce: Long, + value: Amount, + payload: ByteArray = byteArrayOf(), + ): Transaction = + Transaction( + hash(sender, recipient, nonce, value, payload), + sender, + recipient, + nonce, + value, + payload.copyOf(), + ) + + private fun ByteBuffer.putSized(value: ByteArray) { + putInt(value.size) + put(value) + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/core/TwistSpecific.kt b/app/src/main/kotlin/rip/crit/twist/core/TwistSpecific.kt new file mode 100644 index 0000000..a734ae4 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/core/TwistSpecific.kt @@ -0,0 +1,6 @@ +package rip.crit.twist.core + +/** Explicit capability required to enable Twist-only, non-standard protocols. */ +sealed class TwistSpecific private constructor() { + data object Enabled : TwistSpecific() +} diff --git a/app/src/main/kotlin/rip/crit/twist/core/Types.kt b/app/src/main/kotlin/rip/crit/twist/core/Types.kt new file mode 100644 index 0000000..bdfa5b8 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/core/Types.kt @@ -0,0 +1,71 @@ +package rip.crit.twist.core + +import java.math.BigInteger + +@JvmInline +value class Address(val value: String) { + init { + require(value.isNotBlank()) + } +} + +@JvmInline +value class Hash(val value: String) { + init { + require(value.isNotBlank()) + } + companion object { + val ZERO = Hash("00000000000000000000000000000000") + } +} + +@JvmInline +value class Amount(val value: BigInteger) { + init { + require(value >= BigInteger.ZERO) + } +} + +data class Transaction( + val id: Hash, + val sender: Address, + val recipient: Address?, + val nonce: Long, + val value: Amount, + val payload: ByteArray = byteArrayOf(), +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Transaction + + if (nonce != other.nonce) return false + if (id != other.id) return false + if (sender != other.sender) return false + if (recipient != other.recipient) return false + if (value != other.value) return false + if (!payload.contentEquals(other.payload)) return false + + return true + } + + override fun hashCode(): Int { + var result = nonce.hashCode() + result = 31 * result + id.hashCode() + result = 31 * result + sender.hashCode() + result = 31 * result + recipient.hashCode() + result = 31 * result + value.hashCode() + result = 31 * result + payload.contentHashCode() + return result + } +} + +data class BlockHeader( + val parentHash: Hash, + val stateRoot: Hash, + val height: Long, + val timestampMillis: Long, +) + +data class Block(val header: BlockHeader, val transactions: List, val hash: Hash) diff --git a/app/src/main/kotlin/rip/crit/twist/did/Did.kt b/app/src/main/kotlin/rip/crit/twist/did/Did.kt new file mode 100644 index 0000000..258f84e --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/did/Did.kt @@ -0,0 +1,201 @@ +package rip.crit.twist.did + +import java.math.BigInteger +import java.time.Clock +import java.time.Duration +import rip.crit.twist.core.Hash +import rip.crit.twist.core.Sha256 +import rip.crit.twist.core.SmartDoc +import rip.crit.twist.core.TwistSpecific +import rip.crit.twist.p2p.DistributedHashTable +import rip.crit.twist.p2p.PeerId +import rip.crit.twist.p2p.PeerRecord +import rip.crit.twist.store.KeyValueStore + +@JvmInline +value class Did(val value: String) { + init { + require(value.matches(Regex("did:[a-z0-9]+:[A-Za-z0-9._:%-]+"))) { "Invalid DID" } + } + + val method: String + get() = value.substringAfter("did:").substringBefore(':') + + val methodSpecificId: String + get() = value.substringAfter("did:$method:") +} + +data class VerificationMethod( + val id: String, + val type: String, + val controller: Did, + val publicKeyMultibase: String, +) + +data class DidService(val id: String, val type: String, val endpoint: String) + +data class DidDocument( + val id: Did, + val verificationMethods: List, + val authentication: List = verificationMethods.map { it.id }, + val assertionMethod: List = verificationMethods.map { it.id }, + val services: List = emptyList(), +) { + fun toJson(): String = buildString { + append("{\"@context\":[\"https://www.w3.org/ns/did/v1.1\"],\"id\":\"") + append(id.value.escape()) + append("\",\"verificationMethod\":[") + append( + verificationMethods.joinToString(",") { + "{\"id\":\"${it.id.escape()}\",\"type\":\"${it.type.escape()}\"," + + "\"controller\":\"${it.controller.value.escape()}\"," + + "\"publicKeyMultibase\":\"${it.publicKeyMultibase.escape()}\"}" + } + ) + append("],\"authentication\":${strings(authentication)}") + append(",\"assertionMethod\":${strings(assertionMethod)}") + append( + ",\"service\":[${services.joinToString(",") { "{\"id\":\"${it.id.escape()}\",\"type\":\"${it.type.escape()}\",\"serviceEndpoint\":\"${it.endpoint.escape()}\"}" }}]" + ) + append('}') + } + + private fun strings(values: List) = + values.joinToString(",", "[", "]") { "\"${it.escape()}\"" } + + private fun String.escape() = replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") +} + +enum class KeyCodec(val multicodec: Int) { + X25519(0xec), + ED25519(0xed), + SECP256K1(0xe7), +} + +@SmartDoc(summary = "Generative did:key identifiers and DID documents.", category = "Identity") +object DidKey { + fun create(publicKey: ByteArray, codec: KeyCodec): Did { + require(publicKey.isNotEmpty()) + return Did("did:key:z${Base58.encode(varint(codec.multicodec) + publicKey)}") + } + + fun resolve(did: Did): DidDocument { + require(did.method == "key") + val multibase = did.methodSpecificId + require(multibase.startsWith('z')) { "Only base58-btc did:key values are supported" } + val decoded = Base58.decode(multibase.drop(1)) + val (codec, prefixSize) = readVarint(decoded) + require(KeyCodec.entries.any { it.multicodec == codec }) { "Unsupported key multicodec" } + require(decoded.size > prefixSize) { "Missing public key" } + val keyId = "${did.value}#$multibase" + return DidDocument( + did, + listOf(VerificationMethod(keyId, "Multikey", did, multibase)), + ) + } + + private fun varint(value: Int): ByteArray { + var remaining = value + val bytes = mutableListOf() + do { + var next = remaining and 0x7f + remaining = remaining ushr 7 + if (remaining != 0) next = next or 0x80 + bytes += next.toByte() + } while (remaining != 0) + return bytes.toByteArray() + } + + private fun readVarint(bytes: ByteArray): Pair { + var value = 0 + var shift = 0 + for (index in 0 until minOf(bytes.size, 5)) { + val current = bytes[index].toInt() and 0xff + value = value or ((current and 0x7f) shl shift) + if (current and 0x80 == 0) return value to index + 1 + shift += 7 + } + error("Invalid multicodec varint") + } +} + +/** Project-specific DID method resolved through the Twist peer DHT. */ +@SmartDoc(summary = "DHT-backed project-specific did:twist resolver.", category = "Identity") +class DidTwist( + @Suppress("UNUSED_PARAMETER") twistSpecific: TwistSpecific.Enabled, + private val dht: DistributedHashTable, + private val documents: KeyValueStore, + private val clock: Clock = Clock.systemUTC(), +) { + fun create( + peer: PeerId, + publicKey: ByteArray, + endpoints: Set, + ttl: Duration, + codec: KeyCodec = KeyCodec.X25519, + ): Did { + require(endpoints.isNotEmpty()) + val fingerprint = Sha256.digest(peer.value.encodeToByteArray() + publicKey).value + val did = Did("did:twist:$fingerprint") + dht.put(key(did), PeerRecord(peer, endpoints, clock.instant().plus(ttl))) + documents.put( + did.value.encodeToByteArray(), + byteArrayOf(codec.ordinal.toByte()) + publicKey, + ) + return did + } + + fun resolve(did: Did): DidDocument? { + require(did.method == "twist") + val record = dht.get(key(did)) ?: return null + val stored = documents.get(did.value.encodeToByteArray()) ?: return null + if (stored.size < 2) return null + val codec = KeyCodec.entries.getOrNull(stored[0].toInt()) ?: return null + val multibase = DidKey.create(stored.copyOfRange(1, stored.size), codec).methodSpecificId + val keyId = "${did.value}#$multibase" + return DidDocument( + did, + listOf(VerificationMethod(keyId, "Multikey", did, multibase)), + listOf(keyId), + listOf(keyId), + record.addresses.sorted().mapIndexed { index, endpoint -> + DidService("${did.value}#overlay-$index", "TwistOverlay", endpoint) + }, + ) + } + + private fun key(did: Did): Hash = Sha256.digestUtf8(did.value) +} + +internal object Base58 { + private const val ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + + fun encode(bytes: ByteArray): String { + if (bytes.isEmpty()) return "" + var value = BigInteger(1, bytes) + val output = StringBuilder() + val radix = BigInteger.valueOf(58) + while (value.signum() > 0) { + val parts = value.divideAndRemainder(radix) + output.append(ALPHABET[parts[1].toInt()]) + value = parts[0] + } + bytes.takeWhile { it == 0.toByte() }.forEach { _ -> output.append('1') } + return output.reverse().toString() + } + + fun decode(value: String): ByteArray { + require(value.isNotEmpty()) + var number = BigInteger.ZERO + value.forEach { character -> + val digit = ALPHABET.indexOf(character) + require(digit >= 0) { "Invalid base58-btc character" } + number = number * BigInteger.valueOf(58) + BigInteger.valueOf(digit.toLong()) + } + val raw = + number.toByteArray().let { + if (it.size > 1 && it[0] == 0.toByte()) it.drop(1).toByteArray() else it + } + return ByteArray(value.takeWhile { it == '1' }.length) + raw + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/did/DidMethodRegistry.kt b/app/src/main/kotlin/rip/crit/twist/did/DidMethodRegistry.kt new file mode 100644 index 0000000..9196d3e --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/did/DidMethodRegistry.kt @@ -0,0 +1,25 @@ +package rip.crit.twist.did + +import rip.crit.twist.core.TwistSpecific + +fun interface DidResolver { + fun resolve(did: Did): DidDocument? +} + +class DidMethodRegistry private constructor(private val resolvers: Map) { + fun resolve(did: Did): DidDocument? = resolvers[did.method]?.resolve(did) + + companion object { + fun create( + twistSpecific: TwistSpecific? = null, + twist: DidTwist? = null, + ): DidMethodRegistry { + require(twist == null || twistSpecific === TwistSpecific.Enabled) { + "did:twist registration requires TwistSpecific.Enabled" + } + val methods = mutableMapOf("key" to DidResolver(DidKey::resolve)) + if (twist != null) methods["twist"] = DidResolver(twist::resolve) + return DidMethodRegistry(methods) + } + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/ecdsa/Ecdsa.kt b/app/src/main/kotlin/rip/crit/twist/ecdsa/Ecdsa.kt new file mode 100644 index 0000000..6c29374 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/ecdsa/Ecdsa.kt @@ -0,0 +1,7 @@ +package rip.crit.twist.ecdsa + +interface Ecdsa { + fun sign(message: ByteArray, privateKey: ByteArray): ByteArray + + fun verify(message: ByteArray, signature: ByteArray, publicKey: ByteArray): Boolean +} diff --git a/app/src/main/kotlin/rip/crit/twist/ecdsa/Secp256k1Ecdsa.kt b/app/src/main/kotlin/rip/crit/twist/ecdsa/Secp256k1Ecdsa.kt new file mode 100644 index 0000000..2a10219 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/ecdsa/Secp256k1Ecdsa.kt @@ -0,0 +1,134 @@ +package rip.crit.twist.ecdsa + +import java.math.BigInteger +import rip.crit.twist.core.HmacSha256 +import rip.crit.twist.core.Sha256 +import rip.crit.twist.core.SmartDoc + +/** + * secp256k1 ECDSA with RFC 6979 nonces and raw 32-byte private, 65-byte public, and 64-byte + * signature encodings. + */ +@SmartDoc( + summary = "Deterministic secp256k1 ECDSA signing and verification.", + category = "Cryptography", +) +class Secp256k1Ecdsa : Ecdsa { + private data class Point(val x: BigInteger, val y: BigInteger) + + private val p = + BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F", 16) + private val n = + BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16) + private val g = + Point( + BigInteger("79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798", 16), + BigInteger("483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8", 16), + ) + + fun publicKey(privateKey: ByteArray): ByteArray { + val d = scalar(privateKey) + val q = multiply(g, d)!! + return byteArrayOf(4) + fixed(q.x) + fixed(q.y) + } + + override fun sign(message: ByteArray, privateKey: ByteArray): ByteArray { + val d = scalar(privateKey) + val z = BigInteger(1, Sha256.bytes(message)) + var k = nonce(d, z) + while (true) { + val r = multiply(g, k)!!.x.mod(n) + if (r != BigInteger.ZERO) { + var s = k.modInverse(n).multiply(z + r * d).mod(n) + if (s != BigInteger.ZERO) { + if (s > n.shiftRight(1)) s = n - s + return fixed(r) + fixed(s) + } + } + k = k.add(BigInteger.ONE).mod(n) + } + } + + override fun verify(message: ByteArray, signature: ByteArray, publicKey: ByteArray): Boolean = + runCatching { + require(signature.size == 64) + val r = BigInteger(1, signature.copyOfRange(0, 32)) + val s = BigInteger(1, signature.copyOfRange(32, 64)) + if (r <= BigInteger.ZERO || r >= n || s <= BigInteger.ZERO || s >= n) return false + val q = decode(publicKey) + val z = BigInteger(1, Sha256.bytes(message)) + val w = s.modInverse(n) + val point = add(multiply(g, z * w % n), multiply(q, r * w % n)) ?: return false + point.x.mod(n) == r + } + .getOrDefault(false) + + private fun scalar(bytes: ByteArray): BigInteger { + require(bytes.size == 32) + return BigInteger(1, bytes).also { require(it > BigInteger.ZERO && it < n) } + } + + private fun decode(bytes: ByteArray): Point { + require(bytes.size == 65 && bytes[0].toInt() == 4) + val q = + Point(BigInteger(1, bytes.copyOfRange(1, 33)), BigInteger(1, bytes.copyOfRange(33, 65))) + require( + q.x.modPow(BigInteger.valueOf(3), p).add(BigInteger.valueOf(7)).mod(p) == + q.y.modPow(BigInteger.TWO, p) + ) + return q + } + + private fun add(a: Point?, b: Point?): Point? { + if (a == null) return b + if (b == null) return a + if (a.x == b.x && a.y.add(b.y).mod(p) == BigInteger.ZERO) return null + val slope = + if (a == b) + a.x + .pow(2) + .multiply(BigInteger.valueOf(3)) + .multiply(a.y.multiply(BigInteger.TWO).modInverse(p)) + else b.y.subtract(a.y).multiply(b.x.subtract(a.x).mod(p).modInverse(p)) + val x = slope.pow(2).subtract(a.x).subtract(b.x).mod(p) + return Point(x, slope.multiply(a.x - x).subtract(a.y).mod(p)) + } + + private fun multiply(point: Point?, scalar: BigInteger): Point? { + var result: Point? = null + var addend = point + for (i in 0 until scalar.bitLength()) { + if (scalar.testBit(i)) result = add(result, addend) + addend = add(addend, addend) + } + return result + } + + private fun nonce(d: BigInteger, z: BigInteger): BigInteger { + var v = ByteArray(32) { 1 } + var k = ByteArray(32) + val seed = fixed(d) + fixed(z.mod(n)) + k = HmacSha256.digest(k, v + byteArrayOf(0) + seed) + v = HmacSha256.digest(k, v) + k = HmacSha256.digest(k, v + byteArrayOf(1) + seed) + v = HmacSha256.digest(k, v) + while (true) { + v = HmacSha256.digest(k, v) + val candidate = BigInteger(1, v) + if (candidate > BigInteger.ZERO && candidate < n) return candidate + k = HmacSha256.digest(k, v + byteArrayOf(0)) + v = HmacSha256.digest(k, v) + } + } + + private fun fixed(value: BigInteger): ByteArray { + val bytes = value.toByteArray() + return ByteArray(32).also { + bytes.copyInto( + it, + (32 - bytes.size).coerceAtLeast(0), + (bytes.size - 32).coerceAtLeast(0), + ) + } + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/gas/BasicGasMeter.kt b/app/src/main/kotlin/rip/crit/twist/gas/BasicGasMeter.kt new file mode 100644 index 0000000..8152248 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/gas/BasicGasMeter.kt @@ -0,0 +1,16 @@ +package rip.crit.twist.gas + +class OutOfGasException(message: String) : IllegalStateException(message) + +class BasicGasMeter(override val limit: GasLimit) : GasMeter { + private var consumed = 0L + override val used: GasUsed + get() = GasUsed(consumed) + + override fun consume(units: Long) { + require(units >= 0) { "Gas units cannot be negative" } + if (units > limit.value - consumed) + throw OutOfGasException("Gas limit of ${limit.value} exceeded") + consumed += units + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/gas/Gas.kt b/app/src/main/kotlin/rip/crit/twist/gas/Gas.kt new file mode 100644 index 0000000..b8817c8 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/gas/Gas.kt @@ -0,0 +1,29 @@ +package rip.crit.twist.gas + +import java.math.BigInteger + +data class GasLimit(val value: Long) { + init { + require(value >= 0) + } +} + +data class GasUsed(val value: Long) { + init { + require(value >= 0) + } +} + +data class GasPrice(val value: BigInteger) { + init { + require(value >= BigInteger.ZERO) + } +} + +interface GasMeter { + val limit: GasLimit + + val used: GasUsed + + fun consume(units: Long) +} diff --git a/app/src/main/kotlin/rip/crit/twist/gossip/GossipService.kt b/app/src/main/kotlin/rip/crit/twist/gossip/GossipService.kt new file mode 100644 index 0000000..0583f40 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/gossip/GossipService.kt @@ -0,0 +1,13 @@ +package rip.crit.twist.gossip + +import rip.crit.twist.p2p.NetworkMessage + +interface GossipService { + fun start() + + fun stop() + + fun publish(message: NetworkMessage) + + fun subscribe(topic: String, handler: (NetworkMessage) -> Unit) +} diff --git a/app/src/main/kotlin/rip/crit/twist/gossip/InMemoryGossipService.kt b/app/src/main/kotlin/rip/crit/twist/gossip/InMemoryGossipService.kt new file mode 100644 index 0000000..7add056 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/gossip/InMemoryGossipService.kt @@ -0,0 +1,26 @@ +package rip.crit.twist.gossip + +import java.util.concurrent.ConcurrentHashMap +import rip.crit.twist.p2p.NetworkMessage + +class InMemoryGossipService : GossipService { + private val subscriptions = ConcurrentHashMap Unit>>() + private var running = false + + override fun start() { + running = true + } + + override fun stop() { + running = false + } + + override fun publish(message: NetworkMessage) { + check(running) + subscriptions[message.topic]?.toList()?.forEach { it(message) } + } + + override fun subscribe(topic: String, handler: (NetworkMessage) -> Unit) { + subscriptions.computeIfAbsent(topic) { mutableListOf() }.add(handler) + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/jit/JitCompiler.kt b/app/src/main/kotlin/rip/crit/twist/jit/JitCompiler.kt new file mode 100644 index 0000000..2481fd7 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/jit/JitCompiler.kt @@ -0,0 +1,9 @@ +package rip.crit.twist.jit + +interface JitCompiler { + fun compile(bytecode: ByteArray): CompiledProgram +} + +fun interface CompiledProgram { + fun invoke(input: ByteArray): ByteArray +} diff --git a/app/src/main/kotlin/rip/crit/twist/jit/LLVMBuilder.kt b/app/src/main/kotlin/rip/crit/twist/jit/LLVMBuilder.kt new file mode 100644 index 0000000..e0fd387 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/jit/LLVMBuilder.kt @@ -0,0 +1,35 @@ +package rip.crit.twist.jit + +class LLVMBuilder(val name: String, val module: String) { + fun bitcode(): ByteArray = module.encodeToByteArray() +} + +class NativeLlvmRuntime(private val libraryName: String = "twist_llvm") { + private var loaded = false + + fun load() { + if (!loaded) { + System.loadLibrary(libraryName) + loaded = true + } + } + + fun compile(module: LLVMBuilder, optimizationLevel: Int = 2): NativeArtifact { + require(optimizationLevel in 0..3) + check(loaded) { "Native LLVM runtime is not loaded" } + return NativeArtifact(nativeCompile(module.name, module.bitcode(), optimizationLevel)) + } + + private external fun nativeCompile( + name: String, + bitcode: ByteArray, + optimizationLevel: Int, + ): ByteArray +} + +data class NativeArtifact(val machineCode: ByteArray) { + override fun equals(other: Any?): Boolean = + other is NativeArtifact && machineCode.contentEquals(other.machineCode) + + override fun hashCode(): Int = machineCode.contentHashCode() +} diff --git a/app/src/main/kotlin/rip/crit/twist/jit/ValidatedJitCompiler.kt b/app/src/main/kotlin/rip/crit/twist/jit/ValidatedJitCompiler.kt new file mode 100644 index 0000000..a6149f5 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/jit/ValidatedJitCompiler.kt @@ -0,0 +1,13 @@ +package rip.crit.twist.jit + +/** Validates input once and binds it to an execution backend for repeat invocation. */ +class ValidatedJitCompiler( + private val validate: (ByteArray) -> Unit, + private val execute: (ByteArray, ByteArray) -> ByteArray, +) : JitCompiler { + override fun compile(bytecode: ByteArray): CompiledProgram { + val immutableCode = bytecode.copyOf() + validate(immutableCode) + return CompiledProgram { input -> execute(immutableCode, input.copyOf()).copyOf() } + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/merkle/MerkleTree.kt b/app/src/main/kotlin/rip/crit/twist/merkle/MerkleTree.kt new file mode 100644 index 0000000..728783a --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/merkle/MerkleTree.kt @@ -0,0 +1,15 @@ +package rip.crit.twist.merkle + +import rip.crit.twist.core.Hash + +interface MerkleTree { + val root: Hash + + fun proofFor(leaf: Hash): MerkleProof? +} + +data class MerkleProof( + val leaf: Hash, + val siblings: List, + val siblingOnLeft: List = emptyList(), +) diff --git a/app/src/main/kotlin/rip/crit/twist/merkle/Sha256MerkleTree.kt b/app/src/main/kotlin/rip/crit/twist/merkle/Sha256MerkleTree.kt new file mode 100644 index 0000000..276bc29 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/merkle/Sha256MerkleTree.kt @@ -0,0 +1,47 @@ +package rip.crit.twist.merkle + +import rip.crit.twist.core.Hash +import rip.crit.twist.core.Sha256 + +class Sha256MerkleTree(leaves: List) : MerkleTree { + private val levels: List> + override val root: Hash + + init { + require(leaves.isNotEmpty()) { "A Merkle tree needs at least one leaf" } + val built = mutableListOf(leaves.toList()) + while (built.last().size > 1) { + val level = built.last() + built += + level.indices.step(2).map { index -> + val left = level[index] + Sha256.combine(left, level.getOrElse(index + 1) { left }) + } + } + levels = built + root = levels.last().single() + } + + override fun proofFor(leaf: Hash): MerkleProof? { + var index = levels.first().indexOf(leaf) + if (index < 0) return null + val siblings = mutableListOf() + val siblingOnLeft = mutableListOf() + for (level in levels.dropLast(1)) { + val siblingIndex = + if (index % 2 == 0) (index + 1).takeIf { it < level.size } ?: index else index - 1 + siblings += level[siblingIndex] + siblingOnLeft += index % 2 != 0 + index /= 2 + } + return MerkleProof(leaf, siblings, siblingOnLeft) + } +} + +fun MerkleProof.verifies(expectedRoot: Hash): Boolean { + if (siblings.size != siblingOnLeft.size) return false + return siblings.indices.fold(leaf) { current, index -> + if (siblingOnLeft[index]) Sha256.combine(siblings[index], current) + else Sha256.combine(current, siblings[index]) + } == expectedRoot +} diff --git a/app/src/main/kotlin/rip/crit/twist/miner/CpuMiner.kt b/app/src/main/kotlin/rip/crit/twist/miner/CpuMiner.kt new file mode 100644 index 0000000..6bc0c65 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/miner/CpuMiner.kt @@ -0,0 +1,54 @@ +package rip.crit.twist.miner + +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import rip.crit.twist.core.Hash +import rip.crit.twist.proof.ProofOfWork + +data class MiningResult(val subject: Hash, val difficulty: Int, val proof: ProofOfWork?, val attempts: Long, val elapsedNanos: Long) { + constructor(proof: ProofOfWork?, attempts: Long, elapsedNanos: Long) : this( + proof?.subject ?: Hash.ZERO, + proof?.difficulty ?: 0, + proof, + attempts, + elapsedNanos, + ) +} + +/** Bounded multi-core miner. Difficulty is the number of leading zero hexadecimal digits. */ +class CpuMiner(private val threads: Int = Runtime.getRuntime().availableProcessors()) { + init { + require(threads > 0) + } + + fun mine(subject: Hash, difficulty: Int, maxAttempts: Long): MiningResult { + require(difficulty in 0..64) + require(maxAttempts >= 0) + val started = System.nanoTime() + val found = AtomicBoolean(false) + val attempts = AtomicLong(0) + val executor = Executors.newFixedThreadPool(threads) + return try { + val tasks = + (0 until threads).map { lane -> + Callable { + var nonce = lane.toLong() + while (nonce < maxAttempts && !found.get()) { + attempts.incrementAndGet() + val proof = ProofOfWork(subject, nonce, difficulty) + if (proof.verify() && found.compareAndSet(false, true)) + return@Callable proof + nonce += threads + } + null + } + } + val proof = executor.invokeAll(tasks).firstNotNullOfOrNull { it.get() } + MiningResult(subject, difficulty, proof, attempts.get(), System.nanoTime() - started) + } finally { + executor.shutdownNow() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/rip/crit/twist/mint/MintPolicy.kt b/app/src/main/kotlin/rip/crit/twist/mint/MintPolicy.kt new file mode 100644 index 0000000..f9415bf --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/mint/MintPolicy.kt @@ -0,0 +1,7 @@ +package rip.crit.twist.mint + +import rip.crit.twist.core.Amount + +fun interface MintPolicy { + fun rewardAt(height: Long): Amount +} diff --git a/app/src/main/kotlin/rip/crit/twist/mint/Policies.kt b/app/src/main/kotlin/rip/crit/twist/mint/Policies.kt new file mode 100644 index 0000000..af48c95 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/mint/Policies.kt @@ -0,0 +1,17 @@ +package rip.crit.twist.mint + +import rip.crit.twist.core.Amount + +class FixedRewardPolicy(private val reward: Amount) : MintPolicy { + override fun rewardAt(height: Long): Amount { + require(height >= 0) + return reward + } +} + +class HalvingRewardPolicy(private val initial: Amount, private val interval: Long) : MintPolicy { + override fun rewardAt(height: Long): Amount { + require(height >= 0 && interval > 0) + return Amount(initial.value.shiftRight((height / interval).toInt())) + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/ope/AccessParallelExecutor.kt b/app/src/main/kotlin/rip/crit/twist/ope/AccessParallelExecutor.kt new file mode 100644 index 0000000..4958c9e --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/ope/AccessParallelExecutor.kt @@ -0,0 +1,53 @@ +package rip.crit.twist.ope + +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import rip.crit.twist.access.AccessList +import rip.crit.twist.core.Transaction +import rip.crit.twist.tvm.ExecutionResult + +/** Executes conflict-free batches concurrently while preserving input result order. */ +class AccessParallelExecutor( + private val access: (Transaction) -> AccessList, + private val executeOne: (Transaction) -> ExecutionResult, + private val threads: Int = Runtime.getRuntime().availableProcessors(), +) : ParallelExecutor { + init { + require(threads > 0) + } + + override fun execute(transactions: List): List { + val indexed = transactions.mapIndexed { index, transaction -> + IndexedValue(index, transaction) + } + val pending = ArrayDeque(indexed) + val results = arrayOfNulls(transactions.size) + val pool = Executors.newFixedThreadPool(threads) + try { + while (pending.isNotEmpty()) { + val batch = mutableListOf>() + val deferred = ArrayDeque>() + while (pending.isNotEmpty()) { + val candidate = pending.removeFirst() + if (batch.none { access(it.value).conflictsWith(access(candidate.value)) }) { + batch += candidate + } else { + deferred += candidate + } + } + pending.addAll(deferred) + val completed = + pool.invokeAll( + batch.map { item -> Callable { item.index to executeOne(item.value) } } + ) + completed.forEach { future -> + val (index, result) = future.get() + results[index] = result + } + } + } finally { + pool.shutdownNow() + } + return results.map { requireNotNull(it) } + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/ope/ParallelExecutor.kt b/app/src/main/kotlin/rip/crit/twist/ope/ParallelExecutor.kt new file mode 100644 index 0000000..d8585a2 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/ope/ParallelExecutor.kt @@ -0,0 +1,8 @@ +package rip.crit.twist.ope + +import rip.crit.twist.core.Transaction +import rip.crit.twist.tvm.ExecutionResult + +interface ParallelExecutor { + fun execute(transactions: List): List +} diff --git a/app/src/main/kotlin/rip/crit/twist/ope/SequentialExecutor.kt b/app/src/main/kotlin/rip/crit/twist/ope/SequentialExecutor.kt new file mode 100644 index 0000000..f058dd7 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/ope/SequentialExecutor.kt @@ -0,0 +1,14 @@ +package rip.crit.twist.ope + +import rip.crit.twist.core.Transaction +import rip.crit.twist.state.WorldState +import rip.crit.twist.tvm.ExecutionResult +import rip.crit.twist.tvm.VirtualMachine + +class SequentialExecutor(private val vm: VirtualMachine, private val state: WorldState) : + ParallelExecutor { + override fun execute(transactions: List): List = + transactions.map { + vm.execute(it, state) + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/p2p/Adapters.kt b/app/src/main/kotlin/rip/crit/twist/p2p/Adapters.kt new file mode 100644 index 0000000..a042e09 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/p2p/Adapters.kt @@ -0,0 +1,243 @@ +package rip.crit.twist.p2p + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import rip.crit.twist.core.Hash +import rip.crit.twist.core.SmartDoc + + +enum class NetworkAdapterKind { + DEVP2P, + LIBP2P, + TWIST_OVERLAY, +} + +interface NetworkAdapter { + val kind: NetworkAdapterKind + + fun encode(message: NetworkMessage): ByteArray + + fun decode(frame: ByteArray): NetworkMessage +} + +open class EnvelopeAdapter(override val kind: NetworkAdapterKind) : NetworkAdapter { + override fun encode(message: NetworkMessage): ByteArray = + listOf( + message.id.value, + message.topic, + message.payload.joinToString("") { "%02x".format(it) }, + ) + .joinToString("\n") + .encodeToByteArray() + + override fun decode(frame: ByteArray): NetworkMessage { + val fields = frame.decodeToString().split("\n", limit = 3) + require(fields.size == 3) + val payload = fields[2].chunked(2).map { it.toInt(16).toByte() }.toByteArray() + return NetworkMessage(fields[1], payload, Hash(fields[0])) + } +} + +data class ProtocolCapability(val name: String, val version: Int) { + init { + require(name.matches(Regex("[a-z0-9./-]{1,64}"))) + require(version >= 0) + } +} + +data class DevP2pHello( + val clientId: String, + val listenPort: Int, + val nodeId: PeerId, + val capabilities: Set, +) + +data class Libp2pIdentify( + val peer: PeerId, + val protocols: Set, + val addresses: Set, +) + +/** Twist-native capability negotiation with devp2p Hello RLP semantics. */ +@SmartDoc( + summary = "devp2p Hello/Ping/Pong/Disconnect RLP wire with capability negotiation.", + category = "Networking", +) +class DevP2pAdapter( + private val hello: DevP2pHello, + private val capabilityOffset: Int = 16, +) : NetworkAdapter { + constructor( + capabilities: Set = setOf(ProtocolCapability("twist", 1)) + ) : this( + DevP2pHello("twist/1.0.0", 0, PeerId("bootstrap"), capabilities), + ) + + override val kind: NetworkAdapterKind = NetworkAdapterKind.DEVP2P + + fun helloFrame(): ByteArray = + byteArrayOf(Rlpx.MSG_HELLO.toByte()) + Rlpx.encodeHello(hello) + + fun pingFrame(): ByteArray = byteArrayOf(Rlpx.MSG_PING.toByte()) + Rlpx.encodePing() + + fun pongFrame(): ByteArray = byteArrayOf(Rlpx.MSG_PONG.toByte()) + Rlpx.encodePong() + + fun disconnectFrame(reason: Int = Rlpx.DISC_REQUESTED): ByteArray = + byteArrayOf(Rlpx.MSG_DISCONNECT.toByte()) + Rlpx.encodeDisconnect(reason) + + override fun encode(message: NetworkMessage): ByteArray = + Rlpx.encodeSubprotocolMessage(capabilityOffset, 0, WireEnvelope.encode(message)) + + override fun decode(frame: ByteArray): NetworkMessage { + val (subId, payload) = Rlpx.decodeSubprotocolMessage(frame, capabilityOffset) + require(subId == 0) { "Unknown devp2p-twist subprotocol id" } + return WireEnvelope.decode(payload) + } + + fun decodeBase(frame: ByteArray): BaseMessage = + when ((frame[0].toInt() and 0xFF)) { + Rlpx.MSG_HELLO -> BaseMessage.Hello(Rlpx.decodeHello(frame.copyOfRange(1, frame.size))) + Rlpx.MSG_DISCONNECT -> + BaseMessage.Disconnect(Rlpx.decodeDisconnect(frame.copyOfRange(1, frame.size))) + Rlpx.MSG_PING -> BaseMessage.Ping + Rlpx.MSG_PONG -> BaseMessage.Pong + else -> error("Unknown devp2p base message") + } + + fun negotiate(remote: DevP2pHello): Set = + hello.capabilities + .groupBy { it.name } + .mapNotNull { (name, local) -> + remote.capabilities + .filter { it.name == name } + .map { it.version } + .intersect(local.map { it.version }.toSet()) + .maxOrNull() + ?.let { ProtocolCapability(name, it) } + } + .toSet() + + sealed interface BaseMessage { + data class Hello(val hello: DevP2pHello) : BaseMessage + data class Disconnect(val reason: Int) : BaseMessage + data object Ping : BaseMessage + data object Pong : BaseMessage + } +} + +/** libp2p multistream-select + Identify + Gossipsub-publish stream framing. */ +@SmartDoc( + summary = "libp2p multistream-select, Identify protobuf, and Gossipsub publish envelopes.", + category = "Networking", +) +class LibP2pAdapter( + private val protocols: Set = setOf(Libp2p.GOSSIPSUB_PROTO), + private val agent: String = "twist/1.0.0", +) : NetworkAdapter { + override val kind: NetworkAdapterKind = NetworkAdapterKind.LIBP2P + + init { + require(protocols.all { it.startsWith('/') && it.length <= 128 }) + } + + fun multistreamHandshake(protocol: String = Libp2p.GOSSIPSUB_PROTO): ByteArray = + Libp2p.frameLsForTest(Libp2p.MULTISTREAM) + + Libp2p.multistreamPropose(protocol) + + override fun encode(message: NetworkMessage): ByteArray { + val publish = + Libp2p.encodeGossipPublish( + message.id.value.encodeToByteArray(), message.payload, 0L, message.topic) + val header = Libp2p.yamuxHeader(Libp2p.YAMUX_DATA, 0, 1, publish.size) + return header + publish + } + + override fun decode(frame: ByteArray): NetworkMessage { + require(frame.size >= 12) + val header = Libp2p.yamuxParse(frame.copyOfRange(0, 12)) + require(header.type == Libp2p.YAMUX_DATA) + val publish = + Libp2p.decodeGossipPublish( + frame.copyOfRange(12, 12 + header.length.coerceAtMost(frame.size - 12))) + return NetworkMessage(publish.topic, publish.data, Hash(publish.from.decodeToString())) + } + + fun select(remoteProtocols: Set): String? = + protocols.sorted().firstOrNull { it in remoteProtocols } + + fun identify(peer: PeerId, addresses: Set): Libp2pIdentify = + Libp2pIdentify(peer, protocols, addresses) + + fun identifyFrame(peer: PeerId, addresses: Set, observed: String = ""): ByteArray = + Libp2p.encodeIdentify(agent, protocols, observed, addresses) +} + +class LocalPeerNetwork : PeerNetwork { + private val connected = linkedSetOf() + + private var running = false + + val received = mutableListOf() + + override fun start() { + running = true + } + + override fun stop() { + running = false + } + + override fun peers(): Set = connected.toSet() + + override fun broadcast(message: NetworkMessage) { + check(running) + received += message + } + + fun connect(peer: PeerId) { + connected += peer + } +} + +object WireEnvelope { + private const val VERSION: Byte = 1 + private const val MAX_FIELD_SIZE = 16 * 1024 * 1024 + + fun encode(message: NetworkMessage): ByteArray { + val output = ByteArrayOutputStream() + DataOutputStream(output).use { data -> + data.writeByte(VERSION.toInt()) + val idBytes = message.id.value.encodeToByteArray() + require(idBytes.size <= MAX_FIELD_SIZE) { "Invalid id size" } + data.writeInt(idBytes.size) + data.write(idBytes) + val topicBytes = message.topic.encodeToByteArray() + require(topicBytes.size <= MAX_FIELD_SIZE) { "Invalid topic size" } + data.writeInt(topicBytes.size) + data.write(topicBytes) + require(message.payload.size <= MAX_FIELD_SIZE) { "Invalid payload size" } + data.writeInt(message.payload.size) + data.write(message.payload) + } + return output.toByteArray() + } + + fun decode(bytes: ByteArray): NetworkMessage = + DataInputStream(ByteArrayInputStream(bytes)).use { input -> + require(input.readByte() == VERSION) { "Unsupported wire version" } + val idSize = input.readInt() + require(idSize in 0..MAX_FIELD_SIZE) { "Invalid id size" } + val idBytes = ByteArray(idSize).also(input::readFully) + val topicSize = input.readInt() + require(topicSize in 0..MAX_FIELD_SIZE) { "Invalid topic size" } + val topicBytes = ByteArray(topicSize).also(input::readFully) + val payloadSize = input.readInt() + require(payloadSize in 0..MAX_FIELD_SIZE) { "Invalid payload size" } + val payload = ByteArray(payloadSize).also(input::readFully) + require(input.available() == 0) { "Trailing message bytes" } + NetworkMessage(topicBytes.decodeToString(), payload, Hash(idBytes.decodeToString())) + } +} + diff --git a/app/src/main/kotlin/rip/crit/twist/p2p/Dht.kt b/app/src/main/kotlin/rip/crit/twist/p2p/Dht.kt new file mode 100644 index 0000000..de72bcb --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/p2p/Dht.kt @@ -0,0 +1,47 @@ +package rip.crit.twist.p2p + +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap +import rip.crit.twist.core.Hash + +data class PeerRecord(val peer: PeerId, val addresses: Set, val expiresAt: Instant) + +interface DistributedHashTable { + fun put(key: Hash, record: PeerRecord) + + fun get(key: Hash): PeerRecord? + + fun closest(key: Hash, limit: Int = 20): List +} + +class InMemoryDht(private val clock: Clock = Clock.systemUTC()) : DistributedHashTable { + private val records = ConcurrentHashMap() + + override fun put(key: Hash, record: PeerRecord) { + require(record.expiresAt > clock.instant()) + records[key] = record + } + + override fun get(key: Hash): PeerRecord? = + records[key]?.takeIf { it.expiresAt > clock.instant() } + + override fun closest(key: Hash, limit: Int): List { + require(limit > 0) + val target = key.value.toBigInteger(16) + return records.entries + .mapNotNull { (hash, record) -> + get(hash)?.let { (hash.value.toBigInteger(16).xor(target)) to it } + } + .sortedBy { it.first } + .take(limit) + .map { it.second } + } + + fun announce(peer: PeerId, addresses: Set, ttl: Duration = Duration.ofHours(1)): Hash { + val key = Hash(peer.value.padEnd(64, '0').take(64)) + put(key, PeerRecord(peer, addresses, clock.instant().plus(ttl))) + return key + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/p2p/FileDht.kt b/app/src/main/kotlin/rip/crit/twist/p2p/FileDht.kt new file mode 100644 index 0000000..40c0176 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/p2p/FileDht.kt @@ -0,0 +1,62 @@ +package rip.crit.twist.p2p + +import java.nio.file.Files +import java.nio.file.Path +import java.time.Clock +import java.time.Instant +import java.util.Base64 +import rip.crit.twist.core.Hash + +class FileDht(private val root: Path, private val clock: Clock = Clock.systemUTC()) : + DistributedHashTable { + init { + Files.createDirectories(root) + } + + override fun put(key: Hash, record: PeerRecord) { + require(record.expiresAt > clock.instant()) + Files.write( + root.resolve(key.value), + listOf( + record.peer.value, + record.expiresAt.toEpochMilli().toString(), + record.addresses.joinToString("\t") { + Base64.getUrlEncoder().withoutPadding().encodeToString(it.encodeToByteArray()) + }, + ), + ) + } + + override fun get(key: Hash): PeerRecord? = runCatching { + val lines = Files.readAllLines(root.resolve(key.value)) + val record = + PeerRecord( + PeerId(lines[0]), + lines[2] + .split("\t") + .filter(String::isNotEmpty) + .map { Base64.getUrlDecoder().decode(it).decodeToString() } + .toSet(), + Instant.ofEpochMilli(lines[1].toLong()), + ) + record.takeIf { it.expiresAt > clock.instant() } + } + .getOrNull() + + override fun closest(key: Hash, limit: Int): List { + require(limit > 0) + val target = key.value.toBigInteger(16) + return Files.list(root).use { paths -> + paths + .map { it.fileName.toString() } + .filter { it.matches(Regex("[0-9a-fA-F]+")) } + .map { Hash(it) } + .map { hash -> hash.value.toBigInteger(16).xor(target) to get(hash) } + .filter { it.second != null } + .sorted(compareBy { it.first }) + .limit(limit.toLong()) + .map { it.second!! } + .toList() + } + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/p2p/Libp2p.kt b/app/src/main/kotlin/rip/crit/twist/p2p/Libp2p.kt new file mode 100644 index 0000000..3e95b92 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/p2p/Libp2p.kt @@ -0,0 +1,224 @@ +package rip.crit.twist.p2p + +import kotlin.random.Random +import rip.crit.twist.core.HmacSha256 +import rip.crit.twist.core.SmartDoc + +/** + * libp2p transport shape (specs: multistream-select, Noise XX handshake, Yamux/mplex + * framing, Identify protobuf, Gossipsub v1.1 RPC). + * + * Multistream-select: initiator sends `/multistream/1.0.0\n`, then for each proposal + * `varint-len || "\n"`; responder answers `varint-len || "\n"` on accept + * or `varint-len || "na\n"` on reject. + * + * Noise XX (`[e, ->e/NoPayload, e...ee|s...es|s...se]`): three messages carrying + * ephemeral pubkeys, static pubkeys (encrypted), and payloads, chained with + * `MixHash/HKDF` into transport keys sealing subsequent frames with AES-GCM. + * + * Yamux: 12-byte header `version(1) || type || flags(2BE) || stream-id(4BE) || length(4BE)` + * with types Data(0), WindowUpdate(1), Ping(2), GoAway(3). + * + * Identify: protobuf with canonical field numbers (1 agent, 2 protocols, 3 observed-addr, + * 4 listen-addrs, 5 protocols-versions... simplified here to 1..4 + 6 public-key + 8-signed-peer-record + * envelope); Gossipsub RPC: `Publish(topic, seqno, from, data, signature)` with v1.1 + * mesh parameters (`D=6, Dlo=4, Dhi=12, heartbeat=1s`). + * + * Twist profile: Noise DH/static payloads use bundled X25519 + HKDF-SHA256 + AES-GCM; + * protobuf fields use length-delimited varint encoding with the canonical field numbers above. + */ +object Libp2p { + const val MULTISTREAM = "/multistream/1.0.0" + const val NOISE_PROTO = "/noise" + const val YAMUX_PROTO = "/yamux/1.0.0" + const val MPLEX_PROTO = "/mplex/6.7.0" + const val IDENTIFY_PROTO = "/ipfs/id/1.0.0" + const val GOSSIPSUB_PROTO = "/meshsub/1.1.0" + + const val YAMUX_DATA = 0 + const val YAMUX_WINDOW = 1 + const val YAMUX_PING = 2 + const val YAMUX_GOAWAY = 3 + + const val GOSSIP_D = 6 + const val GOSSIP_D_LO = 4 + const val GOSSIP_D_HI = 12 + const val GOSSIP_HEARTBEAT_MS = 1000L + + fun frameLsForTest(line: String): ByteArray = frameLs("$line\n".encodeToByteArray()) + + fun multistreamPropose(protocol: String): ByteArray = + frameLs("$protocol\n".encodeToByteArray()) + + fun multistreamAccept(protocol: String): ByteArray = + frameLs("$protocol\n".encodeToByteArray()) + + fun multistreamReject(): ByteArray = frameLs("na\n".encodeToByteArray()) + + fun multistreamRead(frame: ByteArray): String { + val body = unframeLs(frame).decodeToString() + require(body.endsWith("\n")) { "Bad multistream line" } + return body.dropLast(1) + } + + fun yamuxHeader(type: Int, flags: Int, streamId: Int, length: Int): ByteArray { + require(type in 0..3 && flags in 0..0xFFFF && length >= 0) + return byteArrayOf( + 0, + type.toByte(), + ((flags ushr 8) and 0xFF).toByte(), + (flags and 0xFF).toByte(), + ((streamId ushr 24) and 0xFF).toByte(), + ((streamId ushr 16) and 0xFF).toByte(), + ((streamId ushr 8) and 0xFF).toByte(), + (streamId and 0xFF).toByte(), + ((length ushr 24) and 0xFF).toByte(), + ((length ushr 16) and 0xFF).toByte(), + ((length ushr 8) and 0xFF).toByte(), + (length and 0xFF).toByte()) + } + + fun yamuxParse(header: ByteArray): YamuxHeader { + require(header.size == 12 && header[0].toInt() == 0) { "Bad Yamux header" } + val type = header[1].toInt() and 0xFF + val flags = ((header[2].toInt() and 0xFF) shl 8) or (header[3].toInt() and 0xFF) + var streamId = 0 + var length = 0 + for (i in 4..7) streamId = (streamId shl 8) or (header[i].toInt() and 0xFF) + for (i in 8..11) length = (length shl 8) or (header[i].toInt() and 0xFF) + require(type in 0..3 && length >= 0) + return YamuxHeader(type, flags, streamId, length) + } + + data class YamuxHeader(val type: Int, val flags: Int, val streamId: Int, val length: Int) + + // ---- minimal protobuf (varint + length-delimited) with canonical Identify fields ---- + + fun varint(value: Long): ByteArray { + require(value >= 0) + var v = value + val out = mutableListOf() + while (true) { + var b = (v and 0x7F).toInt() + v = v ushr 7 + if (v != 0L) b = b or 0x80 + out += b.toByte() + if (v == 0L) break + } + return out.toByteArray() + } + + fun varintRead(bytes: ByteArray, offset: Int): Pair { + var result = 0L + var shift = 0 + var pos = offset + while (pos < bytes.size) { + val b = bytes[pos++].toInt() and 0xFF + result = result or ((b and 0x7F).toLong() shl shift) + shift += 7 + require(shift <= 64) { "Varint overflow" } + if (b and 0x80 == 0) return Pair(result, pos) + } + error("Truncated varint") + } + + fun field(number: Int, payload: ByteArray): ByteArray = + varint(((number shl 3) or 2).toLong()) + varint(payload.size.toLong()) + payload + + fun fieldString(number: Int, value: String): ByteArray = + field(number, value.encodeToByteArray()) + + fun parseFields(bytes: ByteArray): Map> { + val out = mutableMapOf>() + var pos = 0 + while (pos < bytes.size) { + val (key, afterKey) = varintRead(bytes, pos) + pos = afterKey + require((key and 7) == 2L) { "Only length-delimited fields supported" } + val number = (key ushr 3).toInt() + val (len, afterLen) = varintRead(bytes, pos) + pos = afterLen + require(len in 0..16 * 1024 * 1024 && pos + len <= bytes.size) { "Bad protobuf length" } + out.getOrPut(number) { mutableListOf() } += bytes.copyOfRange(pos, pos + len.toInt()) + pos += len.toInt() + } + return out + } + + /** Identify fields: 1 agent, 2 protocols (repeated), 3 observed-addr, 4 listen-addrs (repeated). */ + fun encodeIdentify(agent: String, protocols: Set, observed: String, listen: Set): ByteArray { + var out = fieldString(1, agent) + protocols.sorted().forEach { out += fieldString(2, it) } + out += fieldString(3, observed) + listen.sorted().forEach { out += fieldString(4, it) } + return out + } + + fun decodeIdentify(bytes: ByteArray): Libp2pIdentifyFull { + val fields = parseFields(bytes) + val agent = fields[1]?.firstOrNull()?.decodeToString() ?: "" + val protocols = fields[2].orEmpty().map { it.decodeToString() }.toSet() + val observed = fields[3]?.firstOrNull()?.decodeToString() ?: "" + val listen = fields[4].orEmpty().map { it.decodeToString() }.toSet() + return Libp2pIdentifyFull(agent, protocols, observed, listen) + } + + data class Libp2pIdentifyFull( + val agent: String, + val protocols: Set, + val observedAddr: String, + val listenAddrs: Set, + ) + + /** Gossipsub Publish fields: 1 from, 2 data, 3 seqno, 4 topic, 5 signature, 6 key. */ + fun encodeGossipPublish( + from: ByteArray, + data: ByteArray, + seqno: Long, + topic: String, + signature: ByteArray = ByteArray(0), + ): ByteArray { + var out = field(1, from) + out += field(2, data) + out += field(3, seqnoBytes(seqno)) + out += fieldString(4, topic) + if (signature.isNotEmpty()) out += field(5, signature) + return out + } + + fun decodeGossipPublish(bytes: ByteArray): GossipPublish { + val fields = parseFields(bytes) + val from = fields[1]?.firstOrNull() ?: error("Gossip publish missing from") + val data = fields[2]?.firstOrNull() ?: error("Gossip publish missing data") + val seqnoRaw = fields[3]?.firstOrNull() ?: error("Gossip publish missing seqno") + require(seqnoRaw.size == 8) + var seqno = 0L + for (b in seqnoRaw) seqno = (seqno shl 8) or (b.toLong() and 0xFF) + val topic = fields[4]?.firstOrNull()?.decodeToString() ?: "" + return GossipPublish(from, data, seqno, topic, fields[5]?.firstOrNull() ?: ByteArray(0)) + } + + data class GossipPublish( + val from: ByteArray, + val data: ByteArray, + val seqno: Long, + val topic: String, + val signature: ByteArray, + ) + + private fun seqnoBytes(seqno: Long): ByteArray { + val out = ByteArray(8) + for (i in 0..7) out[7 - i] = ((seqno ushr (8 * i)) and 0xFF).toByte() + return out + } + + private fun frameLs(body: ByteArray): ByteArray = varint(body.size.toLong()) + body + + private fun unframeLs(frame: ByteArray): ByteArray { + val (len, pos) = varintRead(frame, 0) + require(len in 0..1024 && pos + len == frame.size.toLong()) { "Bad multistream frame" } + return frame.copyOfRange(pos, frame.size) + } +} + +// NoiseXxSession / NoiseTransport: see transport/NoiseCrypto.kt. diff --git a/app/src/main/kotlin/rip/crit/twist/p2p/PeerNetwork.kt b/app/src/main/kotlin/rip/crit/twist/p2p/PeerNetwork.kt new file mode 100644 index 0000000..aef2b7d --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/p2p/PeerNetwork.kt @@ -0,0 +1,37 @@ +package rip.crit.twist.p2p + +import rip.crit.twist.core.Hash + +@JvmInline value class PeerId(val value: String) + +data class NetworkMessage(val topic: String, val payload: ByteArray, val id: Hash) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as NetworkMessage + + if (topic != other.topic) return false + if (!payload.contentEquals(other.payload)) return false + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + var result = topic.hashCode() + result = 31 * result + payload.contentHashCode() + result = 31 * result + id.hashCode() + return result + } +} + +interface PeerNetwork { + fun start() + + fun stop() + + fun peers(): Set + + fun broadcast(message: NetworkMessage) +} diff --git a/app/src/main/kotlin/rip/crit/twist/p2p/ProtocolCatalog.kt b/app/src/main/kotlin/rip/crit/twist/p2p/ProtocolCatalog.kt new file mode 100644 index 0000000..d673229 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/p2p/ProtocolCatalog.kt @@ -0,0 +1,36 @@ +package rip.crit.twist.p2p + +import rip.crit.twist.core.TwistSpecific + +data class ProtocolDescriptor( + val name: String, + val version: String, + val transports: Set, + val encrypted: Boolean, + val interoperable: Boolean, +) + +object ProtocolCatalog { + val devP2p = + ProtocolDescriptor( + "devp2p-rlpx", + "rlpx-eip8-v4/twist-profile-v1", + setOf("tcp"), + true, + true, + ) + val libP2p = + ProtocolDescriptor( + "libp2p-noise-yamux", + "noise-xx/yamux/meshsub-1.1.0", + setOf("tcp"), + true, + true, + ) + private val twistOverlay = + ProtocolDescriptor("twist-overlay", "1.0", setOf("tcp", "memory"), true, true) + + fun advertised(twistSpecific: TwistSpecific? = null): List = + listOf(devP2p, libP2p) + + if (twistSpecific === TwistSpecific.Enabled) listOf(twistOverlay) else emptyList() +} diff --git a/app/src/main/kotlin/rip/crit/twist/p2p/Rlp.kt b/app/src/main/kotlin/rip/crit/twist/p2p/Rlp.kt new file mode 100644 index 0000000..bfdd0b5 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/p2p/Rlp.kt @@ -0,0 +1,145 @@ +package rip.crit.twist.p2p + +import java.io.ByteArrayOutputStream +import java.math.BigInteger + +/** + * Ethereum Recursive Length Prefix (RLP) codec. + * + * Wire reference: ethereum/devp2p `rlpx.md` — RLPx framing, auth/ack handshake + * payloads, and devp2p Hello/Disconnect/Ping/Pong bodies are all RLP-encoded. + * Single-byte values < 0x80 are their own encoding; all other strings/lists use + * short (<=55 bytes) and long length prefixes. + */ +object PRlp { + private const val SHORT_MAX = 55 + + fun encodeString(bytes: ByteArray): ByteArray = + when { + bytes.size == 1 && (bytes[0].toInt() and 0xFF) < 0x80 -> bytes.copyOf() + bytes.size <= SHORT_MAX -> + byteArrayOf((0x80 + bytes.size).toByte()) + bytes + else -> { + val len = lengthBytes(bytes.size) + byteArrayOf((0xB7 + len.size).toByte()) + len + bytes + } + } + + fun encodeInt(value: BigInteger): ByteArray { + require(value >= BigInteger.ZERO) { "RLP integers must be non-negative" } + if (value == BigInteger.ZERO) return byteArrayOf(0x80.toByte()) + var raw = value.toByteArray().dropWhile { it == 0.toByte() }.toByteArray() + return encodeString(raw) + } + + fun encodeInt(value: Long): ByteArray = encodeInt(BigInteger.valueOf(value)) + + fun encodeList(items: List): ByteArray { + val payload = items.fold(ByteArray(0)) { acc, item -> acc + item } + return when { + payload.size <= SHORT_MAX -> + byteArrayOf((0xC0 + payload.size).toByte()) + payload + else -> { + val len = lengthBytes(payload.size) + byteArrayOf((0xF7 + len.size).toByte()) + len + payload + } + } + } + + fun encodeElements(vararg items: ByteArray): ByteArray = encodeList(items.toList()) + + /** Decodes one RLP item starting at [offset]; returns (item, nextOffset). */ + fun decodeOne(bytes: ByteArray, offset: Int = 0): Pair { + require(offset < bytes.size) { "RLP truncated" } + val prefix = bytes[offset].toInt() and 0xFF + return when { + prefix < 0x80 -> Pair(PRlpItem.String(bytes.copyOfRange(offset, offset + 1)), offset + 1) + prefix <= 0xB7 -> { + val len = prefix - 0x80 + require(offset + 1 + len <= bytes.size) { "RLP string truncated" } + Pair(PRlpItem.String(bytes.copyOfRange(offset + 1, offset + 1 + len)), offset + 1 + len) + } + prefix <= 0xBF -> { + val lenOfLen = prefix - 0xB7 + val len = readLength(bytes, offset + 1, lenOfLen) + require(offset + 1 + lenOfLen + len <= bytes.size) { "RLP long string truncated" } + Pair( + PRlpItem.String(bytes.copyOfRange(offset + 1 + lenOfLen, offset + 1 + lenOfLen + len)), + offset + 1 + lenOfLen + len) + } + prefix <= 0xF7 -> { + val len = prefix - 0xC0 + require(offset + 1 + len <= bytes.size) { "RLP list truncated" } + Pair(decodeList(bytes.copyOfRange(offset + 1, offset + 1 + len)), offset + 1 + len) + } + else -> { + val lenOfLen = prefix - 0xF7 + val len = readLength(bytes, offset + 1, lenOfLen) + require(offset + 1 + lenOfLen + len <= bytes.size) { "RLP long list truncated" } + Pair( + decodeList(bytes.copyOfRange(offset + 1 + lenOfLen, offset + 1 + lenOfLen + len)), + offset + 1 + lenOfLen + len) + } + } + } + + fun decode(bytes: ByteArray): PRlpItem { + val (item, next) = decodeOne(bytes, 0) + require(next == bytes.size) { "Trailing RLP bytes" } + return item + } + + private fun decodeList(payload: ByteArray): PRlpItem.List { + val items = mutableListOf() + var offset = 0 + while (offset < payload.size) { + val (item, next) = decodeOne(payload, offset) + items += item + offset = next + } + require(offset == payload.size) { "RLP list overrun" } + return PRlpItem.List(items) + } + + private fun readLength(bytes: ByteArray, offset: Int, lenOfLen: Int): Int { + require(lenOfLen in 1..4 && offset + lenOfLen <= bytes.size) { "Bad RLP length prefix" } + require(bytes[offset] != 0.toByte()) { "RLP length has leading zero" } + var len = 0 + for (i in 0 until lenOfLen) len = (len shl 8) or (bytes[offset + i].toInt() and 0xFF) + require(len > SHORT_MAX) { "RLP long form used for short payload" } + return len + } + + private fun lengthBytes(size: Int): ByteArray { + val out = ByteArrayOutputStream() + var v = size + val tmp = mutableListOf() + while (v > 0) { + tmp += (v and 0xFF).toByte() + v = v ushr 8 + } + tmp.reversed().forEach { out.write(it.toInt()) } + return out.toByteArray() + } +} + +sealed interface PRlpItem { + data class String(val bytes: ByteArray) : PRlpItem { + fun asLong(): Long { + require(bytes.isNotEmpty()) { "Empty RLP int" } + require(!(bytes.size > 1 && bytes[0] == 0.toByte())) { "Non-canonical RLP int" } + var v = 0L + for (b in bytes) v = (v shl 8) or (b.toLong() and 0xFF) + return v + } + + fun asText(): kotlin.String = bytes.decodeToString() + + override fun equals(other: Any?): Boolean = + other is String && bytes.contentEquals(other.bytes) + + override fun hashCode(): Int = bytes.contentHashCode() + } + + data class List(val items: kotlin.collections.List) : PRlpItem +} diff --git a/app/src/main/kotlin/rip/crit/twist/p2p/Rlpx.kt b/app/src/main/kotlin/rip/crit/twist/p2p/Rlpx.kt new file mode 100644 index 0000000..1fe0eb5 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/p2p/Rlpx.kt @@ -0,0 +1,187 @@ +package rip.crit.twist.p2p + +import kotlin.random.Random +import rip.crit.twist.core.HmacSha256 +import rip.crit.twist.core.SmartDoc + +/** + * RLPx transport shape (ethereum/devp2p `rlpx.md`, EIP-8, wire `p2p.md`). + * + * Handshake: initiator -> auth (`uint16BE size || box || pad`), recipient -> ack (same + * framing). Plaintexts are RLP: `auth = [sig, initiator-pubkey, nonce, version]`, + * `ack = [recipient-ephemeral-pubkey, nonce, version]`; EIP-8 prefixes the RLP list with a + * one-byte version and appends random padding. Secrets derive as + * `ephemeral-shared = ECDH(ephemeral-priv, remote-ephemeral-pub)`, + * `HKDF(ephemeral-shared, nonce-initiator || nonce-recipient)` split into AES + MAC keys. + * + * Framing: 16-byte header `AES(mac-secret, egress-mac) XOR frame-size`, 16-byte header MAC, + * then AES-CTR frame-data `RLP([capability-message-id, payload...])` plus frame MAC; MACs + * form a SHA-256 egress/ingress chain over ciphertext. + * + * Twist profile: secp256k1/ECIES-KDF map onto bundled X25519 + HKDF-SHA256 + AES-GCM + * (auth/ack box) + AES-CTR (frame stream) + HMAC-SHA256 (MAC chain). RLP schemas, + * message-ids (Hello 0x00, Disconnect 0x01, Ping 0x02, Pong 0x03), disconnect reasons, + * and capability offset (`wire-id = capability-offset + subprotocol-id`) match devp2p. + */ +object Rlpx { + const val AUTH_VERSION = 4 + const val PROTOCOL_VERSION = 5 + const val NONCE_SIZE = 32 + const val PUBKEY_SIZE = 32 + const val SIG_SIZE = 64 + const val GCM_NONCE_SIZE = 12 + const val GCM_TAG_SIZE = 16 + const val HEADER_SIZE = 16 + const val MAC_SIZE = 16 + + const val MSG_HELLO = 0x00 + const val MSG_DISCONNECT = 0x01 + const val MSG_PING = 0x02 + const val MSG_PONG = 0x03 + + // p2p disconnect reasons (`p2p.md`). + const val DISC_REQUESTED = 0x00 + const val DISC_TCP_ERROR = 0x01 + const val DISC_BAD_PROTOCOL = 0x02 + const val DISC_USELESS_PEER = 0x03 + const val DISC_TOO_MANY_PEERS = 0x04 + const val DISC_ALREADY_CONNECTED = 0x05 + const val DISC_INCOMPATIBLE_VERSION = 0x06 + const val DISC_NULL_NODE = 0x07 + + data class AuthPlaintext( + val signature: ByteArray, + val initiatorPubkey: ByteArray, + val nonce: ByteArray, + val version: Int = AUTH_VERSION, + ) + + data class AckPlaintext( + val recipientEphemeralPubkey: ByteArray, + val nonce: ByteArray, + val version: Int = AUTH_VERSION, + ) + + data class Secrets(val aes: ByteArray, val mac: ByteArray) + + fun encodeAuth(auth: AuthPlaintext): ByteArray { + require(auth.signature.size == SIG_SIZE && auth.initiatorPubkey.size == PUBKEY_SIZE) + require(auth.nonce.size == NONCE_SIZE) + return PRlp.encodeList( + listOf( + PRlp.encodeString(auth.signature), + PRlp.encodeString(auth.initiatorPubkey), + PRlp.encodeString(auth.nonce), + PRlp.encodeInt(auth.version.toLong()))) + } + + fun decodeAuth(bytes: ByteArray): AuthPlaintext { + val list = (PRlp.decode(bytes) as PRlpItem.List).items + require(list.size == 4) + val sig = (list[0] as PRlpItem.String).bytes + val pub = (list[1] as PRlpItem.String).bytes + val nonce = (list[2] as PRlpItem.String).bytes + require(sig.size == SIG_SIZE && pub.size == PUBKEY_SIZE && nonce.size == NONCE_SIZE) + return AuthPlaintext(sig, pub, nonce, (list[3] as PRlpItem.String).asLong().toInt()) + } + + fun encodeAck(ack: AckPlaintext): ByteArray { + require(ack.recipientEphemeralPubkey.size == PUBKEY_SIZE && ack.nonce.size == NONCE_SIZE) + return PRlp.encodeList( + listOf( + PRlp.encodeString(ack.recipientEphemeralPubkey), + PRlp.encodeString(ack.nonce), + PRlp.encodeInt(ack.version.toLong()))) + } + + fun decodeAck(bytes: ByteArray): AckPlaintext { + val list = (PRlp.decode(bytes) as PRlpItem.List).items + require(list.size == 3) + val pub = (list[0] as PRlpItem.String).bytes + val nonce = (list[1] as PRlpItem.String).bytes + require(pub.size == PUBKEY_SIZE && nonce.size == NONCE_SIZE) + return AckPlaintext(pub, nonce, (list[2] as PRlpItem.String).asLong().toInt()) + } + + // EIP-8 box seal/open and the AES-CTR frame cipher live in transport/RlpxCrypto.kt + // (transport sees p2p + wire + core; p2p must not depend back on transport). + + /** `HKDF-SHA256(salt=nonces, ikm=shared)` expanded with info byte; mirrors rlpx secrets. */ + fun deriveSecrets(shared: ByteArray, initiatorNonce: ByteArray, recipientNonce: ByteArray): Secrets { + val prk = HmacSha256.digest(initiatorNonce + recipientNonce, shared) + val aes = HmacSha256.digest(prk, byteArrayOf(1)) + val mac = HmacSha256.digest(prk, byteArrayOf(2)) + return Secrets(aes, mac) + } + + fun hkdf(shared: ByteArray, context: ByteArray, size: Int): ByteArray { + val prk = HmacSha256.digest(ByteArray(32), shared) + var out = ByteArray(0) + var counter = 1 + while (out.size < size) { + out += HmacSha256.digest(prk, context + counter.toByte()) + counter++ + } + return out.copyOf(size) + } + + // ---- base-protocol RLP bodies (`p2p.md`) ---- + + fun encodeHello(hello: DevP2pHello): ByteArray = + PRlp.encodeList( + listOf( + PRlp.encodeInt(PROTOCOL_VERSION.toLong()), + PRlp.encodeString(hello.clientId.encodeToByteArray()), + PRlp.encodeList( + hello.capabilities + .sortedWith(compareBy({ it.name }, { it.version })) + .map { + PRlp.encodeList( + listOf( + PRlp.encodeString(it.name.encodeToByteArray()), + PRlp.encodeInt(it.version.toLong()))) + }), + PRlp.encodeInt(hello.listenPort.toLong()), + PRlp.encodeString(hello.nodeId.value.encodeToByteArray()))) + + fun decodeHello(bytes: ByteArray): DevP2pHello { + val list = (PRlp.decode(bytes) as PRlpItem.List).items + require(list.size == 5) { "Hello must have 5 fields" } + require((list[0] as PRlpItem.String).asLong() == PROTOCOL_VERSION.toLong()) + val clientId = (list[1] as PRlpItem.String).asText() + val caps = + ((list[2] as PRlpItem.List).items.map { + val cap = (it as PRlpItem.List).items + ProtocolCapability((cap[0] as PRlpItem.String).asText(), (cap[1] as PRlpItem.String).asLong().toInt()) + }).toSet() + val port = (list[3] as PRlpItem.String).asLong().toInt() + val nodeId = PeerId((list[4] as PRlpItem.String).asText()) + return DevP2pHello(clientId, port, nodeId, caps) + } + + fun encodeDisconnect(reason: Int): ByteArray = + PRlp.encodeList(listOf(PRlp.encodeInt(reason.toLong()))) + + fun decodeDisconnect(bytes: ByteArray): Int { + val list = (PRlp.decode(bytes) as PRlpItem.List).items + return if (list.isEmpty()) DISC_REQUESTED else (list[0] as PRlpItem.String).asLong().toInt() + } + + fun encodePing(): ByteArray = PRlp.encodeList(emptyList()) + + fun encodePong(): ByteArray = PRlp.encodeList(emptyList()) + + /** Subprotocol framing: `RLP([wire-id, payload...])`, `wire-id = offset + sub-id`. */ + fun encodeSubprotocolMessage(capabilityOffset: Int, subprotocolId: Int, payload: ByteArray): ByteArray = + PRlp.encodeList(listOf(PRlp.encodeInt((capabilityOffset + subprotocolId).toLong()), PRlp.encodeString(payload))) + + fun decodeSubprotocolMessage(bytes: ByteArray, capabilityOffset: Int): Pair { + val list = (PRlp.decode(bytes) as PRlpItem.List).items + require(list.size == 2) + val wireId = (list[0] as PRlpItem.String).asLong().toInt() + require(wireId >= capabilityOffset) { "Message id below capability offset" } + return Pair(wireId - capabilityOffset, (list[1] as PRlpItem.String).bytes) + } +} + +// RlpxFrameCipher: see transport/RlpxCrypto.kt. diff --git a/app/src/main/kotlin/rip/crit/twist/proof/Proof.kt b/app/src/main/kotlin/rip/crit/twist/proof/Proof.kt new file mode 100644 index 0000000..c9a30dd --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/proof/Proof.kt @@ -0,0 +1,109 @@ +package rip.crit.twist.proof + +import rip.crit.twist.core.Hash +import rip.crit.twist.core.Sha256 + +interface Proof { + val scheme: String + + val subject: Hash + + fun verify(): Boolean +} + +data class ProofOfStore(override val subject: Hash, val challenge: Hash, val response: Hash) : + Proof { + override val scheme: String = "proof-of-store-v1" + + override fun verify(): Boolean = + response == rip.crit.twist.core.Sha256.combine(subject, challenge) + + companion object { + fun create(subject: Hash, challenge: Hash): ProofOfStore = + ProofOfStore(subject, challenge, rip.crit.twist.core.Sha256.combine(subject, challenge)) + } +} + +data class ProofOfStake( + override val subject: Hash, + val validator: String, + val stake: java.math.BigInteger, + val epoch: Long, +) : Proof { + override val scheme: String = "proof-of-stake-v1" + + override fun verify(): Boolean = validator.isNotBlank() && stake.signum() > 0 && epoch >= 0 +} + +data class ProofOfWork(override val subject: Hash, val nonce: Long, val difficulty: Int) : Proof { + override val scheme: String = "proof-of-work-v1" + + override fun verify(): Boolean = + nonce >= 0 && + difficulty in 0..64 && + rip.crit.twist.core.Sha256.digestUtf8("${subject.value}:$nonce") + .value + .take(difficulty) + .all { it == '0' } + + companion object { + fun mine(subject: Hash, difficulty: Int, maxAttempts: Long = 1_000_000): ProofOfWork? { + require(difficulty in 0..64) + for (nonce in 0 until maxAttempts) { + val proof = ProofOfWork(subject, nonce, difficulty) + if (proof.verify()) return proof + } + return null + } + } +} + +data class ProofOfAuthority( + override val subject: Hash, + val authority: String, + val signature: Hash, + val allowedAuthorities: Set, +) : Proof { + override val scheme: String = "proof-of-authority-v1" + + override fun verify(): Boolean = + authority in allowedAuthorities && + signature == rip.crit.twist.core.Sha256.digestUtf8("${subject.value}:$authority") + + companion object { + fun sign(subject: Hash, authority: String, allowedAuthorities: Set) = + ProofOfAuthority( + subject, + authority, + rip.crit.twist.core.Sha256.digestUtf8("${subject.value}:$authority"), + allowedAuthorities, + ) + } +} + +class DynamicProofVerifier(private val verifiers: Map Boolean> = emptyMap()) { + fun verify(proof: Proof): Boolean = verifiers[proof.scheme]?.invoke(proof) ?: proof.verify() +} + +/** A value claim bound to a subject. Authenticity is supplied by the signed containing message. */ +data class ProofOfValue( + override val subject: Hash, + val beneficiary: String, + val value: java.math.BigInteger, + val commitment: Hash, +) : Proof { + override val scheme: String = "proof-of-value-v1" + + override fun verify(): Boolean = + beneficiary.isNotBlank() && + value.signum() >= 0 && + commitment == commit(subject, beneficiary, value) + + companion object { + fun create(subject: Hash, beneficiary: String, value: java.math.BigInteger): ProofOfValue = + ProofOfValue(subject, beneficiary, value, commit(subject, beneficiary, value)) + + private fun commit(subject: Hash, beneficiary: String, value: java.math.BigInteger): Hash = + Sha256.digestUtf8("${subject.value}\u0000$beneficiary\u0000$value") + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/reflect/NetworkReflector.kt b/app/src/main/kotlin/rip/crit/twist/reflect/NetworkReflector.kt new file mode 100644 index 0000000..192a84c --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/reflect/NetworkReflector.kt @@ -0,0 +1,102 @@ +package rip.crit.twist.reflect + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.time.Clock +import java.time.Instant +import rip.crit.twist.p2p.PeerId +import rip.crit.twist.store.KeyValueStore + +data class PeerObservation( + val peer: PeerId, + val addresses: Set, + val neighbors: Set, + val observedAt: Instant, +) + +fun interface PeerProbe { + fun inspect(peer: PeerId): PeerObservation? +} + +class PeerIndex(private val store: KeyValueStore) { + fun put(observation: PeerObservation) = + store.put(key(observation.peer), ObservationCodec.encode(observation)) + + fun get(peer: PeerId): PeerObservation? = store.get(key(peer))?.let(ObservationCodec::decode) + + private fun key(peer: PeerId) = "reflect:${peer.value}".encodeToByteArray() +} + +data class CrawlReport( + val observations: List, + val failures: Set, + val truncated: Boolean, +) + +/** Breadth-first network crawler with strict node and neighbor limits. */ +class NetworkReflector( + private val probe: PeerProbe, + private val index: PeerIndex, + private val clock: Clock = Clock.systemUTC(), +) { + fun crawl(seeds: Collection, maximumPeers: Int = 1_000): CrawlReport { + require(maximumPeers > 0) + val queued = ArrayDeque(seeds.distinct()) + val visited = linkedSetOf() + val observations = mutableListOf() + val failures = linkedSetOf() + while (queued.isNotEmpty() && visited.size < maximumPeers) { + val peer = queued.removeFirst() + if (!visited.add(peer)) continue + val observation = runCatching { probe.inspect(peer) }.getOrNull() + if (observation == null) { + failures += peer + continue + } + val normalized = observation.copy(observedAt = clock.instant()) + index.put(normalized) + observations += normalized + normalized.neighbors.filterNot(visited::contains).forEach(queued::addLast) + } + return CrawlReport(observations, failures, queued.isNotEmpty()) + } +} + +private object ObservationCodec { + fun encode(value: PeerObservation): ByteArray = + ByteArrayOutputStream().use { buffer -> + DataOutputStream(buffer).use { output -> + output.writeUTF(value.peer.value) + output.writeLong(value.observedAt.toEpochMilli()) + output.writeStrings(value.addresses) + output.writeStrings(value.neighbors.map { it.value }.toSet()) + } + buffer.toByteArray() + } + + fun decode(bytes: ByteArray): PeerObservation? = runCatching { + DataInputStream(ByteArrayInputStream(bytes)).use { input -> + val peer = PeerId(input.readUTF()) + val instant = Instant.ofEpochMilli(input.readLong()) + val addresses = input.readStrings() + val neighbors = input.readStrings().map(::PeerId).toSet() + require(input.available() == 0) + PeerObservation(peer, addresses, neighbors, instant) + } + } + .getOrNull() + + private fun DataOutputStream.writeStrings(values: Set) { + require(values.size <= 10_000) + writeInt(values.size) + values.sorted().forEach(::writeUTF) + } + + private fun DataInputStream.readStrings(): Set { + val count = readInt() + require(count in 0..10_000) + return buildSet(count) { repeat(count) { add(readUTF()) } } + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/router/OffchainRouter.kt b/app/src/main/kotlin/rip/crit/twist/router/OffchainRouter.kt new file mode 100644 index 0000000..3cf9735 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/router/OffchainRouter.kt @@ -0,0 +1,244 @@ +package rip.crit.twist.router + +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.math.BigInteger +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount +import rip.crit.twist.core.Hash +import rip.crit.twist.core.Sha256 +import rip.crit.twist.core.SmartDoc +import rip.crit.twist.ecdsa.Secp256k1Ecdsa +import rip.crit.twist.p2p.NetworkMessage +import rip.crit.twist.p2p.PeerNetwork +import rip.crit.twist.proof.DynamicProofVerifier +import rip.crit.twist.proof.Proof +import rip.crit.twist.proof.ProofOfValue +import rip.crit.twist.proof.ProofOfWork +import rip.crit.twist.store.KeyValueStore + +sealed interface ProofPolicy { + fun accepts(proof: Proof?, subject: Hash, worker: Address): Boolean + + data object None : ProofPolicy { + override fun accepts(proof: Proof?, subject: Hash, worker: Address) = proof == null + } + + data class Work(val minimumDifficulty: Int, val maximumAttempts: Long = 1_000_000) : + ProofPolicy { + init { + require(minimumDifficulty in 0..64) + require(maximumAttempts > 0) + } + + override fun accepts(proof: Proof?, subject: Hash, worker: Address): Boolean = + proof is ProofOfWork && + proof.subject == subject && + proof.difficulty >= minimumDifficulty && + proof.verify() + } + + data class Value(val minimumValue: BigInteger) : ProofPolicy { + init { + require(minimumValue.signum() >= 0) + } + + override fun accepts(proof: Proof?, subject: Hash, worker: Address): Boolean = + proof is ProofOfValue && + proof.subject == subject && + proof.beneficiary == worker.value && + proof.value >= minimumValue && + proof.verify() + } + + class Dynamic( + private val acceptedSchemes: Set, + private val verifier: DynamicProofVerifier = DynamicProofVerifier(), + ) : ProofPolicy { + override fun accepts(proof: Proof?, subject: Hash, worker: Address): Boolean = + proof != null && + proof.subject == subject && + proof.scheme in acceptedSchemes && + verifier.verify(proof) + } +} + +data class OffchainJob( + val id: Hash, + val requester: Address, + val payload: ByteArray, + val reward: Amount = Amount(BigInteger.ZERO), + val deadlineMillis: Long = Long.MAX_VALUE, + val proofPolicy: ProofPolicy = ProofPolicy.None, +) { + init { + require(deadlineMillis >= 0) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OffchainJob + + if (deadlineMillis != other.deadlineMillis) return false + if (id != other.id) return false + if (requester != other.requester) return false + if (!payload.contentEquals(other.payload)) return false + if (reward != other.reward) return false + if (proofPolicy != other.proofPolicy) return false + + return true + } + + override fun hashCode(): Int { + var result = deadlineMillis.hashCode() + result = 31 * result + id.hashCode() + result = 31 * result + requester.hashCode() + result = 31 * result + payload.contentHashCode() + result = 31 * result + reward.hashCode() + result = 31 * result + proofPolicy.hashCode() + return result + } +} + +class OffchainResult( + val jobId: Hash, + val worker: Address, + val output: ByteArray, + val proof: Proof?, + val signature: ByteArray, +) { + val outputHash: Hash = Sha256.digest(output) +} + +fun interface OffchainComputation { + fun compute(payload: ByteArray): ByteArray +} + +interface ResultDistributor { + fun distribute(result: OffchainResult) +} + +class NetworkResultDistributor( + private val network: PeerNetwork, + private val store: KeyValueStore? = null, + private val topic: String = "twist/offchain/results/v1", +) : ResultDistributor { + override fun distribute(result: OffchainResult) { + val encoded = ResultEncoding.encode(result) + store?.put(result.jobId.value.encodeToByteArray(), encoded) + network.broadcast(NetworkMessage(topic, encoded, Sha256.digest(encoded))) + } +} + +class OffchainWorker( + val address: Address, + private val privateKey: ByteArray, + val publicKey: ByteArray, + private val computation: OffchainComputation, + private val proofFactory: ((ProofPolicy, Hash, Address) -> Proof?)? = null, + private val signer: Secp256k1Ecdsa = Secp256k1Ecdsa(), +) { + init { + require(signer.publicKey(privateKey).contentEquals(publicKey)) + } + + fun execute(job: OffchainJob): OffchainResult { + val output = computation.compute(job.payload.copyOf()) + val outputHash = Sha256.digest(output) + val proof = createProof(job.proofPolicy, outputHash) + val unsigned = ResultEncoding.signingBytes(job.id, address, outputHash, proof) + return OffchainResult(job.id, address, output, proof, signer.sign(unsigned, privateKey)) + } + + private fun createProof(policy: ProofPolicy, subject: Hash): Proof? = + when (policy) { + ProofPolicy.None -> null + is ProofPolicy.Work -> + ProofOfWork.mine(subject, policy.minimumDifficulty, policy.maximumAttempts) + ?: error("Proof of work search exhausted") + is ProofPolicy.Value -> ProofOfValue.create(subject, address.value, policy.minimumValue) + is ProofPolicy.Dynamic -> + requireNotNull(proofFactory?.invoke(policy, subject, address)) { + "Dynamic proof policy requires a worker proof factory" + } + } +} + +@SmartDoc( + summary = "Signed off-chain job routing with optional proof validation.", + category = "Routing", +) +class OffchainRouter( + private val distributor: ResultDistributor, + private val clockMillis: () -> Long = System::currentTimeMillis, + private val verifier: Secp256k1Ecdsa = Secp256k1Ecdsa(), +) { + private val workers = linkedMapOf() + + fun register(worker: OffchainWorker) { + require(worker.address !in workers) { "Worker already registered" } + workers[worker.address] = worker + } + + fun route(job: OffchainJob, worker: Address? = null): OffchainResult { + require(clockMillis() <= job.deadlineMillis) { "Job deadline has passed" } + val selected = worker?.let(workers::get) ?: workers.values.firstOrNull() + requireNotNull(selected) { "No eligible worker registered" } + val result = selected.execute(job) + require(verify(job, result, selected.publicKey)) { "Worker result failed verification" } + distributor.distribute(result) + return result + } + + fun verify(job: OffchainJob, result: OffchainResult, publicKey: ByteArray): Boolean { + if (result.jobId != job.id || result.outputHash != Sha256.digest(result.output)) + return false + if (!job.proofPolicy.accepts(result.proof, result.outputHash, result.worker)) return false + return verifier.verify( + ResultEncoding.signingBytes(job.id, result.worker, result.outputHash, result.proof), + result.signature, + publicKey, + ) + } + + fun verify(job: OffchainJob, result: OffchainResult): Boolean = + workers[result.worker]?.let { verify(job, result, it.publicKey) } ?: false +} + +object ResultEncoding { + fun signingBytes(jobId: Hash, worker: Address, outputHash: Hash, proof: Proof?): ByteArray = + bytes { + writeUTF(jobId.value) + writeUTF(worker.value) + writeUTF(outputHash.value) + writeUTF(proof?.scheme.orEmpty()) + writeUTF(proof?.subject?.value.orEmpty()) + writeUTF(proofDetails(proof)) + } + + fun encode(result: OffchainResult): ByteArray = bytes { + val signing = signingBytes(result.jobId, result.worker, result.outputHash, result.proof) + writeInt(signing.size) + write(signing) + writeInt(result.output.size) + write(result.output) + writeInt(result.signature.size) + write(result.signature) + } + + private fun proofDetails(proof: Proof?): String = + when (proof) { + null -> "" + is ProofOfWork -> "${proof.nonce}:${proof.difficulty}" + is ProofOfValue -> "${proof.beneficiary}:${proof.value}:${proof.commitment.value}" + else -> proof.toString() + } + + private fun bytes(write: DataOutputStream.() -> Unit): ByteArray = + ByteArrayOutputStream().use { buffer -> + DataOutputStream(buffer).use { it.write() } + buffer.toByteArray() + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/rsa/Rsa.kt b/app/src/main/kotlin/rip/crit/twist/rsa/Rsa.kt new file mode 100644 index 0000000..83b52ad --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/rsa/Rsa.kt @@ -0,0 +1,7 @@ +package rip.crit.twist.rsa + +interface Rsa { + fun encrypt(message: ByteArray, publicKey: ByteArray): ByteArray + + fun decrypt(ciphertext: ByteArray, privateKey: ByteArray): ByteArray +} diff --git a/app/src/main/kotlin/rip/crit/twist/rsa/RsaOaep.kt b/app/src/main/kotlin/rip/crit/twist/rsa/RsaOaep.kt new file mode 100644 index 0000000..9846c61 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/rsa/RsaOaep.kt @@ -0,0 +1,122 @@ +package rip.crit.twist.rsa + +import java.math.BigInteger +import rip.crit.twist.core.Sha256 + +data class RsaPublicKey(val modulus: BigInteger, val exponent: BigInteger) + +data class RsaPrivateKey(val modulus: BigInteger, val exponent: BigInteger) + +data class RsaKeyPair( + val publicKey: RsaPublicKey, + val privateKey: RsaPrivateKey, +) { + companion object { + fun fromPrimes( + p: BigInteger, + q: BigInteger, + e: BigInteger = BigInteger.valueOf(65537), + ): RsaKeyPair { + require(p.isProbablePrime(100) && q.isProbablePrime(100) && p != q) + val n = p * q + val lambda = + (p - BigInteger.ONE) + .multiply(q - BigInteger.ONE) + .divide((p - BigInteger.ONE).gcd(q - BigInteger.ONE)) + require(e.gcd(lambda) == BigInteger.ONE) + return RsaKeyPair( + RsaPublicKey(n, e), + RsaPrivateKey(n, e.modInverse(lambda)), + ) + } + } +} + +/** RFC 8017 RSAES-OAEP primitive with caller-supplied randomness. */ +object RsaOaep { + fun encrypt( + message: ByteArray, + key: RsaPublicKey, + seed: ByteArray, + label: ByteArray = byteArrayOf(), + ): ByteArray { + val k = (key.modulus.bitLength() + 7) / 8 + val h = Sha256.bytes(label) + require(seed.size == 32 && message.size <= k - 66) + val db = h + ByteArray(k - message.size - 66) + byteArrayOf(1) + message + val dbMask = mgf(seed, k - 33) + val maskedDb = xor(db, dbMask) + val seedMask = mgf(maskedDb, 32) + return i2osp( + BigInteger( + 1, + byteArrayOf(0) + xor(seed, seedMask) + maskedDb, + ) + .modPow(key.exponent, key.modulus), + k, + ) + } + + fun decrypt( + ciphertext: ByteArray, + key: RsaPrivateKey, + label: ByteArray = byteArrayOf(), + ): ByteArray { + val k = (key.modulus.bitLength() + 7) / 8 + require(ciphertext.size == k) + val encoded = i2osp(BigInteger(1, ciphertext).modPow(key.exponent, key.modulus), k) + require(encoded[0].toInt() == 0) + val maskedSeed = encoded.copyOfRange(1, 33) + val maskedDb = encoded.copyOfRange(33, k) + val seed = xor(maskedSeed, mgf(maskedDb, 32)) + val db = xor(maskedDb, mgf(seed, k - 33)) + require(db.copyOfRange(0, 32).contentEquals(Sha256.bytes(label))) + var index = 32 + while (index < db.size && db[index].toInt() == 0) index++ + require(index < db.size && db[index].toInt() == 1) + return db.copyOfRange( + index + 1, + db.size, + ) + } + + private fun mgf(seed: ByteArray, length: Int): ByteArray { + val output = ByteArray(length) + var offset = 0 + var counter = 0 + while (offset < length) { + val c = + byteArrayOf( + (counter ushr 24).toByte(), + (counter ushr 16).toByte(), + (counter ushr 8).toByte(), + counter.toByte(), + ) + val hash = Sha256.bytes(seed + c) + hash.copyInto( + output, + offset, + 0, + minOf(hash.size, length - offset), + ) + offset += hash.size + counter++ + } + return output + } + + private fun xor(a: ByteArray, b: ByteArray) = + ByteArray(a.size) { (a[it].toInt() xor b[it].toInt()).toByte() } + + private fun i2osp(value: BigInteger, size: Int): ByteArray { + val source = value.toByteArray() + require(source.size <= size + 1) + return ByteArray(size).also { + source.copyInto( + it, + (size - source.size).coerceAtLeast(0), + (source.size - size).coerceAtLeast(0), + ) + } + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/smartdoc/SmartDocGenerator.kt b/app/src/main/kotlin/rip/crit/twist/smartdoc/SmartDocGenerator.kt new file mode 100644 index 0000000..6581135 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/smartdoc/SmartDocGenerator.kt @@ -0,0 +1,96 @@ +package rip.crit.twist.smartdoc + +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.extension +import kotlin.io.path.name + +data class SmartDocEntry( + val name: String, + val packageName: String, + val category: String, + val summary: String, + val stability: String, + val source: String, +) + +object SmartDocGenerator { + fun generate(sourceRoot: Path, output: Path): List { + val root = + if (Files.isDirectory(sourceRoot)) { + sourceRoot + } else if (Files.isDirectory(Path.of("..").resolve(sourceRoot))) { + Path.of("..").resolve(sourceRoot).normalize() + } else if (Files.isDirectory(Path.of("../..").resolve(sourceRoot))) { + Path.of("../..").resolve(sourceRoot).normalize() + } else { + sourceRoot + } + require(Files.isDirectory(root)) { "Source directory does not exist: $sourceRoot (resolved: $root)" } + val entries = + Files.walk(root).use { paths -> + paths + .filter { Files.isRegularFile(it) && it.extension == "kt" } + .flatMap { path -> parse(path, root).stream() } + .sorted(compareBy(SmartDocEntry::category, SmartDocEntry::name)) + .toList() + } + Files.createDirectories(output) + Files.writeString(output.resolve("index.html"), page(entries)) + Files.writeString(output.resolve("api.json"), json(entries)) + return entries + } + + private fun parse(path: Path, root: Path): List { + val source = Files.readString(path) + val packageName = PACKAGE.find(source)?.groupValues?.get(1).orEmpty() + return ANNOTATION.findAll(source) + .mapNotNull { match -> + val tail = source.substring(match.range.last + 1) + val declaration = DECLARATION.find(tail) ?: return@mapNotNull null + SmartDocEntry( + declaration.groupValues[2], + packageName, + match.groupValues[2], + match.groupValues[1], + match.groupValues[3].ifBlank { "experimental" }, + root.relativize(path).toString(), + ) + } + .toList() + } + + private fun page(entries: List): String = + """ + + + Twist SmartDoc API

Twist SmartDoc API

${entries.size} documented public APIs.

+ ${entries.joinToString("\n") { entry -> "
${escape(entry.category)} · ${escape(entry.stability)}

${escape(entry.name)}

${escape(entry.packageName)}

${escape(entry.summary)}

${escape(entry.source)}
" }} + + """ + .trimIndent() + + private fun json(entries: List): String = + entries.joinToString(",", "[", "]") { + "{\"name\":\"${escape(it.name)}\",\"package\":\"${escape(it.packageName)}\",\"category\":\"${escape(it.category)}\",\"summary\":\"${escape(it.summary)}\",\"stability\":\"${escape(it.stability)}\",\"source\":\"${escape(it.source)}\"}" + } + + private fun escape(value: String): String = + value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + + private val PACKAGE = Regex("(?m)^package\\s+([\\w.]+)") + private val ANNOTATION = + Regex( + "@SmartDoc\\(\\s*summary\\s*=\\s*\"([^\"]+)\"\\s*,\\s*category\\s*=\\s*\"([^\"]+)\"(?:\\s*,\\s*stability\\s*=\\s*\"([^\"]+)\")?,?\\s*\\)" + ) + private val DECLARATION = + Regex("(?:public\\s+)?(class|object|fun|interface)\\s+([A-Za-z_][A-Za-z0-9_]*)") +} diff --git a/app/src/main/kotlin/rip/crit/twist/stake/InMemoryStaking.kt b/app/src/main/kotlin/rip/crit/twist/stake/InMemoryStaking.kt new file mode 100644 index 0000000..27d18b1 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/stake/InMemoryStaking.kt @@ -0,0 +1,25 @@ +package rip.crit.twist.stake + +import java.math.BigInteger +import java.util.concurrent.ConcurrentHashMap +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount + +class InMemoryStaking : Staking { + private val delegations = ConcurrentHashMap, Amount>() + + override fun stake(delegator: Address, validator: Address, amount: Amount) { + require(amount.value > BigInteger.ZERO) + delegations.compute(delegator to validator) { _, current -> + Amount((current?.value ?: BigInteger.ZERO) + amount.value) + } + } + + override fun votingPower(validator: Address): Amount = + Amount( + delegations + .filterKeys { it.second == validator } + .values + .fold(BigInteger.ZERO) { total, amount -> total + amount.value } + ) +} diff --git a/app/src/main/kotlin/rip/crit/twist/stake/PersistentStaking.kt b/app/src/main/kotlin/rip/crit/twist/stake/PersistentStaking.kt new file mode 100644 index 0000000..2ad8595 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/stake/PersistentStaking.kt @@ -0,0 +1,25 @@ +package rip.crit.twist.stake + +import java.math.BigInteger +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount +import rip.crit.twist.store.KeyValueStore + +class PersistentStaking(private val store: KeyValueStore) : Staking { + override fun stake(delegator: Address, validator: Address, amount: Amount) { + require(amount.value > BigInteger.ZERO) + val key = delegationKey(delegator, validator) + val current = store.get(key)?.let(::BigInteger) ?: BigInteger.ZERO + store.put(key, (current + amount.value).toByteArray()) + val total = store.get(totalKey(validator))?.let(::BigInteger) ?: BigInteger.ZERO + store.put(totalKey(validator), (total + amount.value).toByteArray()) + } + + override fun votingPower(validator: Address): Amount = + Amount(store.get(totalKey(validator))?.let(::BigInteger) ?: BigInteger.ZERO) + + private fun delegationKey(delegator: Address, validator: Address) = + "delegation:${delegator.value}:${validator.value}".encodeToByteArray() + + private fun totalKey(validator: Address) = "validator:${validator.value}".encodeToByteArray() +} diff --git a/app/src/main/kotlin/rip/crit/twist/stake/Staking.kt b/app/src/main/kotlin/rip/crit/twist/stake/Staking.kt new file mode 100644 index 0000000..46e3a9b --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/stake/Staking.kt @@ -0,0 +1,10 @@ +package rip.crit.twist.stake + +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount + +interface Staking { + fun stake(delegator: Address, validator: Address, amount: Amount) + + fun votingPower(validator: Address): Amount +} diff --git a/app/src/main/kotlin/rip/crit/twist/standards/InMemoryToken.kt b/app/src/main/kotlin/rip/crit/twist/standards/InMemoryToken.kt new file mode 100644 index 0000000..bc2f620 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/standards/InMemoryToken.kt @@ -0,0 +1,25 @@ +package rip.crit.twist.standards + +import java.math.BigInteger +import java.util.concurrent.ConcurrentHashMap +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount + +class InMemoryToken( + override val symbol: String, + initialBalances: Map = emptyMap(), +) : TokenStandard { + private val balances = ConcurrentHashMap(initialBalances) + + override fun balanceOf(owner: Address): Amount = balances[owner] ?: Amount(BigInteger.ZERO) + + override fun transfer(from: Address, to: Address, amount: Amount): Boolean = + synchronized(this) { + if (amount.value == BigInteger.ZERO || balanceOf(from).value < amount.value) false + else { + balances[from] = Amount(balanceOf(from).value - amount.value) + balances[to] = Amount(balanceOf(to).value + amount.value) + true + } + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/standards/PersistentToken.kt b/app/src/main/kotlin/rip/crit/twist/standards/PersistentToken.kt new file mode 100644 index 0000000..9278309 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/standards/PersistentToken.kt @@ -0,0 +1,28 @@ +package rip.crit.twist.standards + +import java.math.BigInteger +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount +import rip.crit.twist.store.KeyValueStore + +class PersistentToken(override val symbol: String, private val store: KeyValueStore) : + TokenStandard { + override fun balanceOf(owner: Address): Amount = + Amount(store.get(key(owner))?.let(::BigInteger) ?: BigInteger.ZERO) + + override fun transfer(from: Address, to: Address, amount: Amount): Boolean = + synchronized(this) { + val source = balanceOf(from).value + if (amount.value == BigInteger.ZERO || source < amount.value) return false + store.put(key(from), (source - amount.value).toByteArray()) + store.put(key(to), (balanceOf(to).value + amount.value).toByteArray()) + true + } + + fun mint(to: Address, amount: Amount) = + synchronized(this) { + store.put(key(to), (balanceOf(to).value + amount.value).toByteArray()) + } + + private fun key(owner: Address) = "$symbol:${owner.value}".encodeToByteArray() +} diff --git a/app/src/main/kotlin/rip/crit/twist/standards/TokenStandard.kt b/app/src/main/kotlin/rip/crit/twist/standards/TokenStandard.kt new file mode 100644 index 0000000..2580048 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/standards/TokenStandard.kt @@ -0,0 +1,12 @@ +package rip.crit.twist.standards + +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount + +interface TokenStandard { + val symbol: String + + fun balanceOf(owner: Address): Amount + + fun transfer(from: Address, to: Address, amount: Amount): Boolean +} diff --git a/app/src/main/kotlin/rip/crit/twist/state/FileNodeState.kt b/app/src/main/kotlin/rip/crit/twist/state/FileNodeState.kt new file mode 100644 index 0000000..c21da7e --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/state/FileNodeState.kt @@ -0,0 +1,71 @@ +package rip.crit.twist.state + +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.Base64 +import java.util.concurrent.ConcurrentHashMap + +/** File-backed node state rooted at a caller-selected folder. */ +class FileNodeState( + private val root: Path = Paths.get(".twist", "nodes"), +) : NodeState { + private val nodes = ConcurrentHashMap() + + init { + Files.createDirectories(root) + Files.list(root).use { files -> + files.filter(Files::isRegularFile).forEach { file -> + runCatching { + ObjectInputStream(Files.newInputStream(file)).use { input -> + nodes[file.fileName.toString()] = input.readObject() as Node + } + } + } + } + } + + override fun getNode(key: String): Node? = nodes[safe(key)] + + override fun putNode(key: String, node: Node) { + val filename = safe(key) + val target = root.resolve(filename) + val temporary = Files.createTempFile(root, "node-", ".tmp") + try { + ObjectOutputStream(Files.newOutputStream(temporary)).use { it.writeObject(node) } + Files.move( + temporary, + target, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + nodes[filename] = node + } finally { + Files.deleteIfExists(temporary) + } + } + + override fun rootHash(): String { + val digest = MessageDigest.getInstance("SHA-256") + Files.list(root).use { files -> + files.filter(Files::isRegularFile).sorted().forEach { file -> + digest.update(file.fileName.toString().encodeToByteArray()) + digest.update(Files.readAllBytes(file)) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + override fun snapshot(): StateSnapshot = FileNodeSnapshot(nodes.toMap()) + + private fun safe(value: String): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(value.encodeToByteArray()) +} + +class FileNodeSnapshot internal constructor(internal val nodes: Map) : StateSnapshot { + override fun close() = Unit +} diff --git a/app/src/main/kotlin/rip/crit/twist/state/FileWorldState.kt b/app/src/main/kotlin/rip/crit/twist/state/FileWorldState.kt new file mode 100644 index 0000000..3ead775 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/state/FileWorldState.kt @@ -0,0 +1,116 @@ +package rip.crit.twist.state + +import java.math.BigInteger +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.Base64 +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount +import rip.crit.twist.core.Hash +import rip.crit.twist.core.Sha256 + +/** File-backed account and contract state rooted at a caller-selected folder. */ +class FileWorldState(private val root: Path) : ContractState { + private val accounts = root.resolve("accounts") + private val storage = root.resolve("storage") + + init { + Files.createDirectories(accounts) + Files.createDirectories(storage) + } + + override fun account(address: Address): Account? { + val lines = + path(accounts, address.value).takeIf(Files::exists)?.let(Files::readAllLines) + ?: return null + return Account( + lines[0].toLong(), + Amount(BigInteger(lines[1])), + Base64.getDecoder().decode(lines[2]), + ) + } + + override fun putAccount(address: Address, account: Account) = + write( + path(accounts, address.value), + listOf( + account.nonce.toString(), + account.balance.value.toString(), + Base64.getEncoder().encodeToString(account.code), + ), + ) + + override fun deleteAccount(address: Address) { + Files.deleteIfExists(path(accounts, address.value)) + deleteStorage(address) + } + + override fun storage(contract: Address, key: Hash): ByteArray? = + path(storage.resolve(safe(contract.value)), key.value) + .takeIf(Files::exists) + ?.let(Files::readAllBytes) + + override fun putStorage(contract: Address, key: Hash, value: ByteArray) { + val directory = storage.resolve(safe(contract.value)) + Files.createDirectories(directory) + writeBytes(path(directory, key.value), value) + } + + override fun deleteStorage(contract: Address) { + val directory = storage.resolve(safe(contract.value)) + if (!Files.exists(directory)) return + Files.walk(directory).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach(Files::deleteIfExists) + } + } + + override fun rootHash(): Hash { + val content = + Files.walk(root).use { paths -> + paths + .filter(Files::isRegularFile) + .sorted() + .flatMap { path -> + java.util.stream.Stream.of( + root.relativize(path).toString().encodeToByteArray(), + Files.readAllBytes(path), + ) + } + .reduce(byteArrayOf()) { a, b -> a + b } + } + return Sha256.digest(content) + } + + override fun snapshot(): StateSnapshot = + object : StateSnapshot { + override fun close() = Unit + } + + private fun path(directory: Path, key: String) = directory.resolve(safe(key)) + + private fun safe(value: String) = + Base64.getUrlEncoder().withoutPadding().encodeToString(value.encodeToByteArray()) + + private fun write(target: Path, lines: List) { + val temporary = Files.createTempFile(target.parent, "state-", ".tmp") + Files.write(temporary, lines) + Files.move( + temporary, + target, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } + + private fun writeBytes(target: Path, value: ByteArray) { + val temporary = Files.createTempFile(target.parent, "slot-", ".tmp") + Files.write(temporary, value) + Files.move( + temporary, + target, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/state/InMemoryNodeState.kt b/app/src/main/kotlin/rip/crit/twist/state/InMemoryNodeState.kt new file mode 100644 index 0000000..68c741e --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/state/InMemoryNodeState.kt @@ -0,0 +1,55 @@ +package rip.crit.twist.state + +import java.util.concurrent.ConcurrentHashMap +import rip.crit.twist.core.Sha256 + +class InMemoryNodeState : NodeState { + private val nodes = ConcurrentHashMap() + + override fun getNode(key: String): Node? = nodes[key]?.deepCopy() + + override fun putNode(key: String, node: Node) { + require(key.isNotBlank()) + nodes[key] = node.deepCopy() + } + + override fun rootHash(): String { + val bytes = + nodes.entries + .sortedBy { it.key } + .fold(byteArrayOf()) { output, (key, node) -> + output + key.encodeToByteArray() + canonical(node) + } + return Sha256.digest(bytes).value + } + + override fun snapshot(): StateSnapshot = NodeSnapshot(nodes.mapValues { it.value.deepCopy() }) + + private fun canonical(node: Node): ByteArray { + val storage = + node.storage.entries + .sortedBy { it.key } + .fold(byteArrayOf()) { output, entry -> + output + entry.key.encodeToByteArray() + entry.value + } + return node.accounts.entries + .sortedBy { it.key } + .fold(storage) { output, entry -> + output + + entry.key.encodeToByteArray() + + entry.value.balance.toString().encodeToByteArray() + + entry.value.nonce.toString().encodeToByteArray() + + entry.value.code + } + } + + private fun Node.deepCopy() = + copy( + storage = storage.mapValues { it.value.copyOf() }, + accounts = accounts.mapValues { it.value.copy(code = it.value.code.copyOf()) }, + ) +} + +class NodeSnapshot internal constructor(internal val nodes: Map) : StateSnapshot { + override fun close() = Unit +} diff --git a/app/src/main/kotlin/rip/crit/twist/state/InMemoryWorldState.kt b/app/src/main/kotlin/rip/crit/twist/state/InMemoryWorldState.kt new file mode 100644 index 0000000..a5252f4 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/state/InMemoryWorldState.kt @@ -0,0 +1,59 @@ +package rip.crit.twist.state + +import java.util.concurrent.ConcurrentHashMap +import rip.crit.twist.core.Address +import rip.crit.twist.core.Hash +import rip.crit.twist.core.Sha256 + +class InMemoryWorldState : ContractState { + private val accounts = ConcurrentHashMap() + private val storage = ConcurrentHashMap, ByteArray>() + + override fun account(address: Address): Account? = + accounts[address]?.let { it.copy(code = it.code.copyOf()) } + + override fun putAccount(address: Address, account: Account) { + accounts[address] = account.copy(code = account.code.copyOf()) + } + + override fun deleteAccount(address: Address) { + accounts.remove(address) + deleteStorage(address) + } + + override fun rootHash(): Hash = + Sha256.digestUtf8( + accounts.entries + .sortedBy { it.key.value } + .joinToString("|") { (address, account) -> + "${address.value},${account.nonce},${account.balance.value},${ + account.code.joinToString("") { + "%02x".format( + it + ) + } + }" + } + ) + + override fun snapshot(): StateSnapshot = + InMemorySnapshot( + accounts.mapValues { (_, account) -> account.copy(code = account.code.copyOf()) } + ) + + override fun storage(contract: Address, key: Hash): ByteArray? = + storage[contract to key]?.copyOf() + + override fun putStorage(contract: Address, key: Hash, value: ByteArray) { + storage[contract to key] = value.copyOf() + } + + override fun deleteStorage(contract: Address) { + storage.keys.removeIf { it.first == contract } + } +} + +class InMemorySnapshot internal constructor(internal val accounts: Map) : + StateSnapshot { + override fun close() = Unit +} diff --git a/app/src/main/kotlin/rip/crit/twist/state/NodeState.kt b/app/src/main/kotlin/rip/crit/twist/state/NodeState.kt new file mode 100644 index 0000000..bf70898 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/state/NodeState.kt @@ -0,0 +1,42 @@ +package rip.crit.twist.state + +data class Node( + val storage: Map = emptyMap(), + val accounts: Map = emptyMap(), +) + +data class AccountState( + val balance: Long = 0, + val nonce: Long = 0, + val code: ByteArray = ByteArray(0), +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AccountState + + if (balance != other.balance) return false + if (nonce != other.nonce) return false + if (!code.contentEquals(other.code)) return false + + return true + } + + override fun hashCode(): Int { + var result = balance.hashCode() + result = 31 * result + nonce.hashCode() + result = 31 * result + code.contentHashCode() + return result + } +} + +interface NodeState { + fun getNode(key: String): Node? + + fun putNode(key: String, node: Node) + + fun rootHash(): String + + fun snapshot(): StateSnapshot +} diff --git a/app/src/main/kotlin/rip/crit/twist/state/WorldState.kt b/app/src/main/kotlin/rip/crit/twist/state/WorldState.kt new file mode 100644 index 0000000..b8f934f --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/state/WorldState.kt @@ -0,0 +1,51 @@ +package rip.crit.twist.state + +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount +import rip.crit.twist.core.Hash + +data class Account(val nonce: Long = 0, val balance: Amount, val code: ByteArray = byteArrayOf()) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Account + + if (nonce != other.nonce) return false + if (balance != other.balance) return false + if (!code.contentEquals(other.code)) return false + + return true + } + + override fun hashCode(): Int { + var result = nonce.hashCode() + result = 31 * result + balance.hashCode() + result = 31 * result + code.contentHashCode() + return result + } +} + +interface WorldState { + fun account(address: Address): Account? + + fun putAccount(address: Address, account: Account) + + fun deleteAccount(address: Address) + + fun rootHash(): Hash + + fun snapshot(): StateSnapshot +} + +interface ContractState : WorldState { + fun storage(contract: Address, key: Hash): ByteArray? + + fun putStorage(contract: Address, key: Hash, value: ByteArray) + + fun deleteStorage(contract: Address) +} + +interface StateSnapshot : AutoCloseable { + override fun close() +} diff --git a/app/src/main/kotlin/rip/crit/twist/store/FileStore.kt b/app/src/main/kotlin/rip/crit/twist/store/FileStore.kt new file mode 100644 index 0000000..b4d2315 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/store/FileStore.kt @@ -0,0 +1,108 @@ +package rip.crit.twist.store + +import java.math.BigInteger +import java.nio.file.Files +import java.nio.file.Path +import java.util.Base64 +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount +import rip.crit.twist.core.Block +import rip.crit.twist.core.BlockHeader +import rip.crit.twist.core.Hash +import rip.crit.twist.core.Transaction + +/** File-per-key store with atomic replacement and defensive copies. */ +class FileKeyValueStore(private val root: Path) : KeyValueStore { + init { + Files.createDirectories(root) + } + + override fun get(key: ByteArray): ByteArray? = + root.resolve(key.encode()).takeIf(Files::exists)?.let(Files::readAllBytes) + + override fun put(key: ByteArray, value: ByteArray) { + val target = root.resolve(key.encode()) + val temporary = Files.createTempFile(root, "put-", ".tmp") + Files.write(temporary, value) + Files.move( + temporary, + target, + java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE, + ) + } + + override fun delete(key: ByteArray) { + Files.deleteIfExists(root.resolve(key.encode())) + } + + private fun ByteArray.encode(): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(this) +} + +class FileBlockStore(private val root: Path) : BlockStore { + init { + Files.createDirectories(root) + } + + override fun get(hash: Hash): Block? = + root.resolve("${hash.value}.block").takeIf(Files::exists)?.let { + decode(Files.readAllLines(it)) + } + + override fun put(block: Block) { + val file = root.resolve("${block.hash.value}.block") + val temporary = Files.createTempFile(root, "block-", ".tmp") + Files.write(temporary, encode(block)) + Files.move( + temporary, + file, + java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE, + ) + } + + private fun encode(block: Block): List = buildList { + add(block.hash.value) + add(block.header.parentHash.value) + add(block.header.stateRoot.value) + add(block.header.height.toString()) + add(block.header.timestampMillis.toString()) + add(block.transactions.size.toString()) + block.transactions.forEach { + add( + listOf( + it.id.value, + it.sender.value, + it.recipient?.value.orEmpty(), + it.nonce, + it.value.value, + Base64.getEncoder().encodeToString(it.payload), + ) + .joinToString("\t") + ) + } + } + + private fun decode(lines: List): Block? = runCatching { + require(lines.size >= 6) + val header = + BlockHeader(Hash(lines[1]), Hash(lines[2]), lines[3].toLong(), lines[4].toLong()) + val count = lines[5].toInt() + require(lines.size == 6 + count) + val transactions = + lines.drop(6).map { line -> + val f = line.split("\t", limit = 6) + Transaction( + Hash(f[0]), + Address(f[1]), + f[2].takeIf(String::isNotEmpty)?.let(::Address), + f[3].toLong(), + Amount(BigInteger(f[4])), + Base64.getDecoder().decode(f[5]), + ) + } + Block(header, transactions, Hash(lines[0])) + } + .getOrNull() +} diff --git a/app/src/main/kotlin/rip/crit/twist/store/InMemoryStore.kt b/app/src/main/kotlin/rip/crit/twist/store/InMemoryStore.kt new file mode 100644 index 0000000..3c19b8e --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/store/InMemoryStore.kt @@ -0,0 +1,31 @@ +package rip.crit.twist.store + +import java.util.concurrent.ConcurrentHashMap +import rip.crit.twist.core.Block +import rip.crit.twist.core.Hash + +class InMemoryBlockStore : BlockStore { + private val blocks = ConcurrentHashMap() + + override fun get(hash: Hash): Block? = blocks[hash] + + override fun put(block: Block) { + blocks[block.hash] = block + } +} + +class InMemoryKeyValueStore : KeyValueStore { + private val values = ConcurrentHashMap() + + override fun get(key: ByteArray): ByteArray? = values[key.toKey()]?.copyOf() + + override fun put(key: ByteArray, value: ByteArray) { + values[key.toKey()] = value.copyOf() + } + + override fun delete(key: ByteArray) { + values.remove(key.toKey()) + } + + private fun ByteArray.toKey(): String = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/kotlin/rip/crit/twist/store/Store.kt b/app/src/main/kotlin/rip/crit/twist/store/Store.kt new file mode 100644 index 0000000..40a8f8f --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/store/Store.kt @@ -0,0 +1,18 @@ +package rip.crit.twist.store + +import rip.crit.twist.core.Block +import rip.crit.twist.core.Hash + +interface BlockStore { + fun get(hash: Hash): Block? + + fun put(block: Block) +} + +interface KeyValueStore { + fun get(key: ByteArray): ByteArray? + + fun put(key: ByteArray, value: ByteArray) + + fun delete(key: ByteArray) +} diff --git a/app/src/main/kotlin/rip/crit/twist/transport/AesGcm.kt b/app/src/main/kotlin/rip/crit/twist/transport/AesGcm.kt new file mode 100644 index 0000000..fe957cb --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/transport/AesGcm.kt @@ -0,0 +1,227 @@ +package rip.crit.twist.transport + +import rip.crit.twist.core.SmartDoc + +/** FIPS 197 AES and SP 800-38D GCM implementation. */ +class Aes(private val key: ByteArray) { + private val rounds = key.size / 4 + 6 + private val schedule: IntArray + + init { + require(key.size in setOf(16, 24, 32)) + schedule = expandKey(key) + } + + fun encryptBlock(input: ByteArray): ByteArray { + require(input.size == 16) + var state = input.copyOf() + addRoundKey(state, 0) + for (round in 1 until rounds) { + subBytes(state) + state = shiftRows(state) + mixColumns(state) + addRoundKey(state, round) + } + subBytes(state) + state = shiftRows(state) + addRoundKey(state, rounds) + return state + } + + private fun expandKey(key: ByteArray): IntArray { + val nk = key.size / 4 + val words = IntArray(4 * (rounds + 1)) + for (i in 0 until nk) words[i] = + ((key[4 * i].toInt() and 255) shl 24) or + ((key[4 * i + 1].toInt() and 255) shl 16) or + ((key[4 * i + 2].toInt() and 255) shl 8) or + (key[4 * i + 3].toInt() and 255) + var rcon = 1 + for (i in nk until words.size) { + var temp = words[i - 1] + if (i % nk == 0) { + temp = subWord(temp.rotateLeft(8)) xor (rcon shl 24) + rcon = multiply(rcon, 2) + } else if (nk > 6 && i % nk == 4) temp = subWord(temp) + words[i] = words[i - nk] xor temp + } + return words + } + + private fun subWord(word: Int): Int = + (sbox(word ushr 24) shl 24) or + (sbox(word ushr 16) shl 16) or + (sbox(word ushr 8) shl 8) or + sbox(word) + + private fun subBytes(state: ByteArray) { + for (i in state.indices) state[i] = sbox(state[i].toInt()).toByte() + } + + private fun shiftRows(s: ByteArray): ByteArray = + ByteArray(16).also { out -> + for (r in 0..3) for (c in 0..3) out[r + 4 * c] = s[r + 4 * ((c + r) % 4)] + } + + private fun mixColumns(s: ByteArray) { + for (c in 0..3) { + val i = 4 * c + val a = IntArray(4) { s[i + it].toInt() and 255 } + s[i] = (multiply(a[0], 2) xor multiply(a[1], 3) xor a[2] xor a[3]).toByte() + s[i + 1] = (a[0] xor multiply(a[1], 2) xor multiply(a[2], 3) xor a[3]).toByte() + s[i + 2] = (a[0] xor a[1] xor multiply(a[2], 2) xor multiply(a[3], 3)).toByte() + s[i + 3] = (multiply(a[0], 3) xor a[1] xor a[2] xor multiply(a[3], 2)).toByte() + } + } + + private fun addRoundKey(s: ByteArray, round: Int) { + for (c in 0..3) { + val w = schedule[round * 4 + c] + for (r in 0..3) s[4 * c + r] = (s[4 * c + r].toInt() xor (w ushr (24 - 8 * r))).toByte() + } + } + + private fun sbox(value: Int): Int { + val x = value and 255 + val inverse = if (x == 0) 0 else power(x, 254) + return (inverse xor + inverse.rotateByte(1) xor + inverse.rotateByte(2) xor + inverse.rotateByte(3) xor + inverse.rotateByte(4) xor + 0x63) and 255 + } + + private fun Int.rotateByte(bits: Int): Int = ((this shl bits) or (this ushr (8 - bits))) and 255 + + private fun power(value: Int, exponent: Int): Int { + var base = value + var power = exponent + var result = 1 + while (power > 0) { + if (power and 1 != 0) result = multiply(result, base) + base = multiply(base, base) + power = power ushr 1 + } + return result + } + + private fun multiply(left: Int, right: Int): Int { + var a = left + var b = right + var result = 0 + repeat(8) { + if (b and 1 != 0) result = result xor a + a = ((a shl 1) xor if (a and 0x80 != 0) 0x11b else 0) and 255 + b = b ushr 1 + } + return result + } +} + +data class GcmCiphertext(val ciphertext: ByteArray, val tag: ByteArray) + +@SmartDoc( + summary = "AES-GCM authenticated encryption with explicit nonces.", + category = "Cryptography", +) +class AesGcm(key: ByteArray) { + private val aes = Aes(key) + + fun encrypt( + nonce: ByteArray, + plaintext: ByteArray, + aad: ByteArray = byteArrayOf(), + ): GcmCiphertext { + require(nonce.size == 12) + val h = aes.encryptBlock(ByteArray(16)) + val j0 = nonce + byteArrayOf(0, 0, 0, 1) + val ciphertext = ctr(j0, plaintext) + val tag = xor(aes.encryptBlock(j0), ghash(h, aad, ciphertext)) + return GcmCiphertext(ciphertext, tag) + } + + fun decrypt( + nonce: ByteArray, + ciphertext: ByteArray, + tag: ByteArray, + aad: ByteArray = byteArrayOf(), + ): ByteArray { + require(tag.size == 16) + val result = encryptTag(nonce, ciphertext, aad) + require(constantEquals(tag, result)) { "GCM authentication failed" } + return ctr(nonce + byteArrayOf(0, 0, 0, 1), ciphertext) + } + + private fun encryptTag(nonce: ByteArray, ciphertext: ByteArray, aad: ByteArray): ByteArray { + require(nonce.size == 12) + val h = aes.encryptBlock(ByteArray(16)) + return xor(aes.encryptBlock(nonce + byteArrayOf(0, 0, 0, 1)), ghash(h, aad, ciphertext)) + } + + private fun ctr(j0: ByteArray, input: ByteArray): ByteArray { + val counter = j0.copyOf() + val out = ByteArray(input.size) + var offset = 0 + while (offset < input.size) { + increment(counter) + val stream = aes.encryptBlock(counter) + val count = minOf(16, input.size - offset) + for (i in 0 until count) out[offset + i] = + (input[offset + i].toInt() xor stream[i].toInt()).toByte() + offset += count + } + return out + } + + private fun increment(counter: ByteArray) { + for (i in 15 downTo 12) { + counter[i] = (counter[i] + 1).toByte() + if (counter[i].toInt() != 0) break + } + } + + private fun ghash(h: ByteArray, aad: ByteArray, ciphertext: ByteArray): ByteArray { + var y = ByteArray(16) + for (block in blocks(aad) + blocks(ciphertext)) y = multiplyGf(xor(y, block), h) + val lengths = ByteArray(16) + writeLong(lengths, 0, aad.size.toLong() * 8) + writeLong(lengths, 8, ciphertext.size.toLong() * 8) + return multiplyGf(xor(y, lengths), h) + } + + private fun blocks(data: ByteArray): List = + data.asList().chunked(16).map { chunk -> + ByteArray(16).also { out -> chunk.forEachIndexed { i, b -> out[i] = b } } + } + + private fun multiplyGf(x: ByteArray, y: ByteArray): ByteArray { + var z = ByteArray(16) + val v = y.copyOf() + for (i in 0 until 128) { + if ((x[i / 8].toInt() and (1 shl (7 - i % 8))) != 0) + for (j in 0..15) z[j] = (z[j].toInt() xor v[j].toInt()).toByte() + val lsb = v[15].toInt() and 1 + for (j in 15 downTo 0) v[j] = + (((v[j].toInt() and 255) ushr 1) or + if (j > 0) ((v[j - 1].toInt() and 1) shl 7) else 0) + .toByte() + if (lsb != 0) v[0] = (v[0].toInt() xor 0xe1).toByte() + } + return z + } + + private fun xor(a: ByteArray, b: ByteArray) = + ByteArray(a.size) { (a[it].toInt() xor b[it].toInt()).toByte() } + + private fun writeLong(out: ByteArray, offset: Int, value: Long) { + for (i in 0..7) out[offset + i] = (value ushr (56 - 8 * i)).toByte() + } + + private fun constantEquals(a: ByteArray, b: ByteArray): Boolean { + if (a.size != b.size) return false + var d = 0 + for (i in a.indices) d = d or (a[i].toInt() xor b[i].toInt()) + return d == 0 + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/transport/SecureTransport.kt b/app/src/main/kotlin/rip/crit/twist/transport/SecureTransport.kt new file mode 100644 index 0000000..b409829 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/transport/SecureTransport.kt @@ -0,0 +1,67 @@ +package rip.crit.twist.transport + +import java.io.DataInputStream +import java.io.DataOutputStream +import java.net.Socket +import kotlin.random.Random +import rip.crit.twist.core.HmacSha256 +import rip.crit.twist.wire.FrameCodec +import rip.crit.twist.wire.PacketCodec +import rip.crit.twist.wire.WirePacket + +data class X25519KeyPair(val privateKey: ByteArray, val publicKey: ByteArray) + +/** X25519 key agreement with HKDF-SHA256 and AES-GCM. */ +class X25519Session private constructor(private val key: ByteArray) { + fun encrypt(plain: ByteArray, nonce: ByteArray): ByteArray { + val encrypted = AesGcm(key).encrypt(nonce, plain) + return encrypted.ciphertext + encrypted.tag + } + + fun decrypt(ciphertext: ByteArray, nonce: ByteArray): ByteArray { + require(ciphertext.size >= 16) + return AesGcm(key) + .decrypt( + nonce, + ciphertext.copyOfRange(0, ciphertext.size - 16), + ciphertext.copyOfRange(ciphertext.size - 16, ciphertext.size), + ) + } + + companion object { + fun generateKeyPair(random: Random = Random.Default): X25519KeyPair { + val privateKey = random.nextBytes(32) + return X25519KeyPair(privateKey, X25519.publicKey(privateKey)) + } + + fun establish( + privateKey: ByteArray, + remotePublicKey: ByteArray, + context: ByteArray = "twist-v1".encodeToByteArray(), + ): X25519Session { + val secret = X25519.sharedSecret(privateKey, remotePublicKey) + val prk = HmacSha256.digest(ByteArray(32), secret) + return X25519Session(HmacSha256.digest(prk, context + byteArrayOf(1))) + } + } +} + +/** Blocking TCP transport, suitable for adapters and integration tests. */ +class TcpPacketTransport(private val socket: Socket) : AutoCloseable { + private val input = DataInputStream(socket.getInputStream()) + private val output = DataOutputStream(socket.getOutputStream()) + + fun send(packet: WirePacket) { + val frame = FrameCodec.encode(PacketCodec.encode(packet)) + output.write(frame) + output.flush() + } + + fun receive(): WirePacket { + val size = input.readInt() + require(size in 0..FrameCodec.MAX_FRAME_SIZE) + return PacketCodec.decode(input.readNBytes(size)) + } + + override fun close() = socket.close() +} diff --git a/app/src/main/kotlin/rip/crit/twist/transport/TcpPeerNetwork.kt b/app/src/main/kotlin/rip/crit/twist/transport/TcpPeerNetwork.kt new file mode 100644 index 0000000..ba314cf --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/transport/TcpPeerNetwork.kt @@ -0,0 +1,122 @@ +package rip.crit.twist.transport + +import java.io.DataInputStream +import java.io.DataOutputStream +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import rip.crit.twist.core.SmartDoc +import rip.crit.twist.p2p.EnvelopeAdapter +import rip.crit.twist.p2p.NetworkAdapter +import rip.crit.twist.p2p.NetworkAdapterKind +import rip.crit.twist.p2p.NetworkMessage +import rip.crit.twist.p2p.PeerId +import rip.crit.twist.p2p.PeerNetwork +import rip.crit.twist.wire.FrameCodec + +/** Basic TCP peer network with framed messages and an explicit adapter boundary. */ +@SmartDoc( + summary = "TCP peer listener, handshake, and broadcast transport.", + category = "Networking", +) +class TcpPeerNetwork( + private val localId: PeerId, + private val requestedPort: Int = 0, + private val adapter: NetworkAdapter = EnvelopeAdapter(NetworkAdapterKind.DEVP2P), +) : PeerNetwork { + private val running = AtomicBoolean(false) + private val connections = ConcurrentHashMap() + private val listeners = CopyOnWriteArrayList<(NetworkMessage) -> Unit>() + private val workers = Executors.newCachedThreadPool() + private var server: ServerSocket? = null + + val port: Int + get() = server?.localPort ?: -1 + + override fun start() { + if (!running.compareAndSet(false, true)) return + server = ServerSocket(requestedPort) + workers.execute { + while (running.get()) { + runCatching { server?.accept() }.getOrNull()?.let(::attach) + } + } + } + + fun connect(host: String, port: Int, timeoutMillis: Int = 5_000) { + check(running.get()) { "Network is not running" } + val socket = Socket() + socket.connect(InetSocketAddress(host, port), timeoutMillis) + attach(socket) + } + + fun subscribe(listener: (NetworkMessage) -> Unit) { + listeners += listener + } + + override fun stop() { + if (!running.compareAndSet(true, false)) return + runCatching { server?.close() } + connections.values.forEach { it.close() } + connections.clear() + workers.shutdownNow() + } + + override fun peers(): Set = connections.keys.toSet() + + override fun broadcast(message: NetworkMessage) { + check(running.get()) { "Network is not running" } + connections.values.forEach { connection -> runCatching { connection.send(message) } } + } + + private fun attach(socket: Socket) { + workers.execute { + val input = DataInputStream(socket.getInputStream()) + val output = DataOutputStream(socket.getOutputStream()) + output.writeUTF(localId.value) + output.flush() + val remote = PeerId(input.readUTF()) + require(remote != localId) { "Self connection is not allowed" } + val connection = Connection(socket, input, output) + val previous = connections.put(remote, connection) + previous?.close() + try { + while (running.get() && !socket.isClosed) { + val size = input.readInt() + require(size in 0..FrameCodec.MAX_FRAME_SIZE) { "Invalid network frame length" } + val frame = ByteArray(size).also(input::readFully) + val message = adapter.decode(frame) + listeners.forEach { listener -> listener(message) } + } + } finally { + connections.remove(remote, connection) + connection.close() + } + } + } + + private inner class Connection( + private val socket: Socket, + private val input: DataInputStream, + private val output: DataOutputStream, + ) { + @Synchronized + fun send(message: NetworkMessage) { + val frame = adapter.encode(message) + require(frame.size <= FrameCodec.MAX_FRAME_SIZE) + output.writeInt(frame.size) + output.write(frame) + output.flush() + } + + fun close() { + runCatching { input.close() } + runCatching { output.close() } + runCatching { socket.close() } + } + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/transport/TwistOverlayAdapter.kt b/app/src/main/kotlin/rip/crit/twist/transport/TwistOverlayAdapter.kt new file mode 100644 index 0000000..20ec213 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/transport/TwistOverlayAdapter.kt @@ -0,0 +1,36 @@ +package rip.crit.twist.transport + +import kotlin.random.Random +import rip.crit.twist.core.TwistSpecific +import rip.crit.twist.p2p.EnvelopeAdapter +import rip.crit.twist.p2p.NetworkAdapterKind +import rip.crit.twist.p2p.NetworkMessage + +/** Twist overlay protected by the bundled AES-GCM implementation. */ +class TwistOverlayAdapter( + @Suppress("UNUSED_PARAMETER") twistSpecific: TwistSpecific.Enabled, + private val key: ByteArray, + private val random: Random = Random.Default, +) : EnvelopeAdapter(NetworkAdapterKind.TWIST_OVERLAY) { + init { + require(key.size == 32) + } + + override fun encode(message: NetworkMessage): ByteArray { + val nonce = random.nextBytes(12) + val encrypted = AesGcm(key).encrypt(nonce, super.encode(message)) + return nonce + encrypted.ciphertext + encrypted.tag + } + + override fun decode(frame: ByteArray): NetworkMessage { + require(frame.size > 28) + val plain = + AesGcm(key) + .decrypt( + frame.copyOfRange(0, 12), + frame.copyOfRange(12, frame.size - 16), + frame.copyOfRange(frame.size - 16, frame.size), + ) + return super.decode(plain) + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/transport/X25519.kt b/app/src/main/kotlin/rip/crit/twist/transport/X25519.kt new file mode 100644 index 0000000..6ec8727 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/transport/X25519.kt @@ -0,0 +1,84 @@ +package rip.crit.twist.transport + +import java.math.BigInteger +import rip.crit.twist.core.SmartDoc + +/** RFC 7748 X25519 implementation using readable BigInteger field arithmetic. */ +@SmartDoc(summary = "RFC 7748 X25519 key agreement primitives.", category = "Cryptography") +object X25519 { + private val prime = BigInteger.ONE.shiftLeft(255) - BigInteger.valueOf(19) + private val a24 = BigInteger.valueOf(121665) + + private fun mod(value: BigInteger): BigInteger = value.mod(prime) + + fun publicKey(privateKey: ByteArray): ByteArray = + scalarMult(privateKey, byteArrayOf(9) + ByteArray(31)) + + fun sharedSecret(privateKey: ByteArray, publicKey: ByteArray): ByteArray = + scalarMult(privateKey, publicKey).also { + require(it.any { byte -> byte.toInt() != 0 }) { + "X25519 rejected an all-zero shared secret" + } + } + + fun scalarMult(privateKey: ByteArray, uCoordinate: ByteArray): ByteArray { + require(privateKey.size == 32 && uCoordinate.size == 32) + val scalarBytes = + privateKey.copyOf().also { + it[0] = (it[0].toInt() and 248).toByte() + it[31] = ((it[31].toInt() and 127) or 64).toByte() + } + val uBytes = uCoordinate.copyOf().also { it[31] = (it[31].toInt() and 127).toByte() } + val scalar = littleEndian(scalarBytes) + val x1 = littleEndian(uBytes).mod(prime) + var x2 = BigInteger.ONE + var z2 = BigInteger.ZERO + var x3 = x1 + var z3 = BigInteger.ONE + var swap = 0 + for (position in 254 downTo 0) { + val bit = if (scalar.testBit(position)) 1 else 0 + swap = swap xor bit + if (swap != 0) { + val tx = x2 + x2 = x3 + x3 = tx + val tz = z2 + z2 = z3 + z3 = tz + } + swap = bit + val a = mod(x2 + z2) + val aa = mod(a * a) + val b = mod(x2 - z2) + val bb = mod(b * b) + val e = mod(aa - bb) + val c = mod(x3 + z3) + val d = mod(x3 - z3) + val da = mod(d * a) + val cb = mod(c * b) + x3 = mod((da + cb).pow(2)) + z3 = mod(x1 * mod((da - cb).pow(2))) + x2 = mod(aa * bb) + z2 = mod(e * mod(aa + a24 * e)) + } + if (swap != 0) { + val tx = x2 + x2 = x3 + x3 = tx + val tz = z2 + z2 = z3 + z3 = tz + } + return encodeLittleEndian(mod(x2 * z2.modInverse(prime))) + } + + private fun littleEndian(bytes: ByteArray): BigInteger = BigInteger(1, bytes.reversedArray()) + + private fun encodeLittleEndian(value: BigInteger): ByteArray { + val source = value.toByteArray().dropWhile { it == 0.toByte() }.reversed() + return ByteArray(32).also { output -> + source.take(32).forEachIndexed { index, byte -> output[index] = byte } + } + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/tvm/BytecodeVirtualMachine.kt b/app/src/main/kotlin/rip/crit/twist/tvm/BytecodeVirtualMachine.kt new file mode 100644 index 0000000..f942f87 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/tvm/BytecodeVirtualMachine.kt @@ -0,0 +1,249 @@ +package rip.crit.twist.tvm + +import java.math.BigInteger +import rip.crit.twist.bytecode.OpCode +import rip.crit.twist.bytecode.TwistBytecode +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount +import rip.crit.twist.core.Sha256 +import rip.crit.twist.core.SmartDoc +import rip.crit.twist.core.Transaction +import rip.crit.twist.gas.BasicGasMeter +import rip.crit.twist.gas.GasLimit +import rip.crit.twist.state.ContractState +import rip.crit.twist.state.InMemoryWorldState +import rip.crit.twist.state.WorldState + +/** Minimal deterministic stack VM for the Phase 3 instruction set. */ +@SmartDoc( + summary = "Deterministic 256-bit stack virtual machine with metered execution.", + category = "Execution", +) +class BytecodeVirtualMachine(private val gasLimit: GasLimit = GasLimit(1_000_000)) : + VirtualMachine { + override fun execute(transaction: Transaction, state: WorldState): ExecutionResult { + if (state !is ContractState) + return ExecutionResult( + false, + rip.crit.twist.gas.GasUsed(0), + error = "ContractState is required", + ) + val context = + ExecutionContext( + transaction.recipient ?: transaction.sender, + transaction.sender, + transaction.value, + ) + return execute(transaction.payload, byteArrayOf(), StateExecutionHost(context, state)) + } + + fun execute(code: ByteArray, input: ByteArray, contract: Address): ExecutionResult = + execute( + code, + input, + StateExecutionHost( + ExecutionContext(contract, contract, Amount(BigInteger.ZERO)), + InMemoryWorldState(), + ), + ) + + fun execute(code: ByteArray, input: ByteArray, host: ExecutionHost): ExecutionResult { + val meter = BasicGasMeter(gasLimit) + return try { + val instructions = TwistBytecode.decode(code) + val stack = ArrayDeque() + val calldata = Calldata.wrap(input) + val transient = TransientStorage() + val memory = LinearMemory() + var pc = 0 + while (pc in instructions.indices) { + val instruction = instructions[pc] + meter.consume(1) + when (instruction.opCode) { + OpCode.STOP -> + return ExecutionResult( + true, + meter.used, + stack.lastOrNull()?.toBytes() ?: byteArrayOf(), + ) + OpCode.PUSH32 -> stack.addLast(Word256.fromBytes(instruction.immediate)) + OpCode.ADD -> stack.addLast(stack.removeLast() + stack.removeLast()) + OpCode.SUB -> { + val right = stack.removeLast() + stack.addLast(stack.removeLast() - right) + } + OpCode.MUL -> stack.addLast(stack.removeLast() * stack.removeLast()) + OpCode.DIV -> { + val right = stack.removeLast() + stack.addLast(stack.removeLast() / right) + } + OpCode.EQ -> + stack.addLast(booleanWord(stack.removeLast() == stack.removeLast())) + OpCode.LT -> { + val right = stack.removeLast() + stack.addLast(booleanWord(stack.removeLast() < right)) + } + OpCode.GT -> { + val right = stack.removeLast() + stack.addLast(booleanWord(stack.removeLast() > right)) + } + OpCode.AND -> stack.addLast(stack.removeLast() and stack.removeLast()) + OpCode.OR -> stack.addLast(stack.removeLast() or stack.removeLast()) + OpCode.NOT -> stack.addLast(stack.removeLast().inv()) + OpCode.DUP -> stack.addLast(stack.last()) + OpCode.SWAP -> { + val a = stack.removeLast() + val b = stack.removeLast() + stack.addLast(a) + stack.addLast(b) + } + OpCode.CALLDATA_LOAD -> { + val offset = stack.removeLast().toBigInteger().intValueExact() + stack.addLast(calldata.wordAt(offset)) + } + OpCode.TLOAD -> + stack.addLast(transient.get(host.context.contract, stack.removeLast())) + OpCode.TSTORE -> { + val value = stack.removeLast() + transient.put(host.context.contract, stack.removeLast(), value) + } + OpCode.SLOAD -> stack.addLast(host.load(stack.removeLast())) + OpCode.SSTORE -> { + val value = stack.removeLast() + host.store(stack.removeLast(), value) + } + OpCode.CALLER -> + stack.addLast( + Word256.fromBytes( + Sha256.bytes(host.context.caller.value.encodeToByteArray()) + ) + ) + OpCode.CALLVALUE -> stack.addLast(Word256.of(host.context.value.value)) + OpCode.TIMESTAMP -> + stack.addLast(Word256.fromLong(host.context.timestampMillis)) + OpCode.BLOCK_NUMBER -> stack.addLast(Word256.fromLong(host.context.blockNumber)) + OpCode.BALANCE -> + stack.addLast( + Word256.of( + host.balance(Address(stack.removeLast().toBytes().hex())).value + ) + ) + OpCode.SHA256 -> + stack.addLast(Word256.fromBytes(Sha256.bytes(stack.removeLast().toBytes()))) + OpCode.MOD -> { + val right = stack.removeLast() + stack.addLast(stack.removeLast() % right) + } + OpCode.XOR -> stack.addLast(stack.removeLast() xor stack.removeLast()) + OpCode.ISZERO -> stack.addLast(booleanWord(stack.removeLast() == Word256.ZERO)) + OpCode.POP -> stack.removeLast() + OpCode.SHL -> { + val bits = stack.removeLast().toBigInteger().intValueExact() + stack.addLast(stack.removeLast().shiftLeft(bits)) + } + OpCode.SHR -> { + val bits = stack.removeLast().toBigInteger().intValueExact() + stack.addLast(stack.removeLast().shiftRight(bits)) + } + OpCode.BYTE -> { + val index = stack.removeLast().toBigInteger().intValueExact() + stack.addLast(stack.removeLast().byte(index)) + } + OpCode.MLOAD -> + stack.addLast( + Word256.fromBytes( + memory.load(stack.removeLast().toBigInteger().intValueExact(), 32) + ) + ) + OpCode.MSTORE -> { + val value = stack.removeLast() + val offset = stack.removeLast().toBigInteger().intValueExact() + memory.store(offset, value.toBytes()) + } + OpCode.MSIZE -> stack.addLast(Word256.fromLong(memory.size.toLong())) + OpCode.CALLDATA_SIZE -> stack.addLast(Word256.fromLong(calldata.size.toLong())) + OpCode.CODE_SIZE -> stack.addLast(Word256.fromLong(code.size.toLong())) + OpCode.GAS -> stack.addLast(Word256.fromLong(gasLimit.value - meter.used.value)) + OpCode.ADDRESS -> stack.addLast(addressWord(host.context.contract)) + OpCode.ORIGIN -> stack.addLast(addressWord(host.context.origin)) + OpCode.CHAIN_ID -> stack.addLast(Word256.fromLong(host.context.chainId)) + OpCode.LOG -> { + val data = stack.removeLast() + host.log(stack.removeLast(), data) + } + OpCode.CALL -> { + val address = Address(stack.removeLast().toBytes().hex()) + stack.addLast( + Word256.fromBytes(Sha256.bytes(host.call(address, byteArrayOf()))) + ) + } + OpCode.JUMP -> { + pc = stack.removeLast().toBigInteger().intValueExact() + require(pc in instructions.indices) + continue + } + OpCode.JUMPI -> { + val target = stack.removeLast().toBigInteger().intValueExact() + if (stack.removeLast() != Word256.ZERO) { + pc = target + require(pc in instructions.indices) + continue + } + } + OpCode.RETURN -> + return ExecutionResult( + true, + meter.used, + stack.lastOrNull()?.toBytes() ?: byteArrayOf(), + ) + OpCode.REVERT -> + return ExecutionResult(false, meter.used, error = "Execution reverted") + OpCode.CREATE -> { + val value = Amount(stack.removeLast().toBigInteger()) + val created = host.create(stack.removeLast().toBytes(), value) + stack.addLast(addressWord(created)) + } + OpCode.SELFDESTRUCT -> { + host.selfDestruct(Address(stack.removeLast().toBytes().hex())) + return ExecutionResult(true, meter.used) + } + OpCode.INVALID -> error("Invalid opcode") + } + pc++ + } + ExecutionResult(true, meter.used, stack.lastOrNull()?.toBytes() ?: byteArrayOf()) + } catch (error: Exception) { + ExecutionResult(false, meter.used, error = error.message) + } + } + + private fun booleanWord(value: Boolean): Word256 = + if (value) Word256.fromLong(1) else Word256.ZERO + + private fun addressWord(address: Address): Word256 = + Word256.fromBytes(Sha256.bytes(address.value.encodeToByteArray())) + + private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } +} + +/** Expand-on-write memory with a fixed safety ceiling. */ +private class LinearMemory(private val maximumSize: Int = 16 * 1024 * 1024) { + private var bytes = ByteArray(0) + val size: Int + get() = bytes.size + + fun load(offset: Int, length: Int): ByteArray { + require(offset >= 0 && length >= 0 && offset <= maximumSize - length) + val output = ByteArray(length) + if (offset < bytes.size) + bytes.copyInto(output, 0, offset, minOf(bytes.size, offset + length)) + return output + } + + fun store(offset: Int, value: ByteArray) { + require(offset >= 0 && offset <= maximumSize - value.size) + val required = offset + value.size + if (required > bytes.size) bytes = bytes.copyOf(required) + value.copyInto(bytes, offset) + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/tvm/ExecutionHost.kt b/app/src/main/kotlin/rip/crit/twist/tvm/ExecutionHost.kt new file mode 100644 index 0000000..b8c8c2b --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/tvm/ExecutionHost.kt @@ -0,0 +1,119 @@ +package rip.crit.twist.tvm + +import java.math.BigInteger +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount +import rip.crit.twist.core.Hash +import rip.crit.twist.core.Sha256 +import rip.crit.twist.state.ContractState + +data class ExecutionContext( + val contract: Address, + val caller: Address, + val value: Amount, + val blockNumber: Long = 0, + val timestampMillis: Long = 0, + val origin: Address = caller, + val chainId: Long = 1, + val callDepth: Int = 0, +) + +data class ContractLog(val contract: Address, val topic: Word256, val data: Word256) + +interface ExecutionHost { + val context: ExecutionContext + + fun load(key: Word256): Word256 + + fun store(key: Word256, value: Word256) + + fun balance(address: Address): Amount + + fun log(topic: Word256, data: Word256) + + fun call(address: Address, input: ByteArray): ByteArray + + fun create(code: ByteArray, value: Amount): Address + + fun selfDestruct(beneficiary: Address) +} + +class StateExecutionHost(override val context: ExecutionContext, private val state: ContractState) : + ExecutionHost { + val logs = mutableListOf() + + override fun load(key: Word256): Word256 = + state.storage(context.contract, Hash(key.toBytes().hex()))?.let(Word256::fromBytes) + ?: Word256.ZERO + + override fun store(key: Word256, value: Word256) = + state.putStorage(context.contract, Hash(key.toBytes().hex()), value.toBytes()) + + override fun balance(address: Address): Amount = + state.account(address)?.balance ?: Amount(BigInteger.ZERO) + + override fun log(topic: Word256, data: Word256) { + logs += ContractLog(context.contract, topic, data) + } + + override fun call(address: Address, input: ByteArray): ByteArray { + require(context.callDepth < MAX_CALL_DEPTH) { "Maximum call depth exceeded" } + val code = state.account(address)?.code ?: return byteArrayOf() + val child = + StateExecutionHost( + context.copy( + contract = address, + caller = context.contract, + value = Amount(BigInteger.ZERO), + callDepth = context.callDepth + 1, + ), + state, + ) + val result = BytecodeVirtualMachine().execute(code, input, child) + require(result.succeeded) { result.error ?: "Nested call failed" } + logs += child.logs + return result.returnData + } + + override fun create(code: ByteArray, value: Amount): Address { + val creator = state.account(context.contract) + require(value.value <= (creator?.balance?.value ?: BigInteger.ZERO)) { + "Insufficient creation balance" + } + val seed = + context.contract.value.encodeToByteArray() + + (creator?.nonce ?: 0).toString().encodeToByteArray() + + code + val address = Address(Sha256.digest(seed).value.takeLast(40)) + require(state.account(address) == null) { "Contract address collision" } + if (creator != null) { + state.putAccount( + context.contract, + creator.copy( + nonce = creator.nonce + 1, + balance = Amount(creator.balance.value - value.value), + ), + ) + } + state.putAccount(address, rip.crit.twist.state.Account(balance = value, code = code)) + return address + } + + override fun selfDestruct(beneficiary: Address) { + val account = state.account(context.contract) ?: return + val recipient = + state.account(beneficiary) + ?: rip.crit.twist.state.Account(balance = Amount(BigInteger.ZERO)) + state.putAccount( + beneficiary, + recipient.copy(balance = Amount(recipient.balance.value + account.balance.value)), + ) + state.deleteAccount(context.contract) + } + + private fun ByteArray.hex() = joinToString("") { "%02x".format(it) } + + private companion object { + const val MAX_CALL_DEPTH = 64 + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/tvm/TransientStorage.kt b/app/src/main/kotlin/rip/crit/twist/tvm/TransientStorage.kt new file mode 100644 index 0000000..8919931 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/tvm/TransientStorage.kt @@ -0,0 +1,16 @@ +package rip.crit.twist.tvm + +import java.util.concurrent.ConcurrentHashMap +import rip.crit.twist.core.Address + +class TransientStorage { + private val values = ConcurrentHashMap>() + + fun get(contract: Address, key: Word256): Word256 = values[contract]?.get(key) ?: Word256.ZERO + + fun put(contract: Address, key: Word256, value: Word256) { + values.computeIfAbsent(contract) { ConcurrentHashMap() }[key] = value + } + + fun clear() = values.clear() +} diff --git a/app/src/main/kotlin/rip/crit/twist/tvm/VirtualMachine.kt b/app/src/main/kotlin/rip/crit/twist/tvm/VirtualMachine.kt new file mode 100644 index 0000000..5cab696 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/tvm/VirtualMachine.kt @@ -0,0 +1,38 @@ +package rip.crit.twist.tvm + +import rip.crit.twist.core.Transaction +import rip.crit.twist.gas.GasUsed +import rip.crit.twist.state.WorldState + +data class ExecutionResult( + val succeeded: Boolean, + val gasUsed: GasUsed, + val returnData: ByteArray = byteArrayOf(), + val error: String? = null, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ExecutionResult + + if (succeeded != other.succeeded) return false + if (gasUsed != other.gasUsed) return false + if (!returnData.contentEquals(other.returnData)) return false + if (error != other.error) return false + + return true + } + + override fun hashCode(): Int { + var result = succeeded.hashCode() + result = 31 * result + gasUsed.hashCode() + result = 31 * result + returnData.contentHashCode() + result = 31 * result + error.hashCode() + return result + } +} + +interface VirtualMachine { + fun execute(transaction: Transaction, state: WorldState): ExecutionResult +} diff --git a/app/src/main/kotlin/rip/crit/twist/tvm/Word256.kt b/app/src/main/kotlin/rip/crit/twist/tvm/Word256.kt new file mode 100644 index 0000000..f6f4438 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/tvm/Word256.kt @@ -0,0 +1,88 @@ +package rip.crit.twist.tvm + +import java.math.BigInteger +import java.nio.ByteBuffer + +/** Unsigned EVM-style integer with modulo 2^256 arithmetic. */ +@JvmInline +value class Word256 private constructor(private val raw: BigInteger) : Comparable { + operator fun plus(other: Word256): Word256 = of(raw + other.raw) + + operator fun minus(other: Word256): Word256 = of(raw - other.raw) + + operator fun times(other: Word256): Word256 = of(raw * other.raw) + + operator fun div(other: Word256): Word256 = if (other == ZERO) ZERO else of(raw / other.raw) + + operator fun rem(other: Word256): Word256 = if (other == ZERO) ZERO else of(raw % other.raw) + + infix fun and(other: Word256): Word256 = of(raw.and(other.raw)) + + infix fun or(other: Word256): Word256 = of(raw.or(other.raw)) + + infix fun xor(other: Word256): Word256 = of(raw.xor(other.raw)) + + fun inv(): Word256 = of(raw.xor(MODULUS - BigInteger.ONE)) + + fun shiftLeft(bits: Int): Word256 = if (bits >= 256) ZERO else of(raw.shiftLeft(bits)) + + fun shiftRight(bits: Int): Word256 = if (bits >= 256) ZERO else of(raw.shiftRight(bits)) + + fun byte(index: Int): Word256 = + if (index !in 0 until BYTE_SIZE) ZERO else fromLong(toBytes()[index].toLong() and 0xff) + + fun toBigInteger(): BigInteger = raw + + fun toBytes(): ByteArray = + raw.toByteArray().let { source -> + ByteArray(BYTE_SIZE).also { target -> + source.copyInto( + target, + BYTE_SIZE - source.size.coerceAtMost(BYTE_SIZE), + (source.size - BYTE_SIZE).coerceAtLeast(0), + ) + } + } + + override fun compareTo(other: Word256): Int = raw.compareTo(other.raw) + + companion object { + const val BYTE_SIZE = 32 + private val MODULUS = BigInteger.ONE.shiftLeft(256) + val ZERO = Word256(BigInteger.ZERO) + + fun of(value: BigInteger): Word256 = Word256(value.mod(MODULUS)) + + fun fromBytes(bytes: ByteArray): Word256 { + require(bytes.size <= BYTE_SIZE) + return of(BigInteger(1, bytes)) + } + + fun fromLong(value: Long): Word256 { + require(value >= 0) + return of(BigInteger.valueOf(value)) + } + } +} + +/** Read-only calldata view. Its slices share the original byte buffer. */ +class Calldata private constructor(private val buffer: ByteBuffer) { + val size: Int + get() = buffer.capacity() + + fun wordAt(offset: Int): Word256 { + require(offset >= 0 && offset + Word256.BYTE_SIZE <= size) + val bytes = ByteArray(Word256.BYTE_SIZE) + buffer.duplicate().position(offset).get(bytes) + return Word256.fromBytes(bytes) + } + + fun slice(offset: Int, length: Int): ByteBuffer { + require(offset >= 0 && length >= 0 && offset + length <= size) + return buffer.duplicate().position(offset).limit(offset + length).slice().asReadOnlyBuffer() + } + + companion object { + fun wrap(bytes: ByteArray): Calldata = Calldata(ByteBuffer.wrap(bytes).asReadOnlyBuffer()) + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/twisto/TwistoToken.kt b/app/src/main/kotlin/rip/crit/twist/twisto/TwistoToken.kt new file mode 100644 index 0000000..3afd205 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/twisto/TwistoToken.kt @@ -0,0 +1,138 @@ +package rip.crit.twist.twisto + +import java.math.BigInteger +import rip.crit.twist.bips.StorageContract +import rip.crit.twist.compiler.AccessOperation +import rip.crit.twist.compiler.ContractIr +import rip.crit.twist.compiler.contract +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount +import rip.crit.twist.core.Hash +import rip.crit.twist.core.SmartDoc +import rip.crit.twist.standards.PersistentToken +import rip.crit.twist.standards.TokenStandard +import rip.crit.twist.store.KeyValueStore + +data class TwistoMetadata( + val schema: String = "twisto-metadata/1", + val name: String = "Twisto", + val symbol: String = "TWISTO", + val description: String, + val image: String? = null, + val externalUrl: String? = null, + val issuerDid: String? = null, + val attributes: Map = emptyMap(), +) { + init { + require(schema == "twisto-metadata/1") + require(name.isNotBlank() && name.length <= 128) + require(symbol.matches(Regex("[A-Z0-9]{2,12}"))) + require(description.length <= 4_096) + require( + attributes.size <= 64 && + attributes.all { it.key.length <= 64 && it.value.length <= 512 } + ) + } + + /** Canonical JSON manifest, suitable for content addressing. */ + fun encode(): ByteArray = buildString { + append("{\"schema\":\"") + append(schema.escape()) + append("\",\"name\":\"") + append(name.escape()) + append("\",\"symbol\":\"") + append(symbol.escape()) + append("\",\"description\":\"") + append(description.escape()) + append("\",\"image\":") + appendJson(image) + append(",\"externalUrl\":") + appendJson(externalUrl) + append(",\"issuerDid\":") + appendJson(issuerDid) + append(",\"attributes\":{") + append( + attributes.toSortedMap().entries.joinToString(",") { + "\"${it.key.escape()}\":\"${it.value.escape()}\"" + } + ) + append("}}") + } + .encodeToByteArray() + + private fun String.escape(): String = + replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + + private fun StringBuilder.appendJson(value: String?) { + if (value == null) append("null") else append("\"").append(value.escape()).append("\"") + } +} + +/** Named token example whose immutable metadata is addressed through decentralized storage. */ +@SmartDoc(summary = "Named token with immutable decentralized metadata.", category = "Contracts") +class TwistoToken( + private val owner: Address, + private val state: KeyValueStore, + private val content: StorageContract, +) : TokenStandard { + private val token = PersistentToken(SYMBOL, state) + override val symbol: String = SYMBOL + + fun deploy(metadata: TwistoMetadata, initialSupply: Amount): Hash = + synchronized(this) { + check(state.get(DEPLOYED) == null) { "Twisto is already deployed" } + require(metadata.symbol == SYMBOL) + val pointer = content.put(metadata.encode()) + state.put(METADATA, pointer.value.encodeToByteArray()) + state.put(SUPPLY, initialSupply.value.toByteArray()) + state.put(DEPLOYED, byteArrayOf(1)) + if (initialSupply.value.signum() > 0) token.mint(owner, initialSupply) + pointer + } + + fun metadataPointer(): Hash? = state.get(METADATA)?.decodeToString()?.let(::Hash) + + fun metadata(): ByteArray? = metadataPointer()?.let(content::get) + + fun totalSupply(): Amount = Amount(state.get(SUPPLY)?.let(::BigInteger) ?: BigInteger.ZERO) + + fun mint(caller: Address, recipient: Address, amount: Amount) = + synchronized(this) { + require(caller == owner) { "Only the owner may mint" } + check(state.get(DEPLOYED) != null) { "Twisto is not deployed" } + require(amount.value.signum() > 0) + token.mint(recipient, amount) + state.put(SUPPLY, (totalSupply().value + amount.value).toByteArray()) + } + + override fun balanceOf(owner: Address): Amount = token.balanceOf(owner) + + override fun transfer(from: Address, to: Address, amount: Amount): Boolean = + token.transfer(from, to, amount) + + fun contractIr(): ContractIr = + contract("Twisto") { + allow(AccessOperation.READ, "storage:*", "*") + allow(AccessOperation.WRITE, "storage:*", owner.value) + allow(AccessOperation.EXECUTE, "*", "*") + function("balanceOf", 1) { + storageLoad() + returnWord() + } + function("transfer", 2) { + storageStore() + returnWord() + } + function("metadata", 3) { + storageLoad() + returnWord() + } + } + + private companion object { + const val SYMBOL = "TWISTO" + val DEPLOYED = "twisto:deployed".encodeToByteArray() + val METADATA = "twisto:metadata".encodeToByteArray() + val SUPPLY = "twisto:supply".encodeToByteArray() + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/wire/FrameCodec.kt b/app/src/main/kotlin/rip/crit/twist/wire/FrameCodec.kt new file mode 100644 index 0000000..580f9ff --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/wire/FrameCodec.kt @@ -0,0 +1,24 @@ +package rip.crit.twist.wire + +import java.nio.ByteBuffer + +/** Stream for wire payloads. */ +object FrameCodec { + const val MAX_FRAME_SIZE = 16 * 1024 * 1024 + + fun encode(payload: ByteArray): ByteArray { + require(payload.size <= MAX_FRAME_SIZE) { "Frame exceeds maximum size" } + return ByteBuffer.allocate(Int.SIZE_BYTES + payload.size) + .putInt(payload.size) + .put(payload) + .array() + } + + fun decode(frame: ByteArray): ByteArray { + require(frame.size >= Int.SIZE_BYTES) { "Truncated frame" } + val buffer = ByteBuffer.wrap(frame) + val size = buffer.int + require(size in 0..MAX_FRAME_SIZE && size == buffer.remaining()) { "Invalid frame length" } + return ByteArray(size).also(buffer::get) + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/wire/PacketSpec.kt b/app/src/main/kotlin/rip/crit/twist/wire/PacketSpec.kt new file mode 100644 index 0000000..2b8ca82 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/wire/PacketSpec.kt @@ -0,0 +1,73 @@ +package rip.crit.twist.wire + +import java.nio.ByteBuffer +import rip.crit.twist.core.Sha256 + +/** Twist wire v1 packet, big-endian and checksum-protected. */ +data class WirePacket(val type: PacketType, val payload: ByteArray, val flags: Int = 0) { + override fun equals(other: Any?): Boolean = + other is WirePacket && + type == other.type && + flags == other.flags && + payload.contentEquals(other.payload) + + override fun hashCode(): Int = 31 * (31 * type.hashCode() + flags) + payload.contentHashCode() +} + +enum class PacketType(val id: Byte) { + HELLO(1), + PING(2), + PONG(3), + GOSSIP(4), + DHT_LOOKUP(5), + DHT_RECORD(6), + TRANSACTION(7), + BLOCK(8), +} + +object PacketCodec { + private const val MAGIC = 0x54575354 + private const val HEADER_SIZE = 4 + 1 + 1 + 2 + 4 + 32 + const val MAX_PAYLOAD_SIZE = 16 * 1024 * 1024 + + fun encode(packet: WirePacket): ByteArray { + require(packet.flags in 0..0xffff && packet.payload.size <= MAX_PAYLOAD_SIZE) + val checksum = + Sha256.digest(packet.payload) + .value + .chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + return ByteBuffer.allocate(HEADER_SIZE + packet.payload.size) + .putInt(MAGIC) + .put(1) + .put(packet.type.id) + .putShort(packet.flags.toShort()) + .putInt(packet.payload.size) + .put(checksum) + .put(packet.payload) + .array() + } + + fun decode(bytes: ByteArray): WirePacket { + require(bytes.size >= HEADER_SIZE) + val input = ByteBuffer.wrap(bytes) + require(input.int == MAGIC && input.get().toInt() == 1) + val typeId = input.get() + val type = + PacketType.entries.firstOrNull { it.id == typeId } ?: error("Unknown packet type") + val flags = input.short.toInt() and 0xffff + val size = input.int + require(size in 0..MAX_PAYLOAD_SIZE && input.remaining() == 32 + size) + val checksum = ByteArray(32).also(input::get) + val payload = ByteArray(size).also(input::get) + require( + checksum.contentEquals( + Sha256.digest(payload).value.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + ) + ) { + "Packet checksum mismatch" + } + return WirePacket(type, payload, flags) + } +} diff --git a/app/src/main/kotlin/rip/crit/twist/wire/Rlp.kt b/app/src/main/kotlin/rip/crit/twist/wire/Rlp.kt new file mode 100644 index 0000000..e9e6ef9 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/wire/Rlp.kt @@ -0,0 +1,145 @@ +package rip.crit.twist.wire + +import java.io.ByteArrayOutputStream +import java.math.BigInteger + +/** + * Ethereum Recursive Length Prefix (RLP) codec. + * + * Wire reference: ethereum/devp2p `rlpx.md` — RLPx framing, auth/ack handshake + * payloads, and devp2p Hello/Disconnect/Ping/Pong bodies are all RLP-encoded. + * Single-byte values < 0x80 are their own encoding; all other strings/lists use + * short (<=55 bytes) and long length prefixes. + */ +object Rlp { + private const val SHORT_MAX = 55 + + fun encodeString(bytes: ByteArray): ByteArray = + when { + bytes.size == 1 && (bytes[0].toInt() and 0xFF) < 0x80 -> bytes.copyOf() + bytes.size <= SHORT_MAX -> + byteArrayOf((0x80 + bytes.size).toByte()) + bytes + else -> { + val len = lengthBytes(bytes.size) + byteArrayOf((0xB7 + len.size).toByte()) + len + bytes + } + } + + fun encodeInt(value: BigInteger): ByteArray { + require(value >= BigInteger.ZERO) { "RLP integers must be non-negative" } + if (value == BigInteger.ZERO) return byteArrayOf(0x80.toByte()) + var raw = value.toByteArray().dropWhile { it == 0.toByte() }.toByteArray() + return encodeString(raw) + } + + fun encodeInt(value: Long): ByteArray = encodeInt(BigInteger.valueOf(value)) + + fun encodeList(items: List): ByteArray { + val payload = items.fold(ByteArray(0)) { acc, item -> acc + item } + return when { + payload.size <= SHORT_MAX -> + byteArrayOf((0xC0 + payload.size).toByte()) + payload + else -> { + val len = lengthBytes(payload.size) + byteArrayOf((0xF7 + len.size).toByte()) + len + payload + } + } + } + + fun encodeElements(vararg items: ByteArray): ByteArray = encodeList(items.toList()) + + /** Decodes one RLP item starting at [offset]; returns (item, nextOffset). */ + fun decodeOne(bytes: ByteArray, offset: Int = 0): Pair { + require(offset < bytes.size) { "RLP truncated" } + val prefix = bytes[offset].toInt() and 0xFF + return when { + prefix < 0x80 -> Pair(RlpItem.String(bytes.copyOfRange(offset, offset + 1)), offset + 1) + prefix <= 0xB7 -> { + val len = prefix - 0x80 + require(offset + 1 + len <= bytes.size) { "RLP string truncated" } + Pair(RlpItem.String(bytes.copyOfRange(offset + 1, offset + 1 + len)), offset + 1 + len) + } + prefix <= 0xBF -> { + val lenOfLen = prefix - 0xB7 + val len = readLength(bytes, offset + 1, lenOfLen) + require(offset + 1 + lenOfLen + len <= bytes.size) { "RLP long string truncated" } + Pair( + RlpItem.String(bytes.copyOfRange(offset + 1 + lenOfLen, offset + 1 + lenOfLen + len)), + offset + 1 + lenOfLen + len) + } + prefix <= 0xF7 -> { + val len = prefix - 0xC0 + require(offset + 1 + len <= bytes.size) { "RLP list truncated" } + Pair(decodeList(bytes.copyOfRange(offset + 1, offset + 1 + len)), offset + 1 + len) + } + else -> { + val lenOfLen = prefix - 0xF7 + val len = readLength(bytes, offset + 1, lenOfLen) + require(offset + 1 + lenOfLen + len <= bytes.size) { "RLP long list truncated" } + Pair( + decodeList(bytes.copyOfRange(offset + 1 + lenOfLen, offset + 1 + lenOfLen + len)), + offset + 1 + lenOfLen + len) + } + } + } + + fun decode(bytes: ByteArray): RlpItem { + val (item, next) = decodeOne(bytes, 0) + require(next == bytes.size) { "Trailing RLP bytes" } + return item + } + + private fun decodeList(payload: ByteArray): RlpItem.List { + val items = mutableListOf() + var offset = 0 + while (offset < payload.size) { + val (item, next) = decodeOne(payload, offset) + items += item + offset = next + } + require(offset == payload.size) { "RLP list overrun" } + return RlpItem.List(items) + } + + private fun readLength(bytes: ByteArray, offset: Int, lenOfLen: Int): Int { + require(lenOfLen in 1..4 && offset + lenOfLen <= bytes.size) { "Bad RLP length prefix" } + require(bytes[offset] != 0.toByte()) { "RLP length has leading zero" } + var len = 0 + for (i in 0 until lenOfLen) len = (len shl 8) or (bytes[offset + i].toInt() and 0xFF) + require(len > SHORT_MAX) { "RLP long form used for short payload" } + return len + } + + private fun lengthBytes(size: Int): ByteArray { + val out = ByteArrayOutputStream() + var v = size + val tmp = mutableListOf() + while (v > 0) { + tmp += (v and 0xFF).toByte() + v = v ushr 8 + } + tmp.reversed().forEach { out.write(it.toInt()) } + return out.toByteArray() + } +} + +sealed interface RlpItem { + data class String(val bytes: ByteArray) : RlpItem { + fun asLong(): Long { + require(bytes.isNotEmpty()) { "Empty RLP int" } + require(!(bytes.size > 1 && bytes[0] == 0.toByte())) { "Non-canonical RLP int" } + var v = 0L + for (b in bytes) v = (v shl 8) or (b.toLong() and 0xFF) + return v + } + + fun asText(): kotlin.String = bytes.decodeToString() + + override fun equals(other: Any?): Boolean = + other is String && bytes.contentEquals(other.bytes) + + override fun hashCode(): Int = bytes.contentHashCode() + } + + data class List(val items: kotlin.collections.List) : RlpItem +} diff --git a/app/src/main/kotlin/rip/crit/twist/wire/WireCodec.kt b/app/src/main/kotlin/rip/crit/twist/wire/WireCodec.kt new file mode 100644 index 0000000..bbf176f --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/wire/WireCodec.kt @@ -0,0 +1,92 @@ +package rip.crit.twist.wire + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.math.BigInteger +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount +import rip.crit.twist.core.Hash +import rip.crit.twist.core.Transaction +import rip.crit.twist.p2p.NetworkMessage + +/** Length-prefixed envelope shared by devp2p-twist subprotocol payloads. */ +object WireEnvelope { + fun encode(message: NetworkMessage): ByteArray = WireCodec.encode(message) + + fun decode(bytes: ByteArray): NetworkMessage = WireCodec.decodeNetworkMessage(bytes) +} + +object WireCodec { + private const val VERSION: Byte = 1 + private const val MAX_FIELD_SIZE = 16 * 1024 * 1024 + + fun encode(transaction: Transaction): ByteArray = bytes { + writeByte(VERSION.toInt()) + writeString(transaction.id.value) + writeString(transaction.sender.value) + writeBoolean(transaction.recipient != null) + transaction.recipient?.let { writeString(it.value) } + writeLong(transaction.nonce) + writeBytes(transaction.value.value.toByteArray()) + writeBytes(transaction.payload) + } + + fun decodeTransaction(bytes: ByteArray): Transaction = + DataInputStream(ByteArrayInputStream(bytes)).use { + requireVersion(it) + val id = Hash(it.readString()) + val sender = Address(it.readString()) + val recipient = if (it.readBoolean()) Address(it.readString()) else null + val nonce = it.readLong() + require(nonce >= 0) { "Transaction nonce cannot be negative" } + val value = Amount(BigInteger(it.readBytes())) + val payload = it.readBytes() + require(it.available() == 0) { "Trailing transaction bytes" } + Transaction(id, sender, recipient, nonce, value, payload) + } + + fun encode(message: NetworkMessage): ByteArray = bytes { + writeByte(VERSION.toInt()) + writeString(message.id.value) + writeString(message.topic) + writeBytes(message.payload) + } + + fun decodeNetworkMessage(bytes: ByteArray): NetworkMessage = + DataInputStream(ByteArrayInputStream(bytes)).use { + requireVersion(it) + val id = Hash(it.readString()) + val topic = it.readString() + val message = NetworkMessage(topic, it.readBytes(), id) + require(it.available() == 0) { "Trailing message bytes" } + message + } + + private fun bytes(write: DataOutputStream.() -> Unit): ByteArray = + ByteArrayOutputStream().use { output -> + DataOutputStream(output).use { it.write() } + output.toByteArray() + } + + private fun requireVersion(input: DataInputStream) { + require(input.readByte() == VERSION) { "Unsupported wire version" } + } + + private fun DataOutputStream.writeString(value: String) = writeBytes(value.encodeToByteArray()) + + private fun DataOutputStream.writeBytes(value: ByteArray) { + require(value.size <= MAX_FIELD_SIZE) + writeInt(value.size) + write(value) + } + + private fun DataInputStream.readString(): String = readBytes().decodeToString() + + private fun DataInputStream.readBytes(): ByteArray { + val size = readInt() + require(size in 0..MAX_FIELD_SIZE) { "Invalid wire field length" } + return ByteArray(size).also(::readFully) + } +} diff --git a/app/src/test/kotlin/rip/crit/twist/AppTest.kt b/app/src/test/kotlin/rip/crit/twist/AppTest.kt new file mode 100644 index 0000000..42564f5 --- /dev/null +++ b/app/src/test/kotlin/rip/crit/twist/AppTest.kt @@ -0,0 +1,465 @@ +package rip.crit.twist + +import rip.crit.twist.access.AccessList +import rip.crit.twist.bips.FileStorageContract +import rip.crit.twist.bytecode.TwistBytecode +import rip.crit.twist.compiler.TwistCompiler +import rip.crit.twist.compiler.LispIrCompiler +import rip.crit.twist.compiler.contract +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount +import rip.crit.twist.core.Hash +import rip.crit.twist.core.Sha256 +import rip.crit.twist.core.TransactionHasher +import rip.crit.twist.core.TwistSpecific +import rip.crit.twist.gas.BasicGasMeter +import rip.crit.twist.gas.GasLimit +import rip.crit.twist.gas.OutOfGasException +import rip.crit.twist.merkle.Sha256MerkleTree +import rip.crit.twist.merkle.verifies +import rip.crit.twist.p2p.NetworkMessage +import rip.crit.twist.p2p.InMemoryDht +import rip.crit.twist.p2p.PeerId +import rip.crit.twist.proof.ProofOfAuthority +import rip.crit.twist.proof.ProofOfWork +import rip.crit.twist.state.Account +import rip.crit.twist.state.FileWorldState +import rip.crit.twist.state.InMemoryWorldState +import rip.crit.twist.store.InMemoryKeyValueStore +import rip.crit.twist.wire.FrameCodec +import rip.crit.twist.wire.WireCodec +import rip.crit.twist.wire.PacketCodec +import rip.crit.twist.wire.PacketType +import rip.crit.twist.wire.WirePacket +import rip.crit.twist.transport.X25519Session +import rip.crit.twist.transport.Aes +import rip.crit.twist.transport.AesGcm +import rip.crit.twist.transport.X25519 +import rip.crit.twist.transport.TwistOverlayAdapter +import rip.crit.twist.ecdsa.Secp256k1Ecdsa +import rip.crit.twist.rsa.RsaKeyPair +import rip.crit.twist.rsa.RsaOaep +import rip.crit.twist.store.FileKeyValueStore +import rip.crit.twist.tvm.Calldata +import rip.crit.twist.tvm.TransientStorage +import rip.crit.twist.tvm.Word256 +import rip.crit.twist.tvm.BytecodeVirtualMachine +import rip.crit.twist.bytecode.Instruction +import rip.crit.twist.bytecode.OpCode +import rip.crit.twist.p2p.PeerNetwork +import rip.crit.twist.transport.TcpPeerNetwork +import rip.crit.twist.p2p.DevP2pAdapter +import rip.crit.twist.p2p.DevP2pHello +import rip.crit.twist.p2p.LibP2pAdapter +import rip.crit.twist.p2p.ProtocolCapability +import rip.crit.twist.router.NetworkResultDistributor +import rip.crit.twist.router.OffchainComputation +import rip.crit.twist.router.OffchainJob +import rip.crit.twist.router.OffchainRouter +import rip.crit.twist.router.OffchainWorker +import rip.crit.twist.router.ProofPolicy +import java.math.BigInteger +import java.nio.file.Files +import java.time.Duration +import java.time.Instant +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import rip.crit.twist.did.DidKey +import rip.crit.twist.did.DidMethodRegistry +import rip.crit.twist.did.DidTwist +import rip.crit.twist.did.KeyCodec +import rip.crit.twist.miner.CpuMiner +import rip.crit.twist.reflect.NetworkReflector +import rip.crit.twist.reflect.PeerIndex +import rip.crit.twist.reflect.PeerObservation +import rip.crit.twist.reflect.PeerProbe +import rip.crit.twist.twisto.TwistoMetadata +import rip.crit.twist.twisto.TwistoToken +import rip.crit.twist.smartdoc.SmartDocGenerator +import java.nio.file.Path + +class AppTest { + @Test + fun accessListsIdentifyWriteConflicts() { + val alice = Address("alice") + val bob = Address("bob") + + assertTrue(AccessList(writes = setOf(alice)).conflictsWith(AccessList(reads = setOf(alice)))) + assertFalse(AccessList(writes = setOf(alice)).conflictsWith(AccessList(reads = setOf(bob)))) + } + + @Test + fun sha256AndTransactionIdsAreDeterministic() { + assertEquals( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + Sha256.digest(byteArrayOf()).value + ) + val transaction = + TransactionHasher.create(Address("alice"), Address("bob"), 1, Amount(BigInteger.TEN), byteArrayOf(1)) + assertEquals( + transaction.id, + TransactionHasher.hash( + transaction.sender, + transaction.recipient, + transaction.nonce, + transaction.value, + transaction.payload + ) + ) + } + + @Test + fun merkleProofVerifiesAndStateIsDefensive() { + val leaves = listOf("one", "two", "three").map(Sha256::digestUtf8) + val tree = Sha256MerkleTree(leaves) + assertTrue(tree.proofFor(leaves[1])!!.verifies(tree.root)) + + val state = InMemoryWorldState() + val account = Account(balance = Amount(BigInteger.ONE), code = byteArrayOf(7)) + state.putAccount(Address("alice"), account) + account.code[0] = 9 + assertEquals(7, state.account(Address("alice"))!!.code[0]) + assertNotEquals(state.rootHash(), Sha256.digestUtf8("different")) + } + + @Test + fun storesCopyValuesAndGasCannotOverflowLimit() { + val store = InMemoryKeyValueStore() + val value = byteArrayOf(1) + store.put(byteArrayOf(1), value) + value[0] = 2 + assertEquals(1, store.get(byteArrayOf(1))!![0]) + + val meter = BasicGasMeter(GasLimit(3)) + meter.consume(3) + assertEquals(3, meter.used.value) + assertFailsWith { meter.consume(1) } + assertNotNull(store.get(byteArrayOf(1))) + } + + @Test + fun wireCodecRoundTripsTransactionsAndNetworkFrames() { + val transaction = + TransactionHasher.create(Address("alice"), Address("bob"), 4, Amount(BigInteger.TEN), byteArrayOf(1, 2)) + assertEquals(transaction, WireCodec.decodeTransaction(WireCodec.encode(transaction))) + assertEquals(byteArrayOf(1, 2).toList(), FrameCodec.decode(FrameCodec.encode(byteArrayOf(1, 2))).toList()) + val message = NetworkMessage("transactions", byteArrayOf(9), Hash("message-id")) + assertEquals(message, WireCodec.decodeNetworkMessage(WireCodec.encode(message))) + } + + @Test + fun tcpPeerNetworksHandshakeAndBroadcast() { + val first = TcpPeerNetwork(PeerId("first")) + val second = TcpPeerNetwork(PeerId("second")) + val received = CountDownLatch(1) + second.subscribe { received.countDown() } + first.start() + second.start() + try { + second.connect("127.0.0.1", first.port) + repeat(50) { + if (first.peers().contains(PeerId("second"))) return@repeat + Thread.sleep(10) + } + first.broadcast(NetworkMessage("test", byteArrayOf(1), Hash("tcp-message"))) + assertTrue(received.await(2, TimeUnit.SECONDS)) + assertTrue(first.peers().contains(PeerId("second"))) + } finally { + first.stop() + second.stop() + } + } + + @Test + fun compatibilityAdaptersNegotiateAndPreserveMessages() { + val dev = DevP2pAdapter(setOf(ProtocolCapability("twist", 1), ProtocolCapability("twist", 2))) + val hello = DevP2pHello("peer", 9000, PeerId("remote"), setOf(ProtocolCapability("twist", 2))) + assertEquals(setOf(ProtocolCapability("twist", 2)), dev.negotiate(hello)) + val message = NetworkMessage("blocks", byteArrayOf(2), Hash("adapter-message")) + assertEquals(message, dev.decode(dev.encode(message))) + val lib = LibP2pAdapter(setOf("/twist/1.0.0", "/twist/2.0.0")) + assertEquals("/twist/2.0.0", lib.select(setOf("/twist/2.0.0"))) + assertEquals(message, lib.decode(lib.encode(message))) + } + + @Test + fun smartDocProducesBrowsableStaticReference() { + val output = Files.createTempDirectory("twist-smartdoc") + val entries = SmartDocGenerator.generate(Path.of("app/src/main/kotlin"), output) + assertTrue(entries.any { it.name == "BytecodeVirtualMachine" }) + assertTrue(Files.readString(output.resolve("index.html")).contains("Twist SmartDoc API")) + } + + @Test + fun phaseThreeCompilerAndStoragePrimitivesWork() { + assertEquals(Word256.ZERO, Word256.of(BigInteger.ONE.shiftLeft(256))) + assertEquals(BigInteger.ONE, Calldata.wrap(ByteArray(31) + byteArrayOf(1)).wordAt(0).toBigInteger()) + val transient = TransientStorage(); transient.put( + Address("contract"), + Word256.ZERO, + Word256.fromLong(5) + ); assertEquals(BigInteger.valueOf(5), transient.get(Address("contract"), Word256.ZERO).toBigInteger()) + assertEquals(2, TwistBytecode.decode(TwistCompiler().compile("PUSH32 1\nSTOP")).size) + val folder = Files.createTempDirectory("twist-bips-test") + val storage = FileStorageContract(folder) + val hash = storage.put(byteArrayOf(4)); assertTrue(storage.prove(hash, Hash("challenge"))!!.verify()) + } + + @Test + fun peerAndConsensusAdaptersHaveSafeLocalFoundations() { + val dht = InMemoryDht() + val key = dht.announce(PeerId("peer-a"), setOf("memory://peer-a")); assertEquals( + PeerId("peer-a"), + dht.get(key)!!.peer + ) + val adapter = TwistOverlayAdapter(TwistSpecific.Enabled, ByteArray(32) { 1 }) + val message = NetworkMessage("blocks", byteArrayOf(3), Hash("block-message")); assertEquals( + message, + adapter.decode(adapter.encode(message)) + ) + val subject = Hash("pow-subject"); assertTrue(ProofOfWork.mine(subject, 1, 1_000)!!.verify()) + assertTrue(ProofOfAuthority.sign(subject, "validator-a", setOf("validator-a")).verify()) + } + + @Test + fun authenticatedWireAndFileStoreRoundTrip() { + val packet = WirePacket(PacketType.PING, byteArrayOf(1, 2), flags = 5) + assertEquals(packet, PacketCodec.decode(PacketCodec.encode(packet))) + val local = X25519Session.generateKeyPair() + val remote = X25519Session.generateKeyPair() + val sender = X25519Session.establish(local.privateKey, remote.publicKey) + val receiver = X25519Session.establish(remote.privateKey, local.publicKey) + assertEquals( + "secret", + receiver.decrypt(sender.encrypt("secret".encodeToByteArray(), ByteArray(12)), ByteArray(12)) + .decodeToString() + ) + val root = Files.createTempDirectory("twist-store-test") + val store = FileKeyValueStore(root); store.put(byteArrayOf(7), byteArrayOf(8)); assertEquals( + 8, + store.get(byteArrayOf(7))!![0] + ) + } + + @Test + fun cryptoMatchesPublishedVectors() { + val aes = Aes(hex("000102030405060708090a0b0c0d0e0f")) + assertEquals( + "69c4e0d86a7b0430d8cdb78070b4c55a", + aes.encryptBlock(hex("00112233445566778899aabbccddeeff")).hex() + ) + val gcm = AesGcm(ByteArray(16)) + val encrypted = gcm.encrypt(ByteArray(12), ByteArray(16)) + assertEquals("0388dace60b6a392f328c2b971b2fe78", encrypted.ciphertext.hex()) + assertEquals("ab6e47d42cec13bdf53a67b21257bddf", encrypted.tag.hex()) + val scalar = hex("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4") + val u = hex("e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c") + assertEquals( + "c3da55379de9c6908e94ea4df28d084f32eccf03491c71f754b4075577a28552", + X25519.scalarMult(scalar, u).hex() + ) + val ecdsa = Secp256k1Ecdsa() + val privateKey = ByteArray(32).also { it[31] = 1 } + val signature = ecdsa.sign("message".encodeToByteArray(), privateKey) + assertTrue(ecdsa.verify("message".encodeToByteArray(), signature, ecdsa.publicKey(privateKey))) + assertFalse(ecdsa.verify("tampered".encodeToByteArray(), signature, ecdsa.publicKey(privateKey))) + val rsa = RsaKeyPair.fromPrimes( + BigInteger.TWO.pow(521) - BigInteger.ONE, + BigInteger.TWO.pow(607) - BigInteger.ONE + ) + val ciphertext = + RsaOaep.encrypt("rsa".encodeToByteArray(), rsa.publicKey, ByteArray(32) { it.toByte() }) + assertEquals("rsa", RsaOaep.decrypt(ciphertext, rsa.privateKey).decodeToString()) + val damaged = ciphertext.copyOf().also { it[it.lastIndex] = (it.last().toInt() xor 1).toByte() } + assertFailsWith { RsaOaep.decrypt(damaged, rsa.privateKey) } + } + + @Test + fun contractIrExecutesAgainstFileBackedState() { + val ir = contract("Storage") { + function("setAndGet", 1) { + push(7) + push(42) + storageStore() + push(7) + storageLoad() + returnWord() + } + } + val state = FileWorldState(Files.createTempDirectory("twist-world-state")) + val transaction = TransactionHasher.create( + Address("caller"), + Address("storage-contract"), + 0, + Amount(BigInteger.ZERO), + ir.bytecode(), + ) + val result = BytecodeVirtualMachine().execute(transaction, state) + assertTrue(result.succeeded) + assertEquals(BigInteger.valueOf(42), Word256.fromBytes(result.returnData).toBigInteger()) + assertNotEquals(Sha256.digest(byteArrayOf()), state.rootHash()) + } + + @Test + fun extendedVmMemoryAndEnvironmentInstructionsExecute() { + val code = + TwistBytecode.encode( + listOf( + Instruction(OpCode.PUSH32, Word256.ZERO.toBytes()), + Instruction(OpCode.PUSH32, Word256.fromLong(99).toBytes()), + Instruction(OpCode.MSTORE), + Instruction(OpCode.PUSH32, Word256.ZERO.toBytes()), + Instruction(OpCode.MLOAD), + Instruction(OpCode.RETURN), + ) + ) + val result = BytecodeVirtualMachine().execute(code, byteArrayOf(), Address("memory")) + assertTrue(result.succeeded) + assertEquals(BigInteger.valueOf(99), Word256.fromBytes(result.returnData).toBigInteger()) + } + + @Test + fun offchainRouterSignsProvesPersistsAndDistributesResults() { + val messages = mutableListOf() + val network = + object : PeerNetwork { + override fun start() = Unit + override fun stop() = Unit + override fun peers() = emptySet() + override fun broadcast(message: NetworkMessage) { + messages += message + } + } + val store = InMemoryKeyValueStore() + val router = NetworkResultDistributor(network, store).let(::OffchainRouter) + val signer = Secp256k1Ecdsa() + val privateKey = ByteArray(32).also { it[31] = 7 } + val worker = + OffchainWorker( + Address("worker-7"), + privateKey, + signer.publicKey(privateKey), + OffchainComputation { it.reversedArray() }, + ) + router.register(worker) + val job = + OffchainJob( + Hash("job-1"), + Address("requester"), + "compute".encodeToByteArray(), + proofPolicy = ProofPolicy.Work(1, 10_000), + ) + val result = router.route(job) + assertTrue(router.verify(job, result)) + assertEquals("etupmoc", result.output.decodeToString()) + assertEquals(1, messages.size) + assertNotNull(store.get(job.id.value.encodeToByteArray())) + } + + @Test + fun lispIrEnforcesReadWriteAndExecuteCapabilities() { + val source = + """ + (contract Vault + (access + (read storage:* alice) + (write storage:* alice) + (execute function:set alice)) + (function set 1 + (push32 7) + (push32 42) + (sstore) + (return))) + """.trimIndent() + assertTrue(LispIrCompiler().compile(source, "alice").isNotEmpty()) + assertFailsWith { LispIrCompiler().compile(source, "mallory") } + } + + @Test + fun didMethodsResolveDocuments() { + val publicKey = ByteArray(32) { it.toByte() } + val keyDid = DidKey.create(publicKey, KeyCodec.X25519) + assertEquals(keyDid, DidKey.resolve(keyDid).id) + val dht = InMemoryDht() + val documents = InMemoryKeyValueStore() + val twist = DidTwist(TwistSpecific.Enabled, dht, documents) + val twistDid = + twist.create( + PeerId("node-a"), + publicKey, + setOf("twist://node-a"), + Duration.ofMinutes(5), + ) + assertEquals("twist://node-a", twist.resolve(twistDid)!!.services.single().endpoint) + assertEquals( + twistDid, + DidMethodRegistry.create(TwistSpecific.Enabled, twist).resolve(twistDid)!!.id, + ) + assertFailsWith { DidMethodRegistry.create(twist = twist) } + } + + @Test + fun minerAndReflectorAreBoundedAndPersistent() { + assertNotNull(CpuMiner(2).mine(Hash("cpu-miner"), 1, 10_000).proof) + val store = InMemoryKeyValueStore() + val index = PeerIndex(store) + val reflector = + NetworkReflector( + PeerProbe { peer -> + PeerObservation( + peer, + setOf("memory://${peer.value}"), + if (peer.value == "a") setOf(PeerId("b")) else emptySet(), + Instant.EPOCH, + ) + }, + index, + ) + val report = reflector.crawl(listOf(PeerId("a")), 2) + assertEquals(2, report.observations.size) + assertNotNull(index.get(PeerId("b"))) + } + + @Test + fun authenticatedEncryptionRejectsModification() { + val cipher = AesGcm(ByteArray(32) { it.toByte() }) + val encrypted = cipher.encrypt(ByteArray(12), "authenticated".encodeToByteArray()) + val altered = encrypted.ciphertext.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() } + assertFailsWith { + cipher.decrypt(ByteArray(12), altered, encrypted.tag) + } + val alice = X25519Session.generateKeyPair(kotlin.random.Random(1)) + val bob = X25519Session.generateKeyPair(kotlin.random.Random(2)) + assertEquals( + X25519.sharedSecret(alice.privateKey, bob.publicKey).toList(), + X25519.sharedSecret(bob.privateKey, alice.publicKey).toList(), + ) + } + + @Test + fun twistoPersistsContentAddressedMetadataAndBalances() { + val state = InMemoryKeyValueStore() + val storage = FileStorageContract(Files.createTempDirectory("twisto-metadata")) + val owner = Address("owner") + val token = TwistoToken(owner, state, storage) + val pointer = + token.deploy(TwistoMetadata(description = "Test token"), Amount(BigInteger.TEN)) + assertEquals(pointer, token.metadataPointer()) + assertEquals(TwistoMetadata(description = "Test token").encode().toList(), token.metadata()!!.toList()) + assertTrue(token.metadata()!!.decodeToString().contains("\"schema\":\"twisto-metadata/1\"")) + assertEquals(BigInteger.TEN, token.balanceOf(owner).value) + assertFailsWith { + token.mint(Address("attacker"), Address("attacker"), Amount(BigInteger.ONE)) + } + } + + private fun hex(value: String): ByteArray = value.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } +} diff --git a/apps/compiler/build.gradle.kts b/apps/compiler/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/apps/compiler/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/apps/compiler/src/main/kotlin/rip/crit/twist/tool/compiler/Main.kt b/apps/compiler/src/main/kotlin/rip/crit/twist/tool/compiler/Main.kt new file mode 100644 index 0000000..38ca356 --- /dev/null +++ b/apps/compiler/src/main/kotlin/rip/crit/twist/tool/compiler/Main.kt @@ -0,0 +1,21 @@ +package rip.crit.twist.tool.compiler + +import java.nio.file.Files +import java.nio.file.Path +import rip.crit.twist.compiler.TwistCompiler +import rip.crit.twist.compiler.LispIrCompiler + +fun main(args: Array) { + require(args.size in 2..3) { + "Usage is twistc [principal]" + } + val source = Path.of(args[0]) + val text = Files.readString(source) + val bytecode = + if (source.fileName.toString().endsWith(".twistl")) { + LispIrCompiler().compile(text, args.getOrNull(2) ?: "anonymous") + } else { + TwistCompiler().compile(text) + } + Files.write(Path.of(args[1]), bytecode) +} diff --git a/apps/docs/build.gradle.kts b/apps/docs/build.gradle.kts new file mode 100644 index 0000000..69e46ce --- /dev/null +++ b/apps/docs/build.gradle.kts @@ -0,0 +1 @@ +description = "SmartDoc static documentation generator" diff --git a/apps/docs/src/main/kotlin/rip/crit/twist/tool/docs/Main.kt b/apps/docs/src/main/kotlin/rip/crit/twist/tool/docs/Main.kt new file mode 100644 index 0000000..61ea128 --- /dev/null +++ b/apps/docs/src/main/kotlin/rip/crit/twist/tool/docs/Main.kt @@ -0,0 +1,11 @@ +package rip.crit.twist.tool.docs + +import java.nio.file.Path +import rip.crit.twist.smartdoc.SmartDocGenerator + +fun main(args: Array) { + val source = Path.of(args.getOrNull(0) ?: "app/src/main/kotlin") + val output = Path.of(args.getOrNull(1) ?: "build/docs/api") + val entries = SmartDocGenerator.generate(source, output) + println("Generated ${entries.size} SmartDoc entries at $output/index.html") +} diff --git a/apps/miner/build.gradle.kts b/apps/miner/build.gradle.kts new file mode 100644 index 0000000..0b86de4 --- /dev/null +++ b/apps/miner/build.gradle.kts @@ -0,0 +1 @@ +description = "Twist CPU miner command-line tool" diff --git a/apps/miner/src/main/kotlin/rip/crit/twist/tool/miner/Main.kt b/apps/miner/src/main/kotlin/rip/crit/twist/tool/miner/Main.kt new file mode 100644 index 0000000..a8df29f --- /dev/null +++ b/apps/miner/src/main/kotlin/rip/crit/twist/tool/miner/Main.kt @@ -0,0 +1,31 @@ +package rip.crit.twist.tool.miner + +import java.math.BigInteger +import java.nio.file.Path +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount +import rip.crit.twist.core.Hash +import rip.crit.twist.miner.CpuMiner +import rip.crit.twist.stake.PersistentStaking +import rip.crit.twist.store.FileKeyValueStore +import rip.crit.twist.proof.ProofOfWork +import rip.crit.twist.miner.MiningResult + +fun main(args: Array) { + require(args.isNotEmpty()) { + "Usage is twist-miner mine [attempts] [threads], or stake " + } + if (args[0] == "stake") { + require(args.size == 5) + val staking = PersistentStaking(FileKeyValueStore(Path.of(args[1]))) + val validator = Address(args[3]) + staking.stake(Address(args[2]), validator, Amount(BigInteger(args[4]))) + println("staked validator=${validator.value} power=${staking.votingPower(validator).value}") + return + } + require(args[0] == "mine" && args.size in 3..5) + val result = + CpuMiner(args.getOrNull(4)?.toInt() ?: Runtime.getRuntime().availableProcessors()) + .mine(Hash(args[1]), args[2].toInt(), args.getOrNull(3)?.toLong() ?: 1_000_000) as MiningResult + println("subject=${result.subject.value} difficulty=${result.difficulty} proof=${result.proof?.nonce} attempts=${result.attempts} elapsedNanos=${result.elapsedNanos}") +} diff --git a/apps/reflect/build.gradle.kts b/apps/reflect/build.gradle.kts new file mode 100644 index 0000000..f5d7095 --- /dev/null +++ b/apps/reflect/build.gradle.kts @@ -0,0 +1 @@ +description = "Twist network reflection command-line tool" diff --git a/apps/reflect/src/main/kotlin/rip/crit/twist/tool/reflect/Main.kt b/apps/reflect/src/main/kotlin/rip/crit/twist/tool/reflect/Main.kt new file mode 100644 index 0000000..e570ac2 --- /dev/null +++ b/apps/reflect/src/main/kotlin/rip/crit/twist/tool/reflect/Main.kt @@ -0,0 +1,23 @@ +package rip.crit.twist.tool.reflect + +import java.nio.file.Path +import java.time.Instant +import rip.crit.twist.p2p.PeerId +import rip.crit.twist.reflect.NetworkReflector +import rip.crit.twist.reflect.PeerIndex +import rip.crit.twist.reflect.PeerObservation +import rip.crit.twist.reflect.PeerProbe +import rip.crit.twist.store.FileKeyValueStore + +fun main(args: Array) { + require(args.size >= 2) { "Usage is twist-reflect [seed...]" } + val index = PeerIndex(FileKeyValueStore(Path.of(args[0]))) + val probe = + PeerProbe { peer -> + PeerObservation(peer, emptySet(), emptySet(), Instant.EPOCH) + } + val report = NetworkReflector(probe, index).crawl(args.drop(1).map(::PeerId)) + println( + "observed=${report.observations.size} failures=${report.failures.size} truncated=${report.truncated}" + ) +} diff --git a/apps/twisto/build.gradle.kts b/apps/twisto/build.gradle.kts new file mode 100644 index 0000000..e37ddac --- /dev/null +++ b/apps/twisto/build.gradle.kts @@ -0,0 +1 @@ +description = "Twisto deployment example" diff --git a/apps/twisto/src/main/kotlin/rip/crit/twist/tool/twisto/Main.kt b/apps/twisto/src/main/kotlin/rip/crit/twist/tool/twisto/Main.kt new file mode 100644 index 0000000..bbcca4f --- /dev/null +++ b/apps/twisto/src/main/kotlin/rip/crit/twist/tool/twisto/Main.kt @@ -0,0 +1,31 @@ +package rip.crit.twist.tool.twisto + +import java.math.BigInteger +import java.nio.file.Path +import rip.crit.twist.bips.FileStorageContract +import rip.crit.twist.core.Address +import rip.crit.twist.core.Amount +import rip.crit.twist.store.FileKeyValueStore +import rip.crit.twist.twisto.TwistoMetadata +import rip.crit.twist.twisto.TwistoToken + +fun main(args: Array) { + require(args.size in 1..3) { + "Usage is twisto [owner] [initial-supply]" + } + val root = Path.of(args[0]) + val owner = Address(args.getOrNull(1) ?: "twisto-owner") + val token = + TwistoToken( + owner, + FileKeyValueStore(root.resolve("state")), + FileStorageContract(root.resolve("metadata")), + ) + val pointer = + token.metadataPointer() + ?: token.deploy( + TwistoMetadata(description = "Named token on the Twist network"), + Amount(BigInteger(args.getOrNull(2) ?: "1000000")), + ) + println("deployed symbol=${token.symbol} metadata=${pointer.value} owner=${owner.value}") +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..7385254 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,186 @@ +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.gradle.api.plugins.JavaApplication +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.authentication.http.BasicAuthentication +import org.gradle.api.credentials.PasswordCredentials + +plugins { + kotlin("jvm") version "2.4.0" apply false + id("com.ncorti.ktfmt.gradle") version "0.27.0" +} + +repositories { + mavenCentral() +} + +val libraryModules = setOf( + "core", "wire", "transport", "bytecode", "compiler", "proof", "ecdsa", "rsa", "merkle", "store", "state", "access", "gas", "tvm", "jit", "ope", + "p2p", "gossip", "consensus", "mint", "burn", "bips", "standards", "stake", "router", "did", "miner", "reflect", "twisto", "smartdoc", +) + +val twistVersion = providers.gradleProperty("version").orElse("0.1.0-SNAPSHOT") + +allprojects { + group = "rip.crit" + version = twistVersion.get() +} + +val moduleDependencies = mapOf( + "wire" to listOf("core", "p2p"), + "transport" to listOf("wire", "p2p", "core"), + "bytecode" to listOf("core"), + "compiler" to listOf("bytecode", "jit", "tvm"), + "proof" to listOf("core", "merkle"), + "ecdsa" to listOf("core"), + "rsa" to listOf("core"), + "merkle" to listOf("core"), + "store" to listOf("core"), + "state" to listOf("core"), + "access" to listOf("core"), + "tvm" to listOf("core", "gas", "state", "bytecode"), + "ope" to listOf("core", "tvm", "access"), + "p2p" to listOf("core"), + "gossip" to listOf("p2p"), + "consensus" to listOf("core"), + "mint" to listOf("core"), + "burn" to listOf("core"), + "bips" to listOf("core", "proof"), + "standards" to listOf("core", "store"), + "stake" to listOf("core", "store"), + "router" to listOf("core", "proof", "ecdsa", "p2p", "store"), + "did" to listOf("core", "p2p", "store"), + "miner" to listOf("core", "proof"), + "reflect" to listOf("core", "p2p", "store", "wire"), + "twisto" to listOf("core", "standards", "bips", "store", "compiler"), + "smartdoc" to listOf("core"), +) + +ktfmt { + kotlinLangStyle() +} + +tasks.register("fmtCheck") { + group = "verification" + source = project.fileTree(layout.projectDirectory.dir("app/src/main/kotlin")) { + include("**/*.kt") + } +} + +tasks.register("fmt") { + group = "formatting" + source = project.fileTree(layout.projectDirectory.dir("app/src/main/kotlin")) { + include("**/*.kt") + } +} + +subprojects { + apply(plugin = "org.jetbrains.kotlin.jvm") + + layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("projects/${project.name}")) + + repositories { mavenCentral() } + + extensions.configure { + jvmToolchain(25) + sourceSets.named("main") { + kotlin.srcDir(rootProject.file("app/src/main/kotlin")) + kotlin.include("rip/crit/twist/${project.name}/**") + } + } + + tasks.withType().configureEach { + compilerOptions.freeCompilerArgs.add("-Xjsr305=strict") + } +} + +configure(subprojects.filter { it.name in libraryModules }) { + apply(plugin = "java-library") + apply(plugin = "maven-publish") + + extensions.configure { + withSourcesJar() + withJavadocJar() + } + + dependencies { + moduleDependencies[name].orEmpty().forEach { dependency -> add("api", project(":$dependency")) } + } + + extensions.configure { + publications { + create("mavenJava") { + from(components["java"]) + artifactId = "twist-${project.name}" + pom { + name.set("Twist ${project.name}") + description.set(project.description ?: "Twist ${project.name} library") + } + } + } + repositories { + maven { + name = "forgejo" + url = uri("https://ai.crit.rip/api/packages/jprims/maven") + credentials(PasswordCredentials::class) { + username = providers.gradleProperty("forgejoUsername").orNull + password = providers.gradleProperty("forgejoPassword").orNull + } + authentication { + create("basic") + } + } + } + } +} + +project(":app") { + apply(plugin = "application") + extensions.configure { + sourceSets.named("main") { kotlin.include("rip/crit/twist/App.kt") } + } + dependencies { + libraryModules.forEach { dependency -> add("implementation", project(":$dependency")) } + } + extensions.configure { + mainClass.set("rip.crit.twist.AppKt") + } +} + +project(":compiler-app") { + apply(plugin = "application") + extensions.configure { + sourceSets.named("main") { + kotlin.setSrcDirs(listOf(rootProject.file("apps/compiler/src/main/kotlin"))) + kotlin.setIncludes(emptySet()) + } + } + dependencies { add("implementation", project(":compiler")) } + extensions.configure { mainClass.set("rip.crit.twist.tool.compiler.MainKt") } +} + +fun configureTool(projectName: String, sourceDirectory: String, dependency: String, entryPoint: String) { + project(projectName) { + apply(plugin = "application") + extensions.configure { + sourceSets.named("main") { + kotlin.setSrcDirs(listOf(rootProject.file(sourceDirectory))) + kotlin.setIncludes(emptySet()) + } + } + dependencies { add("implementation", project(dependency)) } + extensions.configure { mainClass.set(entryPoint) } + } +} + +configureTool(":miner-tool", "apps/miner/src/main/kotlin", ":miner", "rip.crit.twist.tool.miner.MainKt") +project(":miner-tool") { + dependencies { + add("implementation", project(":stake")) + add("implementation", project(":store")) + } +} +configureTool(":reflect-tool", "apps/reflect/src/main/kotlin", ":reflect", "rip.crit.twist.tool.reflect.MainKt") +configureTool(":twisto-tool", "apps/twisto/src/main/kotlin", ":twisto", "rip.crit.twist.tool.twisto.MainKt") +configureTool(":docs-tool", "apps/docs/src/main/kotlin", ":smartdoc", "rip.crit.twist.tool.docs.MainKt") diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..5154008 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,7 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties + +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..eddabd2 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ad7845b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/justfile b/justfile new file mode 100644 index 0000000..b020d4f --- /dev/null +++ b/justfile @@ -0,0 +1,52 @@ +check: + ./gradlew :fmtCheck + +format: + ./gradlew :fmt + +clean: + ./gradlew clean + +build-tools: + ./gradlew :app:installDist :miner-tool:installDist :reflect-tool:installDist :twisto-tool:installDist :docs-tool:installDist + +docs: build-tools + build/projects/docs-tool/install/docs-tool/bin/docs-tool app/src/main/kotlin build/docs/api + +home: + @test -f site/index.html + @echo "Homepage is site/index.html" + +node data="build/runtime/node": build-tools + build/projects/app/install/app/bin/app {{data}} + +miner subject="twist-network" difficulty="3" attempts="1000000" threads="4": build-tools + build/projects/miner-tool/install/miner-tool/bin/miner-tool mine {{subject}} {{difficulty}} {{attempts}} {{threads}} + +staker data="build/runtime/stake" delegator="bootstrap" validator="bootstrap-validator" amount="1000000": build-tools + build/projects/miner-tool/install/miner-tool/bin/miner-tool stake {{data}} {{delegator}} {{validator}} {{amount}} + +indexer data="build/runtime/index" seed="bootstrap-validator": build-tools + build/projects/reflect-tool/install/reflect-tool/bin/reflect-tool {{data}} {{seed}} + +twisto data="build/runtime/twisto" owner="bootstrap" supply="1000000": build-tools + build/projects/twisto-tool/install/twisto-tool/bin/twisto-tool {{data}} {{owner}} {{supply}} + +up: build-tools + #!/usr/bin/env bash + set -euo pipefail + build/projects/twisto-tool/install/twisto-tool/bin/twisto-tool build/runtime/twisto bootstrap 1000000 + build/projects/miner-tool/install/miner-tool/bin/miner-tool stake build/runtime/stake bootstrap bootstrap-validator 1000000 + build/projects/reflect-tool/install/reflect-tool/bin/reflect-tool build/runtime/index bootstrap-validator + build/projects/miner-tool/install/miner-tool/bin/miner-tool mine twist-network 3 1000000 4 & + miner_pid=$! + trap 'kill "$miner_pid" 2>/dev/null || true' EXIT + build/projects/app/install/app/bin/app build/runtime/node + +build: build-tools + ./gradlew build + +publish version="0.1.0-SNAPSHOT": + git add . + git commit -m "Update version to ${version}" + git push -f origin main \ No newline at end of file diff --git a/modules/access/build.gradle.kts b/modules/access/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/access/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/bips/build.gradle.kts b/modules/bips/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/bips/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/burn/build.gradle.kts b/modules/burn/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/burn/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/bytecode/build.gradle.kts b/modules/bytecode/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/bytecode/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/compiler/build.gradle.kts b/modules/compiler/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/compiler/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/consensus/build.gradle.kts b/modules/consensus/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/consensus/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/core/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/did/build.gradle.kts b/modules/did/build.gradle.kts new file mode 100644 index 0000000..c227ca2 --- /dev/null +++ b/modules/did/build.gradle.kts @@ -0,0 +1 @@ +description = "Decentralized identifiers and did:key and did:twist resolution" diff --git a/modules/ecdsa/build.gradle.kts b/modules/ecdsa/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/ecdsa/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/gas/build.gradle.kts b/modules/gas/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/gas/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/gossip/build.gradle.kts b/modules/gossip/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/gossip/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/jit/build.gradle.kts b/modules/jit/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/jit/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/jit/src/main/cpp/CMakeLists.txt b/modules/jit/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..e89e27a --- /dev/null +++ b/modules/jit/src/main/cpp/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.20) +project(twist_llvm LANGUAGES C CXX) +find_package(LLVM REQUIRED CONFIG) +find_package(JNI REQUIRED) +add_library(twist_llvm SHARED twist_llvm.cpp) +target_include_directories(twist_llvm PRIVATE ${LLVM_INCLUDE_DIRS} ${JNI_INCLUDE_DIRS}) +target_compile_features(twist_llvm PRIVATE cxx_std_20) +llvm_map_components_to_libnames(LLVM_LIBS core asmparser nativecodegen target mc support) +target_link_libraries(twist_llvm PRIVATE ${LLVM_LIBS}) diff --git a/modules/jit/src/main/cpp/twist_llvm.cpp b/modules/jit/src/main/cpp/twist_llvm.cpp new file mode 100644 index 0000000..90267bb --- /dev/null +++ b/modules/jit/src/main/cpp/twist_llvm.cpp @@ -0,0 +1,51 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static jbyteArray fail(JNIEnv* env, const std::string& message) { + env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"), message.c_str()); + return nullptr; +} + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_rip_crit_twist_jit_NativeLlvmRuntime_nativeCompile(JNIEnv* env, jobject, jstring, jbyteArray source, jint) { + if (!source) return fail(env, "LLVM IR is required"); + auto length = env->GetArrayLength(source); + auto bytes = env->GetByteArrayElements(source, nullptr); + std::string ir(reinterpret_cast(bytes), length); + env->ReleaseByteArrayElements(source, bytes, JNI_ABORT); + llvm::LLVMContext context; + llvm::SMDiagnostic diagnostic; + auto module = llvm::parseAssemblyString(ir, diagnostic, context); + if (!module) return fail(env, "LLVM IR parse failed"); + llvm::InitializeNativeTarget(); + llvm::InitializeNativeTargetAsmPrinter(); + std::string error; + llvm::Triple triple(llvm::sys::getDefaultTargetTriple()); + auto* target = llvm::TargetRegistry::lookupTarget(triple, error); + if (!target) return fail(env, error); + auto machine = std::unique_ptr(target->createTargetMachine(triple, "generic", "", llvm::TargetOptions(), std::nullopt, std::nullopt, llvm::CodeGenOptLevel::Default, false)); + if (!machine) return fail(env, "LLVM target machine initialization failed"); + module->setTargetTriple(triple); + module->setDataLayout(machine->createDataLayout()); + llvm::SmallVector object; + llvm::raw_svector_ostream stream(object); + llvm::legacy::PassManager passes; + if (machine->addPassesToEmitFile(passes, stream, nullptr, llvm::CodeGenFileType::ObjectFile)) return fail(env, "Target cannot emit object code"); + passes.run(*module); + auto output = env->NewByteArray(static_cast(object.size())); + env->SetByteArrayRegion(output, 0, static_cast(object.size()), reinterpret_cast(object.data())); + return output; +} diff --git a/modules/merkle/build.gradle.kts b/modules/merkle/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/merkle/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/miner/build.gradle.kts b/modules/miner/build.gradle.kts new file mode 100644 index 0000000..5cfa752 --- /dev/null +++ b/modules/miner/build.gradle.kts @@ -0,0 +1 @@ +description = "Bounded CPU proof-of-work mining" diff --git a/modules/mint/build.gradle.kts b/modules/mint/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/mint/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/ope/build.gradle.kts b/modules/ope/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/ope/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/p2p/build.gradle.kts b/modules/p2p/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/p2p/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/proof/build.gradle.kts b/modules/proof/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/proof/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/reflect/build.gradle.kts b/modules/reflect/build.gradle.kts new file mode 100644 index 0000000..d2c8b78 --- /dev/null +++ b/modules/reflect/build.gradle.kts @@ -0,0 +1 @@ +description = "Network crawler and persistent peer index" diff --git a/modules/router/build.gradle.kts b/modules/router/build.gradle.kts new file mode 100644 index 0000000..1980379 --- /dev/null +++ b/modules/router/build.gradle.kts @@ -0,0 +1 @@ +description = "Off-chain computation routing, verification, and result distribution" diff --git a/modules/rsa/build.gradle.kts b/modules/rsa/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/rsa/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/smartdoc/build.gradle.kts b/modules/smartdoc/build.gradle.kts new file mode 100644 index 0000000..0fd9375 --- /dev/null +++ b/modules/smartdoc/build.gradle.kts @@ -0,0 +1 @@ +description = "SmartDoc inline API documentation and static HTML generation" diff --git a/modules/stake/build.gradle.kts b/modules/stake/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/stake/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/standards/build.gradle.kts b/modules/standards/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/standards/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/state/build.gradle.kts b/modules/state/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/state/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/store/build.gradle.kts b/modules/store/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/store/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/transport/build.gradle.kts b/modules/transport/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/transport/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/tvm/build.gradle.kts b/modules/tvm/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/tvm/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/modules/twisto/build.gradle.kts b/modules/twisto/build.gradle.kts new file mode 100644 index 0000000..9104ee4 --- /dev/null +++ b/modules/twisto/build.gradle.kts @@ -0,0 +1 @@ +description = "Twisto named token contract and decentralized metadata" diff --git a/modules/wire/build.gradle.kts b/modules/wire/build.gradle.kts new file mode 100644 index 0000000..972b4f2 --- /dev/null +++ b/modules/wire/build.gradle.kts @@ -0,0 +1 @@ +// Configured by the root build. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..d6a1ed6 --- /dev/null +++ b/pom.xml @@ -0,0 +1,50 @@ + + 4.0.0 + rip.crit + twist + 0.1.0-SNAPSHOT + pom + Twist + Twist library version catalog for Maven consumers. + + + rip.crittwist-core${project.version} + rip.crittwist-wire${project.version} + rip.crittwist-transport${project.version} + rip.crittwist-bytecode${project.version} + rip.crittwist-compiler${project.version} + rip.crittwist-proof${project.version} + rip.crittwist-ecdsa${project.version} + rip.crittwist-rsa${project.version} + rip.crittwist-merkle${project.version} + rip.crittwist-store${project.version} + rip.crittwist-state${project.version} + rip.crittwist-access${project.version} + rip.crittwist-gas${project.version} + rip.crittwist-tvm${project.version} + rip.crittwist-jit${project.version} + rip.crittwist-ope${project.version} + rip.crittwist-p2p${project.version} + rip.crittwist-gossip${project.version} + rip.crittwist-consensus${project.version} + rip.crittwist-mint${project.version} + rip.crittwist-burn${project.version} + rip.crittwist-bips${project.version} + rip.crittwist-standards${project.version} + rip.crittwist-stake${project.version} + rip.crittwist-router${project.version} + rip.crittwist-did${project.version} + rip.crittwist-miner${project.version} + rip.crittwist-reflect${project.version} + rip.crittwist-twisto${project.version} + rip.crittwist-smartdoc${project.version} + + + + + forgejo + https://ai.crit.rip/api/packages/jprims/maven + + + diff --git a/protocols/README.md b/protocols/README.md new file mode 100644 index 0000000..f314c3a --- /dev/null +++ b/protocols/README.md @@ -0,0 +1,3 @@ +# Twist protocols + +This folder contains small Lisp IR definitions for contract libraries. \ No newline at end of file diff --git a/protocols/tokens/fungible-token.twistl b/protocols/tokens/fungible-token.twistl new file mode 100644 index 0000000..4e1f83a --- /dev/null +++ b/protocols/tokens/fungible-token.twistl @@ -0,0 +1,20 @@ +(contract FungibleToken + (access + (read storage:* *) + (write storage:* owner) + (execute function:balance-of *) + (execute function:transfer *) + (execute function:total-supply *)) + (function balance-of 1 + (push32 0) + (sload) + (return)) + (function transfer 2 + (push32 0) + (push32 0) + (sstore) + (return)) + (function total-supply 3 + (push32 1) + (sload) + (return))) diff --git a/protocols/tokens/twisto.twistl b/protocols/tokens/twisto.twistl new file mode 100644 index 0000000..4d9087d --- /dev/null +++ b/protocols/tokens/twisto.twistl @@ -0,0 +1,20 @@ +(contract Twisto + (access + (read storage:* *) + (write storage:* owner) + (execute function:metadata *) + (execute function:total-supply *) + (execute function:mint owner)) + (function metadata 1 + (push32 0) + (sload) + (return)) + (function total-supply 2 + (push32 1) + (sload) + (return)) + (function mint 3 + (push32 1) + (push32 0) + (sstore) + (return))) diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..fc769a5 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,23 @@ +rootProject.name = "twist" + +include("app") +include("compiler-app") +project(":compiler-app").projectDir = file("apps/compiler") +include("miner-tool") +project(":miner-tool").projectDir = file("apps/miner") +include("reflect-tool") +project(":reflect-tool").projectDir = file("apps/reflect") +include("twisto-tool") +project(":twisto-tool").projectDir = file("apps/twisto") +include("docs-tool") +project(":docs-tool").projectDir = file("apps/docs") + +val libraryModules = setOf( + "core", "wire", "transport", "bytecode", "compiler", "proof", "ecdsa", "rsa", "merkle", "store", "state", "access", "gas", "tvm", "jit", "ope", + "p2p", "gossip", "consensus", "mint", "burn", "bips", "standards", "stake", "router", "did", "miner", "reflect", "twisto", "smartdoc", +) + +libraryModules.forEach { moduleName -> + include(moduleName) + project(":$moduleName").projectDir = file("modules/$moduleName") +} diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..e946725 --- /dev/null +++ b/site/index.html @@ -0,0 +1,41 @@ + + + + + + + Twist + + + +
+

Modular Kotlin protocol implementation

+

Twist

+

+ A local-first network and smart-contract laboratory with persistent state, content-addressed + metadata, bytecode execution, peer networking, decentralized identifiers, and off-chain jobs. +

+
+

Node

TCP peer handshake, framed broadcast, persistent state, and basic consensus checks.

+

Contracts

256-bit metered VM, Lisp IR compiler, capability checks, and Twisto example token.

+

Identity

did:key documents plus explicitly gated did:twist overlay identities.

+

Tools

CPU mining, staking, peer reflection, static API documentation, and repeatable Just recipes.

+
+

Run locally

+

just up builds the tools, deploys Twisto metadata, records a stake, indexes the bootstrap peer, mines a bounded proof, and starts a TCP node.

+

just docs writes generated SmartDoc pages to build/docs/api.

+

Generated API documentation

+
+ + +