Reference implementation showing how a Kotlin app using the Breez SDK can delegate all signing operations to a remote TypeScript server running the Spark JS SDK.
The Kotlin side never holds private keys. The TS server is the key custodian.
┌────────────────────────┐ HTTP/JSON ┌────────────────────────┐
│ Kotlin Client │ ──────────────────> │ TS Enclave Server │
│ (Breez SDK) │ <────────────────── │ (Spark JS SDK) │
│ │ │ │
│ EnclaveSignerClient │ base64-encoded │ UnsafeStateless │
│ implements │ byte arrays │ SparkSigner │
│ ExternalSigner │ │ │
└────────────────────────┘ └────────────────────────┘
The Breez SDK's ExternalSigner interface has ~20 methods covering ECDSA, Schnorr, FROST signing, ECIES encryption/decryption, key derivation, and secret management. EnclaveSignerClient implements every method by serializing inputs as base64 JSON, POSTing to the TS server, and deserializing the response.
FROST nonce round-tripping. The Spark JS SDK's getRandomSigningCommitment returns both the commitment (public, sent to counterparty) and the nonce (private, needed later to produce the signature share). Since the server is stateless, it returns the nonce alongside the commitment. EnclaveSignerClient.generateRandomSigningCommitment encodes the nonce as hiding (32 bytes) || binding (32 bytes) into ExternalFrostCommitments.noncesCiphertext. When signFrost is called later, it extracts those nonces from noncesCiphertext and forwards them to the server's /sign-frost endpoint.
Stateless server design. UnsafeStatelessSparkSigner holds no per-request state between calls. All signing context (including nonces) travels with the request. In a production deployment the nonces would be encrypted with a server-side key (ECIES) before leaving the server, so noncesCiphertext would be an actual ciphertext the client cannot read or tamper with.
- Java 17+
- Gradle (wrapper included)
- Node.js 20+, npm
- A BIP39 mnemonic (12 or 24 words) for the signer
cd js
npm install
SIGNER_MNEMONIC="word1 word2 ... word12" npm run devThe server starts on port 3000 by default. Set PORT to override.
cd javaEach flow is a standalone script. Use -PmainClass=... to select:
# View balance (default)
./gradlew run
# Create a Bolt11 invoice for receiving
AMOUNT_SATS=1000 ./gradlew run -PmainClass=com.example.signer.CreateInvoiceKt
# Pay a Bolt11 invoice
BOLT11=lnbc... ./gradlew run -PmainClass=com.example.signer.PayInvoiceKt
# Claim incoming transfers (syncs and shows recent payments)
./gradlew run -PmainClass=com.example.signer.ClaimTransferKtSet ENCLAVE_URL to override the default http://localhost:3000.
java-js-signer/
├── js/
│ ├── package.json # @buildonspark/spark-sdk dependency
│ ├── tsconfig.json
│ └── src/
│ ├── server.ts # Express server — all ExternalSigner endpoints
│ └── signer-service.ts # Thin wrapper around UnsafeStatelessSparkSigner
└── java/
├── build.gradle.kts # Breez SDK 0.12.1, OkHttp 4, kotlinx-serialization
├── settings.gradle.kts
└── src/main/kotlin/com/example/signer/
├── EnclaveSignerClient.kt # ExternalSigner → HTTP proxy (key file)
├── HttpClient.kt # OkHttp wrapper with base64 helpers
├── SdkSetup.kt # Shared SDK connection logic
├── ViewBalance.kt # Show wallet balance (default)
├── CreateInvoice.kt # Create a Bolt11 invoice
├── PayInvoice.kt # Pay a Bolt11 invoice
└── ClaimTransfer.kt # Sync and claim pending transfers
Every ExternalSigner method maps to a POST endpoint. All byte fields in request and response bodies are base64-encoded strings.
| ExternalSigner method | Endpoint | Request fields | Response fields |
|---|---|---|---|
identityPublicKey |
/identity-public-key |
(none) | bytes |
derivePublicKey |
/derive-public-key |
path |
bytes |
getPublicKeyForNode |
/get-public-key-for-node |
id |
bytes |
signEcdsa |
/sign-ecdsa |
message, path |
bytes |
signEcdsaRecoverable |
/sign-ecdsa-recoverable |
message, path |
bytes |
signHashSchnorr |
/sign-hash-schnorr |
hash, path |
bytes |
decryptEcies |
/decrypt-ecies |
ciphertext, path |
bytes |
encryptEcies |
/encrypt-ecies |
message, path |
bytes |
hmacSha256 |
/hmac-sha256 |
message, path |
bytes |
generateRandomSigningCommitment |
/generate-random-signing-commitment |
(none) | commitment{hiding,binding}, nonce{hiding,binding} |
signFrost |
/sign-frost |
message, keyDerivation, publicKey, verifyingKey, selfCommitment, statechainCommitments?, adaptorPubKey? |
bytes |
aggregateFrost |
/aggregate-frost |
message, publicKey, verifyingKey, selfCommitment, statechainCommitments?, selfSignature, statechainSignatures?, statechainPublicKeys?, adaptorPubKey? |
bytes |
staticDepositSecret |
/static-deposit-secret |
index |
bytes |
staticDepositSigningKey |
/static-deposit-signing-key |
index |
bytes |
staticDepositSecretEncrypted |
/static-deposit-secret-encrypted |
index |
(secretSource object) |
publicKeyFromSecret |
/public-key-from-secret |
source (keyDerivation) |
bytes |
generateRandomSecret |
/generate-random-secret |
(none) | bytes |
subtractSecrets |
/subtract-secrets |
signingKey, newSigningKey |
(secretSource object) |
splitSecretWithProofs |
/split-secret-with-proofs |
secret, threshold, numShares |
shares[] |
encryptSecretForReceiver |
/encrypt-secret-for-receiver |
ciphertext, receiverPublicKey |
bytes |
This demo runs over plain HTTP with no authentication. Before deploying in production:
- TLS / mTLS — Terminate TLS at the server. Use mutual TLS so only known Kotlin clients can connect.
- Authentication — Add an
Authorizationheader (e.g., HMAC-signed request timestamps) to prevent replay attacks. - FROST nonce encryption — Replace the plain
hiding||bindingencoding innoncesCiphertextwith ECIES encryption under a server-held key. The client should treatnoncesCiphertextas an opaque blob. - Enclave attestation — Run the TS server inside a TEE (AWS Nitro Enclaves, Azure Confidential Containers, etc.) and verify the attestation document before trusting the server.
- KMS-backed key storage — Replace the in-memory mnemonic with a KMS-sealed seed that is unsealed only inside the enclave.
- Rate limiting — Limit signing requests per client identity to contain blast radius if a client is compromised.