Blockchain Chain Adapters

kUML ships two optional runtime modules that connect the behaviour runtime to on-chain smart contracts. Both follow the same adapter contract defined in kuml-runtime-core: they implement ChainAdapter and are discovered at start-up via ServiceLoader.

Overview

Module Artifact Target chain

kuml-runtime-chain-cosmos

dev.kuml:kuml-runtime-chain-cosmos:<version>

Cosmos SDK / CosmWasm (Juno, Osmosis, Neutron, …)

kuml-runtime-chain-wasm

dev.kuml:kuml-runtime-chain-wasm:<version>

Substrate / WASM contracts (Polkadot parachain, ink!, Astar, Moonbeam, …)

Neither module is included in the CLI distribution by default. Add the JAR to the class-path or declare it as a Gradle dependency in a project that embeds the runtime.

CosmWasm adapter (kuml-runtime-chain-cosmos)

Dependency

// build.gradle.kts
dependencies {
    implementation("dev.kuml:kuml-runtime-chain-cosmos:0.30.0")
}

Configuration

Place a kuml-chain-cosmos.properties file on the class-path (or next to the kuml.config.kts file when using the CLI):

# CosmWasm adapter configuration
cosmos.rpc.url       = https://rpc.juno.strange.love:443
cosmos.chain.id      = juno-1
cosmos.gas.price     = 0.025ujuno
cosmos.mnemonic      = <24-word BIP-39 mnemonic>   # or use KUML_COSMOS_MNEMONIC env var
cosmos.contract.addr = juno1…                       # default contract; overridable per transition

All sensitive values may be supplied as environment variables instead of the properties file. The adapter reads KUML_COSMOS_RPC_URL, KUML_COSMOS_CHAIN_ID, KUML_COSMOS_GAS_PRICE, and KUML_COSMOS_MNEMONIC.

Usage in a state-machine script

stateMachineDiagram(name = "Token Vesting") {
    chain("cosmos") {                          // selects the CosmWasm adapter
        contract = "juno1abc…def"              // override per state machine
        gasLimit  = 200_000
    }

    val locked   = state("Locked")
    val unlocked = state("Unlocked")
    val claimed  = state("Claimed")

    initialTransition(target = locked)

    transition(source = locked, target = unlocked) {
        trigger = "VestingPeriodElapsed"
        onChain {
            execute("unlock", mapOf("recipient" to "\${recipient}"))
        }
    }

    transition(source = unlocked, target = claimed) {
        trigger = "Claim"
        onChain {
            execute("claim", mapOf("amount" to "\${amount}"))
        }
    }
}

The onChain { execute(msg, args) } block is compiled to a CosmWasm ExecuteMsg JSON envelope and broadcast via the configured RPC endpoint. execute() is fire-and-forget by default; pass awaitTx = true to block until the transaction is included in a block.

CLI usage

# simulate with on-chain calls enabled
kuml simulate vesting.kuml.kts \
    --adapter cosmos \
    --chain-config kuml-chain-cosmos.properties \
    --events events.json

# dry-run: log the ExecuteMsg JSON without broadcasting
kuml simulate vesting.kuml.kts --adapter cosmos --dry-run

Full example — PdV membership token

stateMachineDiagram(name = "PdV Membership Token") {
    chain("cosmos") { contract = "juno1pdv…" }

    val pending  = state("Pending")
    val active   = state("Active")
    val expired  = state("Expired")
    val revoked  = state("Revoked")

    initialTransition(target = pending)

    transition(source = pending, target = active) {
        trigger = "ApplicationApproved"
        onChain { execute("mint_membership", mapOf("member" to "\${member_address}")) }
    }

    transition(source = active, target = expired) {
        trigger = "AnnualFeeNotPaid"
        onChain { execute("expire_membership", mapOf("member" to "\${member_address}")) }
    }

    transition(source = active, target = revoked) {
        trigger = "MemberExpelled"
        onChain { execute("revoke_membership",
            mapOf("member" to "\${member_address}", "reason" to "\${reason}")) }
    }

    transition(source = expired, target = active) {
        trigger = "FeeSettled"
        onChain { execute("renew_membership", mapOf("member" to "\${member_address}")) }
    }
}

WASM / ink! adapter (kuml-runtime-chain-wasm)

Dependency

// build.gradle.kts
dependencies {
    implementation("dev.kuml:kuml-runtime-chain-wasm:0.30.0")
}

Configuration

# WASM / ink! adapter configuration
wasm.rpc.url          = wss://rpc.astar.network
wasm.chain.id         = 592                        # EVM chain-id (Astar) or SS58 prefix
wasm.contract.address = 5E…                        # ink! contract account (SS58)
wasm.keypair.uri      = //Alice                    # development URI; or use KUML_WASM_SEED_PHRASE
wasm.gas.limit        = 50000000000
wasm.storage.deposit  = 0

Environment variable overrides: KUML_WASM_RPC_URL, KUML_WASM_CONTRACT_ADDRESS, KUML_WASM_SEED_PHRASE.

