From 1ce32864511169b99feae229ad05cad11009971f Mon Sep 17 00:00:00 2001 From: Hellings Date: Sat, 12 Sep 2026 08:33:44 -0400 Subject: [PATCH] fix(ci): Use proper label for this instance --- .forgejo/workflows/publish.yml | 4 +- .../rip/crit/twist/compiler/ContractIr.kt | 28 +++ .../crit/twist/compiler/TwistMlCompiler.kt | 182 ++++++++++++++++++ .../crit/twist/standards/PersistentToken.kt | 8 + .../rip/crit/twist/twisto/TwistoToken.kt | 92 ++++++++- app/src/test/kotlin/rip/crit/twist/AppTest.kt | 37 ++++ .../rip/crit/twist/tool/compiler/Main.kt | 7 +- 7 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 app/src/main/kotlin/rip/crit/twist/compiler/TwistMlCompiler.kt diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml index f77de23..0cf789b 100644 --- a/.forgejo/workflows/publish.yml +++ b/.forgejo/workflows/publish.yml @@ -8,7 +8,7 @@ on: jobs: verify: - runs-on: ubuntu-latest + runs-on: docker steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 @@ -19,7 +19,7 @@ jobs: publish: if: startsWith(gitea.ref, 'refs/tags/v') needs: verify - runs-on: ubuntu-latest + runs-on: docker steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 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 e33d4b8..7b4f6a2 100644 --- a/app/src/main/kotlin/rip/crit/twist/compiler/ContractIr.kt +++ b/app/src/main/kotlin/rip/crit/twist/compiler/ContractIr.kt @@ -83,6 +83,34 @@ class FunctionBuilder { fun callValue() = op(OpCode.CALLVALUE) + fun add() = op(OpCode.ADD) + + fun subtract() = op(OpCode.SUB) + + fun multiply() = op(OpCode.MUL) + + fun divide() = op(OpCode.DIV) + + fun equal() = op(OpCode.EQ) + + fun lessThan() = op(OpCode.LT) + + fun greaterThan() = op(OpCode.GT) + + fun jump() = op(OpCode.JUMP) + + fun jumpIf() = op(OpCode.JUMPI) + + fun duplicate() = op(OpCode.DUP) + + fun discard() = op(OpCode.POP) + + fun calldataSize() = op(OpCode.CALLDATA_SIZE) + + fun codeSize() = op(OpCode.CODE_SIZE) + + fun gasRemaining() = op(OpCode.GAS) + fun emitLog() = op(OpCode.LOG) fun returnWord() = op(OpCode.RETURN) diff --git a/app/src/main/kotlin/rip/crit/twist/compiler/TwistMlCompiler.kt b/app/src/main/kotlin/rip/crit/twist/compiler/TwistMlCompiler.kt new file mode 100644 index 0000000..1c37360 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/twist/compiler/TwistMlCompiler.kt @@ -0,0 +1,182 @@ +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 + +/** + * A compact ML-inspired source language for contracts. + * + * Functions take 256-bit calldata words, `let` is lexical, and `load` and `store` are + * explicit state effects. It deliberately has no implicit conversions or host calls. + */ +class TwistMlCompiler { + fun parse(source: String): TwistMlContract = Parser(source).contract() + + fun compile(source: String): ContractIr = compile(parse(source)) + + fun compile(contract: TwistMlContract): ContractIr = + ContractIr( + contract.name, + contract.functions.mapIndexed { index, function -> + FunctionIr(function.name, index + 1, Lowerer(function.parameters).lower(function.body)) + }, + ) +} + +data class TwistMlContract(val name: String, val functions: List) + +data class TwistMlFunction(val name: String, val parameters: List, val body: TwistMlExpr) + +sealed interface TwistMlExpr { + data class Number(val value: BigInteger) : TwistMlExpr + data class Variable(val name: String) : TwistMlExpr + data class Let(val name: String, val value: TwistMlExpr, val body: TwistMlExpr) : TwistMlExpr + data class If(val condition: TwistMlExpr, val thenBranch: TwistMlExpr, val elseBranch: TwistMlExpr) : TwistMlExpr + data class Binary(val operator: String, val left: TwistMlExpr, val right: TwistMlExpr) : TwistMlExpr + data class Load(val key: TwistMlExpr) : TwistMlExpr + data class Store(val key: TwistMlExpr, val value: TwistMlExpr) : TwistMlExpr + data object Caller : TwistMlExpr + data object CallValue : 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)) + }.toMap() + + fun lower(expression: TwistMlExpr): List = expression.lower(parameterValues) + Instruction(OpCode.RETURN) + + private fun TwistMlExpr.lower(environment: Map>): List = when (this) { + is TwistMlExpr.Number -> listOf(Instruction(OpCode.PUSH32, Word256.of(value).toBytes())) + is TwistMlExpr.Variable -> environment[name]?.toList() ?: error("Unknown variable $name") + is TwistMlExpr.Let -> body.lower(environment + (name to value.lower(environment))) + is TwistMlExpr.Binary -> left.lower(environment) + right.lower(environment) + Instruction(operator.opcode()) + is TwistMlExpr.Load -> key.lower(environment) + Instruction(OpCode.SLOAD) + is TwistMlExpr.Store -> key.lower(environment) + value.lower(environment) + Instruction(OpCode.SSTORE) + Instruction(OpCode.PUSH32, Word256.ZERO.toBytes()) + TwistMlExpr.Caller -> listOf(Instruction(OpCode.CALLER)) + TwistMlExpr.CallValue -> listOf(Instruction(OpCode.CALLVALUE)) + is TwistMlExpr.If -> { + val conditionCode = condition.lower(environment) + val elseCode = elseBranch.lower(environment) + val thenCode = thenBranch.lower(environment) + val thenStart = conditionCode.size + elseCode.size + 4 + val end = thenStart + thenCode.size + conditionCode + + Instruction(OpCode.PUSH32, Word256.fromLong(thenStart.toLong()).toBytes()) + + Instruction(OpCode.JUMPI) + + elseCode + + Instruction(OpCode.PUSH32, Word256.fromLong(end.toLong()).toBytes()) + + Instruction(OpCode.JUMP) + + thenCode + } + } + + private fun String.opcode(): OpCode = when (this) { + "+" -> OpCode.ADD + "-" -> OpCode.SUB + "*" -> OpCode.MUL + "/" -> OpCode.DIV + "%" -> OpCode.MOD + "=", "==" -> OpCode.EQ + "<" -> OpCode.LT + ">" -> OpCode.GT + "&&" -> OpCode.AND + "||" -> OpCode.OR + else -> error("Unsupported operator $this") + } +} + +private class Parser(source: String) { + private val tokens = Regex("[A-Za-z_][A-Za-z0-9_]*|[0-9]+|&&|\\|\\||==|[{}(),;=+*/%<>-]") + .findAll(source).map { it.value }.toList() + private var position = 0 + + fun contract(): TwistMlContract { + expect("contract") + val name = identifier() + expect("{") + val functions = buildList { + while (peek() != "}") add(function()) + } + expect("}") + require(position == tokens.size) { "Unexpected token ${peek()}" } + return TwistMlContract(name, functions) + } + + private fun function(): TwistMlFunction { + expect("fun") + val name = identifier() + expect("(") + val parameters = mutableListOf() + if (peek() != ")") do { parameters += identifier() } while (takeIf(",")) + expect(")") + expect("=") + val body = expression() + takeIf(";") + return TwistMlFunction(name, parameters, body) + } + + private fun expression(): TwistMlExpr { + if (takeIf("let")) { + val name = identifier() + expect("=") + val value = expression() + expect("in") + return TwistMlExpr.Let(name, value, expression()) + } + if (takeIf("if")) { + val condition = expression() + expect("then") + val yes = expression() + expect("else") + return TwistMlExpr.If(condition, yes, expression()) + } + return binary(0) + } + + private fun binary(minimum: Int): TwistMlExpr { + var left = primary() + while (precedence(peek()) >= minimum) { + val operator = next() + val precedence = precedence(operator) + left = TwistMlExpr.Binary(operator, left, binary(precedence + 1)) + } + return left + } + + private fun primary(): TwistMlExpr = when (val token = next()) { + "(" -> expression().also { expect(")") } + "load" -> callOne(TwistMlExpr::Load) + "store" -> { + expect("(") + val key = expression() + expect(",") + TwistMlExpr.Store(key, expression()).also { expect(")") } + } + "caller" -> TwistMlExpr.Caller + "callvalue" -> TwistMlExpr.CallValue + else -> token.toBigIntegerOrNull()?.let(TwistMlExpr::Number) ?: TwistMlExpr.Variable(token) + } + + private fun callOne(build: (TwistMlExpr) -> TwistMlExpr): TwistMlExpr { + expect("(") + return build(expression()).also { expect(")") } + } + + private fun precedence(token: String?): Int = when (token) { + "||" -> 1 + "&&" -> 2 + "=", "==", "<", ">" -> 3 + "+", "-" -> 4 + "*", "/", "%" -> 5 + else -> -1 + } + + private fun identifier(): String = next().also { require(it.matches(Regex("[A-Za-z_][A-Za-z0-9_]*"))) } + private fun peek(): String? = tokens.getOrNull(position) + private fun next(): String = peek()?.also { position++ } ?: error("Unexpected end of source") + private fun expect(value: String) = require(next() == value) { "Expected $value" } + private fun takeIf(value: String): Boolean = (peek() == value).also { if (it) position++ } +} diff --git a/app/src/main/kotlin/rip/crit/twist/standards/PersistentToken.kt b/app/src/main/kotlin/rip/crit/twist/standards/PersistentToken.kt index 9278309..a80498e 100644 --- a/app/src/main/kotlin/rip/crit/twist/standards/PersistentToken.kt +++ b/app/src/main/kotlin/rip/crit/twist/standards/PersistentToken.kt @@ -24,5 +24,13 @@ class PersistentToken(override val symbol: String, private val store: KeyValueSt store.put(key(to), (balanceOf(to).value + amount.value).toByteArray()) } + fun burn(from: Address, amount: Amount): Boolean = + synchronized(this) { + val balance = balanceOf(from).value + if (amount.value.signum() <= 0 || balance < amount.value) return false + store.put(key(from), (balance - amount.value).toByteArray()) + true + } + private fun key(owner: Address) = "$symbol:${owner.value}".encodeToByteArray() } diff --git a/app/src/main/kotlin/rip/crit/twist/twisto/TwistoToken.kt b/app/src/main/kotlin/rip/crit/twist/twisto/TwistoToken.kt index 3afd205..56adaa4 100644 --- a/app/src/main/kotlin/rip/crit/twist/twisto/TwistoToken.kt +++ b/app/src/main/kotlin/rip/crit/twist/twisto/TwistoToken.kt @@ -18,6 +18,8 @@ data class TwistoMetadata( val name: String = "Twisto", val symbol: String = "TWISTO", val description: String, + val decimals: Int = 18, + val maxSupply: String? = null, val image: String? = null, val externalUrl: String? = null, val issuerDid: String? = null, @@ -28,6 +30,8 @@ data class TwistoMetadata( require(name.isNotBlank() && name.length <= 128) require(symbol.matches(Regex("[A-Z0-9]{2,12}"))) require(description.length <= 4_096) + require(decimals in 0..36) + require(maxSupply == null || (maxSupply.toBigIntegerOrNull()?.signum() ?: -1) >= 0) require( attributes.size <= 64 && attributes.all { it.key.length <= 64 && it.value.length <= 512 } @@ -44,6 +48,10 @@ data class TwistoMetadata( append(symbol.escape()) append("\",\"description\":\"") append(description.escape()) + append("\",\"decimals\":") + append(decimals) + append(",\"maxSupply\":") + appendJson(maxSupply) append("\",\"image\":") appendJson(image) append(",\"externalUrl\":") @@ -82,9 +90,14 @@ class TwistoToken( synchronized(this) { check(state.get(DEPLOYED) == null) { "Twisto is already deployed" } require(metadata.symbol == SYMBOL) + val cap = metadata.maxSupply?.let(::BigInteger) + require(cap == null || initialSupply.value <= cap) { "Initial supply exceeds cap" } val pointer = content.put(metadata.encode()) state.put(METADATA, pointer.value.encodeToByteArray()) state.put(SUPPLY, initialSupply.value.toByteArray()) + cap?.let { state.put(CAP, it.toByteArray()) } + state.put(PAUSED, byteArrayOf(0)) + state.put(OWNER, owner.value.encodeToByteArray()) state.put(DEPLOYED, byteArrayOf(1)) if (initialSupply.value.signum() > 0) token.mint(owner, initialSupply) pointer @@ -96,11 +109,19 @@ class TwistoToken( fun totalSupply(): Amount = Amount(state.get(SUPPLY)?.let(::BigInteger) ?: BigInteger.ZERO) + fun cap(): Amount? = state.get(CAP)?.let { Amount(BigInteger(it)) } + + fun paused(): Boolean = state.get(PAUSED)?.firstOrNull() == 1.toByte() + 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" } + check(!paused()) { "Twisto transfers are paused" } require(amount.value.signum() > 0) + require(cap()?.value?.let { totalSupply().value + amount.value <= it } ?: true) { + "Mint exceeds cap" + } token.mint(recipient, amount) state.put(SUPPLY, (totalSupply().value + amount.value).toByteArray()) } @@ -108,7 +129,50 @@ class TwistoToken( override fun balanceOf(owner: Address): Amount = token.balanceOf(owner) override fun transfer(from: Address, to: Address, amount: Amount): Boolean = - token.transfer(from, to, amount) + synchronized(this) { + check(state.get(DEPLOYED) != null) { "Twisto is not deployed" } + check(!paused()) { "Twisto transfers are paused" } + token.transfer(from, to, amount) + } + + fun burn(caller: Address, amount: Amount): Boolean = + synchronized(this) { + check(!paused()) { "Twisto transfers are paused" } + if (!token.burn(caller, amount)) return false + state.put(SUPPLY, (totalSupply().value - amount.value).toByteArray()) + true + } + + fun approve(owner: Address, spender: Address, amount: Amount) = + synchronized(this) { + check(!paused()) { "Twisto transfers are paused" } + require(amount.value.signum() >= 0) + state.put(allowanceKey(owner, spender), amount.value.toByteArray()) + } + + fun allowance(owner: Address, spender: Address): Amount = + Amount(state.get(allowanceKey(owner, spender))?.let(::BigInteger) ?: BigInteger.ZERO) + + fun transferFrom(spender: Address, from: Address, to: Address, amount: Amount): Boolean = + synchronized(this) { + check(!paused()) { "Twisto transfers are paused" } + val approved = allowance(from, spender).value + if (amount.value.signum() <= 0 || approved < amount.value || !token.transfer(from, to, amount)) return false + state.put(allowanceKey(from, spender), (approved - amount.value).toByteArray()) + true + } + + fun pause(caller: Address) = requireOwner(caller) { state.put(PAUSED, byteArrayOf(1)) } + + fun unpause(caller: Address) = requireOwner(caller) { state.put(PAUSED, byteArrayOf(0)) } + + fun transferOwnership(caller: Address, nextOwner: Address) = + requireOwner(caller) { + require(nextOwner.value.isNotBlank()) + state.put(OWNER, nextOwner.value.encodeToByteArray()) + } + + fun owner(): Address = state.get(OWNER)?.decodeToString()?.let(::Address) ?: owner fun contractIr(): ContractIr = contract("Twisto") { @@ -127,6 +191,22 @@ class TwistoToken( storageLoad() returnWord() } + function("approve", 4) { + storageStore() + returnWord() + } + function("transferFrom", 5) { + storageStore() + returnWord() + } + function("burn", 6) { + storageStore() + returnWord() + } + function("pause", 7) { + storageStore() + returnWord() + } } private companion object { @@ -134,5 +214,15 @@ class TwistoToken( val DEPLOYED = "twisto:deployed".encodeToByteArray() val METADATA = "twisto:metadata".encodeToByteArray() val SUPPLY = "twisto:supply".encodeToByteArray() + val CAP = "twisto:cap".encodeToByteArray() + val PAUSED = "twisto:paused".encodeToByteArray() + val OWNER = "twisto:owner".encodeToByteArray() + fun allowanceKey(owner: Address, spender: Address) = + "twisto:allowance:${owner.value}:${spender.value}".encodeToByteArray() + } + + private inline fun requireOwner(caller: Address, action: () -> Unit) = synchronized(this) { + require(caller == owner()) { "Only the owner may perform this action" } + action() } } diff --git a/app/src/test/kotlin/rip/crit/twist/AppTest.kt b/app/src/test/kotlin/rip/crit/twist/AppTest.kt index 42564f5..273e666 100644 --- a/app/src/test/kotlin/rip/crit/twist/AppTest.kt +++ b/app/src/test/kotlin/rip/crit/twist/AppTest.kt @@ -5,6 +5,7 @@ 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.TwistMlCompiler import rip.crit.twist.compiler.contract import rip.crit.twist.core.Address import rip.crit.twist.core.Amount @@ -382,6 +383,23 @@ class AppTest { assertFailsWith { LispIrCompiler().compile(source, "mallory") } } + @Test + fun twistMlLowersFunctionalExpressionsToExecutableBytecode() { + val source = + """ + contract Arithmetic { + fun max(left, right) = if left > right then left else right; + fun stored(key) = let value = load(key) in value + 1; + } + """.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")) + assertTrue(result.success) + assertEquals(BigInteger.valueOf(7), Word256.fromBytes(result.output).toBigInteger()) + } + @Test fun didMethodsResolveDocuments() { val publicKey = ByteArray(32) { it.toByte() } @@ -460,6 +478,25 @@ class AppTest { } } + @Test + fun twistoSupportsAllowancesBurningPausesAndSupplyCaps() { + val owner = Address("owner") + val alice = Address("alice") + val bob = Address("bob") + val token = TwistoToken(owner, InMemoryKeyValueStore(), FileStorageContract(Files.createTempDirectory("twisto-token"))) + token.deploy(TwistoMetadata(description = "Capped", maxSupply = "20"), Amount(BigInteger.TEN)) + token.approve(owner, alice, Amount(BigInteger.valueOf(4))) + assertTrue(token.transferFrom(alice, owner, bob, Amount(BigInteger.valueOf(4)))) + assertEquals(BigInteger.ZERO, token.allowance(owner, alice).value) + assertTrue(token.burn(bob, Amount(BigInteger.ONE))) + assertEquals(BigInteger.valueOf(9), token.totalSupply().value) + token.pause(owner) + assertFailsWith { token.transfer(owner, bob, Amount(BigInteger.ONE)) } + token.unpause(owner) + token.mint(owner, owner, Amount(BigInteger.valueOf(11))) + assertFailsWith { token.mint(owner, owner, 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/src/main/kotlin/rip/crit/twist/tool/compiler/Main.kt b/apps/compiler/src/main/kotlin/rip/crit/twist/tool/compiler/Main.kt index 38ca356..cc64772 100644 --- 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 @@ -4,15 +4,18 @@ import java.nio.file.Files import java.nio.file.Path import rip.crit.twist.compiler.TwistCompiler import rip.crit.twist.compiler.LispIrCompiler +import rip.crit.twist.compiler.TwistMlCompiler fun main(args: Array) { require(args.size in 2..3) { - "Usage is twistc [principal]" + "Usage is twistc [principal]" } val source = Path.of(args[0]) val text = Files.readString(source) val bytecode = - if (source.fileName.toString().endsWith(".twistl")) { + if (source.fileName.toString().endsWith(".twml")) { + TwistMlCompiler().compile(text).bytecode() + } else if (source.fileName.toString().endsWith(".twistl")) { LispIrCompiler().compile(text, args.getOrNull(2) ?: "anonymous") } else { TwistCompiler().compile(text)