# Deframe SDK

React widgets for DeFi earn and swap flows

This package ships two host-rendered widgets:
- `EarnWidget`
- `SwapWidget`

The host application owns wallet connection, signing, and transaction submission through `processBytecode`.

## Installation

```bash
# pnpm
pnpm add deframe-sdk
# npm
npm install deframe-sdk
# yarn
yarn add deframe-sdk
```

Notes:
- Use only the production npm package: `deframe-sdk` ([npm](https://www.npmjs.com/package/deframe-sdk)).
- No local/workspace/file dependency references are required for host integration.
- Keep `react` and `react-dom` installed in the host app.
- `deframe-sdk` loads widget styles from its entrypoint. No separate CSS import is required for the default setup.

## Vite: Single React Instance (important for linked/local packages)

If your app consumes SDK packages via `file:`, workspace links, or symlinks, multiple React copies can be resolved at runtime.
That can cause `Invalid hook call` errors (for example: `Cannot read properties of null (reading 'useEffect')`).

Use this Vite config to force one React instance across app + linked packages:

```ts
import path from 'node:path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  resolve: {
    dedupe: ['react', 'react-dom'],
    alias: {
      react: path.resolve(__dirname, 'node_modules/react'),
      'react-dom': path.resolve(__dirname, 'node_modules/react-dom'),
      'react/jsx-runtime': path.resolve(__dirname, 'node_modules/react/jsx-runtime.js'),
      'react/jsx-dev-runtime': path.resolve(__dirname, 'node_modules/react/jsx-dev-runtime.js'),
    },
  },
  optimizeDeps: {
    // Avoid manual .vite cache cleanup in linked-package setups.
    force: true,
  },
})
```

With this config in place, you should not need manual `rm -rf node_modules/.vite` or manual restart instructions in your integration flow.

## CSS Isolation (Host Shell vs Widget)

The SDK widgets are designed to be embedded into existing apps without taking over host styling.

Use this isolation model:

- Keep host/shell styles scoped to your own container (example: `.host-shell ...`).
- Treat `.deframe-widget` as a style boundary owned by the SDK.
- Avoid global resets that can leak into third-party widgets:
  - `* { ... }`
  - `div, button, input { ... }`
  - global `all: initial` / aggressive normalize rules on app-wide containers.
- Use `DeframeProvider` `config.theme` as the source of truth for widget theme (`mode`, `preset`, `overrides`).
- Do not rely on ad-hoc utility-class overrides inside widget internals except as temporary debug workarounds.

Recommended host layout:

```tsx
<main className="host-shell">
  {/* host UI */}
  <section className="widget-slot">
    <EarnWidget autoHeight />
  </section>
</main>
```

```css
.host-shell {
  /* your app styles */
}

.widget-slot {
  /* spacing, border, layout only */
}
```

Reference widget-only variable block (generic dark/default preset, client-agnostic):

```css
/* Scope to widget only. Do not apply globally. */
.host-shell .deframe-widget {
  --deframe-brand-primary: #405eff;
  --deframe-brand-secondary: #a9abf7;
  --deframe-bg-default: #121212;
  --deframe-bg-subtle: #1e1e1e;
  --deframe-bg-muted: #2c2c2c;
  --deframe-bg-raised: #232323;
  --deframe-bg-inverse: #ffffff;
  --deframe-text-primary: #ffffff;
  --deframe-text-secondary: #e3e4e8;
  --deframe-text-disabled: #898d95;
  --deframe-text-inverse: #252050;
  --deframe-state-success: #2ba176;
  --deframe-state-error: #ff4d4f;
  --deframe-state-warning: #f6a700;

  /* Important fallbacks to avoid light cards in dark mode */
  --color-bg-raised: var(--deframe-bg-raised, #232323);
  --color-brand-tint: #1e1e3f;
}
```

Generic preset recommendation:

```ts
theme: {
  mode: 'dark',
  preset: 'default',
}
```

Use `preset: 'cryptocontrol'` only when you intentionally want that brand style.

If you need hard guarantees in high-risk host environments, use one of:

- Dedicated iframe container for the widget.
- Shadow DOM boundary around the widget mount point.

## Environment Variables

```bash
NEXT_PUBLIC_DEFRAME_API_URL=https://api.deframe.com
NEXT_PUBLIC_DEFRAME_API_KEY=your_deframe_api_key
```

Canonical env forms for host integrations:
- public client:
  - `NEXT_PUBLIC_DEFRAME_API_URL`
  - `NEXT_PUBLIC_DEFRAME_API_KEY`
- server/internal:
  - `DEFRAME_API_URL`
  - `DEFRAME_API_KEY`
- if API URL is not set in some internal hosts, default to `https://api.deframe.com` (where applicable).

## Core Integration

### 1) Wrap your widget tree with `DeframeProvider`

```tsx
'use client'

import { ReactNode, useMemo } from 'react'
import { DeframeProvider, type UpdateTxStatus } from 'deframe-sdk'

type BytecodeTransaction = {
  chainId?: number
  to: string
  data: string
  value: string
  gasLimit?: string
}

type ProcessBytecode = (
  payload: { clientTxId: string; bytecodes: BytecodeTransaction[]; simulateError?: boolean },
  ctx: { updateTxStatus: UpdateTxStatus }
) => void | Promise<void>

export function DeframeHostProvider({
  children,
  walletAddress,
  userId,
  globalCurrency,
  globalCurrencyExchangeRate,
  processBytecode,
}: {
  children: ReactNode
  walletAddress: string
  userId?: string
  globalCurrency?: 'USD' | 'BRL'
  globalCurrencyExchangeRate?: number
  processBytecode: ProcessBytecode
}) {
const config = useMemo(() => ({
    DEFRAME_API_URL: process.env.NEXT_PUBLIC_DEFRAME_API_URL,
    DEFRAME_API_KEY: process.env.NEXT_PUBLIC_DEFRAME_API_KEY,
    walletAddress,
    userId,
    globalCurrency,
    globalCurrencyExchangeRate,
    strategiesLimit: 50,
    debug: process.env.NODE_ENV !== 'production',
    theme: {
      mode: 'dark' as const,
      preset: 'cryptocontrol' as const,
    },
  }), [walletAddress, userId, globalCurrency, globalCurrencyExchangeRate])

  return (
    <DeframeProvider config={config} processBytecode={processBytecode}>
      {children}
    </DeframeProvider>
  )
}
```

### 2) Render `EarnWidget`

```tsx
'use client'

import { useState } from 'react'
import { EarnWidget } from 'deframe-sdk'

export function EarnPage() {
  const [routeName, setRouteName] = useState('overview')
  const isOverview = routeName === 'overview'

  return (
    <div className={`w-full mx-auto ${isOverview ? 'lg:p-12' : 'lg:p-6'}`}>
      <div className={`w-full mx-auto ${isOverview ? 'max-w-[1400px]' : 'max-w-[620px]'}`}>
        <EarnWidget autoHeight onRouteChange={setRouteName} />
      </div>
    </div>
  )
}
```

### 3) Render `SwapWidget` with optional prefilled params

```tsx
'use client'

import { useMemo } from 'react'
import { useSearchParams } from 'next/navigation'
import { SwapWidget } from 'deframe-sdk'

export function SwapPage() {
  const searchParams = useSearchParams()

  const toTokenAddress = searchParams.get('toTokenAddress') ?? undefined
  const toChainId = searchParams.get('toChainId') ? Number(searchParams.get('toChainId')) : undefined
  const fromTokenAddress = searchParams.get('fromTokenAddress') ?? undefined
  const fromChainId = searchParams.get('fromChainId') ? Number(searchParams.get('fromChainId')) : undefined

  const widgetProps = useMemo(() => {
    const props: {
      height: string
      toTokenAddress?: string
      toChainId?: number
      fromTokenAddress?: string
      fromChainId?: number
    } = {
      height: '800px',
    }

    if (toTokenAddress) props.toTokenAddress = toTokenAddress
    if (toChainId) props.toChainId = toChainId
    if (fromTokenAddress) props.fromTokenAddress = fromTokenAddress
    if (fromChainId) props.fromChainId = fromChainId

    return props
  }, [toTokenAddress, toChainId, fromTokenAddress, fromChainId])

  const widgetKey = `${toTokenAddress}-${toChainId}-${fromTokenAddress}-${fromChainId}`

  return <SwapWidget key={widgetKey} {...widgetProps} />
}
```

## Widget-to-Host Events
This README keeps the working route pattern loop-safe for existing Privy-based implementations:

Use `EarnWidget` `onRouteChange` prop to keep lightweight host routing state:
- `history` -> `/dashboard/history`
- `wallet` -> `/dashboard`
- `swap` -> `/dashboard/swap`
- `earn` -> `/dashboard/earn`

`useDeframe().addEventListener` route handlers are intentionally omitted here because they caused host loops in some integrations.

## `processBytecode` contract

The host must execute `bytecodes` and report lifecycle updates with `ctx.updateTxStatus(...)`.

Payload shape is:

```ts
type TxIntent = {
  clientTxId: string
  bytecodes: Array<{ chainId?: number; to: string; data: string; value: string; gasLimit?: string }>
  simulateError?: boolean
}
```

### Required success path statuses
- `HOST_ACK`
- `SIGNATURE_PROMPTED`
- `TX_SUBMITTED`
- `TX_CONFIRMED`
- `TX_FINALIZED`
  - include `txHash` when available on `TX_SUBMITTED` and `TX_CONFIRMED` if you have it

### Required error statuses
- `SIGNATURE_DECLINED`
- `SIGNATURE_ERROR`
- `TX_REVERTED`
- `TX_FAILED` (supported)

### Optional advanced statuses
- `TX_REPLACED`
- `TX_DROPPED`
- `SWAP_CROSSCHAIN_DESTINATION_CONFIRMED`

When `SIGNATURE_PROMPTED` is emitted, you typically call the wallet signer next.
Map wallet refusal codes/messages to:
- `SIGNATURE_DECLINED` for user cancellation (`4001`, `ACTION_REJECTED`).
- `SIGNATURE_ERROR` for other thrown errors before submission.

### SDK default fallback (no `processBytecode`)

- If you run without a `processBytecode` prop, Deframe falls back to:
  - `window.parent.postMessage({ type: 'REQUEST_SIGNATURE', clientTxId, bytecodes })`
- Keep this as a compatibility path only if your host is already wired to that message bridge.

### Minimal host implementation

```ts
export const processBytecode = async (
  payload: {
    clientTxId: string
    bytecodes: Array<{ chainId?: number; to: string; data: string; value: string; gasLimit?: string }>
  },
  ctx: {
    updateTxStatus: (event: any) => void
  }
) => {
  const { clientTxId, bytecodes } = payload

  try {
    ctx.updateTxStatus({ type: 'HOST_ACK', clientTxId })

    for (const tx of bytecodes) {
      ctx.updateTxStatus({ type: 'SIGNATURE_PROMPTED', clientTxId })

      // 1) sign + send transaction in your wallet stack
      // 2) get tx hash
      const txHash = await sendTransactionWithYourWallet(tx)

      ctx.updateTxStatus({ type: 'TX_SUBMITTED', clientTxId, chainId: tx.chainId, txHash })

      // Wait until mined/confirmed
      await waitForConfirmation(txHash)
      ctx.updateTxStatus({ type: 'TX_CONFIRMED', clientTxId, txHash })
    }

    ctx.updateTxStatus({ type: 'TX_FINALIZED', clientTxId })
  } catch (error: any) {
    const code = error?.code
    const message = error?.message || 'Transaction failed'

    if (code === 4001 || code === 'ACTION_REJECTED' || String(message).includes('rejected')) {
      ctx.updateTxStatus({ type: 'SIGNATURE_DECLINED', clientTxId })
      return
    }

    ctx.updateTxStatus({
      type: 'SIGNATURE_ERROR',
      clientTxId,
      code: String(code || 'UNKNOWN_ERROR'),
      message,
    })
  }
}
```

## Dynamic Integration (React)

### Install

```bash
pnpm add @dynamic-labs/sdk-react-core @dynamic-labs/ethereum viem
# or npm/yarn equivalents
```

### Provider setup

```tsx
'use client'

import { DynamicContextProvider } from '@dynamic-labs/sdk-react-core'
import { EthereumWalletConnectors } from '@dynamic-labs/ethereum'

export function DynamicProviders({ children }: { children: React.ReactNode }) {
  return (
    <DynamicContextProvider
      settings={{
        environmentId: process.env.NEXT_PUBLIC_DYNAMIC_ENV_ID!,
        walletConnectors: [EthereumWalletConnectors],
      }}
    >
      {children}
    </DynamicContextProvider>
  )
}
```

### `processBytecode` using Dynamic `primaryWallet`

```tsx
'use client'

import { useDynamicContext } from '@dynamic-labs/sdk-react-core'

type Bytecode = { chainId?: number; to: string; data: string; value: string }

export function useDynamicDeframeProcessBytecode() {
  const { primaryWallet } = useDynamicContext()

  return async (
    payload: { clientTxId: string; bytecodes: Bytecode[] },
    ctx: { updateTxStatus: (event: any) => void }
  ) => {
    const { clientTxId, bytecodes } = payload

    if (!primaryWallet) {
      ctx.updateTxStatus({
        type: 'SIGNATURE_ERROR',
        clientTxId,
        code: 'NO_WALLET',
        message: 'No connected Dynamic wallet',
      })
      return
    }

    try {
      ctx.updateTxStatus({ type: 'HOST_ACK', clientTxId })

      for (const tx of bytecodes) {
        if (tx.chainId && primaryWallet.connector.supportsNetworkSwitching?.()) {
          await primaryWallet.switchNetwork(tx.chainId)
        }

        ctx.updateTxStatus({ type: 'SIGNATURE_PROMPTED', clientTxId })

        const walletClient = await primaryWallet.connector.getWalletClient()
        const txHash = await walletClient.sendTransaction({
          account: primaryWallet.address as `0x${string}`,
          to: tx.to as `0x${string}`,
          data: tx.data as `0x${string}`,
          value: BigInt(tx.value || '0'),
        })

        ctx.updateTxStatus({
          type: 'TX_SUBMITTED',
          clientTxId,
          chainId: tx.chainId,
          txHash,
        })

        const publicClient = primaryWallet.connector.getPublicClient?.()
        if (publicClient) {
          await publicClient.waitForTransactionReceipt({ hash: txHash })
        }

        ctx.updateTxStatus({ type: 'TX_CONFIRMED', clientTxId, txHash })
      }

      ctx.updateTxStatus({ type: 'TX_FINALIZED', clientTxId })
    } catch (error: any) {
      if (error?.code === 4001 || error?.code === 'ACTION_REJECTED') {
        ctx.updateTxStatus({ type: 'SIGNATURE_DECLINED', clientTxId })
        return
      }

      ctx.updateTxStatus({
        type: 'SIGNATURE_ERROR',
        clientTxId,
        code: String(error?.code || 'UNKNOWN_ERROR'),
        message: String(error?.message || 'Dynamic transaction failed'),
      })
    }
  }
}
```

## Privy Integration (React)

### Install

```bash
pnpm add @privy-io/react-auth viem
# or npm/yarn equivalents
```

### Provider setup

```tsx
'use client'

import { PrivyProvider } from '@privy-io/react-auth'

export function PrivyProviders({ children }: { children: React.ReactNode }) {
  return (
    <PrivyProvider
      appId={process.env.NEXT_PUBLIC_PRIVY_APP_ID!}
      clientId={process.env.NEXT_PUBLIC_PRIVY_CLIENT_ID!}
      config={{
        embeddedWallets: {
          createOnLogin: 'users-without-wallets',
        },
      }}
    >
      {children}
    </PrivyProvider>
  )
}
```

### `processBytecode` using Privy connected wallets

```tsx
'use client'

import { useWallets } from '@privy-io/react-auth'

type Bytecode = { chainId?: number; to: string; data: string; value: string }

async function waitForReceipt(
  provider: { request: (args: { method: string; params?: any[] }) => Promise<any> },
  txHash: string
) {
  for (;;) {
    const receipt = await provider.request({
      method: 'eth_getTransactionReceipt',
      params: [txHash],
    })

    if (receipt) return receipt
    await new Promise((resolve) => setTimeout(resolve, 1500))
  }
}

export function usePrivyDeframeProcessBytecode() {
  const { wallets, ready } = useWallets()

  return async (
    payload: { clientTxId: string; bytecodes: Bytecode[] },
    ctx: { updateTxStatus: (event: any) => void }
  ) => {
    const { clientTxId, bytecodes } = payload

    if (!ready || wallets.length === 0) {
      ctx.updateTxStatus({
        type: 'SIGNATURE_ERROR',
        clientTxId,
        code: 'NO_WALLET',
        message: 'No Privy wallet is connected',
      })
      return
    }

    const wallet = wallets[0]

    try {
      ctx.updateTxStatus({ type: 'HOST_ACK', clientTxId })

      for (const tx of bytecodes) {
        if (tx.chainId) {
          await wallet.switchChain(tx.chainId)
        }

        const provider = await wallet.getEthereumProvider()

        ctx.updateTxStatus({ type: 'SIGNATURE_PROMPTED', clientTxId })

        const txHash = await provider.request({
          method: 'eth_sendTransaction',
          params: [
            {
              from: wallet.address,
              to: tx.to,
              data: tx.data,
              value: `0x${BigInt(tx.value || '0').toString(16)}`,
            },
          ],
        })

        ctx.updateTxStatus({
          type: 'TX_SUBMITTED',
          clientTxId,
          chainId: tx.chainId,
          txHash,
        })

        await waitForReceipt(provider, txHash)
        ctx.updateTxStatus({ type: 'TX_CONFIRMED', clientTxId, txHash })
      }

      ctx.updateTxStatus({ type: 'TX_FINALIZED', clientTxId })
    } catch (error: any) {
      if (error?.code === 4001 || error?.code === 'ACTION_REJECTED') {
        ctx.updateTxStatus({ type: 'SIGNATURE_DECLINED', clientTxId })
        return
      }

      ctx.updateTxStatus({
        type: 'SIGNATURE_ERROR',
        clientTxId,
        code: String(error?.code || 'UNKNOWN_ERROR'),
        message: String(error?.message || 'Privy transaction failed'),
      })
    }
  }
}
```

## API Reference

### `EarnWidget` props

```ts
type EarnWidgetProps = {
  className?: string
  style?: React.CSSProperties
  height?: string | number
  enableScroll?: boolean
  autoHeight?: boolean
  onRouteChange?: (routeName: string) => void
}
```

### `SwapWidget` props

```ts
type SwapWidgetProps = {
  className?: string
  style?: React.CSSProperties
  height?: string | number
  enableScroll?: boolean
  autoHeight?: boolean
  fromTokenAddress?: string
  fromChainId?: number
  toTokenAddress?: string
  toChainId?: number
}
```

### `DeframeConfig`

```ts
type DeframeConfig = {
  DEFRAME_API_URL?: string
  DEFRAME_API_KEY?: string
  walletAddress?: string
  userId?: string
  strategiesLimit?: number
  globalCurrency?: 'USD' | 'BRL'
  globalCurrencyExchangeRate?: number
  theme?: {
    mode?: 'light' | 'dark' | 'auto'
    preset?: 'default' | 'cryptocontrol'
    overrides?: {
      light?: { colors?: Record<string, string> }
      dark?: { colors?: Record<string, string> }
    }
  }
  debug?: boolean
  components?: ComponentOverridesMap
  behavior?: {
    swap?: {
      initialChainStrategy?: 'fixed' | 'wallet-balance'
      openProcessingHistoryInline?: boolean
      showHistoryTooltip?: boolean
      directionButtonSpacing?: 'default' | 'compact'
    }
    actionSheets?: {
      closeOnBackdropClick?: boolean
      focusSearchOnOpen?: boolean
    }
  }
}
```

### Behavior Configuration

`config.behavior` lets host wallets opt into wallet-specific UI behavior without changing the SDK defaults for every consumer. Omit this config to keep the shared behavior.

```tsx
const config = {
  ...baseConfig,
  behavior: {
    swap: {
      initialChainStrategy: 'wallet-balance',
      openProcessingHistoryInline: true,
      showHistoryTooltip: true,
      directionButtonSpacing: 'compact',
    },
    actionSheets: {
      closeOnBackdropClick: true,
      focusSearchOnOpen: true,
    },
  },
}
```

- `swap.initialChainStrategy`: defaults to `fixed`; use `wallet-balance` to start swap on the visible chain with the highest wallet balance.
- `swap.openProcessingHistoryInline`: defaults to `false`; set `true` to keep processing-screen history navigation inside the swap flow.
- `swap.showHistoryTooltip`: defaults to `false`; set `true` to expose the swap history icon label/tooltip.
- `swap.directionButtonSpacing`: defaults to `default`; use `compact` to reduce the vertical overlap around the simple swap direction button.
- `actionSheets.closeOnBackdropClick`: defaults to `false`; set `true` to close token/network selectors when the backdrop is clicked.
- `actionSheets.focusSearchOnOpen`: defaults to `false`; set `true` to focus selector search inputs when they open.

## Component Overrides

Hosts can replace specific SDK view components via `config.components`, without forking. For example:

```tsx
import { ChooseAnAssetSwapViewCardGrid } from 'deframe-sdk'

const config = {
  ...baseConfig,
  components: {
    ChooseAnAssetSwapView: ChooseAnAssetSwapViewCardGrid
  }
}
```

If no override is set, the default component is used.

## Troubleshooting

- Widget renders with no data:
  - Verify `DEFRAME_API_URL`, `DEFRAME_API_KEY`, and `walletAddress`.
- Tx flow stuck in processing:
  - Ensure host emits `TX_SUBMITTED`, then `TX_CONFIRMED`, then `TX_FINALIZED`.
- Signature rejected path not handled:
  - Map wallet rejection errors (`4001` / `ACTION_REJECTED`) to `SIGNATURE_DECLINED`.
- Cross-route actions should be handled through `onRouteChange`.
- Widget appears with unexpected light cards/surfaces in dark mode:
  - Inspect `.deframe-widget` computed variables and verify:
    - `--color-bg-default`
    - `--color-bg-raised`
    - `--color-text-primary`
  - If `--color-bg-raised` resolves to a light value while using dark theme, set widget-scoped fallbacks:
    - `--color-bg-raised: var(--deframe-bg-raised, #232323)`
    - `--color-brand-tint: #1e1e3f`
  - Keep these overrides scoped to `.deframe-widget`, never global.

## Machine Companion

For AI/editor ingestion, use [llms-full.txt](./llms-full.txt).