Usage in a state-machine script

The WASM adapter exposes the same onChain { call(selector, args) } surface as the CosmWasm adapter, but targets ink! message selectors:

stateMachineDiagram(name = "Escrow") {
    chain("wasm") {
        contract    = "5E…"
        gasLimit    = 50_000_000_000L
    }

    val open     = state("Open")
    val disputed = state("Disputed")
    val resolved = state("Resolved")
    val released = state("Released")

    initialTransition(target = open)

    transition(source = open, target = released) {
        trigger = "SellerDelivered"
        guard   = "buyerConfirmed == true"
        onChain { call("release_funds", mapOf("to" to "\${buyer}")) }
    }

    transition(source = open, target = disputed) {
        trigger = "BuyerRaisedDispute"
        onChain { call("open_dispute", mapOf("reason" to "\${reason}")) }
    }

    transition(source = disputed, target = resolved) {
        trigger = "ArbitratorDecided"
        onChain { call("resolve", mapOf("winner" to "\${winner}", "amount" to "\${amount}")) }
    }
}

CLI usage

kuml simulate escrow.kuml.kts \
    --adapter wasm \
    --chain-config kuml-chain-wasm.properties \
    --events escrow-events.json

# dry-run outputs the SCALE-encoded call bytes without submitting
kuml simulate escrow.kuml.kts --adapter wasm --dry-run

EVM chain adapter (kuml-runtime-chain-evm)

The EVM adapter connects the kuml run interactive execution command to any EVM-compatible blockchain (Ethereum, Polygon, Arbitrum, Base, …) that exposes a standard JSON-RPC endpoint.

The EVM adapter is JVM-only (web3j dependency) and is included in the Fat-JAR CLI but not in the Native Image. It is discovered at runtime via ServiceLoader.

Dependency

dependencies {
    implementation("dev.kuml:kuml-runtime-chain-evm:0.30.0")
}

CLI usage

# Replay events from block 18000000 onwards
kuml run order-lifecycle.kuml.kts \
    --adapter chain-evm \
    --rpc https://mainnet.infura.io/v3/<PROJECT_ID> \
    --contract 0xAbCd…1234 \
    --from-block 18000000

# Subscribe to live events (no --from-block)
kuml run order-lifecycle.kuml.kts \
    --adapter chain-evm \
    --rpc https://rpc.ankr.com/eth \
    --contract 0xAbCd…1234 \
    --chain-id 1

Options:

Option Description

--rpc <url>

JSON-RPC endpoint (http:// or https:// only). Private-range IPs rejected (SSRF guard).

--contract <address>

Contract address — 40 hex characters, optional 0x prefix (normalised automatically).

--from-block <n>

Replay events from block n. Without this flag, subscribes to live events.

--chain-id <n>

Optional chain ID for disambiguation (default: auto-detected from the RPC endpoint).

Hash verification

Before streaming events, the adapter reads the on-chain modelHash from the contract and compares it against the SHA-256 hash of the local .kuml.kts script. On mismatch:

Error: on-chain model hash does not match local script (exit code 50 CHAIN_HASH_MISMATCH).

This ensures the running model matches what was deployed to the chain.

Exit codes

Code Name Meaning

50

CHAIN_HASH_MISMATCH

On-chain modelHash differs from the local script hash.

51

CHAIN_CONNECT_ERROR

RPC connection failed, timeout, or a chain reorganisation was detected.

Security

  • EvmUrlValidator enforces http/https only; rejects RFC-1918 subnets (10.x, 172.16–31.x, 192.168.x), loopback (127.x, ::1), and APIPA (169.254.x.x).

  • EvmJsonRpcClient applies per-request timeouts (connect 5 s, read 10 s) and a 4 MB response-size cap to prevent memory exhaustion from malicious RPC nodes.

  • The live-subscribe Flow is bounded by takeWhile { !manager.isTerminated } to prevent goroutine leaks when the session is shut down.

Shared adapter features

Both adapters support:

  • Dry-run mode (--dry-run) — logs the full transaction payload without broadcasting. Useful in CI to verify message structure without spending gas.

  • Retry policy — configurable exponential back-off (chain.retry.maxAttempts, chain.retry.initialDelayMs).

  • Event filteringawaitEvent(name) blocks execution until an on-chain event matching the given name is emitted by the contract.

  • Custom serializers — implement ChainMessageSerializer and register via ServiceLoader to override the default JSON / SCALE encoding.

Security considerations

  • Never commit private keys or seed phrases to source control. Use environment variables or a secret manager.

  • The CosmWasm adapter enforces an HTTPS/WSS-only policy for RPC URLs; plain http:// endpoints are rejected at start-up.

  • Private-range IP addresses (RFC 1918, loopback, APIPA) are blocked in both adapters to prevent SSRF attacks from misconfigured contract addresses.

  • Gas limits are required fields — there is no "unlimited gas" escape hatch.