Skip to content

Latest commit

 

History

History
461 lines (346 loc) · 17.2 KB

client.md

File metadata and controls

461 lines (346 loc) · 17.2 KB

Client (@caatinga/client)

@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.

Scope

Single-invoker scope

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 contractId lookup
  • 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 / useWalletSession hooks (optional React peer)
  • read()
  • simulate()
  • invoke()
  • buildXdr()
  • explicit debugXdr and debugRaw
  • CAATINGA_XDR_*, binding, wallet, and artifact errors

Not included:

  • CLI XDR commands
  • ctg generate --interop
  • custom SCVal serialization
  • multisig orchestration
  • backend signing

Install

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.

Counter Example

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].contractId

To override artifacts:

const client = createCaatingaClient({
  network,
  artifacts,
  wallet,
  contracts: {
    counter: {
      binding: Counter,
      contractId: "C...",
    },
  },
});

Arguments

Arguments are forwarded as one object to the generated binding method:

await client.contract("token").invoke("transfer", {
  to: receiverAddress,
  amount: 100n,
});

Read and Simulate

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.

Source account

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:

  1. options.sourceAccount on that call
  2. the connected wallet's public key, when getPublicKey() resolves to a non-empty address
  3. readSourceAccount on createCaatingaClient()
  4. 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...",
});

Arguments and options

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, sourceAccount
  • invoke: 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 });

Project layout

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 Debug

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);

Build XDR Only

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().

Implementing a wallet adapter

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 with network.networkPassphrase before building and signing, and throws CAATINGA_WALLET_NETWORK_MISMATCH when they differ. undefined or a rejection means "unknown" and does not block. The Freighter and Stellar Wallets Kit adapters implement it.
  • Reject on dismissal: getPublicKey and signTransaction must 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) on CaatingaClientConfig to cap getPublicKey and signTransaction; when exceeded, the client throws CAATINGA_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.

Wallet session

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 throws

React Hooks

React 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.

Binding Contract

The default binding adapter expects generated bindings to:

  1. export Client
  2. accept contractId, publicKey, rpcUrl, and networkPassphrase
  3. expose contract methods on the client instance
  4. return a transaction-like object with toXDR()
  5. optionally expose prepare()
  6. expose signAndSend({ signTransaction }), where signTransaction returns { 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.

SDK v16 transaction submission

@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.

Failure behavior

Client failures use public CAATINGA_* codes. The most common are:

  • CAATINGA_CONTRACT_ARTIFACT_NOT_FOUND
  • CAATINGA_BINDING_CLIENT_NOT_FOUND
  • CAATINGA_BINDING_METHOD_NOT_FOUND
  • CAATINGA_WALLET_NOT_CONNECTED
  • CAATINGA_WALLET_TIMEOUT
  • CAATINGA_XDR_BUILD_FAILED
  • CAATINGA_XDR_PREPARE_FAILED
  • CAATINGA_SIMULATION_ACCOUNT_NOT_FOUND
  • CAATINGA_SIMULATION_RESOURCE_LIMIT
  • CAATINGA_SIMULATION_ENTRY_ARCHIVED
  • CAATINGA_XDR_SIGN_FAILED
  • CAATINGA_XDR_SUBMIT_FAILED
  • CAATINGA_XDR_RESULT_FAILED
  • CAATINGA_READ_RESULT_MISSING
  • CAATINGA_PLACEHOLDER_BINDING

See errors.md for the full table.