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 |
|---|---|---|
|
|
Cosmos SDK / CosmWasm (Juno, Osmosis, Neutron, …) |
|
|
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}")) }
}
}
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 ( |
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 |
|---|---|
|
JSON-RPC endpoint ( |
|
Contract address — 40 hex characters, optional |
|
Replay events from block |
|
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 |
|---|---|---|
|
|
On-chain |
|
|
RPC connection failed, timeout, or a chain reorganisation was detected. |
Security
-
EvmUrlValidatorenforceshttp/httpsonly; rejects RFC-1918 subnets (10.x, 172.16–31.x, 192.168.x), loopback (127.x, ::1), and APIPA (169.254.x.x). -
EvmJsonRpcClientapplies 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
Flowis bounded bytakeWhile { !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 filtering —
awaitEvent(name)blocks execution until an on-chain event matching the given name is emitted by the contract. -
Custom serializers — implement
ChainMessageSerializerand register viaServiceLoaderto 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.