@caatinga/client is the browser/client-side interop layer for TypeScript bindings generated by npx @stellar/stellar-sdk generate (via ctg generate). It connects:
- generated contract bindings
caatinga.artifacts.json- RPC URL and network passphrase
- a wallet adapter
It does not replace Stellar CLI, Stellar SDK, Soroban SDK, generated bindings, or wallet signing. It does not serialize SCVal manually, parse XDR manually, or store secret keys.
Browser wallet invoke() signs with one connected wallet as the transaction invoker. Multi-auth is application-owned — contracts requiring delegated AddressV2 / non-invoker signAuthEntry credentials fail with CAATINGA_MULTI_AUTH_REQUIRED.
Included:
- artifact-based
contractIdlookup - generated binding registration
CaatingaWalletAdapter- Stellar Wallets Kit adapter
- Freighter adapter compatibility subpath
createWalletSession— framework-agnostic connection state, persistence, silent restore@caatinga/client/react—WalletProvider+useWallet/useWalletSessionhooks (optional React peer)read()simulate()invoke()buildXdr()- explicit
debugXdranddebugRaw CAATINGA_XDR_*, binding, wallet, and artifact errors
Not included:
- CLI XDR commands
ctg generate --interop- custom SCVal serialization
- multisig orchestration
- backend signing
pnpm add @caatinga/client @creit.tech/stellar-wallets-kit@caatinga/client depends on @caatinga/core and imports only the browser-safe subpath
@caatinga/core/browser (errors and artifact types). That entry excludes Node-only modules such as
execa, so Vite and webpack bundles do not pull in the CLI shell layer.
Application code that needs the same types in the browser should import from @caatinga/core/browser
rather than the root @caatinga/core package entry.
import { createCaatingaClient } from "@caatinga/client";
import { createStellarWalletsKitAdapter } from "@caatinga/client/stellar-wallets-kit";
import type { CaatingaArtifacts } from "@caatinga/core/browser";
import * as Counter from "./contracts/generated/counter";
import artifactsJson from "../caatinga.artifacts.json";
// JSON imports widen `version` to number; the cast restores the artifacts schema type.
const artifacts = artifactsJson as CaatingaArtifacts;
const wallet = createStellarWalletsKitAdapter();
const client = createCaatingaClient({
network: {
name: "testnet",
rpcUrl: "https://soroban-testnet.stellar.org",
networkPassphrase: "Test SDF Network ; September 2015",
},
artifacts,
wallet,
contracts: {
counter: {
binding: Counter,
},
},
});
const before = await client.contract("counter").read<number>("get");
const increment = await client.contract("counter").invoke<number>("increment");
const after = increment.result ?? (await client.contract("counter").read<number>("get"));Minimal successful result:
{
"status": "confirmed",
"contract": "counter",
"method": "increment",
"contractId": "C...",
"transactionHash": "..."
}status reflects the transaction's real on-chain outcome, so it must be checked
before showing a success state:
status |
Meaning |
|---|---|
confirmed |
The transaction landed and succeeded (SUCCESS). |
failed |
The transaction was rejected or failed on-chain (FAILED, ERROR). |
pending |
The outcome is unknown — still unresolved, or the polling window expired. Not a success. |
const res = await client.contract("token").invoke("transfer", { to, amount });
if (res.status === "confirmed") {
showSuccess(res.transactionHash);
} else if (res.status === "failed") {
showFailure(res.resultXdr, res.diagnosticEvents);
} else {
showPending(res.transactionHash);
}On failed, resultXdr and diagnosticEvents carry the RPC's diagnosis when
it returned them — no need to re-run with debugRaw. See
runtime-invoke-pipeline.md for the full mapping
from Soroban RPC statuses.
If a contract ID is not passed explicitly, the client resolves it from:
artifacts.networks[network].contracts[contract].contractIdTo override artifacts:
const client = createCaatingaClient({
network,
artifacts,
wallet,
contracts: {
counter: {
binding: Counter,
contractId: "C...",
},
},
});Arguments are forwarded as one object to the generated binding method:
await client.contract("token").invoke("transfer", {
to: receiverAddress,
amount: 100n,
});| API | When to use |
|---|---|
read() |
Read-only methods; returns the parsed value directly |
simulate() |
Read-only methods when you need status, contractId, or debugRaw |
invoke() |
State-changing methods that must be signed and submitted |
Use read() for read-only contract methods when the UI only needs the returned value:
const value = await client.contract("counter").read<number>("get");Use simulate() when the UI or diagnostics need metadata:
const result = await client.contract("counter").simulate<number>("get", {
debugRaw: true,
});
console.log(result.status);
console.log(result.contractId);
console.log(result.result);
console.log(result.raw);simulate() prepares the generated binding transaction and returns the parsed binding result.
It does not call wallet.signTransaction(). If the simulated method does not expose a result,
the client throws CAATINGA_READ_RESULT_MISSING.
Reads do not sign or submit transactions. If simulation reports archived Soroban state, use
invoke() with the explicit restore: true option when invoking the contract method:
await client.contract("counter").invoke("increment", { restore: true });Pass it as the only key of the options object, or as the second argument when the method also
takes args (invoke("set", { value: 1 }, { restore: true })). A single-argument object whose only
key is restore is read as options, so a contract arg literally named restore must be passed
with an explicit options argument: invoke("set_flag", { restore: true }, {}).
The client checks the wallet network first (CAATINGA_WALLET_NETWORK_MISMATCH), then the default
binding adapter forwards this option to compatible generated bindings and supplies the connected
wallet signer; a rejected or empty restore signature fails with CAATINGA_XDR_SIGN_FAILED. The binding may ask the wallet to approve and submit a
RestoreFootprint transaction before preparing the invocation. Custom binding adapters must
forward methodOptions for this option to work. Networks with automatic restoration may not
return a restorePreamble or require a separate restore transaction. If only a read is needed,
simulate() will not submit a restore transaction; use the RPC's restorePreamble.minResourceFee
and restorePreamble.transactionData with the SDK's Operation.restoreFootprint() to construct
and sign a separate restore transaction, following the Stellar JS SDK restore guide, then retry the read.
Calling invoke() on a read-only binding method may fail with a hint to use read() or simulate() instead.
wallet is optional for read() and simulate(). invoke() and buildXdr() still require one
and throw CAATINGA_WALLET_NOT_CONNECTED when it is missing.
Read-only calls choose a source account in this order:
options.sourceAccounton that call- the connected wallet's public key, when
getPublicKey()resolves to a non-empty address readSourceAccountoncreateCaatingaClient()DEFAULT_READ_SOURCE_ACCOUNT(the Stellar null account)
Omitting wallet, a disconnected or locked wallet, CAATINGA_WALLET_NOT_CONNECTED, or a blank
address falls through to the next source. CAATINGA_WALLET_TIMEOUT and any other Caatinga error
from the adapter are thrown: those are real failures, and the client does not hide them by reading
as the fallback account. Wallet public keys are used as the adapter returned them.
readSourceAccount and options.sourceAccount must be G… Ed25519 public keys (56 characters).
Any other value throws CAATINGA_INVALID_CONFIG and names the option that held it. A blank or
whitespace-only value is treated as omitted.
The null-account placeholder is not sent to the RPC. When the resolved source is
DEFAULT_READ_SOURCE_ACCOUNT, the client omits publicKey and @stellar/stellar-sdk builds the
transaction with its local null account (new Account(NULL_ACCOUNT, "0")) instead of
Server.getAccount. A wallet or configured source is loaded with getAccount.
const visitorView = await client.contract("counter").read<number>("get");
const asFeePayer = await client.contract("counter").simulate<number>("get", {
sourceAccount: "G...",
});read, simulate, and invoke take (method, args?, options?). A single object is treated as
options only when it is non-empty and every key is a known option key:
read/simulate:debugRaw,sourceAccountinvoke:debugXdr,debugRaw
Any other object, including one that mixes method arguments with those keys, is forwarded to the contract method. An empty object is arguments.
{ sourceAccount } alone is ambiguous. A contract argument that is literally named sourceAccount
is consumed as the read option unless you pass options as the second argument:
await client.contract("registry").read("lookup", { sourceAccount: account }, {});
await client
.contract("registry")
.read("lookup", { sourceAccount: account }, { sourceAccount: feePayer });Keep Caatinga client wiring in a dedicated module with static imports:
// src/caatinga.ts — static imports only
import { createCaatingaClient } from "@caatinga/client";
import type { CaatingaArtifacts } from "@caatinga/core/browser";
import artifactsJson from "../caatinga.artifacts.json";
import * as Counter from "./contracts/generated/counter";
import { stellarWalletAdapter } from "./wallet.js";
const artifacts = artifactsJson as CaatingaArtifacts;
export const caatingaClient = createCaatingaClient({
artifacts,
/* ... */
});// src/App.tsx — import the pre-built client
import { caatingaClient } from "./caatinga.js";Avoid dynamic import() of bindings or caatinga.artifacts.json inside React components. Vite needs static import paths to bundle JSON artifacts and generated contract modules correctly. The official react-vite-counter template follows this pattern in src/caatinga.ts.
XDR is omitted by default and only returned when debugXdr is enabled.
const result = await client.contract("counter").invoke("increment", {
debugXdr: true,
});
console.log(result.xdr?.unsigned);
console.log(result.xdr?.prepared);
console.log(result.xdr?.signed);Raw binding or submit output is omitted unless debugRaw is enabled:
const result = await client.contract("counter").invoke("increment", {
debugXdr: true,
debugRaw: true,
});
console.log(result.raw);const tx = await client.contract("counter").buildXdr("increment");
console.log(tx.unsignedXdr);
console.log(tx.preparedXdr);buildXdr() creates the generated binding client, so it may call wallet.getPublicKey(). It does not call wallet.signTransaction().
export interface CaatingaWalletAdapter {
getPublicKey(): Promise<string>;
signTransaction(input: { xdr: string; networkPassphrase: string }): Promise<string>;
/** Optional: the wallet's current network passphrase, or undefined if unknown. */
getNetworkPassphrase?(): Promise<string | undefined>;
}Contract:
- Network check (optional): if the adapter implements
getNetworkPassphrase,invoke()compares it withnetwork.networkPassphrasebefore building and signing, and throwsCAATINGA_WALLET_NETWORK_MISMATCHwhen they differ.undefinedor a rejection means "unknown" and does not block. The Freighter and Stellar Wallets Kit adapters implement it. - Reject on dismissal:
getPublicKeyandsignTransactionmust reject when the user cancels or dismisses the wallet UI. Do not leave the promise pending indefinitely. - Adapter timeouts: Your adapter may apply its own timeout before rejecting.
- Caatinga timeout: Caatinga does not impose a default timeout. Pass optional
walletTimeout(milliseconds) onCaatingaClientConfigto capgetPublicKeyandsignTransaction; when exceeded, the client throwsCAATINGA_WALLET_TIMEOUT.
The Stellar Wallets Kit adapter is exported from:
import { createStellarWalletsKitAdapter } from "@caatinga/client/stellar-wallets-kit";The Freighter adapter remains available for compatibility:
import { freighterWalletAdapter } from "@caatinga/client/freighter";For the full adapter guide — bundled adapters, custom adapters, capability methods, and Stellar Wallets Kit bundler workarounds — see Wallets.
Vite apps using Stellar Wallets Kit get reusable bundler helpers from @caatinga/client/vite:
walletStubViteAliases, walletStubOverrides, and walletStubPnpmWorkspaceYaml. These avoid
copying 15+ lines of overrides by hand — see Wallets — Adding SWK to a custom Vite app.
createWalletSession(adapter, options?) wraps any adapter with connection state
(disconnected / connecting / connected), subscriptions, optional persistence, and silent
restore — usable from any framework or plain TypeScript:
import { createWalletSession } from "@caatinga/client";
const session = createWalletSession(wallet, { persist: true });
session.subscribe(() => render(session.getState()));
await session.connect(); // modal when the adapter has one, else getPublicKey()
await session.restore(); // silent reconnect on page load — never throwsReact apps (react >= 18, optional peer) get a provider and hook from the react subpath:
import { WalletProvider, useWallet } from "@caatinga/client/react";
export default function App() {
return (
<WalletProvider adapter={wallet} options={{ persist: true }}>
<Body />
</WalletProvider>
);
}
function Body() {
const { publicKey, connected, connecting, error, connect, disconnect } = useWallet();
// ...
}useWallet is backed by useSyncExternalStore; WalletProvider restores persisted sessions on
mount automatically. useWalletSession() returns the underlying WalletSession from the nearest
WalletProvider when you need direct access to the session API. Full reference and examples: Wallets.
The default binding adapter expects generated bindings to:
- export
Client - accept
contractId,publicKey,rpcUrl, andnetworkPassphrase - expose contract methods on the client instance
- return a transaction-like object with
toXDR() - optionally expose
prepare() - expose
signAndSend({ signTransaction }), wheresignTransactionreturns{ signedTxXdr }
Caatinga adapts CaatingaWalletAdapter.signTransaction({ xdr, networkPassphrase }) into generated transaction signTransaction(xdr, opts), and does not parse XDR or serialize Soroban values.
If @stellar/stellar-sdk generate changes this generated shape, the compatibility fix belongs in the binding adapter/client integration layer, not in application code.
@caatinga/client calls assembledTx.signAndSend({ signTransaction }) on generated bindings. SDK v16 accepts optional force, signTransaction, and watcher; Caatinga passes only signTransaction.
The return is a SentTransaction<T>. Caatinga normalizes sendTransactionResponse.hash → transactionHash and reads the result getter → result on invoke responses.
Multi-auth: when a simulated transaction has unsigned non-invoker Soroban auth entries (delegated address credentials / "AddressV2"), signing raises NeedsMoreSignaturesError.
Caatinga surfaces CAATINGA_MULTI_AUTH_REQUIRED. Handle multi-signer flows in application code.
Client failures use public CAATINGA_* codes. The most common are:
CAATINGA_CONTRACT_ARTIFACT_NOT_FOUNDCAATINGA_BINDING_CLIENT_NOT_FOUNDCAATINGA_BINDING_METHOD_NOT_FOUNDCAATINGA_WALLET_NOT_CONNECTEDCAATINGA_WALLET_TIMEOUTCAATINGA_XDR_BUILD_FAILEDCAATINGA_XDR_PREPARE_FAILEDCAATINGA_SIMULATION_ACCOUNT_NOT_FOUNDCAATINGA_SIMULATION_RESOURCE_LIMITCAATINGA_SIMULATION_ENTRY_ARCHIVEDCAATINGA_XDR_SIGN_FAILEDCAATINGA_XDR_SUBMIT_FAILEDCAATINGA_XDR_RESULT_FAILEDCAATINGA_READ_RESULT_MISSINGCAATINGA_PLACEHOLDER_BINDING
See errors.md for the full table.