Skip to main content

React Integration

This guide shows how to integrate the SatsTerminal Borrow SDK into a React application.

Setup

Install Dependencies

npm install @satsterminal-sdk/borrow

Create SDK Context

// src/contexts/BorrowContext.tsx
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { BorrowSDK, ChainType, UserStatus, BorrowSDKConfig } from '@satsterminal-sdk/borrow';

interface BorrowContextType {
  sdk: BorrowSDK | null;
  userStatus: UserStatus | null;
  isInitialized: boolean;
  isLoading: boolean;
  error: string | null;
  initialize: (walletAddress: string, signMessage: (msg: string) => Promise<string>) => Promise<void>;
  disconnect: () => void;
}

const BorrowContext = createContext<BorrowContextType | undefined>(undefined);

interface BorrowProviderProps {
  children: ReactNode;
  apiKey: string;
  baseUrl: string;
  chain?: ChainType;
}

export function BorrowProvider({
  children,
  apiKey,
  baseUrl,
  chain = ChainType.ARBITRUM
}: BorrowProviderProps) {
  const [sdk, setSdk] = useState<BorrowSDK | null>(null);
  const [userStatus, setUserStatus] = useState<UserStatus | null>(null);
  const [isInitialized, setIsInitialized] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const initialize = useCallback(async (
    walletAddress: string,
    signMessage: (msg: string) => Promise<string>
  ) => {
    setIsLoading(true);
    setError(null);

    try {
      const config: BorrowSDKConfig = {
        apiKey,
        baseUrl,
        chain,
        wallet: {
          address: walletAddress,
          signMessage
        }
      };

      const borrowSdk = new BorrowSDK(config);
      const { userStatus: status } = await borrowSdk.setup();

      setSdk(borrowSdk);
      setUserStatus(status);
      setIsInitialized(true);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to initialize');
      throw err;
    } finally {
      setIsLoading(false);
    }
  }, [apiKey, baseUrl, chain]);

  const disconnect = useCallback(() => {
    if (sdk) {
      sdk.clearSession();
    }
    setSdk(null);
    setUserStatus(null);
    setIsInitialized(false);
  }, [sdk]);

  return (
    <BorrowContext.Provider
      value={{
        sdk,
        userStatus,
        isInitialized,
        isLoading,
        error,
        initialize,
        disconnect
      }}
    >
      {children}
    </BorrowContext.Provider>
  );
}

export function useBorrowContext() {
  const context = useContext(BorrowContext);
  if (!context) {
    throw new Error('useBorrowContext must be used within BorrowProvider');
  }
  return context;
}

Wrap Your App

// src/App.tsx
import { BorrowProvider } from './contexts/BorrowContext';

function App() {
  return (
    <BorrowProvider
      apiKey={process.env.REACT_APP_API_KEY!}
      baseUrl="https://api.satsterminal.com"
      chain={ChainType.ARBITRUM}
    >
      <YourApp />
    </BorrowProvider>
  );
}

Custom Hooks

useLoan Hook

// src/hooks/useLoan.ts
import { useState, useCallback } from 'react';
import { useBorrowContext } from '../contexts/BorrowContext';
import { WorkflowStatus, DepositInfo, Quote } from '@satsterminal-sdk/borrow';

interface UseLoanOptions {
  collateralBTC: number;
  loanAmountUSD: number;
  ltv?: number;
}

interface UseLoanReturn {
  isLoading: boolean;
  status: WorkflowStatus | null;
  depositInfo: DepositInfo | null;
  quote: Quote | null;
  error: string | null;
  isComplete: boolean;
  startLoan: () => Promise<void>;
  stopTracking: () => void;
}

export function useLoan(options: UseLoanOptions): UseLoanReturn {
  const { sdk } = useBorrowContext();
  const [isLoading, setIsLoading] = useState(false);
  const [status, setStatus] = useState<WorkflowStatus | null>(null);
  const [depositInfo, setDepositInfo] = useState<DepositInfo | null>(null);
  const [quote, setQuote] = useState<Quote | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [isComplete, setIsComplete] = useState(false);
  const [stopFn, setStopFn] = useState<(() => void) | null>(null);

  const startLoan = useCallback(async () => {
    if (!sdk) {
      setError('SDK not initialized');
      return;
    }

    setIsLoading(true);
    setError(null);
    setIsComplete(false);
    setStatus(null);
    setDepositInfo(null);

    try {
      const result = await sdk.getLoan({
        collateralBTC: options.collateralBTC,
        loanAmountUSD: options.loanAmountUSD,
        ltv: options.ltv || 70,
        onStatusUpdate: setStatus,
        onDepositReady: setDepositInfo,
        onComplete: () => {
          setIsComplete(true);
          setIsLoading(false);
        },
        onError: (err) => {
          setError(err);
          setIsLoading(false);
        }
      });

      setQuote(result.quote);
      setStopFn(() => result.stop);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to start loan');
      setIsLoading(false);
    }
  }, [sdk, options]);

  const stopTracking = useCallback(() => {
    if (stopFn) {
      stopFn();
    }
  }, [stopFn]);

  return {
    isLoading,
    status,
    depositInfo,
    quote,
    error,
    isComplete,
    startLoan,
    stopTracking
  };
}

