> ## Documentation Index
> Fetch the complete documentation index at: https://docs.keystoneos.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Custody Wallet Integration

> Connect deposit flows to Fireblocks, BitGo, or any custody provider.

This guide shows how to route on-chain deposits through your institutional custody provider using action delegates. This is the Tier 2 integration pattern - the most common setup for institutional platforms.

## Prerequisites

* `@keystoneos/react` and `@keystoneos/node` installed
* `KeystoneProvider` configured with `settlements:write` scope
* A backend that can submit transactions via your custody provider (Fireblocks, BitGo, etc.)

## Why action delegates exist

Institutional platforms do not let end-users sign transactions in the browser. Assets are held in custody wallets managed by Fireblocks, BitGo, or similar infrastructure. When a settlement requires a deposit to escrow, the transaction must route through the custody provider's API.

Action delegates bridge this gap. Instead of the widget interacting with the blockchain directly, it calls your backend, which submits the transaction through your signing infrastructure.

## The deposit flow

```mermaid theme={null}
sequenceDiagram
    participant U as Trader
    participant FE as Your Frontend
    participant KS as KeyStone API
    participant BE as Your Backend
    participant FB as Fireblocks / BitGo
    participant CH as Blockchain

    U->>FE: Clicks "Deposit"
    FE->>KS: GET /v1/settlements/{id}/legs/{idx}/deposit-calldata
    KS-->>FE: calldata, escrowAddress, chainId, tokenAddress, amount

    FE->>BE: POST /api/deposit (calldata, escrowAddress, chainId)
    BE->>FB: Create transaction (to: escrowAddress, data: calldata)
    FB->>CH: Submit signed transaction
    CH-->>FB: txHash
    FB-->>BE: txHash
    BE-->>FE: { txHash }

    FE->>FE: Show "Deposit pending..."
    CH-->>KS: Escrow event: leg deposited
    KS-->>FE: Settlement state update
    FE->>FE: Show "Deposit confirmed"
```

## Frontend setup

### Configuring the provider with action delegates

Pass `onDepositRequired` (and optionally `onApprovalRequired`) to the `KeystoneProvider`. These callbacks fire when the user triggers a deposit from the UI.

```tsx theme={null}
// app.tsx
import { useState, useEffect } from 'react';
import { KeystoneProvider } from '@keystoneos/react';

function App() {
  const [token, setToken] = useState<string | null>(null);

  useEffect(() => {
    fetch('/api/keystone/session', { method: 'POST' })
      .then(r => r.json())
      .then(d => setToken(d.token));
  }, []);

  if (!token) return <div>Loading...</div>;

  return (
    <KeystoneProvider
      sessionToken={token}
      environment="production"
      onTokenExpired={async () => {
        const res = await fetch('/api/keystone/session', { method: 'POST' });
        return (await res.json()).token;
      }}
      actionDelegates={{
        onDepositRequired: async (leg, depositInfo) => {
          const response = await fetch('/api/deposit', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              calldata: depositInfo.calldata,
              escrowAddress: depositInfo.escrowAddress,
              chainId: depositInfo.chainId,
              tokenAddress: depositInfo.tokenAddress,
              amount: depositInfo.depositAmount,
            }),
          });

          if (!response.ok) {
            const error = await response.json();
            throw new Error(error.message ?? 'Deposit submission failed');
          }

          const result = await response.json();
          return { txHash: result.txHash };
        },
        onApprovalRequired: async (leg, approvalInfo) => {
          const response = await fetch('/api/approve', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              calldata: approvalInfo.approvalCalldata,
              tokenAddress: approvalInfo.tokenAddress,
              spender: approvalInfo.escrowAddress,
              chainId: leg.chain_id,
            }),
          });

          if (!response.ok) {
            const error = await response.json();
            throw new Error(error.message ?? 'Approval submission failed');
          }

          const result = await response.json();
          return { txHash: result.txHash };
        },
      }}
    >
      <YourApp />
    </KeystoneProvider>
  );
}
```

### Building the deposit button component

The `useDeposit` hook manages the multi-step deposit flow. It returns the current status, deposit info for preview, and the `deposit()` function to trigger the flow.

