# Get classic account state
Source: https://buckspay.mintlify.app/api-reference/account-state/get-classic-account-state
/api-reference/openapi.json get /stellar/account/{publicKey}
Returns on-chain state for a classic Stellar G-address: whether it exists, whether it has a USDC trustline, and its XLM and USDC balances. Used by the SDK to gate the onboarding flow and to display account state in UI.
# Get contract account state
Source: https://buckspay.mintlify.app/api-reference/account-state/get-contract-account-state
/api-reference/openapi.json get /stellar/contract/{address}
Returns on-chain state for a passkey smart-account or policy session-account (C-address). Same response shape as the classic account endpoint. The SDK routes G-addresses to `/stellar/account/:pk` and C-addresses here.
# Email OTP -> Stellar key
Source: https://buckspay.mintlify.app/api-reference/auth/email-otp->-stellar-key
/api-reference/openapi.json post /auth/email
Signer-proxy endpoint. The BFF (`createSignerProxyRoute({ provider: 'email', ... })`) forwards this request to the facilitator's `/auth/email`. The facilitator verifies the one-time code and returns the user's derived Stellar public key.
**Never call the facilitator's `/auth/email` directly from the browser.** Use the BFF route: the SDK's `@buckspay/signers/email` posts to your same-origin BFF which proxies the call.
The request body is provider-specific (email address + OTP code). The response `publicKey` is a Stellar G-address (ed25519 StrKey).
# Social identity -> Stellar key
Source: https://buckspay.mintlify.app/api-reference/auth/social-identity->-stellar-key
/api-reference/openapi.json post /auth/social
Signer-proxy endpoint. The BFF (`createSignerProxyRoute({ provider: 'web3auth', ... })`) forwards this request to the facilitator's `/auth/social` with the server-held API key injected. The facilitator verifies the provider identity token, derives or recovers the user's Stellar key, and returns it.
**Never call the facilitator's `/auth/social` directly from the browser.** Use the BFF route: the SDK's `@buckspay/signers/social` posts to your same-origin BFF which proxies the call.
The request body is provider-specific (Web3Auth / web3auth verifier). The response `publicKey` is a Stellar G-address (ed25519 StrKey).
# Quote a gas-in-token fee
Source: https://buckspay.mintlify.app/api-reference/fee/quote-a-gas-in-token-fee
/api-reference/openapi.json post /fee/quote
Request a fee quote for paying Soroban gas in a token instead of XLM. The facilitator simulates the calls, prices the XLM fee as a token amount, and returns the quote including the `FeeForwarder` contract address and a ledger-based expiry.
The SDK uses this quote to build the `FeeForwarder.forward()` authorization entry before calling `POST /relay` with `feeToken` set.
# API Reference
Source: https://buckspay.mintlify.app/api-reference/index
HTTP wire contract for the Buckspay facilitator and BFF relay route.
## Overview
The Buckspay facilitator is the off-chain relay service that accepts signed Stellar authorization
entries and submits them gaslessly to the network. The endpoints documented here are the HTTP
wire contract that the SDK speaks internally.
**The SDK calls these endpoints for you.** You do not call the facilitator directly from
application code. This reference is for teams building their own BFF relay route or integrating
the facilitator from a custom server-side context. See [Facilitator and BFF](/concepts/facilitator-and-bff)
and [Next.js BFF](/platforms/nextjs-bff) for the standard integration pattern.
## Security model
All facilitator endpoints require the `x-api-key` header. **The API key is a server-side secret
and must never appear in a browser bundle.** The standard pattern is a same-origin BFF route on
your server: the browser POSTs the signed payload to your BFF, which validates business rules and
then calls the facilitator with the key injected server-side.
The `@buckspay/nextjs` package provides `createRelayRoute` and `createSignerProxyRoute` to build
compliant BFF handlers without handling the key injection yourself.
## Endpoint groups
| Group | Paths | Purpose |
| ----------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| **Relay** | `POST /relay` | Submit a signed `RelayPayload` -> `Receipt` |
| **Fee** | `POST /fee/quote` | Quote gas-in-token fee before building the `forward()` entry |
| **Account State** | `GET /stellar/account/{pk}`, `GET /stellar/contract/{addr}` | Read on-chain balances and trustline status |
| **Onboarding** | `POST /stellar/onboard/build`, `/submit`, `/contract/deploy`, `/session-account/deploy` | Sponsor-funded account setup and smart-contract deploy |
| **Auth** | `POST /auth/social`, `POST /auth/email` | Signer-proxy: verify provider identity -> Stellar key |
| **Swap** | `POST /swap/quote` | EVM gasless swap quote (requires `swapChain` config) |
## Schema sources
All request and response schemas are derived directly from the SDK's own zod validators:
* **`RelayPayload` / `Receipt`** - `packages/core/src/types.ts` + `packages/relayer/src/buckspay-facilitator/internals.ts` (`receiptSchema`)
* **`FeeQuote`** - `packages/relayer/src/buckspay-facilitator/internals.ts` (`feeQuoteSchema`)
* **`AccountState`** - `packages/relayer/src/buckspay-facilitator/internals.ts` (`accountStateSchema`)
* **`AuthKeyResponse`** - `packages/core/src/types.ts` (`AuthDetails`)
* **`SwapQuoteResponse`** - `packages/relayer/src/buckspay-facilitator/internals.ts` (`swapQuoteResponseSchema`)
* **BFF relay / auth request validation** - `packages/nextjs/src/routes.ts` (`relayPayloadSchema`, `bodyObjectSchema`)
# Build onboarding transaction
Source: https://buckspay.mintlify.app/api-reference/onboarding/build-onboarding-transaction
/api-reference/openapi.json post /stellar/onboard/build
Build the sponsor-funded onboarding transaction for a classic G-address: creates the account if needed and establishes the USDC trustline. The facilitator returns the unsigned transaction XDR for the user to sign and submit via `POST /stellar/onboard/submit`.
If the account is already fully onboarded, `nothingToDo: true` is returned and no XDR needs to be signed.
# Deploy a passkey smart-account
Source: https://buckspay.mintlify.app/api-reference/onboarding/deploy-a-passkey-smart-account
/api-reference/openapi.json post /stellar/contract/deploy
Sponsor-deploy a passkey smart-account (secp256r1 / WebAuthn). The SDK sends the passkey's 65-byte uncompressed public key; the facilitator holds the sponsor secret, enforces the pinned Wasm hash, and returns the deployed contract address.
# Deploy a policy session-account
Source: https://buckspay.mintlify.app/api-reference/onboarding/deploy-a-policy-session-account
/api-reference/openapi.json post /stellar/session-account/deploy
Sponsor-deploy a policy-scoped session-account bound to an ed25519 root key. The SDK sends the root public key (G-address); the facilitator holds the sponsor secret, enforces the pinned Wasm hash, and returns the deployed contract address. Requires `deploySessionAccount` support in the relayer.
# Submit signed onboarding transaction
Source: https://buckspay.mintlify.app/api-reference/onboarding/submit-signed-onboarding-transaction
/api-reference/openapi.json post /stellar/onboard/submit
Submit the user-signed onboarding transaction XDR from `POST /stellar/onboard/build`. The facilitator fee-bumps it (so the user does not need XLM for fees) and submits it to Stellar.
# Relay a signed payment
Source: https://buckspay.mintlify.app/api-reference/relay/relay-a-signed-payment
/api-reference/openapi.json post /relay
Submit a signed Soroban `SorobanAuthorizationEntry` (base64 XDR) to the facilitator. The facilitator wraps it in a fee-bump transaction, submits it to Stellar, and returns a `Receipt`.
In gas-in-token mode, set `feeToken` and ensure `authorizationEntryXdr` encodes the `FeeForwarder.forward()` invocation (a single host-function op that includes the actual transfer as a sub-invocation). No separate fee auth entry is needed.
**The API key is injected server-side by the BFF; the browser never sends this header directly.**
# Get an EVM swap quote
Source: https://buckspay.mintlify.app/api-reference/swap/get-an-evm-swap-quote
/api-reference/openapi.json post /swap/quote
Get a gasless EVM token swap quote. Requires the relayer to be configured with a `swapChain` (see `FacilitatorOptions`). Returns a quote ID, expected amounts, and EIP-712 typed data for the swap authorization.
**This endpoint is available only when the EVM swap rail is configured.** If `swapChain` is absent, `BuckspayClient.swap()` fails with `SWAP_FAILED`.
# Account models
Source: https://buckspay.mintlify.app/concepts/account-models
Classic G-addresses vs. passkey smart-contract accounts - when to use each.
Buckspay separates *who signs* (`BuckspaySigner`) from *what account signs* (`AccountAdapter`).
They are **orthogonal**: you can pair any signer with any account type. The two account models
ship in v1.
## Comparison
| | Classic | Passkey / contract |
| --------------------- | --------------------------------------------- | ----------------------------------------- |
| User prerequisite | A Stellar wallet (Freighter / xBull / LOBSTR) | A device with WebAuthn support |
| Address | `G...` account | `C...` contract account |
| Materialization | Sponsored onboarding (account + trustline) | Sponsored OZ Smart Account deploy |
| Signature verified by | The network (Ed25519) | The contract's `__check_auth` (secp256r1) |
| Best for | Existing Stellar users | New users and passwordless onboarding |
## Classic account (`G...`)
For users who already have a Stellar wallet (Freighter, xBull, LOBSTR). Calling `connect()`
resolves the `G...` address and runs sponsored onboarding - it creates the account and adds the
USDC trustline - if either is missing.
The network verifies the user's signature via standard Ed25519 authentication. No contract is
involved.
The `classicAccount()` adapter is imported from `@buckspay/accounts/classic`; the matching
signer is `walletsKit` from `@buckspay/signers/wallets-kit`.
```ts theme={null}
// Recipe A - classic wallet account (G...), for users who already have a Stellar wallet.
import { createBuckspayClient, createRpcSimContext, type BuckspayConfig } from "@buckspay/core";
import { classicAccount } from "@buckspay/accounts/classic";
import { walletsKit } from "@buckspay/signers/wallets-kit";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
export const classicConfig: BuckspayConfig = {
network: "testnet",
account: classicAccount(),
signer: walletsKit({ network: "testnet" }),
relayer: buckspayFacilitator({ url: "/api/gasless", network: "testnet" }),
gas: { mode: "sponsored" }
};
// connect() resolves the G... address and runs sponsored onboarding (account + USDC trustline) if missing.
export const classicClient = createBuckspayClient(
classicConfig,
createRpcSimContext("https://soroban-testnet.stellar.org")
);
```
## Passkey / contract account (`C...`)
The hero flow for new users. `connect()` creates a WebAuthn credential, derives the
deterministic `C...` address, and deploys the OpenZeppelin Smart Account **sponsored** by the
facilitator. The user only taps an authenticator prompt - no seed phrase, no XLM balance
required.
Signing happens in the device authenticator (secp256r1). The contract's `__check_auth`
verifies the signature on-chain. See [Gasless on Stellar](/concepts/gasless-on-stellar) for
the mechanics.
The `ozContractAccount()` adapter is imported from `@buckspay/accounts/oz-contract`;
`sponsorAddress` is the facilitator's public sponsor `G...` address, needed to derive the `C...`
address offline.
```ts theme={null}
// Recipe B - passkey smart account (C...), the hero flow. Browser only (uses window).
import { createBuckspayClient, createRpcSimContext, 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";
// Public facilitator sponsor/deployer G-address (derives the deterministic C-address).
const SPONSOR_G: string = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
export const passkeyConfig: BuckspayConfig = {
network: "testnet",
account: ozContractAccount({ network: "testnet", sponsorAddress: SPONSOR_G }),
// create()s a passkey, derives the C-address, deploys the OZ Smart Account sponsored.
signer: passkey({ rpId: window.location.hostname, rpName: "buckspay" }),
relayer: buckspayFacilitator({ url: "/api/gasless", network: "testnet" }),
gas: { mode: "sponsored" }
};
export const passkeyClient = createBuckspayClient(
passkeyConfig,
createRpcSimContext("https://soroban-testnet.stellar.org")
);
```
## The `AccountAdapter` seam
`AccountAdapter` is the interface both models implement. OZ Smart Accounts is the only
contract implementation in v1, but the interface is open - you can wire in any conforming
adapter without changing the rest of the client.
## Next
The three-phase flow every Buckspay payment follows.
The native Stellar mechanisms behind sponsored and contract accounts.
# Facilitator and BFF
Source: https://buckspay.mintlify.app/concepts/facilitator-and-bff
The relayer's role in the payment flow, and why your API key must stay server-side behind a same-origin BFF.
## The facilitator's role
The Buckspay facilitator is the off-chain relay service that takes a signed Stellar
authorization entry, wraps it in a fee-bump transaction, and submits it to the network. It
is what makes the payment gasless: the facilitator's sponsor account sources the XLM fee so
the payer does not need any.
In the SDK the facilitator is represented as a `Relayer` - the third config slot in
`createBuckspayClient`. You construct one with `buckspayFacilitator({ url, network })` from
`@buckspay/relayer/buckspay-facilitator`.
## The same-origin BFF boundary
The facilitator authenticates callers via an API key. **The API key must never reach the
browser.** A browser bundle is inspectable by any user; shipping the key client-side exposes
it to replay attacks and quota abuse.
The standard pattern is a **Backend for Frontend (BFF)** route on your own server:
1. The browser runs `prepare()` and `sign()`, then POSTs the `SignedIntent` to your backend.
2. Your backend validates business rules (intent not expired, amount within tolerance, etc.).
3. Your backend calls `server.send(signed)` using a `createBuckspayClient` that is configured
with `buckspayFacilitator({ url, apiKey, network })` - the `apiKey` is an environment
variable, never bundled.
The `apiKey` passed to `buckspayFacilitator` must only appear in server-side code. If you
import a module containing the key in a browser bundle, any user can extract it from the
network tab.
The `RelayPayload` that `send()` forwards is byte-identical to what the facilitator expects -
the BFF is a pass-through after your validation, not a translation layer.
## BFF example
The example below is the canonical server-side pattern. It is the only Buckspay example that
passes an `apiKey` - all browser examples omit it and point `url` at a same-origin route
like `/api/gasless`.
```ts theme={null}
// SERVER-ONLY - this file holds the facilitator API key. NEVER import it in a browser
// bundle. It is the BFF boundary: the browser POSTs a SignedIntent here; this validates
// and forwards to the facilitator with the secret key server-side.
import { createBuckspayClient, createRpcSimContext, type Receipt, type SignedIntent } from "@buckspay/core";
import { classicAccount } from "@buckspay/accounts/classic";
import { walletsKit } from "@buckspay/signers/wallets-kit";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
// account/signer are unused by send() (the intent is already signed); only the relayer
// (with the server-side key) and gas engine matter here.
const server = createBuckspayClient(
{
network: "testnet",
account: classicAccount(),
signer: walletsKit({ network: "testnet" }),
relayer: buckspayFacilitator({
url: process.env.FACILITATOR_URL ?? "http://localhost:3000",
// server-side secret - never shipped to the browser
...(process.env.FACILITATOR_API_KEY ? { apiKey: process.env.FACILITATOR_API_KEY } : {}),
network: "testnet"
}),
gas: { mode: "sponsored" }
},
createRpcSimContext(process.env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org")
);
/** BFF handler: validate business rules, then relay. `RelayPayload` is byte-identical to the legacy body. */
export async function bffRelay(signed: SignedIntent): Promise {
// ... your business validation: intent exists / not expired / amount tolerance / sponsorship budget ...
return server.send(signed);
}
```
## What stays in the browser
The browser-side `buckspayFacilitator` call omits `apiKey` entirely and points `url` at your
BFF route:
```ts theme={null}
relayer: buckspayFacilitator({ url: "/api/gasless", network: "testnet" })
```
The SDK sends a `POST /relay` to that route with the serialized `RelayPayload`. Your BFF
handler validates, then calls the real facilitator with the key.
## Migration from direct fetch
If you currently `fetch` the facilitator directly from a backend route (hand-building the
`SorobanRelayBody`), the migration is:
* Delete the hand-rolled `signTransferAuth` / `normalizeSignature` helpers - `walletsKit`
absorbs the double-encode quirk of Freighter automatically.
* Replace the raw `fetch` with `server.send(signed)`, where `server` is a
`createBuckspayClient` with `buckspayFacilitator({ url, apiKey, network })`.
* Keep your rail schema and BFF route path unchanged.
## Next
Testnet vs. pubnet, the mainnet opt-in, and USDC decimals.
The three-phase flow and where the BFF slot fits in.
# Gasless on Stellar
Source: https://buckspay.mintlify.app/concepts/gasless-on-stellar
How Stellar's native account-abstraction primitives make the payer's XLM balance irrelevant.
Stellar has two orthogonal mechanisms that together make a payment genuinely gasless for the
end user. Understanding them clarifies why Buckspay needs no fee-abstraction layer bolted on
from the outside - it uses what the protocol already provides.
## Sponsor fee-bump and sponsored reserves
Every Stellar transaction consumes a small XLM fee, and every account must maintain a minimum
XLM reserve to exist on-chain. Normally that burden falls on the account owner. Stellar's
**fee-bump** transaction wraps a signed inner transaction with an outer envelope that designates
a different account - the *sponsor* - as the fee source. The ledger debits the sponsor, not
the inner-transaction signer.
The same sponsor pattern applies to **reserves**: when an account or trustline is created
inside a `BEGIN_SPONSORING_FUTURE_RESERVES` / `END_SPONSORING_FUTURE_RESERVES` sandwich,
the reserve is charged to the sponsor's balance, not the new account's.
The payer needs zero XLM - not to send a payment, and not even to appear on-chain for the
first time. Buckspay's facilitator is the sponsor for both the fee and the reserve.
In Buckspay's sponsored mode the user signs **only** the `SorobanAuthorizationEntry` for their
payment. The facilitator wraps that authorization in a fee-bump transaction, sources the XLM
fee, and submits to the network. The user's account balance is never consulted for fees.
## `__check_auth` for contract accounts
Classic Stellar accounts (`G...`) authenticate via Ed25519 - the network verifies the signature
directly. Contract accounts (`C...`) follow a different path: the network calls the contract's
`__check_auth` entry point with the authorization payload and the signature(s), and the
contract decides whether to accept or reject.
This is Soroban's **custom authentication** hook. Buckspay uses an OpenZeppelin Smart Account
contract whose `__check_auth` implementation verifies a **secp256r1 (WebAuthn) signature**
on-chain. That is what makes passkey-based accounts possible on Stellar: the private key never
leaves the user's authenticator, and the secp256r1 check runs inside the contract, settled by
the ledger.
## Putting them together
For a passkey account payment:
1. The browser calls `prepare()`, which simulates the Soroban call and records the
authorization entries.
2. The user's authenticator signs the authorization entry (secp256r1 via WebAuthn).
3. `send()` (via the BFF) hands the signed intent to the facilitator.
4. The facilitator wraps the transaction in a fee-bump and submits - the contract's
`__check_auth` validates the passkey signature on-chain; the sponsor covers the fee.
The result: the user taps an authenticator prompt and the payment settles. No seed phrases,
no XLM balance, no transaction approval dialog.
```ts theme={null}
// Gasless modes - v1 ships exactly one: sponsored. The facilitator's sponsor account
// pays the XLM fee; the payer needs zero XLM.
import type { GasConfig } from "@buckspay/core";
export const sponsored: GasConfig = { mode: "sponsored" };
// Roadmap (NOT available in v1 - do not pass these):
// { mode: "token", token: "USDC:GA5..." } // pay gas in USDC via FeeForwarder
// { mode: "self" } // payer pays their own fee
// The GasConfig type only admits `{ mode: "sponsored" }` today, so an unimplemented
// mode fails to type-check - the docs can't drift into an unsupported config.
```
## Next
Classic G-addresses vs. passkey smart-contract accounts.
The three-phase flow every Buckspay payment follows.
# Networks
Source: https://buckspay.mintlify.app/concepts/networks
Testnet vs. pubnet, the mainnet opt-in guard, USDC decimals, and the multi-network rail model.
## Testnet and pubnet
Buckspay supports two Stellar networks:
| Value | Network | Use case |
| ----------- | ------------------------ | ----------------------------------- |
| `"testnet"` | Stellar testnet (Fuji) | Development and integration testing |
| `"pubnet"` | Stellar pubnet (mainnet) | Production - real funds |
Pass the `network` field in `BuckspayConfig`. The same field is forwarded to
`buckspayFacilitator`, which sets the `FacilitatorChain` header - `"stellar-testnet"` or
`"stellar-pubnet"` (hyphen-separated).
## Mainnet opt-in guard
Mainnet is **supported** and **off by default**. Without an explicit opt-in, constructing a
client on `"pubnet"` throws `BuckspayError("INVALID_CONFIG")`. This makes it impossible for
a misconfigured or forgotten config to move real funds.
Constructing a `createBuckspayClient` with `network: "pubnet"` without opting in throws
`BuckspayError("INVALID_CONFIG")`. Add the opt-in deliberately, not as a drive-by change.
The opt-in is deliberately different in each environment so neither can accidentally inherit
the other's setting:
* **Browser:** `allowMainnet: true` in the `BuckspayConfig`.
* **Node / server:** `BUCKSPAY_ALLOW_MAINNET=1` environment variable.
## USDC and the asset-agnostic design
Buckspay does not hardcode any USDC address. You pass the Stellar Asset Contract (SAC)
address when constructing a transfer call:
```ts theme={null}
client.transfer({ token: USDC_SAC, to: MERCHANT, amount: "1.50" })
```
This keeps the SDK asset-agnostic - the same client handles any Soroban token.
**USDC = 7 decimals on Stellar.** Circle's implementation uses seven decimal places, not
the six common on EVM chains. Amounts in the SDK are expressed as human-readable strings
(`"1.50"`, `"0.000001"`) and converted internally - you do not deal with raw stroops.
On pubnet, the USDC SAC is Circle's official contract address. On testnet, use the Circle
testnet USDC SAC.
## Dedicated Soroban RPC
`createRpcSimContext(rpcUrl)` connects the SDK's simulation engine to a Soroban RPC node.
On pubnet, use a **dedicated, consistent RPC endpoint** rather than the shared public load
balancer, which is eventually-consistent and can cause simulation failures when ledger state
lags across nodes.
For pubnet with a contract account, `mainnetSimContext(rpcUrl, { sponsorAddress })` is the
correct preset - it sets the funded sponsor `G...` address as the `simSource` that the
facilitator uses when recording the transaction.
## Pubnet example
The example below shows a complete pubnet configuration with the `allowMainnet: true` opt-in,
a dedicated RPC URL, and `mainnetSimContext`:
```ts theme={null}
// Recipe 08 - MAINNET (pubnet) gasless USDC, contract/passkey account. Browser only.
//
// Mainnet is gated: it runs ONLY because `allowMainnet: true` is set in config (the
// browser opt-in, equivalent to Node `BUCKSPAY_ALLOW_MAINNET=1`). Without it,
// constructing the client on "pubnet" throws BuckspayError("INVALID_CONFIG").
import {
createBuckspayClient,
mainnetSimContext,
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";
// The facilitator sponsor's PUBLIC G-address (used to derive the C-address AND to frame
// the recording sim on pubnet). PUBLIC only - the sponsor secret lives in the facilitator.
const SPONSOR_G: string = "GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57";
// Mainnet USDC SAC (C-address). Differs per network - the caller passes it; the SDK is asset-agnostic.
const USDC_SAC_PUBNET: string = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7EQI5VS577FRESC2GUDOAAEZ3";
const MERCHANT: string = "GAMX62ZD4FWIKMWGVPEDR6WNL2TYTPQMO2ZJEAZUAON7VCZ5G2GWDF7W";
const SOROBAN_RPC_PUBNET = "https://mainnet.sorobanrpc.com";
export const mainnetConfig: BuckspayConfig = {
network: "pubnet",
// Explicit browser opt-in - without this, the client refuses to construct on pubnet.
allowMainnet: true,
account: ozContractAccount({ network: "pubnet", sponsorAddress: SPONSOR_G }),
signer: passkey({ rpId: window.location.hostname, rpName: "buckspay" }),
// url points at YOUR backend, which forwards to the facilitator with the key server-side.
relayer: buckspayFacilitator({ url: "/api/gasless", network: "pubnet" }),
gas: { mode: "sponsored" }
};
export const mainnetClient = createBuckspayClient(
mainnetConfig,
// Contract model on pubnet MUST carry the funded sponsor G as `simSource`; this preset forces it.
mainnetSimContext(SOROBAN_RPC_PUBNET, { sponsorAddress: SPONSOR_G })
);
export async function payOnMainnet(): Promise {
await mainnetClient.connect(); // derive C-address + ensureReady (sponsored deploy if needed)
const call = mainnetClient.transfer({ token: USDC_SAC_PUBNET, to: MERCHANT, amount: "1.50" });
const receipt = await mainnetClient.pay([call]); // prepare -> sign -> send
console.log(receipt.transferTx); // settled on pubnet
}
```
## Multi-network rail model
A single asset can be offered over multiple rails: the payer chooses which network to settle
on. The `FacilitatorChain` value (`"stellar-testnet"` | `"stellar-pubnet"`) in the relay
payload tells the facilitator which network to submit to. The SDK resolves the rail from the
client's `network` field - no extra wiring needed.
## Next
How to configure the relayer and keep the API key server-side.
Classic and passkey accounts - both work on testnet and pubnet.
# Prepare -> Sign -> Send
Source: https://buckspay.mintlify.app/concepts/prepare-sign-send
The three-phase mental model every Buckspay payment follows - and where to insert backend validation.
Every Buckspay payment - regardless of account model, gas mode, or network - follows the same
three-phase flow. Understanding this model makes the rest of the API predictable.
### Prepare
`prepare(calls)` simulates the Soroban transaction against the network's RPC and records the
authorization entries the signer must approve. It returns a `PreparedIntent` - a typed
value that carries the simulation result, the ledger footprint, and the entries awaiting
signature. No network write happens at this step.
`createRpcSimContext` (or `mainnetSimContext` for pubnet) provides the Soroban RPC simulator
that `prepare` uses. Pass it once when constructing the client.
### Sign
`sign(prepared)` passes the authorization entries to the configured `BuckspaySigner`. For a
classic wallet the signer invokes the wallet extension; for a passkey account it triggers a
WebAuthn assertion in the device authenticator. The result is a `SignedIntent` - the same
entries, now carrying the cryptographic authorization.
**This is the only user-interaction point.** The user sees a single signing prompt, regardless
of how many calls are batched.
### Send
`send(signed)` serializes the `SignedIntent` into a `RelayPayload` and posts it to the
configured `Relayer`. For browser apps the relayer URL points at your own backend route (the
BFF), which validates the intent and forwards it to the Buckspay facilitator with the API key
server-side. The facilitator wraps the transaction in a fee-bump and submits it to Stellar.
`send` resolves with a `Receipt` once the transaction is finalized on-chain.
## The `pay` shortcut
`client.pay(calls)` chains all three phases in a single call. It is equivalent to:
```ts theme={null}
const prepared = await client.prepare(calls);
const signed = await client.sign(prepared);
const receipt = await client.send(signed);
```
Use the three-step form when you need to insert backend validation between `sign` and `send` -
for example, to check that the intent has not expired or that the amount is within a
pre-approved tolerance.
## Where the BFF fits
The `SignedIntent` that comes out of `sign()` is safe to POST to your backend: it carries no
private key material. Your server validates business rules, then calls `server.send(signed)` -
using a server-side `createBuckspayClient` configured with the `buckspayFacilitator` API key.
The API key never leaves the server. See [Facilitator and BFF](/concepts/facilitator-and-bff)
for the full pattern.
## Next
How the relayer works and why the API key must stay server-side.
Which account type to use and how they affect the sign step.
# Atomic batch
Source: https://buckspay.mintlify.app/features/atomic-batch
Settle multiple transfers all-or-nothing in one Stellar transaction with a single signature.
`sendCalls([...])` settles N calls **all-or-nothing in one transaction**, authorized with a
**single** signature. It is the EIP-5792-style alias of `pay` - same input shape, same
`Receipt` - with the atomicity guarantee made explicit.
## Quick example
```ts theme={null}
// 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 {
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 {
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;
}
}
```
## The `batch()` builder
`batch()` is a pure, fluent builder that collects calls and enforces the size cap on
`build()`:
```ts theme={null}
import { batch, MAX_BATCH_CALLS } from "@buckspay/core";
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(); // throws BATCH_TOO_LARGE if > MAX_BATCH_CALLS
const receipt = await client.sendCalls(calls);
```
You can also pass an array directly to `sendCalls([...])` - the builder is optional.
## Native mechanism
The atomic unit is always the **transaction**; partial application is impossible at the
protocol level - not enforced by retry logic in the SDK.
| Account type | Mechanism |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Classic (`G...`) | Multi-operation Stellar transaction: operations share one envelope, so all land or none do. |
| Contract (`C...`) | The pinned Multicall router's `batch_transfer`: the smart account authorizes the whole batch in `__check_auth` with one signature. |
## Size cap
`MAX_BATCH_CALLS` is **16**. `batch().build()` and `sendCalls` directly both reject an
oversized batch with `BuckspayError("BATCH_TOO_LARGE")`. Keep batches bounded so a single
authorization entry stays within simulation and fee limits.
## Parity invariant
A **batch of one is byte-identical to the single-call entry** produced by `pay([call])`.
Batching adds a wrapper only for N > 1; it never changes the one-call path. This invariant
is pinned by a regression golden so the core path cannot drift.
**Throughput for independent payments.** Settling many independent payments fast is a
separate concern from atomic batching. The facilitator spreads independent calls across a
pool of channel accounts with non-sequential nonces - transparently, with no API change
to how you call `pay`.
## Next
Grant scoped session keys so users can pay within fixed limits without a root prompt per action.
Let users pay transaction fees in USDC instead of XLM.
# Gas in token
Source: https://buckspay.mintlify.app/features/gas-in-token
Let users pay Soroban transaction fees in USDC instead of XLM via the FeeForwarder contract.
`gas: { mode: "token", token }` lets the payer settle the Soroban fee in USDC rather than
holding XLM. The facilitator still fronts the actual XLM gas; the `FeeForwarder` contract
reimburses it from the payer's stablecoin balance atomically.
Choose this mode when **you want the user, not your sponsor, to cover the fee** in the asset
they already hold. For the sponsor-pays default, see [Gasless modes](/features/gasless-modes).
## Configuration
```ts theme={null}
import type { BuckspayConfig } from "@buckspay/core";
const config: BuckspayConfig = {
network: "testnet",
// ...account, signer, relayer...
gas: {
mode: "token",
token: USDC_SAC, // C-address of the fee token
maxFee: "2000000" // optional ceiling in stroops (here: 0.2 USDC)
}
};
```
## How it settles - one signature
`prepare()` calls `POST /fee/quote`, which returns a `FeeQuote`:
| Field | Type | Description |
| ----------------- | -------- | -------------------------------------------------- |
| `forwarder` | `string` | `FeeForwarder` contract address that pulls the fee |
| `collector` | `string` | Destination for the relayer's reimbursement |
| `token` | `string` | The fee token (echoes your config) |
| `estimatedXlmFee` | `string` | XLM the relayer fronts for the transaction fee |
| `tokenAmount` | `string` | Amount owed by the payer in `token` |
| `expiresAtLedger` | `number` | Ledger number after which the quote is invalid |
The engine builds a **single** `FeeForwarder.forward(payer, token, merchant, payment, collector, fee)`
invocation - one authorization entry that pays the merchant **and** reimburses the relayer's
XLM gas at the same time. The payer signs once; there is no second entry to approve.
## The `maxFee` ceiling
`maxFee` (optional, in stroops) is a hard cap on the quoted fee. If `tokenAmount` from the
quote exceeds `maxFee`, `prepare()` throws `BuckspayError("TOKEN_GAS_REJECTED")` **before**
anything is signed. A fee surge can never surprise the user at signing time. Omit `maxFee`
to accept the facilitator's quote as-is.
## Full example
```ts theme={null}
// Recipe 09 - GAS IN TOKEN. The payer pays Soroban gas in USDC instead of XLM. The SDK does NOT relay
// the direct transfer - it relays a SINGLE FeeForwarder `forward(payer, token, merchant, payment,
// collector, fee)` invocation that pays the merchant AND the relayer's gas in one auth entry, so the
// user signs ONCE. Sponsored mode (recipe 03) needs none of this; this is the opt-in for "the user holds
// USDC but no XLM".
import { createBuckspayClient, createRpcSimContext, type BuckspayConfig } from "@buckspay/core";
import { classicAccount } from "@buckspay/accounts/classic";
import { walletsKit } from "@buckspay/signers/wallets-kit";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
// Testnet USDC SAC (C-address). The caller passes it; the SDK is asset-agnostic. The fee token here is the
// SAME USDC the user transfers - they pay both the value and the gas in it.
const USDC_SAC_TESTNET: string = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const MERCHANT: string = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
export const tokenGasConfig: BuckspayConfig = {
network: "testnet",
account: classicAccount(),
signer: walletsKit({ network: "testnet" }),
// url points at YOUR backend, which forwards to the facilitator with the key server-side.
relayer: buckspayFacilitator({ url: "/api/gasless", network: "testnet" }),
// Pay gas in USDC. prepare() quotes the fee + forwarder + collector, builds ONE forward() entry, and
// refuses a quote above `maxFee` (stroops) with BuckspayError("TOKEN_GAS_REJECTED").
gas: { mode: "token", token: USDC_SAC_TESTNET, maxFee: "2000000" } // 0.2 USDC ceiling
};
export const tokenGasClient = createBuckspayClient(
tokenGasConfig,
createRpcSimContext("https://soroban-testnet.stellar.org")
);
export async function payGasInUsdc(): Promise {
await tokenGasClient.connect();
const call = tokenGasClient.transfer({ token: USDC_SAC_TESTNET, to: MERCHANT, amount: "1.50" });
// prepare -> feeQuote -> build the single forward() entry -> sign once -> relay. Settles on testnet.
const receipt = await tokenGasClient.pay([call]);
console.log(receipt.transferTx);
}
```
## Next
Overview of the GasConfig union and when to use each mode.
Settle multiple transfers in one all-or-nothing transaction.
# Gasless modes
Source: https://buckspay.mintlify.app/features/gasless-modes
Sponsored vs. token-fee gas abstraction - choose who pays for the transaction fee.
`GasConfig` controls who covers the Stellar transaction fee. The type is a discriminated union
with exactly two members - any other value fails to type-check:
```ts theme={null}
type GasConfig =
| { mode: "sponsored" }
| { mode: "token"; token: string; maxFee?: string };
```
## Sponsored mode
The facilitator's sponsor account pays the XLM fee on behalf of the payer. The payer signs
only the `SorobanAuthorizationEntry` off-chain; the facilitator wraps it in a fee-bump
transaction, supplies the XLM, and submits to the network.
**The payer needs zero XLM** - not to transact, and not even to exist on-chain. This is the
default for most applications. See [Onboarding](/features/onboarding) to understand how an
account with no XLM balance is materialized in the first place.
```ts theme={null}
// Gasless modes - v1 ships exactly one: sponsored. The facilitator's sponsor account
// pays the XLM fee; the payer needs zero XLM.
import type { GasConfig } from "@buckspay/core";
export const sponsored: GasConfig = { mode: "sponsored" };
// Roadmap (NOT available in v1 - do not pass these):
// { mode: "token", token: "USDC:GA5..." } // pay gas in USDC via FeeForwarder
// { mode: "self" } // payer pays their own fee
// The GasConfig type only admits `{ mode: "sponsored" }` today, so an unimplemented
// mode fails to type-check - the docs can't drift into an unsupported config.
```
## Token-fee mode
`{ mode: "token", token }` lets the payer cover the fee in USDC instead of XLM. The
facilitator still fronts the XLM gas; the `FeeForwarder` contract collects reimbursement
from the payer's stablecoin balance in a single authorization entry - the payer signs once.
Use token-fee when **you want the user, not your sponsor, to bear the gas cost** in the
asset they already hold.
See [Gas in token](/features/gas-in-token) for the full details: the `FeeQuote` fields,
the `maxFee` ceiling, and the `TOKEN_GAS_REJECTED` error code.
## Choosing a mode
| Scenario | Recommended mode |
| -------------------------------------- | ----------------------------------- |
| Your app subsidizes fees (UX priority) | `sponsored` |
| User holds USDC, no sponsor budget | `token` |
| Any other token | not yet supported - use `sponsored` |
The `GasConfig` type only admits these two members. Passing an undeclared mode is a
compile-time error - you cannot accidentally configure something unsupported.
## Next
Full reference for the USDC fee-forwarding mode: FeeQuote, maxFee, and error codes.
How a payer with zero XLM gets a payment-ready account, sponsored end-to-end.
# Feature coverage
Source: https://buckspay.mintlify.app/features/index
Every Buckspay feature mapped to its native Stellar mechanism, SDK surface, runnable example, and guide page.
Every Buckspay feature is an extension of an existing primitive that maps to a **native Stellar
mechanism** - "adding an auth method is an adapter, not a rewrite." This table is the index from
feature -> mechanism -> SDK surface -> runnable example -> guide.
| Feature | Native Stellar mechanism | SDK surface | Example | Guide |
| ---------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------- | ------------------------------------------------ |
| Gasless (sponsored) | sponsor fee-bump (CAP-15) + sponsored reserves (CAP-33) | `gas: { mode: "sponsored" }` | `03-gasless-sponsored.ts` | [Gasless modes](/features/gasless-modes) |
| Passkey smart account | secp256r1 in `__check_auth` (CAP-51) | `@buckspay/accounts/oz-contract` + `passkey` | `02b-passkey-account.ts` | [Account models](/concepts/account-models) |
| Gas in stablecoin | token-forwarding contract via relayer (FeeForwarder) | `gas: { mode: "token", token }` | `09-gas-in-token.ts` | [Gas in token](/features/gas-in-token) |
| Atomic batch | multi-op tx (classic) / Multicall contract (Soroban) | `sendCalls([...])` / `batch()` | `10-batch.ts` | [Atomic batch](/features/atomic-batch) |
| Sessions | policy signers in `__check_auth` (spend limit + allowlist on-chain) | `grantSession` / `revokeSession` | `11-sessions.ts` | [Sessions overview](/features/sessions/overview) |
| Parallel (transparent) | channel-account pool + non-sequential nonces (Protocol 23) | none (facilitator throughput) | - | [Atomic batch](/features/atomic-batch) |
| Social login | external provider -> ed25519 key -> smart-account signer | `socialSigner({ provider: "web3auth" })` | `12-social-login.ts` | [Social login](/signers/social-login) |
| Email/SMS login | external provider -> derived ed25519 key | `emailSigner({ proxyUrl })` | `16-email-login.ts` | [Social login](/signers/social-login) |
| Mobile (React Native) | native passkey (WebAuthn) + secure storage | `@buckspay/react-native` `nativePasskey` | `13-react-native.tsx` | [React Native](/platforms/react-native) |
| Server bindings (BFF) | server-side relay + signer proxy | `@buckspay/nextjs` `createRelayRoute` / `createSignerProxyRoute` | `15-nextjs-bff.ts` | [Next.js BFF](/platforms/nextjs-bff) |
| Swaps (optional) | native DEX / Soroswap (reuses the facilitator `/swap/*` rail) | `quoteSwap` / `swap` | `14-swap.ts` | [Swaps](/features/swaps) |
> **Swaps is optional** - the first surface deferred if the cycle tightens. The core (connect,
> gasless pay, gas-in-token, batch, sessions, social/email login, React Native) does not depend on
> it.
## Swaps - cut decision
**The EVM/Stellar seam.** The facilitator's existing `/swap/*` rail is the EVM Calibur (EIP-7702)
path: `/swap/submit` consumes an EIP-712 `batchSignature` from the payer's **EVM wallet**, not the
SDK's Soroban `BuckspaySigner`. `quoteSwap` is signer-agnostic and ships end-to-end; the `swap`
**submit** leg needs the EVM wallet path (the app BFF -> `/swap/submit`).
> **Decided: KEEP - 2026-06-30**
**As KEPT (current state):**
* `BuckspayClient.quoteSwap` ships end-to-end (delegates to `/swap/quote`, maps to `SwapQuote`).
* `BuckspayClient.swap` enforces the `minOut` floor before submit and fails closed (`SWAP_FAILED`)
until the EVM wallet submit path (app BFF -> `/swap/submit`) is wired; the full submit leg / a
native Stellar (Soroswap) rail is the future swap work.
* `buckspayFacilitator({ swapChain })` exposes `quoteSwap`/`swap`; without `swapChain` both are
omitted. `docs/examples/14-swap.ts` and the gated `swap.e2e.test.ts` cover the quote path.
**If later cut:** revert this surface - the `Receipt.chain` widening, `Relayer.quoteSwap?`/`swap?`,
the `BuckspayClient.quoteSwap`/`swap` methods, the `swapChain` option, `docs/examples/14-swap.ts`,
the gated `swap.e2e.test.ts`, and this section's KEEP detail - and move swaps to a native DEX /
Soroswap rail on the Stellar side (removing the EVM seam).
# Onboarding
Source: https://buckspay.mintlify.app/features/onboarding
Sponsored account creation and trustline setup - the payer needs zero XLM, not even to exist on-chain.
`connect()` calls `account.ensureReady(...)` to materialize a payment-ready account -
sponsored end-to-end, so the payer needs no XLM. This covers both the classic `G...` account
(funded reserves + USDC trustline) and the contract `C...` account (smart-account deployment).
## Read state first
Check what the account still needs before calling `connect()`:
```ts theme={null}
import type { AccountState } from "@buckspay/core";
const state: AccountState = await client.getAccountState(address);
// { exists, hasUsdcTrustline, xlmBalance?, usdcBalance? }
if (!state.exists || !state.hasUsdcTrustline) {
await client.connect(); // materializes the account, sponsored
}
```
## Full example
```ts theme={null}
// Onboarding - read account state first, then connect() materializes it (sponsored).
import type { AccountState } from "@buckspay/core";
import { classicClient } from "./02a-classic-account.js";
export async function ensureOnboarded(address: string): Promise {
const state: AccountState = await classicClient.getAccountState(address);
// For classic: missing account or no USDC trustline -> connect() runs the sponsored
// sandwich (createAccount + changeTrust), the sponsor covers the XLM reserves (CAP-0033).
if (!state.exists || !state.hasUsdcTrustline) {
await classicClient.connect();
}
// For the contract model, connect() deploys the OZ Smart Account sponsored instead.
}
export function describeState(state: AccountState): string {
const xlm = state.xlmBalance ?? "0";
const usdc = state.usdcBalance ?? "0";
return `exists=${String(state.exists)} trustline=${String(state.hasUsdcTrustline)} xlm=${xlm} usdc=${usdc}`;
}
```
## Classic onboarding (`G...`)
If the `G...` account does not exist or has no USDC trustline, the SDK:
1. Fetches an unsigned sponsor-sandwich transaction from the facilitator (`relayer.buildOnboard`).
2. The wallet signs it.
3. `relayer.submitOnboard` co-signs and submits.
The sponsor covers the XLM reserves using Stellar's sponsored-reserves mechanism (CAP-0033).
**The payer contributes zero XLM** - not for reserves, not for the fee.
## Contract deploy (`C...`)
`ensureReady` deploys the OpenZeppelin Smart Account contract via
`relayer.deployContract({ passkeyPublicKey })`, sponsored by the facilitator. The contract
address (`C...`) is returned and stored in the account adapter.
`connect()` is **idempotent** - calling it again on a fully onboarded account is a no-op.
The payer needs zero XLM - not to transact, and not even to appear on-chain for the first
time. The facilitator acts as sponsor for both the fee and the on-chain reserves.
## Next
How sponsored and token-fee gas modes work after onboarding.
Classic G-addresses vs. passkey smart-contract accounts.
# Allowlist policy
Source: https://buckspay.mintlify.app/features/sessions/allowlist
Restrict a session key to a fixed set of contracts - enforced on-chain in __check_auth.
`allowlist` restricts which contracts a session key may call. Any call to a contract
not in the list is rejected **on-chain** in `__check_auth`. A stolen session blob cannot
be used to interact with arbitrary contracts - it is scoped to exactly the set you declare.
## Usage
```ts theme={null}
import { allowlist } from "@buckspay/accounts/policy";
const policy = allowlist([
APP_CONTRACT, // C-address of an allowed contract
USDC_SAC // another allowed contract (e.g. the token itself)
]);
```
Pass the policy in the `policies` array when calling `grantSession`:
```ts theme={null}
const { session } = await client.grantSession({
sessionKey: { type: "ed25519", publicKey: sessionKp.publicKey() },
policies: [allowlist([APP_CONTRACT])],
expiresAt: Date.now() + 86_400_000
});
```
## What happens when a call targets an unlisted contract
The contract rejects the authorization during `__check_auth`. The facilitator maps the
on-chain rejection to `BuckspayError("SESSION_POLICY_VIOLATION")`.
The allowlist check runs inside the contract - not in the SDK, not in the facilitator.
A session key with an allowlist of `[APP_CONTRACT]` cannot be used to call any other
contract, even if the session blob is extracted from memory.
## Combining policies
`allowlist` is commonly paired with `spendLimit` to scope the session both by callable
contracts and by cumulative spend. See [Sessions overview](/features/sessions/overview) for
the full lifecycle example, which uses both policies together.
## Next
Cap cumulative token spend over a rolling period.
Full lifecycle: grant, serialize, use, and revoke a session.
# Sessions
Source: https://buckspay.mintlify.app/features/sessions/overview
Scoped, expiring session keys that let users pay within fixed on-chain policies without a root prompt per action.
A **session** is a scoped, expiring key the app mints so the user can pay within fixed limits
**without a root prompt per action**. The session key is never the account's root key - it
carries on-chain policies (a spend limit, an allowlist) and is revocable at any time.
Sessions are a **contract (`C...`) account** feature only. On the classic model,
`grantSession` throws `BuckspayError("INVALID_CONFIG")` - a classic `G...` key has no
on-chain policy surface to scope.
## Lifecycle
```ts theme={null}
import { serializeSession, deserializeSession } from "@buckspay/core";
import { spendLimit, allowlist } from "@buckspay/accounts/policy";
// 1. Grant - root-signed; installs the session key + policies on-chain.
const { session, receipt } = await client.grantSession({
sessionKey: { type: "ed25519", publicKey: sessionKp.publicKey() },
policies: [
spendLimit({ token: USDC_SAC, max: "100", period: "day" }),
allowlist([APP_CONTRACT])
],
expiresAt: Date.now() + 86_400_000 // 24 h
});
// 2. Serialize to secure storage; rehydrate with deserializeSession.
const blob = serializeSession(session);
// 3. The session key pays within its policies - no per-action root prompts.
// 4. Revoke when done - root-signed; the key authorizes nothing afterward.
await client.revokeSession(session);
```
## Full example
```ts theme={null}
// Recipe 11 - SESSIONS (contract/passkey accounts only). Grant a scoped session key with on-chain
// policies (spend limit + allowlist), serialize it for later, then revoke it. Sessions are refused on
// the classic model: grantSession throws BuckspayError("INVALID_CONFIG").
import { Keypair } from "@stellar/stellar-sdk";
import { createBuckspayClient, createRpcSimContext, serializeSession, type BuckspayConfig } from "@buckspay/core";
import { ozContractAccount } from "@buckspay/accounts/oz-contract";
import { spendLimit, allowlist } from "@buckspay/accounts/policy";
import { passkey } from "@buckspay/signers/passkey";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
const SPONSOR_G = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
const USDC_SAC = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const APP_CONTRACT = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75";
const config: BuckspayConfig = {
network: "testnet",
account: ozContractAccount({ network: "testnet", sponsorAddress: SPONSOR_G }),
signer: passkey({ rpId: "localhost", rpName: "buckspay" }), // the ROOT signer authorizes the install
relayer: buckspayFacilitator({ url: "/api/gasless", network: "testnet" }),
gas: { mode: "sponsored" }
};
const client = createBuckspayClient(
config,
createRpcSimContext("https://soroban-testnet.stellar.org", { simSource: SPONSOR_G })
);
export async function runSession(): Promise {
await client.connect();
// The session key is the ONE key the app deliberately mints - scoped + revocable, never the root
// account key. Store its secret in secure storage; the SDK only ever needs its public key.
const sessionKp = Keypair.random();
const { session, receipt } = await client.grantSession({
sessionKey: { type: "ed25519", publicKey: sessionKp.publicKey() },
policies: [spendLimit({ token: USDC_SAC, max: "100", period: "day" }), allowlist([APP_CONTRACT])],
expiresAt: Date.now() + 86_400_000 // 24h
});
console.log("granted", session.id, receipt.transferTx);
// Persist the session (e.g. to secure storage) and rehydrate it later with deserializeSession.
const blob = serializeSession(session);
console.log("serialized session blob length", blob.length);
// ...the session key now pays within its policies, with no per-action root prompts...
// Revoke when done (root-signed); the session key no longer authorizes anything.
const revokeReceipt = await client.revokeSession(session);
console.log("revoked", revokeReceipt.transferTx);
}
```
## The native mechanism
The policies compile to **policy signers evaluated on-chain in `__check_auth`**. The
contract - not the SDK - enforces them, so a stolen session blob still cannot exceed its
limits or call outside its allowlist.
## Error codes
| Code | Meaning |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SESSION_EXPIRED` | The session is past `expiresAt`. The SDK rejects it before the relay; the contract would reject it too. |
| `SESSION_POLICY_VIOLATION` | A payment breaches a policy - over the spend limit, or a contract outside the allowlist. The facilitator maps the on-chain rejection to this code. |
| `INVALID_CONFIG` | `grantSession` was called on a classic (`G...`) account. |
## Install and revoke use the gated relay
`grantSession` and `revokeSession` are ordinary root-signed operations over the same gated
`/relay` path as any payment. They inherit its nonce and expiration replay protection. There
is no separate, weaker channel for session management.
## Policies
Cap cumulative spend of a token over a rolling period (day, week, month, or total).
Restrict which contracts the session key may call.
# Spend limit policy
Source: https://buckspay.mintlify.app/features/sessions/spend-limit
Cap a session key's cumulative token spend over a rolling period - enforced on-chain in __check_auth.
`spendLimit` caps how much of a given token a session key may spend over a rolling time
window. The limit is enforced **on-chain** in the contract's `__check_auth` - not in the
SDK, not in the facilitator. A stolen session blob still cannot exceed its configured cap.
## Usage
```ts theme={null}
import { spendLimit } from "@buckspay/accounts/policy";
const policy = spendLimit({
token: USDC_SAC, // C-address of the token to cap
max: "100", // maximum spend in display units (e.g. "100" = 100 USDC)
period: "day" // rolling window: "day" | "week" | "month" | "total"
});
```
Pass the policy in the `policies` array when calling `grantSession`:
```ts theme={null}
const { session } = await client.grantSession({
sessionKey: { type: "ed25519", publicKey: sessionKp.publicKey() },
policies: [spendLimit({ token: USDC_SAC, max: "100", period: "day" })],
expiresAt: Date.now() + 86_400_000
});
```
## Period values
| Value | Window |
| --------- | --------------------------------------- |
| `"day"` | Rolling 24-hour window |
| `"week"` | Rolling 7-day window |
| `"month"` | Rolling 30-day window |
| `"total"` | Lifetime cap for the session (no reset) |
## What happens when the limit is exceeded
A payment that would exceed the cap is rejected by the contract during `__check_auth`. The
facilitator maps the on-chain rejection to `BuckspayError("SESSION_POLICY_VIOLATION")`.
The limit is per-session, per-token. Two separate sessions each with `max: "100"` can each
spend up to 100 USDC independently.
## Combining policies
`spendLimit` is commonly paired with `allowlist` so the session key is scoped both by
spend and by callable contracts. See [Sessions overview](/features/sessions/overview) for
the full lifecycle example.
## Next
Restrict which contracts the session key may call.
Full lifecycle: grant, serialize, use, and revoke a session.
# Swaps
Source: https://buckspay.mintlify.app/features/swaps
Optional token-swap surface via the facilitator's swap rail - quoteSwap ships end-to-end; swap enforces a minOut floor.
Swaps is an **optional** feature surface. `quoteSwap` works end-to-end. `swap` enforces
the `minOut` floor before submit and fails closed (`SWAP_FAILED`) until the full EVM wallet
submit path is wired through your BFF. The core SDK (connect, pay, batch, sessions) does
not depend on this surface.
## Enabling swaps
Pass `swapChain` to `buckspayFacilitator`. Without it, `quoteSwap` and `swap` are omitted
from the relayer and both methods throw if called:
```ts theme={null}
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
const relayer = buckspayFacilitator({
url: "/api/gasless", // your BFF - the facilitator API key stays server-side
network: "testnet",
swapChain: "base-sepolia"
});
```
## `quoteSwap` - ships end-to-end
`quoteSwap` is signer-agnostic and delegates to `/swap/quote`:
```ts theme={null}
const quote = await client.quoteSwap({
tokenIn: "0xSellTokenAddress",
tokenOut: "0xBuyTokenAddress",
amount: "1000000"
});
// quote: SwapQuote
```
## `swap` - enforces the floor, fails closed
`swap` checks `minOut` before any submit. If the live quote falls below `minOut`, it throws
`BuckspayError("SWAP_FAILED")` without signing or submitting anything:
```ts theme={null}
const receipt = await client.swap({
tokenIn: "0xSellTokenAddress",
tokenOut: "0xBuyTokenAddress",
amount: "1000000",
minOut: "985000" // floor: reject if the received amount would be below this
});
```
## Full example
```ts theme={null}
// Recipe 14 - SWAP (STRETCH, optional). Gasless token swap via the facilitator's existing
// /swap rail. NOTE: this is the first feature cut if the cycle tightens - the core surface does
// NOT depend on it. The submit leg uses the EVM wallet typed-data signature through your BFF.
import { createBuckspayClient, createRpcSimContext, type SwapQuote } from "@buckspay/core";
import { classicAccount } from "@buckspay/accounts/classic";
import { walletsKit } from "@buckspay/signers/wallets-kit";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
const client = createBuckspayClient(
{
network: "testnet",
account: classicAccount(),
signer: walletsKit({ network: "testnet" }),
// `swapChain` enables quoteSwap/swap; without it the relayer omits them (fails closed).
relayer: buckspayFacilitator({
url: "/api/gasless", // your BFF - the facilitator API key stays server-side
network: "testnet",
swapChain: "base-sepolia"
}),
gas: { mode: "sponsored" }
},
createRpcSimContext("https://soroban-testnet.stellar.org")
);
export async function quoteOnly(): Promise {
await client.connect();
// quoteSwap is signer-agnostic and works end-to-end today.
return client.quoteSwap({
tokenIn: "0xSellTokenAddress",
tokenOut: "0xBuyTokenAddress",
amount: "1000000"
});
}
export async function swapWithFloor(): Promise {
await client.connect();
// minOut is enforced BEFORE any submit; a below-floor quote throws SWAP_FAILED.
const receipt = await client.swap({
tokenIn: "0xSellTokenAddress",
tokenOut: "0xBuyTokenAddress",
amount: "1000000",
minOut: "985000"
});
console.log(receipt.transferTx);
}
```
## Next
Settle multiple transfers all-or-nothing in one transaction.
How a user with zero XLM gets a payment-ready account.
# Installation
Source: https://buckspay.mintlify.app/get-started/installation
Add Buckspay SDK packages to your project.
## Requirements
* **Node.js ≥ 20** (LTS recommended)
* `@stellar/stellar-sdk` is a peer dependency of `@buckspay/core` - install it alongside the SDK
## Core package
Every Buckspay integration starts with `@buckspay/core`. Install it with your preferred
package manager:
```bash pnpm theme={null}
pnpm add @buckspay/core @stellar/stellar-sdk
```
```bash npm theme={null}
npm install @buckspay/core @stellar/stellar-sdk
```
```bash yarn theme={null}
yarn add @buckspay/core @stellar/stellar-sdk
```
```bash bun theme={null}
bun add @buckspay/core @stellar/stellar-sdk
```
## Surface packages
Add the surface packages for your target platform. You only need the ones you use.
### Web (browser)
```bash pnpm theme={null}
pnpm add @buckspay/react @buckspay/signers @buckspay/accounts @buckspay/relayer
```
```bash npm theme={null}
npm install @buckspay/react @buckspay/signers @buckspay/accounts @buckspay/relayer
```
```bash yarn theme={null}
yarn add @buckspay/react @buckspay/signers @buckspay/accounts @buckspay/relayer
```
```bash bun theme={null}
bun add @buckspay/react @buckspay/signers @buckspay/accounts @buckspay/relayer
```
### Mobile (React Native)
```bash pnpm theme={null}
pnpm add @buckspay/react-native @buckspay/signers @buckspay/accounts @buckspay/relayer
```
```bash npm theme={null}
npm install @buckspay/react-native @buckspay/signers @buckspay/accounts @buckspay/relayer
```
```bash yarn theme={null}
yarn add @buckspay/react-native @buckspay/signers @buckspay/accounts @buckspay/relayer
```
```bash bun theme={null}
bun add @buckspay/react-native @buckspay/signers @buckspay/accounts @buckspay/relayer
```
### Next.js BFF (server-side route)
```bash pnpm theme={null}
pnpm add @buckspay/nextjs @buckspay/relayer
```
```bash npm theme={null}
npm install @buckspay/nextjs @buckspay/relayer
```
```bash yarn theme={null}
yarn add @buckspay/nextjs @buckspay/relayer
```
```bash bun theme={null}
bun add @buckspay/nextjs @buckspay/relayer
```
## Package overview
Not sure which packages you need? See [Which package do I need?](/get-started/which-package)
for a one-line summary of each.
# Quickstart
Source: https://buckspay.mintlify.app/get-started/quickstart
Send a gasless USDC transfer on Stellar in under five minutes.
Buckspay makes a gasless Stellar USDC payment a three-call affair. The user signs with a
wallet they already have (Freighter, xBull, LOBSTR); Buckspay's facilitator pays the XLM
fee. **No private keys ever touch your app.**
### Install the packages
Add the core client plus the account, signer, and relayer packages you need for a classic
Stellar address flow.
```bash theme={null}
pnpm add @buckspay/core @buckspay/accounts @buckspay/signers @buckspay/relayer
```
### Run the full example
The snippet below is the complete, runnable file - compiled and type-checked in CI. It:
1. **Creates a client** with a classic G-address account, a Wallets Kit browser signer, and
your backend BFF route as the relayer. `createRpcSimContext` gives `prepare()` a
recording simulator over the Soroban RPC.
2. **Connects** the user's wallet and ensures the account is ready on-chain.
3. **Calls `transfer`** to build the payment intent.
4. **Calls `pay`** - which runs prepare -> sign -> send in one call.
5. **Logs the settled receipt** (`receipt.transferTx`).
```ts theme={null}
// Quickstart - classic gasless USDC transfer (browser; NO apiKey).
import { createBuckspayClient, createRpcSimContext } from "@buckspay/core";
import { classicAccount } from "@buckspay/accounts/classic";
import { walletsKit } from "@buckspay/signers/wallets-kit";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
const USDC_SAC: string = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const MERCHANT: string = "GA6HCMBLTZS5VYYBCATRBR5VBZJEH5C2OON6XQGB3RNYDDAQ7JZ65YQH";
export async function quickstart(): Promise {
const buckspay = createBuckspayClient(
{
network: "testnet",
account: classicAccount(),
signer: walletsKit({ network: "testnet" }),
// url points at YOUR backend, which forwards to the facilitator with the key server-side.
relayer: buckspayFacilitator({ url: "/api/gasless", network: "testnet" }),
gas: { mode: "sponsored" }
},
// prepare() simulates against the Soroban RPC.
createRpcSimContext("https://soroban-testnet.stellar.org")
);
await buckspay.connect(); // wallet + ensureReady
const call = buckspay.transfer({ token: USDC_SAC, to: MERCHANT, amount: "1.50" });
const receipt = await buckspay.pay([call]); // prepare -> sign -> send
console.log(receipt.transferTx); // settled on testnet
}
```
> **No API key in the browser.** `url: "/api/gasless"` points at *your* backend route, which
> forwards the request to the facilitator with the secret key server-side.
> See [BFF / backend integration](/concepts/facilitator-and-bff) for the route implementation.
### Confirm on-chain
`receipt` is the facilitator's settlement response:
```ts theme={null}
{
ok: true,
via: "buckspay_self",
token: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
chain: "stellar-testnet",
transferTx: "a1b2c3...", // transaction hash - verify on Stellar Expert
ledger: 54321,
status: "SUCCESS"
}
```
Paste `transferTx` into [Stellar Expert (testnet)](https://testnet.stellar.expert) to confirm
the payment landed.
## Next steps
Classic G-addresses vs. smart-contract accounts (passkey and multi-sig).
Sponsored, fee-forwarding, and session-based gas strategies.
Drop-in hooks for React apps - `useWallet`, `useStellarPay`, and `BuckspayProvider`.
# Which package do I need?
Source: https://buckspay.mintlify.app/get-started/which-package
A one-line guide to every published Buckspay SDK package.
All packages are published under the `@buckspay` scope on npm and follow the same version
number. Start with `@buckspay/core`; add surface packages for your target platform.
The composable client - `createBuckspayClient`, context, and shared types. Required by
every integration.
Account adapters: `classicAccount()` for G-addresses and `ozContractAccount()` for
smart-contract wallets.
Browser and mobile signing strategies: Wallets Kit (Freighter, xBull, LOBSTR) and
passkey-based signers.
Relayer adapters that forward signed transactions to a gasless facilitator - use with
your own BFF backend route.
React hooks (`useWallet`, `useStellarPay`) and provider components for browser apps.
Next.js App Router helpers and server-side BFF route handler for the relayer.
React Native bindings with passkey and wallet-kit support for iOS and Android.
# Buckspay SDK
Source: https://buckspay.mintlify.app/index
Gasless USDC payments on Stellar, in a few lines - on web and mobile.
Send USDC on Stellar (Soroban) without your users ever touching gas. The Buckspay SDK wraps
account management, signing, and gasless relaying into a single composable client - for browsers,
React Native, and server-side Next.js BFFs.
Send your first gasless USDC transfer in under five minutes.
Classic (G-address) vs. smart-contract accounts (passkey + OZ).
Sponsored, fee-forwarding, and session-based gas strategies.
Passkey and wallets-kit signers on iOS and Android.
# Platforms
Source: https://buckspay.mintlify.app/platforms/index
The Buckspay SDK core is platform-agnostic. Each platform binding swaps only the host layer and the signer - the account model, gas engine, and payment flow are identical everywhere.
The core (`@buckspay/core`) owns the prepare -> sign -> send pipeline, the gas-abstraction engine,
and the Soroban wire format. Platform packages layer on top: they swap the signer transport
(browser WebAuthn, native enclave, injected wallet) and the React host (web DOM vs. React Native
primitives), while re-exporting the same hooks and status machine without forking them.
Choose your platform:
`BuckspayProvider`, `useWallet`, and `useStellarPay` for React web apps.
Works with any browser signer (Wallets Kit, passkey).
The same hooks, re-exported unchanged, plus `nativePasskey` (iOS / Android secure enclave)
and secure-storage adapters.
Use the SDK directly - no React. Ideal for server scripts, CLI tools, and backend workers
that build and relay intents programmatically.
App Router route factories: `createRelayRoute` and `createSignerProxyRoute`. The API key
and provider secrets stay server-side behind a same-origin BFF.
Game engines, embedded, and additional server runtimes - coming soon.
# Next.js BFF - @buckspay/nextjs
Source: https://buckspay.mintlify.app/platforms/nextjs-bff
App Router route factories that keep the facilitator API key and provider secrets server-side behind same-origin BFF routes.
`@buckspay/nextjs` exports two App Router route factories. They act as the BFF (Backend for
Frontend) boundary described in [Facilitator and BFF](/concepts/facilitator-and-bff): the
browser POSTs to your same-origin routes; the routes forward to the facilitator with the secret
credentials server-side.
`@buckspay/nextjs` is **server-only**. Never import it in a Client Component or a file that
ends up in the browser bundle. The API key and provider secrets you pass to these factories
are captured in a server closure and are never serialized or echoed to the client.
### Install
```bash theme={null}
pnpm add @buckspay/nextjs @buckspay/core @buckspay/relayer
```
### Create the relay route
Create `app/api/buckspay/relay/route.ts`. `createRelayRoute` returns a `(req: Request) => Promise`
handler - assign it to `POST` as required by App Router conventions.
```ts theme={null}
// app/api/buckspay/relay/route.ts - SERVER-ONLY
import { createRelayRoute } from "@buckspay/nextjs";
export const POST = createRelayRoute({
facilitatorUrl: process.env.BUCKSPAY_FACILITATOR_URL!,
apiKey: process.env.BUCKSPAY_FACILITATOR_API_KEY!, // SERVER-SIDE ONLY
network: "testnet"
});
```
`createRelayRoute` accepts a `CreateRelayRouteOptions` object:
| Option | Type | Description |
| ---------------- | --------- | ------------------------------------------------------------ |
| `facilitatorUrl` | `string` | Base URL of the Buckspay facilitator |
| `apiKey` | `string` | Facilitator API key - from a server env var, never a literal |
| `network` | `Network` | `"testnet"` or `"mainnet"` |
The route validates the incoming `RelayPayload` with a strict Zod schema before forwarding, so
malformed requests are rejected at the BFF layer. On error it returns a coded JSON response
(`{ error: BuckspayErrorCode }`) without leaking the upstream message.
### Create signer proxy routes (social and email login)
If you use social or email-based signers, create proxy routes that forward the auth body to the
facilitator's `/auth/*` endpoints while injecting the API key server-side.
```ts theme={null}
// app/api/buckspay/auth/social/route.ts - SERVER-ONLY
import { createSignerProxyRoute } from "@buckspay/nextjs";
export const POST = createSignerProxyRoute({ provider: "web3auth", network: "testnet" });
```
```ts theme={null}
// app/api/buckspay/auth/email/route.ts - SERVER-ONLY
import { createSignerProxyRoute } from "@buckspay/nextjs";
export const POST = createSignerProxyRoute({ provider: "email", network: "testnet" });
```
`createSignerProxyRoute` reads `BUCKSPAY_FACILITATOR_URL` and `BUCKSPAY_FACILITATOR_API_KEY`
from the server environment by default. You can override both via the optional second argument
(`CreateSignerProxyRouteDeps`).
### Point the browser client at your BFF routes
In your browser (or React) code, configure the relayer without an API key - it points at your
same-origin BFF routes:
```ts theme={null}
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
const relayer = buckspayFacilitator({ url: "/api/buckspay/relay", network: "testnet" });
// No apiKey here - the key lives server-side in the route handler above.
```
## Full example
The snippet below shows all three route handlers in a single file for reference. In production,
each goes in its own `route.ts` under `app/`.
```ts theme={null}
// Recipe 15 - NEXT.JS BFF (App Router). Three server-only route handlers, packaged.
//
// These are the same-origin endpoints your browser code calls (`/api/buckspay/...`). The
// facilitator apiKey and the provider secrets stay SERVER-SIDE - they are never in the client
// bundle. Each `export const POST` below lives in its own `route.ts` under app/.
import { createRelayRoute, createSignerProxyRoute } from "@buckspay/nextjs";
// app/api/buckspay/relay/route.ts - the gasless relay BFF (the dashboard pattern, packaged).
export const relayPOST = createRelayRoute({
facilitatorUrl: process.env.BUCKSPAY_FACILITATOR_URL ?? "https://facilitator.example",
apiKey: process.env.BUCKSPAY_FACILITATOR_API_KEY ?? "", // SERVER-SIDE ONLY
network: "testnet"
});
// app/api/buckspay/auth/social/route.ts - social signer-proxy (forwards to /auth/social).
// Reads BUCKSPAY_FACILITATOR_URL + BUCKSPAY_FACILITATOR_API_KEY from server env.
export const socialPOST = createSignerProxyRoute({ provider: "web3auth", network: "testnet" });
// app/api/buckspay/auth/email/route.ts - email signer-proxy (forwards to /auth/email).
export const emailPOST = createSignerProxyRoute({ provider: "email", network: "testnet" });
```
## Next
Why the API key must stay server-side and how the BFF slot fits the payment flow.
The browser-side hooks that POST to your BFF relay route.
# Node / Core - @buckspay/core
Source: https://buckspay.mintlify.app/platforms/node-core
Use createBuckspayClient directly - no React. Suitable for server scripts, CLI tools, and backend workers.
`@buckspay/core` is the platform-agnostic heart of the SDK. You can use it directly - without
any React layer - in Node.js scripts, backend workers, or any environment where you control
the signer manually.
The surface is the same three-phase flow described in [Prepare -> Sign -> Send](/concepts/prepare-sign-send):
`createBuckspayClient` assembles the client; `createRpcSimContext` gives `prepare()` a Soroban
simulator; then `connect()` -> `pay()` (or the split `prepare` / `sign` / `send`) executes the
payment.
### Install
```bash theme={null}
pnpm add @buckspay/core @buckspay/accounts @buckspay/signers @buckspay/relayer
```
### Create the client
```ts theme={null}
import { createBuckspayClient, createRpcSimContext } from "@buckspay/core";
import { classicAccount } from "@buckspay/accounts/classic";
import { walletsKit } from "@buckspay/signers/wallets-kit";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
const client = createBuckspayClient(
{
network: "testnet",
account: classicAccount(),
signer: walletsKit({ network: "testnet" }),
relayer: buckspayFacilitator({ url: "/api/gasless", network: "testnet" }),
gas: { mode: "sponsored" }
},
createRpcSimContext("https://soroban-testnet.stellar.org")
);
```
### Connect and pay
```ts theme={null}
await client.connect();
const call = client.transfer({ token: USDC_SAC, to: MERCHANT, amount: "1.50" });
const receipt = await client.pay([call]);
console.log(receipt.transferTx);
```
## When to use the core directly
| Scenario | Recommended approach |
| -------------------------------------- | ------------------------------------------------------------------------------------------------ |
| React / React Native app | [`@buckspay/react`](/platforms/web-react) or [`@buckspay/react-native`](/platforms/react-native) |
| Next.js App Router BFF route | [`@buckspay/nextjs`](/platforms/nextjs-bff) |
| Node.js script, backend worker, or CLI | `@buckspay/core` directly |
| Server-side relay (any framework) | `createBuckspayClient` + `buckspayFacilitator({ apiKey })` - server-only |
When using `@buckspay/core` in a server-side relay, pass `apiKey` to `buckspayFacilitator`
as an environment variable. The API key must never appear in a browser bundle.
## Full quickstart example
```ts theme={null}
// Quickstart - classic gasless USDC transfer (browser; NO apiKey).
import { createBuckspayClient, createRpcSimContext } from "@buckspay/core";
import { classicAccount } from "@buckspay/accounts/classic";
import { walletsKit } from "@buckspay/signers/wallets-kit";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
const USDC_SAC: string = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const MERCHANT: string = "GA6HCMBLTZS5VYYBCATRBR5VBZJEH5C2OON6XQGB3RNYDDAQ7JZ65YQH";
export async function quickstart(): Promise {
const buckspay = createBuckspayClient(
{
network: "testnet",
account: classicAccount(),
signer: walletsKit({ network: "testnet" }),
// url points at YOUR backend, which forwards to the facilitator with the key server-side.
relayer: buckspayFacilitator({ url: "/api/gasless", network: "testnet" }),
gas: { mode: "sponsored" }
},
// prepare() simulates against the Soroban RPC.
createRpcSimContext("https://soroban-testnet.stellar.org")
);
await buckspay.connect(); // wallet + ensureReady
const call = buckspay.transfer({ token: USDC_SAC, to: MERCHANT, amount: "1.50" });
const receipt = await buckspay.pay([call]); // prepare -> sign -> send
console.log(receipt.transferTx); // settled on testnet
}
```
## Next
The full quickstart walkthrough with a classic G-address account.
The three-phase flow and where the BFF slot fits.
# React Native - @buckspay/react-native
Source: https://buckspay.mintlify.app/platforms/react-native
The same BuckspayProvider and hooks on iOS and Android, with nativePasskey (device secure enclave) and secure-storage adapters.
`@buckspay/react-native` brings the SDK to iOS and Android. The core pipeline (auth-entry build,
fee-bump, prepare -> sign -> send) is platform-agnostic. This package adds three things on top:
* **`nativePasskey`** - a `WebAuthnLike` backed by `react-native-passkey` that drives the
device secure enclave instead of `navigator.credentials`. The private key never leaves the
device.
* **Secure-storage adapters** - `memorySecureStore`, `expoSecureStore`, `keychainSecureStore`
for persisting session blobs and credential IDs.
* **Hermes polyfills** - installed as a side-effect on import, before `@stellar/stellar-sdk` is
used.
`BuckspayProvider`, `useWallet`, and `useStellarPay` are re-exported **by reference** from
`@buckspay/react` - the same store, same status machine, zero fork. Your screen logic is
identical to a web component; only the host primitives (`` / `` / ``)
and the signer differ.
### Install the package and peer dependencies
Install `@buckspay/react-native` plus the native peers your app requires:
```bash theme={null}
pnpm add @buckspay/react-native @buckspay/react @buckspay/core @buckspay/accounts @buckspay/relayer
# Native peers - install in the app, not in the SDK
pnpm add react-native react-native-passkey react-native-get-random-values
```
The peer modules (`react-native-passkey`, `react-native-get-random-values`) are optional: each
is lazily loaded only if you use the feature that depends on it, so apps that skip `nativePasskey`
never pull in the native module.
### Build the config with `nativePasskey`
The config shape is identical to web. The only mobile-specific change is the signer:
```tsx theme={null}
import { nativePasskey } from "@buckspay/react-native";
import { ozContractAccount } from "@buckspay/accounts/oz-contract";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
import type { BuckspayConfig } from "@buckspay/core";
const config: BuckspayConfig = {
network: "testnet",
account: ozContractAccount({ network: "testnet", sponsorAddress: "GA...SPONSOR" }),
signer: nativePasskey({ rpId: "app.example.com", rpName: "My App" }),
relayer: buckspayFacilitator({ url: "/api/gasless", network: "testnet" }),
gas: { mode: "sponsored" }
};
```
`nativePasskey` accepts:
| Option | Type | Required | Description |
| -------- | -------- | -------- | ----------------------------------------------------- |
| `rpId` | `string` | Yes | Relying-party domain, bound at registration |
| `rpName` | `string` | No | Human-readable name shown in the authenticator prompt |
### Wrap the tree with `BuckspayProvider`
Exactly the same as web - pass `sim` so `pay()` can simulate:
```tsx theme={null}
import { BuckspayProvider } from "@buckspay/react-native";
import { createRpcSimContext } from "@buckspay/core";
export function App() {
return (
{/* your screens */}
);
}
```
### Connect and pay - identical to web
`useWallet` and `useStellarPay` work exactly as on web. Replace DOM elements with React
Native primitives:
```tsx theme={null}
import { useWallet, useStellarPay } from "@buckspay/react-native";
import { View, Text, Pressable } from "react-native";
function PayScreen() {
const { wallet, connect, status } = useWallet();
const { pay, status: payStatus, receipt } = useStellarPay();
if (!wallet) {
return void connect()} disabled={status === "connecting"}>
{status === "connecting" ? "Connecting..." : "Connect"};
}
return (
void pay([/* calls */])} disabled={payStatus === "relaying"}>
Pay 1.50 USDC (free)
{receipt && Settled: {receipt.transferTx}}
);
}
```
## Secure storage
Session blobs and credential IDs are persisted via a `SecureStore` port. Three adapters are
exported from `@buckspay/react-native`:
| Adapter | Use |
| ---------------------------------- | ------------------------------------------- |
| `memorySecureStore()` | Tests and ephemeral in-memory sessions |
| `expoSecureStore()` | Expo managed workflow (`expo-secure-store`) |
| `keychainSecureStore({ service })` | Bare React Native (`react-native-keychain`) |
Each adapter lazily imports its peer, so apps that do not use a given adapter never pull the
native module into their bundle.
## Full example
```tsx theme={null}
// Recipe 13 - REACT NATIVE. The mobile binding re-exports the SAME @buckspay/react hooks and only
// swaps the signer (native passkey / secure enclave), storage, and Hermes polyfills. The screen
// logic is identical to web (recipe 05); only the host components (/) and the signer
// differ. This example pins the @buckspay/react-native surface; the View/Text layer is the app's,
// so it is omitted here (it would pull react-native intrinsics) - fragments stand in for it.
import {
BuckspayProvider,
useWallet,
useStellarPay,
nativePasskey,
memorySecureStore,
type SecureStore
} from "@buckspay/react-native";
import { ozContractAccount } from "@buckspay/accounts/oz-contract";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
import { createRpcSimContext, type BuckspayConfig, type Call } from "@buckspay/core";
const SPONSOR_G = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
const USDC_SAC = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const MERCHANT = "GA6HCMBLTZS5VYYBCATRBR5VBZJEH5C2OON6XQGB3RNYDDAQ7JZ65YQH";
// Same config shape as web - the ONLY mobile-specific line is the native passkey signer.
export const mobileConfig: BuckspayConfig = {
network: "testnet",
account: ozContractAccount({ network: "testnet", sponsorAddress: SPONSOR_G }),
signer: nativePasskey({ rpId: "app.buckspay.dev", rpName: "buckspay" }),
relayer: buckspayFacilitator({ url: "/api/gasless", network: "testnet" }),
gas: { mode: "sponsored" }
};
// Session blobs / credential ids persist via a SecureStore port (keychain/expo on device).
export const store: SecureStore = memorySecureStore();
// The hooks are byte-identical to @buckspay/react - no fork (recipe 05).
export function PayScreen() {
const { wallet, address, connect } = useWallet();
const { pay, status, receipt } = useStellarPay();
if (!wallet) {
// On device this is a inside a .
return <>{"Connect"}>;
}
const call: Call = {
contract: USDC_SAC,
fn: "transfer",
args: [] // built from address -> MERCHANT in the real screen; see recipe 05
};
void address;
void (() => pay([call]));
return <>{status === "success" && receipt ? `settled: ${receipt.transferTx}` : "Pay 1.50 USDC (free)"}>;
}
// Wrap the tree once, exactly like web - pass `sim` so useStellarPay().pay() can simulate.
export function App() {
return (
);
}
```
## Next
How `nativePasskey` delegates to the shared passkey crypto pipeline.
The web counterpart - same hooks, browser authenticator transport.
# Web - @buckspay/react
Source: https://buckspay.mintlify.app/platforms/web-react
BuckspayProvider, useWallet, and useStellarPay - React hooks for gasless USDC payments in web apps.
`@buckspay/react` wraps the core client in idiomatic hooks. Wrap your component tree once with
`BuckspayProvider`; every component inside can then connect a wallet and send gasless payments
with two hooks.
### Install
```bash theme={null}
pnpm add @buckspay/react @buckspay/core @buckspay/accounts @buckspay/signers @buckspay/relayer
```
### Wrap the tree with `BuckspayProvider`
Place `BuckspayProvider` at the root of the tree (or the subtree that needs payment). Pass a
`BuckspayConfig` and a simulator context so `useStellarPay().pay()` can simulate the transaction
before signing.
```tsx theme={null}
import { BuckspayProvider } from "@buckspay/react";
import { createBuckspayClient, createRpcSimContext } from "@buckspay/core";
import { classicAccount } from "@buckspay/accounts/classic";
import { walletsKit } from "@buckspay/signers/wallets-kit";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
const config = {
network: "testnet",
account: classicAccount(),
signer: walletsKit({ network: "testnet" }),
relayer: buckspayFacilitator({ url: "/api/gasless", network: "testnet" }),
gas: { mode: "sponsored" as const }
};
export function App() {
return (
{/* your app */}
);
}
```
Pass `sim` (a `createRpcSimContext(rpcUrl)`) so `pay()` can record authorization entries
against the Soroban RPC. Omit it only in connect-only apps that never call `pay()`.
### Connect with `useWallet`
`useWallet()` returns `{ wallet, address, connect, status, error }`. Gate the payment UI on
`wallet !== null`:
```tsx theme={null}
import { useWallet } from "@buckspay/react";
function ConnectButton() {
const { wallet, connect, status } = useWallet();
if (wallet) return null;
return (
);
}
```
### Pay with `useStellarPay`
`useStellarPay()` returns `{ pay, prepare, sign, status, receipt, error, reset }`.
* `pay(calls)` - one-shot: prepare -> sign -> send in a single call.
* `prepare(calls)` / `sign(intent)` - split flow for apps that validate the signed intent in a
BFF before sending. See [Facilitator and BFF](/concepts/facilitator-and-bff).
* `reset()` - returns the store to `idle` so the user can retry.
#### Status machine
`status` follows the path:
```
idle -> connecting -> ready -> signing -> relaying -> success | error
```
Render `error.code` (a `BuckspayErrorCode`) for user-facing messages, and announce state
changes with `aria-live` for accessibility.
## Full example
The component below is the compiled, type-checked reference implementation.
```tsx theme={null}
// React hooks - full pay component. Compiles against @types/react 19 + the real hook types.
import { useMemo, type CSSProperties } from "react";
import { Address, nativeToScVal } from "@stellar/stellar-sdk";
import { useWallet, useStellarPay } from "@buckspay/react";
import type { Call } from "@buckspay/core";
const USDC_SAC = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const MERCHANT = "GA6HCMBLTZS5VYYBCATRBR5VBZJEH5C2OON6XQGB3RNYDDAQ7JZ65YQH";
const btn: CSSProperties = { padding: "0.75rem 1.25rem", borderRadius: 8, cursor: "pointer" };
export function PayButton() {
const { wallet, address, connect, status: wStatus, error: wErr } = useWallet();
const { pay, status, receipt, error } = useStellarPay();
const transferCall = useMemo(() => {
if (!address) return null;
return {
contract: USDC_SAC,
fn: "transfer",
args: [new Address(address).toScVal(), new Address(MERCHANT).toScVal(), nativeToScVal(15000000n, { type: "i128" })]
};
}, [address]);
const busy = status === "signing" || status === "relaying";
const shownError = error ?? wErr;
if (!wallet) {
return (
);
}
return (
{receipt &&
settled: {receipt.transferTx}
}
{shownError && (
{shownError.code}: {shownError.message}
)}
);
}
```
## Next
The same hooks on iOS and Android, with `nativePasskey` and secure-storage adapters.
Route factories that keep the API key server-side.
# Mainnet cutover runbook
Source: https://buckspay.mintlify.app/production/mainnet-cutover
The deliberate, reversible, audited procedure to enable Stellar pubnet for the Buckspay SDK and facilitator.
**Invariant:** nothing touches pubnet until the GO/NO-GO gate passes, and every step has a
rollback. Mainnet is OFF by default - the cutover is "turn the deliberate opt-in on, watched",
and the rollback is "turn it back off."
**Owner:** buckspay eng lead, **On-call:** the rotation named in the team runbook (PagerDuty),
**Security sign-off:** per [Audit prep](/production/security/audit-prep).
***
## Pre-flight checklist
Every row maps to a concrete verification command. **All must be green before the GO/NO-GO gate.**
| Item | Owner | Verify |
| ---------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Release gate PASS | eng | `bash scripts/release-gate.sh` exits 0 (all hard guards PASS) |
| Secrets rotated | security | Sponsor secret + facilitator API key rotated post-incident; old keys revoked; `bash scripts/check-no-committed-env.sh` exits 0 (no tracked `.env*`, sponsor ≠ leaked `G`) |
| Sponsor funded + low-balance alert armed | ops | Pubnet sponsor XLM ≥ the configured floor (`SPONSOR_MIN_XLM`); the low-balance alert fires below it (tested in staging) |
| OZ Wasm installed on pubnet + pin parity | eng | Pubnet-installed Wasm hash == `OZ_SMART_ACCOUNT_WASM_HASH`; `node scripts/verify-wasm-hash.mjs` + `bash scripts/check-pin-parity.sh` pass |
| Dedicated RPC | ops | `SOROBAN_RPC_URL_PUBNET` points at a dedicated/consistent provider (NOT a public load balancer); a health probe returns the latest ledger |
| USDC allow-list set | eng | Only Circle's pubnet USDC SAC is accepted by the facilitator relay allow-list |
| Spend caps + kill-switch armed | ops | Per-tx + rolling spend caps configured; the kill-switch is reachable and tested in staging |
| Guarded mainnet smoke green | eng | `BUCKSPAY_E2E_MAINNET=1 BUCKSPAY_E2E=1 pnpm e2e` runs **both** pubnet smokes green with tiny funds (0.0001 USDC) |
### Extended surface (gas-in-token / batch / sessions / login / parallel)
Every row inherits the controls in the base checklist above (spend caps, kill-switch, token
allow-list, redacted observability) - these rows add only what the extended surface introduces.
| Item | Owner | Verify |
| -------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Multicall router pinned on pubnet + parity | eng | Pubnet-installed router address == the SDK pin; `pnpm --filter @buckspay/accounts exec vitest run batch-entry` + `bash scripts/check-pin-parity.sh` pass |
| Session policy-account Wasm pinned | eng | `pnpm --filter @buckspay/accounts exec vitest run policy-account` passes (the on-chain spend-limit + allowlist authorizer hash is pinned) |
| Contract decision gates GO | eng | `bash scripts/check-contract-decisions.sh` exits 0 (the gas-forwarder / batch-router / policy-signer decisions are GO + fixtures pinned) |
| Fee token on the relay allow-list | eng | Only Circle's pubnet USDC SAC is accepted as the gas fee token, so `gas: { mode: "token" }` cannot forward an arbitrary token |
| Signer-proxy secrets server-side | security | web3auth client secret + the OTP backend secret are in the server vault, NOT in any bundle; `POST /auth/social` + `POST /auth/email` are reachable **only** through the app BFF (`@buckspay/nextjs`) |
| Channel-account pool funded + boot-validated | ops | The parallel channel accounts exist on pubnet, are funded above the floor, and are validated at facilitator boot; per-channel low-balance alerts armed |
| Cross-feature smoke green | eng | `BUCKSPAY_E2E_MAINNET=1 BUCKSPAY_E2E=1 pnpm e2e` runs the session + batch + gas-in-token smoke green with tiny funds |
| Feature docs + examples present | eng | `bash scripts/check-feature-docs.sh` exits 0 (every feature guide page + compiled example present, coverage table complete) |
## GO / NO-GO gate
The single decision point.
**GO only if ALL hold:**
* Every pre-flight row is checked green.
* `bash scripts/release-gate.sh` exits 0 (all hard guards PASS).
* The guarded mainnet smoke (classic + contract) is green.
* Two named approvers have signed: the **eng owner** and the **security sign-off**.
**NO-GO if ANY of these is true** (explicit triggers):
* `release-gate.sh` FAILs.
* Either mainnet smoke is red.
* Pubnet sponsor XLM below the floor.
* Pubnet Wasm hash ≠ `OZ_SMART_ACCOUNT_WASM_HASH` (pin mismatch).
* USDC allow-list unset (facilitator would sponsor an arbitrary token).
* Kill-switch unreachable / untested.
| Approver | Role | Date | GO / NO-GO |
| ----------- | ----------------- | ---- | ---------- |
| *(pending)* | eng owner | | |
| *(pending)* | security sign-off | | |
## Rollback
Reverse the cutover with **no fund loss** (pubnet config is OFF by default, so rollback = "turn
the deliberate flag back off"):
1. **Flip the opt-in off.** SDK: stop passing `allowMainnet: true` (browser) / unset
`BUCKSPAY_ALLOW_MAINNET` (Node) -> `resolveNetwork("pubnet", ...)` throws again. Facilitator:
set `STELLAR_NETWORK=testnet` / disable the pubnet config -> the relay refuses
pubnet payloads.
2. **Drain in-flight intents.** Let signed, unexpired auth entries settle or expire
(`signatureExpirationLedger` bounds them); do not accept new pubnet relays (kill-switch).
3. **Revert the docs "supported" change** if the GA changeset already shipped - publish a
follow-up `patch` re-gating note (mainnet remains technically opt-in; the docs walk it back to
"experimental" until re-cutover).
4. **Re-point clients to testnet** and confirm no pubnet traffic reaches the facilitator.
## Sponsor-refill procedure
The pubnet sponsor holds **only operational float**, never custody.
* **Funding source:** the buckspay treasury account (named in the ops vault), NOT a user account.
* **Threshold + target:** refill when sponsor XLM \< `SPONSOR_MIN_XLM` (the low-balance auto-pause
floor); top up to the configured target balance (≈ N days of expected gas).
* **Transfer:** the ops owner submits a payment from the treasury to the sponsor `G...` for the
top-up amount; record the tx hash in the ops log.
* **Confirm:** after the refill confirms, verify the low-balance alert **clears** and
the facilitator resumes relays (auto-pause lifts once balance ≥ floor).
## Emergency pause (kill-switch)
Immediately stop pubnet relays without a full rollback (seconds, reversible).
* **Trip it:** `POST /admin/relays/pause` with `x-admin-api-key` - or set
`RELAYS_ENABLED=false` and restart. `/relay` then returns `503 relays_paused`.
* **Who:** any on-call engineer with the admin key (SEPARATE from the relay `API_KEY`).
* **Propagation:** effective immediately on the instance(s); confirm across all replicas.
* **Verify tripped:** a pubnet relay attempt is refused with `503 relays_paused`; `submitStellarTransfer`
is never reached.
* **Pause vs rollback:** pause is reversible in seconds (`/admin/relays/resume`); rollback is the
full cutover reversal above. Pause first, then decide.
## Incident response
* **Severities:** SEV-1 (sponsor draining / funds at risk), SEV-2 (relays failing / degraded),
SEV-3 (elevated errors, no fund risk).
* **Escalation:** on-call -> eng lead -> security sign-off. Page on SEV-1/2.
* **First five minutes:** (1) **trip the kill-switch** (emergency pause above); (2) assess the
sponsor balance + spend-cap counters; (3) check facilitator logs for the offending
intent (PII-free structured records - chain/token/outcome, hashed address tags);
(4) snapshot RPC/ledger state for the post-mortem.
* **Comms:** status update to the incident channel every 30 min until resolved; customer-facing
note if user funds were affected.
* **Post-incident:** a written review within 48h (timeline, root cause, the guard that should have
caught it, the follow-up). The relayer-trust + sponsor-exposure surfaces are in the
[threat model](/production/security/threat-model).
## Go-live sequence (GA)
The human/machine boundary is explicit: **Claude prepares + stages; the maintainer publishes.**
1. **Claude** - ensure a changeset exists for every change since the last release
(`.changeset/mainnet-ga.md` + any others) and stage it.
2. **Claude / CI** - run the go/no-go gate: `bash scripts/release-gate.sh` **must exit 0** (all
hard guards PASS); `bash scripts/check-cutover-runbook.sh` -> exit 0; `bash scripts/check-feature-docs.sh`
-> exit 0; the guarded mainnet smoke green (`BUCKSPAY_E2E_MAINNET=1 BUCKSPAY_E2E=1 pnpm e2e`).
Security sign-off finalized. This is the GO/NO-GO gate above.
3. **MAINTAINER (user)** - run `pnpm changeset version` (computes the lockstep bump + CHANGELOGs -
never hand-edit a `version`), then `pnpm -r build` + `pnpm -r publish --dry-run` (confirm each
tarball is `dist/`-only), then `pnpm changeset publish` + `git push --follow-tags`. **Claude
never runs version/publish** - it is outward, irreversible, and needs npm auth (see
[Versioning and migration](/production/versioning-and-migration)).
4. **Flip pubnet on** per the pre-flight + GO gate; watch the sponsor balance + spend caps
for the first window; keep the kill-switch one command away.
## Extended surface go-live
Bringing up gas-in-token, atomic batch, sessions, social/email login, and the parallel
channel pool is **additive** to the base cutover - the GO/NO-GO gate, kill-switch, and rollback
above are unchanged and now cover the new flows. Sequence the additions; do not weaken the gate.
1. **New endpoints** (`POST /fee/quote`, `POST /auth/social`, `POST /auth/email`) - enable per the
extended pre-flight. Each inherits the base controls (spend caps, kill-switch, token allow-list,
redacted observability); it does not get a parallel control plane. The `/auth/*` routes are
reachable **only** through the app BFF (`@buckspay/nextjs`) - provider secrets stay server-side,
never in a browser or mobile bundle.
2. **Pinned contracts** - confirm the pubnet-installed **Multicall** router address and the
**policy-account** (session authorizer) Wasm hash match the SDK pins, with the same pin-parity
discipline as the passkey smart-account hash. A mismatch is a **NO-GO** trigger. The FeeForwarder
is facilitator-side (returned by `/fee/quote`); its fee token must be on the relay allow-list.
3. **Channel-account pool** - bring the pool up funded and boot-validated; it is transparent to the
SDK (parallel throughput, no API surface). Watch the per-channel balances alongside the sponsor
for the first window.
4. **Rollback addendum** - disabling the extended surface = unset the FeeForwarder / Multicall /
channel config + the `/auth/*` routes (the relay then refuses token-gas / batched / auth payloads);
the sponsored single-call path keeps working. The kill-switch still stops **all** pubnet relays in
seconds.
# Audit prep
Source: https://buckspay.mintlify.app/production/security/audit-prep
Scope, release-blocker checklist, and sign-off table for the Buckspay SDK security audit.
Scopes a v1 audit and the release-blocker gate. Every blocker maps to a verification command.
## Scope (in)
The SDK packages - `core`, `accounts/oz-contract`, `signers/passkey`,
`relayer/buckspay-facilitator`, `react` - and the facilitator's new `contract` endpoints +
the relay `from`=C-address path. **Focus areas:** auth-entry construction, the `__check_auth`
secp256r1 signature format, expiration/nonce handling, the relayer trust boundary, and the
BFF key boundary. Also **in scope: the deployed `minimal-passkey-account` smart-account wrapper**
(`spikes/passkey-contract/contract/src/lib.rs`) - buckspay-authored `__constructor`/`__check_auth`
around OZ's audited verifier; see [Contract provenance](/production/security/contract-provenance).
## Scope (out / deferred)
OpenZeppelin's audited WebAuthn/secp256r1 **verifier** (`stellar_accounts::verifiers::webauthn::verify`)
* we pin the wasm that calls it and do not re-audit OZ's verifier; the buckspay wrapper around it
is **in** scope (above). Also out of scope: the wallet/authenticator implementations (Freighter,
platform WebAuthn) and the gasless features (token-gas, batch, sessions, social login).
## Contracts vs SDK boundary
buckspay **deploys + authorizes against** a smart-account contract. It does **not** author the
cryptographic verifier (OZ's audited `verify`), but it **does** author the thin
`minimal-passkey-account` wrapper around it (`__constructor`/`__check_auth`) - that wrapper is in
audit scope (see [Contract provenance](/production/security/contract-provenance)). The SDK's off-chain
responsibility ends at producing correct, bounded, validated auth entries and never holding secrets.
The on-chain `__check_auth` (wrapper) -> `verify` (OZ) is the sole signature verifier.
## Transitive risks from `@creit.tech/stellar-wallets-kit` (status)
Its multi-wallet connectors (Trezor/xBull/LOBSTR/Near/Hot/WalletConnect/ethereumjs) pull deps
that are **not buckspay code**:
* **Advisories - RESOLVED (high + critical).** All 5 high + 1 critical were `protobufjs <7.6.1`.
Pinned to the patched `>=7.6.1 <8` via a `pnpm overrides` entry in `pnpm-workspace.yaml`
(kept on the 7.x line to avoid a major bump). `pnpm audit --audit-level=high --prod` is now
clean; the full workspace suite stays green. Remaining: **1 low + 1 moderate**, accepted -
not high-severity, no patch path that doesn't churn the connector tree; re-checked weekly by CI.
* **Copyleft/Unknown licenses - ACCEPTED (confined + CI-locked).** AGPL `ua-parser-js`, LGPL
`rpc-websockets`, GPL `@lobstrco/*`, MPL `@ethereumjs/*`, plus several `Unknown` (Trezor/Near/
Hot/xBull connectors). All are **hard transitive deps of `@creit.tech/stellar-wallets-kit`** -
connector modules the Stellar gasless path never exercises; they are not redistributed as part
of any `@buckspay/*` package. **Finding:** narrowing the runtime module set does
**not** remove them - they are declared by the umbrella package, so importing it pulls the whole
tree regardless of which modules are instantiated (the signer already wires only Freighter/xBull/
LOBSTR). The license gate exempts exactly this set with a per-package reason
(`scripts/check-licenses.mjs`), and a guard locks the acceptance: any copyleft/Unknown dep
**outside** the known wallets-kit set fails CI
(`pnpm --filter @buckspay/signers exec vitest run no-unexpected-copyleft`). **Clean-slate fix
(deferred, deliberate product decision):** vendor a Freighter-only connector via
`@stellar/freighter-api` and drop `@creit.tech/stellar-wallets-kit` entirely - this removes the
copyleft tree but loses multi-wallet (xBull/LOBSTR) support, so it was not taken.
## Release-blocker checklist
| Blocker | Verification command |
| --------------------------------------------------------------- | ------------------------------------------------------------------- |
| Threat model present | `bash scripts/check-threat-model.sh` |
| No secrets in src | `bash scripts/check-no-secrets-in-src.sh` |
| No secret in react bundle | `pnpm --filter @buckspay/react exec vitest run no-secret-in-bundle` |
| Expiration bounded | `pnpm --filter @buckspay/core exec vitest run expiration-bounded` |
| Licenses within allow-list | `pnpm licenses` |
| OZ Wasm hash pinned (real pinned value) | `pnpm --filter @buckspay/accounts exec vitest run oz-wasm-pin` |
| Mainnet gated | `pnpm --filter @buckspay/core exec vitest run network-gate` |
| e2e green on testnet (classic + contract/passkey flows) | `BUCKSPAY_E2E=1 pnpm e2e` |
| `pnpm audit` reviewed (transitive advisories accepted/resolved) | `pnpm audit --audit-level=high --prod` |
Run all at once: `bash scripts/release-gate.sh`.
## Mainnet release blockers
Extends the checklist above with the mainnet-cutover items. Each maps 1:1 to a line in
`scripts/release-gate.sh`.
| Blocker | Verification command | Status |
| ------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------ |
| Secrets rotated (no tracked `.env*`, sponsor ≠ leaked `G`) | `bash scripts/check-no-committed-env.sh` | guard |
| Secret-rotation runbook present | `test -f docs/security/secret-rotation.md` | doc |
| Licenses within allow-list (copyleft confined to wallets-kit) | `node scripts/check-licenses.mjs` | guard |
| No **unexpected** copyleft in prod tree | `pnpm --filter @buckspay/signers exec vitest run no-unexpected-copyleft` | guard |
| OZ wasm-hash pin reproducible | `node scripts/verify-wasm-hash.mjs` | guard |
| Cross-repo wasm-pin parity | `bash scripts/check-pin-parity.sh` | guard |
| Mainnet cutover runbook present | `bash scripts/check-cutover-runbook.sh` | guard |
| Gated mainnet smoke | `BUCKSPAY_E2E_MAINNET=1 BUCKSPAY_E2E=1 pnpm e2e` | gated (skips when unset) |
This gate enforces "no copyleft **outside** the accepted wallets-kit tree", not "zero
copyleft" - the multi-wallet connectors are an accepted, confined risk (see the
transitive-risks section above). Flipping to zero-copyleft requires the deferred Freighter-only
vendoring.
## Sign-off
The audit sign-off is filled by the auditor once `bash scripts/release-gate.sh` is green
end-to-end. The **scope hash** ties a specific audit to a specific tree state - recompute with
`bash scripts/audit-scope-hash.sh`; if it no longer matches, in-scope code changed after sign-off
and the audit must be re-validated.
| Auditor | Date | Scope hash | Result |
| ------------------------------------ | ---- | ------------------------------------------------------------------ | ----------- |
| *(pending - engagement not yet run)* | | `963d5e9e36f38dcb16719da305372ae6af2ea0cb981ff96fbe8a506637f944d9` | *(pending)* |
# Contract provenance
Source: https://buckspay.mintlify.app/production/security/contract-provenance
Which wasm Buckspay deploys, where it comes from, how to reproduce it, and what that means for the audit scope.
Records exactly **which** contract wasm buckspay deploys/authorizes against, **where it
comes from**, **how to reproduce it byte-for-byte**, and **what that means for the audit
scope**. The wasm IS the on-chain `__check_auth` authorizer, so "the code we deploy == the
code we audited" must be a checkable fact - enforced by `scripts/verify-wasm-hash.mjs`,
`scripts/check-pin-parity.sh`, and this document.
**Status:** active, **Pinned hash:** `bf1aa9b2a4f8c05e1e5226009800bf69bd9ab7375ad160a1113d8d31a6ffdc69`, **Last reviewed:** 2026-06-28
***
## Deployed wasm
The deployed contract is the **custom `minimal-passkey-account` wrapper** - single-signer,
buckspay-authored, built locally. **It is NOT the official OpenZeppelin Smart Account release.**
It binds the account to **one** secp256r1 (WebAuthn) public key (`pubkey: BytesN<65>`, the
65-byte uncompressed `0x04‖x‖y`) at construction, and on every authorization it calls
OpenZeppelin's **audited** WebAuthn/secp256r1 verifier `stellar_accounts::verifiers::webauthn::verify`
(stellar-accounts 0.7.1) for the cryptography. The `__constructor` + `__check_auth` wrapper around
that verifier is buckspay code (`spikes/passkey-contract/contract/src/lib.rs`). It deliberately
skips the OZ composable framework (context rules / policies / external verifier contracts) - the
initial gate only needed to prove the `secp256r1 -> __check_auth -> relay` path with a single deploy.
* **Pinned hash:** `bf1aa9b2a4f8c05e1e5226009800bf69bd9ab7375ad160a1113d8d31a6ffdc69`
* The hash is the **sha256 of the wasm bytes**, so it is **network-independent** - identical on
testnet and pubnet. The bytes must be **installed once per network** (`uploadContractWasm`);
see [Pin & install](#pin--install) below.
## Source & commit
* **Crate:** `spikes/passkey-contract/contract` - package `minimal-passkey-account`,
`crate-type = ["cdylib"]`, `publish = false`.
* **Dependencies:** `soroban-sdk 26.1.0`, `stellar-accounts 0.7.1` (both via the build
workspace; the workspace `Cargo.lock` is the determinism anchor - do **not** bump these
without re-pinning).
* **Source files:** `src/lib.rs` (the `__constructor` + `CustomAccountInterface::__check_auth`
impl) and `Cargo.toml`.
* **Git commit of the artifact build:** `77d7b4327cc8870c36f64312bb74ad6bdba96256`
(`git -C buckspay-sdk rev-parse HEAD` at the time the installed/pinned wasm was produced).
* **Pre-built artifact:** `spikes/passkey-contract/wasm/minimal_passkey_account.wasm`
(14,897 bytes) - byte-identical to the installed wasm; committed as the reproducibility
fixture at `packages/accounts/test/fixtures/oz-smart-account.wasm`.
## Reproducible build
Anyone with the toolchain + commit reproduces the **byte-identical** wasm and the same hash.
* **Toolchain:** `stellar` CLI **25.2.0**, `cargo`/`rustc` **1.93**.
* **Commands** (from the crate root):
```bash theme={null}
cd spikes/passkey-contract/contract
stellar contract build
# -> target/wasm32v1-none/release/minimal_passkey_account.wasm
stellar contract optimize --wasm target/wasm32v1-none/release/minimal_passkey_account.wasm
# -> target/wasm32v1-none/release/minimal_passkey_account.optimized.wasm
shasum -a 256 target/wasm32v1-none/release/minimal_passkey_account.optimized.wasm
# -> bf1aa9b2a4f8c05e1e5226009800bf69bd9ab7375ad160a1113d8d31a6ffdc69
```
* **Verify without a Rust toolchain:** `node scripts/verify-wasm-hash.mjs` hashes the committed
fixture and asserts it equals the pin (`pnpm --filter @buckspay/accounts exec vitest run
test/wasm-hash-bytes.test.ts` does the same in CI).
## Audit-scope consequence
Because the deployed wasm is a **custom wrapper** (not the official audited OZ Smart Account
release), the wrapper's logic **IS in audit scope**. Be precise about what is and isn't inherited:
* **Inherited as audited (out of buckspay scope):** only OZ's `stellar_accounts::verifiers::webauthn::verify`
* the WebAuthn/secp256r1 verifier. We pin the wasm that calls it; we do not re-audit OZ's verifier.
* **In scope (the auditor MUST review `src/lib.rs`):**
1. **Single-signer storage** - `pubkey: BytesN<65>` set once in `__constructor`, read in
`__check_auth`; there is **no key-rotation, multi-sig, recovery, or admin/upgrade path**.
2. **Payload conversion** - `signature_payload: Hash<32>` -> `to_bytes().to_bytes()` -> the
`payload` passed to `verify`. Confirm it matches what the verifier expects.
3. **No in-contract replay protection** - the wrapper relies on the **Soroban host nonce** +
the SDK-bounded `signatureExpirationLedger` for anti-replay, not on contract-side state.
Confirm that boundary is sound.
4. **Failure = panic** - `verify` panics on a bad signature (auth rejected); confirm there is
no path that returns `Ok(())` without a successful verify.
* **Residual risk / re-scope trigger:** adopting a future "real OZ Smart Account release" (or any
wasm change) **re-scopes** this - it re-runs the build + hash procedure, re-pins, updates this
doc, and re-audits the new bytes.
## Pin & install
* **SDK pin:** `packages/accounts/src/oz-contract/wasm-pin.ts` - `OZ_SMART_ACCOUNT_WASM_HASH`
(single value; network-independent) + `assertPinnedWasmHash`.
* **Facilitator config:** `facilitator/src/stellarContract.ts` - `OZ_SMART_ACCOUNT_WASM_HASH`
(`Record`, both `stellar-testnet` and `stellar-pubnet` = the pin) +
`isPinnedWasm`. The cross-repo guard `scripts/check-pin-parity.sh` (and
`packages/accounts/test/pin-parity.test.ts`) asserts the SDK pin === the facilitator's
pubnet hash byte-for-byte.
* **Per-network install:** the wasm is installed once per network via `uploadContractWasm`.
Testnet is already installed + proven (`spikes/passkey-contract/DECISION.md` = GO). The pubnet
install is `facilitator/scripts/install-wasm-pubnet.ts` - gated by
`BUCKSPAY_ALLOW_MAINNET=1` + a funded sponsor, idempotent, asserts the installed hash == the pin.
## Sign-off
| Auditor | Date | Wasm hash | Commit |
| ----------- | ---- | ------------------------------------------------------------------ | ------------------------------------------ |
| *(pending)* | | `bf1aa9b2a4f8c05e1e5226009800bf69bd9ab7375ad160a1113d8d31a6ffdc69` | `77d7b4327cc8870c36f64312bb74ad6bdba96256` |
# Key handling
Source: https://buckspay.mintlify.app/production/security/key-handling
Where every key and secret may live, where it must not, and the operational rules that enforce the boundary.
Every place a key/secret could appear, and the rule for each. Enforced by
`scripts/check-no-secrets-in-src.sh` (no secret-shaped material in `packages/*/src`) and the
react bundle guard (`no-secret-in-bundle.test.ts`).
| Asset | MAY live in | MUST NOT live in | Enforced by |
| ------------------------------------------- | --------------------------------------- | ------------------------------------------ | --------------------------------------------------- |
| Stellar secret seed (`S...`) | the user's wallet, test-only `e2e/` env | any `packages/*` source, any client bundle | `check-no-secrets-in-src.sh` + react bundle guard |
| Passkey private key (secp256r1) | the platform authenticator only | exportable storage, any JS variable | WebAuthn (non-extractable by spec) |
| Facilitator API key | server env / BFF | any browser bundle | react bundle guard (`x-api-key`, `apiKey` patterns) |
| Sponsor secret (`STELLAR_SPONSOR_SECRET_*`) | the facilitator env only | referenced by the SDK **at all** | `check-no-secrets-in-src.sh` |
## Operational rules
* **Rotate** the sponsor secret and the facilitator API key on a schedule; treat anything
that ever entered git history as compromised (rotate immediately). See the
[secret rotation runbook](/production/security/secret-rotation).
* The sponsor account holds **only operational float** (enough for fees + reserves), never a
treasury balance.
* **Never log** a raw `S...`/`C...`/`G...` address or a signature; logs carry codes, not PII.
* **Mainnet is gated:** `pubnet` is refused unless `BUCKSPAY_ALLOW_MAINNET=1`
(`core/src/network-gate.ts`), so a default config cannot move real funds.
* The browser **never** holds the facilitator key - authenticated calls go through the
app's same-origin BFF (`/api/gasless/relay`), which injects `x-api-key` server-side.
# Secret rotation runbook
Source: https://buckspay.mintlify.app/production/security/secret-rotation
Enumeration and rotation procedure for every burned secret from the .env.bak incident.
A `.env.bak` file containing **real** secrets was committed to the facilitator repo. Removing
it from `HEAD` did **not** un-leak the values - they live in git history and in every clone.
The only remediation is **rotation**. This runbook enumerates every burned secret and the
exact procedure to rotate it. Enforced by `scripts/check-no-committed-env.sh`.
**Status:** active, **Incident date:** 2026-06, **Owner:** buckspay security
***
## Incident
A `.env.bak` carrying production-grade secrets was committed to `github.com/bucks-pay/facilitator`
(present in commits `25bd513` and `4e8c83b`). It was later removed from tracking with
`git rm --cached .env.bak` and `.env.bak` is now in `.gitignore`. **Removal from `HEAD` is NOT
remediation:** the values remain in git history and in every existing clone/fork, in any CI log
that printed them, and with every current/former collaborator who pulled. Anyone with repo
history has **every value below**. The blast radius is the full set of secrets listed in
the Burned secrets section below. The only fix is to rotate each to brand-new material and abandon the old.
## Burned secrets (rotate ALL)
Exact keys present in the leaked `.env.bak` (verified from the file; values intentionally not
reproduced here).
| Secret | What it controls | Rotation action | New home | Old-value disposition |
| ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ------------------------------------- |
| `STELLAR_SPONSOR_SECRET_TESTNET` | Sponsors/pays for ALL testnet gasless Soroban txs. **Leaked sponsor public key: `GDKACWHUTPRUFHENFT56SL7XPM5FJU25DARE76OG43Q73XCATTUX4RPI`.** | Generate a brand-new sponsor keypair on a clean host; **drain** the leaked G's XLM to the new sponsor (or treasury); abandon the old G permanently - never re-fund it. | Secret manager (`KmsSecretProvider`) | Drained to 0, abandoned, never reused |
| `RELAYER_PRIVATE_KEY` | EVM relayer signer - pays gas / submits on AVAX/Polygon/Base/Celo (`chain.ts` `privateKeyToAccount(env.RELAYER_PRIVATE_KEY)`). | Generate a new EOA; move any operational float off the leaked address; repoint `chain.ts` to the new key. | Secret manager | Float swept out, abandoned |
| `DEPLOYER_PRIVATE_KEY` | Contract-deployer EOA. | Rotate to a new deployer; the leaked one must hold nothing and never deploy again. | Secret manager | Emptied, never deploys again |
| `API_KEY` | Facilitator inbound auth key (`env.API_KEY`, checked on `/relay`). | Issue a new key; distribute to legitimate callers (dashboard BFF); revoke the old at the gateway so the burned value `401`s. | Secret manager / BFF env | Revoked at gateway |
| `ZEROEX_API_KEY` | 0x swap-provider key. | Rotate in the 0x dashboard (revoke old, issue new); update env. | Secret manager | Revoked in 0x dashboard |
| `ONEINCH_API_KEY` | 1inch swap-provider key. | Rotate in the 1inch dashboard (revoke old, issue new); update env. | Secret manager | Revoked in 1inch dashboard |
| `CALIBUR_*` (8: `CALIBUR_AVALANCHE`, `_POLYGON`, `_BASE`, `_CELO`, `_AVALANCHE_FUJI`, `_POLYGON_AMOY`, `_BASE_SEPOLIA`, `_CELO_SEPOLIA`) | EIP-7702 Calibur delegate addresses/keys (mainnet + testnet sets). | Rotate per the Calibur key-rotation procedure; treat every leaked delegate as compromised. | Secret manager | Treated as compromised, rotated |
| `AVAX_RPC_URL`, `BASE_RPC_URL`, `CELO_RPC_URL`, `POLYGON_RPC_URL` | RPC endpoints. | If any URL embeds a provider API key, rotate that provider key; otherwise these are **public endpoints, no secret** - recorded here so the row is not skipped. | env | Provider key rotated iff embedded |
Non-secret values also present (no rotation needed): `PORT`, `SWAP_DEFAULT_SLIPPAGE_BPS`,
`SWAP_MAX_SLIPPAGE_BPS`, `SWAP_QUOTE_TTL_SECONDS`.
The mainnet sponsor `STELLAR_SPONSOR_SECRET_PUBNET` was **NOT** in `.env.bak`, but it MUST be
generated fresh on a clean host post-incident and MUST differ from the leaked address above -
enforced by `scripts/check-no-committed-env.sh` Guard 2.
## Rotation procedure
Apply these four steps to **every** row above:
1. **Generate** new material on a clean host. Never paste an old value into a new file; never
reuse a leaked key on a "fresh" account.
2. **Deploy** to the secret manager / deploy target - the facilitator's `SecretProvider`:
`KmsSecretProvider` in prod, a gitignored `.env` in dev. **Never** into a
tracked file.
3. **Revoke / abandon** the old value at its source: gateway revoke for API keys (0x/1inch/
`API_KEY`); for on-chain keys, sweep all funds out and never reuse the address.
4. **Verify** the new value works and the old value fails: old `API_KEY` -> `401`; leaked sponsor
G -> zero balance and no longer referenced anywhere.
**Drain the leaked sponsor (specific):** build a payment of the full XLM balance (minus the base
reserve) from the leaked `GDKACWHU...4RPI` to the new sponsor (or treasury), submit it **once**,
then record the leaked G as abandoned in this doc. Never re-fund it.
## Never commit a secret file again (controls)
1. **`.gitignore`** in both repos ignores `.env` and `.env.*` and explicitly `.env.bak` /
`.env.local`, allowing ONLY `.env.example`. (SDK: `.env`, `.env.*`, `!.env.example`;
facilitator: `.env`, `.env.bak`, `.env.local`, `.env.*.local`.)
2. **`scripts/check-no-committed-env.sh`** runs in CI and the release gate - fails if any
`.env*` (≠ `.env.example`) is tracked, and fails if the configured pubnet sponsor equals the
leaked address.
3. **Pre-commit / CI secret scanner** (`gitleaks` or `trufflehog`) blocks secret-shaped strings
before they land. This extends `scripts/check-no-secrets-in-src.sh` (blocks
`S...` seeds / PEM markers / `apiKey` literals in `packages/*/src`) to **tracked files
anywhere**, not just `src`.
## Post-rotation verification
* [ ] Leaked sponsor `GDKACWHU...4RPI` balance == 0 (drained + abandoned).
* [ ] New sponsor funded and referenced via the secret manager (never a tracked file).
* [ ] Old `API_KEY` returns `401` at the gateway; new key distributed to the BFF.
* [ ] `bash scripts/check-no-committed-env.sh` exits 0.
* [ ] No `.env*` (except `.env.example`) tracked in either repo:
`git ls-files | grep -E '(^|/)\.env'` shows only `*.env.example`.
* [ ] 0x / 1inch / Calibur / EVM relayer + deployer keys rotated and old ones revoked/emptied.
# Threat model
Source: https://buckspay.mintlify.app/production/security/threat-model
STRIDE-style analysis of the six security surfaces in the Buckspay SDK.
STRIDE-style rows for the six surfaces. Status ∈ .
Verified present by `scripts/check-threat-model.sh`.
## 1. Signer custody
**Threat:** key extraction / unauthorized signing.
**Mitigation:** keys live only in the wallet (Wallets Kit) or the authenticator (WebAuthn
secp256r1). `BuckspaySigner` returns **only** public keys + signatures - there is no
code path that reads a secret. The Ed25519 signer used in tests is a test-only double under
`e2e/`. **Status: mitigated by design.**
## 2. Relayer trust
**Threat:** a malicious/compromised facilitator returns a fake receipt or substitutes the transfer.
**Mitigation:** the facilitator can only *submit* what the payer signed - the on-chain auth
entry binds `from/to/amount/contract` (`facilitator/src/stellarSubmit.ts validateInvocation`),
so the relayer **cannot alter** the transfer. The SDK validates the `Receipt` shape with zod
before trusting `transferTx`. `Relayer` is an interface, so a neutral relayer is droppable later.
**Status: mitigated** (single-provider risk **accepted** for v1; relayer neutrality is planned for a future release).
## 3. Sponsor-key exposure
**Threat:** the facilitator's sponsor secret (`STELLAR_SPONSOR_SECRET_*`) leaks.
**Mitigation:** the sponsor secret lives only in the facilitator env - never in the SDK or any
client; the SDK never references it. Onboarding/deploy txs are sponsor-sandwich validated
server-side (`facilitator/src/stellar.ts ALLOWED_ONBOARD_OPS`). **Status: mitigated**; key
rotation + spend caps tracked in [Key handling](/production/security/key-handling).
## 4. Replay / expiration
**Threat:** a captured signed auth entry is replayed.
**Mitigation:** each entry carries a random `nonce` and a `signatureExpirationLedger`; the
facilitator rejects expired entries (`stellarSubmit.ts`) and the network rejects nonce reuse.
The SDK **caps** the expiration ledger to `MAX_EXPIRATION_LEDGERS` (`core/src/expiration.ts`,
guard test `expiration-bounded.test.ts`), so the replay window can't be widened.
**Status: mitigated.**
## 5. Passkey phishing (rpId binding)
**Threat:** a phishing origin tries to drive the user's passkey.
**Mitigation:** WebAuthn binds credentials to the `rpId`; the SDK requires `passkey({ rpId })`
and the demo derives `rpId` from `window.location.hostname`. A credential created for
`app.buckspay.dev` **cannot** be asserted from `evil.example`. Rule: never accept a
caller-supplied `rpId` that doesn't match the page origin. **Status: mitigated** by WebAuthn +
correct rpId usage.
## 6. Tenant isolation
**Threat:** one tenant's payment/intent leaks into another's flow.
**Mitigation:** the SDK is stateless per `BuckspayClient` instance (one config = one
network/account/relayer); there is no shared module-level mutable state across tenants. The
dashboard BFF scopes intents per tenant (out of SDK scope, but the integrator's
responsibility). **Status: mitigated in SDK**; integrator-responsibility boundary stated.
## Out of scope for v1 / deferred to audit
The audited OpenZeppelin Smart Account *contract* (we pin its Wasm, we don't re-audit OZ),
the wallet/authenticator implementations, and the gasless features (token-gas, batch, sessions,
social login). See [Audit prep](/production/security/audit-prep).
# Versioning and migration
Source: https://buckspay.mintlify.app/production/versioning-and-migration
How @buckspay/* packages are versioned, what your ^ pin actually resolves, and how to migrate across breaking changes.
## Versioning policy
All `@buckspay/*` packages follow [SemVer 2.0.0](https://semver.org/) and are managed with
[Changesets](https://github.com/changesets/changesets) in a monorepo.
### Lockstep releases
**All five packages share a single version and are released together:**
* `@buckspay/core`
* `@buckspay/signers`
* `@buckspay/accounts`
* `@buckspay/relayer`
* `@buckspay/react`
`@buckspay/react@0.3.0` always pairs with `@buckspay/core@0.3.0`. This removes the
compatibility matrix - one version number answers "which packages work together?"
### Bump rules while pre-1.0
The SDK is currently in `0.x`. The bump rules below apply **until `1.0.0`**:
| Change | Bump | Example |
| ----------------------------------------------------- | --------- | ---------------- |
| Breaking API change (rename, remove, behavior change) | **minor** | `0.3.0 -> 0.4.0` |
| New backward-compatible feature | patch | `0.3.0 -> 0.3.1` |
| Bug fix | patch | `0.3.0 -> 0.3.1` |
`major` is reserved for the one-time graduation to `1.0.0`. It is never used before then.
### How `^` behaves on a `0.x` version
npm's caret on a `0.x` range **behaves like a tilde - it allows patch updates only**:
| Pin | Resolves to |
| -------- | ------------------------------------- |
| `^0.3.0` | `>=0.3.0 <0.4.0` (patch updates only) |
| `~0.3.0` | `>=0.3.0 <0.4.0` (same result) |
A consumer on `@buckspay/react@^0.3.0` automatically gets bug fixes and new features within
`0.3.x`, but **must explicitly update** to `0.4.0` when a breaking change ships. This is
intentional: you can't accidentally take a breaking change from a `^` range.
**Recommendation:** pin all five packages with `^`:
```bash theme={null}
pnpm add @buckspay/core@^0.x.y @buckspay/react@^0.x.y
```
Because versions are lockstep, a single version number covers the whole suite.
### Changelogs
Each package ships a `CHANGELOG.md` generated from changeset summaries. Read it before
upgrading across a `minor` bump to understand what the breaking change requires.
***
## Migrating to a new version
### Patch updates (`0.x.y -> 0.x.z`)
No action required. Your `^0.x.y` pin resolves the new patch automatically. No public API
changed.
### Minor updates (`0.x.y -> 0.(x+1).0`) - breaking changes
A minor bump signals a **breaking API change** under the pre-1.0 rules. Steps:
1. **Read the CHANGELOG** for each affected package. The changeset summary describes exactly
what changed and what the replacement looks like.
2. **Update your pin** in `package.json` from `^0.x.y` to `^0.(x+1).0`.
3. **Update all five packages together** - because they are lockstep, mixing versions
(e.g. `core@0.4.0` with `react@0.3.x`) is unsupported and may cause type errors at build time.
4. **Fix call sites** per the CHANGELOG. Breaking changes are accompanied by a migration note
that shows the before/after.
### Future: upgrading to `1.0.0`
When `1.0.0` ships, the `^` caret regains its usual meaning: `^1.0.0` resolves
`>=1.0.0 <2.0.0`. Packages will graduate from `0.x` lockstep at the same time, so the
migration from `0.x` to `1.0.0` is a single coordinated bump across the suite. Migration
notes will be published in each package's `CHANGELOG.md`.
Breaking changes in `1.x+` cost a `major` bump, so `^1.0.0` will never silently take a
breaking change - this is the standard SemVer guarantee that `0.x` does not provide.
# accounts/src/classic
Source: https://buckspay.mintlify.app/sdk-reference/accounts/src/classic/index
# accounts/src/classic
## Interfaces
* [ClassicAccountOptions](/sdk-reference/accounts/src/classic/interfaces/ClassicAccountOptions)
## Variables
* [MULTICALL\_CONTRACT\_ID](/sdk-reference/accounts/src/classic/variables/MULTICALL_CONTRACT_ID)
## Functions
* [buildBatchTransferEntry](/sdk-reference/accounts/src/classic/functions/buildBatchTransferEntry)
* [classicAccount](/sdk-reference/accounts/src/classic/functions/classicAccount)
* [resolveMulticallContract](/sdk-reference/accounts/src/classic/functions/resolveMulticallContract)
# accounts/src/oz-contract
Source: https://buckspay.mintlify.app/sdk-reference/accounts/src/oz-contract/index
# accounts/src/oz-contract
## Interfaces
* [OzContractOptions](/sdk-reference/accounts/src/oz-contract/interfaces/OzContractOptions)
## Variables
* [OZ\_SMART\_ACCOUNT\_WASM\_HASH](/sdk-reference/accounts/src/oz-contract/variables/OZ_SMART_ACCOUNT_WASM_HASH)
## Functions
* [assertPinnedWasmHash](/sdk-reference/accounts/src/oz-contract/functions/assertPinnedWasmHash)
* [contractSalt](/sdk-reference/accounts/src/oz-contract/functions/contractSalt)
* [deriveContractAddress](/sdk-reference/accounts/src/oz-contract/functions/deriveContractAddress)
* [ozContractAccount](/sdk-reference/accounts/src/oz-contract/functions/ozContractAccount)
## References
### buildBatchTransferEntry
Re-exports [buildBatchTransferEntry](/sdk-reference/accounts/src/classic/functions/buildBatchTransferEntry)
***
### MULTICALL\_CONTRACT\_ID
Re-exports [MULTICALL\_CONTRACT\_ID](/sdk-reference/accounts/src/classic/variables/MULTICALL_CONTRACT_ID)
***
### resolveMulticallContract
Re-exports [resolveMulticallContract](/sdk-reference/accounts/src/classic/functions/resolveMulticallContract)
# accounts/src/policy-account
Source: https://buckspay.mintlify.app/sdk-reference/accounts/src/policy-account/index
# accounts/src/policy-account
## Interfaces
* [PolicyAccountOptions](/sdk-reference/accounts/src/policy-account/interfaces/PolicyAccountOptions)
## Variables
* [POLICY\_ACCOUNT\_WASM\_HASH](/sdk-reference/accounts/src/policy-account/variables/POLICY_ACCOUNT_WASM_HASH)
## Functions
* [assertPinnedPolicyWasm](/sdk-reference/accounts/src/policy-account/functions/assertPinnedPolicyWasm)
* [policyAccount](/sdk-reference/accounts/src/policy-account/functions/policyAccount)
* [resolvePolicyAccountAddress](/sdk-reference/accounts/src/policy-account/functions/resolvePolicyAccountAddress)
# accounts/src/policy
Source: https://buckspay.mintlify.app/sdk-reference/accounts/src/policy/index
# accounts/src/policy
## Type Aliases
* [SessionPolicy](/sdk-reference/accounts/src/policy/type-aliases/SessionPolicy)
## Functions
* [allowlist](/sdk-reference/accounts/src/policy/functions/allowlist)
* [buildInstallArgs](/sdk-reference/accounts/src/policy/functions/buildInstallArgs)
* [buildRevokeArgs](/sdk-reference/accounts/src/policy/functions/buildRevokeArgs)
* [compilePolicies](/sdk-reference/accounts/src/policy/functions/compilePolicies)
* [spendLimit](/sdk-reference/accounts/src/policy/functions/spendLimit)
# core/src
Source: https://buckspay.mintlify.app/sdk-reference/core/src/index
# core/src
## Classes
* [BuckspayClient](/sdk-reference/core/src/classes/BuckspayClient)
* [BuckspayError](/sdk-reference/core/src/classes/BuckspayError)
* [GasAbstractionEngine](/sdk-reference/core/src/classes/GasAbstractionEngine)
## Interfaces
* [AccountAdapter](/sdk-reference/core/src/interfaces/AccountAdapter)
* [AccountSimContext](/sdk-reference/core/src/interfaces/AccountSimContext)
* [AccountState](/sdk-reference/core/src/interfaces/AccountState)
* [AssembleInput](/sdk-reference/core/src/interfaces/AssembleInput)
* [AuthDetails](/sdk-reference/core/src/interfaces/AuthDetails)
* [AuthEntryPayload](/sdk-reference/core/src/interfaces/AuthEntryPayload)
* [BatchBuilder](/sdk-reference/core/src/interfaces/BatchBuilder)
* [BuckspayConfig](/sdk-reference/core/src/interfaces/BuckspayConfig)
* [BuckspaySigner](/sdk-reference/core/src/interfaces/BuckspaySigner)
* [BuckspayState](/sdk-reference/core/src/interfaces/BuckspayState)
* [BuckspayWallet](/sdk-reference/core/src/interfaces/BuckspayWallet)
* [BuildBatchEntryInput](/sdk-reference/core/src/interfaces/BuildBatchEntryInput)
* [BuildEntryInput](/sdk-reference/core/src/interfaces/BuildEntryInput)
* [Call](/sdk-reference/core/src/interfaces/Call)
* [EnsureReadyInput](/sdk-reference/core/src/interfaces/EnsureReadyInput)
* [FeeQuote](/sdk-reference/core/src/interfaces/FeeQuote)
* [PreparedIntent](/sdk-reference/core/src/interfaces/PreparedIntent)
* [Receipt](/sdk-reference/core/src/interfaces/Receipt)
* [RecordingResult](/sdk-reference/core/src/interfaces/RecordingResult)
* [Relayer](/sdk-reference/core/src/interfaces/Relayer)
* [RelayPayload](/sdk-reference/core/src/interfaces/RelayPayload)
* [Session](/sdk-reference/core/src/interfaces/Session)
* [SessionGrant](/sdk-reference/core/src/interfaces/SessionGrant)
* [SessionInstallInput](/sdk-reference/core/src/interfaces/SessionInstallInput)
* [SessionManager](/sdk-reference/core/src/interfaces/SessionManager)
* [SessionManagerDeps](/sdk-reference/core/src/interfaces/SessionManagerDeps)
* [SessionRevokeInput](/sdk-reference/core/src/interfaces/SessionRevokeInput)
* [Signature](/sdk-reference/core/src/interfaces/Signature)
* [SignedIntent](/sdk-reference/core/src/interfaces/SignedIntent)
* [SignerKey](/sdk-reference/core/src/interfaces/SignerKey)
* [SorobanSimulateRaw](/sdk-reference/core/src/interfaces/SorobanSimulateRaw)
* [SorobanSimulator](/sdk-reference/core/src/interfaces/SorobanSimulator)
* [SubInvocation](/sdk-reference/core/src/interfaces/SubInvocation)
* [SwapQuote](/sdk-reference/core/src/interfaces/SwapQuote)
* [SwapQuoteRequest](/sdk-reference/core/src/interfaces/SwapQuoteRequest)
* [SwapRequest](/sdk-reference/core/src/interfaces/SwapRequest)
## Type Aliases
* [AccountModel](/sdk-reference/core/src/type-aliases/AccountModel)
* [BuckspayErrorCode](/sdk-reference/core/src/type-aliases/BuckspayErrorCode)
* [FacilitatorChain](/sdk-reference/core/src/type-aliases/FacilitatorChain)
* [GasConfig](/sdk-reference/core/src/type-aliases/GasConfig)
* [Network](/sdk-reference/core/src/type-aliases/Network)
* [RpcFetch](/sdk-reference/core/src/type-aliases/RpcFetch)
* [SessionPolicy](/sdk-reference/core/src/type-aliases/SessionPolicy)
* [SignerType](/sdk-reference/core/src/type-aliases/SignerType)
* [SwapChain](/sdk-reference/core/src/type-aliases/SwapChain)
## Variables
* [MAX\_BATCH\_CALLS](/sdk-reference/core/src/variables/MAX_BATCH_CALLS)
* [USDC\_DECIMALS](/sdk-reference/core/src/variables/USDC_DECIMALS)
## Functions
* [batch](/sdk-reference/core/src/functions/batch)
* [buildUnsignedCallEntry](/sdk-reference/core/src/functions/buildUnsignedCallEntry)
* [buildUnsignedEntry](/sdk-reference/core/src/functions/buildUnsignedEntry)
* [createBuckspayClient](/sdk-reference/core/src/functions/createBuckspayClient)
* [createBuckspayConfig](/sdk-reference/core/src/functions/createBuckspayConfig)
* [createRpcSimContext](/sdk-reference/core/src/functions/createRpcSimContext)
* [createSessionManager](/sdk-reference/core/src/functions/createSessionManager)
* [createSorobanSimulator](/sdk-reference/core/src/functions/createSorobanSimulator)
* [deserializeSession](/sdk-reference/core/src/functions/deserializeSession)
* [getLatestLedger](/sdk-reference/core/src/functions/getLatestLedger)
* [mainnetSimContext](/sdk-reference/core/src/functions/mainnetSimContext)
* [randomNonce](/sdk-reference/core/src/functions/randomNonce)
* [serializeSession](/sdk-reference/core/src/functions/serializeSession)
* [sessionId](/sdk-reference/core/src/functions/sessionId)
* [simulateRecording](/sdk-reference/core/src/functions/simulateRecording)
* [toStroops](/sdk-reference/core/src/functions/toStroops)
# nextjs/src
Source: https://buckspay.mintlify.app/sdk-reference/nextjs/src/index
# nextjs/src
## Interfaces
* [CreateRelayRouteDeps](/sdk-reference/nextjs/src/interfaces/CreateRelayRouteDeps)
* [CreateRelayRouteOptions](/sdk-reference/nextjs/src/interfaces/CreateRelayRouteOptions)
* [CreateSignerProxyRouteDeps](/sdk-reference/nextjs/src/interfaces/CreateSignerProxyRouteDeps)
* [CreateSignerProxyRouteOptions](/sdk-reference/nextjs/src/interfaces/CreateSignerProxyRouteOptions)
## Type Aliases
* [Network](/sdk-reference/nextjs/src/type-aliases/Network)
## Functions
* [createRelayRoute](/sdk-reference/nextjs/src/functions/createRelayRoute)
* [createSignerProxyRoute](/sdk-reference/nextjs/src/functions/createSignerProxyRoute)
# react-native/src
Source: https://buckspay.mintlify.app/sdk-reference/react-native/src/index
# react-native/src
## Interfaces
* [BuckspayProviderProps](/sdk-reference/react-native/src/interfaces/BuckspayProviderProps)
* [NativePasskeyOptions](/sdk-reference/react-native/src/interfaces/NativePasskeyOptions)
* [SecureStore](/sdk-reference/react-native/src/interfaces/SecureStore)
* [UseStellarPayResult](/sdk-reference/react-native/src/interfaces/UseStellarPayResult)
* [UseWalletResult](/sdk-reference/react-native/src/interfaces/UseWalletResult)
## Functions
* [BuckspayProvider](/sdk-reference/react-native/src/functions/BuckspayProvider)
* [expoSecureStore](/sdk-reference/react-native/src/functions/expoSecureStore)
* [keychainSecureStore](/sdk-reference/react-native/src/functions/keychainSecureStore)
* [memorySecureStore](/sdk-reference/react-native/src/functions/memorySecureStore)
* [nativePasskey](/sdk-reference/react-native/src/functions/nativePasskey)
* [useStellarPay](/sdk-reference/react-native/src/functions/useStellarPay)
* [useWallet](/sdk-reference/react-native/src/functions/useWallet)
# react/src
Source: https://buckspay.mintlify.app/sdk-reference/react/src/index
# react/src
## Interfaces
* [BuckspayProviderProps](/sdk-reference/react/src/interfaces/BuckspayProviderProps)
* [UseStellarPayResult](/sdk-reference/react/src/interfaces/UseStellarPayResult)
* [UseWalletResult](/sdk-reference/react/src/interfaces/UseWalletResult)
## Functions
* [BuckspayProvider](/sdk-reference/react/src/functions/BuckspayProvider)
* [useStellarPay](/sdk-reference/react/src/functions/useStellarPay)
* [useWallet](/sdk-reference/react/src/functions/useWallet)
# relayer/src/buckspay-facilitator
Source: https://buckspay.mintlify.app/sdk-reference/relayer/src/buckspay-facilitator/index
# relayer/src/buckspay-facilitator
## Interfaces
* [FacilitatorOptions](/sdk-reference/relayer/src/buckspay-facilitator/interfaces/FacilitatorOptions)
## Functions
* [buckspayFacilitator](/sdk-reference/relayer/src/buckspay-facilitator/functions/buckspayFacilitator)
# signers/src/email
Source: https://buckspay.mintlify.app/sdk-reference/signers/src/email/index
# signers/src/email
## Interfaces
* [EmailSigner](/sdk-reference/signers/src/email/interfaces/EmailSigner)
* [EmailSignerDeps](/sdk-reference/signers/src/email/interfaces/EmailSignerDeps)
* [EmailSignerOptions](/sdk-reference/signers/src/email/interfaces/EmailSignerOptions)
## Functions
* [emailSigner](/sdk-reference/signers/src/email/functions/emailSigner)
# signers/src/passkey
Source: https://buckspay.mintlify.app/sdk-reference/signers/src/passkey/index
# signers/src/passkey
## Interfaces
* [CheckAuthParts](/sdk-reference/signers/src/passkey/interfaces/CheckAuthParts)
* [CreateInput](/sdk-reference/signers/src/passkey/interfaces/CreateInput)
* [CreateOutput](/sdk-reference/signers/src/passkey/interfaces/CreateOutput)
* [GetInput](/sdk-reference/signers/src/passkey/interfaces/GetInput)
* [GetOutput](/sdk-reference/signers/src/passkey/interfaces/GetOutput)
* [PasskeyOptions](/sdk-reference/signers/src/passkey/interfaces/PasskeyOptions)
* [WebAuthnLike](/sdk-reference/signers/src/passkey/interfaces/WebAuthnLike)
## Functions
* [coseToUncompressed](/sdk-reference/signers/src/passkey/functions/coseToUncompressed)
* [decodeCheckAuthSignature](/sdk-reference/signers/src/passkey/functions/decodeCheckAuthSignature)
* [extractCoseKey](/sdk-reference/signers/src/passkey/functions/extractCoseKey)
* [formatCheckAuthSignature](/sdk-reference/signers/src/passkey/functions/formatCheckAuthSignature)
* [passkey](/sdk-reference/signers/src/passkey/functions/passkey)
# signers/src/social
Source: https://buckspay.mintlify.app/sdk-reference/signers/src/social/index
# signers/src/social
## Interfaces
* [AuthDetails](/sdk-reference/signers/src/social/interfaces/AuthDetails)
* [BuckspaySigner](/sdk-reference/signers/src/social/interfaces/BuckspaySigner)
* [SocialProvider](/sdk-reference/signers/src/social/interfaces/SocialProvider)
* [SocialSignerOptions](/sdk-reference/signers/src/social/interfaces/SocialSignerOptions)
* [Web3AuthLike](/sdk-reference/signers/src/social/interfaces/Web3AuthLike)
## Type Aliases
* [Web3AuthLoader](/sdk-reference/signers/src/social/type-aliases/Web3AuthLoader)
## Functions
* [socialSigner](/sdk-reference/signers/src/social/functions/socialSigner)
# signers/src/wallets-kit
Source: https://buckspay.mintlify.app/sdk-reference/signers/src/wallets-kit/index
# signers/src/wallets-kit
## Interfaces
* [KitOptions](/sdk-reference/signers/src/wallets-kit/interfaces/KitOptions)
* [WalletsKitLike](/sdk-reference/signers/src/wallets-kit/interfaces/WalletsKitLike)
## Functions
* [normalizeSignature](/sdk-reference/signers/src/wallets-kit/functions/normalizeSignature)
* [walletsKit](/sdk-reference/signers/src/wallets-kit/functions/walletsKit)
# Email OTP
Source: https://buckspay.mintlify.app/signers/email-otp
Authenticate users with a one-time code sent to their inbox - no wallet, no seed phrase, no OAuth account required.
The email signer authenticates users with a one-time password (OTP) sent to their email address.
The OTP backend and the derived Stellar ed25519 key are custodied server-side by the facilitator;
the browser only ever sees the public key and 64-byte signatures, delivered through your
same-origin signer-proxy.
## When to use
Choose `emailSigner` when you want the lowest-friction onboarding: the user enters their email,
receives a code, and pays - no wallet install, no seed phrase, no OAuth account. The resulting key
backs a classic `G...` account.
The OTP backend secret and the custodied private key are **never** in the browser bundle.
Configure a same-origin `proxyUrl` pointing at your backend signer-proxy, created with
`createSignerProxyRoute` from `@buckspay/nextjs`. The private key never reaches client-side code.
## Factory
```ts theme={null}
import { emailSigner } from "@buckspay/signers/email";
const signer = emailSigner({
proxyUrl: "/api/buckspay/auth/email",
network: "testnet"
});
```
`emailSigner(opts)` accepts an `EmailSignerOptions` object:
| Option | Type | Required | Description |
| ---------- | ----------------------- | -------- | -------------------------------------------------------- |
| `proxyUrl` | `string` | Yes | Same-origin route to the `@buckspay/nextjs` signer-proxy |
| `network` | `"testnet" \| "pubnet"` | Yes | Stellar network to target |
## Two-step authentication
The email signer uses a two-step flow before the payment:
**Step 1 - Request the OTP**
```ts theme={null}
await signer.requestOtp(email);
// Sends the one-time code to the user's inbox.
```
**Step 2 - Verify and authenticate**
```ts theme={null}
const details = await signer.authenticate?.({ email, otp: codeFromInbox });
// details: AuthDetails { publicKey: "G...", provider: "email" }
```
After `authenticate()` resolves, the signer's `getPublicKey()` and `signAuthEntry()` are ready
and the payment can proceed with the normal `prepare -> sign -> send` flow.
## Server-side proxy
Mount the signer-proxy in your backend:
```ts theme={null}
// app/api/buckspay/auth/email/route.ts
import { createSignerProxyRoute } from "@buckspay/nextjs";
export const POST = createSignerProxyRoute({ provider: "email", network: "testnet" });
```
The proxy forwards the `issue` and `verify` actions to the facilitator with the backend secret.
## Example
```ts theme={null}
// Recipe 16 - EMAIL / OTP LOGIN -> gasless USDC. Browser app.
//
// The user enters their email, receives a one-time code, and verifies it. The OTP backend
// and the derived ed25519 key are custodied by the facilitator; the browser only ever sees
// the public key + signatures, reached through YOUR signer-proxy. No secret ships client-side.
import { createBuckspayClient, createRpcSimContext, type BuckspayConfig } from "@buckspay/core";
import { classicAccount } from "@buckspay/accounts/classic";
import { emailSigner } from "@buckspay/signers/email";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
const USDC_SAC_TESTNET = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const MERCHANT = "GA6HCMBLTZS5VYYBCATRBR5VBZJEH5C2OON6XQGB3RNYDDAQ7JZ65YQH";
const signer = emailSigner({
// YOUR same-origin signer-proxy route; forwards to the facilitator with creds server-side.
proxyUrl: "/api/buckspay/auth/email",
network: "testnet"
});
export const emailConfig: BuckspayConfig = {
network: "testnet",
account: classicAccount(),
signer,
relayer: buckspayFacilitator({ url: "/api/buckspay/relay", network: "testnet" }),
gas: { mode: "sponsored" }
};
export const emailClient = createBuckspayClient(
emailConfig,
createRpcSimContext("https://soroban-testnet.stellar.org")
);
export async function payWithEmailLogin(email: string, otpFromInbox: string): Promise {
// 1. Issue the code (UI then collects it from the user's inbox).
await signer.requestOtp(email);
// 2. Verify the code -> resolves the custodied Stellar ed25519 key.
const details = await signer.authenticate?.({ email, otp: otpFromInbox });
console.log("signed in as", details?.publicKey, "via", details?.provider);
// 3. Ordinary gasless payment - accounts/relayer/engine are untouched.
await emailClient.connect();
const call = emailClient.transfer({ token: USDC_SAC_TESTNET, to: MERCHANT, amount: "2.00" });
const receipt = await emailClient.pay([call]);
console.log(receipt.transferTx);
}
```
## Next
Use an OAuth provider (Google, Apple, Discord) instead of an email code.
How the signer-proxy and API key work together server-side.
# Native passkey
Source: https://buckspay.mintlify.app/signers/native-passkey
iOS and Android WebAuthn via the device secure enclave - the same secp256r1 crypto as the web passkey signer, only the authenticator transport differs.
`nativePasskey` from `@buckspay/react-native` brings passkey signing to iOS and Android. It
delegates to the exact same crypto pipeline as the web `passkey` signer - the secp256r1
signatures, the COSE key extraction, and the OZ `__check_auth` payload are byte-for-byte
identical. The only difference is the authenticator transport: `react-native-passkey` drives
the platform's secure enclave instead of `navigator.credentials`.
## When to use
Choose `nativePasskey` for mobile React Native apps where you want the same passwordless,
seed-phrase-free experience as the web passkey flow. The private key never leaves the device
secure enclave. Like its web counterpart, it must be paired with `ozContractAccount`.
## Factory
```ts theme={null}
import { nativePasskey } from "@buckspay/react-native";
const signer = nativePasskey({ rpId: "app.example.com", rpName: "My App" });
```
`nativePasskey(opts)` accepts a `NativePasskeyOptions` object:
| Option | Type | Required | Description |
| -------- | -------- | -------- | ---------------------------------------------------------------------------- |
| `rpId` | `string` | Yes | Relying party domain (same rules as the web passkey - bound at registration) |
| `rpName` | `string` | No | Human-readable name shown in the authenticator prompt |
## The same hooks, same flow
`@buckspay/react-native` re-exports `BuckspayProvider`, `useWallet`, and `useStellarPay` by
reference from `@buckspay/react`. The store and status machine are identical - your screen logic
is the same as a web component. Only the host primitives (`` / ``) and the signer
differ.
## Secure storage
Session blobs and credential IDs are persisted via a `SecureStore` port. Three adapters are
available:
| Adapter | Use |
| ----------------------- | ------------------------------------------------ |
| `memorySecureStore()` | Tests and ephemeral sessions |
| `expoSecureStore()` | Expo managed workflow (uses `expo-secure-store`) |
| `keychainSecureStore()` | Bare React Native (uses `react-native-keychain`) |
Each adapter lazily imports its optional native peer, so apps that do not use a given adapter
never pull its native module into the bundle.
## Peer dependencies
Install these in your React Native app (not in the SDK):
* `react-native` - the React Native runtime
* `react-native-passkey` - the native WebAuthn module
* `react-native-get-random-values` - Hermes crypto polyfill
## Example
```tsx theme={null}
// Recipe 13 - REACT NATIVE. The mobile binding re-exports the SAME @buckspay/react hooks and only
// swaps the signer (native passkey / secure enclave), storage, and Hermes polyfills. The screen
// logic is identical to web (recipe 05); only the host components (/) and the signer
// differ. This example pins the @buckspay/react-native surface; the View/Text layer is the app's,
// so it is omitted here (it would pull react-native intrinsics) - fragments stand in for it.
import {
BuckspayProvider,
useWallet,
useStellarPay,
nativePasskey,
memorySecureStore,
type SecureStore
} from "@buckspay/react-native";
import { ozContractAccount } from "@buckspay/accounts/oz-contract";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
import { createRpcSimContext, type BuckspayConfig, type Call } from "@buckspay/core";
const SPONSOR_G = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
const USDC_SAC = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const MERCHANT = "GA6HCMBLTZS5VYYBCATRBR5VBZJEH5C2OON6XQGB3RNYDDAQ7JZ65YQH";
// Same config shape as web - the ONLY mobile-specific line is the native passkey signer.
export const mobileConfig: BuckspayConfig = {
network: "testnet",
account: ozContractAccount({ network: "testnet", sponsorAddress: SPONSOR_G }),
signer: nativePasskey({ rpId: "app.buckspay.dev", rpName: "buckspay" }),
relayer: buckspayFacilitator({ url: "/api/gasless", network: "testnet" }),
gas: { mode: "sponsored" }
};
// Session blobs / credential ids persist via a SecureStore port (keychain/expo on device).
export const store: SecureStore = memorySecureStore();
// The hooks are byte-identical to @buckspay/react - no fork (recipe 05).
export function PayScreen() {
const { wallet, address, connect } = useWallet();
const { pay, status, receipt } = useStellarPay();
if (!wallet) {
// On device this is a inside a .
return <>{"Connect"}>;
}
const call: Call = {
contract: USDC_SAC,
fn: "transfer",
args: [] // built from address -> MERCHANT in the real screen; see recipe 05
};
void address;
void (() => pay([call]));
return <>{status === "success" && receipt ? `settled: ${receipt.transferTx}` : "Pay 1.50 USDC (free)"}>;
}
// Wrap the tree once, exactly like web - pass `sim` so useStellarPay().pay() can simulate.
export function App() {
return (
);
}
```
## Next
The web counterpart - identical crypto, browser authenticator transport.
How every signer feeds into the same client and payment flow.
# Signers
Source: https://buckspay.mintlify.app/signers/overview
Every signer factory returns a BuckspaySigner that plugs into the same client and the same prepare -> sign -> send flow. Only how you obtain the signer differs.
The central idea behind Buckspay signers: **the payment flow never changes**. Whether the user
authenticates with a browser wallet, a device passkey, a social login, or a mobile secure enclave,
every signer factory returns a `BuckspaySigner` value that drops into the same
`createBuckspayClient` call and the same `prepare -> sign -> send` pipeline.
```ts theme={null}
// The ONLY line that changes across all signer types:
const signer = walletsKit({ network: "testnet" });
// passkey({ rpId: "app.example.com" })
// socialSigner({ provider: "web3auth", clientId, network, proxyUrl })
// emailSigner({ proxyUrl, network })
// nativePasskey({ rpId: "app.example.com" }) // @buckspay/react-native
// Everything below is identical regardless of which signer you chose:
const client = createBuckspayClient({ network, account, signer, relayer, gas });
await client.connect();
const receipt = await client.pay([client.transfer({ token, to, amount })]);
```
## How it works
A signer factory is a function that returns a `BuckspaySigner` object. The object implements two
methods the engine calls internally:
* **`getPublicKey()`** - resolves the Stellar account address backing this signer.
* **`signAuthEntry(payload)`** - signs a Soroban authorization entry for the current payment.
The engine calls these during `sign()` (the second phase of `prepare -> sign -> send`). All user
interaction - wallet pop-ups, WebAuthn prompts, OAuth redirects, OTP verification - happens inside
this one call. The rest of the pipeline is silent.
Some signers also expose an optional **`authenticate?()`** step for social and email flows, which
runs the identity-resolution phase before the payment itself.
## Choosing a signer
| Signer | Package | Best for |
| -------------- | ------------------------------- | ---------------------------------------------- |
| Wallets Kit | `@buckspay/signers/wallets-kit` | Users with Freighter, xBull, or LOBSTR |
| Passkey | `@buckspay/signers/passkey` | Passwordless new-user flows (browser) |
| Social login | `@buckspay/signers/social` | Sign in with Google/Apple/Discord via web3auth |
| Email OTP | `@buckspay/signers/email` | Email-code verification without a wallet |
| Native passkey | `@buckspay/react-native` | iOS and Android (secure enclave) |
Browser injected wallets - Freighter, xBull, LOBSTR.
WebAuthn / secp256r1 on a smart contract account.
Google, Apple, Discord via web3auth - secrets stay server-side.
One-time code over email - no wallet, no seed phrase.
iOS/Android secure enclave via `@buckspay/react-native`.
The shared flow every signer feeds into.
# Passkey
Source: https://buckspay.mintlify.app/signers/passkey
WebAuthn / secp256r1 signing on a smart contract account - the private key never leaves the authenticator.
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
```ts theme={null}
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.
```ts theme={null}
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])`.
```ts theme={null}
// 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 {
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 {
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
The same secp256r1 crypto on iOS and Android via the secure enclave.
When to use ozContractAccount vs. classicAccount.
# Social login
Source: https://buckspay.mintlify.app/signers/social-login
Sign users in with Google, Apple, or Discord via web3auth - the provider secret stays server-side, never in the browser bundle.
The social signer lets users authenticate with an OAuth provider (Google, Apple, Discord) through
web3auth. The OAuth public flow runs in the browser; the provider secret and verifier callback are
handled by your backend signer-proxy. A Stellar ed25519 key backs the resulting account - the user
never manages a seed phrase.
## When to use
Choose `socialSigner` when you want a familiar sign-in button (Google, Apple, Discord) without
requiring your users to install a wallet or remember a seed phrase. The resulting key backs a
classic `G...` account.
The web3auth **client secret** must never be included in a browser bundle. Configure a
same-origin `proxyUrl` pointing at your backend signer-proxy, which is created with
`createSignerProxyRoute` from `@buckspay/nextjs`. Only the public OAuth flow runs in the
browser; the secret callback and verifier go through your server.
## Factory
```ts theme={null}
import { socialSigner } from "@buckspay/signers/social";
const signer = socialSigner({
provider: "web3auth",
clientId: "BNxxxx-your-web3auth-client-id",
network: "testnet",
proxyUrl: "/api/buckspay/auth/social"
});
```
`socialSigner(opts)` accepts a `SocialSignerOptions` object:
| Option | Type | Required | Description |
| ---------- | ----------------------- | -------- | ---------------------------------------------------------- |
| `provider` | `"web3auth"` | Yes | OAuth provider backend (only `"web3auth"` in v1) |
| `clientId` | `string` | Yes | Your web3auth project client ID (public - safe in browser) |
| `network` | `"testnet" \| "pubnet"` | Yes | Stellar network to target |
| `proxyUrl` | `string` | No | Same-origin route to the `@buckspay/nextjs` signer-proxy |
## Authentication
Call `signer.authenticate?.(params)` before the payment. It runs the provider's OAuth flow and
resolves an `AuthDetails` object:
```ts theme={null}
const details = await signer.authenticate?.({ loginProvider: "google" });
// details: AuthDetails { publicKey: "G...", provider: "web3auth" }
```
After `authenticate()` returns, the signer's `getPublicKey()` and `signAuthEntry()` are ready.
Provider or network failures surface as `BuckspayError` with code `AUTH_PROVIDER_ERROR`.
## Server-side proxy
Mount the signer-proxy in your backend (for example, a Next.js App Router route):
```ts theme={null}
// app/api/buckspay/auth/social/route.ts
import { createSignerProxyRoute } from "@buckspay/nextjs";
export const POST = createSignerProxyRoute({ provider: "web3auth", network: "testnet" });
```
The proxy receives the OAuth callback and calls the facilitator's `POST /auth/social` endpoint
with the secret - only public key material is returned to the browser.
## Example
```ts theme={null}
// Recipe 12 - SOCIAL LOGIN (web3auth) -> gasless USDC. Browser app.
//
// The user signs in with Google/Apple/Discord via web3auth. The PUBLIC OAuth runs in the
// browser; the SECRET verifier callback is completed by YOUR backend's signer-proxy
// (`@buckspay/nextjs` createSignerProxyRoute -> facilitator POST /auth/social). web3auth
// hands back a Stellar ed25519 key - the smart-account signer. No web3auth secret and no
// private key ever reach this bundle.
import { createBuckspayClient, createRpcSimContext, type BuckspayConfig } from "@buckspay/core";
import { classicAccount } from "@buckspay/accounts/classic";
import { socialSigner } from "@buckspay/signers/social";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
const USDC_SAC_TESTNET = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const MERCHANT = "GA6HCMBLTZS5VYYBCATRBR5VBZJEH5C2OON6XQGB3RNYDDAQ7JZ65YQH";
// The social ed25519 key backs the classic account (its G-address IS the account).
const signer = socialSigner({
provider: "web3auth",
clientId: "BNxxxx-your-web3auth-client-id",
network: "testnet",
// YOUR same-origin signer-proxy route; it forwards to the facilitator with the secret server-side.
proxyUrl: "/api/buckspay/auth/social"
});
export const socialConfig: BuckspayConfig = {
network: "testnet",
account: classicAccount(),
signer,
// url points at YOUR backend relay route (apiKey server-side), never the raw facilitator key.
relayer: buckspayFacilitator({ url: "/api/buckspay/relay", network: "testnet" }),
gas: { mode: "sponsored" }
};
export const socialClient = createBuckspayClient(
socialConfig,
createRpcSimContext("https://soroban-testnet.stellar.org")
);
export async function payWithSocialLogin(): Promise {
// 1. Run the provider OAuth flow -> resolves the Stellar ed25519 key.
const details = await signer.authenticate?.({ loginProvider: "google" });
console.log("signed in as", details?.publicKey, "via", details?.provider);
// 2. From here it is an ordinary gasless payment - accounts/relayer/engine are untouched.
await socialClient.connect();
const call = socialClient.transfer({ token: USDC_SAC_TESTNET, to: MERCHANT, amount: "1.50" });
const receipt = await socialClient.pay([call]);
console.log(receipt.transferTx);
}
```
## Next
No OAuth required - authenticate with a code sent to the user's inbox.
How the signer-proxy and API key work together server-side.
# Wallets Kit
Source: https://buckspay.mintlify.app/signers/wallets-kit
Connect Freighter, xBull, or LOBSTR with a single factory - the signer plugs into the same client and payment flow as every other Buckspay signer.
The Wallets Kit signer wraps [Stellar Wallets Kit](https://github.com/creit-tech/StellarWalletsKit)
and gives users their preferred browser wallet - Freighter, xBull, or LOBSTR - without any
wallet-specific branching in your application code.
## When to use
Choose `walletsKit` when your users already have a Stellar wallet installed. It suits classic
`G...` accounts backed by an existing keypair. For new users who have no wallet, consider the
[passkey signer](/signers/passkey) instead.
## Factory
```ts theme={null}
import { walletsKit } from "@buckspay/signers/wallets-kit";
const signer = walletsKit({ network: "testnet" });
// network: "pubnet" - for production
```
`walletsKit(opts)` accepts a `KitOptions` object:
| Option | Type | Required | Description |
| ------------------ | ----------------------- | -------- | ---------------------------------------- |
| `network` | `"testnet" \| "pubnet"` | Yes | Which Stellar network to target |
| `selectedWalletId` | `string` | No | Pre-select a wallet (default: Freighter) |
The factory returns a `BuckspaySigner`. The browser wallet library is loaded lazily on first use
so it never runs during server-side rendering.
## Example
The snippet below shows a complete classic gasless transfer using `walletsKit` paired with
`classicAccount`. This is the canonical starting point for users who already have a wallet.
```ts theme={null}
// Quickstart - classic gasless USDC transfer (browser; NO apiKey).
import { createBuckspayClient, createRpcSimContext } from "@buckspay/core";
import { classicAccount } from "@buckspay/accounts/classic";
import { walletsKit } from "@buckspay/signers/wallets-kit";
import { buckspayFacilitator } from "@buckspay/relayer/buckspay-facilitator";
const USDC_SAC: string = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const MERCHANT: string = "GA6HCMBLTZS5VYYBCATRBR5VBZJEH5C2OON6XQGB3RNYDDAQ7JZ65YQH";
export async function quickstart(): Promise {
const buckspay = createBuckspayClient(
{
network: "testnet",
account: classicAccount(),
signer: walletsKit({ network: "testnet" }),
// url points at YOUR backend, which forwards to the facilitator with the key server-side.
relayer: buckspayFacilitator({ url: "/api/gasless", network: "testnet" }),
gas: { mode: "sponsored" }
},
// prepare() simulates against the Soroban RPC.
createRpcSimContext("https://soroban-testnet.stellar.org")
);
await buckspay.connect(); // wallet + ensureReady
const call = buckspay.transfer({ token: USDC_SAC, to: MERCHANT, amount: "1.50" });
const receipt = await buckspay.pay([call]); // prepare -> sign -> send
console.log(receipt.transferTx); // settled on testnet
}
```
## How signatures work
Freighter historically returned a double-encoded auth entry in some versions. The Wallets Kit
signer applies `normalizeSignature` internally before returning, so your code always receives a
clean 64-byte signature regardless of which wallet or version the user has installed.
## Next
Passwordless flow for new users - no wallet required.
Pair walletsKit with classicAccount or ozContractAccount.