Skip to main content

Full Bridge Widget

This example combines chain selection, token selection, amount input, quote display, execution, and status tracking into a complete bridge widget.

Overview

The full integration includes:
  • Chain and token selection with balance display
  • Amount input with max button
  • Real-time quote fetching with debouncing
  • Execution with wallet integration
  • Transaction status tracking
  • Comprehensive error handling

Complete Widget Component

'use client';

import { useState, useCallback, useMemo } from 'react';
import {
  MinaProvider,
  useMina,
  useQuote,
  useTokenBalance,
  useTransactionStatus,
} from '@siphoyawe/mina-sdk/react';
import type {
  Chain,
  Token,
  Quote,
  ExecutionResult,
  StepStatusPayload,
  TransactionStatusPayload,
} from '@siphoyawe/mina-sdk/react';
import { useAccount, useWalletClient } from 'wagmi';

// Main widget component
export function BridgeWidget() {
  return (
    <MinaProvider config={{ integrator: 'my-bridge-app', autoDeposit: true }}>
      <BridgeWidgetInner />
    </MinaProvider>
  );
}

function BridgeWidgetInner() {
  const { mina, isReady } = useMina();
  const { address, isConnected } = useAccount();
  const { data: walletClient } = useWalletClient();

  // Form state
  const [sourceChain, setSourceChain] = useState<Chain | null>(null);
  const [sourceToken, setSourceToken] = useState<Token | null>(null);
  const [amount, setAmount] = useState('');
  const [slippage, setSlippage] = useState(0.5);

  // Execution state
  const [isExecuting, setIsExecuting] = useState(false);
  const [executionResult, setExecutionResult] = useState<ExecutionResult | null>(null);
  const [currentStep, setCurrentStep] = useState<StepStatusPayload | null>(null);
  const [txHash, setTxHash] = useState<string | null>(null);

  // Convert amount to smallest unit
  const amountInSmallestUnit = useMemo(() => {
    if (!amount || !sourceToken) return '';
    try {
      const parsed = parseFloat(amount);
      if (isNaN(parsed) || parsed <= 0) return '';
      return (BigInt(Math.floor(parsed * 10 ** sourceToken.decimals))).toString();
    } catch {
      return '';
    }
  }, [amount, sourceToken]);

  // Fetch quote with useQuote hook
  const { quote, isLoading: isQuoteLoading, error: quoteError, refetch: refetchQuote } = useQuote({
    fromChain: sourceChain?.id,
    toChain: 999, // HyperEVM
    fromToken: sourceToken?.address,
    toToken: '0xb88339cb7199b77e23db6e890353e22632ba630f', // USDC on HyperEVM
    amount: amountInSmallestUnit,
    fromAddress: address,
    slippageTolerance: slippage,
    enabled: Boolean(sourceChain && sourceToken && amountInSmallestUnit && address),
  });

  // Fetch source token balance
  const {
    formattedBalance,
    balance: rawBalance,
    isLoading: isBalanceLoading,
    refetch: refetchBalance,
  } = useTokenBalance({
    chainId: sourceChain?.id,
    tokenAddress: sourceToken?.address,
    walletAddress: address ?? undefined,
    refetchInterval: 15000,
  });

  // Track transaction status
  const { status: txStatus } = useTransactionStatus(txHash);

  // Create signer from wallet client
  const signer = useMemo(() => {
    if (!walletClient || !sourceChain) return null;

    return {
      sendTransaction: async (request: {
        to: string;
        data: string;
        value: string;
        chainId: number;
      }) => {
        const hash = await walletClient.sendTransaction({
          to: request.to as `0x${string}`,
          data: request.data as `0x${string}`,
          value: BigInt(request.value),
          chain: {
            id: request.chainId,
            name: sourceChain.name,
            nativeCurrency: {
              name: sourceChain.nativeToken.name,
              symbol: sourceChain.nativeToken.symbol,
              decimals: sourceChain.nativeToken.decimals,
            },
            rpcUrls: { default: { http: [] } },
          },
        });
        return hash;
      },
      getAddress: async () => walletClient.account.address,
      getChainId: async () => sourceChain.id,
    };
  }, [walletClient, sourceChain]);

  // Handle max button
  const handleMaxClick = useCallback(() => {
    if (formattedBalance && sourceToken) {
      setAmount(formattedBalance);
    }
  }, [formattedBalance, sourceToken]);

  // Handle execution
  const handleExecute = useCallback(async () => {
    if (!mina || !quote || !signer) return;

    setIsExecuting(true);
    setExecutionResult(null);
    setCurrentStep(null);

    try {
      const result = await mina.execute({
        quote,
        signer,
        onStepChange: (step: StepStatusPayload) => {
          setCurrentStep(step);
          if (step.txHash) {
            setTxHash(step.txHash);
          }
        },
        onStatusChange: (status: TransactionStatusPayload) => {
          console.log('Status update:', status);
        },
      });

      setExecutionResult(result);

      if (result.status === 'completed') {
        // Reset form on success
        setAmount('');
        refetchBalance();
      }
    } catch (error) {
      console.error('Execution failed:', error);
      setExecutionResult({
        executionId: 'error',
        status: 'failed',
        steps: [],
        error: error instanceof Error ? error : new Error('Unknown error'),
      });
    } finally {
      setIsExecuting(false);
    }
  }, [mina, quote, signer, refetchBalance]);

  // Reset when chain changes
  const handleChainChange = useCallback((chain: Chain) => {
    setSourceChain(chain);
    setSourceToken(null);
    setAmount('');
    setExecutionResult(null);
    setTxHash(null);
  }, []);

  // Validation
  const hasInsufficientBalance = useMemo(() => {
    if (!rawBalance || !amountInSmallestUnit) return false;
    return BigInt(amountInSmallestUnit) > BigInt(rawBalance);
  }, [rawBalance, amountInSmallestUnit]);

  const canExecute = useMemo(() => {
    return (
      isConnected &&
      quote &&
      !isQuoteLoading &&
      !hasInsufficientBalance &&
      !isExecuting &&
      signer
    );
  }, [isConnected, quote, isQuoteLoading, hasInsufficientBalance, isExecuting, signer]);

  if (!isReady) {
    return <div className="bridge-widget loading">Initializing SDK...</div>;
  }

  return (
    <div className="bridge-widget">
      <div className="widget-header">
        <h2>Bridge to Hyperliquid</h2>
        <SlippageSelector value={slippage} onChange={setSlippage} />
      </div>

      {/* Source Chain Selection */}
      <div className="form-section">
        <label>From Chain</label>
        <ChainSelector
          selectedChainId={sourceChain?.id ?? null}
          onSelect={handleChainChange}
        />
      </div>

      {/* Source Token Selection */}
      <div className="form-section">
        <label>Token</label>
        <TokenSelector
          chainId={sourceChain?.id ?? null}
          walletAddress={address ?? null}
          selectedToken={sourceToken}
          onSelect={setSourceToken}
        />
      </div>

      {/* Amount Input */}
      <div className="form-section">
        <div className="amount-header">
          <label>Amount</label>
          {formattedBalance && (
            <span className="balance">
              Balance: {formattedBalance} {sourceToken?.symbol}
              <button onClick={handleMaxClick} className="max-button">
                MAX
              </button>
            </span>
          )}
        </div>
        <input
          type="number"
          placeholder="0.00"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
          disabled={!sourceToken}
          className={hasInsufficientBalance ? 'error' : ''}
        />
        {hasInsufficientBalance && (
          <span className="error-text">Insufficient balance</span>
        )}
      </div>

      {/* Destination Display */}
      <div className="destination-section">
        <div className="destination-chain">
          <span className="label">To</span>
          <div className="chain-display">
            <span className="chain-name">HyperEVM</span>
            <span className="token-name">USDC</span>
          </div>
        </div>
      </div>

      {/* Quote Display */}
      {(isQuoteLoading || quote || quoteError) && (
        <QuoteSection
          quote={quote}
          isLoading={isQuoteLoading}
          error={quoteError}
          onRefresh={refetchQuote}
        />
      )}

      {/* Execution Status */}
      {(isExecuting || executionResult || currentStep) && (
        <ExecutionStatus
          isExecuting={isExecuting}
          currentStep={currentStep}
          result={executionResult}
          txStatus={txStatus}
        />
      )}

      {/* Execute Button */}
      <button
        className="execute-button"
        onClick={handleExecute}
        disabled={!canExecute}
      >
        {!isConnected
          ? 'Connect Wallet'
          : isExecuting
          ? 'Bridging...'
          : hasInsufficientBalance
          ? 'Insufficient Balance'
          : !quote
          ? 'Enter Amount'
          : 'Bridge'}
      </button>
    </div>
  );
}

