branch(main): Initial commit.
This commit is contained in:
commit
ef72a56501
142 changed files with 6977 additions and 0 deletions
32
.forgejo/workflows/publish.yml
Normal file
32
.forgejo/workflows/publish.yml
Normal file
|
|
@ -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 }}
|
||||||
12
.gitattributes
vendored
Normal file
12
.gitattributes
vendored
Normal file
|
|
@ -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
|
||||||
|
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Ignore Gradle project-specific cache directory
|
||||||
|
.gradle/
|
||||||
|
|
||||||
|
# Ignore Gradle build output directory
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Ignore Kotlin plugin data
|
||||||
|
.kotlin/
|
||||||
0
README.md
Normal file
0
README.md
Normal file
3
app/build.gradle.kts
Normal file
3
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
dependencies {
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
}
|
||||||
66
app/src/main/kotlin/rip/crit/twist/App.kt
Normal file
66
app/src/main/kotlin/rip/crit/twist/App.kt
Normal file
|
|
@ -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<String>) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
9
app/src/main/kotlin/rip/crit/twist/access/AccessList.kt
Normal file
9
app/src/main/kotlin/rip/crit/twist/access/AccessList.kt
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
package rip.crit.twist.access
|
||||||
|
|
||||||
|
import rip.crit.twist.core.Address
|
||||||
|
|
||||||
|
data class AccessList(val reads: Set<Address> = emptySet(), val writes: Set<Address> = emptySet()) {
|
||||||
|
fun conflictsWith(other: AccessList): Boolean =
|
||||||
|
writes.intersect(other.reads + other.writes).isNotEmpty() ||
|
||||||
|
other.writes.intersect(reads).isNotEmpty()
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/src/main/kotlin/rip/crit/twist/bips/StorageContract.kt
Normal file
23
app/src/main/kotlin/rip/crit/twist/bips/StorageContract.kt
Normal file
|
|
@ -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<String, Hash> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/src/main/kotlin/rip/crit/twist/burn/BurnPolicy.kt
Normal file
7
app/src/main/kotlin/rip/crit/twist/burn/BurnPolicy.kt
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
package rip.crit.twist.burn
|
||||||
|
|
||||||
|
import rip.crit.twist.core.Amount
|
||||||
|
|
||||||
|
fun interface BurnPolicy {
|
||||||
|
fun amountFor(fee: Amount): Amount
|
||||||
|
}
|
||||||
18
app/src/main/kotlin/rip/crit/twist/burn/Policies.kt
Normal file
18
app/src/main/kotlin/rip/crit/twist/burn/Policies.kt
Normal file
|
|
@ -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))
|
||||||
|
)
|
||||||
|
}
|
||||||
87
app/src/main/kotlin/rip/crit/twist/bytecode/TwistBytecode.kt
Normal file
87
app/src/main/kotlin/rip/crit/twist/bytecode/TwistBytecode.kt
Normal file
|
|
@ -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<Instruction>): 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<Instruction> {
|
||||||
|
val input = ByteBuffer.wrap(code)
|
||||||
|
val output = mutableListOf<Instruction>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
100
app/src/main/kotlin/rip/crit/twist/compiler/ContractIr.kt
Normal file
100
app/src/main/kotlin/rip/crit/twist/compiler/ContractIr.kt
Normal file
|
|
@ -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<String>,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(resource.isNotBlank())
|
||||||
|
require(principals.isNotEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class IrAccessPolicy(val rules: List<AccessRule> = 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<FunctionIr>,
|
||||||
|
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<Instruction>)
|
||||||
|
|
||||||
|
class ContractBuilder(private val name: String) {
|
||||||
|
private val functions = mutableListOf<FunctionIr>()
|
||||||
|
private val accessRules = mutableListOf<AccessRule>()
|
||||||
|
|
||||||
|
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<Instruction>()
|
||||||
|
|
||||||
|
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<Instruction> = 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()
|
||||||
166
app/src/main/kotlin/rip/crit/twist/compiler/LispIrCompiler.kt
Normal file
166
app/src/main/kotlin/rip/crit/twist/compiler/LispIrCompiler.kt
Normal file
|
|
@ -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<AccessRule>()
|
||||||
|
val functions = mutableListOf<FunctionIr>()
|
||||||
|
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<SExpr>): List<AccessRule> = 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<SExpr>): 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>) : SExpr
|
||||||
|
|
||||||
|
fun atom(): String = (this as? Atom)?.value ?: error("Expected atom")
|
||||||
|
|
||||||
|
fun list(expectedHead: String? = null): List<SExpr> =
|
||||||
|
(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<SExpr> = 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<SExpr>()
|
||||||
|
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<String> {
|
||||||
|
val output = mutableListOf<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/src/main/kotlin/rip/crit/twist/compiler/TwistCompiler.kt
Normal file
43
app/src/main/kotlin/rip/crit/twist/compiler/TwistCompiler.kt
Normal file
|
|
@ -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]}")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
10
app/src/main/kotlin/rip/crit/twist/core/HmacSha256.kt
Normal file
10
app/src/main/kotlin/rip/crit/twist/core/HmacSha256.kt
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
170
app/src/main/kotlin/rip/crit/twist/core/Sha256.kt
Normal file
170
app/src/main/kotlin/rip/crit/twist/core/Sha256.kt
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/src/main/kotlin/rip/crit/twist/core/SmartDoc.kt
Normal file
10
app/src/main/kotlin/rip/crit/twist/core/SmartDoc.kt
Normal file
|
|
@ -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",
|
||||||
|
)
|
||||||
56
app/src/main/kotlin/rip/crit/twist/core/TransactionHasher.kt
Normal file
56
app/src/main/kotlin/rip/crit/twist/core/TransactionHasher.kt
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/src/main/kotlin/rip/crit/twist/core/TwistSpecific.kt
Normal file
6
app/src/main/kotlin/rip/crit/twist/core/TwistSpecific.kt
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
71
app/src/main/kotlin/rip/crit/twist/core/Types.kt
Normal file
71
app/src/main/kotlin/rip/crit/twist/core/Types.kt
Normal file
|
|
@ -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<Transaction>, val hash: Hash)
|
||||||
201
app/src/main/kotlin/rip/crit/twist/did/Did.kt
Normal file
201
app/src/main/kotlin/rip/crit/twist/did/Did.kt
Normal file
|
|
@ -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<VerificationMethod>,
|
||||||
|
val authentication: List<String> = verificationMethods.map { it.id },
|
||||||
|
val assertionMethod: List<String> = verificationMethods.map { it.id },
|
||||||
|
val services: List<DidService> = 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<String>) =
|
||||||
|
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<Byte>()
|
||||||
|
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<Int, Int> {
|
||||||
|
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<String>,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/src/main/kotlin/rip/crit/twist/did/DidMethodRegistry.kt
Normal file
25
app/src/main/kotlin/rip/crit/twist/did/DidMethodRegistry.kt
Normal file
|
|
@ -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<String, DidResolver>) {
|
||||||
|
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<String, DidResolver>("key" to DidResolver(DidKey::resolve))
|
||||||
|
if (twist != null) methods["twist"] = DidResolver(twist::resolve)
|
||||||
|
return DidMethodRegistry(methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/src/main/kotlin/rip/crit/twist/ecdsa/Ecdsa.kt
Normal file
7
app/src/main/kotlin/rip/crit/twist/ecdsa/Ecdsa.kt
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
134
app/src/main/kotlin/rip/crit/twist/ecdsa/Secp256k1Ecdsa.kt
Normal file
134
app/src/main/kotlin/rip/crit/twist/ecdsa/Secp256k1Ecdsa.kt
Normal file
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/src/main/kotlin/rip/crit/twist/gas/BasicGasMeter.kt
Normal file
16
app/src/main/kotlin/rip/crit/twist/gas/BasicGasMeter.kt
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/src/main/kotlin/rip/crit/twist/gas/Gas.kt
Normal file
29
app/src/main/kotlin/rip/crit/twist/gas/Gas.kt
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
13
app/src/main/kotlin/rip/crit/twist/gossip/GossipService.kt
Normal file
13
app/src/main/kotlin/rip/crit/twist/gossip/GossipService.kt
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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<String, MutableList<(NetworkMessage) -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
app/src/main/kotlin/rip/crit/twist/jit/JitCompiler.kt
Normal file
9
app/src/main/kotlin/rip/crit/twist/jit/JitCompiler.kt
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
package rip.crit.twist.jit
|
||||||
|
|
||||||
|
interface JitCompiler {
|
||||||
|
fun compile(bytecode: ByteArray): CompiledProgram
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface CompiledProgram {
|
||||||
|
fun invoke(input: ByteArray): ByteArray
|
||||||
|
}
|
||||||
35
app/src/main/kotlin/rip/crit/twist/jit/LLVMBuilder.kt
Normal file
35
app/src/main/kotlin/rip/crit/twist/jit/LLVMBuilder.kt
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/src/main/kotlin/rip/crit/twist/merkle/MerkleTree.kt
Normal file
15
app/src/main/kotlin/rip/crit/twist/merkle/MerkleTree.kt
Normal file
|
|
@ -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<Hash>,
|
||||||
|
val siblingOnLeft: List<Boolean> = emptyList(),
|
||||||
|
)
|
||||||
|
|
@ -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<Hash>) : MerkleTree {
|
||||||
|
private val levels: List<List<Hash>>
|
||||||
|
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<Hash>()
|
||||||
|
val siblingOnLeft = mutableListOf<Boolean>()
|
||||||
|
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
|
||||||
|
}
|
||||||
54
app/src/main/kotlin/rip/crit/twist/miner/CpuMiner.kt
Normal file
54
app/src/main/kotlin/rip/crit/twist/miner/CpuMiner.kt
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/src/main/kotlin/rip/crit/twist/mint/MintPolicy.kt
Normal file
7
app/src/main/kotlin/rip/crit/twist/mint/MintPolicy.kt
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
package rip.crit.twist.mint
|
||||||
|
|
||||||
|
import rip.crit.twist.core.Amount
|
||||||
|
|
||||||
|
fun interface MintPolicy {
|
||||||
|
fun rewardAt(height: Long): Amount
|
||||||
|
}
|
||||||
17
app/src/main/kotlin/rip/crit/twist/mint/Policies.kt
Normal file
17
app/src/main/kotlin/rip/crit/twist/mint/Policies.kt
Normal file
|
|
@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Transaction>): List<ExecutionResult> {
|
||||||
|
val indexed = transactions.mapIndexed { index, transaction ->
|
||||||
|
IndexedValue(index, transaction)
|
||||||
|
}
|
||||||
|
val pending = ArrayDeque(indexed)
|
||||||
|
val results = arrayOfNulls<ExecutionResult>(transactions.size)
|
||||||
|
val pool = Executors.newFixedThreadPool(threads)
|
||||||
|
try {
|
||||||
|
while (pending.isNotEmpty()) {
|
||||||
|
val batch = mutableListOf<IndexedValue<Transaction>>()
|
||||||
|
val deferred = ArrayDeque<IndexedValue<Transaction>>()
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Transaction>): List<ExecutionResult>
|
||||||
|
}
|
||||||
14
app/src/main/kotlin/rip/crit/twist/ope/SequentialExecutor.kt
Normal file
14
app/src/main/kotlin/rip/crit/twist/ope/SequentialExecutor.kt
Normal file
|
|
@ -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<Transaction>): List<ExecutionResult> =
|
||||||
|
transactions.map {
|
||||||
|
vm.execute(it, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
243
app/src/main/kotlin/rip/crit/twist/p2p/Adapters.kt
Normal file
243
app/src/main/kotlin/rip/crit/twist/p2p/Adapters.kt
Normal file
|
|
@ -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<ProtocolCapability>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Libp2pIdentify(
|
||||||
|
val peer: PeerId,
|
||||||
|
val protocols: Set<String>,
|
||||||
|
val addresses: Set<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 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<ProtocolCapability> = 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<ProtocolCapability> =
|
||||||
|
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<String> = 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>): String? =
|
||||||
|
protocols.sorted().firstOrNull { it in remoteProtocols }
|
||||||
|
|
||||||
|
fun identify(peer: PeerId, addresses: Set<String>): Libp2pIdentify =
|
||||||
|
Libp2pIdentify(peer, protocols, addresses)
|
||||||
|
|
||||||
|
fun identifyFrame(peer: PeerId, addresses: Set<String>, observed: String = ""): ByteArray =
|
||||||
|
Libp2p.encodeIdentify(agent, protocols, observed, addresses)
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalPeerNetwork : PeerNetwork {
|
||||||
|
private val connected = linkedSetOf<PeerId>()
|
||||||
|
|
||||||
|
private var running = false
|
||||||
|
|
||||||
|
val received = mutableListOf<NetworkMessage>()
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun peers(): Set<PeerId> = 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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
47
app/src/main/kotlin/rip/crit/twist/p2p/Dht.kt
Normal file
47
app/src/main/kotlin/rip/crit/twist/p2p/Dht.kt
Normal file
|
|
@ -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<String>, val expiresAt: Instant)
|
||||||
|
|
||||||
|
interface DistributedHashTable {
|
||||||
|
fun put(key: Hash, record: PeerRecord)
|
||||||
|
|
||||||
|
fun get(key: Hash): PeerRecord?
|
||||||
|
|
||||||
|
fun closest(key: Hash, limit: Int = 20): List<PeerRecord>
|
||||||
|
}
|
||||||
|
|
||||||
|
class InMemoryDht(private val clock: Clock = Clock.systemUTC()) : DistributedHashTable {
|
||||||
|
private val records = ConcurrentHashMap<Hash, PeerRecord>()
|
||||||
|
|
||||||
|
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<PeerRecord> {
|
||||||
|
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<String>, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/src/main/kotlin/rip/crit/twist/p2p/FileDht.kt
Normal file
62
app/src/main/kotlin/rip/crit/twist/p2p/FileDht.kt
Normal file
|
|
@ -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<PeerRecord> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
224
app/src/main/kotlin/rip/crit/twist/p2p/Libp2p.kt
Normal file
224
app/src/main/kotlin/rip/crit/twist/p2p/Libp2p.kt
Normal file
|
|
@ -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 || "<proto>\n"`; responder answers `varint-len || "<proto>\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<Byte>()
|
||||||
|
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<Long, Int> {
|
||||||
|
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<Int, List<ByteArray>> {
|
||||||
|
val out = mutableMapOf<Int, MutableList<ByteArray>>()
|
||||||
|
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<String>, observed: String, listen: Set<String>): 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<String>,
|
||||||
|
val observedAddr: String,
|
||||||
|
val listenAddrs: Set<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 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.
|
||||||
37
app/src/main/kotlin/rip/crit/twist/p2p/PeerNetwork.kt
Normal file
37
app/src/main/kotlin/rip/crit/twist/p2p/PeerNetwork.kt
Normal file
|
|
@ -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<PeerId>
|
||||||
|
|
||||||
|
fun broadcast(message: NetworkMessage)
|
||||||
|
}
|
||||||
36
app/src/main/kotlin/rip/crit/twist/p2p/ProtocolCatalog.kt
Normal file
36
app/src/main/kotlin/rip/crit/twist/p2p/ProtocolCatalog.kt
Normal file
|
|
@ -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<String>,
|
||||||
|
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<ProtocolDescriptor> =
|
||||||
|
listOf(devP2p, libP2p) +
|
||||||
|
if (twistSpecific === TwistSpecific.Enabled) listOf(twistOverlay) else emptyList()
|
||||||
|
}
|
||||||
145
app/src/main/kotlin/rip/crit/twist/p2p/Rlp.kt
Normal file
145
app/src/main/kotlin/rip/crit/twist/p2p/Rlp.kt
Normal file
|
|
@ -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>): 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<PRlpItem, Int> {
|
||||||
|
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<PRlpItem>()
|
||||||
|
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<Byte>()
|
||||||
|
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>) : PRlpItem
|
||||||
|
}
|
||||||
187
app/src/main/kotlin/rip/crit/twist/p2p/Rlpx.kt
Normal file
187
app/src/main/kotlin/rip/crit/twist/p2p/Rlpx.kt
Normal file
|
|
@ -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<Int, ByteArray> {
|
||||||
|
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.
|
||||||
109
app/src/main/kotlin/rip/crit/twist/proof/Proof.kt
Normal file
109
app/src/main/kotlin/rip/crit/twist/proof/Proof.kt
Normal file
|
|
@ -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<String>,
|
||||||
|
) : 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<String>) =
|
||||||
|
ProofOfAuthority(
|
||||||
|
subject,
|
||||||
|
authority,
|
||||||
|
rip.crit.twist.core.Sha256.digestUtf8("${subject.value}:$authority"),
|
||||||
|
allowedAuthorities,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DynamicProofVerifier(private val verifiers: Map<String, (Proof) -> 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
102
app/src/main/kotlin/rip/crit/twist/reflect/NetworkReflector.kt
Normal file
102
app/src/main/kotlin/rip/crit/twist/reflect/NetworkReflector.kt
Normal file
|
|
@ -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<String>,
|
||||||
|
val neighbors: Set<PeerId>,
|
||||||
|
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<PeerObservation>,
|
||||||
|
val failures: Set<PeerId>,
|
||||||
|
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<PeerId>, maximumPeers: Int = 1_000): CrawlReport {
|
||||||
|
require(maximumPeers > 0)
|
||||||
|
val queued = ArrayDeque(seeds.distinct())
|
||||||
|
val visited = linkedSetOf<PeerId>()
|
||||||
|
val observations = mutableListOf<PeerObservation>()
|
||||||
|
val failures = linkedSetOf<PeerId>()
|
||||||
|
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<String>) {
|
||||||
|
require(values.size <= 10_000)
|
||||||
|
writeInt(values.size)
|
||||||
|
values.sorted().forEach(::writeUTF)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DataInputStream.readStrings(): Set<String> {
|
||||||
|
val count = readInt()
|
||||||
|
require(count in 0..10_000)
|
||||||
|
return buildSet(count) { repeat(count) { add(readUTF()) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
244
app/src/main/kotlin/rip/crit/twist/router/OffchainRouter.kt
Normal file
244
app/src/main/kotlin/rip/crit/twist/router/OffchainRouter.kt
Normal file
|
|
@ -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<String>,
|
||||||
|
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<Address, OffchainWorker>()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/src/main/kotlin/rip/crit/twist/rsa/Rsa.kt
Normal file
7
app/src/main/kotlin/rip/crit/twist/rsa/Rsa.kt
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
122
app/src/main/kotlin/rip/crit/twist/rsa/RsaOaep.kt
Normal file
122
app/src/main/kotlin/rip/crit/twist/rsa/RsaOaep.kt
Normal file
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<SmartDocEntry> {
|
||||||
|
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<SmartDocEntry> {
|
||||||
|
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<SmartDocEntry>): String =
|
||||||
|
"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Twist SmartDoc API</title><style>
|
||||||
|
body{font:16px system-ui,sans-serif;max-width:960px;margin:3rem auto;padding:0 1rem;color:#18212f;background:#f8fafc}
|
||||||
|
h1{color:#5b21b6}.entry{background:white;border:1px solid #dbe3ef;border-radius:8px;padding:1rem;margin:.75rem 0}
|
||||||
|
code{color:#0f766e}.tag{float:right;color:#475569;font-size:.85rem}
|
||||||
|
</style></head><body><h1>Twist SmartDoc API</h1><p>${entries.size} documented public APIs.</p>
|
||||||
|
${entries.joinToString("\n") { entry -> "<article class=\"entry\"><span class=\"tag\">${escape(entry.category)} · ${escape(entry.stability)}</span><h2>${escape(entry.name)}</h2><code>${escape(entry.packageName)}</code><p>${escape(entry.summary)}</p><small>${escape(entry.source)}</small></article>" }}
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
.trimIndent()
|
||||||
|
|
||||||
|
private fun json(entries: List<SmartDocEntry>): 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_]*)")
|
||||||
|
}
|
||||||
25
app/src/main/kotlin/rip/crit/twist/stake/InMemoryStaking.kt
Normal file
25
app/src/main/kotlin/rip/crit/twist/stake/InMemoryStaking.kt
Normal file
|
|
@ -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<Pair<Address, Address>, 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
10
app/src/main/kotlin/rip/crit/twist/stake/Staking.kt
Normal file
10
app/src/main/kotlin/rip/crit/twist/stake/Staking.kt
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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<Address, Amount> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
71
app/src/main/kotlin/rip/crit/twist/state/FileNodeState.kt
Normal file
71
app/src/main/kotlin/rip/crit/twist/state/FileNodeState.kt
Normal file
|
|
@ -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<String, Node>()
|
||||||
|
|
||||||
|
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<String, Node>) : StateSnapshot {
|
||||||
|
override fun close() = Unit
|
||||||
|
}
|
||||||
116
app/src/main/kotlin/rip/crit/twist/state/FileWorldState.kt
Normal file
116
app/src/main/kotlin/rip/crit/twist/state/FileWorldState.kt
Normal file
|
|
@ -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<String>) {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String, Node>()
|
||||||
|
|
||||||
|
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<String, Node>) : StateSnapshot {
|
||||||
|
override fun close() = Unit
|
||||||
|
}
|
||||||
|
|
@ -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<Address, Account>()
|
||||||
|
private val storage = ConcurrentHashMap<Pair<Address, Hash>, 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<Address, Account>) :
|
||||||
|
StateSnapshot {
|
||||||
|
override fun close() = Unit
|
||||||
|
}
|
||||||
42
app/src/main/kotlin/rip/crit/twist/state/NodeState.kt
Normal file
42
app/src/main/kotlin/rip/crit/twist/state/NodeState.kt
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
package rip.crit.twist.state
|
||||||
|
|
||||||
|
data class Node(
|
||||||
|
val storage: Map<String, ByteArray> = emptyMap(),
|
||||||
|
val accounts: Map<String, AccountState> = 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
|
||||||
|
}
|
||||||
51
app/src/main/kotlin/rip/crit/twist/state/WorldState.kt
Normal file
51
app/src/main/kotlin/rip/crit/twist/state/WorldState.kt
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
108
app/src/main/kotlin/rip/crit/twist/store/FileStore.kt
Normal file
108
app/src/main/kotlin/rip/crit/twist/store/FileStore.kt
Normal file
|
|
@ -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<String> = 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<String>): 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()
|
||||||
|
}
|
||||||
31
app/src/main/kotlin/rip/crit/twist/store/InMemoryStore.kt
Normal file
31
app/src/main/kotlin/rip/crit/twist/store/InMemoryStore.kt
Normal file
|
|
@ -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<Hash, Block>()
|
||||||
|
|
||||||
|
override fun get(hash: Hash): Block? = blocks[hash]
|
||||||
|
|
||||||
|
override fun put(block: Block) {
|
||||||
|
blocks[block.hash] = block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InMemoryKeyValueStore : KeyValueStore {
|
||||||
|
private val values = ConcurrentHashMap<String, ByteArray>()
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
18
app/src/main/kotlin/rip/crit/twist/store/Store.kt
Normal file
18
app/src/main/kotlin/rip/crit/twist/store/Store.kt
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
227
app/src/main/kotlin/rip/crit/twist/transport/AesGcm.kt
Normal file
227
app/src/main/kotlin/rip/crit/twist/transport/AesGcm.kt
Normal file
|
|
@ -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<ByteArray> =
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
122
app/src/main/kotlin/rip/crit/twist/transport/TcpPeerNetwork.kt
Normal file
122
app/src/main/kotlin/rip/crit/twist/transport/TcpPeerNetwork.kt
Normal file
|
|
@ -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<PeerId, Connection>()
|
||||||
|
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<PeerId> = 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/src/main/kotlin/rip/crit/twist/transport/X25519.kt
Normal file
84
app/src/main/kotlin/rip/crit/twist/transport/X25519.kt
Normal file
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
249
app/src/main/kotlin/rip/crit/twist/tvm/BytecodeVirtualMachine.kt
Normal file
249
app/src/main/kotlin/rip/crit/twist/tvm/BytecodeVirtualMachine.kt
Normal file
|
|
@ -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<Word256>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
119
app/src/main/kotlin/rip/crit/twist/tvm/ExecutionHost.kt
Normal file
119
app/src/main/kotlin/rip/crit/twist/tvm/ExecutionHost.kt
Normal file
|
|
@ -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<ContractLog>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/src/main/kotlin/rip/crit/twist/tvm/TransientStorage.kt
Normal file
16
app/src/main/kotlin/rip/crit/twist/tvm/TransientStorage.kt
Normal file
|
|
@ -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<Address, ConcurrentHashMap<Word256, Word256>>()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
38
app/src/main/kotlin/rip/crit/twist/tvm/VirtualMachine.kt
Normal file
38
app/src/main/kotlin/rip/crit/twist/tvm/VirtualMachine.kt
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
88
app/src/main/kotlin/rip/crit/twist/tvm/Word256.kt
Normal file
88
app/src/main/kotlin/rip/crit/twist/tvm/Word256.kt
Normal file
|
|
@ -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<Word256> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
138
app/src/main/kotlin/rip/crit/twist/twisto/TwistoToken.kt
Normal file
138
app/src/main/kotlin/rip/crit/twist/twisto/TwistoToken.kt
Normal file
|
|
@ -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<String, String> = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/src/main/kotlin/rip/crit/twist/wire/FrameCodec.kt
Normal file
24
app/src/main/kotlin/rip/crit/twist/wire/FrameCodec.kt
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/src/main/kotlin/rip/crit/twist/wire/PacketSpec.kt
Normal file
73
app/src/main/kotlin/rip/crit/twist/wire/PacketSpec.kt
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
145
app/src/main/kotlin/rip/crit/twist/wire/Rlp.kt
Normal file
145
app/src/main/kotlin/rip/crit/twist/wire/Rlp.kt
Normal file
|
|
@ -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>): 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<RlpItem, Int> {
|
||||||
|
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<RlpItem>()
|
||||||
|
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<Byte>()
|
||||||
|
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>) : RlpItem
|
||||||
|
}
|
||||||
92
app/src/main/kotlin/rip/crit/twist/wire/WireCodec.kt
Normal file
92
app/src/main/kotlin/rip/crit/twist/wire/WireCodec.kt
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
465
app/src/test/kotlin/rip/crit/twist/AppTest.kt
Normal file
465
app/src/test/kotlin/rip/crit/twist/AppTest.kt
Normal file
|
|
@ -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<OutOfGasException> { 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<IllegalArgumentException> { 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<NetworkMessage>()
|
||||||
|
val network =
|
||||||
|
object : PeerNetwork {
|
||||||
|
override fun start() = Unit
|
||||||
|
override fun stop() = Unit
|
||||||
|
override fun peers() = emptySet<PeerId>()
|
||||||
|
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<IllegalArgumentException> { 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<IllegalArgumentException> { 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<IllegalArgumentException> {
|
||||||
|
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<IllegalArgumentException> {
|
||||||
|
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) }
|
||||||
|
}
|
||||||
1
apps/compiler/build.gradle.kts
Normal file
1
apps/compiler/build.gradle.kts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
// Configured by the root build.
|
||||||
|
|
@ -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<String>) {
|
||||||
|
require(args.size in 2..3) {
|
||||||
|
"Usage is twistc <source.twistasm|source.twistl> <output.tbc> [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)
|
||||||
|
}
|
||||||
1
apps/docs/build.gradle.kts
Normal file
1
apps/docs/build.gradle.kts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
description = "SmartDoc static documentation generator"
|
||||||
11
apps/docs/src/main/kotlin/rip/crit/twist/tool/docs/Main.kt
Normal file
11
apps/docs/src/main/kotlin/rip/crit/twist/tool/docs/Main.kt
Normal file
|
|
@ -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<String>) {
|
||||||
|
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")
|
||||||
|
}
|
||||||
1
apps/miner/build.gradle.kts
Normal file
1
apps/miner/build.gradle.kts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
description = "Twist CPU miner command-line tool"
|
||||||
31
apps/miner/src/main/kotlin/rip/crit/twist/tool/miner/Main.kt
Normal file
31
apps/miner/src/main/kotlin/rip/crit/twist/tool/miner/Main.kt
Normal file
|
|
@ -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<String>) {
|
||||||
|
require(args.isNotEmpty()) {
|
||||||
|
"Usage is twist-miner mine <subject> <difficulty> [attempts] [threads], or stake <folder> <delegator> <validator> <amount>"
|
||||||
|
}
|
||||||
|
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}")
|
||||||
|
}
|
||||||
1
apps/reflect/build.gradle.kts
Normal file
1
apps/reflect/build.gradle.kts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
description = "Twist network reflection command-line tool"
|
||||||
|
|
@ -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<String>) {
|
||||||
|
require(args.size >= 2) { "Usage is twist-reflect <index-folder> <seed> [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}"
|
||||||
|
)
|
||||||
|
}
|
||||||
1
apps/twisto/build.gradle.kts
Normal file
1
apps/twisto/build.gradle.kts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
description = "Twisto deployment example"
|
||||||
|
|
@ -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<String>) {
|
||||||
|
require(args.size in 1..3) {
|
||||||
|
"Usage is twisto <data-folder> [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}")
|
||||||
|
}
|
||||||
186
build.gradle.kts
Normal file
186
build.gradle.kts
Normal file
|
|
@ -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<com.ncorti.ktfmt.gradle.tasks.KtfmtCheckTask>("fmtCheck") {
|
||||||
|
group = "verification"
|
||||||
|
source = project.fileTree(layout.projectDirectory.dir("app/src/main/kotlin")) {
|
||||||
|
include("**/*.kt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask>("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<KotlinJvmProjectExtension> {
|
||||||
|
jvmToolchain(25)
|
||||||
|
sourceSets.named("main") {
|
||||||
|
kotlin.srcDir(rootProject.file("app/src/main/kotlin"))
|
||||||
|
kotlin.include("rip/crit/twist/${project.name}/**")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<KotlinCompile>().configureEach {
|
||||||
|
compilerOptions.freeCompilerArgs.add("-Xjsr305=strict")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(subprojects.filter { it.name in libraryModules }) {
|
||||||
|
apply(plugin = "java-library")
|
||||||
|
apply(plugin = "maven-publish")
|
||||||
|
|
||||||
|
extensions.configure<org.gradle.api.plugins.JavaPluginExtension> {
|
||||||
|
withSourcesJar()
|
||||||
|
withJavadocJar()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
moduleDependencies[name].orEmpty().forEach { dependency -> add("api", project(":$dependency")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
extensions.configure<PublishingExtension> {
|
||||||
|
publications {
|
||||||
|
create<MavenPublication>("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<BasicAuthentication>("basic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
project(":app") {
|
||||||
|
apply(plugin = "application")
|
||||||
|
extensions.configure<KotlinJvmProjectExtension> {
|
||||||
|
sourceSets.named("main") { kotlin.include("rip/crit/twist/App.kt") }
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
libraryModules.forEach { dependency -> add("implementation", project(":$dependency")) }
|
||||||
|
}
|
||||||
|
extensions.configure<JavaApplication> {
|
||||||
|
mainClass.set("rip.crit.twist.AppKt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
project(":compiler-app") {
|
||||||
|
apply(plugin = "application")
|
||||||
|
extensions.configure<KotlinJvmProjectExtension> {
|
||||||
|
sourceSets.named("main") {
|
||||||
|
kotlin.setSrcDirs(listOf(rootProject.file("apps/compiler/src/main/kotlin")))
|
||||||
|
kotlin.setIncludes(emptySet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencies { add("implementation", project(":compiler")) }
|
||||||
|
extensions.configure<JavaApplication> { 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<KotlinJvmProjectExtension> {
|
||||||
|
sourceSets.named("main") {
|
||||||
|
kotlin.setSrcDirs(listOf(rootProject.file(sourceDirectory)))
|
||||||
|
kotlin.setIncludes(emptySet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencies { add("implementation", project(dependency)) }
|
||||||
|
extensions.configure<JavaApplication> { 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")
|
||||||
7
gradle.properties
Normal file
7
gradle.properties
Normal file
|
|
@ -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
|
||||||
|
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue