In this tutorial I’ll be walking you through how I built a zkLogin system on stellar using BLS ring signatures in a Soroban smart contract.
BLS & Ring Signatures
Stellar smart contracts have native BLS12_381 functions (env.crypto().bls12_381()) which enable us to verify aggregated signatures on-chain.
BLS differs from the standard signatures we use in our wallets because it enables us to aggregate lots of individual signatures into a single group signature and verify that instead of having to verify each one individually.
Ring signatures take this a step further and if you imagine a group of people standing in a circle. Any one of these people can sign a message and prove they are a member of the ring. Their signature can be verified that it came from the ring without disclosing which member signed the transaction.
This provides “accountability without exposure” and technology like this is enabling things like anonymous authentication, private transactions, anonymous voting etc.
The ring itself is a list of public keys, this forms a public list of who is in the group. With a zkLogin system this would be a public list of BLS public keys for who has access to the site. With anonymous transactions this might be a list of who has rights to a pool of funds.
When someone wants to prove they are a member of the group they sign with their BLS private key. This works via a circular challenge response loop that links all the keys together. The math ensures the loop only closes correctly if one valid private key was used.
Why BLS?
- Aggregate n signatures > 1 curve point
- Deterministic – hashing to the curve is trivial
- Verification is pairing based and therefore cheap when the host does it for us
A ring signatures takes this a step further and hides which member signed.
The Contract (lib.rs)
This contract has helper functions in to generate keys etc. Note that you wouldn’t use a public RPC node to generate keys or sign a transaction in production. This would be done off chain and you would pass through the mesage and signature only.
Full code is on Github at: https://github.com/jamesbachini/Stellar-BLS/blob/main/contracts/src/lib.rs
Let’s look at a unit test to walk through the logic:
#[test]
fn ring_signature_roundtrip_and_login_count() {
let env = Env::default();
let bls = env.crypto().bls12_381();
let gen_g = G1Affine::from_bytes(BytesN::from_array(&env, &super::G1_GENERATOR));
let mut sks: std::vec::Vec<Fr> = std::vec::Vec::new();
let mut ring: Vec<BytesN<96>> = Vec::new(&env);
for _ in 0..3 {
let sk = Fr::from_bytes(BytesN::from_array(&env, &random_32()));
let pk = bls.g1_mul(&gen_g, &sk).to_bytes();
sks.push(sk);
ring.push_back(pk);
}
let signer = 1usize;
let msg = Bytes::from_slice(&env, b"Free Roman Storm");
let sig = sign(&env, &msg, ring.clone(), signer, &sks[signer]);
let id = env.register(RingSigContract, ());
let client = RingSigContractClient::new(&env, &id);
client.init(&ring);
assert!(client.verify(&msg, &sig));
assert_eq!(client.get_login_count(), 1);
assert!(client.verify(&msg, &sig));
assert_eq!(client.get_login_count(), 2);
}
Here we are populating two vectors, one with secret keys using random bytes and then deriving a public key from this for the ring.
We then sign a message before setting up the contract and intializing it with the public keys in the ring. From there we call client.verify with the message and signature to increase the login count which is a state variable in the contract protected by the verify function.
Front End (index.html)

Full code is on Github at: https://github.com/jamesbachini/Stellar-BLS/blob/main/frontend/index.html
We can clone this and run it locally using:
git clone https://github.com/jamesbachini/Stellar-BLS.git
cd Stellar-BLS
npm install
npm run dev
I had issues using the standard noble/bls and chainsafe npm libraries so ended up using the simulate tx method to generate keys directly from the contract. (Don’t do this in production)
Here are the functions to generate the ring and sing transactions:
$("genRing").onclick = async ()=>{
if(!$("cid").value.trim()) return alert("Enter contract id first!");
contract = new StellarSdk.Contract( $("cid").value.trim() );
const N = parseInt($("ringSize").value)||3;
setStatus("Simulating create_keys()");
const res = await simulate("create_keys", U32(N));
keyRing = StellarSdk.scValToNative(res);
setStatus("Sending init() transaction");
await invoke("init", Vec(keyRing.ring.map(Bytes)));
$("walletBtns").innerHTML = "<b>Wallets:</b><br>";
keyRing.secret_keys.forEach((sk,i)=>{
const b = document.createElement("button");
b.textContent = `Sign-in with wallet ${i}`;
b.classList = 'btn'
b.onclick = ()=>login(i);
$("walletBtns").appendChild(b);
$("walletBtns").appendChild(document.createTextNode(" "));
});
await refreshCounter();
setStatus("Ring initialised on-chain ✅");
};
async function login(idx){
clearStatus();
const msg = Bytes(new TextEncoder().encode("zkLogin"));
setStatus(`Simulating sign() for wallet ${idx}`);
const sigXdr = await simulate("sign",
msg,
Vec(keyRing.ring.map(Bytes)),
U32(idx),
Bytes(keyRing.secret_keys[idx])
);
const sigJS = StellarSdk.scValToNative(sigXdr); // {challenge,responses}
const sigVal = Map([
["challenge", Bytes(sigJS.challenge)],
["responses", Vec(sigJS.responses.map(Bytes))]
]);
setStatus("Sending verify() tx …");
const txRes = await invoke("verify", msg, sigVal);
console.log(txRes.returnValue)
const ok = StellarSdk.scValToNative(txRes.returnValue);
if(ok){ setStatus("✅ login successful", true); }
else { setStatus("❌ verify returned false", false); }
await refreshCounter();
}
We start by generating a keyring by calling create_keys(n), then we pass the public keys to init(ring), the frontend then generates a sign in with wallet n for each keypair.
The login function signs a message and sends this to the verify(msg, sig) function in the contract which returns true/false.
Use Cases & The Future of On Chain Privacy
zk Login / token gated dApps
Keep a public list of authorised BLS public keys on contract storage.
Users sign the string “login” with a ring signature; the contract increments a login counter or returns a JWT style capability.
Anonymous payments / mixers
Fund a contract with a set of deposits. Withdrawals require a ring signature over the UTXO set, guaranteeing withdrawals come from legitimate depositors while unlinking which one.
DAO secret ballots
The ring is the token holder snapshot. Ballot contracts accept one ring signature per proposal, counting membership without revealing who voted.
Rate limiting without accounts
Think of a faucet that allows “one claim per key per day” without tracking addresses in storage – only the ring itself.
Because Soroban brings the BLS12-381 host functions to every validator for free, developers no longer need to compromise between privacy and feasibility. The example above stays within the current CPU & memory limits and can be deployed to Testnet today.
You don’t need an external zk library – Soroban gives you deterministic curve ops. Do your heavy math locally or using simulateTx, then send the result on chain for cheap verification.
Store only what must be public (the ring, counters, etc.); everything else remains client side. Ring signatures unlock many UX patterns that were previously impossible on public ledgers.
Privacy is a human right ✊


