diff --git a/docs/features/soroban-auth-tree.md b/docs/features/soroban-auth-tree.md
new file mode 100644
index 00000000..55d7b4d0
--- /dev/null
+++ b/docs/features/soroban-auth-tree.md
@@ -0,0 +1,89 @@
+# Soroban Contract Authorization Tree (#850)
+
+The Soroban invocation UI now renders an **Authorization Requirements** panel immediately after each simulation. This tree lets developers verify which accounts must sign and what invocations they are authorizing—including cross-contract calls—before committing a transaction.
+
+## Overview
+
+When you simulate a Soroban contract call, the Stellar RPC returns a list of `SorobanAuthorizationEntry` values embedded in the simulation result. Each entry describes:
+
+- **Who must sign** — the `SorobanCredentials` field, which is either the transaction's source account or an explicit Stellar address.
+- **What they are authorizing** — the `rootInvocation` tree, which may recursively contain sub-invocations representing cross-contract calls.
+
+The `AuthorizationTree` component reads these entries from the simulation result, serializes them to plain JSON-safe values, and renders the tree visually in the Contract Interaction panel.
+
+## Using the feature
+
+1. Open the **Contract Interaction** panel and enter a valid contract ID, function name, and arguments.
+2. Click **Simulate**.
+3. If the contract function requires any authorization, the **Authorization Requirements** panel appears below the simulation output.
+4. Each signer entry shows:
+ - A color-coded credentials badge (green for source-account signers, orange for explicit address signers).
+ - The nonce and signature-expiration ledger for address credentials.
+ - An expandable invocation tree with every function name and contract address the signer is authorizing, including nested cross-contract calls.
+
+## Security guidance
+
+- **Always inspect before signing.** The authorization tree shows the full scope of what a signer is committing to. An entry that lists only the expected top-level function is very different from one with unexpected sub-invocations (potential rug-pull patterns).
+- **Address credentials with an expiry ledger close to the current ledger** should be treated with extra caution—the window for replay may still be open.
+- **Source-account credentials** authorize the transaction signer directly. If you see a source-account entry for a function you did not expect to call, abort and investigate the contract.
+- All displayed values originate from XDR data returned by the Soroban RPC server. React's default string escaping prevents XSS; no raw HTML is rendered.
+
+## Compatibility
+
+| Requirement | Notes |
+|---|---|
+| Stellar SDK | `@stellar/stellar-sdk` ≥ 17 (already required by this project) |
+| Soroban Protocol | Protocol 20 and later (the `auth` field on `SimulateHostFunctionResult` was introduced in Protocol 20) |
+| Browser | Any modern browser; no additional APIs required |
+| Network | Works on Testnet, Mainnet, and custom networks |
+
+## Migration notes
+
+- No breaking changes. The `authEntries` field was **added** to `ContractSimulationResult`; existing callers that don't reference it are unaffected.
+- The `AuthorizationTree` component renders `null` when `authEntries` is empty or absent, so rendering it unconditionally after a simulation result is safe.
+- Simulations of non-invocation transactions (e.g. restoring a footprint) return no `result` object, and therefore no auth entries. The panel will not appear for those simulations.
+
+## Data model reference
+
+The serialized types used by `AuthorizationTree` are exported from `src/lib/stellar.ts`:
+
+```typescript
+// Top-level entry
+interface SerializedAuthEntry {
+ credentials: SerializedAuthCredentials;
+ rootInvocation: SerializedAuthInvocation;
+}
+
+// Discriminated union for credentials
+type SerializedAuthCredentials =
+ | { type: 'source_account' }
+ | {
+ type: 'address';
+ address: string; // Stellar G... address
+ nonce: string; // i64 nonce as decimal string
+ signatureExpirationLedger: number;
+ };
+
+// Recursive invocation node
+interface SerializedAuthInvocation {
+ functionType: 'contract_fn' | 'create_contract' | 'create_contract_v2' | 'unknown';
+ contractAddress: string; // empty for host-function variants
+ functionName: string; // empty for host-function variants
+ args: unknown[]; // serialized ScVal values
+ subInvocations: SerializedAuthInvocation[];
+}
+```
+
+## Component API
+
+```tsx
+import AuthorizationTree from 'src/components/dashboard/AuthorizationTree';
+
+
+```
+
+The component accepts `authEntries?: SerializedAuthEntry[]` and renders nothing (`null`) when the array is empty or the prop is absent/null/non-array.
diff --git a/src/components/dashboard/AuthorizationTree.tsx b/src/components/dashboard/AuthorizationTree.tsx
new file mode 100644
index 00000000..aca27cc2
--- /dev/null
+++ b/src/components/dashboard/AuthorizationTree.tsx
@@ -0,0 +1,434 @@
+/**
+ * AuthorizationTree — #850
+ *
+ * Renders the nested authorization entries returned by a Soroban simulation.
+ * Each entry describes which signer is required and the full tree of
+ * contract invocations they must authorize, including cross-contract calls.
+ *
+ * Props:
+ * authEntries — Array of SerializedAuthEntry from simulateContractCall.
+ * Renders nothing when the array is empty.
+ * className — Optional CSS class forwarded to the outer wrapper.
+ *
+ * Security note:
+ * Values displayed here come from XDR data returned by the Soroban RPC.
+ * All values are treated as untrusted display text and rendered through
+ * React's normal string escaping — no dangerouslySetInnerHTML is used.
+ * Signers should independently verify authorization entries before signing.
+ */
+
+import React, { useState } from 'react';
+import { Shield, ChevronRight, ChevronDown, Key, User, Lock, Code } from 'lucide-react';
+import type { SerializedAuthEntry, SerializedAuthInvocation } from '../../lib/stellar';
+
+// ─── Internal helpers ─────────────────────────────────────────────────────────
+
+function truncateAddress(address: string, leadingChars = 8, trailingChars = 6): string {
+ if (!address || address.length <= leadingChars + trailingChars + 3) return address;
+ return `${address.slice(0, leadingChars)}…${address.slice(-trailingChars)}`;
+}
+
+// ─── Sub-components ───────────────────────────────────────────────────────────
+
+interface InvocationNodeProps {
+ invocation: SerializedAuthInvocation;
+ depth?: number;
+}
+
+/**
+ * Renders a single invocation node and, recursively, all its sub-invocations.
+ * Nodes at depth > 0 are indented with a connecting tree-line gutter.
+ */
+function InvocationNode({ invocation, depth = 0 }: InvocationNodeProps) {
+ const [expanded, setExpanded] = useState(true);
+ const hasChildren = invocation.subInvocations.length > 0;
+
+ const functionLabel: string = (() => {
+ if (invocation.functionType === 'contract_fn') {
+ return invocation.functionName || '(unknown function)';
+ }
+ if (invocation.functionType === 'create_contract') return 'create_contract';
+ if (invocation.functionType === 'create_contract_v2') return 'create_contract_v2';
+ return '(unknown)';
+ })();
+
+ const contractLabel =
+ invocation.functionType === 'contract_fn' && invocation.contractAddress
+ ? truncateAddress(invocation.contractAddress)
+ : null;
+
+ return (
+
0 ? '20px' : '0',
+ borderLeft: depth > 0 ? '1px solid var(--border)' : 'none',
+ marginLeft: depth > 0 ? '10px' : '0',
+ }}
+ >
+ {/* Node header row */}
+
setExpanded((v) => !v) : undefined}
+ >
+ {/* Expand/collapse toggle */}
+
+ {expanded ? : }
+
+
+
+
+
+ {/* Function name */}
+
+ {functionLabel}
+
+
+ {/* Contract address */}
+ {contractLabel && (
+
+ {contractLabel}
+
+ )}
+
+ {/* Argument count */}
+ {invocation.args.length > 0 && (
+
+ {invocation.args.length} arg{invocation.args.length !== 1 ? 's' : ''}
+
+ )}
+
+
+ {/* Sub-invocation badge */}
+ {invocation.subInvocations.length > 0 && (
+
+ {invocation.subInvocations.length}
+
+ )}
+
+
+ {/* Recursive children */}
+ {hasChildren && expanded && (
+
+ {invocation.subInvocations.map((sub, idx) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+interface CredentialsBadgeProps {
+ entry: SerializedAuthEntry;
+}
+
+function CredentialsBadge({ entry }: CredentialsBadgeProps) {
+ const { credentials } = entry;
+
+ if (credentials.type === 'source_account') {
+ return (
+
+
+
+ Source Account
+
+
+ (transaction signer)
+
+
+ );
+ }
+
+ // Address credentials
+ return (
+
+
+
+
+ Address Signature Required
+
+
+
+ {credentials.address}
+
+
+ Nonce: {credentials.nonce}
+ Expires ledger: {credentials.signatureExpirationLedger}
+
+
+ );
+}
+
+// ─── Primary export ───────────────────────────────────────────────────────────
+
+export interface AuthorizationTreeProps {
+ /** Authorization entries from simulateContractCall. */
+ authEntries: SerializedAuthEntry[];
+ /** Optional additional CSS class for the outer wrapper. */
+ className?: string;
+}
+
+/**
+ * AuthorizationTree renders the authorization entries returned by a Soroban
+ * simulation so developers can verify which accounts need to sign and what
+ * invocations they are authorizing before committing a transaction.
+ *
+ * Returns `null` when `authEntries` is empty or not provided, so callers can
+ * render it unconditionally after a simulation result arrives.
+ */
+export default function AuthorizationTree({ authEntries, className }: AuthorizationTreeProps) {
+ // Input guard: gracefully handle non-array values (e.g. legacy result without
+ // this field, or a direct rendering error).
+ if (!Array.isArray(authEntries) || authEntries.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {/* Panel header */}
+
+
+
+ Authorization Requirements
+
+
+ {authEntries.length} signer{authEntries.length !== 1 ? 's' : ''}
+
+
+
+ {/* Security notice */}
+
+
+
+ Review all authorization entries before signing. Each entry represents a signer whose
+ private key will be used to authorize the invocations listed below.
+
+
+
+ {/* Auth entry list */}
+
+ {authEntries.map((entry, idx) => (
+
+ {/* Entry header */}
+
+
+ Signer {idx + 1}
+
+ {authEntries.length > 1 && (
+
+ )}
+
+
+ {/* Credentials */}
+
+
+ {/* Invocation tree */}
+
+
+ Authorized Invocations
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/dashboard/ContractInteraction.tsx b/src/components/dashboard/ContractInteraction.tsx
index 4aa2e032..31976dcb 100644
--- a/src/components/dashboard/ContractInteraction.tsx
+++ b/src/components/dashboard/ContractInteraction.tsx
@@ -13,6 +13,7 @@ import { getContractInteractions } from "../../lib/storage";
import { Sparkles, AlertTriangle, AlertCircle, HelpCircle } from "lucide-react";
import GasCostEstimator from "./GasCostEstimator";
import ResourceMetrics from "./ResourceMetrics";
+import AuthorizationTree from "./AuthorizationTree";
import MainnetReviewModal from "../security/MainnetReviewModal";
import MainnetConfirmDialog from "../security/MainnetConfirmDialog";
import { useWriteGuard } from "../../hooks/useWriteGuard";
@@ -1033,6 +1034,7 @@ export default function ContractInteraction() {
network={network}
inclusionFee={100} // Basic minimum inclusion fee
/>
+
)}
diff --git a/tests/unit/components/AuthorizationTree.test.tsx b/tests/unit/components/AuthorizationTree.test.tsx
new file mode 100644
index 00000000..f4b6931b
--- /dev/null
+++ b/tests/unit/components/AuthorizationTree.test.tsx
@@ -0,0 +1,285 @@
+/**
+ * Tests for AuthorizationTree (#850)
+ *
+ * Covers:
+ * - Primary flow: renders auth entries with signer credentials and invocation tree.
+ * - Boundary cases: empty entries array, deeply-nested sub-invocations, address
+ * credential with long address, multiple signers, unknown function types.
+ * - Failure cases: null/undefined entries, non-array value, malformed entry.
+ *
+ * Testing pattern follows ResourceMetrics.test.tsx: direct component imports with
+ * vi.mock for dependencies.
+ */
+
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import '@testing-library/jest-dom';
+import AuthorizationTree from '../../../src/components/dashboard/AuthorizationTree';
+import type {
+ SerializedAuthEntry,
+ SerializedAuthInvocation,
+} from '../../../src/lib/stellar';
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const CONTRACT_ADDR =
+ 'CCJJUDP3ZY7YTQCVHV2BRTUMQKQAHLJIMYTCGVOFG5NQZR3FW6GKU7O';
+
+function makeContractFnInvocation(
+ functionName: string,
+ contractAddress = CONTRACT_ADDR,
+ subInvocations: SerializedAuthInvocation[] = [],
+): SerializedAuthInvocation {
+ return {
+ functionType: 'contract_fn',
+ contractAddress,
+ functionName,
+ args: [],
+ subInvocations,
+ };
+}
+
+const sourceAccountEntry: SerializedAuthEntry = {
+ credentials: { type: 'source_account' },
+ rootInvocation: makeContractFnInvocation('transfer'),
+};
+
+const addressEntry: SerializedAuthEntry = {
+ credentials: {
+ type: 'address',
+ address: 'GBXLTPJHZJGGFG5NQZR3FW6GKU7OCCJJUDP3ZY7YTQCVHV2BRTUMQKA',
+ nonce: '12345678',
+ signatureExpirationLedger: 9_876_543,
+ },
+ rootInvocation: makeContractFnInvocation('approve'),
+};
+
+const nestedEntry: SerializedAuthEntry = {
+ credentials: { type: 'source_account' },
+ rootInvocation: makeContractFnInvocation('swap', CONTRACT_ADDR, [
+ makeContractFnInvocation('transfer', CONTRACT_ADDR, [
+ makeContractFnInvocation('emit_event'),
+ ]),
+ ]),
+};
+
+const unknownFnEntry: SerializedAuthEntry = {
+ credentials: { type: 'source_account' },
+ rootInvocation: {
+ functionType: 'unknown',
+ contractAddress: '',
+ functionName: '',
+ args: [],
+ subInvocations: [],
+ },
+};
+
+const createContractEntry: SerializedAuthEntry = {
+ credentials: { type: 'source_account' },
+ rootInvocation: {
+ functionType: 'create_contract',
+ contractAddress: '',
+ functionName: '',
+ args: [],
+ subInvocations: [],
+ },
+};
+
+// ─── Primary flow ─────────────────────────────────────────────────────────────
+
+describe('AuthorizationTree — primary flow', () => {
+ it('renders the panel heading and signer count badge', () => {
+ render();
+
+ expect(screen.getByText('Authorization Requirements')).toBeInTheDocument();
+ // Badge label
+ expect(screen.getByText('1 signer')).toBeInTheDocument();
+ });
+
+ it('renders source account credentials badge', () => {
+ render();
+
+ expect(screen.getByText('Source Account')).toBeInTheDocument();
+ expect(screen.getByText('(transaction signer)')).toBeInTheDocument();
+ });
+
+ it('renders function name from root invocation', () => {
+ render();
+
+ expect(screen.getByText('transfer')).toBeInTheDocument();
+ });
+
+ it('renders address credential with address, nonce, and expiry ledger', () => {
+ render();
+
+ expect(screen.getByText('Address Signature Required')).toBeInTheDocument();
+ expect(
+ screen.getByText('GBXLTPJHZJGGFG5NQZR3FW6GKU7OCCJJUDP3ZY7YTQCVHV2BRTUMQKA'),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/Nonce: 12345678/)).toBeInTheDocument();
+ expect(screen.getByText(/Expires ledger: 9876543/)).toBeInTheDocument();
+ });
+
+ it('renders a security notice', () => {
+ render();
+ expect(screen.getByRole('note')).toBeInTheDocument();
+ expect(screen.getByText(/Review all authorization entries before signing/)).toBeInTheDocument();
+ });
+
+ it('marks the panel as a tree for accessibility', () => {
+ render();
+ expect(screen.getByRole('tree')).toBeInTheDocument();
+ });
+});
+
+// ─── Boundary cases ───────────────────────────────────────────────────────────
+
+describe('AuthorizationTree — boundary cases', () => {
+ it('returns null (renders nothing) for an empty entries array', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders plural signer badge for multiple entries', () => {
+ render();
+
+ expect(screen.getByText('2 signers')).toBeInTheDocument();
+ });
+
+ it('renders multiple signer sections labeled Signer 1, Signer 2', () => {
+ render();
+
+ expect(screen.getByText('Signer 1')).toBeInTheDocument();
+ expect(screen.getByText('Signer 2')).toBeInTheDocument();
+ });
+
+ it('renders nested sub-invocations in the tree', () => {
+ render();
+
+ // Root function
+ expect(screen.getByText('swap')).toBeInTheDocument();
+ // First-level sub-invocation
+ expect(screen.getByText('transfer')).toBeInTheDocument();
+ // Second-level sub-invocation
+ expect(screen.getByText('emit_event')).toBeInTheDocument();
+ });
+
+ it('shows sub-invocation count badge on nodes with children', () => {
+ render();
+
+ // The root node 'swap' has 1 sub-invocation; the badge should appear.
+ // nestedEntry also has 'transfer' with 1 sub-invocation, so there are two
+ // badges with aria-label "1 sub-invocation".
+ const badges = screen.getAllByLabelText('1 sub-invocation');
+ expect(badges.length).toBeGreaterThan(0);
+ });
+
+ it('collapses and re-expands a node with sub-invocations', () => {
+ render();
+
+ // 'swap' is the root treeitem (depth 0). There may be multiple matching
+ // treeitems; we want the first (outermost) expanded node.
+ const treeItems = screen.getAllByRole('treeitem', { name: /1 sub-invocation/ });
+ const swapNode = treeItems[0];
+ fireEvent.click(swapNode);
+
+ // After collapse, 'transfer' (child) should no longer be visible.
+ expect(screen.queryByText('transfer')).not.toBeInTheDocument();
+
+ // Click again to expand.
+ fireEvent.click(swapNode);
+ expect(screen.getByText('transfer')).toBeInTheDocument();
+ });
+
+ it('renders create_contract function type label', () => {
+ render();
+ expect(screen.getByText('create_contract')).toBeInTheDocument();
+ });
+
+ it('renders unknown function type fallback label', () => {
+ render();
+ expect(screen.getByText('(unknown)')).toBeInTheDocument();
+ });
+
+ it('truncates long contract addresses in invocation node', () => {
+ // CONTRACT_ADDR is 56 chars — longer than the truncation threshold.
+ render();
+ // Full address should NOT appear as visible text (it's in the title attribute).
+ const node = screen.queryByText(CONTRACT_ADDR);
+ expect(node).not.toBeInTheDocument();
+ });
+});
+
+// ─── Failure / invalid input cases ───────────────────────────────────────────
+
+describe('AuthorizationTree — failure cases', () => {
+ it('returns null when authEntries is undefined', () => {
+ // Simulate a caller passing an older result that lacks authEntries.
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('returns null when authEntries is null', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('returns null when authEntries is not an array', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders without crashing when invocation has an empty function name', () => {
+ const entry: SerializedAuthEntry = {
+ credentials: { type: 'source_account' },
+ rootInvocation: {
+ functionType: 'contract_fn',
+ contractAddress: CONTRACT_ADDR,
+ functionName: '',
+ args: [],
+ subInvocations: [],
+ },
+ };
+ render();
+ // Falls back to placeholder label
+ expect(screen.getByText('(unknown function)')).toBeInTheDocument();
+ });
+
+ it('renders without crashing when invocation has no args and no sub-invocations', () => {
+ const entry: SerializedAuthEntry = {
+ credentials: { type: 'source_account' },
+ rootInvocation: {
+ functionType: 'contract_fn',
+ contractAddress: CONTRACT_ADDR,
+ functionName: 'noop',
+ args: [],
+ subInvocations: [],
+ },
+ };
+ // Should not throw and should render the function name.
+ render();
+ expect(screen.getByText('noop')).toBeInTheDocument();
+ });
+
+ it('renders without crashing for a create_contract_v2 function type', () => {
+ const entry: SerializedAuthEntry = {
+ credentials: { type: 'source_account' },
+ rootInvocation: {
+ functionType: 'create_contract_v2',
+ contractAddress: '',
+ functionName: '',
+ args: [],
+ subInvocations: [],
+ },
+ };
+ render();
+ expect(screen.getByText('create_contract_v2')).toBeInTheDocument();
+ });
+});