Introduction

Lucent is a confidential payments layer built on Stellar. It lets you send, receive, run payroll, and lock escrow while keeping every amount hidden on-chain — visible to no one except the parties you authorize, not even validators.

Not production ready. The UltraHonk verifier backend and the circuits are unaudited, and the escrow custody model carries a documented trust caveat (see Encrypted Escrow, below). Lucent runs on Stellar testnet only — do not use it with real value.

The trust model

Lucent doesn't rely on trusted relayers or off-chain custody. Privacy comes from zero-knowledge proofs generated in your browser and verified natively by Stellar's Protocol 26 host functions. There is no operator key that could be compromised, and no bridge holding your funds.

The privacy model

Lucent encrypts amounts only. Counterparty addresses remain fully public, so on-chain auditability is preserved — you can verify that a payment moved from wallet A to wallet B without knowing how much. That is the right trade-off for payments and payroll: the who is public; the how much is private.

Every balance is a Pedersen commitment on the Grumpkin curve, C = v·G + r·H. The network only ever sees commitments and the UltraHonk proofs that the arithmetic behind them is correct — no plaintext amount ever appears in a transaction, event, or contract state entry.

How it Works

Every confidential account holds a spendable balance (what you can send or withdraw) and a receiving balance (where deposits and incoming transfers land). A merge folds receiving into spendable — a homomorphic point addition that needs no proof.

register    proof  Bind your Grumpkin keys to the contract (one-time)
deposit     —      Public USDC -> your receiving balance
merge       —      Fold receiving -> spendable
withdraw    proof  Spendable -> public USDC
transfer    proof  Spendable -> another account's receiving balance

Every transfer also emits dual auditor ciphertexts — one for the sender's channel, one for the recipient's — so the registered auditor key can decrypt every amount without either party's cooperation.

Deposit & Withdraw

Register (one-time)

Before any of this, Shield derives a Grumpkin key set deterministically from a Freighter message signature — the same keys every session, nothing extra to back up — and proves ownership of it with a zero-knowledge proof that binds the key set to your Stellar address on the token contract. This happens once per account; every confidential operation below depends on it having already happened.

Deposit

Go to Shield and deposit. This moves public USDC into your confidential receiving balance at a 1:1 ratio — no proof required, since the deposit commitment has zero blinding. The deposit amount is a public plaintext i128 in USDC base units (7 decimals, so 1 USDC = 10,000,000 base units). Merge it into spendable before sending or withdrawing.

Spendable and receiving are shown as two separate numbers deliberately: a deposit (or an incoming transfer) counts as yours the moment it lands, but it isn't spendable or withdrawable until you merge it in.

Withdraw

