Privacy primitives for the Flow blockchain. Current release: v0.5.5.
Send FLOW without revealing the amount. Run a private payroll. Accept donations with hidden values. All using your existing Flow wallet — no new tools required.
The Janus privacy stack is Cadence-first: privacy lives in Cadence-native flows that settle through Flow EVM. The EVM is the implementation detail, not the product surface. Think of it as a doorway: you stand on the Cadence side, and the ZK machinery lives quietly beneath the threshold.
Privacy, not anonymity. Sender and recipient addresses stay public on-chain. Only the transferred amount is hidden. OFAC sanctions screening is planned for mainnet (Chainalysis Oracle hook on wrap).
npm install @claucondor/sdkimport {
JanusFlow,
buildAmountDiscloseProof,
buildShieldedTransferProof,
generateBlinding,
flowToWei,
} from "@claucondor/sdk";
import { ethers } from "ethers";
// 1. Connect
const provider = new ethers.JsonRpcProvider("https://testnet.evm.nodes.onflow.org");
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
const flow = new JanusFlow();
await flow.connectWithSigner(wallet);
// 2. Wrap 5 FLOW — amount visible at the boundary, hidden everywhere after
const amountWei = flowToWei(5n); // 5 * 10^18
const blinding = generateBlinding(); // 128-bit secret — STORE LOCALLY
const wrapProof = await buildAmountDiscloseProof({ amount: amountWei, blinding });
await flow.wrap({
amountWei,
txCommit: wrapProof.txCommit,
amountProof: wrapProof.proof,
});
// 3. Shielded transfer — amount HIDDEN in calldata, events, and storage
const transferAmount = flowToWei(2n);
const transferBlinding = generateBlinding();
const newBlinding = generateBlinding(); // store: your new residual blinding
const xferProof = await buildShieldedTransferProof({
oldBalance: amountWei,
oldBlinding: blinding,
transferAmount,
transferBlinding,
newBlinding,
});
await flow.shieldedTransfer({
to: "0x000000000000000000000000000000000000Babe",
publicInputs: xferProof.publicInputs,
proof: xferProof.proof,
});
// 4. Unwrap — amount + recipient visible at the boundary
const exitAmount = flowToWei(3n); // your residual
const exitBlinding = generateBlinding();
const amountExit = await buildAmountDiscloseProof({ amount: exitAmount, blinding: newBlinding });
const transferExit = await buildShieldedTransferProof({
oldBalance: amountWei - transferAmount, // local accounting
oldBlinding: newBlinding,
transferAmount: exitAmount,
transferBlinding: exitBlinding,
newBlinding: 0n, // empties the slot
});
await flow.unwrap({
claimedAmountWei: exitAmount,
recipient: wallet.address,
txCommit: amountExit.txCommit,
amountProof: amountExit.proof,
transferPublicInputs: transferExit.publicInputs,
transferProof: transferExit.proof,
});For the visual explanation of the underlying primitives, see PrivateTip /learn.
Fee model: wrap and unwrap each carry a 0.1% boundary fee (10 bps, hard cap 100 bps). Shielded transfers between accounts are free — no fee is taken on in-pool transfers. Fee recipient is the admin COA; the fee is deducted from the gross amount before crediting the shielded slot.
| Channel | wrap | shieldedTransfer | unwrap |
|---|---|---|---|
| msg.value | LEAK (gross amount, boundary in) | HIDE (always 0) | N/A — function is non-payable |
| calldata | LEAK (netAmount as proof public input) | HIDE (only commitments + proofs) | LEAK (claimedAmount + recipient as proof public inputs) |
| events | Wrapped(sender, netAmount) + WrapWithSnapshot(sender, netAmount, encryptedBlob) |
ConfidentialTransfer(from, to) (no amount) + ShieldedTransferWithSnapshot(from, to, encryptedBlob) |
Unwrapped(sender, recipient, netToRecipient) + UnwrapWithSnapshot(sender, claimedAmount, encryptedBlob) |
| storage | commitment opaque (Pedersen) | commitment opaque | commitment opaque |
| commitment | 128-bit Pedersen blinding | 128-bit Pedersen blinding | 128-bit Pedersen blinding |
Wrap note:
msg.valueis the gross amount; the fee is deducted before the proof is verified.WrappedandWrapWithSnapshotboth emit the net amount (post-fee). The fee is inferrable sincefeeBpsis public.
Unwrap note:
unwrapis non-payable —msg.valueis always 0 at unwrap. The recipient receives the net via an internal FLOW transfer, which is visible as an internal transaction on the explorer regardless of what events are emitted.
Aggregate totalLocked is always public — external observers can audit the
pool size at any time. This is intentional boundary accounting.
Amount privacy on shielded transfers, transparency at boundaries — by design,
not by accident. Unwrap amounts are inherently public: the native FLOW transfer
to the recipient is an internal transaction visible on any block explorer.
Removing events would not make unwraps private — calldata, the internal value
transfer, the totalLocked storage delta, and the contract balance delta all
independently leak the amount. This is a property of EVM, not of this contract.
npm install @claucondor/sdkPeer dependencies (installed automatically):
ethers^6 — Flow EVM provider@onflow/fcl^1.13 — Cadence transactionscircomlibjs^0.1.7 — BabyJubJub + Pedersensnarkjs^0.7.6 — Groth16 proof generation
Three concrete confidential tokens ship with the SDK. Privacy semantics are identical across all three — the difference is the underlying asset and the wrapping mechanism.
| Token | Underlying | Recommended for |
|---|---|---|
JanusFlow |
Native FLOW | Cadence apps tipping / paying in FLOW |
JanusFTCadence |
Any FungibleToken vault | Cadence-native FT integrations |
JanusERC20 |
ERC20 (MockUSDC on testnet) | EVM-DeFi apps |
Most apps want JanusFlow.
Deployed as a UUPS proxy at 0x09A3DCa868EcC39360fDe4E22046eCfcbA5b4078 on
Flow EVM testnet, with a Cadence router façade at 0x5dcbeb41055ec57e. Users
sign normal Cadence transactions; the router orchestrates the cross-VM EVM call
via the signer's COA.
The full end-to-end walkthrough is in the Quick start above. Condensed notes on the caller's responsibilities:
- Persist every
(value, blinding)pair on the user's device. The contract stores only the Pedersen commitment — the cleartext balance lives locally. - Track
oldBalance/oldBlindingacross transfers. After eachshieldedTransferyour new balance isoldBalance − transferAmountand your new blinding isnewBlinding(the one you passed tobuildShieldedTransferProof). - Amount range:
transferAmount ≤ oldBalanceandamount ∈ [0, 2^128)— effectively unbounded for any realistic use.
Cadence-only confidential-amount wrapper. Deployed at 0xbef3c77681c15397.
import { JanusFTCadence, TX_FT_WRAP, TX_FT_SHIELDED_TRANSFER } from "@claucondor/sdk";
const ft = await new JanusFTCadence({ network: "testnet" }).configure();
const totalLocked = await ft.getTotalLocked();
const commit = await ft.balanceOfCommitment(someAddress);
// State-changing flows use FCL — pass the exported templates:
// await fcl.mutate({ cadence: TX_FT_WRAP, args: ... })Wraps an arbitrary ERC20 underlying. Deployed at
0xf2C04b1A32B815ac7Ffd87a4C312096592BBCa1e (pinned to MockUSDC — Flow EVM
testnet has no canonical USDC). Same shielded-transfer privacy as JanusFlow;
the wrap boundary is approve + transferFrom rather than msg.value.
import { JanusERC20 } from "@claucondor/sdk";
const usdc = new JanusERC20();
await usdc.connectWithSigner(wallet);
// Approve first: underlying.approve(usdc.address, amount)
await usdc.wrap({
amountRaw: 1_000_000n, // 1 USDC at 6 decimals
txCommit: [proof.commit.x, proof.commit.y],
amountProof: proof.proof,
});
await usdc.shieldedTransfer({ to, publicInputs, proof });Every shielded transfer creates a Pedersen commitment delta C_tx — but the
recipient only receives an elliptic-curve point. To later spend or unwrap, they
need the plaintext (amount, blinding) pair that produced it. ShieldedNote is
the canonical encrypted channel for shipping that payload alongside the transfer.
import {
encryptShieldedNote,
decryptShieldedNote,
generateBabyJubKeypair,
} from "@claucondor/sdk";
// Sender: encrypt the note to the recipient's BabyJub pubkey
const note = {
amount: transferAmount, // bigint, wei
blinding: transferBlinding, // bigint, 128-bit secret
data: "Thanks for the help last week!", // optional UTF-8 app payload
};
const ciphertext = await encryptShieldedNote(note, recipientPubkey);
// Attach ciphertext to your shielded transfer transaction
// Recipient: decrypt on arrival
const decoded = await decryptShieldedNote(
ciphertext.ciphertext,
ciphertext.ephemeralPubkey,
recipientPrivkey,
);
// decoded.amount, decoded.blinding, decoded.data — all availableThe wire format is versioned JSON ({"v":1,"a":"...","b":"...","d":"..."})
encrypted with ECIES + AES-GCM over BabyJubJub.
PrivateTip uses this to carry the memo text + blinding end-to-end. For the
theory, see the /learn page in the PrivateTip demo.
generateBabyJubKeypair() generates a fresh random scalar every call — correct
for ephemeral ECIES keys, wrong for a user's persistent MemoKey. The sign-derive
pattern solves this: derive the MemoKey deterministically from a wallet signature
so any device with the same wallet recovers the same key.
import { deriveBabyJubKeypairFromBytes } from "@claucondor/sdk";
import { ethers } from "ethers";
// Prompt the user to sign a fixed message — the signature is the entropy source
const sig = await wallet.signMessage("Janus MemoKey v1");
const sigBytes = ethers.getBytes(sig); // 65-byte Uint8Array
// Same wallet → same keypair, on any device, forever
const memoKey = await deriveBabyJubKeypairFromBytes(sigBytes, "openjanus/memokey/v1");
// { privkey: bigint, pubkey: { x: bigint, y: bigint } }
// Publish memoKey.pubkey so senders can encrypt notes to you
// Keep memoKey.privkey in memory only — never persisted, never on-chainThe context string provides domain separation — you can derive independent keys from the same signature:
"openjanus/memokey/v1" → persistent memo-encryption key
"openjanus/viewkey/v1" → read-only audit key (future)
"openjanus/spendkey/v1" → spend-authorization key (future)
Under the hood: HKDF-SHA256 over 64 bytes, reduced mod the BabyJub subgroup order, giving < 2^-127 statistical bias. Same pattern used by Phantom / Argent / Rabby for non-custodial encrypted messaging.
@claucondor/sdk
├── tokens/ JanusToken (abstract), JanusFlow (recommended),
│ JanusFTCadence, JanusFlowCadence (router helper),
│ JanusERC20 (advanced)
├── crypto/ buildAmountDiscloseProof, buildShieldedTransferProof,
│ computeCommitmentV05, encryptShieldedNote, decryptShieldedNote,
│ deriveBabyJubKeypairFromBytes, generateBabyJubKeypair,
│ generateBlinding, encryptText, decryptText, flowToWei, weiToFlow
├── primitives/ BabyJub, Pedersen, Groth16 (low-level)
├── network/ createEvmProvider, createEvmWallet, COA helpers
└── utils/ formatPoint, isValidFlowAddress, parseFlowToWei, hex helpers
| Contract | Network | Address |
|---|---|---|
| JanusFlow proxy | Flow EVM testnet | 0x09A3DCa868EcC39360fDe4E22046eCfcbA5b4078 |
| JanusFlow impl | Flow EVM testnet | 0xa2607E9EAb1718a2fAf5a1328A7d3a9Aa854efff |
| AmountDiscloseVerifier | Flow EVM testnet | 0x9c83b2b1EFFD3bd375b9Bee93Cb618005D6A2Dc4 |
| ConfidentialTransferVerifier | Flow EVM testnet | 0x48f791D2a4992F448Cc36F12e5500b6553e969b3 |
| BabyJub.sol | Flow EVM testnet | 0x27139AFda7425f51F68D32e0A38b7D43BcB0f870 |
| JanusFlow.cdc router | Flow Cadence testnet | 0x5dcbeb41055ec57e |
| JanusFTCadence | Flow Cadence testnet | 0xbef3c77681c15397 |
| JanusERC20 proxy | Flow EVM testnet | 0xf2C04b1A32B815ac7Ffd87a4C312096592BBCa1e |
| JanusERC20 impl | Flow EVM testnet | 0x7FE0B05ED77E0540519B6f10DD4b4521e867590D |
| MockUSDC (test underlying) | Flow EVM testnet | 0x3e8973dE565743Ef9748779bE377BBE050A13C22 |
| Admin owner (COA, EVM) | Flow EVM testnet | 0x0000000000000000000000022f6b30af48a94787 |
The Groth16 verifiers are backed by a two-phase ceremony:
- Phase 1 (universal):
powersOfTau28_hez_final_18.ptau— Hermez pot18 transcript, 200+ contributors via the Polygon community. Canonical source:https://storage.googleapis.com/zkevm/ptau/. - Phase 2 (circuit-specific): one named contributor, entropy from
openssl rand -hex 32not logged per protocol. - Beacon randomness: Flow VRF, testnet block
324,226,714, block ID6e470bc1fc410b1a12b72991da0a8b4d7cfc5c8872eff0a3d57ae0c8ecffdc7a. - Full provenance: contribution hashes, beacon hash, and
ZKey Ok!verification results live incircuits/CEREMONY-RECORD.json(shipped with the SDK).
# Unit tests (no network, ~3 seconds; ~30s with real proofs)
npm test
SKIP_PROOF_TESTS=1 npm test # skip the snarkjs proof tests
# Integration tests (read-only Flow testnet)
RUN_INTEGRATION=1 npm run test:integration
# All
npm run test:allMIT — oydual3 claucondor@gmail.com