Styling

BridgeWidget.css
.bridge-widget {
  max-width: 440px;
  margin: 0 auto;
  padding: 24px;
  background: #0d0d1a;
  border: 1px solid #2d2d44;
  border-radius: 20px;
  color: #ffffff;
}

.widget-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
}

.widget-header h2 {
  margin: 0;
  font-size: 20px;
}

.form-section {
  margin-bottom: 16px;
}

.form-section label {
  display: block;
  margin-bottom: 8px;
  font-size: 14px;
  color: #888;
}

.amount-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}

.amount-header .balance {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 12px;
  color: #888;
}

.max-button {
  padding: 2px 8px;
  background: #7dd3fc;
  border: none;
  border-radius: 4px;
  font-size: 10px;
  font-weight: 600;
  color: #0d0d1a;
  cursor: pointer;
}

input[type="number"] {
  width: 100%;
  padding: 16px;
  background: #1a1a2e;
  border: 1px solid #2d2d44;
  border-radius: 12px;
  font-size: 24px;
  color: #ffffff;
}

input[type="number"]:focus {
  outline: none;
  border-color: #7dd3fc;
}

input[type="number"].error {
  border-color: #ef4444;
}

.error-text {
  display: block;
  margin-top: 4px;
  font-size: 12px;
  color: #ef4444;
}