usePositions Hook

// src/hooks/usePositions.ts
import { useState, useEffect, useCallback } from 'react';
import { useBorrowContext } from '../contexts/BorrowContext';
import { WalletPosition } from '@satsterminal-sdk/borrow';

export function usePositions(refreshInterval = 30000) {
  const { sdk, isInitialized } = useBorrowContext();
  const [positions, setPositions] = useState<WalletPosition[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const fetchPositions = useCallback(async () => {
    if (!sdk || !isInitialized) return;

    setIsLoading(true);
    try {
      const response = await sdk.getWalletPositions({
        filterTrash: 'only_non_trash'
      });
      setPositions(response.data);
      setError(null);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to fetch positions');
    } finally {
      setIsLoading(false);
    }
  }, [sdk, isInitialized]);

  useEffect(() => {
    fetchPositions();

    const interval = setInterval(fetchPositions, refreshInterval);
    return () => clearInterval(interval);
  }, [fetchPositions, refreshInterval]);

  return { positions, isLoading, error, refresh: fetchPositions };
}

useLoanHistory Hook

// src/hooks/useLoanHistory.ts
import { useState, useEffect, useCallback } from 'react';
import { useBorrowContext } from '../contexts/BorrowContext';
import { UserTransaction } from '@satsterminal-sdk/borrow';

export function useLoanHistory(status: 'active' | 'pending' | 'all' = 'all') {
  const { sdk, isInitialized } = useBorrowContext();
  const [loans, setLoans] = useState<UserTransaction[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);

  const fetchLoans = useCallback(async (pageNum: number) => {
    if (!sdk || !isInitialized) return;

    setIsLoading(true);
    try {
      const response = await sdk.getLoanHistory({
        page: pageNum,
        limit: 10,
        status
      });

      if (pageNum === 1) {
        setLoans(response.transactions);
      } else {
        setLoans(prev => [...prev, ...response.transactions]);
      }

      setHasMore(response.pagination.hasNext);
      setError(null);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to fetch loans');
    } finally {
      setIsLoading(false);
    }
  }, [sdk, isInitialized, status]);

  useEffect(() => {
    setPage(1);
    fetchLoans(1);
  }, [fetchLoans]);

  const loadMore = useCallback(() => {
    if (!isLoading && hasMore) {
      const nextPage = page + 1;
      setPage(nextPage);
      fetchLoans(nextPage);
    }
  }, [isLoading, hasMore, page, fetchLoans]);

  return { loans, isLoading, error, hasMore, loadMore };
}

Components

LoanForm Component

// src/components/LoanForm.tsx
import React, { useState } from 'react';
import { useLoan } from '../hooks/useLoan';

export function LoanForm() {
  const [collateralBTC, setCollateralBTC] = useState(0.1);
  const [loanAmountUSD, setLoanAmountUSD] = useState(5000);

  const {
    isLoading,
    status,
    depositInfo,
    quote,
    error,
    isComplete,
    startLoan
  } = useLoan({ collateralBTC, loanAmountUSD });

  return (
    <div className="loan-form">
      <h2>Get a Loan</h2>

      {!isLoading && !status && (
        <form onSubmit={(e) => { e.preventDefault(); startLoan(); }}>
          <div>
            <label>Collateral (BTC)</label>
            <input
              type="number"
              step="0.001"
              value={collateralBTC}
              onChange={(e) => setCollateralBTC(parseFloat(e.target.value))}
            />
          </div>

          <div>
            <label>Loan Amount (USD)</label>
            <input
              type="number"
              value={loanAmountUSD}
              onChange={(e) => setLoanAmountUSD(parseFloat(e.target.value))}
            />
          </div>

          <button type="submit">Get Loan</button>
        </form>
      )}

      {status && (
        <div className="status">
          <h3>Status: {status.label}</h3>
          <p>{status.description}</p>
          <progress value={status.step} max={8} />
        </div>
      )}

      {depositInfo && !isComplete && (
        <div className="deposit-info">
          <h3>Deposit Required</h3>
          <p>Amount: {depositInfo.amountBTC} BTC</p>
          <code>{depositInfo.address}</code>
        </div>
      )}

      {quote && (
        <div className="quote-info">
          <h4>Quote Details</h4>
          <p>Protocol: {quote.protocol}</p>
          <p>APY: {quote.borrowApy.variable}%</p>
        </div>
      )}

      {isComplete && (
        <div className="success">
          <h3>Loan Complete!</h3>
          <p>Your funds are now available in your smart account.</p>
        </div>
      )}

      {error && (
        <div className="error">
          <p>Error: {error}</p>
        </div>
      )}
    </div>
  );
}

PositionsList Component

// src/components/PositionsList.tsx
import React from 'react';
import { usePositions } from '../hooks/usePositions';

export function PositionsList() {
  const { positions, isLoading, error, refresh } = usePositions();

  if (isLoading && positions.length === 0) {
    return <div>Loading positions...</div>;
  }

  if (error) {
    return <div>Error: {error}</div>;
  }

  const totalValue = positions.reduce(
    (sum, p) => sum + (p.attributes.value || 0),
    0
  );

  return (
    <div className="positions">
      <div className="header">
        <h2>Wallet Positions</h2>
        <button onClick={refresh}>Refresh</button>
      </div>

      <div className="total">
        Total Value: ${totalValue.toFixed(2)}
      </div>

      <table>
        <thead>
          <tr>
            <th>Token</th>
            <th>Amount</th>
            <th>Value</th>
            <th>24h</th>
          </tr>
        </thead>
        <tbody>
          {positions.map((position) => (
            <tr key={position.id}>
              <td>{position.attributes.fungible_info?.symbol || 'Unknown'}</td>
              <td>{position.attributes.quantity.float.toFixed(4)}</td>
              <td>${(position.attributes.value || 0).toFixed(2)}</td>
              <td className={
                (position.attributes.changes?.percent_1d || 0) >= 0
                  ? 'positive'
                  : 'negative'
              }>
                {position.attributes.changes?.percent_1d?.toFixed(2) || 0}%
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

ConnectWallet Component

// src/components/ConnectWallet.tsx
import React from 'react';
import { useBorrowContext } from '../contexts/BorrowContext';

export function ConnectWallet() {
  const { isInitialized, isLoading, error, initialize, disconnect, userStatus } = useBorrowContext();

  const handleConnect = async () => {
    // Example with a Bitcoin wallet library
    // Replace with your actual wallet integration
    const wallet = await connectBitcoinWallet();

    await initialize(
      wallet.address,
      (message) => wallet.signMessage(message)
    );
  };

  if (isInitialized) {
    return (
      <div className="wallet-info">
        <p>Connected: {userStatus?.btcAddress?.slice(0, 10)}...</p>
        <p>Smart Account: {userStatus?.smartAccountAddress?.slice(0, 10)}...</p>
        <button onClick={disconnect}>Disconnect</button>
      </div>
    );
  }

  return (
    <div className="connect-wallet">
      <button onClick={handleConnect} disabled={isLoading}>
        {isLoading ? 'Connecting...' : 'Connect Wallet'}
      </button>
      {error && <p className="error">{error}</p>}
    </div>
  );
}

Full Example App

// src/App.tsx
import React from 'react';
import { BorrowProvider } from './contexts/BorrowContext';
import { ConnectWallet } from './components/ConnectWallet';
import { LoanForm } from './components/LoanForm';
import { PositionsList } from './components/PositionsList';
import { useBorrowContext } from './contexts/BorrowContext';
import { ChainType } from '@satsterminal-sdk/borrow';

function Dashboard() {
  const { isInitialized } = useBorrowContext();

  if (!isInitialized) {
    return (
      <div className="app">
        <h1>SatsTerminal Borrow</h1>
        <ConnectWallet />
      </div>
    );
  }

  return (
    <div className="app">
      <header>
        <h1>SatsTerminal Borrow</h1>
        <ConnectWallet />
      </header>

      <main>
        <section>
          <LoanForm />
        </section>

        <section>
          <PositionsList />
        </section>
      </main>
    </div>
  );
}

export default function App() {
  return (
    <BorrowProvider
      apiKey={process.env.REACT_APP_SATSTERMINAL_API_KEY!}
      baseUrl="https://api.satsterminal.com"
      chain={ChainType.ARBITRUM}
    >
      <Dashboard />
    </BorrowProvider>
  );
}

Best Practices

1. Error Boundaries

class BorrowErrorBoundary extends React.Component<
  { children: ReactNode },
  { hasError: boolean; error: Error | null }
> {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-boundary">
          <h2>Something went wrong</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => this.setState({ hasError: false })}>
            Try Again
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

2. Loading States

function LoadingSpinner() {
  return <div className="spinner">Loading...</div>;
}

function withLoading<P extends object>(
  Component: React.ComponentType<P>,
  useLoadingHook: () => boolean
) {
  return function WithLoadingComponent(props: P) {
    const isLoading = useLoadingHook();

    if (isLoading) {
      return <LoadingSpinner />;
    }

    return <Component {...props} />;
  };
}

3. Optimistic Updates

function useLoanWithOptimisticUpdate() {
  const [optimisticLoans, setOptimisticLoans] = useState<UserTransaction[]>([]);

  const startLoan = async (options: LoanOptions) => {
    // Add optimistic loan
    const optimisticLoan: UserTransaction = {
      id: 'temp-' + Date.now(),
      type: 'borrow',
      status: 'pending',
      amount: options.loanAmountUSD.toString(),
      currency: 'USD',
      timestamp: Date.now()
    };

    setOptimisticLoans(prev => [optimisticLoan, ...prev]);

    try {
      await sdk.getLoan(options);
      // Refresh real data
      await refreshLoans();
    } finally {
      // Remove optimistic loan
      setOptimisticLoans(prev =>
        prev.filter(l => l.id !== optimisticLoan.id)
      );
    }
  };

  return { optimisticLoans, startLoan };
}