All skills

Pollar Wallets & Fiat Ramps

Community builtSKILL.md

Add embedded Stellar wallets and local fiat on/off-ramps to a web or React Native app with @pollar/core and @pollar/react.

Install
npx skills add https://github.com/pollar-xyz/pollar
About

Add embedded Stellar wallets and local fiat on/off-ramps to a web or React Native app with @pollar/core and @pollar/react. Users sign in with email, social, or a passkey and get a funded account with no seed phrase; the same client quotes and runs fiat deposits and withdrawals, payments, sponsored (gasless) activation and trustlines, multi-venue swaps, and SEP-53 / SEP-10 ownership proofs. Use when an app needs Stellar wallets for people who do not have one, or needs to move money between fiat and Stellar.

Links
Tags
pollar-wallet-auth · community · pollar-xyz
SKILL.md

Pollar: embedded Stellar wallets and login

Pollar is wallet-as-a-service for Stellar and Solana. A user signs in with Google, GitHub, email OTP, or a device passkey and comes out the other side holding a Stellar account, funded and with trustlines already set, without ever seeing a secret key. The same client also connects external wallets (Freighter, Albedo, xBull) so both kinds of user go through one code path.

The SDK is @pollar/core (framework agnostic) plus @pollar/react (provider, hooks, and prebuilt modals). Server-side signing, sponsorship, and key management live behind the Pollar API, configured from dashboard.pollar.xyz.

When to use this skill

  • The app wants Stellar accounts for users who do not have a wallet and should not manage keys
  • Users need to move money between local fiat (SPEI, Pix, PSE, ACH, and so on) and their wallet: on-ramp quotes, deposit instructions, off-ramp payouts, and the identity checks in between
  • Onboarding must be gasless: the app pays the base reserve, trustline reserves, and fees
  • One login flow has to cover both embedded and external (Freighter / Albedo) wallets
  • The app needs passkey smart accounts (Soroban C-addresses) instead of classic G-addresses
  • The app needs payments, trustlines, swaps, or SEP-10 / SEP-53 ownership proofs on top of that wallet

When NOT to use this skill

  • Writing or auditing a Soroban contract: use the Stellar smart-contracts skill
  • Talking to Horizon or Stellar RPC directly, or signing with a raw keypair held by the app: use the Stellar dapp and data skills
  • Issuing an asset from your own issuer account: use the Stellar assets skill

Read the file that matches the task

TaskFile
Keys, dashboard config, funding modes, origin allowlistSetup (below)
Login flows, auth state machine, sessions, logoutAuthentication (below)
Which wallet a user got, addresses, balancesWallets and balances (below)
React and Next.js wiring, usePollar, prebuilt modals, brandingreact.md
React Native and Expo, polyfills, secure storage, deep linksreact-native.md
Fiat on/off-ramps: quotes, deposit instructions, KYC, payoutsramps.md
Payments, sponsorship, trustlines, swaps, earn, proofstransactions.md
Things that silently break an integrationGotchas (below)

Install

npm install @pollar/react @pollar/core   # React and React Native
npm install @pollar/core                 # anything else (Vue, Angular, Svelte, plain TS, Node)

Requires Node 20+ in the toolchain and React 18+ for @pollar/react.


Mental model

Every authenticated session carries a wallet with one of three custody types. Almost every branch in an integration comes down to which one the user has, so read it once and keep it:

custodyWhat it isAddressSigning
internalEmbedded wallet Pollar provisions at login, keys managed server-side. The default for social and email usersG...Server-side, sponsored per the app config
smartSoroban smart account deployed for a device passkeyC...WebAuthn ceremony on the device
externalA wallet the user already had, connected through an adapterG...The user's own extension or signer
const wallet = client.getWallet(); // WalletInfo | null
if (wallet?.custody === 'internal') {
  /* server signs */
}

There is no isAuthenticated() on the core client. A session exists when getAuthState().step is 'authenticated', or equivalently when getWallet() is non-null. @pollar/react exposes the boolean as isAuthenticated.


Setup

1. Create the app and take the keys

Sign in at dashboard.pollar.xyz, create an application, then Build > API Keys > Generate. Keys are per network and come in two flavours:

PrefixWhere it belongs
pub_testnet_ / pub_mainnet_Frontend. Safe to ship in the bundle
sec_testnet_ / sec_mainnet_Backend only. Never in client code