.selector {
  position: relative;
}

.selector-trigger {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  padding: 12px 16px;
  background: #1a1a2e;
  border: 1px solid #2d2d44;
  border-radius: 12px;
  color: #ffffff;
  cursor: pointer;
  text-align: left;
}

.selector-trigger .logo {
  width: 28px;
  height: 28px;
  border-radius: 50%;
}

.selector-dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  margin-top: 4px;
  background: #1a1a2e;
  border: 1px solid #2d2d44;
  border-radius: 12px;
  max-height: 300px;
  overflow-y: auto;
  z-index: 100;
}

.search-input {
  width: calc(100% - 24px);
  margin: 12px;
  padding: 10px 12px;
  background: #0d0d1a;
  border: 1px solid #2d2d44;
  border-radius: 8px;
  color: #ffffff;
}

.option {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  padding: 12px 16px;
  background: transparent;
  border: none;
  color: #ffffff;
  cursor: pointer;
  text-align: left;
}

.option:hover {
  background: #2d2d44;
}

.option.selected {
  background: rgba(125, 211, 252, 0.2);
}

.destination-section {
  padding: 16px;
  background: #1a1a2e;
  border-radius: 12px;
  margin-bottom: 16px;
}

.quote-section {
  padding: 16px;
  background: #1a1a2e;
  border-radius: 12px;
  margin-bottom: 16px;
}

.quote-section.loading {
  display: flex;
  align-items: center;
  gap: 12px;
}

.quote-row {
  display: flex;
  justify-content: space-between;
  padding: 8px 0;
  border-bottom: 1px solid #2d2d44;
}

.quote-row:last-child {
  border-bottom: none;
}

.quote-row .label {
  color: #888;
}

.warning {
  margin-top: 12px;
  padding: 12px;
  background: rgba(239, 68, 68, 0.1);
  border-radius: 8px;
  color: #ef4444;
  font-size: 12px;
}

.badge {
  display: inline-block;
  margin-top: 12px;
  padding: 6px 12px;
  border-radius: 20px;
  font-size: 12px;
}

.badge.success {
  background: rgba(34, 197, 94, 0.1);
  color: #22c55e;
}

.execution-status {
  display: flex;
  gap: 16px;
  padding: 16px;
  border-radius: 12px;
  margin-bottom: 16px;
}

.execution-status.success {
  background: rgba(34, 197, 94, 0.1);
}

.execution-status.error {
  background: rgba(239, 68, 68, 0.1);
}

.execution-status.pending {
  background: rgba(125, 211, 252, 0.1);
}

.execute-button {
  width: 100%;
  padding: 16px;
  background: #7dd3fc;
  border: none;
  border-radius: 12px;
  font-size: 16px;
  font-weight: 600;
  color: #0d0d1a;
  cursor: pointer;
  transition: opacity 0.2s;
}

.execute-button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.execute-button:not(:disabled):hover {
  opacity: 0.9;
}

.spinner {
  width: 20px;
  height: 20px;
  border: 2px solid #2d2d44;
  border-top-color: #7dd3fc;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.slippage-selector {
  display: flex;
  align-items: center;
  gap: 8px;
}

.slippage-selector .label {
  font-size: 12px;
  color: #888;
}

.slippage-selector .presets {
  display: flex;
  gap: 4px;
}

.slippage-selector .preset {
  padding: 4px 8px;
  background: #1a1a2e;
  border: 1px solid #2d2d44;
  border-radius: 6px;
  font-size: 12px;
  color: #888;
  cursor: pointer;
}

.slippage-selector .preset.active {
  background: #7dd3fc;
  border-color: #7dd3fc;
  color: #0d0d1a;
}

Integration with Wagmi

The widget uses wagmi for wallet connection. Wrap your app:
App.tsx
import { WagmiProvider, createConfig, http } from 'wagmi';
import { arbitrum, optimism, base, mainnet } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BridgeWidget } from './BridgeWidget';

const config = createConfig({
  chains: [mainnet, arbitrum, optimism, base],
  transports: {
    [mainnet.id]: http(),
    [arbitrum.id]: http(),
    [optimism.id]: http(),
    [base.id]: http(),
  },
});

const queryClient = new QueryClient();

export function App() {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <BridgeWidget />
      </QueryClientProvider>
    </WagmiProvider>
  );
}

Next Steps

Error Handling

Handle all error scenarios

Event Tracking

Real-time status updates