Skip to main content
Cyphers uses x25519 Diffie-Hellman key exchange and the Rescue hash function (RescueCipher) to encrypt bet inputs before they are written to Solana. The placeBet action handles all of this automatically - you only need these utilities if you are building a custom bet submission flow, a position viewer, or a tool that needs to inspect encrypted positions client-side.
client.actions.placeBet calls createUserKeypair, fetchMxePublicKey, createCipher, and encryptBetInput internally. You do not need to call these functions manually for a standard bet placement. These utilities are documented here for advanced integrations such as batch bet tools, custom UI overlays, or automated resolvers that need to pre-check winner eligibility.

Privacy model

When a bet is placed, the following steps happen client-side before any transaction is sent:
  1. createUserKeypair() - generates a fresh x25519 keypair. The public key is stored on-chain in the position account; the private key never leaves the client.
  2. fetchMxePublicKey(client) - fetches the Arcium MXE cluster’s public key (cached after first call).
  3. createCipher(privateKey, mxePublicKey) - derives a shared secret and constructs a RescueCipher instance.
  4. encryptBetInput({ side, amount, ... }) - encrypts the side and net amount into two 32-byte ciphertexts.
  5. The ciphertexts, your x25519 public key, and a nonce are submitted on-chain. The Arcium MPC cluster later uses its private key to derive the same shared secret and decrypt your bet during settlement.
User x25519 private key  ──┐
                            ├─ ECDH ──► shared secret ──► RescueCipher
MXE x25519 public key   ──┘

encryptBetInput(side=1, amount=5_000_000n)
  → encryptedSide   (32 bytes, on-chain)
  → encryptedAmount (32 bytes, on-chain)

createUserKeypair

Generates a fresh x25519 keypair for encrypting a single bet. Each bet should use its own keypair.
import { createUserKeypair } from "@cypher-zk/sdk";

const keypair = await createUserKeypair();
// keypair.privateKey  - CryptoKey, exportable, never transmitted
// keypair.publicKey   - CryptoKey, sent on-chain in position.userPubkey
Returns Promise<UserCryptoKeypair> - a UserCryptoKeypair wrapping the x25519 key pair used for ECDH encryption.
Persist keypair.privateKey immediately. Export and store it securely (e.g., encrypted local storage or a hardware wallet). There is no recovery path - if you lose this key you can still claim funds but cannot decrypt your position to verify which side you chose.
const exported = await crypto.subtle.exportKey("raw", keypair.privateKey);
localStorage.setItem(`bet:${marketId}:${betIndex}`, bufferToHex(exported));

fetchMxePublicKey

Fetches the Arcium MXE (Multi-party eXecution Environment) cluster’s x25519 public key. The result is cached after the first call per CypherClient instance.
import { fetchMxePublicKey } from "@cypher-zk/sdk";

const mxePublicKey = await fetchMxePublicKey(client);
if (!mxePublicKey) {
  throw new Error("MXE public key unavailable - check cluster connectivity");
}
client
CypherClient
required
An initialised CypherClient instance. The function reads client.cluster to target the correct Arcium deployment.
Returns Promise<Uint8Array | null> - 32-byte raw x25519 public key bytes, or null if the MXE account is not found.

createCipher

Derives the ECDH shared secret and returns a RescueCipher instance ready for encryption or decryption.
import { createCipher } from "@cypher-zk/sdk";

const cipher = createCipher(userPrivateKeyBytes, mxePublicKeyBytes);
// cipher is a RescueCipher bound to the shared secret
userPrivateKey
Uint8Array
required
Raw 32-byte x25519 private key bytes (not the CryptoKey object - export it first with crypto.subtle.exportKey("raw", key)).
mxePublicKey
Uint8Array
required
Raw 32-byte MXE x25519 public key bytes from fetchMxePublicKey.
Returns RescueCipher - a cipher instance. Use it with encryptBetInput or decryptBetInput.

encryptBetInput

Encrypts a (side, amount) pair into two 32-byte ciphertexts. This is what gets stored in position.encryptedSide and position.encryptedAmount on-chain.
import {
  createUserKeypair,
  fetchMxePublicKey,
  encryptBetInput,
} from "@cypher-zk/sdk";

const userKeypair = await createUserKeypair();
const mxeKey = await fetchMxePublicKey(client);

const privateKeyBytes = new Uint8Array(
  await crypto.subtle.exportKey("raw", userKeypair.privateKey)
);
const cipher = createCipher(privateKeyBytes, mxeKey);