The key decides the network: a pub_testnet_ key can only talk to testnet. Keep stellarNetwork in the client config in sync with the key prefix rather than treating it as an independent switch.

# .env.local (Next.js)
NEXT_PUBLIC_POLLAR_PUBLISHABLE_KEY=pub_testnet_xxxxxxxxxxxxxxxxxxxx
POLLAR_SECRET_KEY=sec_testnet_xxxxxxxxxxxxxxxxxxxx

Testnet keys are capped at 1,000 requests per day, which is enough for development.

2. Allowlist the origin

Publishable keys are bound to origins. Register every origin the app runs on (including http://localhost:3000) under Build > Domains, or requests get rejected before any auth happens.

React Native and Expo send no Origin header at all. Those keys need the native-clients flag enabled on the key itself, otherwise every request from the app fails the origin check. See react-native.md.

3. Instantiate the client once

import { PollarClient } from '@pollar/core';

// Module scope, a DI container, or React context. Never inside a render function.
export const client = new PollarClient({
  apiKey: process.env.NEXT_PUBLIC_POLLAR_PUBLISHABLE_KEY!,
  stellarNetwork: 'testnet', // default: 'testnet'
  logLevel: 'warn', // 'silent' | 'error' | 'warn' | 'info' | 'debug'
});

await client.ready(); // keypair initialised and any persisted session restored

On the web, storage (localStorage with an in-memory fallback) and the DPoP key manager (non-extractable WebCrypto P-256) are autodetected. React Native has to inject both.

4. Decide the funding mode

Every Stellar account needs reserves. Dashboard > Treasury > Funding Mode picks who triggers the spend, and no code changes when you switch:

ModeReserve is lockedUse for
IMMEDIATEAt registration, automatically at loginConsumer apps with no compliance gate
DEFERREDOnly when your backend calls the fund endpointKYC-gated products, remittances, neobanks

The reserve is sponsored (CAP-33), not transferred: 1 XLM + 0.5 XLM per configured asset stays locked in the app's funding wallet while it sponsors the user account. In DEFERRED mode the wallet exists as a G... address with no on-chain account until funded, so read wallet.existsOnStellar before offering any operation that needs a live account.

In IMMEDIATE mode the account is created in the background, after login() returns. The wallet is real and its address is worth showing, but for the first few seconds nothing on-chain works. Read wallet.provisioning and subscribe once:

// 'READY' | 'CREATING' | 'FAILED'. Absent on a session minted before 0.11.4 -
// undefined means "not reported", not "not ready".
if (client.getWallet()?.provisioning === 'CREATING') showPreparing();

const off = client.onWalletStateChange((provisioning) => {
  if (provisioning === 'READY') enableSending();
  if (provisioning === 'FAILED') showSupportPath(); // a new login re-enqueues it
});

The server refuses on-chain operations in that window with SDK_WALLET_NOT_READY (409) rather than letting each one fail as op_no_source_account. Recognize it with isWalletNotReady(errorOrOutcome), which takes a thrown PollarApiError or a returned tx outcome, and wait for the transition rather than retrying in a loop. @pollar/react's Send, Receive and wallet-button templates already do this; a custom template gets the sentence from the optional notReadyReason prop or from the exported walletNotReadyReason(wallet, chain).


Authentication

client.login() is fire and forget. It returns void and reports progress through the auth state subscription, so never await it and never derive UI state from its return value.

import { PollarClient, WalletType } from '@pollar/core';

client.login({ provider: 'google' });
client.login({ provider: 'github' });
client.login({ provider: 'email', email: 'user@example.com' });
client.login({ provider: WalletType.FREIGHTER }); // an adapter's `type` IS the provider
client.login({ provider: 'xbull' }); // adapters registered via `walletAdapters`

client.verifyEmailCode('123456'); // second step of the email flow
client.cancelLogin(); // abort whatever is in flight, back to `idle`

There is no 'wallet' provider. External wallets are selected by the adapter id.

Passkey smart accounts are their own pair of calls, and both need the passkey ceremony injected in the config (@pollar/react supplies it with @simplewebauthn/browser):

client.loginSmartWallet(); // returning user, WebAuthn get
client.createSmartWallet(); // new user, WebAuthn create plus sponsored deploy

Drive the UI from the state machine

const unsubscribe = client.onAuthStateChange((state) => {
  // state.step: 'idle' | 'authenticating' | ... | 'authenticated' | 'error'
  render(state.step, state.errorCode);
});

In React use useSyncExternalStore over onAuthStateChange plus getAuthState, or just use usePollar() from @pollar/react. See react.md.

PII is memory only

The session persisted to storage holds ids, tokens, and wallet addresses. Email, name, avatar, and the linked providers are never written to disk:

const profile = client.getUserProfile();
// { mail, first_name, last_name, avatar, providers } | null, until /auth/login completes

Sessions and logout

const sessions = await client.listSessions(); // one row per refresh-token family
await client.revokeSession(familyId);
await client.logout(); // this device
await client.logout({ everywhere: true }); // every session for this user

Every authenticated request is signed with a DPoP proof (RFC 9449) bound to a per-session keypair, so a stolen access token is useless on its own. client.refresh() is race safe: concurrent 401s coalesce into a single refresh.


Wallets and balances

const wallet = client.getWallet(); // primary Stellar wallet, or null
const all = client.getWallets(); // one per chain, [] on pre-multichain sessions

await client.refreshBalance(); // pushes into WalletBalanceState
const state = client.getWalletBalanceState();

The wallet exposes address only. There is no publicKey field, and address can be null when the wallet has no address yet.

Balance records are multichain and tagged by chain, each carrying decimals, limit, and sponsored. balance and available are string | null, and null means the chain could not be read. Render it as unavailable, never as 0, or the UI will tell a user their money is gone during a transient RPC outage.

For anything that moves value (payments, trustlines, swaps, ramps, earn, ownership proofs) read transactions.md.


Gotchas

These are the failures that look like SDK bugs and are not:

  1. Recreating the client on every render. PollarClient owns the keypair, storage listeners, and the refresh scheduler. Instantiate it once. In @pollar/react the client prop is locked at first render and swapping it later is ignored.
  2. Awaiting login(). It returns void. Subscribe to the auth state instead.
  3. Origin not allowlisted. The first symptom is a rejected request before any login UI appears. Add the origin under Build > Domains, localhost included.
  4. React Native without the entry-file polyfills. DPoP proof construction needs crypto.getRandomValues, TextEncoder / TextDecoder, and a spec-compliant URL. Missing any of them means no authenticated request works at all. See react-native.md.
  5. Treating a null balance as zero. See above.
  6. Reading PII off the persisted session. It is not there. Call getUserProfile().
  7. Assuming the account exists on-chain. Three different reasons it may not. In DEFERRED funding mode it is unfunded until the backend funds it; a freshly connected external wallet needs createAccount(); and in IMMEDIATE mode a brand-new embedded wallet is still being created for the first seconds after login. Check wallet.existsOnStellar, and wallet.provisioning for the third - retrying a SDK_WALLET_NOT_READY in a loop instead of waiting for onWalletStateChange is the usual mistake.
  8. Deciding sponsorship client-side. Who pays is server-side app config. The client can only opt out with skipSponsorship: true.
  9. Expecting smart wallets to do everything. Passkey C-address sessions have no classic trustlines, do not use signTx (they go through signAndSubmitTx), and are not supported yet by swaps, earn, or SEP-53 / SEP-10 proofs, which need a classic ed25519 key.
  10. Mixing a mainnet key with stellarNetwork: 'testnet'. The key wins. Keep them in sync.

Version note

This skill tracks @pollar/core and @pollar/react 0.11.x, which moved every request to the /v2 API and added Solana alongside Stellar. 0.11.4 is where wallet.provisioning, onWalletStateChange() and isWalletNotReady() come from, along with ramp requirement steps (requirementsRequired, see ramps.md); on an older SDK the background-creation window exists but nothing in the client describes it. 0.11.5 adds Turnkey co-signed wallets, settled by the device on its own under the default confirmation: 'auto', and confirmation: 'prompt' to show the user every signature first. Two earlier breaks matter if an existing integration is being upgraded: 0.11.1 made balances nullable, and 0.10.0 replaced the singular walletAdapter resolver and loginWallet(id) with a walletAdapters: WalletAdapter[] array. Check UPGRADE.md before bumping versions.

Sources

Verify anything version-sensitive against the upstream docs rather than assuming this file is current:

Related skills