A pure-Kotlin FROST trusted dealer for the
promenade threshold signer, byte-compatible with
@fiatjaf/promenade-trusted-dealer 0.4.3.
It splits a secp256k1 secret key into shards (Shamir over the scalar field, BIP340 even-y
normalized) and recovers it from any threshold-many shards (Lagrange interpolation at x=0). The
shard wire format matches the reference implementation byte-for-byte, verified by cross-language
test vectors captured from the JS library with a pinned RNG (see
FrostDealerVectorTest).
No JS/npm dependency: the elliptic-curve math is implemented over a multiplatform BigInteger (ionspin/kotlin-multiplatform-bignum), so the same code runs on every Kotlin target.
| Target | Status |
|---|---|
| JVM | yes |
Android (minSdk 21) |
yes |
| iOS (arm64, simulatorArm64, x64) | yes |
| JS (browser + Node.js, IR) | yes |
Available on Maven Central:
// build.gradle.kts
dependencies {
implementation("io.github.nostrord:pomegranate-dealer:0.1.0")
}Or with a version catalog:
# gradle/libs.versions.toml
[libraries]
pomegranate-dealer = { module = "io.github.nostrord:pomegranate-dealer", version = "0.1.0" }// build.gradle.kts
dependencies {
implementation(libs.pomegranate.dealer)
}It's a Kotlin Multiplatform library — add it to commonMain in KMP projects and Gradle resolves
the right variant per target (JVM, Android, iOS, JS).
import io.github.nostrord.pomegranatedealer.FrostDealer
// Split a secret into 5 shards, any 3 can sign.
// randomScalar must return 32 CSPRNG-quality bytes per call (one per secret coefficient).
val shards = FrostDealer.deal(
secretKeyHex = "c90c269c4f8fcbe6880f72a721ddfbf1914268a346d2100b7eaec33b21ac1e3f",
threshold = 3,
count = 5,
randomScalar = { secureRandom32Bytes() },
)
shards.forEach { println(it.shardHex) } // 104-byte hex, ready for the operators
shards.forEach { println(it.pubShardHex) } // 39-byte public shard
// Recover the secret from any `threshold` shard hexes.
val secretHex = FrostDealer.aggregate(shards.take(3).map { it.shardHex })id (u16 LE) + vssCommitCount (u32 LE, always 0) + pubShard (33-byte compressed point) +
secret (32-byte BE scalar) + accountPubkey (33-byte compressed point). The public shard is
the first 39 bytes (id + count + pubShard).
- You supply randomness.
dealtakes arandomScalar: () -> ByteArrayso the caller owns CSPRNG selection. Pass platform-secure bytes (e.g.SecureRandom,SecKeyGenerate,crypto.getRandomValues); neverkotlin.random.Random. - Key material lifetime is the caller's job. The dealer works with hex strings and returns them; it does not zero buffers. Wipe secrets you no longer need.
- Not constant-time. The secp256k1 arithmetic here is for one-shot dealing, not for signing or any operation exposed to timing attackers.
- BIP340 even-y normalization means the recovered secret may be
n - original(same x-only public key), matching the reference library.
./gradlew jvmTest # runs the byte-exact cross-language vectors on the JVM
./gradlew jsNodeTest # same vectors on Node.js
./gradlew iosSimulatorArm64Test # same vectors on the iOS simulator (macOS only)The Unlicense — public domain.