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:
| Option | Type | Required | Description |
|---|
rpId | string | Yes | Relying party domain (must match the page’s effective domain) |
rpName | string | No | Human-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.