Withdrawing is a two-step, in-browser flow: a withdraw proof is generated locally (this takes a few seconds — you're proving "I can open this commitment to a value at most my balance" without revealing the balance itself), then submitted on-chain. The Soroban verifier checks the proof and converts your spendable balance back into public USDC.

Private Payments

Go to Send, choose a registered recipient, and enter an amount. A transfer proof is generated in your browser and submitted with the transaction — the recipient address is visible on-chain; the amount is not.

Why only registered recipients

Building the transfer proof requires the recipient's public viewing key (PVK), which only exists on-chain once they've completed the one-time register step themselves. Send only lists addresses the app has already seen register — an address with no PVK has nowhere for the proof to encrypt an amount into.

What the proof actually proves

confidential_transferproves, without revealing any of the three numbers involved: "I know the opening of my spendable commitment, it covers at least this amount, and here are two new commitments — one for what I'm sending and one for what stays in my own balance." The proof also seals the amount into two ciphertexts, one per auditor channel (sender's and recipient's), so a registered auditor can decrypt it later even though no one else can.

The sent amount lands in the recipient's receiving balance, not spendable — same as a deposit, they merge it in whenever they like.

Confidential Payroll

PayrollVaultis an orchestrator, not a custodian: an employer creates a template of employees and opens a run. Salaries never touch chain storage — at execution the employer's browser proves one confidential transfer per employee, and the vault routes them atomically. No employee can read another's salary, and the vault itself never sees a plaintext amount either.

create_template(employer, employees) -> template_id
create_run(template_id)               -> run_id
fund_run(run_id)
execute_run(run_id, transfers)        // one proof per employee
cancel_run(run_id)
claim()                               // employee folds salary into spendable

Employer flow

Create a template once with the employee list — templates carry no amounts, just who gets paid. Create a run against it to open a Scheduled run. Fund the runrecords that the employer's confidential spendable balance covers the total — a state marker, not a transfer, since the vault can't read an encrypted balance to lock anything against it.

Execute the runis where the money actually moves: the employer enters each salary, and the browser builds one transfer proof per employee, chained — employee 2's proof spends the balance opening left over after employee 1's, and so on, so the whole batch is internally consistent. All proofs submit in one transaction; the vault runs every transfer and only marks the run Executed if all of them succeed — no partial payroll runs. A Scheduled run can be cancelled any time before execution.

Employee flow

A paid employee sees the salary appear in their receiving balance and merges it into spendable with Claim, same merge as anywhere else — no proof needed.

Compliance

Every salary transfer is an ordinary confidential_transfer, so it carries the same dual auditor ciphertexts as a Send. Register the employer as the deployment's auditor and every salary they've ever paid becomes decryptable to them on the Auditor screen — while each employee still only ever sees their own.

Encrypted Escrow

PrivateEscrowis custodial, unlike Payroll: the funds actually need to sit somewhere confidential between funding and release, and a confidential balance only exists at a contract address, so each escrow deploys its own tiny instance contract just to hold it — its own confidential account, isolated from every other escrow's.

Depositor creates -> funds escrow      (CREATED -> FUNDED)
Recipient delivers -> mark_completed   (FUNDED -> COMPLETED)
Depositor reviews -> release           (COMPLETED -> RELEASED)

If depositor stalls past the release window:
  With arbiter    -> recipient disputes    (DISPUTED, arbiter resolves)
  Without arbiter -> recipient claims      (auto RELEASED)

No delivery -> depositor waits for timeout -> reclaims (REFUNDED)

The two clocks

Two independent, non-overlapping timers gate the escrow — one per failure mode:

Timeout protects the depositor if the recipient never engages at all. Chosen per-escrow at creation — 1h, 24h, 7d, or 30d — and only live while still Funded: once the recipient marks delivery, the timeout path is permanently blocked, so a stale timeout can never undercut a delivery that's already been confirmed.

Release window protects the recipient if the depositor stalls after delivery is confirmed. Fixed at 10 minutes from mark_completed, not configurable per-escrow. Both deadlines are enforced by the contract itself, not the app — calling an action before its deadline simply reverts on-chain. The Escrow screen shows a live countdown to whichever deadline currently applies, and disables the corresponding action (Timeout Refund / Claim / Dispute) until it actually opens, so there's no guessing at when a call will succeed.

Funding: two calls, not one

Funding is the expensive step, proof-wise — the depositor derives a one-time Grumpkin identity for the instance itself and builds four proofs: a register proof for that identity, a transfer-in proof (depositor → instance), and — because the instance can never generate a proof on its own later — both possible payout proofs up front, instance → recipient and instance → depositor. At roughly 14KB each, all four together exceed what fits in one Soroban transaction, so funding is two calls (two wallet confirmations, back to back):

store_payout_proofs(release_proof, refund_proof)   // stored first
fund(register_data, auditor_id, transfer_in)       // then this moves the money

fund checks the payout proofs are already stored before it will run, so the escrow can never end up Fundedwithout a working settlement path already in place. Once funding completes, the depositor discards the instance's one-time secret — see the trust caveat below.

Settlement

Every payout proof was generated once, at funding time, against a fixed opening. That's what lets the instance submit one of them unattended, whenever the state machine says so: no one needs to be online or hold a key at settlement time. On the happy path the recipient marks delivery, a 10-minute release window opens, and the depositor releases. If the depositor stalls, the recipient either self-serves after the window (no arbiter) or escalates to a dispute (arbiter configured) — the arbiter resolves by state transition alone and never sees the amount.

Trust caveat

To pre-generate the two payout proofs, the depositor must derive the instance's Grumpkin secret at fund time. They're expected to discard it immediately afterward — a depositor who keeps it could re-spend the escrowed balance and invalidate both stored proofs. Acceptable for a testnet demo; not a production-grade custody model.

Selective Disclosure

The Auditor screen gives one party standing access to everything under an auditor id — the right tool for a compliance relationship, wrong for a one-off. Selective disclosure answers a narrower need: proving one specific paymentto someone who shouldn't get a decrypt key to your whole history — an accountant who needs one receipt, a landlord who wants proof of one rent payment. It's a proof, generated and verified entirely off-chain — there's no on-chain disclosure verifier, the chain is only ever read from, never written to, for this flow.

Two claims

D-recipient— "this on-chain transfer paid me exactly this amount." Available for anything you received; you can already decrypt it with your own viewing key.

D-sender— "I sent this on-chain transfer for exactly this amount." This one needs more: you re-derive the one-time ephemeral scalar your wallet used at send time from data still on the event itself, then prove you can reconstruct what the recipient decrypted. If that scalar isn't recoverable (old local state, a different device), the transfer isn't disclosable as a sender — your Home activity feed marks it accordingly.

How it works

On the Verifyscreen (no wallet required — this can be anyone, even someone with no Stellar account at all), click "Create request": a fresh public key and nonce, generated locally, that binds whatever proof comes back to this request specifically so it can't be replayed against someone else. Copy the resulting JSON and send it to the holder however you'd normally share a file.

The holder pastes that JSON against the relevant transfer right in their Homeactivity feed — a "Prove" action sits next to any transfer that's disclosable. The browser generates a zero-knowledge proof bound to it — a few seconds of in-browser proving — and produces a bundle to copy back. Paste the bundle into the Verify screen to check it.

Why the verifier doesn't have to trust the bundle

The verifier never takes the holder's word for anything except the proof and one sealed ciphertext. It doesn't trust the bundle for who sent the transfer, who received it, or what the transaction even was — it re-reads the actual event from the chain by the bundle's event reference, re-reads the relevant account's public viewing key from the token contract, and re-derives every other public input from that independently-fetched chain state. Only then does it check the proof, and only after the proof checks out does it decrypt the sealed amount with the verifier's own secret key — never the holder's. The circuit's range and binding constraints mean a holder can't get a false amount past the proof by construction.

Auditor View Key

Every account registers under an auditor id. The holder of that Grumpkin secret key can decrypt every transfer amount and balance checkpoint on the Auditorscreen — the institutional compliance primitive. Nobody else can. The screen needs no wallet: paste the secret into the unlock field, and it stays in page state only — never written to local storage, never sent anywhere, gone the moment you navigate away or reload. The app's shipped bundle does not contain a working default; that's deliberate, so no one gets auditor access just by loading the page.

Once unlocked, the console fetches every transfer/withdraw event under that auditor id, decrypts each one, and replays the stream into a running per-account view: spendable balance from the sender-channel checkpoints, receiving balance as the sum of decrypted inbound transfers plus public deposits, folded on merge.

Try it — demo key (testnet only)

This deployment's registered auditor id 0 key, so you can try the console without deploying your own stack. Paste the secretvalue into the Auditor screen's unlock field.
secret:  0x002db306c1ef9309a478294fc1f14b2e9b112052d3d5b8ce7e5c0f1c69bb2804
K_aud.x: 0x2414ea8f68ff9c6ab9c49a5113cbee002c5646fcea36757e8108b1edcf82d4d0
K_aud.y: 0x0ad237715304822d5c532c31a1afbd83b690f34c8e5af01bd9b47b3271e5ac4d

This key decrypts every confidential balance and transfer registered under auditor id 0 on this specific testnet deployment. There's nothing sensitive behind it — testnet, no real value, and the whole point of this screen is that this is what an auditor is meant to see — but it is real key material for a live deployment, not a placeholder. It stops applying the moment this stack is redeployed (a fresh random key is generated each time); check the Contracts section below for which deployment is current.

Compliance Policy

Beyond the auditor's standing visibility into amounts, the token contract carries two independent, on-chain-enforced compliance primitives: an allowlist policy and per-account freezing. Both are gated behind a single compliance admin address set at deploy time — nobody else can call either, and the contract enforces that itself rather than relying on the app to hide the buttons.

Allowlist. A separate policy contract holds a simple allow/deny registry. When wired onto the token, every deposit, transfer, receive, and withdraw checks the policy first — an account not on the list is rejected on-chain, before any amount is touched. This is the primitive an institution needs for KYC-gated payroll: onboard employees onto the allowlist once, and every payroll run after that is automatically restricted to verified accounts, with no per-transaction compliance check outside the contract itself.

Freezing. Independent of the allowlist, the compliance admin can freeze a specific account outright — it can no longer deposit, transfer, receive, or withdraw until unfrozen, regardless of allowlist status. Useful for responding to a flagged account without having to touch the broader allowlist.

Both are managed from the Auditorscreen's Compliance panel: connect the compliance-admin's wallet, paste an address, and allow/remove or freeze/unfreeze it. The panel also shows the live allowlist/frozen status for any address you check.

Contracts

All contracts are deployed on Stellar testnet. Source is available on GitHub, built on OpenZeppelin/stellar-contracts.

FAQ

Can anyone see my balance?

No. Your balance is a Pedersen commitment on-chain. The plaintext openings live only in events, reconstructed and persisted locally by your browser's state engine — never broadcast in the clear.

Does the escrow arbiter see the locked amount?

No. The arbiter resolves disputes by state transition alone; the amount stays hidden through the entire lifecycle.

Auditor console vs. selective disclosure — which one do I want?

Auditor if you need standing, ongoing visibility into every amount under an auditor id — the compliance relationship Payroll is built around. Selective disclosure if you need to prove just one payment to one party who shouldn't get broader access — see Selective Disclosure above.

Is Lucent audited?

No. Lucent runs on Stellar testnet and the UltraHonk verifier and circuits are unaudited. Do not use it with real value.

What happens if I lose my wallet?

Confidential keys are derived deterministically from a Freighter signature, so they can be re-derived from the same wallet. Local balance history persisted in your browser is not recoverable if lost, but re-syncing from chain events reconstructs it within the RPC's ~7-day retention window.

Where is the source code?

The contracts and frontend are open source on GitHub.