Skip to content

Wallet & frontend integration

How the Stellaroid Earn Next.js frontend talks to Stellar: a config layer of NEXT_PUBLIC_* env vars, a multi-provider wallet layer (Freighter extension + Albedo web wallet), and a contract client that simulates reads and signs-and-submits writes over Soroban RPC.

The Stellaroid Earn frontend is a Next.js 15 (App Router) + React 19 app that uses @stellar/stellar-sdk to build and submit Soroban transactions, @stellar/freighter-api for the Freighter browser extension, and @albedo-link/intent for the Albedo web wallet. This page condenses the integration into its three layers and the code patterns you need to extend it.

Stellaroid Earn is an early-access pilot running entirely on Stellar testnet. Every default in the config layer — RPC URL, network passphrase, explorer link — points at testnet. Nothing on this page targets mainnet.

Architecture

The integration is layered so the UI never talks to a specific wallet SDK or to process.env directly:

  • Config layer (frontend/src/lib/config.ts) — reads all NEXT_PUBLIC_* env vars once into a single appConfig object, maps network names to canonical passphrases, and exposes guards like hasRequiredConfig().
  • Wallet provider layer (frontend/src/lib/wallet/) — a registry of providers behind one three-method interface (read, connect, sign). Two providers ship today: Freighter (desktop browser extension) and Albedo (web wallet that also works on mobile). The active provider id is persisted in localStorage so sessions survive reloads.
  • Contract client (frontend/src/lib/contract-client.ts) — builds transactions with @stellar/stellar-sdk. Reads run as signature-free simulations sourced from a read-only address; writes are prepared by the RPC, signed by the active wallet, submitted, and polled to confirmation.
  • UI layer (components/, hooks/) — everything touching a wallet is marked "use client", because both wallet SDKs are browser-only APIs that cannot run in Server Components.
npm add @stellar/stellar-sdk @stellar/freighter-api @albedo-link/intent

Config layer: environment variables

All configuration is public (NEXT_PUBLIC_*), read in one place, and defaulted to testnet values. hasRequiredConfig() requires contractId and rpcUrl; the contract client throws early with a clear message when they are missing.

VariablePurposeDefault
NEXT_PUBLIC_STELLAR_RPC_URLSoroban RPC endpoint used for simulation and submissionhttps://soroban-testnet.stellar.org
NEXT_PUBLIC_STELLAR_NETWORKNetwork name (TESTNET, PUBLIC, or PUBNET), mapped to its canonical passphraseTESTNET
NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASEFallback passphrase used when the network name is not recognizedTestnet passphrase
NEXT_PUBLIC_SOROBAN_CONTRACT_IDDeployed Soroban contract ID (required)empty
NEXT_PUBLIC_STELLAR_READ_ADDRESSFunded account used as the source for read-only simulationsempty (client falls back to a built-in simulation source)
NEXT_PUBLIC_SOROBAN_ASSET_ADDRESSToken contract address of the payout assetempty
NEXT_PUBLIC_SOROBAN_ASSET_CODEDisplay code of the payout assetXLM
NEXT_PUBLIC_SOROBAN_ASSET_DECIMALSDecimal places used when parsing and formatting amounts7
NEXT_PUBLIC_STELLAR_EXPLORER_URLBlock explorer base URL for transaction linkshttps://stellar.expert/explorer/testnet
NEXT_PUBLIC_STELLAR_ADMIN_ADDRESSAdmin account address exposed to the frontendempty
NEXT_PUBLIC_FEE_SPONSOR_ADDRESSFee-sponsor account address exposed to the frontendempty
NEXT_PUBLIC_E2E_MODETest-only wallet bypass for headless e2e runs; honored only when isE2EModeAllowed() passes (checks NODE_ENV, CI, Playwright, and Vercel environment)off
NEXT_PUBLIC_PLAYWRIGHTSet to 1 by the Playwright e2e runner; feeds the same guarded e2e-mode gate and is inert in production and previewoff

getExpectedNetworkPassphrase() resolves the network name to the canonical SDK passphrase (Networks.TESTNET / Networks.PUBLIC) and falls back to the configured passphrase for unrecognized names. Every transaction the app builds or signs uses this expected passphrase.

The network passphrase must match the network the contract is deployed on. A mismatch is the most common integration failure: transactions build fine locally but fail signing or submission.

Wallet provider layer

The wallet layer is no longer Freighter-only. frontend/src/lib/wallet/index.ts keeps a registry of providers (PROVIDERS = [freighterProvider, albedoProvider] — order is display priority in the picker), each implementing the same interface from frontend/src/lib/wallet/types.ts:

