Перейти к содержанию

Withdrawals API Endpoints (Integration-Only)

Version: 3.0.0 (Integration-Only) Updated: 2025-11-14 Architecture: Crypto2B API + Fordefi Manual Processing

Overview

The Withdrawals API provides comprehensive functionality for managing user withdrawal requests through Integration-Only architecture. The system implements operator-managed workflow through external providers (Crypto2B + Fordefi), ensuring security and compliance without direct blockchain operations.

Key Features (Integration-Only)

  • User-Initiated Requests: Users create withdrawal requests specifying amount and destination address
  • Operator Approval Workflow: Multi-stage process (pending → approved → processing → completed)
  • Crypto2B Integration: All withdrawals processed through Crypto2B API
  • Manual Execution by Operators: Operators process withdrawals manually via Fordefi + Crypto2B
  • Webhook Status Updates: Automated status synchronization via Crypto2B webhooks
  • Security Validation: Address validation, amount verification, fraud detection

Architecture (Integration-Only)

Withdrawal Lifecycle:

User Request → Pending → Operator Approval → Processing → Crypto2B Execution → Completed
              Operator Reject → Failed

Components:

  • AdminWithdrawalManagementRouter - Operator withdrawal operations
  • UserAPIRouter - User withdrawal request creation and viewing
  • AdminService - Business logic for withdrawal processing via Crypto2B
  • Crypto2BWebhookHandler - Status updates from Crypto2B
  • CanonicalTransactionRequestRepository - Data persistence

Table of Contents

User Endpoints

  1. Create Withdrawal Request
  2. Get User Withdrawals

Admin Endpoints

  1. Get Withdrawals List
  2. Get Pending Withdrawals
  3. Sync Withdrawal Statuses
  4. Get Withdrawal Details
  5. Approve Withdrawal
  6. Reject Withdrawal
  7. Update Withdrawal Status
  8. Process Withdrawal
  9. Complete Withdrawal

Use Cases


User Endpoints

1. Create Withdrawal Request: `POST /api/user/withdrawals`

Creates a new withdrawal request for the authenticated user. The request enters `pending` status and requires admin approval before processing.

Authentication

  • Required: Yes (JWT Token)
  • Type: User Token
  • Header: `Authorization: Bearer `

Request Body

{
  "amount": "100.00",
  "currency": "USDC",
  "toAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"
}

Fields:

  • `amount` (string, required): Withdrawal amount as decimal string
  • `currency` (string, required): Currency code (e.g., "USDC")
  • `toAddress` (string, required): Ethereum destination address (validated)

Response: Success (201 Created)

{
  "success": true,
  "data": {
    "id": "req_abc123",
    "status": "pending",
    "amount": "100.00",
    "currency": "USDC"
  },
  "message": "Withdrawal request created successfully",
  "trace_id": "trace_xyz789"
}

Response: Validation Error (400 Bad Request)

{
  "success": false,
  "error": {
    "message": "Invalid withdrawal address",
    "code": "VALIDATION_ERROR",
    "details": "toAddress validation failed"
  },
  "trace_id": "trace_xyz789"
}

cURL Example

curl -X POST https://api.saga.surf/api/user/withdrawals \\
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \\
  -H "Content-Type: application/json" \\
  -d '{
    "amount": "100.00",
    "currency": "USDC",
    "toAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"
  }'

TypeScript/React Example

import { useState } from 'react';

interface CreateWithdrawalRequest {
  amount: string;
  currency: string;
  toAddress: string;
}

interface WithdrawalCreatedResponse {
  id: string;
  status: string;
  amount: string;
  currency: string;
}