```tsx theme={null}
// deposit-button.tsx
import { useDeposit } from '@keystoneos/react';

interface DepositButtonProps {
  settlementId: string;
  legIndex: number;
  instrumentId: string;
}

const STATUS_MESSAGES: Record<string, string> = {
  idle: '',
  fetching_info: 'Fetching deposit details...',
  checking_balance: 'Checking token balance...',
  approving: 'Submitting token approval...',
  approval_confirmed: 'Approval confirmed. Submitting deposit...',
  depositing: 'Submitting deposit transaction...',
  deposit_confirmed: 'Deposit confirmed on-chain.',
  error: 'Deposit failed.',
};

export function DepositButton({ settlementId, legIndex, instrumentId }: DepositButtonProps) {
  const { status, depositInfo, txHash, error, deposit } = useDeposit(settlementId, legIndex);

  const isProcessing = ['fetching_info', 'checking_balance', 'approving', 'approval_confirmed', 'depositing'].includes(status);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
      {/* Deposit info preview */}
      {depositInfo && status === 'idle' && (
        <div style={{ padding: '12px', backgroundColor: '#f9fafb', borderRadius: '6px', fontSize: '13px' }}>
          <p>Escrow: <code>{depositInfo.escrowAddress}</code></p>
          <p>Amount: {depositInfo.depositAmount} {instrumentId}</p>
          <p>Chain: {depositInfo.chainId}</p>
        </div>
      )}

      {/* Action button */}
      {status === 'idle' && (
        <button
          onClick={deposit}
          style={{
            padding: '10px 20px',
            backgroundColor: '#2DD4A8',
            color: '#fff',
            border: 'none',
            borderRadius: '6px',
            fontWeight: 600,
            cursor: 'pointer',
          }}
        >
          Deposit {instrumentId}
        </button>
      )}

      {/* Processing status */}
      {isProcessing && (
        <div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#1e40af' }}>
          <div style={{
            width: '16px', height: '16px', border: '2px solid #1e40af',
            borderTopColor: 'transparent', borderRadius: '50%',
            animation: 'spin 1s linear infinite',
          }} />
          <span style={{ fontSize: '14px' }}>{STATUS_MESSAGES[status]}</span>
        </div>
      )}

      {/* Success */}
      {status === 'deposit_confirmed' && (
        <div style={{ padding: '12px', backgroundColor: '#dcfce7', borderRadius: '6px', color: '#166534', fontSize: '14px' }}>
          <p style={{ fontWeight: 600 }}>{STATUS_MESSAGES[status]}</p>
          {txHash && (
            <p style={{ marginTop: '4px', fontFamily: 'monospace', fontSize: '12px' }}>
              TX: {txHash}
            </p>
          )}
        </div>
      )}

      {/* Error */}
      {status === 'error' && (
        <div style={{ padding: '12px', backgroundColor: '#fee2e2', borderRadius: '6px', color: '#991b1b', fontSize: '14px' }}>
          <p style={{ fontWeight: 600 }}>Deposit failed</p>
          <p style={{ marginTop: '4px' }}>{error}</p>
          <button
            onClick={deposit}
            style={{
              marginTop: '8px', padding: '6px 12px', backgroundColor: '#991b1b',
              color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '13px',
            }}
          >
            Retry
          </button>
        </div>
      )}
    </div>
  );
}
```

## Backend implementation

Your backend receives the pre-encoded calldata from the frontend and submits it to your custody provider. The calldata is already ABI-encoded by the KeyStone API - your backend just needs to forward it.

### Fireblocks

```typescript theme={null}
// routes/deposit.ts
import { FireblocksSDK, TransactionArguments, PeerType } from 'fireblocks-sdk';

const fireblocks = new FireblocksSDK(
  process.env.FIREBLOCKS_API_SECRET!,
  process.env.FIREBLOCKS_API_KEY!,
  process.env.FIREBLOCKS_API_URL,
);

// Map chain IDs to Fireblocks asset IDs
const CHAIN_TO_ASSET: Record<number, string> = {
  1: 'ETH',
  137: 'MATIC_POLYGON',
  43114: 'AVAX',
  11155111: 'ETH_TEST5',
};

app.post('/api/deposit', async (req, res) => {
  const { calldata, escrowAddress, chainId, tokenAddress, amount } = req.body;

  try {
    const txArgs: TransactionArguments = {
      assetId: CHAIN_TO_ASSET[chainId],
      source: {
        type: PeerType.VAULT_ACCOUNT,
        id: process.env.FIREBLOCKS_VAULT_ID!,
      },
      destination: {
        type: PeerType.ONE_TIME_ADDRESS,
        oneTimeAddress: { address: escrowAddress },
      },
      amount: '0', // Value is zero - the deposit amount is in the calldata
      extraParameters: {
        contractCallData: calldata,
      },
      note: `KeyStone escrow deposit: ${amount} to ${escrowAddress}`,
    };

    const tx = await fireblocks.createTransaction(txArgs);

    // Fireblocks returns immediately with a pending tx ID.
    // The actual txHash is available once mined. Poll or use webhook.
    // For simplicity, wait for the tx to complete:
    const completedTx = await waitForFireblocksTx(fireblocks, tx.id);

    res.json({ txHash: completedTx.txHash });
  } catch (err) {
    console.error('Fireblocks deposit failed:', err);
    res.status(500).json({ message: 'Failed to submit deposit transaction' });
  }
});

async function waitForFireblocksTx(fb: FireblocksSDK, txId: string, maxAttempts = 60): Promise<{ txHash: string }> {
  for (let i = 0; i < maxAttempts; i++) {
    const tx = await fb.getTransactionById(txId);
    if (tx.status === 'COMPLETED') {
      return { txHash: tx.txHash };
    }
    if (['FAILED', 'REJECTED', 'CANCELLED', 'BLOCKED'].includes(tx.status)) {
      throw new Error(`Fireblocks transaction ${tx.status}: ${tx.subStatus}`);
    }
    await new Promise(resolve => setTimeout(resolve, 2000));
  }
  throw new Error('Fireblocks transaction timed out');
}
```

