# Fedimint SDK for React Native: the full guide # Add ecash and Lightning to the app you already have Join a federation, hold ecash, send and receive it, and restore from seed words, with one typed SDK. ```sh npm install --save-exact @fedimint/react-native@0.1.0 @fedimint/react-native-bindings@0.1.0 cd ios && pod install ``` ```tsx const session = await openSdk({ dataDir }) const fed = await session.sdk.join(InviteCode.parse(code)) const msat = useBalance(fed) return {`${msat / 1000n} sats`} ``` ## What is Fedimint [Fedimint](https://fedimint.org) is an open source protocol that lets a group of guardians, a _federation_, hold bitcoin together for a community. The federation issues **ecash**: digital notes, backed by that bitcoin, that your wallet keeps on the device. Ecash payments are fast, cheap and private, and a Lightning gateway connects them to the Lightning Network. In short: your app holds ecash and talks to one federation. The federation reaches Lightning through a gateway. Your app can also give notes straight to another wallet. ## What this SDK does This guide follows one path, from an empty project to a wallet that joins a federation, holds ecash, moves it two ways, lists its history and restores itself from seed words. It uses two npm packages. You import everything from `@fedimint/react-native`. It adds `openSdk` and two React hooks, `useBalance` and `useOperation`. `@fedimint/react-native-bindings` holds the native part, generated from the Rust crate `rust/fedimint-sdk`. Your app lists both. The packages ship prebuilt native libraries for iOS 15.0 or newer and Android API 24 or newer. Nothing compiles when you install them. They need the New Architecture, and they do not run in Expo Go. Expo apps use a config plugin and a development build. Amounts are millisatoshis (msat), as a `bigint`. A failed payment is a state that you observe, not an error that is thrown. Every code block in this guide is compiled against the packages before it is published here. This guide does not cover on chain send and receive. A federation that supports them gives you `fed.onchain()`. ## Where to start - [**Quickstart**](quickstart.md): From an empty app to a balance on screen in one page. - [**Guides**](join.md): One task each: join, balance, receive, send, history, share the session, restore. - [**Reference**](errors.md): Errors, amounts, and seed words. ## For AI agents An agent can follow this guide from start to end. Every page is also plain Markdown. Give the agent one of these files: - [**llms-full.txt**](/react-native/llms-full.txt): Every page in one file. Use this one first. - [**llms.txt**](/react-native/llms.txt): The list of pages, with a link to each page as Markdown. - md/quickstart.md: One page as plain Markdown. Every page has one, at `md/.md`. - [**SKILL.md**](https://github.com/fedimint/fedimint-sdk/blob/main/skills/fedimint-react-native-install/SKILL.md): An agent skill for a first install in an existing app: checks, safety rules, failures and a definition of done. Then give the agent this prompt: ```text Read llms-full.txt from the Fedimint SDK for React Native guide. Add the SDK to this React Native app. Follow the pages in order, from Installation to Restore from seed words. Install the exact package versions that the guide gives. Make sure that each step works before you start the next one. ``` ## The pages - [Quickstart](quickstart.md) - [Installation](installation.md) - [Join a federation](join.md) - [Show a balance](balance.md) - [Receive money](receive.md) - [Send money](send.md) - [See the history](history.md) - [Share the session](session.md) - [Restore from seed words](restore.md) - [Errors](errors.md) - [Amounts](amounts.md) - [Seed words](seed-words.md) --- # Quickstart From an empty React Native app to a federation balance on screen. - Proven: iOS 15.0 and Android API 24 or newer - Proven: prebuilt native libraries - Proven: New Architecture required - Not yet: not available in Expo Go This page is the short path. It joins Fedi Testnet, a test federation on signet, and shows its balance. Each step links to the page with the full detail and the reasons. ### 01 · Install the two packages Add both packages at the same exact version. Your app must list both, because autolinking links only the packages your app names. The packages ship prebuilt native libraries, so nothing compiles at install. The screen below also uses `react-native-fs` to find the documents folder. `install.sh` ```sh npm install --save-exact @fedimint/react-native@0.1.0 @fedimint/react-native-bindings@0.1.0 npm install react-native-fs ls node_modules/@fedimint/ ``` Install the iOS pods. For Android, build only the two ABIs the packages ship. Set them in `android/gradle.properties`. `pods.sh` ```sh cd ios export LANG=en_US.UTF-8 bundle exec pod install ``` `android/gradle.properties` ```properties reactNativeArchitectures=arm64-v8a,x86_64 ``` With Expo, add the config plugin and make a development build instead. [Installation step 03](installation.md#step-03) shows how. [Installation](installation.md) has every step, and what to do when one fails. You will see `react-native` and `react-native-bindings` in `node_modules/@fedimint/`, and `Pod installation complete!`. ### 02 · Write the screen Replace `App.tsx` with this file. It does four things: 1. `openWallet` opens the data folder once for the whole app. A second open of the same folder fails with `StorageInUse`. The first open of an empty folder creates the seed. [Share the session](session.md) has the full detail. 2. `connectTestnet` parses the invite code. Then it reconnects to the federation if this device holds it, or joins it on the first launch. [Join a federation](join.md) has the full detail. `connectWallet` runs the open and the connect once. React can run an effect twice, and a second `join` would fail with `AlreadyJoined`. 3. `useBalance` returns the balance in msat, as a `bigint`. It is `undefined` until the first value arrives. Then it stays current. [Show a balance](balance.md) has the full detail. 4. `explain` turns a failure into its error code and reason. [Errors](errors.md) has the full detail. `App.tsx` ```tsx import React, { useEffect, useState } from 'react' import { SafeAreaView, Text } from 'react-native' import RNFS from 'react-native-fs' import { ErrorCode, Exception, InviteCode, openSdk, useBalance, type FederationLike, type SdkSession, } from '@fedimint/react-native' // Fedi Testnet, a test federation on signet. Its money has no value. const TESTNET_INVITE = 'fed11qgqrgvnhwden5te0v9k8q6rp9ekh2arfdeukuet595cr2ttpd3jhq6rzve6zuer9wchxvetyd938gcewvdhk6tcqqysptkuvknc7erjgf4em3zfh90kffqf9srujn6q53d6r056e4apze5cw27h75' let opening: Promise | undefined /** The one session for the whole app. Every caller gets the same promise. */ function openWallet(): Promise { // A plain path, not a file:// URI. const dataDir = `${RNFS.DocumentDirectoryPath}/fedimint` opening ??= openSdk({ dataDir }).catch((err: unknown) => { opening = undefined // let the next caller try again throw err }) return opening } /** Reconnect to Fedi Testnet if this device holds it. Join it on the first launch. */ async function connectTestnet(session: SdkSession): Promise { const { sdk } = session const invite = InviteCode.parse(TESTNET_INVITE.trim()) const id = invite.federationId() const stored = sdk.storedFederations().some((f) => f.id === id) return ( sdk.federation(id) ?? (stored ? await sdk.reopenFederation(id) : await sdk.join(invite)) ) } let connecting: Promise | undefined /** Open and connect once. A second call, such as an effect that runs twice, gets the same promise. */ function connectWallet(): Promise { connecting ??= openWallet() .then(connectTestnet) .catch((err: unknown) => { connecting = undefined // let the next caller try again throw err }) return connecting } function explain(e: unknown): string { if (!Exception.hasInner(e)) return String(e) const inner = Exception.getInner(e) return `${ErrorCode[inner.code()]}: ${inner.reason()}` } export default function App() { const [fed, setFed] = useState() const [error, setError] = useState() useEffect(() => { let cancelled = false connectWallet().then( (f) => { if (!cancelled) setFed(f) }, (e: unknown) => { if (!cancelled) setError(explain(e)) }, ) return () => { cancelled = true } }, []) // undefined until the first value arrives, then the balance in msat, kept current. const msat = useBalance(fed, (e) => setError(explain(e))) const line = error !== undefined ? `FAILED: ${error}` : msat === undefined ? 'loading...' : `${msat / 1000n} sats` return ( {fed?.name() ?? 'Fedi Testnet'} {line} ) } ``` You will see no errors from `npx tsc --noEmit`. ### 03 · Run it Build and run. This is a full native build, because a new native library went in. A JavaScript reload is not enough. `run.sh` ```sh npx react-native run-ios npx react-native run-android ``` The first launch joins Fedi Testnet. Later launches reconnect to it. To see the number change, pay the wallet from a signet faucet. [Receive money](receive.md) shows how to make an invoice. You will see the federation name and `0 sats`. If you see `FAILED:`, the line names the error code. [Installation](installation.md#troubleshooting) lists the common ones. ## Next - [Receive money](receive.md) - [Send money](send.md) - [Share the session](session.md) --- # Installation Add both packages, set up each platform, and see the SDK answer on a device. - Proven: prebuilt native libraries - Proven: iOS 15.0 and Android API 24 or newer - Proven: Expo config plugin - Not yet: not available in Expo Go By the end of this page your wallet will show one line of text: a Rust program inside your app has stored a seed. That is a small result, and it is the right first result, because it proves every layer at once. If the line appears, both packages resolved, the native library linked, the bridge loaded and the Rust core answered. **These packages contain compiled Rust, so they cannot run inside Expo Go.** Expo Go ships a fixed set of native libraries and yours is not one of them. Use a development build instead. Step 03 shows the Expo path: a config plugin, `npx expo prebuild`, and a development build. A bare React Native project skips step 03. Everything else in Expo still works. The router, the asset pipeline, the config plugins and over the air updates are all unaffected. You give up one client app, not the framework. ## Two packages, and why `@fedimint/react-native` is the package you import. It adds `openSdk`, which opens the SDK over a folder, and two React hooks, `useBalance` and `useOperation`. It also re-exports everything from the bindings, so every import in this guide comes from this one package. `@fedimint/react-native-bindings` is the native part. It is generated from the Rust crate `rust/fedimint-sdk`, and it holds the prebuilt native libraries, the podspec and the Android build. Nothing compiles when you install it. Your app must list it too, because React Native autolinks only the packages your app names directly. A native library that only another package depends on is never linked. | Item | Version | Note | | --------------------------------- | ----------------- | ------------------------------------------------------------------- | | `@fedimint/react-native` | `0.1.0` | Install the exact version | | `@fedimint/react-native-bindings` | `0.1.0` | The same version as the package above | | React Native | `0.78` or newer | `0.83` is recommended | | React | `18` or newer | | | New Architecture | Required | The bindings are a Turbo Module. There is no legacy bridge fallback | | iOS | `15.0` or newer | | | Android | API `24` or newer | ABIs `arm64-v8a` and `x86_64` only | | Expo | SDK `52` or newer | Development build only, not Expo Go | ## The seven steps Steps 01 to 05 are setup and each one can fail loudly. Steps 06 and 07 are the payoff. Work in order. Every step ends with something you can check, so you never carry a broken state into the next one. ### 01 · Check your toolchain Run these before you touch the project, because each failure below arrives later disguised as something else. You need no Rust toolchain: the native libraries come prebuilt. `check-toolchain.sh` ```sh node --version # 22.13 or newer ruby --version # must match your project's .ruby-version xcodebuild -version # 16.1 or newer, for iOS pod --version # 1.13 or newer, for iOS java -version # 17, for Android echo "LANG=$LANG" # must not be empty ``` **If `LANG` is empty, fix it now:** `export LANG=en_US.UTF-8`, and add that line to your shell profile. CocoaPods will otherwise fail deep inside Ruby with `Unicode Normalization not appropriate for ASCII-8BIT`, which names Ruby internals and never mentions the locale. If `ruby --version` disagrees with the `.ruby-version` file in your project, install the pinned version with a version manager such as rbenv. If `pod` is missing, your project almost certainly has a `Gemfile`: run `bundle install`, then use `bundle exec pod` everywhere below. Three Ruby problems come up often: - If `rbenv install ` says `definition not found`, your ruby-build is too old. Run `brew upgrade ruby-build`, then install again. - If `bundle install` fails, install the Bundler version that `Gemfile.lock` names under `BUNDLED WITH`: `gem install bundler:`. - In a non-interactive shell (CI, or an agent), rbenv is not active, so `ruby` is the macOS system Ruby. Run `eval "$(rbenv init - zsh)"` first, with `bash` in place of `zsh` in a bash shell. The name must match the shell, or the init fails with `complete: command not found`. Then check that `ruby -v` matches `.ruby-version`. For Android, set two environment variables. Set `JAVA_HOME` to a JDK 17 or newer. The JBR that comes with Android Studio works: `/Applications/Android Studio.app/Contents/jbr/Contents/Home`. Set `ANDROID_HOME` to your Android SDK folder. On macOS it is usually `~/Library/Android/sdk`. Then open the SDK Manager and install the NDK and CMake. Install the NDK version that `ndkVersion` names in your app's Android build files. You will see five versions and a locale that is not empty. ### 02 · Add both packages to your app Install both packages at the same exact version. `--save-exact` writes the version into `package.json` with no range, so the two can never drift apart. The probe in step 06 also uses `react-native-fs` to find the documents folder. Skip that line if your app already has a file library. `install.sh` ```sh npm install --save-exact @fedimint/react-native@0.1.0 @fedimint/react-native-bindings@0.1.0 npm install react-native-fs ls node_modules/@fedimint/ ``` The bindings package is a download of several hundred megabytes. A slow install is not a hang. Wait for it to finish. You will see both packages: ```sh $ ls node_modules/@fedimint/ react-native react-native-bindings ``` ### 03 · With Expo: add the plugin and prebuild Skip this step in a bare React Native project. With Expo SDK 52 or newer, add the config plugin to `app.json`. On Android it turns on AndroidX and the New Architecture. On iOS it does nothing. `app.json` ```json { "expo": { "plugins": ["@fedimint/react-native"] } } ``` If `app.json` already has a `plugins` list, add the entry to it. Then generate the native projects and make a development build. The development build replaces Expo Go on your device or simulator. `expo-prebuild.sh` ```sh npx expo prebuild npx expo run:ios npx expo run:android ``` `npx expo prebuild` runs `pod install` for you, so an Expo project skips step 04. It still needs step 05. Set the ABIs again after `npx expo prebuild --clean`, because that command writes a new `android` folder. You will see new `ios` and `android` folders, and your app opens in its own development build, not in Expo Go. ### 04 · Install the iOS pods Autolinking finds the bindings on its own, because your app lists them. You do not edit the `Podfile`. `pod install` runs in the `ios` folder, not in the project root. Set `LANG` first, as step 01 says. `pods.sh` ```sh cd ios export LANG=en_US.UTF-8 bundle exec pod install --repo-update ``` The `--repo-update` flag refreshes the CocoaPods spec index. Use it for the first install. Without it, an old cached index can fail the install. Two pods arrive together. `ReactNativeBindings` holds the native library. `uniffi-bindgen-react-native` is the runtime that carries values across the boundary between JavaScript and Rust. CocoaPods does not take this second pod from `node_modules`. The `ReactNativeBindings` podspec asks for it by version, and CocoaPods downloads it from the CocoaPods trunk. `@fedimint/react-native` adds no pod, because it has no native code. Your `Podfile.lock` will name both: ```text ReactNativeBindings uniffi-bindgen-react-native ``` If they are missing, the package was not found. Ask the tooling what it can see before you change anything: `check-autolink.sh` ```sh npx react-native config | grep -A3 react-native-bindings ``` ### 05 · Set the Android ABIs The bindings ship native libraries for two Android ABIs only: `arm64-v8a` for phones and for emulators on Apple silicon Macs, and `x86_64` for emulators on Intel hosts. Tell Gradle to build only those two. Find the `reactNativeArchitectures` line in `android/gradle.properties` and replace it, or add it if it is missing. `android/gradle.properties` ```properties reactNativeArchitectures=arm64-v8a,x86_64 ``` There is no other Android step. Gradle picks the package up through autolinking on the next build. You will see the one line: ```sh $ grep reactNativeArchitectures android/gradle.properties reactNativeArchitectures=arm64-v8a,x86_64 ``` ### 06 · Call the SDK This screen is a probe. It opens the SDK and asks how many seed words it holds. It joins nothing and talks to no network. Create the file whole. Four things here will surprise you if you are coming from an ordinary library: 1. `openSdk` takes a `dataDir`, because the SDK owns a database on disk. Your app chooses where it lives. Give a plain path, not a `file://` URI. `RNFS.DocumentDirectoryPath` is already a plain path. 2. The first open of an empty folder creates a seed and stores it. Every later open reads the same seed back. You never generate one yourself. 3. The probe counts the words and never shows them. Seed words are shown only to their owner, on a screen they asked for. [Seed words](seed-words.md) has that screen. 4. `session.close()` is required. It gives the folder back. Until then, a second open of the same folder fails with `StorageInUse`. So a remount waits for the last close before it opens. `screen/wallets/FedimintDebug.tsx` ```tsx import React, { useEffect, useState } from 'react' import { SafeAreaView, Text, View } from 'react-native' import RNFS from 'react-native-fs' import { ErrorCode, Exception, openSdk } from '@fedimint/react-native' /** The error code and reason of a failure from the core, or the plain message. */ function explain(e: unknown): string { if (!Exception.hasInner(e)) return String(e) const inner = Exception.getInner(e) return `${ErrorCode[inner.code()]}: ${inner.reason()}` } // The last screen's close. The next open waits for it, or it fails with StorageInUse. let closing: Promise = Promise.resolve() export default function FedimintDebug() { const [status, setStatus] = useState('calling the rust core...') useEffect(() => { let cancelled = false // Your app owns this path. Use the real documents directory from the start: // a simulator shortcut such as /tmp would not survive on a device. const dataDir = `${RNFS.DocumentDirectoryPath}/fedimint` const opening = closing.then(() => openSdk({ dataDir })) opening .then((opened) => { if (cancelled) return // Count the words. Never put them on a debug screen, and never log them. const count = opened.sdk.exportMnemonic().words().length setStatus(`the rust core answered: a ${count} word seed is stored`) }) .catch((e: unknown) => { if (!cancelled) setStatus('FAILED: ' + explain(e)) }) // Gives the folder back, even if the open is still running. The cancelled // flag stops a late reply from setting state on a screen that has already gone. return () => { cancelled = true closing = opening.then((s) => s.close()).catch(() => undefined) } }, []) return ( FEDIMINT DEBUG {status} ) } ``` Then register the screen in your navigator, the same way you register any other. The SDK has no opinion about navigation. TypeScript will accept the file with no errors: ```sh $ npx tsc --noEmit no output, exit 0 ``` ### 07 · See the core answer Build and run. This is a full native build, because a new native library went in, so expect minutes rather than seconds. A JavaScript reload is not enough. With Expo, use `npx expo run:ios` or `npx expo run:android` instead. `run.sh` ```sh npx react-native run-ios npx react-native run-android ``` Open the debug screen. It will say that a 12 word seed is stored. Open it a second time and it says the same: the seed was written once, on the first open, and read on the second. ### If the screen says FAILED, or the build stops Read the message before you change anything. A failure from the core reaches JavaScript as an error that is not an `instanceof Exception`. The probe reads it with `Exception.hasInner(e)`, then `Exception.getInner(e).code()` and `.reason()`. The code is a number. `ErrorCode[code]` gives its name, such as `StorageInUse`. [Errors](errors.md) lists every code. These cover almost every case. ```text TurboModuleRegistry.getEnforcing(...): 'ReactNativeBindings' could not be found The native side did not load. Your app does not list the bindings (step 02), pod install did not run after that (step 04), the New Architecture is off, or you ran an old build. Rebuild. A JavaScript reload cannot fix this. Invalid hook call Two copies of React. Run npm ls react. Every entry must be your app's copy, marked deduped. If another version shows, pin react to your app's version with an overrides entry in package.json, then install again. None of your spec sources contain a spec satisfying the dependency: uniffi-bindgen-react-native CocoaPods has an old cached index and does not know the pod version. Run bundle exec pod install --repo-update in the ios folder (step 04). FAILED: StorageInUse: ... The folder is already open. Something opened the SDK twice, often a fast refresh while the first session was still open. Open it once per app (Share the session), and close the session before you open it again. ``` ## What you just did You added both packages at one exact version, set up iOS and Android, and called across three languages to get an answer back. Nothing on this page talked to a network. The seed was written once, on the first open, into the folder you named. You also met the rules that shape every later page. The SDK owns a folder, so it needs a path. It remembers, so a seed is written once and read afterwards. It holds the folder until you close the session. And the seed words are the money, so they are never logged and never shown to anybody but their owner. [Share the session](session.md) goes back to `openSdk` and shows how to open once for the whole app. [Seed words](seed-words.md) shows the seed words safely. --- # Join a federation Read an invite code, show the user what they are joining, then join and reconnect on every launch. - Proven: parsing a code needs no network - Proven: preview writes nothing to storage - Not yet: leaving needs a zero balance A federation is a group of guardians who between them issue the ecash your wallet will hold. Joining one asks the guardians for their configuration and writes it to the wallet's folder. After this page your wallet has somewhere to keep money. Leaving is hard. `sdk.forgetFederation(id)` erases a federation only when it holds no money and has no pending operation. So a screen that joins should show the user what they are joining first. That is why the preview comes before the join. **Practise on money that is not real.** Fedi Testnet runs on signet, where coins have no value. Pay it from a signet faucet. Its invite code is: `fed11qgqrgvnhwden5te0v9k8q6rp9ekh2arfdeukuet595cr2ttpd3jhq6rzve6zuer9wchxvetyd938gcewvdhk6tcqqysptkuvknc7erjgf4em3zfh90kffqf9srujn6q53d6r056e4apze5cw27h75` ### 01 · Reject a bad code without a network call An invite code is a string that starts with `fed1`. `InviteCode.parse` reads it on the device, with no network call. Call it as soon as the user pastes or scans, and tell them at once if the code is bad. `read-invite.ts` ```ts import { ErrorCode, Exception, InviteCode, type InviteCodeLike, } from '@fedimint/react-native' /** The parsed invite, or null when the pasted text is not a code. No network. */ export function readInvite(pasted: string): InviteCodeLike | null { try { // parse does not trim. Remove the spaces and line breaks a paste brings with it. return InviteCode.parse(pasted.trim()) } catch (err) { if ( Exception.hasInner(err) && Exception.getInner(err).code() === ErrorCode.InvalidInput ) { return null } throw err } } ``` A code that does not parse throws an error with the code `ErrorCode.InvalidInput`. The error is not an `instanceof` any class, so read its code with `Exception.hasInner` and `Exception.getInner`. [`invite.federationId()`](api-values-invitecode-federationid.md) gives the federation's id, and `invite.display()` gives the code back as a string. `readInvite('hello')` returns `null`. `readInvite` with the Fedi Testnet code returns an invite, and its `federationId()` is a 64 character hex string. ### 02 · Show the user what they are about to join `sdk.preview(invite)` asks the guardians for the federation's configuration. It writes nothing to storage, so it is safe to call on a code the user has not agreed to yet. It also checks that this SDK can use the federation. A federation it cannot use fails here with `UnsupportedFederation`, not later at the join. `preview-federation.ts` ```ts import { Network, type InviteCodeLike, type SdkLike, } from '@fedimint/react-native' export async function previewForAScreen(sdk: SdkLike, invite: InviteCodeLike) { // Asks the guardians for their configuration. Writes nothing. const preview = await sdk.preview(invite) return { // undefined when the federation published no name. Do not invent one. name: preview.name ?? `federation ${preview.id.slice(0, 8)}`, // Other fields the federation published, as a Map of strings. welcome: preview.meta.get('welcome_message'), guardians: preview.guardians, modules: preview.modules, // Network.Bitcoin is real money. Signet, Testnet, Testnet4 and Regtest are not. realMoney: preview.network === Network.Bitcoin, } } ``` `preview.guardians` is the number of guardians. `preview.modules` lists the module names the federation runs, such as `mint`, `ln` and `wallet`. **Show the network.** `preview.network` says which chain this federation's money is on. `Network.Bitcoin` is real money. A test build should refuse it, and a real build should say so plainly before the user joins. With the Fedi Testnet code, you will see a name, a guardian count above zero, and `realMoney` set to `false`. ### 03 · Join, then reconnect on every launch `sdk.join(invite)` returns the federation, open and ready. The invite is only needed for this first join. The wallet keeps the federation in its own folder, so your app does not need to save the invite. `join-federation.ts` ```ts import type { FederationLike, InviteCodeLike, SdkLike, } from '@fedimint/react-native' /** After the user said yes on the preview screen. */ export async function joinFederation( sdk: SdkLike, invite: InviteCodeLike, ): Promise { return sdk.join(invite) } ``` Joining a federation the wallet already holds fails with `AlreadyJoined`. On every later launch, use the function below. It lists the federations in the wallet's folder with [`sdk.storedFederations()`](api-sdk-storedfederations.md). It needs no saved invite. It returns `undefined` when the wallet holds no federation yet. Then show your Join screen. `federation-on-launch.ts` ```ts import type { FederationLike, SdkLike } from '@fedimint/react-native' /** On every launch. Returns undefined when the wallet has not joined a federation. */ export async function federationOnLaunch( sdk: SdkLike, ): Promise { const [stored] = sdk.storedFederations() if (!stored) return undefined return sdk.federation(stored.id) ?? (await sdk.reopenFederation(stored.id)) } ``` | The federation is | `federationOnLaunch` does | | --------------------------------------- | ------------------------------------------------------------ | | already open in this session | `sdk.federation(id)` hands it back | | joined or restored before, not open yet | `sdk.reopenFederation(id)` opens it from the wallet's folder | | never joined | returns `undefined` | `storedFederations()` lists every federation in the folder. A federation that `recover` restored is in the list too. This example takes the first one. A wallet that holds more than one federation picks by `id`. A federation answers a few questions without a network call: [`fed.id()`](api-federation-id.md), [`fed.name()`](api-federation-name.md), [`fed.network()`](api-federation-network.md), [`fed.inviteCode()`](api-federation-invitecode.md) and [`fed.capabilities()`](api-federation-capabilities.md). [`fed.ecash()`](api-federation-ecash.md), [`fed.lightning()`](api-federation-lightning.md) and [`fed.onchain()`](api-federation-onchain.md) return `undefined` when the federation does not run that module. [`sdk.federations()`](api-sdk-federations.md) lists the federations open right now. After a successful join, `await fed.balance()` returns `0n` and does not throw. That is the smallest proof that the federation is open, and it is where [Show a balance](balance.md) begins. ## What you just did You read an invite code on the device, showed the user who is behind it and which network it is on, and joined. On every later launch, `federationOnLaunch` brings the same federation back, with no saved invite. Two things this page did not do. It did not leave a federation: `sdk.forgetFederation(id)` does that, and only for a federation with no money and no pending operation. And a wallet restored from seed words should not use `join` at all. It uses `recover`, which looks for money the seed already holds. [Restore from seed words](restore.md) shows it. --- # Show a balance Read the balance once, then follow it as it changes. - Proven: first value is the balance now - Proven: useBalance ships in the package - Not yet: no retry after FederationClosed Every amount in this SDK is a `bigint` of millisatoshis. One sat is `1000n`. The core never rounds an amount for you, so a fee of half a sat is `500n`, not zero. The type is called `Amount`. No conversion helpers ship, so do the arithmetic inline: `msat / 1000n` gives whole sats. That choice costs you one rule, and [Amounts](amounts.md) ends with it: a `bigint` cannot go through `JSON.stringify`. ### 01 · Read it once [`fed.balance()`](api-federation-balance.md) is the spendable balance of one federation, now. Use it where you need a number once, such as before a send. `read-balance.ts` ```ts import type { FederationLike } from '@fedimint/react-native' export async function balanceLine(fed: FederationLike): Promise { // A bigint, in millisatoshis: 1000n is one sat. A new wallet answers 0n. const msat = await fed.balance() return `${msat / 1000n} sats` } ``` On a federation you just joined, you will see `0 sats`. ### 02 · Follow it A screen that shows a balance wants every change, not one reading. [`fed.balanceUpdates()`](api-federation-balanceupdates.md) returns a cursor. Its first `next()` returns the balance now. Each later `next()` waits for the next change. Pass an `AbortSignal` so that you can stop it. This is how code outside React follows a balance: `follow-balance.ts` ```ts import type { Amount, FederationLike } from '@fedimint/react-native' /** Calls onChange with the balance now, then on every change. Returns a function that stops it. */ export function followBalance( fed: FederationLike, onChange: (msat: Amount) => void, onError: (err: unknown) => void, ): () => void { const controller = new AbortController() const { signal } = controller const updates = fed.balanceUpdates() void (async () => { try { for (;;) { // The first value is the balance now. Every later value is a change. const msat = await updates.next({ signal }) if (signal.aborted) return onChange(msat) } } catch (err) { // A balance never ends cleanly. FederationClosed is how the stream stops. if (!signal.aborted) onError(err) } })() return () => controller.abort() } ``` In React, use `useBalance`. It does the same work, and it stops when the component unmounts: `BalanceLine.tsx` ```tsx import React from 'react' import { Text } from 'react-native' import { useBalance, type FederationLike } from '@fedimint/react-native' export function BalanceLine({ fed }: { fed: FederationLike | undefined }) { // undefined until the first value arrives, then the balance in msat, kept current. const msat = useBalance(fed, (err) => console.log('balance watch', err)) return ( {msat === undefined ? 'loading...' : `${msat / 1000n} sats`} ) } ``` The `onError` callback hears about failures to watch, not failures of money. The usual one is `FederationClosed`, after the federation or the SDK closes. Neither `followBalance` nor `useBalance` starts again by itself. After you reopen the federation, start a new watch. For `useBalance`, remount the component with a new React `key`. [Share the session](session.md) shows how. Render `BalanceLine` and receive some ecash as in [Receive money](receive.md). You will see the line change from `0 sats` to the new balance with no code of yours in between. ## What you just did You read a balance once, and followed it as it changes, both outside React and with `useBalance`. [See the history](history.md) uses the same `bigint` amounts for history rows. --- # Receive money Redeem ecash, and create and watch a Lightning invoice. - Proven: ecash in - Proven: Lightning in - Proven: useOperation hook included - Not yet: on chain deposits not covered here Money arrives two ways. Ecash comes as a string somebody hands you, in a chat, a QR code or a file. Lightning comes as a payment to an invoice you create. Both give you back an operation: a handle on money that is on its way, which you can watch until it lands. Each rail is a method on the federation, [`fed.ecash()`](api-federation-ecash.md) and [`fed.lightning()`](api-federation-lightning.md). Either can return `undefined`, because a federation that does not run that module cannot offer it. The types make you check, so a missing module is a message on screen and not a crash. ```text operation.updates() a cursor. next() gives the state now, then each change, then undefined operation.awaitFinal() resolves with the final state: success, failure or expiry alike operation.state() the state now, once operation.id() a string, to find it again in history (See the history) useOperation(op) the same states in a React component. It stops when the component unmounts ``` ### 01 · Ecash: find out what it is worth Before the user accepts a string, tell them what it is worth. `Notes.parse(str).value()` reads the value written inside the notes, in msat. It is instant and talks to no network. `inspect-notes.ts` ```ts import { Notes, type FederationLike } from '@fedimint/react-native' /** What a pasted ecash string is worth, before the user commits to it. No network. */ export function describeNotes(fed: FederationLike, pasted: string): string { // Undefined when this federation does not run the ecash module. The compiler makes you check. if (!fed.ecash()) return 'This federation does not take ecash.' // Throws InvalidInput when the string is not ecash. const msat = Notes.parse(pasted.trim()).value() return `${msat / 1000n} sats` } ``` You will see the value of the notes in sats, before anything is redeemed. `value()` reads the notes. It cannot know whether they were already spent or taken back by the sender. Only the receive in step 02 finds that out. ### 02 · Ecash: redeem it `ecash.receive(Notes.parse(str))` exchanges the notes with the federation for fresh ones that belong to this wallet. Only then is the money yours. Until that happens the sender can still take it back. So redeem as soon as the user says yes. `redeem-notes.ts` ```ts import { EcashReceiveState_Tags, Notes, type EcashReceiveState, type FederationLike, } from '@fedimint/react-native' export async function redeem( fed: FederationLike, pasted: string, ): Promise { const ecash = fed.ecash() if (!ecash) throw new Error('This federation does not take ecash.') // Returns as soon as the redemption has started. The notes must come from this // federation: notes from another one fail here with InvalidInput. const operation = await ecash.receive(Notes.parse(pasted.trim())) // Show progress while you wait. The first value is the state now. const updates = operation.updates() for (;;) { const state = await updates.next() if (state === undefined) break // the stream has ended if (state.tag === EcashReceiveState_Tags.Issuing) console.log('redeeming...') } // awaitFinal() resolves with the final state, good or bad. It does not throw for a failed receive. const final = await operation.awaitFinal() if (final.tag === EcashReceiveState_Tags.Failed) { // Most often the sender took the notes back first. console.log('receive failed:', final.inner.reason) } return final // Done means the value is in the balance and spendable. } ``` | State | Means | | --------- | ------------------------------------------------------- | | `Created` | Accepted here, about to go to the federation | | `Issuing` | The federation is issuing fresh notes | | `Done` | Final. The value is in the balance and can be spent | | `Failed` | Final. Nothing arrived. `inner.reason` is for your logs | You will see the final state. After `Done`, the balance shows the new value. Any fee comes out of the notes, not on top. There is no quote on this side, because the receiver has nothing to decide. ### 03 · Lightning: create an invoice `ln.receive(msat, description)` asks a Lightning gateway for an invoice and returns it with the operation that will see it paid. The amount is a msat `bigint`, so multiply what the user typed in sats by `1000n`. The description is required. The payer's wallet shows it. `create-invoice.ts` ```ts import { Network, type FederationLike } from '@fedimint/react-native' /** Set this to true only in a build that is meant to hold real money. */ const REAL_MONEY_BUILD = false export async function invoiceFor(fed: FederationLike, sats: number) { const ln = fed.lightning() if (!ln) throw new Error('This federation has no Lightning module.') // A test build asks for test money only. Signet, like Fedi Testnet, is safe to play with. if (fed.network() === Network.Bitcoin && !REAL_MONEY_BUILD) { throw new Error( 'This federation is on Bitcoin mainnet. Refusing in a test build.', ) } // The amount is in msat. The description is required, and the payer sees it. const { invoice, operation } = await ln.receive( BigInt(sats) * 1000n, 'Coffee', ) return { invoice, operation } // show invoice as text and as a QR code } ``` You will see an invoice string that starts with `lntbs` on signet. **Check the network before you ask for money.** While you build, use a signet federation such as Fedi Testnet and pay the invoice from a signet faucet. The check above stops a test build from asking anybody for real bitcoin because a mainnet invite slipped into a config file. ### 04 · Lightning: watch the invoice The state is a tagged union. Switch on `state.tag`. The compiler knows every case, offers them as you type, and lets you read `state.inner` only on the cases that carry one. `Failed` here is not an expired invoice. It means a payment arrived and did not become money in the wallet, so show it as an error to report. Outside React, pull the [`updates()`](api-operation-updates.md) cursor. Pass an `AbortSignal`, and abort it when the screen closes, so nothing runs on a screen that has gone. `watch-invoice.ts` ```ts import { LnReceiveState_Tags, type LnReceiveOperationLike, type LnReceiveState, } from '@fedimint/react-native' /** What to show for each state. */ export function describeInvoice(state: LnReceiveState): string { switch (state.tag) { case LnReceiveState_Tags.Created: case LnReceiveState_Tags.WaitingForPayment: return 'Waiting for payment' case LnReceiveState_Tags.Funded: return 'Paid. Claiming...' case LnReceiveState_Tags.Claimed: return 'Received' // in the balance now case LnReceiveState_Tags.Expired: return 'This invoice expired unpaid' case LnReceiveState_Tags.Canceled: console.log('invoice canceled:', state.inner.reason) // for your logs, not the screen return 'The gateway withdrew this invoice. Make a new one' case LnReceiveState_Tags.Failed: return 'Something went wrong with this payment. Please report it' } } /** Follows the invoice until it ends, or until signal is aborted. */ export async function watchInvoice( operation: LnReceiveOperationLike, show: (text: string) => void, signal: AbortSignal, ): Promise { const updates = operation.updates() try { for (;;) { // The first value is the state now. undefined means the stream has ended. const state = await updates.next({ signal }) if (state === undefined) return show(describeInvoice(state)) } } catch (err) { if (!signal.aborted) throw err // an abort is the screen closing, not an error } } ``` In a React screen, `useOperation` does the same work. It starts on mount and stops on unmount. It returns `undefined` until the first state arrives, and keeps the final state after the stream ends. `InvoiceStatus.tsx` ```ts import { Text } from 'react-native'; import { useOperation, type LnReceiveOperationLike } from '@fedimint/react-native'; import { describeInvoice } from './watch-invoice'; export function InvoiceStatus({ operation }: { operation: LnReceiveOperationLike }) { // A failure to watch goes to the second argument. It is never the payment's outcome. const state = useOperation(operation, err => console.log('cannot watch the invoice:', err)); return {state === undefined ? 'Loading...' : describeInvoice(state)}; } ``` Pay the invoice from a signet faucet. You will see the state move to `Claimed`, and the balance line from [Show a balance](balance.md) changes by itself. ## What you just did You priced a string of ecash before accepting it, redeemed it, created a Lightning invoice after checking the network, and followed both operations to a final state. This page does not cover on chain deposits. They live on [`fed.onchain()`](api-federation-onchain.md), which can return `undefined` like the other two rails. --- # Send money Quote and send ecash, take it back, and pay a Lightning invoice. - Proven: quote, then send - Proven: ecash can be taken back - Not yet: the sender learns of a redemption only at reclaim - Not yet: a Lightning payment cannot be cancelled - Not yet: on chain sends not covered here Sending is where a wallet can lose somebody's money, so this page is more careful than the last one. Both rails work the same way, in two calls. First a **quote**: the SDK works out exactly what the send will cost and freezes it. You show those numbers and the user confirms. Then **send**, with that quote and nothing else. The user never agrees to one number and pays another. ```text ecash quote(msat) -> show fee and total -> send(quote) -> { notes, operation } lightning quote(invoice) -> show fee and total -> send(quote) -> operation ``` Two more rules carry the rest. Ecash leaves the balance before anybody has received it. And a Lightning send that returns has started, not finished. ### 01 · Ecash: quote it A federation issues notes in fixed sizes, so the notes for a request can be worth a little more than was asked. The quote shows both, and the fee, and the total the balance will move by. Every amount is a msat `bigint`. `quote-ecash.ts` ```ts import type { EcashQuoteLike, FederationLike } from '@fedimint/react-native' /** Prices a send. Nothing leaves the balance yet. */ export async function priceEcash(fed: FederationLike, sats: number) { const ecash = fed.ecash() if (!ecash) throw new Error('This federation does not take ecash.') const quote: EcashQuoteLike = await ecash.quote(BigInt(sats) * 1000n) // What the confirm screen shows. total() is what the balance moves by. const lines = { asked: `${quote.requestedAmount() / 1000n} sats`, notesWorth: `${quote.notesValue() / 1000n} sats`, // can be a little more than asked fee: `${quote.fee() / 1000n} sats`, total: `${quote.total() / 1000n} sats`, } return { quote, lines } } ``` | On the quote | Means | | ----------------------------------------------------- | ----------------------------------------------------------------- | | [`requestedAmount()`](api-ecash-requestedamount.md) | What you asked for | | [`notesValue()`](api-ecash-notesvalue.md) | What the receiver can redeem. Never less than asked | | `fee()` | The cost on top. Show it, even when it is zero | | `total()` | What leaves the balance. Show this one before the user confirms | | `expiresAt()` | When the quote stops working, as a `bigint` of epoch milliseconds | You will see the four numbers in sats. The balance has not changed. A quote can fail with `NotSupported` when the wallet's notes cannot make up the amount. This SDK hands out only notes it already holds, and does not make change. Say so, and suggest a different amount. A quote can also fail with `InsufficientBalance`, even for an amount the balance would cover exactly, because the notes round up. ### 02 · Ecash: hand over a string Sending takes the total out of the balance and gives you notes. [`notes.display()`](api-values-notes-display.md) turns them into the string to hand over. Whoever holds that string holds the money. There is no recipient, no address and no confirmation. `send-ecash.ts` ```ts import { ErrorCode, Exception, type EcashQuoteLike, type FederationLike, } from '@fedimint/react-native' /** A QuoteExpired for a quote that was already sent. [Errors](./errors.md) explains it. */ function alreadyExecuted(err: unknown): boolean { const details = Exception.hasInner(err) ? Exception.getInner(err).details() : undefined if (details?.kind !== 'QuoteExpired' || details.payload.byteLength < 9) return false return new DataView(details.payload).getUint8(8) === 1 } /** After the user tapped Confirm on the quote. Returns the string to hand over. */ export async function sendEcash(fed: FederationLike, quote: EcashQuoteLike) { const ecash = fed.ecash() if (!ecash) throw new Error('This federation does not take ecash.') try { // The balance drops when this returns, before anybody has redeemed anything. const { notes, operation } = await ecash.send(quote) return { text: notes.display(), operation } } catch (err) { const code = Exception.hasInner(err) ? Exception.getInner(err).code() : undefined if (code === ErrorCode.QuoteChanged) { // Nothing was spent. Quote again and ask the user to confirm the new numbers. return null } if (code === ErrorCode.QuoteExpired && !alreadyExecuted(err)) { // The quote ran out of time. Nothing was spent. Quote again, as above. return null } // A quote that was already sent once. That send may have moved money. Check activity() // and the balance before you quote again, or you may send twice. throw err } } ``` You will see a long string, and the balance drops by `total()`. **Treat the string as cash on screen.** It is bearer money in plain text. Anything that copies it, screenshots it, logs it or syncs it is moving the money itself. It is also often too long for one QR code. Give the user a copy button as well as a QR, and handle the QR library's failure rather than showing an empty square. A quote is for one send. After `expiresAt()` the send fails with `QuoteExpired`. If another send spent the notes it planned to use first, it fails with `QuoteChanged`. For both, nothing was spent: quote again and ask the user to confirm the new numbers. A quote that was already sent once also fails with `QuoteExpired`. That earlier send may have moved money, or it may have failed. Check [History](history.md) and the balance before you quote again, and follow the earlier send if it went through. ### 03 · Ecash: take it back Until the receiver redeems the string, the sender can ask for the value back. [`operation.requestCancel()`](api-operation-requestcancel.md) records the request and returns. It does not wait for the network. The outcome arrives as a state, because the receiver may redeem first. `EcashSendState` is a plain enum, so compare it with `===`. `take-it-back.ts` ```ts import { EcashSendState, type EcashSendOperationLike, } from '@fedimint/react-native' /** What to show for each state of a send. */ export function describeSend(state: EcashSendState): string { switch (state) { case EcashSendState.Created: return 'Sent. The receiver may have it already' case EcashSendState.CancelRequested: return 'Taking it back...' case EcashSendState.Canceled: return 'Back in your balance' case EcashSendState.Redeemed: return 'The receiver has it' } } /** The user changed their mind. Only an ecash send can be taken back. */ export async function takeItBack( operation: EcashSendOperationLike, ): Promise { // Returns once the request is recorded. It can still lose the race to a // receiver who redeemed first. await operation.requestCancel() const final = await operation.awaitFinal() return final === EcashSendState.Canceled ? 'Back in your balance' : 'The receiver redeemed it first' } ``` | State | Means | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `Created` | The string exists. The receiver may have redeemed it already. The sender cannot see that yet, so do not wait for `Redeemed` to tell the user the send worked | | `CancelRequested` | Taking it back. Not final: the receiver can still win | | `Canceled` | Final. The value is back in the balance | | `Redeemed` | Final. The receiver has it | You will see `Back in your balance`, or `The receiver redeemed it first` if the receiver was faster. **The sender learns the outcome only at reclaim.** A redemption sends nothing back to the sender. The SDK finds out only when it tries to take the notes back: after `requestCancel()`, or at the time in `details().reclaimAt`, when it takes unredeemed notes back by itself. On some federations that automatic reclaim runs only when your app reads the send again, so read its state after `reclaimAt`. [See the history](history.md) shows how to open a send again. Only an ecash send has `requestCancel()`. The other operation types do not have the method, so the compiler stops you before you try. ### 04 · Lightning: quote the invoice `ln.quote(invoice)` reads the invoice, picks a gateway and works out the fee. Most of what can go wrong with a payment goes wrong here, before any money moves, which is the best place for it. `quote-invoice.ts` ```ts import { ErrorCode, Exception, type FederationLike, type LnQuoteLike, } from '@fedimint/react-native' export async function priceInvoice(fed: FederationLike, pasted: string) { const ln = fed.lightning() if (!ln) throw new Error('This federation has no Lightning module.') // Remove spaces and a "lightning:" prefix from what the user pasted or scanned. const invoice = pasted.trim().replace(/^lightning:/i, '') try { const quote: LnQuoteLike = await ln.quote(invoice) return { quote, pays: `${quote.invoiceAmount() / 1000n} sats`, fee: `${quote.fee() / 1000n} sats`, total: `${quote.total() / 1000n} sats`, // what the balance moves by } } catch (err) { const code = Exception.hasInner(err) ? Exception.getInner(err).code() : undefined if (code === ErrorCode.AmountlessInvoice) return 'Ask for an invoice with an amount.' if (code === ErrorCode.NetworkMismatch) return 'That invoice is for a different network.' if (code === ErrorCode.InsufficientBalance) return 'Not enough in this wallet.' if (code === ErrorCode.GatewayUnavailable) return 'No Lightning gateway answered. Try later.' throw err } } ``` | Code | Means | | --------------------- | ------------------------------------------------------------------------------------ | | `AmountlessInvoice` | The invoice names no amount. This SDK cannot pay those | | `NetworkMismatch` | The invoice is for another network, such as a mainnet invoice on a signet federation | | `InvalidInput` | Not an invoice, or already expired | | `InsufficientBalance` | The balance cannot cover `total()` | | `GatewayUnavailable` | No gateway could be reached and checked | You will see the invoice amount, the fee and the total in sats, or one of the messages above. ### 05 · Lightning: pay it, then wait for the answer `ln.send(quote)` returns an operation as soon as the payment has started. The payment is not made yet. The final state is the answer, and only `Success` means the invoice was paid. A failed payment is a state, not a thrown error. `pay-invoice.ts` ```ts import { LnSendState_Tags, type FederationLike, type LnQuoteLike, } from '@fedimint/react-native' export async function payInvoice( fed: FederationLike, quote: LnQuoteLike, ): Promise { const ln = fed.lightning() if (!ln) throw new Error('This federation has no Lightning module.') // Returns when the payment has STARTED. Nothing is paid yet. const operation = await ln.send(quote) // The final state is the answer. awaitFinal() resolves for success, refund and failure alike. const final = await operation.awaitFinal() switch (final.tag) { case LnSendState_Tags.Success: // The preimage is the receipt: proof the invoice was paid. return `Paid. Fee ${final.inner.fee / 1000n} sats. Proof ${final.inner.preimage}` case LnSendState_Tags.Refunded: return 'Not paid. The money is back in your balance.' case LnSendState_Tags.Failed: // The SDK cannot say where the money is. Log the reason, then read the balance. console.log('payment failed:', final.inner.reason) return 'Not paid. Check your balance before you try again.' default: return 'Still in progress' // awaitFinal() only resolves on a final state } } ``` | Final state | Means | Tell the user | | ----------- | ------------------------------------------------------------- | ------------------------------------- | | `Success` | Paid. `inner.preimage` proves it, `inner.fee` is what it cost | Paid | | `Refunded` | Not paid, and the money is spendable again | Not paid, nothing lost | | `Failed` | Not paid, and the SDK cannot say where the money is | Check the balance before trying again | You will see `Paid` with the fee and the preimage, or one of the two other answers. **A Lightning payment cannot be cancelled.** Once sent it runs to one of those three endings. Do not offer a cancel button on this screen. ## What you just did You quoted before every send, so the user saw the fee and the total before agreeing. You handed out ecash and took it back, and you paid an invoice and waited for the answer instead of trusting the return of a call. This page does not cover on chain sends. They live on [`fed.onchain()`](api-federation-onchain.md), which can return `undefined` like the other two rails. --- # See the history Page through operations and turn each one into a row. - Proven: one list, both rails - Proven: pages, newest first - Proven: kept across restarts Every send and receive the wallet has made is kept in its data directory, across restarts. This page reads that record: as a list, one page at a time, and as a single operation you can open again and keep watching. ### 01 · Read the newest page `fed.activity(cursor, limit)` returns a page of `ActivityItem` rows, newest first, for both rails in one list. Pass `undefined` as the cursor for the first page. `limit` is the most rows a page holds. It is required, and zero fails with `InvalidInput`. `first-page.ts` ```ts import type { ActivityPage, FederationLike } from '@fedimint/react-native' /** The newest twenty operations, both rails, in one list. */ export function firstPage(fed: FederationLike): Promise { return fed.activity(undefined, 20) } ``` | Field | Type | Means | | --------------- | --------------------------- | ------------------------------------------------------------------ | | `operationId` | `string` | Opens the operation again, in step 04 | | `kind` | `OperationKind` | `EcashSend`, `EcashReceive`, `LnSend`, `LnReceive` and others | | `status` | `ActivityStatus` | How it turned out. Step 03 | | `isFinal` | `boolean` | Whether it can still change | | `amount`, `fee` | `bigint` msat, or undefined | The two numbers a list shows. `fee` is never folded into `amount` | | `direction` | `Direction`, or undefined | `Incoming` or `Outgoing` | | `time` | `bigint` | Epoch milliseconds, from this device's clock, not the federation's | You will see up to twenty rows, with the newest first. A new wallet shows an empty `items` array. ### 02 · Load the next page A page carries `next`, a cursor for the page after it, or `undefined` on the last page. Pass it back as the first argument when the user scrolls to the end of the list. `more-pages.ts` ```ts import type { ActivityItem, Cursor, FederationLike, } from '@fedimint/react-native' /** For a list that loads more as the user scrolls. */ export async function loadMore( fed: FederationLike, after: Cursor | undefined, ): Promise<{ items: ActivityItem[]; next: Cursor | undefined }> { // undefined for the first page. The previous page's next after that. const page = await fed.activity(after, 20) return { items: page.items, next: page.next } } ``` `Cursor` is a plain string. You can keep it in state or in storage, and pass it back unchanged. Do not build one yourself: a cursor this federation did not issue fails with `InvalidInput`. You will see the older rows. When `next` is `undefined`, the list has ended. ### 03 · Turn each row into your own, and know what pending means An `ActivityItem` holds `bigint` values, so the rule from [Show a balance](balance.md) applies: convert it at the edge into a row of your own before you keep it, cache it or send it anywhere. `to-row.ts` ```ts import { ActivityStatus, Direction, OperationKind, type ActivityItem, } from '@fedimint/react-native' /** Your list row. Plain numbers and strings, so it can go through JSON and into storage. */ export interface HistoryRow { id: string kind: string // "EcashSend", "LnReceive", ... status: string // "Pending", "Success", "Refunded", ... incoming: boolean | null // null when the row has no direction sats: number | null // null when the row carries no amount feeSats: number | null // null when the fee is not known yet time: number // epoch milliseconds, from this device's clock spinner: boolean // true while the operation can still change } export function toRow(item: ActivityItem): HistoryRow { return { id: item.operationId, kind: OperationKind[item.kind], status: ActivityStatus[item.status], incoming: item.direction === undefined ? null : item.direction === Direction.Incoming, sats: item.amount === undefined ? null : Number(item.amount / 1000n), feeSats: item.fee === undefined ? null : Number(item.fee / 1000n), time: Number(item.time), spinner: !item.isFinal, } } ``` | Status | Means | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `Pending` | Still moving. The only status that can change | | `Success` | Completed as intended | | `Refunded` | Did not complete, and the value is spendable again | | `Canceled` | Called off or lapsed: ecash taken back, an invoice that expired unpaid. Nothing went wrong | | `Failed` | Did not complete, and the value is not known to be back. Worth a look | | `Unknown` | A row this version of the SDK cannot read. It has no amount and no direction, so do not show it as money in or out. Use `isFinal` for the spinner | You will see one plain row for each item, with names such as `"LnReceive"` and `"Success"`. Decide whether to draw a spinner from `isFinal`, not from `status`. They agree on every row the SDK understands, and `isFinal` still answers correctly on a row it does not. ### 04 · Open one operation again A list row is two numbers and a status. A receipt screen wants more. `fed.operation(id)` finds the operation again, even after a restart, and its [`kind()`](api-operation-kind.md) tells you which accessor to use: [`asEcashSend()`](api-operation-asecashsend.md), `asEcashReceive()`, `asLnSend()` or `asLnReceive()`. Each returns the same kind of operation as the screen that started it, so [`updates()`](api-operation-updates.md), [`awaitFinal()`](api-operation-awaitfinal.md), `useOperation` and, for an ecash send, `requestCancel()` all work. `open-one.ts` ```ts import { EcashSendState, OperationKind, type FederationLike, } from '@fedimint/react-native' /** A tap on a row: open that operation again and read what the SDK kept about it. */ export async function reopenEcashSend( fed: FederationLike, operationId: string, ) { const any = await fed.operation(operationId) if (!any) return null // not an operation of this federation if (any.kind() !== OperationKind.EcashSend) return null // Each accessor returns undefined unless the operation is of that kind. const send = any.asEcashSend() if (!send) return null const state = await send.state() const details = await send.details() return { redeemed: state === EcashSendState.Redeemed, // Still the same bearer string. Show it again only to the person who sent it. notes: details.notes.display(), // After this time the wallet takes unredeemed notes back. reclaimAt: new Date(Number(details.reclaimAt)), // The same operation as on the send screen: updates, awaitFinal and requestCancel all work. operation: send, } } ``` [`details()`](api-operation-details.md) is what the SDK recorded about the operation. For an ecash send, that is the notes, the amounts, the fee and when unredeemed notes come back. For a Lightning send, it is the invoice, the route and the fee. Read it for a receipt rather than rebuilding one from list rows. You will see the send's notes and reclaim time again, for an operation made before the app restarted. ## What you just did You listed both rails in one history, page by page, turned each row into a plain one your app can store, drew spinners from the one field that is always right, and opened an old operation again to read its receipt and keep watching it. --- # Share the session Open the SDK once in a module your app owns, share the session, and follow it from any screen with hooks. - Proven: two hooks ship: useBalance and useOperation - Proven: React only for the hooks - Not yet: no provider ships, on purpose [Seed words](seed-words.md) said to open the SDK once and hand the same session to every screen. `@fedimint/react-native` does not ship a provider for this, on purpose. Your app decides when the wallet opens and when it closes. A provider that opens on mount and closes on unmount would take that choice away, and a remount could close the wallet under a screen that still uses it. | Piece | Who writes it | Does | | ---------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------- | | `src/fedimint.ts` | You | Opens the SDK once, hands every caller the same `SdkSession`, and closes it when your app says so | | `SessionProvider`, [`useSession()`](api-session-usesession.md) | You, optional | A small React context that holds the session | | `useBalance(fed, onError?)` | The SDK | A federation's balance in msat, kept current. `undefined` until the first value | | `useOperation(op, onError?)` | The SDK | An operation's latest state, kept current. `undefined` until the first value | ### 01 · Open once, in a module of your own Put the session in one module. `open()` calls `openSdk` the first time and gives every later caller the same session, even a caller that comes while the first open is still running. [`close()`](api-session-close.md) is the only place the session closes. `src/fedimint.ts` ```ts import RNFS from 'react-native-fs' import { openSdk, type SdkSession } from '@fedimint/react-native' // A plain path, not a file:// URI. RNFS gives a plain path. const dataDir = `${RNFS.DocumentDirectoryPath}/fedimint` let session: SdkSession | undefined let opening: Promise | undefined let closing: Promise | undefined // close() adds one. An open that finishes in an older generation was closed while it ran. let generation = 0 /** Opens the SDK once. Every caller gets the same session. */ export function open(): Promise { const mine = generation opening ??= (async () => { // A close that is still running holds the folder. Wait for it, or the open fails with StorageInUse. await closing?.catch(() => undefined) return openSdk({ dataDir }) })().then( (opened) => { // If close() ran while this open was pending, close() closes this session. Do not publish it. if (mine === generation) session = opened return opened }, (err) => { if (mine === generation) opening = undefined // let the next call try again throw err }, ) return opening } /** Closes the session. Every federation and operation from it stops working. */ export function close(): Promise { const pending = opening generation += 1 session = undefined opening = undefined // An open that failed left nothing to close. if (pending) closing = pending.then( (s) => s.close(), () => undefined, ) return closing ?? Promise.resolve() } /** The open session, or undefined before open() resolves and after close(). */ export const current = (): SdkSession | undefined => session ``` Gate the tree on it once, near the top. The root opens the session and does not close it on unmount. An unmount is not the end of the wallet's life. Your app calls `close()` at the points it chooses, such as sign out or before a restore over the same folder. `src/WalletRoot.tsx` ```tsx import React, { useEffect, useState } from 'react' import { Text } from 'react-native' import type { SdkSession } from '@fedimint/react-native' import { open } from './fedimint' /** Put this near the top of your tree, once. */ export function WalletRoot({ children }: { children: React.ReactNode }) { const [session, setSession] = useState(undefined) const [failed, setFailed] = useState(undefined) useEffect(() => { let live = true open().then( (opened) => { if (live) setSession(opened) }, (err) => { if (live) setFailed(String(err)) }, ) // No close() here. The app closes the session, not a component. return () => { live = false } }, []) if (failed) return Could not open the wallet: {failed} if (!session) return Opening the wallet... return <>{children} } ``` Below `WalletRoot`, any code can call `current()` and get the open session. Code outside React, such as a background task, calls `await open()` and gets the same one. **One module, one session.** Two calls to `openSdk` on the same folder do not share anything. The second fails with `StorageInUse`. Call `open()` from this module everywhere, and never call `openSdk` anywhere else. Wrap your navigator in `WalletRoot`. You will see `Opening the wallet...` for a moment, then your first screen. ### 02 · Optional: hold the session in a context `current()` is enough for many apps. If you prefer to pass the session through React, write a small context. This is your code, not part of the SDK. It holds a session that is already open. It does not open one, and it does not close one. `src/SessionContext.tsx` ```tsx import React, { createContext, useContext } from 'react' import type { SdkSession } from '@fedimint/react-native' const SessionContext = createContext(undefined) /** Your own provider. It holds an open session and never closes it. */ export function SessionProvider({ session, children, }: { session: SdkSession children: React.ReactNode }) { return ( {children} ) } /** The session from the nearest SessionProvider. Throws when there is none above. */ export function useSession(): SdkSession { const session = useContext(SessionContext) if (!session) throw new Error('useSession needs a SessionProvider above it') return session } ``` In `WalletRoot` from step 01, change the last line to `return {children};` and import `SessionProvider` from `./SessionContext`. Then any screen below can call `useSession().sdk`. A screen below `WalletRoot` that renders `useSession().sdk.federations().length` shows `0` before the first join and `1` after it. ### 03 · Follow a balance and an operation with hooks The SDK ships two hooks. `useBalance(fed, onError)` keeps a balance on screen. `useOperation(op, onError)` keeps an operation's state on screen: a receive, a send, or one reopened from history. Both accept `undefined`, so a screen can call them before its federation or operation is ready. That keeps the hooks in the same order on every render, which React requires. `InvoiceStatus.tsx` ```tsx import React from 'react' import { Text } from 'react-native' import { LnReceiveState_Tags, useOperation, type LnReceiveOperationLike, } from '@fedimint/react-native' /** Renders again on every state change, and stops listening when it unmounts. */ export function InvoiceStatus({ operation, }: { operation: LnReceiveOperationLike | undefined }) { const state = useOperation(operation, (err) => console.log('operation watch', err), ) if (!state) return Creating the invoice... if (state.tag === LnReceiveState_Tags.Claimed) return Received return {state.tag} } ``` Each hook is keyed on the object's `id()`. A new handle with the same id does not restart the watch. The hooks also never retry. When the federation or the session closes, the watch fails with `FederationClosed`, reports it to `onError`, and stops. After you reopen, remount the component with a new React `key`: `BalanceCard.tsx` ```tsx import React, { useState } from 'react' import { Button, Text } from 'react-native' import { ErrorCode, Exception, useBalance, type FederationLike, } from '@fedimint/react-native' function BalanceLine({ fed, onClosed, }: { fed: FederationLike onClosed: () => void }) { const msat = useBalance(fed, (err) => { if ( Exception.hasInner(err) && Exception.getInner(err).code() === ErrorCode.FederationClosed ) { onClosed() } else { console.log('balance watch', err) } }) return ( {msat === undefined ? 'loading...' : `${msat / 1000n} sats`} ) } type Props = { fed: FederationLike /** Your code: open the session again and get the federation back, as in Join a federation. */ reopen: () => Promise } export function BalanceCard({ fed, reopen }: Props) { const [current, setCurrent] = useState(fed) const [watch, setWatch] = useState(0) const [closed, setClosed] = useState(false) if (closed) { return (