diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml index 0cf789b..8dd7864 100644 --- a/.forgejo/workflows/publish.yml +++ b/.forgejo/workflows/publish.yml @@ -11,7 +11,7 @@ jobs: runs-on: docker steps: - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 + - uses: https://github.com/actions/setup-java@v4 with: distribution: temurin java-version: "25" diff --git a/app/src/main/kotlin/rip/crit/twist/bytecode/TwistBytecode.kt b/app/src/main/kotlin/rip/crit/twist/bytecode/TwistBytecode.kt index 5d00980..88273d6 100644 --- a/app/src/main/kotlin/rip/crit/twist/bytecode/TwistBytecode.kt +++ b/app/src/main/kotlin/rip/crit/twist/bytecode/TwistBytecode.kt @@ -58,6 +58,7 @@ enum class OpCode(val code: Byte) { data class Instruction(val opCode: OpCode, val immediate: ByteArray = byteArrayOf()) { init { require(opCode != OpCode.PUSH32 || immediate.size == 32) + require(opCode == OpCode.PUSH32 || immediate.isEmpty()) } } @@ -78,8 +79,10 @@ object TwistBytecode { 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() + val immediate = if (op == OpCode.PUSH32) { + require(input.remaining() >= 32) { "Truncated PUSH32 immediate" } + 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 index 7b4f6a2..35079b3 100644 --- a/app/src/main/kotlin/rip/crit/twist/compiler/ContractIr.kt +++ b/app/src/main/kotlin/rip/crit/twist/compiler/ContractIr.kt @@ -43,7 +43,49 @@ data class ContractIr( val functions: List, val accessPolicy: IrAccessPolicy = IrAccessPolicy(), ) { - fun bytecode(): ByteArray = TwistBytecode.encode(functions.flatMap { it.instructions }) + /** + * Encodes a deployed contract. Calldata word zero is the function selector and all + * function arguments begin at word one. A non-matching selector deterministically reverts. + */ + fun bytecode(): ByteArray { + require(functions.map { it.selector }.distinct().size == functions.size) { "Duplicate function selector" } + val dispatchSize = 2 + functions.size * 5 + 1 + var nextBody = dispatchSize + val targets = functions.associate { function -> + function.selector to nextBody.also { nextBody += function.instructions.size } + } + val output = mutableListOf( + Instruction(OpCode.PUSH32, Word256.ZERO.toBytes()), + Instruction(OpCode.CALLDATA_LOAD), + ) + functions.forEach { function -> + output += Instruction(OpCode.DUP) + output += Instruction(OpCode.PUSH32, Word256.fromLong(function.selector.toLong()).toBytes()) + output += Instruction(OpCode.EQ) + output += Instruction(OpCode.PUSH32, Word256.fromLong(targets.getValue(function.selector).toLong()).toBytes()) + output += Instruction(OpCode.JUMPI) + } + output += Instruction(OpCode.REVERT) + functions.forEach { function -> + val offset = targets.getValue(function.selector) + output += function.instructions.mapIndexed { index, instruction -> + if ( + instruction.opCode == OpCode.PUSH32 && + function.instructions.getOrNull(index + 1)?.opCode in setOf(OpCode.JUMP, OpCode.JUMPI) + ) { + Instruction( + OpCode.PUSH32, + Word256.of( + Word256.fromBytes(instruction.immediate).toBigInteger() + + java.math.BigInteger.valueOf(offset.toLong()) + ) + .toBytes(), + ) + } else instruction + } + } + return TwistBytecode.encode(output) + } } data class FunctionIr(val name: String, val selector: Int, val instructions: List) diff --git a/app/src/main/kotlin/rip/crit/twist/compiler/TwistMlCompiler.kt b/app/src/main/kotlin/rip/crit/twist/compiler/TwistMlCompiler.kt index 1c37360..dc9e73a 100644 --- a/app/src/main/kotlin/rip/crit/twist/compiler/TwistMlCompiler.kt +++ b/app/src/main/kotlin/rip/crit/twist/compiler/TwistMlCompiler.kt @@ -3,6 +3,7 @@ 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.jit.LLVMBuilder import rip.crit.twist.tvm.Word256 /** @@ -23,6 +24,108 @@ class TwistMlCompiler { FunctionIr(function.name, index + 1, Lowerer(function.parameters).lower(function.body)) }, ) + + /** Lowers pure arithmetic and explicit storage effects to portable LLVM IR. */ + fun lowerToLlvm(source: String, moduleName: String = "twistml"): LLVMBuilder { + val contract = parse(source) + return LLVMBuilder(moduleName, LlvmLowerer(contract).lower()) + } +} + +private class LlvmLowerer(private val contract: TwistMlContract) { + private var value = 0 + private var label = 0 + private val lines = mutableListOf() + private var needsLoad = false + private var needsStore = false + private var needsCaller = false + private var needsCallValue = false + + fun lower(): String { + contract.functions.forEach { function -> lowerFunction(function) } + return buildString { + append("target triple = \"unknown-unknown-unknown\"\n") + if (needsLoad) append("declare i256 @twist_sload(i256)\n") + if (needsStore) append("declare void @twist_sstore(i256, i256)\n") + if (needsCaller) append("declare i256 @twist_caller()\n") + if (needsCallValue) append("declare i256 @twist_callvalue()\n") + append(lines.joinToString("\n")) + } + } + + private fun lowerFunction(function: TwistMlFunction) { + value = 0 + label = 0 + val arguments = function.parameters.mapIndexed { index, _ -> "i256 %arg$index" } + lines += "define i256 @${function.name}(" + arguments.joinToString(", ") + ") {" + lines += "entry:" + val environment = function.parameters.mapIndexed { index, name -> name to "%arg$index" }.toMap() + val result = emit(function.body, environment) + lines += " ret i256 $result" + lines += "}" + } + + private fun emit(expression: TwistMlExpr, environment: Map): String = when (expression) { + is TwistMlExpr.Number -> expression.value.toString() + is TwistMlExpr.Variable -> environment[expression.name] ?: error("Unknown variable ${expression.name}") + is TwistMlExpr.Let -> emit(expression.body, environment + (expression.name to emit(expression.value, environment))) + TwistMlExpr.Caller -> { + needsCaller = true + call("i256 @twist_caller()") + } + TwistMlExpr.CallValue -> { + needsCallValue = true + call("i256 @twist_callvalue()") + } + is TwistMlExpr.Load -> { + needsLoad = true + call("i256 @twist_sload(${emit(expression.key, environment)})") + } + is TwistMlExpr.Store -> { + needsStore = true + lines += " call void @twist_sstore(i256 ${emit(expression.key, environment)}, i256 ${emit(expression.value, environment)})" + "0" + } + is TwistMlExpr.Binary -> { + val left = emit(expression.left, environment) + val right = emit(expression.right, environment) + when (expression.operator) { + "+" -> instruction("add i256 $left, $right") + "-" -> instruction("sub i256 $left, $right") + "*" -> instruction("mul i256 $left, $right") + "/" -> instruction("udiv i256 $left, $right") + "%" -> instruction("urem i256 $left, $right") + "&&" -> instruction("and i256 $left, $right") + "||" -> instruction("or i256 $left, $right") + "=", "==", "<", ">" -> { + val predicate = mapOf("=" to "eq", "==" to "eq", "<" to "ult", ">" to "ugt").getValue(expression.operator) + val comparison = instruction("icmp $predicate i256 $left, $right") + instruction("zext i1 $comparison to i256") + } + else -> error("Unsupported operator ${expression.operator}") + } + } + is TwistMlExpr.If -> { + val condition = emit(expression.condition, environment) + val test = instruction("icmp ne i256 $condition, 0") + val id = label++ + val thenLabel = "then$id" + val elseLabel = "else$id" + val mergeLabel = "merge$id" + lines += " br i1 $test, label %$thenLabel, label %$elseLabel" + lines += "$thenLabel:" + val thenValue = emit(expression.thenBranch, environment) + lines += " br label %$mergeLabel" + lines += "$elseLabel:" + val elseValue = emit(expression.elseBranch, environment) + lines += " br label %$mergeLabel" + lines += "$mergeLabel:" + instruction("phi i256 [$thenValue, %$thenLabel], [$elseValue, %$elseLabel]") + } + } + + private fun call(body: String): String = instruction("call $body") + private fun instruction(body: String): String = "%v${value++}".also { lines += " $it = $body" } } data class TwistMlContract(val name: String, val functions: List) @@ -43,7 +146,7 @@ sealed interface TwistMlExpr { private class Lowerer(parameters: List) { private val parameterValues = parameters.mapIndexed { index, name -> - name to listOf(Instruction(OpCode.PUSH32, Word256.fromLong((index * 32).toLong()).toBytes()), Instruction(OpCode.CALLDATA_LOAD)) + name to listOf(Instruction(OpCode.PUSH32, Word256.fromLong(((index + 1) * 32).toLong()).toBytes()), Instruction(OpCode.CALLDATA_LOAD)) }.toMap() fun lower(expression: TwistMlExpr): List = expression.lower(parameterValues) + Instruction(OpCode.RETURN) diff --git a/app/src/test/kotlin/rip/crit/twist/AppTest.kt b/app/src/test/kotlin/rip/crit/twist/AppTest.kt index 273e666..390f6c8 100644 --- a/app/src/test/kotlin/rip/crit/twist/AppTest.kt +++ b/app/src/test/kotlin/rip/crit/twist/AppTest.kt @@ -45,6 +45,8 @@ 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.tvm.ExecutionContext +import rip.crit.twist.tvm.StateExecutionHost import rip.crit.twist.bytecode.Instruction import rip.crit.twist.bytecode.OpCode import rip.crit.twist.p2p.PeerNetwork @@ -394,12 +396,36 @@ class AppTest { """.trimIndent() val contract = TwistMlCompiler().compile(source) assertEquals(listOf("max", "stored"), contract.functions.map { it.name }) - val input = Word256.fromLong(3).toBytes() + Word256.fromLong(7).toBytes() - val result = BytecodeVirtualMachine().execute(contract.functions.first().instructions.let(TwistBytecode::encode), input, Address("ml")) + val input = + Word256.fromLong(1).toBytes() + + Word256.fromLong(3).toBytes() + + Word256.fromLong(7).toBytes() + val result = BytecodeVirtualMachine().execute(contract.bytecode(), input, Address("ml")) assertTrue(result.success) assertEquals(BigInteger.valueOf(7), Word256.fromBytes(result.output).toBigInteger()) } + @Test + fun twistoTwistMlContractInitializesAndTransfersOnTheVm() { + val contract = TwistMlCompiler().compile(Files.readString(Path.of("protocols/tokens/twisto.twml"))) + val code = contract.bytecode() + val state = InMemoryWorldState() + val address = Address("twisto") + val owner = Address("owner") + fun call(caller: Address, selector: Long, vararg arguments: Word256) = + BytecodeVirtualMachine().execute( + code, + Word256.fromLong(selector).toBytes() + arguments.fold(byteArrayOf()) { bytes, word -> bytes + word.toBytes() }, + StateExecutionHost(ExecutionContext(address, caller, Amount(BigInteger.ZERO)), state), + ) + assertTrue(call(owner, 1, Word256.fromLong(99), Word256.fromLong(10), Word256.fromLong(10)).success) + val recipient = Word256.fromBytes(Sha256.bytes("recipient".encodeToByteArray())) + assertTrue(call(owner, 6, recipient, Word256.fromLong(4)).success) + val ownerKey = Word256.fromBytes(Sha256.bytes(owner.value.encodeToByteArray())) + assertEquals(BigInteger.valueOf(6), Word256.fromBytes(call(owner, 5, ownerKey).output).toBigInteger()) + assertEquals(BigInteger.valueOf(4), Word256.fromBytes(call(owner, 5, recipient).output).toBigInteger()) + } + @Test fun didMethodsResolveDocuments() { val publicKey = ByteArray(32) { it.toByte() } 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 index bbcca4f..35d3f22 100644 --- 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 @@ -1,31 +1,31 @@ package rip.crit.twist.tool.twisto import java.math.BigInteger +import java.nio.file.Files 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.compiler.TwistMlCompiler import rip.crit.twist.twisto.TwistoMetadata -import rip.crit.twist.twisto.TwistoToken +import rip.crit.twist.tvm.Word256 fun main(args: Array) { require(args.size in 1..3) { - "Usage is twisto [owner] [initial-supply]" + "Usage is twisto [owner-label] [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}") + val owner = args.getOrNull(1) ?: "twisto-owner" + val supply = BigInteger(args.getOrNull(2) ?: "1000000") + val metadata = FileStorageContract(root.resolve("metadata")) + val pointer = metadata.put(TwistoMetadata(description = "Named token on the Twist network").encode()) + val source = checkNotNull(object {}.javaClass.getResource("/twisto.twml")) { "Missing Twisto contract source" }.readText() + val code = TwistMlCompiler().compile(source).bytecode() + Files.createDirectories(root) + Files.write(root.resolve("twisto.tbc"), code) + val initialization = + Word256.fromLong(1).toBytes() + + Word256.fromBytes(pointer.value.chunked(2).map { it.toInt(16).toByte() }.toByteArray()).toBytes() + + Word256.of(supply).toBytes() + + Word256.of(supply).toBytes() + Files.write(root.resolve("initialize.calldata"), initialization) + println("compiled contract=${root.resolve("twisto.tbc")} metadata=${pointer.value} owner-label=$owner") } diff --git a/apps/twisto/src/main/resources/twisto.twml b/apps/twisto/src/main/resources/twisto.twml new file mode 100644 index 0000000..34f512c --- /dev/null +++ b/apps/twisto/src/main/resources/twisto.twml @@ -0,0 +1,10 @@ +contract Twisto { + fun initialize(metadata, supply, cap) = if load(0) == 0 then let ignored = store(0, caller) in let ignored = store(1, metadata) in let ignored = store(2, cap) in let ignored = store(3, supply) in store(caller, supply) else 0; + fun metadata() = load(1); + fun total_supply() = load(3); + fun cap() = load(2); + fun balance_of(account) = load(account); + fun transfer(to, amount) = let from = caller in let balance = load(from) in if amount > balance then 0 else let ignored = store(from, balance - amount) in store(to, load(to) + amount); + fun mint(to, amount) = let owner = load(0) in let supply = load(3) in let ceiling = load(2) in if caller == owner then if ceiling < supply + amount then 0 else let ignored = store(3, supply + amount) in store(to, load(to) + amount) else 0; + fun burn(amount) = let account = caller in let balance = load(account) in let supply = load(3) in if amount > balance then 0 else let ignored = store(account, balance - amount) in store(3, supply - amount); +} diff --git a/protocols/tokens/twisto.twml b/protocols/tokens/twisto.twml new file mode 100644 index 0000000..384b6bc --- /dev/null +++ b/protocols/tokens/twisto.twml @@ -0,0 +1,40 @@ +contract Twisto { + fun initialize(metadata, supply, cap) = + if load(0) == 0 then + let ignored = store(0, caller) in + let ignored = store(1, metadata) in + let ignored = store(2, cap) in + let ignored = store(3, supply) in + store(caller, supply) + else 0; + + fun metadata() = load(1); + fun total_supply() = load(3); + fun cap() = load(2); + fun balance_of(account) = load(account); + + fun transfer(to, amount) = + let from = caller in + let balance = load(from) in + if amount > balance then 0 else + let ignored = store(from, balance - amount) in + store(to, load(to) + amount); + + fun mint(to, amount) = + let owner = load(0) in + let supply = load(3) in + let ceiling = load(2) in + if caller == owner then + if ceiling < supply + amount then 0 else + let ignored = store(3, supply + amount) in + store(to, load(to) + amount) + else 0; + + fun burn(amount) = + let account = caller in + let balance = load(account) in + let supply = load(3) in + if amount > balance then 0 else + let ignored = store(account, balance - amount) in + store(3, supply - amount); +}