### BitGo

```typescript theme={null}
// routes/deposit-bitgo.ts
import { BitGoAPI } from '@bitgo/sdk-api';

const bitgo = new BitGoAPI({
  accessToken: process.env.BITGO_ACCESS_TOKEN!,
  env: 'prod',
});

const CHAIN_TO_COIN: Record<number, string> = {
  1: 'eth',
  137: 'polygon',
  43114: 'avaxc',
  11155111: 'gteth',
};

app.post('/api/deposit', async (req, res) => {
  const { calldata, escrowAddress, chainId } = req.body;

  try {
    const coin = CHAIN_TO_COIN[chainId];
    const wallet = await bitgo.coin(coin).wallets().get({ id: process.env.BITGO_WALLET_ID! });

    const tx = await wallet.sendTransaction({
      recipients: [{
        address: escrowAddress,
        amount: '0',
        data: calldata,
      }],
      walletPassphrase: process.env.BITGO_WALLET_PASSPHRASE!,
    });

    res.json({ txHash: tx.txid });
  } catch (err) {
    console.error('BitGo deposit failed:', err);
    res.status(500).json({ message: 'Failed to submit deposit transaction' });
  }
});
```

### Generic signing infrastructure

If you use a different custody provider or your own HSM, the pattern is the same - forward the pre-encoded calldata:

```typescript theme={null}
app.post('/api/deposit', async (req, res) => {
  const { calldata, escrowAddress, chainId } = req.body;

  const txHash = await yourSigner.sendTransaction({
    to: escrowAddress,
    data: calldata,
    value: '0x0',
    chainId,
  });

  res.json({ txHash });
});
```

## Deposit status states

The `useDeposit` hook progresses through these states:

```
idle -> fetching_info -> approving -> approval_confirmed -> depositing -> deposit_confirmed
                \                          \                      \
                 -> error                   -> error                -> error
```

| Status               | What is happening                       | User sees                    |
| -------------------- | --------------------------------------- | ---------------------------- |
| `idle`               | Nothing in progress                     | Deposit button               |
| `fetching_info`      | Calling KeyStone API for calldata       | Loading spinner              |
| `approving`          | ERC-20 `approve()` submitted to custody | "Submitting approval..."     |
| `approval_confirmed` | Approval mined, proceeding to deposit   | Brief transition             |
| `depositing`         | `depositLeg()` submitted to custody     | "Submitting deposit..."      |
| `deposit_confirmed`  | Deposit mined and confirmed by escrow   | Success with txHash          |
| `error`              | Any step failed                         | Error message + retry button |

## Error handling

### Common failure scenarios

| Failure              | Cause                                               | What happens                                                    |
| -------------------- | --------------------------------------------------- | --------------------------------------------------------------- |
| Custody API rejects  | Insufficient balance, policy violation, rate limit  | `onDepositRequired` throws, `useDeposit` sets `status: "error"` |
| Gas estimation fails | Contract revert (wrong calldata, already deposited) | Custody provider returns error before signing                   |
| Transaction reverts  | On-chain revert (deadline passed, wrong token)      | txHash exists but transaction failed                            |
| Network timeout      | RPC or custody API unreachable                      | `onDepositRequired` throws with timeout error                   |

### Implementing robust error handling

```tsx theme={null}
actionDelegates={{
  onDepositRequired: async (leg, depositInfo) => {
    const response = await fetch('/api/deposit', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        calldata: depositInfo.calldata,
        escrowAddress: depositInfo.escrowAddress,
        chainId: depositInfo.chainId,
      }),
    });

    if (!response.ok) {
      const error = await response.json();

      // Map backend errors to user-friendly messages
      switch (error.code) {
        case 'INSUFFICIENT_BALANCE':
          throw new Error('Insufficient token balance in custody wallet');
        case 'POLICY_VIOLATION':
          throw new Error('Transaction blocked by custody policy. Contact your administrator.');
        case 'ALREADY_DEPOSITED':
          throw new Error('This leg has already been deposited');
        default:
          throw new Error(error.message ?? 'Deposit failed. Please try again.');
      }
    }

    return (await response.json()) as { txHash: string };
  },
}}
```

## Security considerations

* The backend deposit endpoint should authenticate the request (verify the session or user identity before submitting a transaction)
* Validate that the `escrowAddress` matches a known KeyStone escrow contract before signing
* Implement rate limiting on the deposit endpoint
* Log all deposit submissions with the settlement ID, leg index, and caller identity for audit

## Next steps

<CardGroup cols={2}>
  <Card title="Real-time Tracking" icon="signal" href="/elements/guides/realtime-tracking">
    Track deposit progress and settlement state in real time.
  </Card>

  <Card title="Action Delegates Reference" icon="code" href="/elements/action-delegates">
    Full reference for all action delegate callbacks.
  </Card>

  <Card title="useDeposit Reference" icon="code" href="/hooks/use-deposit">
    Full API reference for the useDeposit hook.
  </Card>

  <Card title="Escrow Deposits Guide" icon="vault" href="/guides/escrow-deposits">
    Server-side guide for escrow deposits using the SDK.
  </Card>
</CardGroup>
