android-example-voting について
android-example-votingは、Kuira Android SDKでMidnight Network向けの投票dAppを構築するスキルです。Android開発者向けに、環境設定からコントラクトのデプロイ、投票、リアクティブな結果表示まで案内し、バージョン固定とトラブル対処も記載しています。
主な機能
- 01Android投票dAppの構築ガイド
- 02Passkey-DIDとウォレットの作成
- 03Compactコントラクトのデプロイ
- 044択のオンデバイスZK投票
- 05管理者による投票操作
- 06リアクティブな台帳結果の更新
活用シーン
- 01Midnight向けAndroid投票アプリの構築
- 02管理者キー限定の投票作成
- 03デバイス上でのプライバシー保護投票
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ kali-decoder/ midnight-skills --skill android-example-votingインストール後、Claude Code で/android-example-votingを入力して読み込まれたか確認します。
詳細
Kuira Voting dApp
このスキルは、Kuira Android SDKを使用して、Midnight Network 用の実行可能な投票 dApp を生成します。 以下のスニペットはすべて、動作する starter-android コードベースから直接引用しています。ビルドに失敗した場合は、失敗モードのセクションをお読みください。よくある3つの問題を取り上げています。
主なリファレンス:
https://kuiralabs.github.io/kuira-sdk-android/— Kuira SDK ドキュメントhttps://kuiralabs.github.io/kuira-sdk-android/api/— Dokka API リファレンスhttps://github.com/kuiralabs/kuira-starter-android— 公式スターターhttps://central.sonatype.com/namespace/io.github.kuiralabs— Maven Central
これで構築できるもの
| 機能 | 方法 |
|---|---|
| シジルを生成 | PanelBar — 生体認証1回でパスキー DID とウォレットを作成 |
| コントラクトをデプロイ | Compact コントラクトを Midnight にデプロイ |
| 投票を作成 | SHA-256 管理者キーの照合による、主催者限定の操作 |
| 投票する | 4つの選択肢から1つ選び、デバイス上で ZK 証明を生成 |
| 投票を締め切る | 管理者キーの所有者のみ締め切り可能 |
| リアルタイムの結果 | observeLedger() が状態の変化をリアクティブにストリーミング |
固定バージョン
以下は、動作するコードベースで実際に使用されているバージョンです。異なるバージョンを使用すると、依存関係の解決に失敗します(§9 を参照)。
| レイヤー | バージョン |
|---|---|
Kuira SDK (io.github.kuiralabs:dapp-ui) | 0.1.0-alpha04 |
| Kuira Contract プラグイン | 0.1.0-alpha04 |
| Kuira Localnet プラグイン | 0.1.0-alpha04 |
| AGP | 8.13.2 |
| Kotlin | 2.3.20 |
| KSP | 2.3.6 |
| Hilt | 2.58 |
| Compose BOM | 2026.03.01 |
| Gradle | 8.13 |
| JDK | 17 |
compileSdk / targetSdk | 36 |
minSdk | 30 |
appcompat | 1.7.0 |
activity-compose | 1.10.1 |
fragment-ktx | 1.8.5 |
hilt-navigation-compose | 1.1.0 |
security-crypto | 1.1.0-alpha07 |
@midnight-ntwrk/compact-runtime | 0.16.0 |
| Compact コンパイラー | 0.31.0(コンパイルコマンドで固定) |
| Compact 言語プラグマ | >= 0.22 |
1) プロジェクト構成
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 localnet
警告: local.properties に sdk.dir=... を指定して存在させる必要があります。そうしないと、AGP の android ブロックで compileSdk を解決できません。Android Studio では自動作成されますが、CI では手動で作成するか、環境変数を使用する必要があります。
2) 前提条件
2.1) ツールチェーン
java --version # JDK 17+
node --version # Node.js 18+
compact update 0.31.0 # Install Compact compiler
compact list # Verify 0.31.0 is the default
2.2) Android のセットアップ
- Android Studio Ladybug 以降
- 画面ロックを設定した Android 13 以降(API 33 以降)のデバイスまたはエミュレーター
- パスキーの
rpId用ドメイン(または GitHub Pages を使用) - Google Password Manager(GMS Core 23.40.13 以降)
2.3) Gradle ラッパー
gradle/wrapper/gradle-wrapper.properties のラッパーでは、Gradle 8.13(AGP 8.13.2 に対応)を指定する必要があります。
distributionBase=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/dists
3) Gradle の設定
3.1) gradle.properties — 必須
このファイルがないと、Gradle はビルドを極端に遅くしたり、構成キャッシュを壊したりするデフォルト設定を使用します。
android.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=true
失敗モード: android.useAndroidX=true がないと、SDK の AppCompatActivity 依存関係が古いサポートライブラリに解決され、実行時に ClassNotFoundException が発生します。
失敗モード: org.gradle.jvmargs=-Xmx2048m がないと、規模がやや大きいプロジェクトでは KSP と Hilt を併用した際にメモリ不足になることがあります。OutOfMemoryError: Metaspace が発生する場合は、-Xmx4096m に増やしてください。
失敗モード: org.gradle.configuration-cache=true は、app/build.gradle.kts が構成時にプロジェクトファイル(例:file("../contract"))にアクセスすると問題が発生します。以下の Copy タスクでは doFirst と rootProject.file() を使ってこれを回避していますが、評価時にファイルを読み込むプラグインを追加する場合は、構成キャッシュを無効にしてください。
3.2) settings.gradle.kts
pluginManagement {
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_REPOS が設定されているため、app/build.gradle.kts 内で repositories { ... } を宣言することはできません。ライブラリが google() または mavenCentral() にない場合は、ここで dependencyResolutionManagement に追加してください。
3.3) Root build.gradle.kts
plugins {
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
}
注:Kuira contract プラグイン(io.github.kuiralabs.contract)と
localnet プラグイン(io.github.kuiralabs.localnet)は、バージョン
カタログ経由ではなく、app/build.gradle.kts で ID とバージョンを
直接指定して適用します。ここには記載されません。
3.4) gradle/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" }
バージョン固定のルール:
- AGP と Gradle ラッパーのメジャー・マイナー番号は一致させる必要があります:AGP 8.13.x ↔ Gradle 8.13
- Kotlin と KSP のバージョンは完全に一致させる必要があります:Kotlin 2.3.20 ↔ KSP 2.3.6
- Compose BOM と Compose コンパイラープラグインは、
kotlin-composeプラグインを介して連動しています。compose-compiler のバージョンを手動で 設定しないでください - Hilt と ksp-hilt のバージョンは一致させる必要があります(ksp アーティファクトは 推移的に解決されるため、依存関係を明示的に指定する必要はありません)
3.5) app/build.gradle.kts
import 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.contract — kuiraContract { ... }
ブロックを処理します。次の処理を行います。
validateKuiraContractSourceを登録します。../contract/src/managed/votingが存在しない場合、明確なメッセージを表示してビルドを失敗させますsyncContractAssetsを登録します。JS とキーをsrc/main/assets/{runtime,keys}/にコピーしますsyncContractAssetsをpreBuildに組み込みますbundleWalletKeys.set(true)フラグにより、バンドルタスク中にプラグインが.bzkirバンドルファイルをアセットにコピーします
io.github.kuiralabs.localnet — 物理デバイスでの installDebug 中に、
localnet のポートに対して adb reverse を自動実行します。エミュレーターでは
何もしません(エミュレーターでは 10.0.2.2 を使用します)。
プラグインと Copy タスクの両方がある理由は? kuiraContract プラグインは
主要なソースを自動的に処理します(JS とキーの同期、および bundleWalletKeys)。
手動で作成した Copy タスクは、明示的な並行同期のためにスターターコードベースに
用意されています。このスキルではプラグインだけで十分です。Copy タスクは
分かりやすさのために用意されています。
失敗するケース: minSdk < 30 の場合、SDK は実行時に失敗します。
android.security.identity.IdentityCredential(API 30 以降)と
CredentialManager(API 34 以降。API 30 以降のフォールバックパスあり)に
依存しているためです。
失敗するケース: compileSdk != 36 または targetSdk != 36 の場合、
Kuira SDK の WalletNotifications.PERMISSION 定数(API 33 で追加された
POST_NOTIFICATIONS。API 34 で動作が変更)が正しく解決されない可能性があります。
compileSdk = 36 を維持してください。
4) Android リソース
4.1) app/src/main/res/values/strings.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Kuira Starter</string>
</resources>
4.2) 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>
AppCompat を親にする理由は? SDK の SigilStatusPanel が生体認証プロンプトを
内部でホストするため、MainActivity は ComponentActivity ではなく
AppCompatActivity を継承します。プロンプトには FragmentActivity が必要です。
AppCompatActivity はこの条件を満たしますが、ComponentActivity は満たしません。
テーマ XML には互換性のある親が必要です。
4.3) app/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" を指定するより、
こちらを推奨します。どちらも localnet では機能しますが、この方法なら
デバッグビルドでインターネット全体に対してクリアテキスト通信を許可しません。
デバッグマニフェスト方式(§4.5 を参照)を使用する場合は、debug
ビルドタイプでのみ有効になるため、こちらも安全です。
4.4) メインの AndroidManifest.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>
ネットワーク設定: @xml/network_security_config を指定する
android:networkSecurityConfig 属性により、グローバルに有効化することなく、
localnet 用に localhost とエミュレーターのホストへのクリアテキスト通信を許可します。
4.5) デバッグマニフェスト(network_security_config の代替)
app/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>
マージルール:デバッグマニフェストはデバッグビルドの場合にのみ、メインの マニフェストにマージされます。セキュリティ設定方式よりシンプルですが、 localnet だけでなく、すべてのホストに対してクリアテキスト通信を許可します。
失敗するケース: networkSecurityConfig と
usesCleartextTraffic="true" の両方を使用すると、セキュリティ設定が優先されます。
設定に正しいドメインが含まれていない場合、クリアテキスト通信は引き続き失敗します。
5) コントラクト
5.1) 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"
}
}
失敗モード:ランタイムバージョンの不一致。 コンパイル済みの JS src/managed/voting/contract/index.js には、固定のランタイムバージョン要件がハードコードされています。package.json の @midnight-ntwrk/compact-runtime のバージョンが、コンパイル時にコンパイラーが埋め込んだバージョンと一致しない場合、コントラクトの読み込み時に Android アプリで JS ランタイムエラーが発生します。
症状: "Could not find module '@midnight-ntwrk/compact-runtime'"
または、JS エンジンからの不可解な TypeError です。
修正方法: マネージド出力内の contract-info.json を確認します。
cat src/managed/voting/compiler/contract-info.json
# Look for "runtime-version" — must match package.json's dep
異なる場合は、package.json の @midnight-ntwrk/compact-runtime を一致するバージョンに上げるか、正しいツールチェーンバージョンで再コンパイルしてください。
compact compile コマンドの +0.31.0 の固定は重要です。
これがないと、コマンドは compact ツールチェーンのデフォルトのコンパイラーを使用しますが、コントラクトを最後にコンパイルしたときのランタイムバージョンと一致しない可能性があります。必ずバージョンを固定してください。
5.2) contract/scripts/patch-voting.js
const 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');
このパッチが必要な理由: Kuira SDK の Kotlin から JS へのブリッジは、Kotlin の Long を bigint ではなく JS の number として送信します。Compact の型 Uint<64> は bigint を想定しています。このパッチがないと、castVote(0) は 0(JS の number)を渡し、ランタイム内部の typeof チェックに失敗します。
失敗モード: compact compile によって異なる変数名が生成されると、この文字列の照合は何もせずに終了します。ツールチェーンを更新した後は、必ず contract/index.js を既知の正常なコピーと比較してください。
失敗モード: パッチを適用する行は、optionIdx_0 = args_1[1] が castVote サーキットの最初の引数であることを前提としています。これはコンパイラーによって生成されるため、変更される可能性があります。再コンパイル後は、必ずパッチが機能することを確認してください。
5.3) contract/src/voting.compact
pragma 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);
}
コントラクト設計上の主な決定事項:
-
created台帳フィールドはありません。 一般的な CRUD コントラクトとは異なり、このコントラクトは投票が作成されたかどうかを追跡しません。UI はpollQuestion != ""からこれを推測します。createPollを 2 回呼び出すと、最初の投票は上書きされます(assert(!created)によるガードはありません)。これはシンプルさを優先した意図的な設計です。 -
deriveAdminKeyはpure circuitです。 つまり、それ自体ではパブリック出力を生成せず、他のサーキット内でのみ使用されます。pureキーワードは、このサーキットに台帳への書き込みもパブリック入力もないことを示します。これはヘルパー関数です。 -
言語バージョンの範囲は
>= 0.22(厳密な指定ではありません)。 互換性のある 0.22.x コンパイラーであれば、どれでも受け入れられます。実際にコンパイラーを選択するのは、compact compile +0.31.0コマンドラインのバージョン固定です。 -
pollClosedはdisclose()を介してtrueに設定されます。 投票が締め切られると、pollClosedはパブリックになり、オンチェーンで確認できます。締め切られる前は、コンストラクターによってデフォルトのfalseに設定されます。 -
二重投票を防ぐガードはありません。 投票者の識別情報を確認する
Set<Bytes<32>>はありません。UI のhasVotedフラグは、observeLedger()から得たローカルキャッシュにすぎません。投票後にアプリを再インストールしたユーザーが再度投票しても成功します。これは、このシンプルなコントラクトの意図的な設計です。 -
adminKeyHashはコンストラクターではなくcreatePollで設定されます。 コンストラクターでは何も初期化されません(Compact のデフォルト値)。つまり、最初のcreatePollで管理者キーが設定され、その後の呼び出しで上書きされる可能性があります(ガードはありません)。Android 側のclosePollのアサーションは現在のadminKeyHashと照合するため、adminSecret()を知っている人だけが投票を締め切れます。
5.4) コントラクトをコンパイルする
cd contract
npm install
npm run compile:voting
期待される出力:
Compiling 3 circuits:
circuit "createPoll" (k=...)
circuit "castVote" (k=...)
circuit "closePoll" (k=...)
Patched BigInt shim into voting contract JS
パッチを適用する行が表示されない場合、contract/index.js に想定される変数名がありません。正規表現を確認して更新してください。
コンパイル後、ランタイムバージョンを確認します:
cat src/managed/voting/compiler/contract-info.json
# Confirm runtime-version matches @midnight-ntwrk/compact-runtime in package.json
6) アプリケーションと DI
6.1) KuiraStarterApp.kt
package 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) は、アプリレベルのセッションロックトリガー(バックグラウンド検出(画面オフ/ホームボタン)とアイドルタイムアウト)を登録します。Activity ごとのリセットは onUserInteraction が処理します。
WalletForegroundService.attach(this) は、アプリがバックグラウンドに移行してもウォレット操作(dust 同期、送信、コントラクト呼び出し)が継続するようにします。進行状況の通知を表示し、フォアグラウンドに戻るかロックされると終了します。
失敗するケース: @HiltAndroidApp を忘れると、Dagger は依存関係グラフを初期化できず、起動時に実行時クラッシュが発生します(dagger.hilt.internal.NoopComponentManager)。
6.2) di/PasskeyConfigModule.kt
package 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")
}
Dagger がこれなしではコンパイルできない理由: Kuira SDK は、独自のモジュールで PasskeyConfig を必須バインディングとして宣言しています。利用するアプリがこれを提供しないと、Dagger はコンパイル時に [Dagger/MissingBinding] PasskeyConfig is injected at ... というエラーで失敗します。これは意図的な仕様です。SDK はアプリのドメインを把握できないためです。
パスキーのドメイン設定:
PASSKEY_RP_IDに、自分が管理するドメインを設定します- アプリの
applicationIdと署名の SHA-256 を含むassetlinks.jsonをhttps://<rpId>/.well-known/assetlinks.jsonでホストします。./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_MISMATCH が発生します。パスキーの認証情報がある rpId で作成された後、別の rpId を使用しています。解決方法は、Google Password Manager から認証情報を削除して再作成することだけです。
7) データレイヤー
7.1) data/VotingContract.kt
これは中核となるコントラクトのラッパーです。すべての詳細が重要です。
package 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),
)
}
}
重要なパターン:object と class の違い — これはシングルトンであり、インスタンスごとに作成するクラスではありません。ADMIN_SECRET は ByteArray(32) { 0x42 }(すべてのバイトが 0x42)としてハードコードされています。同じ APK からデプロイするたびに、同じ管理者シークレットが使われます。APK を逆コンパイルできる人は、デプロイされたすべての投票の管理者キーを取得できます。
WitnessResult(null, ADMIN_SECRET): 最初のパラメーターは privateState: Any? です。ここではプライベート状態が不要なため null です。2 つ目は witness のバイト配列です。WitnessResult は両方を 1 つのオブジェクトにまとめ、witness 関数から返します。
MidnightContract.create(sdk.config) { ... } DSL ビルダー:
name:コントラクト JS アセットの検索キーとして使用しますcontractJs:コントラクト JS ファイルのInputStreamaddress:オンチェーンアドレス(デプロイ時は null)coinPublicKey:書き込み操作(署名)に必要ですcircuitVerifierKeys:回路名 → 検証キーのバイト配列のマップwitness(name) { ... }:witness プロバイダーを登録します
アセットパスの規則: コントラクト JS は assets/runtime/ に、検証キーは assets/keys/ に配置します。kuiraContract Gradle プラグインが、管理対象の出力ディレクトリから自動的にコピーします。
失敗するケース: IllegalStateException: Contract JS not found at assets/runtime/voting-contract.js。アセットが同期されていません。同期を実行するには ./gradlew :app:assembleDebug を実行し、app/src/main/assets/ が存在し、正しいファイルが含まれていることを確認してください。
失敗するケース: adminKeyHash がまだ設定されていない場合(createPoll の前)に getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32) を呼び出すと、例外が発生します。コントラクトはコンストラクターで adminKeyHash を初期化しないため、すべてゼロ、または未定義の値になっている可能性があります。実際のコントラクトでは、createPoll で設定します。UI は adminKeyHash が存在しない場合に対応する必要があります。
7.2) data/ContractAddressStore.kt
package 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"で、VotingContract の名前と一致します。
各ネットワークには専用のスロット(例:voting.UNDEPLOYED)が割り当てられるため、
ウォレットパネルでネットワークを切り替えても、以前のデプロイが失われることはありません。
EncryptedSharedPreferences: マスターキーには AES256-GCM、キーの暗号化には
AES256-SIV を使用します。MasterKey.Builder は内部で Android Keystore を使用するため、
TEE を備えたデバイスでは暗号化キーがハードウェアによって保護されます。
障害時の動作: MasterKey.Builder.setKeyScheme には API 23 以降が必要です。
minSdk は 30 なので問題ありません。ただし、minSdk がより低いバージョンにバックポートする場合は、
代わりに MasterKey.DEFAULT_MASTER_KEY_ALIAS を使用してください。
8) UI レイヤー
8.1) ui/VotingUiState.kt
package 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? — コントラクトではcreatePollが呼び出されるまで設定されないため、
nullable になっています。それまでは、台帳にこのフィールドの有意な値はありません。
hasVotedはローカルのブール値です — castVote()の成功後、ViewModel でtrueに設定されます。
プロセス終了後には保持されません。ViewModel が破棄されると(例:メモリ不足によるアクティビティの終了)、
falseにリセットされます。このコードベースには、永続化される「投票済み」フラグはありません。
8.2) ui/VotingViewModel.kt
package 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.selectedNetworkはStateFlow<MidnightNetwork>です。SDK のPanelBarコンポーザブルがこれにバインドされます。 ViewModel はこれを読み取り、ContractAddressStoreに渡します。 -
recomputeStateとapplyLedger: SDK またはネットワークが変更されたとき (例:ウォレットのロック解除、ネットワークの切り替え)にrecomputeStateが呼び出されます。 初期状態にリセットします。オンチェーンの台帳が更新されたとき (observeLedger()または手動のreadLedger()経由)にはapplyLedgerが呼び出されます。 Deployed 状態を保持し、ライブデータを反映します。 -
readHandleのキャッシュ: 読み取り専用アクセス用のMidnightContractハンドルを、 アドレスごとにキャッシュします。アドレスが変更されたとき(例:新しいコントラクトのデプロイ時)は、 古いハンドルを破棄します。 -
runActionのエラー処理: すべての例外をキャッチし、メッセージを表示します。closePollが失敗すると(管理者シークレットが間違っている場合)、CircuitExecutionException("Only admin can close poll")がスローされます。_callStageの進捗はfinallyブロックでクリアされます。 -
ContractCallStage: SDK はデプロイ/呼び出しの進捗を表す sealed interface を定義しています。 これはContractCallProgressBarコンポーザブルを介して表示されます。 ViewModel はこれをcallStage: StateFlowとして公開します。 -
observeLedger().catch { }: 台帳の監視エラーを黙って無視します。 監視中に接続が切れても、ViewModel はクラッシュしません。次のユーザー操作でreadAndUpdateLedgerが実行されるまで、UI の更新が止まるだけです。
8.3) ui/VotingScreen.kt
package 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)**は、フローティングのシジルチップ(左)とウォレットチップ(右)を
表示します。これは SDK が推奨する組み合わせです。シジルチップは forge/作成/インポートを処理し、
ウォレットチップは残高を表示するとともに、送信/受信/dust 登録を提供します。
**WindowInsets.safeDrawing**により、コンテンツがディスプレイの切り欠きやシステムバーを避けるようにします。
8.4) ui/VotingCard.kt
package 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,
)
}
}
}
}
}
8.5) テーマファイル
ui/theme/Color.kt — パレットの例:
package 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.kt — Material 3 のタイポグラフィスケール:
package 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.kt — VoteThemeコンポーザブル:
package 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,
)
}
テーマはダークモードのみです。3 つのファイルはすべて必須であり、いずれかを省略すると ビルドが失敗します。
8.6) MainActivity.kt
package 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()
}
}
なぜ AppCompatActivity なのですか? SDK の SigilStatusPanel は生体認証プロンプトをホストしますが、これには FragmentActivity が必要です。AppCompatActivity は FragmentActivity のサブクラスですが、ComponentActivity はそうではありません。
WalletAppShell は次のものをラップします。
SessionLockGate— セッションがロックされている場合(自動ロックまたは手動ロック)に、ロック画面のオーバーレイを表示しますWalletOverlayHost— アクティビティウィンドウ内で全画面の送信/受信/設定オーバーレイをホストします
WalletNotifications.shouldRequest(this) は、API 33 以降での POST_NOTIFICATIONS 権限の必要性を確認します。バックグラウンドでのウォレット操作の進行状況を通知する必要がある場合は、この権限をリクエストしてください。これはベストエフォートであり、許可されなくても機能には影響しません。
9) 失敗時の動作とトラブルシューティング
9.1) ビルドの失敗
| 症状 | 原因 | 対処方法 |
|---|---|---|
Could not find method kuiraContract() | io.github.kuiralabs.contract プラグインが適用されていません | app/build.gradle.kts のプラグイン ID とバージョンを確認します |
Cannot access class 'com.midnight.kuira.*' | Kuira の依存関係が解決されていません | settings.gradle.kts の mavenCentral() を確認し、カタログ内の kuira バージョンを確認します |
[Dagger/MissingBinding] PasskeyConfig | PasskeyConfigModule がありません | モジュールを作成します(§6.2) |
OutOfMemoryError: Metaspace | JVM ヒープが不足しています | org.gradle.jvmargs を -Xmx4096m に引き上げます |
Configuration cache state found but cache is disabled | プロジェクトの途中で構成キャッシュの設定を変更しました | ~/.gradle/configuration-cache/ を削除して再ビルドします |
Could not find required SDK platform API 36 | SDK プラットフォームがインストールされていません | Android SDK Platform 36 をインストールします |
java.lang.RuntimeException: Cannot load contract JS | アセットが同期されていません | kuiraContract の同期を実行するため、./gradlew :app:assembleDebug を実行します |
IllegalStateException: Contract not compiled at .../managed/voting | kuiraContract.validateKuiraContractSource が失敗しています | contract/ で npm run compile:voting を実行します |
9.2) 実行時の失敗
| 症状 | 原因 | 対処方法 |
|---|---|---|
"Could not find module '@midnight-ntwrk/compact-runtime'" | ランタイムのバージョンが一致していません | contract-info.json と package.json を確認します(§5.1) |
CircuitExecutionException: Only admin can close poll | witness の値が正しくありません | ハードコードされた ADMIN_SECRET が一致するかどうかのどちらかです。別のデバイスから復旧する方法はありません。 |
CircuitExecutionException: Poll is closed | すでに閉じられています | 次の投票を待ちます。回路を再度開く方法はありません |
TypeError: optionIdx_0 is not a function | BigInt のパッチに失敗しました | patch-voting.js が生成された JS と一致しているか確認します(§5.2) |
SessionLock attach failed | 適切でないライフサイクルから呼び出されています | Activity.onCreate() ではなく、Application.onCreate() で呼び出します |
API < 33 で WalletNotifications.shouldRequest がクラッシュする | 実行時チェックで保護されていません | SDK は内部で処理します。クラッシュする場合は、minSdk が 30 未満の可能性があります |
createPoll の前に readLedger で NullPointerException が発生する | adminKeyHash が未定義です | UI で VotingLedger.adminKeyHash がすべてゼロまたは null の場合を処理します |
| ユーザーがすでに持っているのに、アプリに「Forge a sigil」と表示される | SDK の状態がリセットされました | アプリデータを消去して再度 forge するか、logcat で SigilStateProvider のエラーを確認します |
9.3) ネットワークの失敗
| 症状 | 原因 | 対処方法 |
|---|---|---|
java.net.ConnectException: Connection refused | Localnet が実行されていません | mn localnet up を実行します |
javax.net.ssl.SSLHandshakeException | Localnet でクリアテキスト通信がブロックされています | network_security_config.xml に 10.0.2.2 を追加します(§4.3)。またはデバッグマニフェストを使用します(§4.5) |
RP_ID_MISMATCH | Passkey のドメインが変更されています | Google Password Manager で認証情報を削除し、再度 forge します |
INSUFFICIENT_DUST | ウォレットに資金がありません | NIGHT をエアドロップし、dust を登録します(§10.2) |
| トランザクションがいつまでも保留中になる | インデクサーが同期されていません | observeLedger() が出力するまで待つか、インデクサーの健全性を確認します |
9.4) コントラクトのコンパイル失敗
| 症状 | 原因 | 対処方法 |
|---|---|---|
Error: Unknown compiler version +0.31.0 | 該当バージョンの compactc がインストールされていません | compact update 0.31.0 |
Could not find package '@midnight-ntwrk/compact-runtime' | npm の依存関係がインストールされていません | cd contract && npm install |
Syntax error: expected ';' | 言語バージョンが一致していません | pragma language_version がツールチェーンと一致しているか確認してください |
The compiler cannot find module | インポートパスに問題があります | contract-info.json のパスが相対パスであることを確認してください |
9.5) Gradle バージョンカタログの注意点
-
Kotlin と KSP のバージョン連動: Kotlin 2.3.20 には KSP 2.3.6 が必要です。Kotlin を 2.3.21 にアップグレードする場合は、対応するバージョンの KSP も必ず確認してアップグレードしてください。KSP のバージョン形式は
<kotlin-version>-<ksp-release>です。 -
AppCompat 1.7.x には fragment-ktx 1.8.x が必要です — 新しい appcompat と古い fragment を組み合わせると、
NoSuchMethodError: Fragment.getParentFragmentManager()が発生します。 -
AGP と Gradle のバージョン連動: AGP 8.13 には Gradle 8.13 が必要です。ラッパーの配布 URL を変更すると、
buildscriptのクラスパスでエラーが発生します。 -
Compose BOM とコンパイラー: Kotlin 2.0 以降の
kotlin-composeプラグインは、従来のcomposeOptions { kotlinCompilerExtensionVersion }ブロックに置き換わります。Compose コンパイラーのバージョンは手動で設定しないでください。BOM とプラグインが自動的に連携します。
10) ウォレットとデプロイのフロー
10.1) 前提条件:パスキーのドメイン
- 自分が管理するドメインを選びます(例:
yourname.github.io) assetlinks.jsonをhttps://<domain>/.well-known/にアップロードしますPasskeyConfigModule.ktでPASSKEY_RP_IDを設定します
10.2) ウォレットへの資金供給(localnet)
- アプリを開き、ウォレットパネルの Forge sigil をタップします
- ウォレットパネルからウォレットアドレスをコピーします
- NIGHT をエアドロップします:
mn airdrop 1000 --wallet <addr> --network undeployed - アプリで、ウォレットパネルの Register dust をタップします
- dust の同期を約 30 秒待ちます
- Deploy contract をタップします
10.3) アプリの使用方法
- 投票を作成: 質問と選択肢(カンマ区切り)を入力し、「Create poll」をタップします。トランザクションが確定するまで待ちます。
- 投票する: 選択肢のカードをタップします。
ContractCallProgressBarに ZK 証明の進行状況が表示されます。成功すると「You Voted」バッジが表示され、投票数が更新されます。 - 投票を締め切る: 「Close poll」をタップします(デプロイしたデバイスでのみ実行できます)。締め切ると「Closed」バッジが表示され、投票が無効になります。
- ネットワークを切り替える:
PanelBarのネットワークセレクターを使用します。ネットワークごとにデプロイされたコントラクトアドレスが異なります。 - 切断する: 保存されたアドレスを消去し、デプロイ画面に戻ります。
11) テストと検証
Localnet のフロー
# Start localnet (Docker stack)
mn localnet up
# Compile contract
cd contract && npm install && npm run compile:voting
# Install + run
cd .. && ./gradlew :app:installDebug
APK のアセットを検証する
./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 kuiraDoctor
以下を検出します:PasskeyConfig のバインディング漏れ、ホストされていない assetlinks.json、古いコントラクトアーティファクト、アセットの欠落。
12) ツールチェーンの更新手順
compactc、言語バージョン、または @midnight-ntwrk/compact-runtime が更新された場合:
compact update <new-version>とcompact listを実行して確認しますcontract/package.jsonのengines.compactcを更新しますcontract/package.jsonの@midnight-ntwrk/compact-runtimeを更新します- 重要:
compile:votingスクリプトの+<version>の指定を更新します scripts/patch-voting.jsを新たに生成された JS と照合します — 変数名が変わっている可能性がありますrm -rf src/managed/voting && npm run compile:votingを実行しますcontract-info.jsonのランタイムバージョンがpackage.jsonと一致することを確認します./gradlew :app:assembleDebugを実行してアセットを再同期します- localnet のフローを実行し、デプロイ、createPoll、castVote、 closePoll がすべて動作することを確認します
./gradlew kuiraDoctorを実行します- 再生成されたアーティファクトをコミットします
13) 使用している SDK API
| API | 目的 |
|---|---|
MidnightSdkProvider | MidnightSdk とネットワーク選択を提供する、注入されたシングルトン |
MidnightContract.create(config) { } | 名前、JS、アドレス、検証キー、ウィットネスを指定してコントラクトハンドルを構築 |
.deploy(onProgress) | コントラクトをデプロイし、DeployResult (.contractAddress) を返す |
.call(circuit, vararg args, onProgress) | サーキットを呼び出します。サーキットの assert が失敗すると CircuitExecutionException がスローされます |
.ledger() | 現在のオンチェーン状態を読み取り、型付きの MidnightLedger を返す |
.observeLedger() | リアクティブな Flow<MidnightLedger>。現在の状態を出力した後、変更のたびに出力します |
MidnightLedger.getBoolean/getString/getStringOrNull/getUint64/getBytes(n, size) | 型付きの台帳フィールドアクセサー |
ProvingKeyManager.installCircuitKeysFromAssets() | デバイス上での証明用に、アセットから証明キーをステージングします |
PanelBar(floating = true, network, onNetworkChange) | フローティングシジルとウォレットピルを手軽に追加できます |
WalletAppShell { } | ルートラッパー。SessionLockGate と WalletOverlayHost を含みます |
ContractCallProgressBar(stage, accent) | デプロイ/呼び出しステージ用の組み込みプログレスバー |
WalletForegroundService.attach(this) | アプリがバックグラウンドに移行しても、ウォレット操作を継続します |
SessionLock.attach(this) / .onUserActivity() | アプリ全体の自動ロックと、アイドルタイマーのリセット |
WalletNotifications.shouldRequest(this) / PERMISSION | API 33 以降での POST_NOTIFICATIONS の許可判定 |
MidnightSdk.coinPublicKey | 書き込み用ハンドルに必要なウォレットの公開鍵 |
WitnessResult(null, bytes) | 静的なバイト配列を返すウィットネスプロバイダー |