Skip to main content
The passkey signer lets users authenticate with their device’s built-in WebAuthn authenticator (Touch ID, Face ID, Windows Hello, a hardware security key). The private key never leaves the authenticator; the Soroban contract’s __check_auth verifies the secp256r1 signature on-chain.

When to use

Choose passkey for the hero new-user flow: no seed phrase, no existing Stellar wallet, no XLM balance required. The signer must be paired with a contract account (ozContractAccount) because the secp256r1 verification runs inside the contract, not at the network layer.

Factory

import { passkey } from "@buckspay/signers/passkey";

const signer = passkey({ rpId: "app.example.com", rpName: "My App" });
passkey(opts) accepts a PasskeyOptions object:
OptionTypeRequiredDescription
rpIdstringYesRelying party domain (must match the page’s effective domain)
rpNamestringNoHuman-readable name shown in the authenticator prompt
rpId is bound permanently to the credential at registration time. If you change it, existing users cannot authenticate - their credential is tied to the original domain. Use window.location.hostname for consistency, or a stable parent domain for cross-subdomain apps. This is the WebAuthn anti-phishing mechanism: a credential registered at app.example.com cannot be used on evil.example.net.

Required account type

passkey must be paired with ozContractAccount. The contract’s __check_auth method verifies the WebAuthn signature on-chain; a classic G... account cannot accept secp256r1 signatures.
import { ozContractAccount } from "@buckspay/accounts/oz-contract";
import { passkey } from "@buckspay/signers/passkey";

const account = ozContractAccount({ network: "testnet", sponsorAddress: SPONSOR_G });
const signer  = passkey({ rpId: "app.example.com", rpName: "My App" });
sponsorAddress is the facilitator’s public G... address, needed to derive the deterministic C... address offline before the contract is deployed.

Example

The snippet below shows atomic batch transfers using passkey and ozContractAccount. A batch of one call is equivalent to pay([call]).
// Recipe 10 - ATOMIC BATCH (sendCalls). N USDC transfers settle all-or-nothing in ONE tx via the
// pinned Multicall router's `batch_transfer` - the smart account (or classic wallet) authorizes the
// whole batch with a SINGLE signature. A batch of 1 is exactly pay([call]).
import {
  createBuckspayClient,
  batch,
  MAX_BATCH_CALLS,
  BuckspayError,
  type BuckspayConfig
} from "@buckspay/core";
import { ozContractAccount } from "@buckspay/accounts/oz-contract";
import { passkey } from "@buckspay/signers/passkey";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";

const SPONSOR_G = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
const USDC_SAC = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const A = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
const B = "GBPYQYRH62E6NLRGXHBT4I3ZPTEHVBQMYTSH44YLOAPTCDYNAXDOLJRY";

export const batchConfig: BuckspayConfig = {
  network: "testnet",
  account: ozContractAccount({ network: "testnet", sponsorAddress: SPONSOR_G }),
  signer: passkey({ rpId: "localhost", rpName: "buckspay" }),
  relayer: buckspayFacilitator({ url: "/api/gasless", network: "testnet" }),
  gas: { mode: "sponsored" }
};

export const client = createBuckspayClient(batchConfig);

export async function payManyAtomically(): Promise<void> {
  await client.connect();
  // Collect calls with the pure builder (enforces MAX_BATCH_CALLS on build()).
  const calls = batch()
    .add(client.transfer({ token: USDC_SAC, to: A, amount: "1.00" }))
    .add(client.transfer({ token: USDC_SAC, to: B, amount: "2.50" }))
    .build();

  // sendCalls = atomic, all-or-nothing. Either BOTH transfers land or NEITHER does.
  const receipt = await client.sendCalls(calls);
  console.log(receipt.transferTx); // one settlement tx for the whole batch
}

export async function guardOversizeBatch(): Promise<void> {
  const tooMany = Array.from({ length: MAX_BATCH_CALLS + 1 }, () =>
    client.transfer({ token: USDC_SAC, to: A, amount: "0.01" })
  );
  try {
    await client.sendCalls(tooMany);
  } catch (e) {
    if (e instanceof BuckspayError && e.code === "BATCH_TOO_LARGE") {
      console.error(`batch capped at ${MAX_BATCH_CALLS} calls`);
      return;
    }
    throw e;
  }
}

Next

Native passkey

The same secp256r1 crypto on iOS and Android via the secure enclave.

Account models

When to use ozContractAccount vs. classicAccount.