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

Twist SmartDoc API

${entries.size} documented public APIs.

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

${escape(entry.name)}

${escape(entry.packageName)}

${escape(entry.summary)}

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

Modular Kotlin protocol implementation

+

Twist

+

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

+
+

Node

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

+

Contracts

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

+

Identity

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

+

Tools

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

+
+

Run locally

+

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

+

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

+

Generated API documentation

+
+ + +