const { encryptedSide, encryptedAmount, nonce } = encryptBetInput(
  { amount: 4_900_000n, side: 1 },
  cipher,
  // optional: pass a pre-generated nonce, or omit to auto-generate
);
// encryptedSide   → Uint8Array(32)
// encryptedAmount → Uint8Array(32)
// nonce           → Uint8Array(16)
input.amount
bigint
required
Net bet amount in USDC lamports (after fees). This is netAmount from computeFees.
input.side
number
required
Outcome index. Same value as the side parameter you pass to placeBet.
cipher
RescueCipher
required
A RescueCipher instance from createCipher.
nonce
Uint8Array
Optional 16-byte nonce. If omitted, a fresh random nonce is generated via freshNonce().
Returns EncryptedBetInput:
encryptedAmount
Uint8Array
32-byte ciphertext for the stake amount.
encryptedSide
Uint8Array
32-byte ciphertext for the outcome side.
nonce
Uint8Array
16-byte nonce used for encryption. Stored on-chain in position.nonce.

decryptBetInput

Decrypts an on-chain EncryptedPosition to reveal the original side and amount. Only the user who placed the bet (holding the original private key) can decrypt.
import {
  createCipher,
  decryptBetInput,
  bigIntToLeBytes,
  fetchMxePublicKey,
} from "@cypher-zk/sdk";

async function revealPosition(
  client: CypherClient,
  position: EncryptedPositionAccount,
  savedPrivateKeyBytes: Uint8Array,
) {
  const mxeKey = await fetchMxePublicKey(client);
  if (!mxeKey) throw new Error("MXE key unavailable");

  const cipher = createCipher(savedPrivateKeyBytes, mxeKey);
  const nonceBytes = bigIntToLeBytes(position.nonce, 16);

  const { side, amount } = decryptBetInput(position, cipher, nonceBytes);
  return {
    side,   // 0 = NO/first outcome, 1 = YES/second outcome, etc.
    amount, // bigint, USDC lamports (net amount)
  };
}
encryptedPosition
EncryptedPositionAccount
required
The on-chain position account with encryptedAmount, encryptedSide, and nonce fields.
cipher
RescueCipher
required
A RescueCipher instance constructed with the user’s saved private key and the MXE public key.
nonceBytes
Uint8Array
required
16-byte nonce. Derive from the on-chain position.nonce (a bigint u128) using bigIntToLeBytes(position.nonce, 16).
Returns BetInput:
side
number
The original outcome index (0 = NO/first, 1 = YES/second, etc.).
amount
bigint
The net USDC lamport amount that entered the pool.

bigIntToLeBytes

Converts a bigint to a little-endian Uint8Array of a specified byte length. Used to prepare the nonce for decryption.
import { bigIntToLeBytes } from "@cypher-zk/sdk";

const nonceBytes = bigIntToLeBytes(position.nonce, 16);
// Uint8Array(16) in little-endian byte order
value
bigint
required
The bigint value to convert.
length
number
required
Output byte length. Use 16 for a u128 nonce.
Returns Uint8Array of the specified length.

Full custom bet flow

The following shows how to build a complete bet submission without using client.actions.placeBet, composing the raw primitives yourself:
import {
  createUserKeypair,
  fetchMxePublicKey,
  createCipher,
  encryptBetInput,
  computeFees,
  bigIntToLeBytes,
} from "@cypher-zk/sdk";

async function submitCustomBet(
  client: CypherClient,
  marketId: PublicKey,
  side: number,
  grossAmount: bigint,
) {
  // 1. Fetch global state for fee rates
  const gs = await client.globalState.fetch();

  // 2. Calculate net amount after fees
  const { netAmount } = computeFees(grossAmount, {
    protocolFeeRateBps: gs.protocolFeeRate,
    lpFeeRateBps: gs.lpFeeRate,
  });

  // 3. Generate user keypair
  const userKeypair = await createUserKeypair();
  const privBytes = new Uint8Array(
    await crypto.subtle.exportKey("raw", userKeypair.privateKey)
  );

  // 4. Fetch MXE public key (cached)
  const mxeKey = await fetchMxePublicKey(client);

  // 5. Create cipher and encrypt
  const cipher = createCipher(privBytes, mxeKey);
  const { encryptedSide, encryptedAmount, nonce } = encryptBetInput(
    { side, amount: netAmount },
    cipher,
  );

  // 6. Submit via raw instruction builder
  const ix = await client.bets.placeYesnoIx({
    payer: client.wallet.publicKey,
    user: client.wallet.publicKey,
    marketId: BigInt(marketId.toString()),
    encryptedSide,
    encryptedAmount,
    userX25519Pubkey: new Uint8Array(
      await crypto.subtle.exportKey("raw", userKeypair.publicKey)
    ),
    nonce: BigInt(`0x${Buffer.from(nonce).toString("hex")}`),
    betAmountUsdc: grossAmount,
    // ... other required accounts
  });

  const sig = await client.sendIx(ix);

  return { sig, userKeypair, nonce };
}