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 operationsUserAPIRouter- User withdrawal request creation and viewingAdminService- Business logic for withdrawal processing via Crypto2BCrypto2BWebhookHandler- Status updates from Crypto2BCanonicalTransactionRequestRepository- Data persistence
Table of Contents¶
User Endpoints¶
Admin Endpoints¶
- Get Withdrawals List
- Get Pending Withdrawals
- Sync Withdrawal Statuses
- Get Withdrawal Details
- Approve Withdrawal
- Reject Withdrawal
- Update Withdrawal Status
- Process Withdrawal
- 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)¶
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¶
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¶
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¶
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¶
- Ethereum Address Validation: All withdrawal addresses validated against Ethereum format
- Admin Authorization: Multi-stage approval prevents unauthorized withdrawals
- Blockchain Verification: All completed withdrawals verified on-chain
- Manual Execution Control: Processing stage requires admin blockchain execution
- 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)
);
Related Endpoints¶
- Transactions API - Transaction history and management
- Auth API - JWT token generation and validation
- User API - User account management
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)