export type WalletProviderId = "freighter" | "albedo";

// "extension" wallets need a desktop browser extension; "web" wallets run in
// any browser (including mobile) via a popup/redirect and need no install.
export type WalletProviderKind = "extension" | "web";

export interface WalletProviderModule extends WalletProviderMeta {
  /** Read the current connection without prompting the user. */
  read(): Promise<WalletSnapshot>;
  /** Prompt the user to connect; resolves to a connected snapshot or throws. */
  connect(): Promise<WalletSnapshot>;
  /** Sign a transaction XDR for `address`; resolves to the signed XDR. */
  sign(xdr: string, address: string): Promise<string>;
}
ProviderKindSigning UXMobileNotes
FreighterextensionIn-page popup from the browser extensionNo — desktop browser extension (install at freighter.app)Extension calls are wrapped in a 5-second timeout so a missing extension fails fast instead of hanging
AlbedowebPopup/redirect to albedo.linkYes — any browser, including iOS Safari and Android Chrome; no installNo silently readable session: the connected public key is cached in localStorage for display only, and every signature re-prompts the user. Albedo signs for the network the app requests, so a wrong-network state cannot occur

The active provider id is persisted under the localStorage key stellaroid:wallet-provider, so readWallet() can restore a session on page load without prompting. disconnectWallet() is local-only: it clears that key (and Albedo's cached address) but does not revoke any permissions inside the wallet itself. In e2e mode the whole layer is bypassed with a fixed test address so headless tests never need an extension or popup.

Contract client: reads vs writes

The contract client has two interaction modes with very different requirements:

ReadsWrites
Mechanismrpc.Server.simulateTransaction — executed on the RPC node, never broadcastprepareTransaction → wallet sign → sendTransaction → pollTransaction
Source accountNEXT_PUBLIC_STELLAR_READ_ADDRESS (any funded account; a built-in fallback source is used if unset)The connected wallet's address
SignatureNoneRequired — signed by the active wallet provider
FeesNoneNetwork fee; prepareTransaction attaches the Soroban footprint and resource fee from simulation
Return valueDecoded with scValToNative, then normalized to app typesTransaction hash, plus the decoded return value once confirmed

Because reads need no wallet, anonymous visitors can query contract state (issuers, certificates, opportunities) without connecting anything — the funded read address exists purely because simulateTransaction requires a valid on-chain source account.

The shipped client also carries raw JSON-RPC fallbacks: when the SDK's XDR parser throws Bad union switch on newer RPC response arms, simulation, submission, and polling each retry over plain fetch JSON-RPC calls instead of failing the whole operation.

Key code patterns

Detecting and connecting a wallet

UI code talks only to the public API of @/lib/wallet — never to a specific wallet SDK:

"use client";

import { connectWallet, listProviders, readWallet } from "@/lib/wallet";

// Enumerate wallets for the picker UI (registry order = display priority)
const providers = listProviders();
// [{ id: "freighter", kind: "extension", label: "Freighter", ... },
//  { id: "albedo",    kind: "web",       label: "Albedo", ... }]

// Restore an existing session on mount, without prompting
const existing = await readWallet();

// Prompt the user to connect a specific provider
const snapshot = await connectWallet("albedo");
if (snapshot.status === "connected") {
  // snapshot.address, snapshot.provider, snapshot.isExpectedNetwork
}

Network check

The Freighter provider compares the wallet's reported network against the app's expected one, accepting a match by either passphrase or network name, and the UI gates every write behind the result:

const networkPassphrase =
  networkResponse.networkPassphrase || getExpectedNetworkPassphrase();

// Accept a match by either passphrase or network name
const isExpectedNetwork =
  networkPassphrase === getExpectedNetworkPassphrase() ||
  networkResponse.network === appConfig.network;

// Gate all contract writes behind this flag in the UI
const actionsBlocked =
  wallet.status !== "connected" || !wallet.address || !wallet.isExpectedNetwork;

Read-only call (simulate)

const simulation = await server.simulateTransaction(transaction);

if (rpc.Api.isSimulationError(simulation)) {
  throw new Error(normalizeError(simulation.error));
}

// Decode the XDR return value, then normalize to app types
return transform(scValToNative(simulation.result.retval));

Write call (sign and submit)

Writes follow a five-step shape. Note that signing goes through the wallet layer's signTransaction — whichever provider the user connected handles the actual prompt:

import { signTransaction as signWithWallet } from "@/lib/wallet";

// 1. Build the invocation against the source account's sequence number
const transaction = await buildTransaction(sourceAddress, method, args);

// 2. Prepare: RPC simulation attaches the Soroban footprint and resource fee
const prepared = await server.prepareTransaction(transaction);

// 3. Sign with the active wallet (Freighter popup or Albedo redirect)
const signedXdr = await signWithWallet(prepared.toXDR(), sourceAddress);
const signed = TransactionBuilder.fromXDR(
  signedXdr,
  getExpectedNetworkPassphrase(),
);

// 4. Submit
const sendResponse = await server.sendTransaction(signed);

// 5. Poll until confirmed or failed
const final = await server.pollTransaction(sendResponse.hash, {
  attempts: 20,
  sleepStrategy: () => 1200, // 1.2 s between polls
});

The production client accepts both PENDING and DUPLICATE submission statuses, and treats a FAILED or NOT_FOUND poll result as an error surfaced through a normalizer that maps Soroban's numeric error codes (#1, #2, ...) to human-readable messages matching the contract's Rust contracterror enum.

Common pitfalls

IssueCauseFix
Wrong network passphraseThe wallet is on a different network than NEXT_PUBLIC_STELLAR_NETWORK, or the passphrase does not match the network the contract is deployed onSwitch the wallet's network; the UI gates writes on isExpectedNetwork, so action buttons stay disabled until it matches. Albedo is immune — it signs for the network the app requests
simulateTransaction fails on readsThe read address is not funded on testnet — simulation requires a valid on-chain source account even though nothing is broadcastFund NEXT_PUBLIC_STELLAR_READ_ADDRESS at friendbot.stellar.org
Wallet APIs crash during SSR or buildFreighter and Albedo are browser-only; Server Components cannot access extensions or windowPut "use client" as the first line of every file that touches the wallet layer or React state; the Albedo SDK is additionally lazy-imported so it never executes during SSR
Reads work but writes failSimulation never broadcasts and needs no signature; submission additionally requires prepareTransaction, a wallet signature, and a funded source account on the right networkTreat the two paths separately when debugging — a passing read proves RPC and config, not the write path. Check the poll result status after sendTransaction
scValToNative returns a MapSDK version behavior when decoding contract structsWrite normalizers that check instanceof Map before falling back to plain-object access
Amount precision errorsDisplay-formatted amounts passed directly into contract argsConvert with parseAmountToInt() using NEXT_PUBLIC_SOROBAN_ASSET_DECIMALS (7 for native XLM) before building i128 args

Further reading

The full step-by-step guide — including argument serialization with nativeToScVal, return-value normalizers, error mapping, amount formatting helpers, and complete data-flow walkthroughs — lives in the repo at docs/reference/freighter-integration.md and is the canonical spec for this integration.

The repo guide is written Freighter-first. The shipped code generalizes the same flow behind the multi-provider wallet layer described on this page, so read "Freighter" in the guide as "the active wallet provider".

Frequently asked questions

Which wallets does Stellaroid Earn support?
Two providers: Freighter, a desktop browser extension, and Albedo, a web wallet that signs via a popup/redirect to albedo.link and works in any browser — including iOS Safari and Android Chrome — with no install. Both sit behind the same three-method provider interface (read, connect, sign), so the UI never depends on a specific wallet SDK.
Why does the frontend need a funded read address?
Soroban's simulateTransaction requires a valid on-chain source account even for read-only calls. NEXT_PUBLIC_STELLAR_READ_ADDRESS supplies a pre-funded testnet account so anonymous visitors can query contract state without connecting a wallet. Fund it via friendbot.stellar.org on testnet.
Do read-only contract calls cost fees or require a signature?
No. Reads run through simulateTransaction, which executes the call on the RPC node without broadcasting anything to the network — no signature and no fee. Only state-changing writes are signed by the wallet and submitted.
How does the app handle a wallet on the wrong network?
For Freighter, the app compares the wallet's network passphrase and network name against the expected values derived from NEXT_PUBLIC_STELLAR_NETWORK, and blocks all contract writes until isExpectedNetwork is true. Albedo signs for whichever network the app requests, so a mismatch cannot occur with it.
Is any of this running on Stellar mainnet?
No. Stellaroid Earn is an early-access pilot on Stellar testnet. Every configuration default — the RPC URL, the network passphrase, and the block-explorer link — points at testnet, and no flow in the documentation or repo targets mainnet.

Run it yourself on testnet

Everything documented here is live in the early-access pilot — free, testnet-only, and auditable on stellar.expert.

Wallet & frontend integration | Stellaroid Earn