Loading...
Loading...
Build a voting/poll dApp on Midnight Network using the Kuira Android SDK — Compact smart contract with create/cast/close circuits, passkey-derived identity, embedded wallet, Compose UI, reactive ledger reads via observeLedger(), and on-device ZK proving. Use when building a poll or voting dApp targeting Android.
npx skill4agent add kali-decoder/midnight-skills android-example-votinghttps://kuiralabs.github.io/kuira-sdk-android/https://kuiralabs.github.io/kuira-sdk-android/api/https://github.com/kuiralabs/kuira-starter-androidhttps://central.sonatype.com/namespace/io.github.kuiralabs| Capability | How |
|---|---|
| Forge sigil | |
| Deploy contract | Deploy the Compact contract to Midnight |
| Create poll | Organizer-only via SHA-256 admin key match |
| Cast vote | Select one of 4 options, ZK-proved on-device |
| Close poll | Only the admin key holder can close |
| Live results | |
| Layer | Version |
|---|---|
Kuira SDK ( | |
| Kuira Contract plugin | |
| Kuira Localnet plugin | |
| AGP | |
| Kotlin | |
| KSP | |
| Hilt | |
| Compose BOM | |
| Gradle | |
| JDK | |
| |
| |
| |
| |
| |
| |
| |
| |
| Compact compiler | |
| Compact language pragma | |
kuira-starter-android/
├── settings.gradle.kts
├── build.gradle.kts # Root (plugin declarations only)
├── gradle.properties # MUST exist — jvmargs, caching
├── gradle/
│ ├── libs.versions.toml
│ └── wrapper/
│ └── gradle-wrapper.properties # Gradle 8.13
├── local.properties # sdk.dir — NOT committed
├── contract/
│ ├── package.json
│ ├── scripts/
│ │ └── patch-voting.js
│ └── src/
│ ├── voting.compact
│ └── managed/voting/ # Compiled artifacts (gitignored)
├── app/
│ ├── build.gradle.kts
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── res/
│ │ │ ├── values/
│ │ │ │ ├── strings.xml
│ │ │ │ └── themes.xml
│ │ │ ├── xml/
│ │ │ │ └── network_security_config.xml
│ │ │ ├── drawable/
│ │ │ │ ├── ic_launcher_foreground.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ │ └── mipmap-anydpi-v26/
│ │ │ └── ic_launcher.xml
│ │ └── java/com/kuiralabs/starter/counter/
│ │ ├── KuiraStarterApp.kt
│ │ ├── MainActivity.kt
│ │ ├── di/PasskeyConfigModule.kt
│ │ ├── data/
│ │ │ ├── VotingContract.kt
│ │ │ └── ContractAddressStore.kt
│ │ └── ui/
│ │ ├── theme/{Color,Type,Theme}.kt
│ │ ├── VotingScreen.kt
│ │ ├── VotingCard.kt
│ │ ├── VotingViewModel.kt
│ │ └── VotingUiState.kt
│ └── debug/AndroidManifest.xml # Cleartext for localnetlocal.propertiessdk.dir=...androidcompileSdkjava --version # JDK 17+
node --version # Node.js 18+
compact update 0.31.0 # Install Compact compiler
compact list # Verify 0.31.0 is the defaultrpIdgradle/wrapper/gradle-wrapper.propertiesdistributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/distsgradle.propertiesandroid.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.configuration-cache=trueandroid.useAndroidX=trueAppCompatActivityClassNotFoundExceptionorg.gradle.jvmargs=-Xmx2048m-Xmx4096mOutOfMemoryError: Metaspaceorg.gradle.configuration-cache=trueapp/build.gradle.ktsfile("../contract")rootProject.file()doFirstsettings.gradle.ktspluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "kuira-starter-android"
include(":app")FAIL_ON_PROJECT_REPOSrepositories { ... }app/build.gradle.ktsdependencyResolutionManagementbuild.gradle.ktsplugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
}io.github.kuiralabs.contractio.github.kuiralabs.localnetapp/build.gradle.ktsgradle/libs.versions.toml[versions]
agp = "8.13.2"
kotlin = "2.3.20"
ksp = "2.3.6"
hilt = "2.58"
composeBom = "2026.03.01"
androidxAppcompat = "1.7.0"
androidxActivity = "1.10.1"
androidxFragment = "1.8.5"
androidxLifecycle = "2.8.7"
androidxHiltNavCompose = "1.1.0"
androidxSecurityCrypto = "1.1.0-alpha07"
kuira = "0.1.0-alpha04"
[libraries]
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" }
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidxFragment" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavCompose" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "androidxSecurityCrypto" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
kuira-dapp-ui = { group = "io.github.kuiralabs", name = "dapp-ui", version.ref = "kuira" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }kotlin-composeapp/build.gradle.ktsimport org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
id("io.github.kuiralabs.contract") version "0.1.0-alpha04"
id("io.github.kuiralabs.localnet") version "0.1.0-alpha04"
}
android {
namespace = "com.kuiralabs.starter.counter"
compileSdk = 36
defaultConfig {
applicationId = "com.kuiralabs.starter.counter"
minSdk = 30
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
debug { isMinifyEnabled = false }
release { isMinifyEnabled = false }
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures { compose = true }
}
kotlin {
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
kuiraContract {
source.set("../contract/src/managed/voting")
bundleWalletKeys.set(true)
}
// Hand-rolled Copy to sync voting contract assets (the plugin handles
// the primary source; this is explicit for clarity and matches the
// starter codebase pattern):
tasks.register<Copy>("syncVotingAssets") {
description = "Sync compiled Voting compact contract artifacts into app assets."
group = "build"
from("../contract/src/managed/voting/contract") {
include("index.js")
rename { "voting-contract.js" }
into("runtime")
}
from("../contract/src/managed/voting/keys") {
include("*.prover", "*.verifier")
into("keys")
}
from("../contract/src/managed/voting/zkir") {
include("*.bzkir")
into("keys")
}
into("src/main/assets")
doFirst {
if (!file("../contract/src/managed/voting").exists()) {
throw GradleException("Contract not compiled — run `npm run compile:voting` in contract/ first.")
}
}
}
tasks.named("preBuild") { dependsOn("syncVotingAssets") }
dependencies {
implementation(libs.kuira.dapp.ui)
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.security.crypto)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
}io.github.kuiralabs.contractkuiraContract { ... }validateKuiraContractSource../contract/src/managed/votingsyncContractAssetssrc/main/assets/{runtime,keys}/syncContractAssetspreBuildbundleWalletKeys.set(true).bzkirio.github.kuiralabs.localnetadb reverseinstallDebug10.0.2.2kuiraContractCopyminSdk < 30android.security.identity.IdentityCredentialCredentialManagercompileSdk != 36targetSdk != 36WalletNotifications.PERMISSIONPOST_NOTIFICATIONSapp/src/main/res/values/strings.xml<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Kuira Starter</string>
</resources>app/src/main/res/values/themes.xml<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KuiraStarter" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>MainActivityAppCompatActivityComponentActivitySigilStatusPanelFragmentActivityAppCompatActivityComponentActivityapp/src/main/res/xml/network_security_config.xml<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
</domain-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>android:usesCleartextTraffic="true"debugAndroidManifest.xml<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".KuiraStarterApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.KuiraStarter">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.KuiraStarter">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>android:networkSecurityConfig@xml/network_security_configapp/src/debug/AndroidManifest.xml<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:usesCleartextTraffic="true" />
</manifest>networkSecurityConfigusesCleartextTraffic="true"contract/package.json{
"name": "@yourapp/voting-contract",
"version": "0.1.0",
"private": true,
"description": "Voting contract for the Kuira Voting dApp. Compiled artifacts under src/managed/voting/ are consumed by the Android app via the kuiraContract Gradle plugin.",
"scripts": {
"compile:voting": "compact compile +0.31.0 src/voting.compact src/managed/voting && node scripts/patch-voting.js",
"inspect:voting": "mn contract inspect --managed src/managed/voting"
},
"dependencies": {
"@midnight-ntwrk/compact-runtime": "0.16.0"
},
"engines": {
"compactc": "0.31.0"
}
}src/managed/voting/contract/index.js@midnight-ntwrk/compact-runtimepackage.json"Could not find module '@midnight-ntwrk/compact-runtime'"TypeErrorcontract-info.jsoncat src/managed/voting/compiler/contract-info.json
# Look for "runtime-version" — must match package.json's dep@midnight-ntwrk/compact-runtimepackage.json+0.31.0compact compilecompactcontract/scripts/patch-voting.jsconst fs = require('fs');
const path = require('path');
const file = path.join(__dirname, '..', 'src', 'managed', 'voting', 'contract', 'index.js');
let src = fs.readFileSync(file, 'utf8');
src = src.replace(
"const optionIdx_0 = args_1[1]",
"let optionIdx_0 = args_1[1];\n if (typeof optionIdx_0 !== 'bigint') optionIdx_0 = BigInt(optionIdx_0)"
);
fs.writeFileSync(file, src, 'utf8');
console.log('Patched BigInt shim into voting contract JS');LongnumberbigintUint<64>bigintcastVote(0)0typeofcompact compilecontract/index.jsoptionIdx_0 = args_1[1]contract/src/voting.compactpragma language_version >= 0.22;
import CompactStandardLibrary;
export ledger pollQuestion: Opaque<"string">;
export ledger pollOptions: Opaque<"string">;
export ledger adminKeyHash: Bytes<32>;
export ledger voteCount: Uint<64>;
export ledger tally0: Uint<64>;
export ledger tally1: Uint<64>;
export ledger tally2: Uint<64>;
export ledger tally3: Uint<64>;
export ledger pollClosed: Boolean;
witness adminSecret(): Bytes<32>;
pure circuit deriveAdminKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "poll:admin:v1"), sk]);
}
constructor() {
pollClosed = false;
tally0 = 0;
tally1 = 0;
tally2 = 0;
tally3 = 0;
voteCount = 0;
}
export circuit createPoll(question: Opaque<"string">, options: Opaque<"string">): [] {
pollQuestion = disclose(question);
pollOptions = disclose(options);
adminKeyHash = disclose(deriveAdminKey(adminSecret()));
}
export circuit castVote(optionIdx: Uint<64>): [] {
assert(!pollClosed, "Poll is closed");
assert(optionIdx < 4, "Invalid option index (0-3)");
const idx = disclose(optionIdx);
const count = voteCount;
voteCount = disclose((count + 1) as Uint<64>);
if (idx == 0) {
tally0 = (tally0 + 1) as Uint<64>;
} else if (idx == 1) {
tally1 = (tally1 + 1) as Uint<64>;
} else if (idx == 2) {
tally2 = (tally2 + 1) as Uint<64>;
} else {
tally3 = (tally3 + 1) as Uint<64>;
}
}
export circuit closePoll(): [] {
assert(adminKeyHash == deriveAdminKey(adminSecret()), "Only admin can close poll");
pollClosed = disclose(true);
}createdpollQuestion != ""createPollassert(!created)deriveAdminKeypure circuitpure>= 0.22compact compile +0.31.0pollClosedtruedisclose()pollClosedfalseSet<Bytes<32>>hasVotedobserveLedger()adminKeyHashcreatePollcreatePollclosePolladminKeyHashadminSecret()cd contract
npm install
npm run compile:votingCompiling 3 circuits:
circuit "createPoll" (k=...)
circuit "castVote" (k=...)
circuit "closePoll" (k=...)
Patched BigInt shim into voting contract JScontract/index.jscat src/managed/voting/compiler/contract-info.json
# Confirm runtime-version matches @midnight-ntwrk/compact-runtime in package.jsonKuiraStarterApp.ktpackage com.kuiralabs.starter.counter
import android.app.Application
import com.midnight.kuira.sdk.walletruntime.SessionLock
import com.midnight.kuira.sdk.walletruntime.WalletForegroundService
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class KuiraStarterApp : Application() {
override fun onCreate() {
super.onCreate()
SessionLock.attach(this)
WalletForegroundService.attach(this)
}
}SessionLock.attach(this)onUserInteractionWalletForegroundService.attach(this)@HiltAndroidAppdagger.hilt.internal.NoopComponentManagerdi/PasskeyConfigModule.ktpackage com.kuiralabs.starter.counter.di
import com.midnight.kuira.core.identity.passkey.PasskeyConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object PasskeyConfigModule {
private const val PASSKEY_RP_ID = "YOUR_DOMAIN.example.com"
@Provides
@Singleton
fun providePasskeyConfig(): PasskeyConfig =
PasskeyConfig(rpId = PASSKEY_RP_ID, rpName = "Kuira Sigil")
}PasskeyConfig[Dagger/MissingBinding] PasskeyConfig is injected at ...PASSKEY_RP_IDassetlinks.jsonhttps://<rpId>/.well-known/assetlinks.jsonapplicationId./gradlew signingReport | grep SHA-256[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.kuiralabs.starter.counter",
"sha256_cert_fingerprints": ["SHA-256:..."]
}
}]RP_ID_MISMATCHrpIddata/VotingContract.ktpackage com.kuiralabs.starter.counter.data
import android.content.Context
import com.midnight.kuira.core.compact.ContractCallStage
import com.midnight.kuira.core.compact.MidnightContract
import com.midnight.kuira.core.compact.WitnessResult
import com.midnight.kuira.core.compact.proving.ProvingKeyManager
import com.midnight.kuira.sdk.MidnightSdk
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.security.MessageDigest
internal object VotingContract {
private const val NAME = "voting"
private const val CIRCUIT_CREATE_POLL = "createPoll"
private const val CIRCUIT_CAST_VOTE = "castVote"
private const val CIRCUIT_CLOSE_POLL = "closePoll"
private const val WITNESS_ADMIN_SECRET = "adminSecret"
private const val LEDGER_FIELD_POLL_QUESTION = "pollQuestion"
private const val LEDGER_FIELD_POLL_OPTIONS = "pollOptions"
private const val LEDGER_FIELD_ADMIN_KEY_HASH = "adminKeyHash"
private const val LEDGER_FIELD_VOTE_COUNT = "voteCount"
private const val LEDGER_FIELD_TALLY0 = "tally0"
private const val LEDGER_FIELD_TALLY1 = "tally1"
private const val LEDGER_FIELD_TALLY2 = "tally2"
private const val LEDGER_FIELD_TALLY3 = "tally3"
private const val LEDGER_FIELD_POLL_CLOSED = "pollClosed"
private const val CONTRACT_JS_ASSET = "runtime/$NAME-contract.js"
private const val VERIFIER_ASSET_CREATE = "keys/$CIRCUIT_CREATE_POLL.verifier"
private const val VERIFIER_ASSET_CAST = "keys/$CIRCUIT_CAST_VOTE.verifier"
private const val VERIFIER_ASSET_CLOSE = "keys/$CIRCUIT_CLOSE_POLL.verifier"
private val ADMIN_SECRET = ByteArray(32) { 0x42 }
private fun loadVerifierKeys(context: Context): Map<String, ByteArray> {
val createVerifier = context.assets.open(VERIFIER_ASSET_CREATE).use { it.readBytes() }
val castVerifier = context.assets.open(VERIFIER_ASSET_CAST).use { it.readBytes() }
val closeVerifier = context.assets.open(VERIFIER_ASSET_CLOSE).use { it.readBytes() }
return mapOf(
CIRCUIT_CREATE_POLL to createVerifier,
CIRCUIT_CAST_VOTE to castVerifier,
CIRCUIT_CLOSE_POLL to closeVerifier,
)
}
private fun installProvingKeys(context: Context) {
ProvingKeyManager(context).installCircuitKeysFromAssets()
}
private fun buildHandle(
context: Context,
sdk: MidnightSdk,
address: String?,
forWrite: Boolean,
): MidnightContract = MidnightContract.create(sdk.config) {
name = NAME
contractJs = context.assets.open(CONTRACT_JS_ASSET)
if (address != null) this.address = address
if (forWrite) {
coinPublicKey = sdk.coinPublicKey
circuitVerifierKeys = loadVerifierKeys(context)
witness(WITNESS_ADMIN_SECRET) { adminSecretWitness() }
}
}
private fun adminSecretWitness(): WitnessResult =
WitnessResult(null, ADMIN_SECRET)
private suspend fun stageKeysAndHandle(
context: Context,
sdk: MidnightSdk,
address: String?,
): MidnightContract {
installProvingKeys(context)
return buildHandle(context, sdk, address = address, forWrite = true)
}
suspend fun deploy(
context: Context,
sdk: MidnightSdk,
onProgress: (suspend (ContractCallStage) -> Unit)? = null,
): String {
val handle = stageKeysAndHandle(context, sdk, address = null)
return handle.deploy(onProgress = onProgress).contractAddress
}
suspend fun createPoll(
context: Context,
sdk: MidnightSdk,
address: String,
question: String,
options: String,
onProgress: (suspend (ContractCallStage) -> Unit)? = null,
) {
val handle = stageKeysAndHandle(context, sdk, address = address)
handle.call(CIRCUIT_CREATE_POLL, question, options, onProgress = onProgress)
}
suspend fun castVote(
context: Context,
sdk: MidnightSdk,
address: String,
optionIdx: Long,
onProgress: (suspend (ContractCallStage) -> Unit)? = null,
) {
val handle = stageKeysAndHandle(context, sdk, address = address)
handle.call(CIRCUIT_CAST_VOTE, optionIdx, onProgress = onProgress)
}
suspend fun closePoll(
context: Context,
sdk: MidnightSdk,
address: String,
onProgress: (suspend (ContractCallStage) -> Unit)? = null,
) {
val handle = stageKeysAndHandle(context, sdk, address = address)
handle.call(CIRCUIT_CLOSE_POLL, onProgress = onProgress)
}
fun buildReadHandle(context: Context, sdk: MidnightSdk, address: String): MidnightContract =
buildHandle(context, sdk, address = address, forWrite = false)
data class VotingLedger(
val pollQuestion: String,
val pollOptions: String,
val adminKeyHash: ByteArray,
val voteCount: Long,
val tally0: Long,
val tally1: Long,
val tally2: Long,
val tally3: Long,
val pollClosed: Boolean,
)
suspend fun readLedger(handle: MidnightContract): VotingLedger {
val ledger = handle.ledger()
return VotingLedger(
pollQuestion = ledger.getStringOrNull(LEDGER_FIELD_POLL_QUESTION) ?: "",
pollOptions = ledger.getStringOrNull(LEDGER_FIELD_POLL_OPTIONS) ?: "",
adminKeyHash = ledger.getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32),
voteCount = ledger.getUint64(LEDGER_FIELD_VOTE_COUNT),
tally0 = ledger.getUint64(LEDGER_FIELD_TALLY0),
tally1 = ledger.getUint64(LEDGER_FIELD_TALLY1),
tally2 = ledger.getUint64(LEDGER_FIELD_TALLY2),
tally3 = ledger.getUint64(LEDGER_FIELD_TALLY3),
pollClosed = ledger.getBoolean(LEDGER_FIELD_POLL_CLOSED),
)
}
fun observeLedger(handle: MidnightContract): Flow<VotingLedger> =
handle.observeLedger().map { ledger ->
VotingLedger(
pollQuestion = ledger.getStringOrNull(LEDGER_FIELD_POLL_QUESTION) ?: "",
pollOptions = ledger.getStringOrNull(LEDGER_FIELD_POLL_OPTIONS) ?: "",
adminKeyHash = ledger.getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32),
voteCount = ledger.getUint64(LEDGER_FIELD_VOTE_COUNT),
tally0 = ledger.getUint64(LEDGER_FIELD_TALLY0),
tally1 = ledger.getUint64(LEDGER_FIELD_TALLY1),
tally2 = ledger.getUint64(LEDGER_FIELD_TALLY2),
tally3 = ledger.getUint64(LEDGER_FIELD_TALLY3),
pollClosed = ledger.getBoolean(LEDGER_FIELD_POLL_CLOSED),
)
}
}objectclassADMIN_SECRETByteArray(32) { 0x42 }WitnessResult(null, ADMIN_SECRET)privateState: Any?nullWitnessResultMidnightContract.create(sdk.config) { ... }namecontractJsInputStreamaddresscoinPublicKeycircuitVerifierKeyswitness(name) { ... }assets/runtime/assets/keys/kuiraContractIllegalStateException: Contract JS not found at assets/runtime/voting-contract.js./gradlew :app:assembleDebugapp/src/main/assets/getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32)adminKeyHashcreatePolladminKeyHashcreatePolladminKeyHashdata/ContractAddressStore.ktpackage com.kuiralabs.starter.counter.data
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.midnight.kuira.core.network.MidnightNetwork
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ContractAddressStore @Inject constructor(
@ApplicationContext context: Context,
) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"kuira-starter-contract-prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
fun get(network: MidnightNetwork, prefix: String = "voting"): String? =
prefs.getString("$prefix.${network.name}", null)
fun put(network: MidnightNetwork, prefix: String = "voting", address: String) {
prefs.edit().putString("$prefix.${network.name}", address).apply()
}
fun clear(network: MidnightNetwork, prefix: String = "voting") {
prefs.edit().remove("$prefix.${network.name}").apply()
}
}"voting"voting.UNDEPLOYEDMasterKey.BuilderMasterKey.Builder.setKeySchemeMasterKey.DEFAULT_MASTER_KEY_ALIASui/VotingUiState.ktpackage com.kuiralabs.starter.counter.ui
sealed interface VotingUiState {
data object NotReady : VotingUiState
data object ReadyToDeploy : VotingUiState
data class Deployed(
val address: String,
val pollQuestion: String?,
val pollOptions: List<String>?,
val adminKeyHash: ByteArray?,
val voteCount: Long?,
val tallies: List<Long>?,
val pollClosed: Boolean?,
val hasVoted: Boolean,
) : VotingUiState
}adminKeyHash: ByteArray?createPollhasVotedtruecastVote()falseui/VotingViewModel.ktpackage com.kuiralabs.starter.counter.ui
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.kuiralabs.starter.counter.data.ContractAddressStore
import com.kuiralabs.starter.counter.data.VotingContract
import com.kuiralabs.starter.counter.data.VotingContract.VotingLedger
import com.midnight.kuira.core.compact.ContractCallStage
import com.midnight.kuira.core.compact.MidnightContract
import com.midnight.kuira.core.network.MidnightNetwork
import com.midnight.kuira.sdk.MidnightSdk
import com.midnight.kuira.sdk.walletruntime.MidnightSdkProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class VotingViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val sdkProvider: MidnightSdkProvider,
private val addressStore: ContractAddressStore,
) : ViewModel() {
private val _state = MutableStateFlow<VotingUiState>(VotingUiState.NotReady)
val state: StateFlow<VotingUiState> = _state.asStateFlow()
private val _busy = MutableStateFlow(false)
val busy: StateFlow<Boolean> = _busy.asStateFlow()
private val _callStage = MutableStateFlow<ContractCallStage?>(null)
val callStage: StateFlow<ContractCallStage?> = _callStage.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val _question = MutableStateFlow("")
val question: StateFlow<String> = _question.asStateFlow()
private val _options = MutableStateFlow("")
val options: StateFlow<String> = _options.asStateFlow()
val selectedNetwork: StateFlow<MidnightNetwork> get() = sdkProvider.selectedNetwork
fun selectNetwork(network: MidnightNetwork) = sdkProvider.selectNetwork(network)
private var ledgerJob: Job? = null
init {
viewModelScope.launch {
sdkProvider.sdk.combine(sdkProvider.selectedNetwork) { sdk, net -> sdk to net }
.collect { (sdk, network) -> recomputeState(sdk, network) }
}
}
private fun recomputeState(sdk: MidnightSdk?, network: MidnightNetwork) {
val persisted = addressStore.get(network, "voting")
val next = when {
sdk == null -> VotingUiState.NotReady
persisted == null -> VotingUiState.ReadyToDeploy
else -> VotingUiState.Deployed(
address = persisted,
pollQuestion = null,
pollOptions = null,
adminKeyHash = null,
voteCount = null,
tallies = null,
pollClosed = null,
hasVoted = false,
)
}
_state.value = next
if (sdk != null && persisted != null) startObserving(sdk, persisted) else stopObserving()
}
fun deploy() {
val sdk = sdkProvider.sdk.value ?: return
val network = sdkProvider.selectedNetwork.value
runAction {
val address = VotingContract.deploy(context, sdk) { _callStage.value = it }
addressStore.put(network, "voting", address)
recomputeState(sdk, network)
}
}
fun disconnect() {
val network = sdkProvider.selectedNetwork.value
addressStore.clear(network, "voting")
recomputeState(sdkProvider.sdk.value, network)
}
fun updateQuestion(value: String) { _question.value = value }
fun updateOptions(value: String) { _options.value = value }
fun createPoll() {
val sdk = sdkProvider.sdk.value ?: return
val address = (state.value as? VotingUiState.Deployed)?.address ?: return
val q = _question.value.trim()
val o = _options.value.trim()
if (q.isBlank() || o.isBlank()) return
runAction {
VotingContract.createPoll(context, sdk, address, q, o) { _callStage.value = it }
readAndUpdateLedger(sdk, address)
}
}
fun castVote(optionIdx: Int) {
val sdk = sdkProvider.sdk.value ?: return
val address = (state.value as? VotingUiState.Deployed)?.address ?: return
runAction {
VotingContract.castVote(context, sdk, address, optionIdx.toLong()) { _callStage.value = it }
readAndUpdateLedger(sdk, address)
_state.update { current ->
if (current is VotingUiState.Deployed && current.address == address) {
current.copy(hasVoted = true)
} else current
}
}
}
fun closePoll() {
val sdk = sdkProvider.sdk.value ?: return
val address = (state.value as? VotingUiState.Deployed)?.address ?: return
runAction {
VotingContract.closePoll(context, sdk, address) { _callStage.value = it }
readAndUpdateLedger(sdk, address)
}
}
private suspend fun readAndUpdateLedger(sdk: MidnightSdk, address: String) {
val fresh = VotingContract.readLedger(readHandleFor(sdk, address))
applyLedger(fresh, address)
}
private fun runAction(block: suspend () -> Unit) {
viewModelScope.launch {
_busy.value = true
_error.value = null
try {
block()
} catch (t: Throwable) {
_error.value = t.message ?: t::class.simpleName ?: "Unknown error"
} finally {
_busy.value = false
_callStage.value = null
}
}
}
private var readHandle: MidnightContract? = null
private var readHandleAddress: String? = null
private fun readHandleFor(sdk: MidnightSdk, address: String): MidnightContract {
if (readHandle == null || readHandleAddress != address) {
readHandle = VotingContract.buildReadHandle(context, sdk, address)
readHandleAddress = address
}
return readHandle!!
}
private fun startObserving(sdk: MidnightSdk, address: String) {
ledgerJob?.cancel()
ledgerJob = viewModelScope.launch {
val handle = readHandleFor(sdk, address)
VotingContract.observeLedger(handle)
.catch { }
.collect { fresh -> applyLedger(fresh, address) }
}
}
private fun applyLedger(fresh: VotingLedger, address: String) {
val optList = fresh.pollOptions.split(",").map { it.trim() }.filter { it.isNotBlank() }
_state.update { current ->
if (current is VotingUiState.Deployed && current.address == address) {
current.copy(
pollQuestion = fresh.pollQuestion,
pollOptions = optList,
adminKeyHash = fresh.adminKeyHash,
voteCount = fresh.voteCount,
tallies = listOf(fresh.tally0, fresh.tally1, fresh.tally2, fresh.tally3),
pollClosed = fresh.pollClosed,
)
} else current
}
}
private fun stopObserving() {
ledgerJob?.cancel()
ledgerJob = null
readHandle = null
readHandleAddress = null
}
}sdkProvider.selectedNetworkStateFlow<MidnightNetwork>PanelBarContractAddressStorerecomputeStateapplyLedgerrecomputeStateapplyLedgerobserveLedger()readLedger()readHandleMidnightContractrunActionclosePollCircuitExecutionException("Only admin can close poll")_callStagefinallyContractCallStageContractCallProgressBarcallStage: StateFlowobserveLedger().catch { }readAndUpdateLedgerui/VotingScreen.ktpackage com.kuiralabs.starter.counter.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.midnight.kuira.dapp.PanelBar
@Composable
fun VotingScreen(
modifier: Modifier = Modifier,
viewModel: VotingViewModel = hiltViewModel(),
) {
val selectedNetwork by viewModel.selectedNetwork.collectAsState()
Box(modifier = modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(rememberScrollState())
.padding(16.dp)
.padding(top = 56.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Voting",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
)
VotingCard()
}
PanelBar(
floating = true,
network = selectedNetwork,
onNetworkChange = viewModel::selectNetwork,
)
}
}PanelBar(floating=true)WindowInsets.safeDrawingui/VotingCard.ktpackage com.kuiralabs.starter.counter.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.midnight.kuira.dapp.ContractCallProgressBar
@Composable
fun VotingCard(
modifier: Modifier = Modifier,
viewModel: VotingViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val busy by viewModel.busy.collectAsState()
val callStage by viewModel.callStage.collectAsState()
val error by viewModel.error.collectAsState()
val question by viewModel.question.collectAsState()
val options by viewModel.options.collectAsState()
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Header(state)
when (val s = state) {
VotingUiState.NotReady -> NotReadyBody()
VotingUiState.ReadyToDeploy -> ReadyToDeployBody(busy = busy, onDeploy = viewModel::deploy)
is VotingUiState.Deployed -> DeployedBody(
state = s, busy = busy,
question = question, onQuestionChange = viewModel::updateQuestion,
options = options, onOptionsChange = viewModel::updateOptions,
onCreatePoll = viewModel::createPoll, onCastVote = viewModel::castVote,
onClosePoll = viewModel::closePoll, onDeployNew = viewModel::deploy,
onDisconnect = viewModel::disconnect,
)
}
if (busy) {
if (callStage == null) {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
} else {
ContractCallProgressBar(
stage = callStage,
accent = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
labelColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
val err = error
if (err != null) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = err,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
}
}
@Composable
private fun Header(state: VotingUiState) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "Voting",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
StatusBadge(state)
}
}
@Composable
private fun StatusBadge(state: VotingUiState) {
val (label, dotColor) = when (state) {
VotingUiState.NotReady -> "Not connected" to MaterialTheme.colorScheme.error
VotingUiState.ReadyToDeploy -> "Ready" to MaterialTheme.colorScheme.tertiary
is VotingUiState.Deployed -> "Active" to MaterialTheme.colorScheme.primary
}
Surface(
shape = RoundedCornerShape(20.dp),
color = dotColor.copy(alpha = 0.12f),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.size(8.dp).clip(CircleShape).background(dotColor)
)
Spacer(Modifier.width(6.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = dotColor,
)
}
}
}
@Composable
private fun NotReadyBody() {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Forge a sigil above, then fund the wallet and register dust.",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ReadyToDeployBody(busy: Boolean, onDeploy: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Wallet ready. Deploy a new voting contract to the current network.",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
Button(
onClick = onDeploy, enabled = !busy,
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp),
) { Text(text = "Deploy contract") }
}
}
@Composable
private fun DeployedBody(
state: VotingUiState.Deployed, busy: Boolean,
question: String, onQuestionChange: (String) -> Unit,
options: String, onOptionsChange: (String) -> Unit,
onCreatePoll: () -> Unit, onCastVote: (Int) -> Unit,
onClosePoll: () -> Unit, onDeployNew: () -> Unit, onDisconnect: () -> Unit,
) {
AddressSection(state.address)
if (state.pollQuestion.isNullOrBlank()) {
CreatePollSection(
question = question, onQuestionChange = onQuestionChange,
options = options, onOptionsChange = onOptionsChange,
onCreatePoll = onCreatePoll, busy = busy,
)
} else {
PollResultsSection(
question = state.pollQuestion ?: "",
options = state.pollOptions ?: emptyList(),
tallies = state.tallies ?: listOf(0, 0, 0, 0),
totalVotes = state.voteCount ?: 0,
hasVoted = state.hasVoted, pollClosed = state.pollClosed == true,
onCastVote = onCastVote, onClosePoll = onClosePoll, busy = busy,
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = onDeployNew, enabled = !busy,
modifier = Modifier.weight(1f), shape = RoundedCornerShape(8.dp),
) { Text(text = "Deploy new") }
OutlinedButton(
onClick = onDisconnect, enabled = !busy,
modifier = Modifier.weight(1f), shape = RoundedCornerShape(8.dp),
) { Text(text = "Disconnect") }
}
Text(
text = "Results update live from chain.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
@Composable
private fun AddressSection(address: String) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "Contract",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
Text(
text = address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun CreatePollSection(
question: String, onQuestionChange: (String) -> Unit,
options: String, onOptionsChange: (String) -> Unit,
onCreatePoll: () -> Unit, busy: Boolean,
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Create a poll",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "No poll exists yet. Fill in the details below to create one.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = question, onValueChange = onQuestionChange,
label = { Text("Poll question") },
placeholder = { Text("e.g. What's the best programming language?") },
singleLine = true, shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = options, onValueChange = onOptionsChange,
label = { Text("Options") },
placeholder = { Text("e.g. Rust, TypeScript, Kotlin, Go") },
supportingText = { Text("Comma-separated, max 4 options") },
singleLine = true, shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = onCreatePoll,
enabled = !busy && question.isNotBlank() && options.isNotBlank(),
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
) { Text(text = "Create poll") }
}
}
}
@Composable
private fun PollResultsSection(
question: String, options: List<String>, tallies: List<Long>,
totalVotes: Long, hasVoted: Boolean, pollClosed: Boolean,
onCastVote: (Int) -> Unit, onClosePoll: () -> Unit, busy: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = question,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (pollClosed) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.errorContainer,
) {
Text(
text = "Closed",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
if (hasVoted && !pollClosed) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Text(
text = "You voted",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
if (totalVotes > 0) {
Text(
text = "$totalVotes vote${if (totalVotes != 1L) \"s\" else \"\"}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterVertically),
)
}
}
val maxTally = tallies.maxOrNull()?.coerceAtLeast(1) ?: 1
options.forEachIndexed { idx, option ->
val tally = tallies.getOrElse(idx) { 0L }
val fraction = if (totalVotes > 0) tally.toFloat() / totalVotes else 0f
OptionCard(
label = option, tally = tally, fraction = fraction,
totalVotes = totalVotes,
isWinning = tally > 0 && tally == tallies.maxOrNull(),
canVote = !hasVoted && !pollClosed && !busy,
onVote = { onCastVote(idx) },
)
}
if (!pollClosed) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Admin",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = onClosePoll, enabled = !busy,
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) { Text(text = "Close poll") }
}
}
}
}
}
@Composable
private fun OptionCard(
label: String, tally: Long, fraction: Float,
totalVotes: Long, isWinning: Boolean, canVote: Boolean, onVote: () -> Unit,
) {
val bgColor = when {
isWinning && tally > 0 -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
canVote -> MaterialTheme.colorScheme.surface
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
}
Surface(
shape = RoundedCornerShape(12.dp),
color = bgColor,
tonalElevation = if (canVote) 1.dp else 0.dp,
modifier = Modifier
.fillMaxWidth()
.then(if (canVote) Modifier.clickable(onClick = onVote) else Modifier),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
Text(
text = if (tally > 0) "$tally" else "0",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (isWinning && tally > 0)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LinearProgressIndicator(
progress = { fraction },
modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)),
color = if (isWinning && tally > 0)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.primary.copy(alpha = 0.4f),
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = if (totalVotes > 0) "${(fraction * 100).toInt()}%" else "-",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (canVote) {
Text(
text = "Tap to vote",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
if (isWinning && tally > 0) {
Text(
text = "Leading",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}ui/theme/Color.ktpackage com.kuiralabs.starter.counter.ui.theme
import androidx.compose.ui.graphics.Color
val PrimaryRed = Color(0xFFDC0000)
val DarkRed = Color(0xFF8B0000)
val LightRed = Color(0xFFFF4D4D)
val LightRedBg = Color(0xFFFFDAD4)
val AccentYellow = Color(0xFFFFD700)
val DarkYellow = Color(0xFF9A8200)
val LightYellow = Color(0xFFFFF8D4)
val NearBlack = Color(0xFF0D0D0D)
val DarkSurface = Color(0xFF1A1A1A)
val SurfaceVariant = Color(0xFF2D2D2D)
val OutlineColor = Color(0xFF6B6B6B)
val OnSurfaceVariantColor = Color(0xFFC4C4C4)
val AccentGreen = Color(0xFF00D2A0)
val DarkGreen = Color(0xFF003828)
val White = Color(0xFFF5F5F5)ui/theme/Type.ktpackage com.kuiralabs.starter.counter.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val VoteTypography = Typography(
headlineLarge = TextStyle(
fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp,
),
headlineMedium = TextStyle(
fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp,
),
headlineSmall = TextStyle(
fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 26.sp,
),
titleLarge = TextStyle(
fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 24.sp,
),
titleMedium = TextStyle(
fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 22.sp,
),
titleSmall = TextStyle(
fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 20.sp,
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp,
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp,
),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp,
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp,
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp,
),
labelSmall = TextStyle(
fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 14.sp,
),
)ui/theme/Theme.ktVoteThemepackage com.kuiralabs.starter.counter.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
private val VoteDarkColorScheme = darkColorScheme(
primary = PrimaryRed,
onPrimary = White,
primaryContainer = DarkRed,
onPrimaryContainer = LightRedBg,
secondary = AccentYellow,
onSecondary = NearBlack,
secondaryContainer = DarkYellow,
onSecondaryContainer = LightYellow,
tertiary = AccentGreen,
onTertiary = NearBlack,
tertiaryContainer = DarkGreen,
onTertiaryContainer = AccentGreen,
background = NearBlack,
onBackground = White,
surface = DarkSurface,
onSurface = White,
surfaceVariant = SurfaceVariant,
onSurfaceVariant = OnSurfaceVariantColor,
outline = OutlineColor,
outlineVariant = SurfaceVariant,
error = Color(0xFFCF6679),
onError = NearBlack,
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
)
@Composable
fun VoteTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = VoteDarkColorScheme,
typography = VoteTypography,
content = content,
)
}MainActivity.ktpackage com.kuiralabs.starter.counter
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.kuiralabs.starter.counter.ui.VotingScreen
import com.kuiralabs.starter.counter.ui.theme.VoteTheme
import com.midnight.kuira.dapp.wallet.WalletAppShell
import com.midnight.kuira.sdk.walletruntime.WalletNotifications
import com.midnight.kuira.sdk.walletruntime.SessionLock
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject lateinit var sessionLock: SessionLock
private val notifPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (WalletNotifications.shouldRequest(this)) {
notifPermission.launch(WalletNotifications.PERMISSION)
}
setContent {
WalletAppShell {
VoteTheme {
Surface(modifier = Modifier.fillMaxSize()) {
VotingScreen()
}
}
}
}
}
override fun onUserInteraction() {
super.onUserInteraction()
sessionLock.onUserActivity()
}
}AppCompatActivitySigilStatusPanelFragmentActivityAppCompatActivityFragmentActivityComponentActivityWalletAppShellSessionLockGateWalletOverlayHostWalletNotifications.shouldRequest(this)POST_NOTIFICATIONS| Symptom | Cause | Fix |
|---|---|---|
| | Check the plugin ID and version in |
| Kuira dep not resolved | Check |
| No | Create the module (§6.2) |
| Not enough JVM heap | Bump |
| Changed config cache setting mid-project | Delete |
| SDK platform not installed | Install Android SDK Platform 36 |
| Asset not synced | Run |
| | Run |
| Symptom | Cause | Fix |
|---|---|---|
| Runtime version mismatch | Check |
| Wrong witness value | The hardcoded |
| Already closed | Wait for next poll — no reopen circuit exists |
| BigInt patch failed | Check |
| Called from wrong lifecycle | Call in |
| Runtime check not guarded | The SDK handles this internally; if it crashes, you may have minSdk < 30 |
| | Handle |
| App shows "Forge a sigil" but user already has one | SDK state reset | Clear app data and re-forge, or check logcat for |
| Symptom | Cause | Fix |
|---|---|---|
| Localnet not running | Run |
| Cleartext blocked on localnet | Add |
| Passkey domain changed | Delete credential in Google Password Manager, re-forge |
| Wallet not funded | Airdrop NIGHT and register dust (§10.2) |
| Transaction pending forever | Indexer not synced | Wait for |
| Symptom | Cause | Fix |
|---|---|---|
| compactc not installed at that version | |
| npm deps not installed | |
| Language version mismatch | Check |
| Import path issue | Ensure |
<kotlin-version>-<ksp-release>NoSuchMethodError: Fragment.getParentFragmentManager()buildscriptkotlin-composecomposeOptions { kotlinCompilerExtensionVersion }yourname.github.ioassetlinks.jsonhttps://<domain>/.well-known/PASSKEY_RP_IDPasskeyConfigModule.ktmn airdrop 1000 --wallet <addr> --network undeployedContractCallProgressBarPanelBar# Start localnet (Docker stack)
mn localnet up
# Compile contract
cd contract && npm install && npm run compile:voting
# Install + run
cd .. && ./gradlew :app:installDebug./gradlew :app:assembleDebug
unzip -l app/build/outputs/apk/debug/app-debug.apk | grep voting
# Expected:
# assets/runtime/voting-contract.js
# assets/keys/createPoll.verifier
# assets/keys/castVote.verifier
# assets/keys/closePoll.verifier
# assets/keys/createPoll.prover
# assets/keys/castVote.prover
# assets/keys/closePoll.prover
# assets/keys/*.bzkir (if bundleWalletKeys is enabled)./gradlew kuiraDoctorPasskeyConfigassetlinks.jsoncompactc@midnight-ntwrk/compact-runtimecompact update <new-version>compact listengines.compactccontract/package.json@midnight-ntwrk/compact-runtimecontract/package.json+<version>compile:votingscripts/patch-voting.jsrm -rf src/managed/voting && npm run compile:votingcontract-info.jsonpackage.json./gradlew :app:assembleDebug./gradlew kuiraDoctor| API | Purpose |
|---|---|
| Injected singleton providing |
| Build contract handle with name, JS, address, verifier keys, witnesses |
| Deploy contract, returns |
| Call a circuit — circuit |
| Read current on-chain state, returns typed |
| Reactive |
| Typed ledger field accessors |
| Stage proving keys from assets for on-device proving |
| Drop-in floating sigil + wallet pills |
| Root wrapper — |
| Built-in progress for deploy/call stages |
| Background wallet ops survive app backgrounding |
| App-wide auto-lock + idle-timer reset |
| |
| The wallet's public key needed for write handles |
| Witness provider returning a static byte array |