const createWithdrawal = async (
  request: CreateWithdrawalRequest
): Promise<WithdrawalCreatedResponse> => {
  const token = localStorage.getItem('saga_access_token');

  const response = await fetch('/api/user/withdrawals', {
    method: 'POST',
    headers: {
      'Authorization': \`Bearer \${token}\`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(request)
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error?.message || 'Failed to create withdrawal');
  }

  const { data } = await response.json();
  return data;
};

// React Component
const WithdrawalForm: React.FC = () => {
  const [amount, setAmount] = useState('');
  const [toAddress, setToAddress] = useState('');
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);

    try {
      const result = await createWithdrawal({
        amount,
        currency: 'USDC',
        toAddress
      });

      alert(\`Withdrawal request created! ID: \${result.id}\`);
    } catch (error) {
      console.error('Withdrawal creation failed:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Amount"
        value={amount}
        onChange={(e) => setAmount(e.target.value)}
        required
      />
      <input
        type="text"
        placeholder="Destination Address (0x...)"
        value={toAddress}
        onChange={(e) => setToAddress(e.target.value)}
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Creating...' : 'Create Withdrawal'}
      </button>
    </form>
  );
};

Error Codes

Code Description
`VALIDATION_ERROR` Invalid request data (missing fields, invalid address)
`WITHDRAWAL_ERROR` Failed to create withdrawal in database
`UNAUTHORIZED` Missing or invalid JWT token

2. Get User Withdrawals: `GET /api/user/withdrawals`

Retrieves all withdrawal requests for the authenticated user with pagination support.

Authentication

  • Required: Yes (JWT Token)
  • Type: User Token
  • Header: `Authorization: Bearer `

Query Parameters

Parameter Type Required Default Description
`page` integer No 1 Page number (1-indexed)
`limit` integer No 100 Results per page (max 100)

Response: Success (200 OK)

{
  "success": true,
  "data": {
    "requests": [
      {
        "id": "req_abc123",
        "status": "pending",
        "amount": "100.00",
        "currency": "USDC",
        "toAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
        "createdAt": "2025-10-06T10:30:00Z",
        "txHash": ""
      },
      {
        "id": "req_xyz789",
        "status": "completed",
        "amount": "50.00",
        "currency": "USDC",
        "toAddress": "0x123...",
        "createdAt": "2025-10-05T14:20:00Z",
        "txHash": "0xabc123def456..."
      }
    ]
  },
  "message": "Withdrawal requests retrieved successfully",
  "trace_id": "trace_abc123"
}

cURL Example

curl -X GET "https://api.saga.surf/api/user/withdrawals?page=1&limit=10" \\
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

TypeScript/React Example

import { useEffect, useState } from 'react';

interface WithdrawalRequest {
  id: string;
  status: string;
  amount: string;
  currency: string;
  toAddress: string;
  createdAt: string;
  txHash?: string;
}

const getUserWithdrawals = async (
  page: number = 1,
  limit: number = 10
): Promise<WithdrawalRequest[]> => {
  const token = localStorage.getItem('saga_access_token');

  const response = await fetch(
    \`/api/user/withdrawals?page=\${page}&limit=\${limit}\`,
    {
      headers: {
        'Authorization': \`Bearer \${token}\`
      }
    }
  );

  const { data } = await response.json();
  return data.requests;
};

// React Component
const WithdrawalHistory: React.FC = () => {
  const [withdrawals, setWithdrawals] = useState<WithdrawalRequest[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchWithdrawals = async () => {
      try {
        const data = await getUserWithdrawals();
        setWithdrawals(data);
      } catch (error) {
        console.error('Failed to fetch withdrawals:', error);
      } finally {
        setLoading(false);
      }
    };

    fetchWithdrawals();
  }, []);

  if (loading) return <div>Loading...</div>;

  return (
    <div className="withdrawal-history">
      <h2>My Withdrawals</h2>
      {withdrawals.length === 0 ? (
        <p>No withdrawals yet</p>
      ) : (
        <table>
          <thead>
            <tr>
              <th>Amount</th>
              <th>Status</th>
              <th>Address</th>
              <th>Date</th>
              <th>TX Hash</th>
            </tr>
          </thead>
          <tbody>
            {withdrawals.map((w) => (
              <tr key={w.id}>
                <td>{w.amount} {w.currency}</td>
                <td>
                  <span className={\`status-\${w.status}\`}>
                    {w.status}
                  </span>
                </td>
                <td title={w.toAddress}>
                  {w.toAddress.substring(0, 10)}...
                </td>
                <td>{new Date(w.createdAt).toLocaleDateString()}</td>
                <td>
                  {w.txHash ? (
                    <a
                      href={\`https://etherscan.io/tx/\${w.txHash}\`}
                      target="_blank"
                      rel="noopener noreferrer"
                    >
                      View TX
                    </a>
                  ) : (
                    'N/A'
                  )}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </div>
  );
};

Admin Endpoints

3. Get Withdrawals List: `GET /api/admin/withdrawals`

Retrieves all withdrawal requests with filtering and pagination. Admin-only endpoint for withdrawal management overview.

Authentication

  • Required: Yes (Admin JWT Token)
  • Type: Admin Token
  • Header: `Authorization: Bearer `

Query Parameters

Parameter Type Required Default Description
`page` integer No 1 Page number (1-indexed)
`limit` integer No 50 Results per page (max 100)
`status` string No - Filter by status: pending, approved, processing, completed, failed
`user_id` string No - Filter by specific user ID

Response: Success (200 OK)

{
  "success": true,
  "data": {
    "withdrawals": [
      {
        "id": "req_abc123",
        "userId": "user_123",
        "userEmail": "user@example.com",
        "status": "pending",
        "amount": "100.00",
        "currency": "USDC",
        "toAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
        "createdAt": "2025-10-06T10:30:00Z",
        "txHash": null,
        "approvedBy": null,
        "approvedAt": null
      }
    ],
    "total": 15
  },
  "message": "Список заявок на вывод",
  "trace_id": "trace_xyz789"
}

Response Notes:

  • Empty withdrawals returns `[]` array (never `null`)
  • `total` indicates total count matching filters
  • `userEmail` included for admin convenience

cURL Example

# Get all pending withdrawals
curl -X GET "https://api.saga.surf/api/admin/withdrawals?status=pending&page=1&limit=20" \\
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

# Get withdrawals for specific user
curl -X GET "https://api.saga.surf/api/admin/withdrawals?user_id=user_123" \\
  -H "Authorization: Bearer <admin_token>"

TypeScript/React Example

interface AdminWithdrawal {
  id: string;
  userId: string;
  userEmail: string;
  status: string;
  amount: string;
  currency: string;
  toAddress: string;
  createdAt: string;
  txHash: string | null;
  approvedBy: string | null;
  approvedAt: string | null;
}

interface GetWithdrawalsParams {
  page?: number;
  limit?: number;
  status?: string;
  userId?: string;
}

const getAdminWithdrawals = async (
  params: GetWithdrawalsParams = {}
): Promise<{ withdrawals: AdminWithdrawal[]; total: number }> => {
  const adminToken = localStorage.getItem('admin_auth_token');

  const queryParams = new URLSearchParams();
  if (params.page) queryParams.set('page', params.page.toString());
  if (params.limit) queryParams.set('limit', params.limit.toString());
  if (params.status) queryParams.set('status', params.status);
  if (params.userId) queryParams.set('user_id', params.userId);

  const response = await fetch(
    \`/api/admin/withdrawals?\${queryParams}\`,
    {
      headers: {
        'Authorization': \`Bearer \${adminToken}\`
      }
    }
  );

  const { data } = await response.json();
  return data;
};

4. Get Pending Withdrawals: `GET /api/admin/withdrawals/pending`

Retrieves all withdrawals in `pending` status awaiting admin approval. Convenience endpoint for admin workflow.

Authentication

  • Required: Yes (Admin JWT Token)
  • Type: Admin Token

Query Parameters

Parameter Type Required Default Description
`page` integer No 1 Page number
`limit` integer No 50 Results per page (max 100)

Response: Success (200 OK)

{
  "success": true,
  "data": {
    "withdrawals": [
      {
        "id": "req_abc123",
        "userId": "user_123",
        "userEmail": "user@example.com",
        "status": "pending",
        "amount": "100.00",
        "currency": "USDC",
        "toAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
        "createdAt": "2025-10-06T10:30:00Z"
      }
    ],
    "total": 3
  },
  "message": "Список ожидающих выводов"
}

cURL Example

curl -X GET "https://api.saga.surf/api/admin/withdrawals/pending?page=1&limit=10" \\
  -H "Authorization: Bearer <admin_token>"

(Continuing with remaining 7 admin endpoints...)

5. Sync Withdrawal Statuses: `POST /api/admin/withdrawals/sync-statuses`

Synchronizes statuses of all `processing` withdrawals with Crypto2B API. Verifies external transactions and updates statuses automatically via webhooks.

Authentication

  • Required: Yes (Admin JWT Token)
  • Type: Admin Token

Request Body

Empty body (no parameters required)

Response: Success (200 OK)

{
  "success": true,
  "data": {
    "syncedCount": 5,
    "failedCount": 1,
    "details": [
      {
        "withdrawalId": "req_abc123",
        "status": "completed",
        "txHash": "0xabc123...",
        "synced": true
      },
      {
        "withdrawalId": "req_xyz789",
        "status": "processing",
        "error": "Transaction not confirmed yet",
        "synced": false
      }
    ]
  },
  "message": "Статусы withdrawal синхронизированы с блокчейном"
}

cURL Example

curl -X POST https://api.saga.surf/api/admin/withdrawals/sync-statuses \\
  -H "Authorization: Bearer <admin_token>" \\
  -H "Content-Type: application/json"

6. Get Withdrawal Details: `GET /api/admin/withdrawals/:id`

Retrieves detailed information about a specific withdrawal request.

Authentication

  • Required: Yes (Admin JWT Token)
  • Type: Admin Token

Path Parameters

Parameter Type Required Description
`id` string Yes Withdrawal request ID

Response: Success (200 OK)

{
  "success": true,
  "data": {
    "id": "req_abc123",
    "userId": "user_123",
    "userEmail": "user@example.com",
    "status": "processing",
    "amount": "100.00",
    "currency": "USDC",
    "toAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
    "createdAt": "2025-10-06T10:30:00Z",
    "approvedBy": "admin_456",
    "approvedAt": "2025-10-06T11:00:00Z",
    "txHash": "0xabc123def456...",
    "notes": "Approved by admin"
  },
  "message": "Детали заявки на вывод"
}

Response: Not Found (404)

{
  "success": false,
  "error": {
    "message": "Заявка на вывод не найдена",
    "code": "NOT_FOUND"
  }
}

cURL Example

curl -X GET https://api.saga.surf/api/admin/withdrawals/req_abc123 \\
  -H "Authorization: Bearer <admin_token>"

7. Approve Withdrawal: `POST /api/admin/withdrawals/:id/approve`

Approves a pending withdrawal request, moving it to `approved` status. Next step is processing.

Authentication

  • Required: Yes (Admin JWT Token)
  • Type: Admin Token

Path Parameters

Parameter Type Required Description
`id` string Yes Withdrawal request ID

Request Body

Empty body (admin ID extracted from JWT)

Response: Success (200 OK)

{
  "success": true,
  "data": {
    "id": "req_abc123",
    "status": "approved",
    "approvedBy": "admin_456",
    "approvedAt": "2025-10-06T11:00:00Z",
    "notes": "Одобрено администратором"
  },
  "message": "Заявка на вывод одобрена"
}

cURL Example

curl -X POST https://api.saga.surf/api/admin/withdrawals/req_abc123/approve \\
  -H "Authorization: Bearer <admin_token>" \\
  -H "Content-Type: application/json"

8. Reject Withdrawal: `POST /api/admin/withdrawals/:id/reject`

Rejects a withdrawal request, moving it to `failed` status with reason.

Authentication

  • Required: Yes (Admin JWT Token)
  • Type: Admin Token

Path Parameters

Parameter Type Required Description
`id` string Yes Withdrawal request ID

Request Body

{
  "reason": "Insufficient verification"
}

Fields:

  • `reason` (string, optional): Rejection reason (default: "rejected_by_admin")

Response: Success (200 OK)

{
  "success": true,
  "data": {
    "id": "req_abc123",
    "status": "failed",
    "rejectedBy": "admin_456",
    "rejectedAt": "2025-10-06T11:05:00Z",
    "reason": "Insufficient verification"
  },
  "message": "Заявка на вывод отклонена"
}

cURL Example

curl -X POST https://api.saga.surf/api/admin/withdrawals/req_abc123/reject \\
  -H "Authorization: Bearer <admin_token>" \\
  -H "Content-Type: application/json" \\
  -d '{"reason": "Insufficient verification"}'

9. Update Withdrawal Status: `PUT /api/admin/withdrawals/:id/status`

Updates withdrawal status directly (admin override). Supports all status transitions with optional transaction hash.

Authentication

  • Required: Yes (Admin JWT Token)
  • Type: Admin Token

Path Parameters

Parameter Type Required Description
`id` string Yes Withdrawal request ID

Request Body

{
  "status": "completed",
  "txHash": "0xabc123def456..."
}

Fields:

  • `status` (string, required): New status (pending, processing, completed, failed)
  • `txHash` (string, optional): Blockchain transaction hash

Response: Success (200 OK)

{
  "success": true,
  "data": {
    "id": "req_abc123",
    "status": "completed",
    "txHash": "0xabc123def456...",
    "updatedBy": "admin_456",
    "updatedAt": "2025-10-06T11:10:00Z"
  },
  "message": "Статус заявки на вывод обновлен"
}

cURL Example

curl -X PUT https://api.saga.surf/api/admin/withdrawals/req_abc123/status \\
  -H "Authorization: Bearer <admin_token>" \\
  -H "Content-Type: application/json" \\
  -d '{
    "status": "completed",
    "txHash": "0xabc123def456..."
  }'

10. Process Withdrawal: `POST /api/admin/withdrawals/:id/process`

Moves withdrawal to `processing` status for manual processing via Crypto2B + Fordefi. Admin must execute the external transaction manually and then complete the withdrawal.

Authentication

  • Required: Yes (Admin JWT Token)
  • Type: Admin Token

Path Parameters

Parameter Type Required Description
`id` string Yes Withdrawal request ID

Request Body

{
  "action": "start_processing",
  "finalAmount": "100.00",
  "adminNote": "Processing withdrawal manually"
}

Fields (all optional):

  • `action` (string): Processing action descriptor
  • `finalAmount` (string): Final withdrawal amount (if different from requested)
  • `adminNote` (string): Admin processing note

Response: Success (200 OK)

{
  "success": true,
  "data": {
    "success": true,
    "status": "processing",
    "message": "Withdrawal moved to processing status. Manual execution required.",
    "requiresManualExecution": true,
    "manualInstructions": "Please execute this withdrawal manually using Crypto2B API, then use the Complete Withdrawal endpoint with the transaction hash.",
    "withdrawalId": "req_abc123",
    "nextSteps": "Complete withdrawal using Crypto2B API and transaction hash"
  },
  "message": "Заявка переведена в статус processing"
}

cURL Example

curl -X POST https://api.saga.surf/api/admin/withdrawals/req_abc123/process \\
  -H "Authorization: Bearer <admin_token>" \\
  -H "Content-Type: application/json"

11. Complete Withdrawal: `POST /api/admin/withdrawals/:id/complete`

Completes a withdrawal after manual blockchain execution. Requires transaction hash for verification.

Authentication

  • Required: Yes (Admin JWT Token)
  • Type: Admin Token

Path Parameters

Parameter Type Required Description
`id` string Yes Withdrawal request ID

Request Body

{
  "txHash": "0xabc123def456789...",
  "notes": "Executed via Crypto2B API"
}

Fields:

  • `txHash` (string, required): Blockchain transaction hash
  • `notes` (string, optional): Completion notes

Response: Success (200 OK)

{
  "success": true,
  "data": {
    "id": "req_abc123",
    "status": "completed",
    "txHash": "0xabc123def456789...",
    "completedBy": "admin_456",
    "completedAt": "2025-10-06T11:15:00Z",
    "notes": "Завершено администратором с подтверждением blockchain транзакции"
  },
  "message": "Вывод средств завершен с подтверждением блокчейна"
}

cURL Example

curl -X POST https://api.saga.surf/api/admin/withdrawals/req_abc123/complete \\
  -H "Authorization: Bearer <admin_token>" \\
  -H "Content-Type: application/json" \\
  -d '{
    "txHash": "0xabc123def456789...",
    "notes": "Executed successfully via Crypto2B API"
  }'

Use Cases

Use Case 1: Complete Withdrawal Management System

Full-featured withdrawal management interface with user request creation and admin approval workflow.

import { useState, useEffect } from 'react';

// ============= Types =============

interface WithdrawalRequest {
  id: string;
  userId: string;
  userEmail: string;
  status: 'pending' | 'approved' | 'processing' | 'completed' | 'failed';
  amount: string;
  currency: string;
  toAddress: string;
  createdAt: string;
  txHash?: string;
  approvedBy?: string;
  approvedAt?: string;
}

// ============= API Functions =============

const withdrawalAPI = {
  // User functions
  createWithdrawalRequest: async (
    amount: string,
    toAddress: string,
    currency: string = 'USDC'
  ) => {
    const token = localStorage.getItem('saga_access_token');

    const response = await fetch('/api/user/withdrawals', {
      method: 'POST',
      headers: {
        'Authorization': \`Bearer \${token}\`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ amount, currency, toAddress })
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.error?.message || 'Failed to create withdrawal');
    }

    const { data } = await response.json();
    return data;
  },

  getUserWithdrawals: async () => {
    const token = localStorage.getItem('saga_access_token');

    const response = await fetch('/api/user/withdrawals', {
      headers: { 'Authorization': \`Bearer \${token}\` }
    });

    const { data } = await response.json();
    return data.requests;
  },

  // Admin functions
  getAdminWithdrawals: async (status?: string) => {
    const adminToken = localStorage.getItem('admin_auth_token');
    const params = status ? \`?status=\${status}\` : '';

    const response = await fetch(\`/api/admin/withdrawals\${params}\`, {
      headers: { 'Authorization': \`Bearer \${adminToken}\` }
    });

    const { data } = await response.json();
    return data.withdrawals;
  },

  approveWithdrawal: async (withdrawalId: string) => {
    const adminToken = localStorage.getItem('admin_auth_token');

    const response = await fetch(
      \`/api/admin/withdrawals/\${withdrawalId}/approve\`,
      {
        method: 'POST',
        headers: {
          'Authorization': \`Bearer \${adminToken}\`,
          'Content-Type': 'application/json'
        }
      }
    );

    if (!response.ok) throw new Error('Approval failed');
    const { data } = await response.json();
    return data;
  },

  rejectWithdrawal: async (withdrawalId: string, reason: string) => {
    const adminToken = localStorage.getItem('admin_auth_token');

    const response = await fetch(
      \`/api/admin/withdrawals/\${withdrawalId}/reject\`,
      {
        method: 'POST',
        headers: {
          'Authorization': \`Bearer \${adminToken}\`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ reason })
      }
    );

    if (!response.ok) throw new Error('Rejection failed');
    const { data } = await response.json();
    return data;
  },

  processWithdrawal: async (withdrawalId: string) => {
    const adminToken = localStorage.getItem('admin_auth_token');

    const response = await fetch(
      \`/api/admin/withdrawals/\${withdrawalId}/process\`,
      {
        method: 'POST',
        headers: {
          'Authorization': \`Bearer \${adminToken}\`,
          'Content-Type': 'application/json'
        }
      }
    );

    const { data } = await response.json();
    return data;
  },

  completeWithdrawal: async (
    withdrawalId: string,
    txHash: string
  ) => {
    const adminToken = localStorage.getItem('admin_auth_token');

    const response = await fetch(
      \`/api/admin/withdrawals/\${withdrawalId}/complete\`,
      {
        method: 'POST',
        headers: {
          'Authorization': \`Bearer \${adminToken}\`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ txHash })
      }
    );

    if (!response.ok) throw new Error('Completion failed');
    const { data } = await response.json();
    return data;
  }
};

// ============= User Component =============

const UserWithdrawalPanel: React.FC = () => {
  const [withdrawals, setWithdrawals] = useState<WithdrawalRequest[]>([]);
  const [amount, setAmount] = useState('');
  const [toAddress, setToAddress] = useState('');
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    loadWithdrawals();
  }, []);

  const loadWithdrawals = async () => {
    try {
      const data = await withdrawalAPI.getUserWithdrawals();
      setWithdrawals(data);
    } catch (error) {
      console.error('Failed to load withdrawals:', error);
    }
  };

  const handleCreateWithdrawal = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);

    try {
      await withdrawalAPI.createWithdrawalRequest(amount, toAddress);
      alert('✅ Withdrawal request created!');
      setAmount('');
      setToAddress('');
      loadWithdrawals();
    } catch (error: any) {
      alert(\`❌ Error: \${error.message}\`);
    } finally {
      setLoading(false);
    }
  };

  const getStatusColor = (status: string) => {
    const colors = {
      pending: '#FFA500',
      approved: '#4169E1',
      processing: '#9370DB',
      completed: '#32CD32',
      failed: '#DC143C'
    };
    return colors[status] || '#808080';
  };

  return (
    <div className="user-withdrawal-panel">
      <h2>💰 Withdraw Funds</h2>

      <form onSubmit={handleCreateWithdrawal} className="withdrawal-form">
        <input
          type="text"
          placeholder="Amount (USDC)"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
          required
        />
        <input
          type="text"
          placeholder="Destination Address (0x...)"
          value={toAddress}
          onChange={(e) => setToAddress(e.target.value)}
          required
        />
        <button type="submit" disabled={loading}>
          {loading ? 'Creating...' : 'Request Withdrawal'}
        </button>
      </form>

      <h3>📋 My Withdrawal Requests</h3>
      {withdrawals.length === 0 ? (
        <p>No withdrawal requests yet</p>
      ) : (
        <div className="withdrawals-list">
          {withdrawals.map((w) => (
            <div key={w.id} className="withdrawal-card">
              <div className="withdrawal-header">
                <span className="amount">{w.amount} {w.currency}</span>
                <span
                  className="status"
                  style={{ color: getStatusColor(w.status) }}
                >
                  {w.status.toUpperCase()}
                </span>
              </div>
              <div className="withdrawal-details">
                <p><strong>To:</strong> {w.toAddress}</p>
                <p><strong>Created:</strong> {new Date(w.createdAt).toLocaleString()}</p>
                {w.txHash && (
                  <p>
                    <strong>TX:</strong>{' '}
                    <a
                      href={\`https://etherscan.io/tx/\${w.txHash}\`}
                      target="_blank"
                      rel="noopener noreferrer"
                    >
                      View on Etherscan
                    </a>
                  </p>
                )}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
};

// ============= Admin Component =============

const AdminWithdrawalPanel: React.FC = () => {
  const [withdrawals, setWithdrawals] = useState<WithdrawalRequest[]>([]);
  const [filter, setFilter] = useState<string>('pending');
  const [loading, setLoading] = useState(false);
  const [processingId, setProcessingId] = useState<string | null>(null);

  useEffect(() => {
    loadWithdrawals();
  }, [filter]);

  const loadWithdrawals = async () => {
    setLoading(true);
    try {
      const data = await withdrawalAPI.getAdminWithdrawals(
        filter === 'all' ? undefined : filter
      );
      setWithdrawals(data);
    } catch (error) {
      console.error('Failed to load withdrawals:', error);
    } finally {
      setLoading(false);
    }
  };

  const handleApprove = async (withdrawalId: string) => {
    try {
      await withdrawalAPI.approveWithdrawal(withdrawalId);
      alert('✅ Withdrawal approved!');
      loadWithdrawals();
    } catch (error: any) {
      alert(\`❌ Error: \${error.message}\`);
    }
  };

  const handleReject = async (withdrawalId: string) => {
    const reason = prompt('Enter rejection reason:');
    if (!reason) return;

    try {
      await withdrawalAPI.rejectWithdrawal(withdrawalId, reason);
      alert('✅ Withdrawal rejected');
      loadWithdrawals();
    } catch (error: any) {
      alert(\`❌ Error: \${error.message}\`);
    }
  };

  const handleProcess = async (withdrawalId: string) => {
    try {
      const result = await withdrawalAPI.processWithdrawal(withdrawalId);

      if (result.requiresManualExecution) {
        setProcessingId(withdrawalId);
        alert(
          \`⚠️ Manual Execution Required:\\n\\n\` +
          \`\${result.manualInstructions}\\n\\n\` +
          \`Next: \${result.nextSteps}\`
        );
      }

      loadWithdrawals();
    } catch (error: any) {
      alert(\`❌ Error: \${error.message}\`);
    }
  };

  const handleComplete = async (withdrawalId: string) => {
    const txHash = prompt(
      'Enter the external transaction hash from Crypto2B API:'
    );
    if (!txHash) return;

    try {
      await withdrawalAPI.completeWithdrawal(withdrawalId, txHash);
      alert('✅ Withdrawal completed successfully!');
      setProcessingId(null);
      loadWithdrawals();
    } catch (error: any) {
      alert(\`❌ Error: \${error.message}\`);
    }
  };

  return (
    <div className="admin-withdrawal-panel">
      <h2>🔐 Admin: Withdrawal Management</h2>

      <div className="filter-controls">
        <button
          onClick={() => setFilter('pending')}
          className={filter === 'pending' ? 'active' : ''}
        >
          Pending
        </button>
        <button
          onClick={() => setFilter('approved')}
          className={filter === 'approved' ? 'active' : ''}
        >
          Approved
        </button>
        <button
          onClick={() => setFilter('processing')}
          className={filter === 'processing' ? 'active' : ''}
        >
          Processing
        </button>
        <button
          onClick={() => setFilter('completed')}
          className={filter === 'completed' ? 'active' : ''}
        >
          Completed
        </button>
        <button
          onClick={() => setFilter('all')}
          className={filter === 'all' ? 'active' : ''}
        >
          All
        </button>
      </div>

      {loading ? (
        <div>Loading...</div>
      ) : withdrawals.length === 0 ? (
        <p>No withdrawals in this category</p>
      ) : (
        <table className="withdrawals-table">
          <thead>
            <tr>
              <th>User</th>
              <th>Amount</th>
              <th>Address</th>
              <th>Status</th>
              <th>Created</th>
              <th>Actions</th>
            </tr>
          </thead>
          <tbody>
            {withdrawals.map((w) => (
              <tr key={w.id}>
                <td>{w.userEmail}</td>
                <td>{w.amount} {w.currency}</td>
                <td title={w.toAddress}>
                  {w.toAddress.substring(0, 10)}...
                </td>
                <td>
                  <span className={\`status-badge status-\${w.status}\`}>
                    {w.status}
                  </span>
                </td>
                <td>{new Date(w.createdAt).toLocaleDateString()}</td>
                <td>
                  {w.status === 'pending' && (
                    <>
                      <button onClick={() => handleApprove(w.id)}>
                        ✅ Approve
                      </button>
                      <button onClick={() => handleReject(w.id)}>
                        ❌ Reject
                      </button>
                    </>
                  )}
                  {w.status === 'approved' && (
                    <button onClick={() => handleProcess(w.id)}>
                      ⚡ Process
                    </button>
                  )}
                  {w.status === 'processing' && (
                    <button onClick={() => handleComplete(w.id)}>
                      ✔️ Complete
                    </button>
                  )}
                  {w.status === 'completed' && w.txHash && (
                    <a
                      href={\`https://etherscan.io/tx/\${w.txHash}\`}
                      target="_blank"
                      rel="noopener noreferrer"
                    >
                      View TX
                    </a>
                  )}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}

      {processingId && (
        <div className="processing-alert">
          ⚠️ Withdrawal {processingId} is ready for manual execution.
          Execute the transaction via Crypto2B API, then click "Complete"
          and enter the transaction hash.
        </div>
      )}
    </div>
  );
};

export { UserWithdrawalPanel, AdminWithdrawalPanel };

CSS Styling:

.user-withdrawal-panel,
.admin-withdrawal-panel {
  padding: 20px;
  max-width: 1200px;
  margin: 0 auto;
}

.withdrawal-form {
  display: flex;
  flex-direction: column;
  gap: 15px;
  margin-bottom: 30px;
  max-width: 500px;
}

.withdrawal-form input {
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 14px;
}

.withdrawal-form button {
  padding: 12px;
  background-color: #4169E1;
  color: white;
  border: none;
  border-radius: 6px;
  font-weight: 600;
  cursor: pointer;
}

.withdrawal-form button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

.withdrawals-list {
  display: grid;
  gap: 15px;
}

.withdrawal-card {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 15px;
  background: white;
}

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

.withdrawal-header .amount {
  font-size: 18px;
  font-weight: 600;
}

.withdrawal-header .status {
  font-weight: 600;
  font-size: 14px;
}

.withdrawal-details p {
  margin: 5px 0;
  font-size: 14px;
  color: #666;
}

.filter-controls {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

.filter-controls button {
  padding: 10px 20px;
  border: 1px solid #ddd;
  background: white;
  border-radius: 6px;
  cursor: pointer;
}

.filter-controls button.active {
  background: #4169E1;
  color: white;
  border-color: #4169E1;
}

.withdrawals-table {
  width: 100%;
  border-collapse: collapse;
}

.withdrawals-table th,
.withdrawals-table td {
  padding: 12px;
  text-align: left;
  border-bottom: 1px solid #ddd;
}

.withdrawals-table th {
  background: #f5f5f5;
  font-weight: 600;
}

.status-badge {
  padding: 4px 12px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 600;
}

.status-pending {
  background: #FFF3CD;
  color: #856404;
}

.status-approved {
  background: #D1ECF1;
  color: #0C5460;
}

.status-processing {
  background: #E7D4F5;
  color: #6A1B9A;
}

.status-completed {
  background: #D4EDDA;
  color: #155724;
}

.status-failed {
  background: #F8D7DA;
  color: #721C24;
}

.withdrawals-table button {
  padding: 6px 12px;
  margin-right: 5px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 12px;
}

.processing-alert {
  margin-top: 20px;
  padding: 15px;
  background: #FFF3CD;
  border: 1px solid #FFC107;
  border-radius: 6px;
  font-size: 14px;
}

Technical Details

Authentication

All endpoints require valid JWT tokens: - User Endpoints: Standard user JWT token from wallet authentication - Admin Endpoints: Admin JWT token with elevated permissions

Security Features

  1. Ethereum Address Validation: All withdrawal addresses validated against Ethereum format
  2. Admin Authorization: Multi-stage approval prevents unauthorized withdrawals
  3. Blockchain Verification: All completed withdrawals verified on-chain
  4. Manual Execution Control: Processing stage requires admin blockchain execution
  5. Audit Trail: All status changes tracked with admin ID and timestamps

Error Handling

Common HTTP Status Codes:

  • `200 OK`: Successful operation
  • `201 Created`: Withdrawal request created
  • `400 Bad Request`: Validation error, invalid parameters
  • `401 Unauthorized`: Missing or invalid JWT token
  • `404 Not Found`: Withdrawal request not found
  • `409 Conflict`: Status conflict (e.g., already processed)
  • `500 Internal Server Error`: Server error

Error Response Format:

{
  "success": false,
  "error": {
    "message": "Human-readable error message",
    "code": "ERROR_CODE",
    "details": "Additional context"
  },
  "trace_id": "trace_abc123"
}

Withdrawal Status Flow

pending
approved (by admin)
processing (manual processing via Crypto2B + Fordefi)
completed (with blockchain tx hash)

Alternative: pending → rejected (failed status)

Status Descriptions:

  • pending: Initial state, awaiting admin approval
  • approved: Admin approved, ready for processing
  • processing: Manual execution in progress (admin using Crypto2B API)
  • completed: Blockchain transaction confirmed, withdrawal successful
  • failed: Rejected by admin or execution failed

Database Schema

Transaction Requests Table:

CREATE TABLE transaction_requests (
  id VARCHAR(255) PRIMARY KEY,
  user_id VARCHAR(255) NOT NULL,
  type VARCHAR(50) NOT NULL, -- 'withdrawal'
  amount NUMERIC(20, 8) NOT NULL,
  currency VARCHAR(10) NOT NULL,
  to_address VARCHAR(255),
  status VARCHAR(50) NOT NULL,
  tx_hash VARCHAR(255),
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id)
);


Changelog

Version 1.0.0 (2025-10-06)

  • Initial documentation release
  • 11 endpoints documented (2 user + 9 admin)
  • Complete withdrawal lifecycle coverage
  • Comprehensive React/TypeScript examples
  • Full use case implementation with admin and user panels


📋 Метаданные

Версия: 3.0.0 (Integration-Only)

Обновлено: 2025-11-14

Статус: Ready for Implementation

Архитектура: Crypto2B API + Fordefi Manual Processing, Email-First


"Integration-Only принципы: внешние API для withdrawal операций, operator-managed processing, webhook status updates"

— Saga Development Team (Phase 2 API Updates)