fix(ci): Use proper label for this instance

This commit is contained in:
Hellings 2026-09-12 08:33:44 -04:00
parent ef72a56501
commit 1ce3286451
7 changed files with 353 additions and 5 deletions

View file

@ -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

View file

@ -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)

View file

@ -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<TwistMlFunction>)
data class TwistMlFunction(val name: String, val parameters: List<String>, 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<String>) {
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<Instruction> = expression.lower(parameterValues) + Instruction(OpCode.RETURN)
private fun TwistMlExpr.lower(environment: Map<String, List<Instruction>>): List<Instruction> = 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<String>()
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++ }
}

View file

@ -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()
}

View file

@ -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 =
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()
}
}

View file

@ -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<IllegalArgumentException> { 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<IllegalStateException> { token.transfer(owner, bob, Amount(BigInteger.ONE)) }
token.unpause(owner)
token.mint(owner, owner, Amount(BigInteger.valueOf(11)))
assertFailsWith<IllegalArgumentException> { 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) }
}

View file

@ -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<String>) {
require(args.size in 2..3) {
"Usage is twistc <source.twistasm|source.twistl> <output.tbc> [principal]"
"Usage is twistc <source.twistasm|source.twistl|source.twml> <output.tbc> [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)