Use when implementing cryptocurrency exchange withdrawal flows in TypeScript without React, including optional exchange auto-swap before withdrawal. Provides a state machine that orchestrates OAuth/QR-code wallet connection, balance loading, tradable asset discovery, trade order placement and polling, quote generation, withdrawal execution with 2FA/SMS/KYC challenges (including exchange-specific multi-step 2FA for Binance, KuCoin, Coinbase, Kraken), and real-time WebSocket updates. Choose thi...
Scanned 9/6/2026
Install to Claude Code
npx -y skills add bluvoinc/sdk --skill skill --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Skill?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/bluvoinc-skill-sdk)More formats (shields.io, HTML) on the badges page.
---
name: bluvo-sdk-ts
description: "Use when implementing cryptocurrency exchange withdrawal flows in TypeScript without React, including optional exchange auto-swap before withdrawal. Provides a state machine that orchestrates OAuth/QR-code wallet connection, balance loading, tradable asset discovery, trade order placement and polling, quote generation, withdrawal execution with 2FA/SMS/KYC challenges (including exchange-specific multi-step 2FA for Binance, KuCoin, Coinbase, Kraken), and real-time WebSocket updates. Choose this over the React skill when building server-side integrations, non-React frontends, or custom UI frameworks."
license: MIT
compatibility: "TypeScript 4.7+. Node.js 18+ for server-side use. Browser for client-side OAuth/WebSocket flows."
metadata:
version: "3.1.0"
---
# @bluvo/sdk-ts
## Mental Model
The Bluvo SDK uses a **state machine paradigm**: you send events and the machine transitions through states. You do not call REST APIs directly — the `BluvoFlowClient` orchestrates all API calls internally and emits state changes you subscribe to.
There are two client models: **`BluvoClient`** runs server-side (requires API key, never in browser) and provides direct REST access. **`BluvoWebClient`** runs in the browser (no API key, uses OAuth popups and WebSocket). **`BluvoFlowClient`** is the high-level orchestrator that wraps both — it accepts server-side callback functions and manages the complete withdrawal flow through a nested state machine.
The flow progresses through phases: **exchanges** → **oauth/qrcode** → **wallet** → optional **trade/convert** → **quote** → **withdraw**. Each phase has loading, ready, and error states. Invalid state transitions are silently ignored (return current state unchanged), not errors.
### State Diagram
```
idle ──→ exchanges:loading ──→ exchanges:ready ──→ oauth:waiting / qrcode:waiting
│ │
exchanges:error oauth:processing / qrcode:displaying
│
oauth:completed ←─ qrcode:scanning
│
wallet:loading
│
wallet:ready
│ │
LOAD_TRADABLE_ASSETS│ │REQUEST_QUOTE
▼ │
trade:assetsReady │
│ │
PLACE_TRADE_ORDER
│
trade:orderPlaced
│
POLL_TRADE_ORDER
│
trade:orderFilled
│
wallet:ready (refreshed)
│
quote:requesting
│
quote:ready ←─ (auto-refresh)
│
withdraw:processing
│ │ │
error2FA │ errorSMS errorKYC
error2FAMultiStep │ errorBalance
│
readyToConfirm │ retrying
│
withdraw:completed / fatal / blocked
CANCEL_FLOW → flow:cancelled (from ANY state)
```
## Initialization
### Server-side Client
```typescript
import { createClient, createSandboxClient, createDevClient } from '@bluvo/sdk-ts';
// Production
const client = createClient({ orgId: '...', projectId: '...', apiKey: '...' });
// Sandbox (test.api-bluvo.com)
const client = createSandboxClient({ orgId: '...', projectId: '...', apiKey: '...' });
// Local development (localhost:8787)
const client = createDevClient({ orgId: '...', projectId: '...', apiKey: '...' });
// Custom domain
const client = createClient({
orgId: '...', projectId: '...', apiKey: '...',
customDomain: 'api.mycompany.com' // or { api: 'api.co', ws: 'ws.co' }
});
```
### Browser Flow Client
```typescript
import { BluvoFlowClient } from '@bluvo/sdk-ts';
const flowClient = new BluvoFlowClient({
orgId: '...',
projectId: '...',
// All callbacks are server-side functions (e.g., Next.js server actions)
listExchangesFn: serverListExchanges,
fetchWithdrawableBalanceFn: serverFetchBalances,
requestQuotationFn: serverRequestQuote,
executeWithdrawalFn: serverExecuteWithdrawal,
getTradableAssetsFn: serverGetTradableAssets, // optional, required for auto-swap
placeOrderFn: serverPlaceOrder, // optional, required for auto-swap
getOrderFn: serverGetOrder, // optional, required for auto-swap polling
getWalletByIdFn: serverGetWallet,
pingWalletByIdFn: serverPingWallet,
// Optional
onWalletConnectedFn: (walletId, exchange) => { /* persist */ },
mkUUIDFn: () => crypto.randomUUID(),
options: {
sandbox: false,
dev: false,
maxRetryAttempts: 3,
autoRefreshQuotation: true, // default
customDomain: undefined,
},
cache: {
prefix: 'bluvo:',
minRemainingLifetimeSec: 15,
disabled: false,
},
});
```
## State Reference
### idle
Starting state. No flow active. Transitions to `exchanges:loading` or directly to `oauth:waiting`/`qrcode:waiting`.
### exchanges:loading / exchanges:ready / exchanges:error
Exchange list loading phase. `exchanges:ready` makes the `exchanges` array available in context.
### oauth:waiting / oauth:processing / oauth:completed / oauth:error / oauth:fatal / oauth:window_closed_by_user
OAuth popup authentication phase. `oauth:error` is recoverable, `oauth:fatal` is not (e.g., `OAUTH_TOKEN_EXCHANGE_FAILED`).
### qrcode:waiting / qrcode:displaying / qrcode:scanning / qrcode:error / qrcode:timeout / qrcode:fatal
QR code authentication (for `binance-web`). Displays QR code, tracks scan status, handles expiration.
### wallet:loading / wallet:ready / wallet:error
Wallet balance loading phase. `wallet:ready` makes `walletBalances` available in context.
### trade:assetsLoading / trade:assetsReady / trade:assetsError
Tradable asset discovery phase. `trade:assetsReady` makes `tradableAssets` available in context, including normalized assets and spot routes such as `kraken:spot:DOGE/USDC`.
### trade:orderPlacing / trade:orderPlaced / trade:orderPolling / trade:orderFilled / trade:orderError
Trade order phase. `placeTradeOrder()` places the order exactly once and stores `tradeOrder` with the returned `orderId`/`txid`. `pollTradeOrder()` then polls that order until it is `filled`, moves through `trade:orderPolling`, stores `lastTradeOrderStatus`, and refreshes wallet balances by default before returning to `wallet:ready`.
### quote:requesting / quote:ready / quote:expired / quote:error
Quote phase. `quote:ready` makes `quote` available (id, amount, fee, expiresAt). Auto-refreshes by default.
### withdraw:idle / withdraw:processing
Withdrawal execution phase. `withdraw:processing` means the withdrawal is being executed.
### withdraw:error2FA / withdraw:error2FAMultiStep / withdraw:errorSMS / withdraw:errorKYC / withdraw:errorBalance
Challenge states. User must provide 2FA code, SMS code, KYC verification, or has insufficient balance.
### withdraw:retrying / withdraw:readyToConfirm
`retrying` — withdrawal failed but can be retried. `readyToConfirm` — all multi-step 2FA verified, waiting for final confirmation.
### withdraw:completed / withdraw:blocked / withdraw:fatal
Terminal states. `completed` has `withdrawal.transactionId`. `fatal` has `error`. `blocked` has `error` with reason.
### flow:cancelled
Flow was cancelled via `cancel()`. Disposes withdrawal machine.
## Driving Transitions
### loadExchanges(status?)
**Valid from**: any (creates machine if needed). **Result**: `exchanges:loading` → `exchanges:ready` or `exchanges:error`.
### startWithdrawalFlow({ exchange, walletId, popupOptions? })
**Auto-detects** QR code exchanges (`binance-web`). Checks if wallet exists first — if yes, delegates to `resumeWithdrawalFlow`. Opens OAuth popup or starts QR code flow.
### resumeWithdrawalFlow({ exchange, walletId })
Skips OAuth. Transitions through OAuth states synthetically, then loads wallet balance.
### silentResumeWithdrawalFlow({ walletId, exchange, preloadedBalances?, callbacks... })
Jumps directly to `wallet:ready` with optional preloaded balance data. Used after wallet preview.
### loadTradableAssets()
**Valid from**: `wallet:ready`, `trade:assetsReady`, `trade:assetsError`. Calls `getTradableAssetsFn(walletId)` and stores normalized tradable assets/routes in `context.tradableAssets`.
### placeTradeOrder({ routeId, side, type, volume, price? })
**Valid from**: `wallet:ready`, `trade:assetsReady`, `trade:orderPlaced`, `trade:orderError`. Calls `placeOrderFn(walletId, body)` once and stores the returned `tradeOrder`. Use the returned `orderId` for polling; do not retry blindly unless the previous call failed before returning an order id.
### pollTradeOrder(orderId, { maxAttempts?, intervalMs?, refreshWalletOnFilled? })
**Valid from**: `wallet:ready`, `trade:assetsReady`, `trade:orderPlaced`, `trade:orderError`. Calls `getOrderFn(walletId, orderId)` until the normalized status is `filled`, `canceled`, `expired`, or polling times out. Defaults: `maxAttempts: 30`, `intervalMs: 1000`, `refreshWalletOnFilled: true`.
### Auto-swap before withdrawal
Use this sequence when the wallet has a source asset but the withdrawal needs a different asset: load balances, load tradable routes, place a market order once, poll until `filled`, let the wallet refresh, then request a quote for the destination asset and withdraw it.
### requestQuote({ asset, amount, destinationAddress, network?, tag?, includeFee? })
**Valid from**: `wallet:ready`, `trade:assetsReady`, `trade:orderFilled`, `quote:ready` (refresh), `quote:expired`. Requests a quote from the backend. Sets up auto-refresh timer if `autoRefreshQuotation` is true.
### executeWithdrawal(quoteId)
**Valid from**: `quote:ready`. Starts withdrawal, subscribes to WebSocket for progress and completion.
### submit2FA(code)
**Valid from**: `withdraw:error2FA`. Re-executes withdrawal with TOTP code.
### submit2FAMultiStep(stepType, code)
**Valid from**: `withdraw:error2FAMultiStep`. Submits code for one step ('GOOGLE', 'EMAIL', or 'SMS'). For incremental-verification exchanges (binance-web, bybit-web) it re-invokes the withdrawal immediately with all collected codes. For **batch-validation exchanges (KuCoin)** with `relation: 'AND'`, it only *collects* the code (via the `COLLECT_2FA_MULTI_STEP` action — no API call, state stays `withdraw:error2FAMultiStep`, returns `{ success: true, result: { collected: true, awaitingSteps } }`) until every required code-based step has a code; the final code triggers exactly one withdrawal execution with `bizNo` + all collected codes. See Gotcha 13.
### pollFaceVerification()
**Valid from**: `withdraw:error2FAMultiStep`. Transitions to processing to check FACE step completion.
### pollRoamingFidoVerification()
**Valid from**: `withdraw:error2FAMultiStep`. Polls backend to check ROAMING_FIDO step completion (no state transition during poll — MFA UI stays visible).
### confirmWithdrawal()
**Valid from**: `withdraw:readyToConfirm`. Sends final confirmation after all 2FA steps verified.
### retryWithdrawal()
**Valid from**: `withdraw:retrying`. Re-executes withdrawal with new idempotency key.
### refreshQRCode()
**Valid from**: `qrcode:error` or `qrcode:timeout`. Generates a new QR code.
### subscribe(listener), getState(), cancel(), dispose()
Standard machine operations. `cancel()` transitions to `flow:cancelled`. `dispose()` cleans up timers and subscriptions.
## Error Handling
### Error Codes
The `ERROR_CODES` constant contains 40+ error codes grouped by category:
- **Generic**: `GENERIC_NOT_FOUND`, `GENERIC_UNAUTHORIZED`, `GENERIC_INTERNAL_SERVER_ERROR`, etc.
- **Wallet**: `WALLET_NOT_FOUND`, `WALLET_INVALID_CREDENTIALS`
- **Trading**: trading APIs surface validation/provider errors via backend error codes and may return `APIKEY_INSUFFICIENT_PERMISSIONS` when the project key lacks trading scopes.
- **Quote**: `QUOTE_NOT_FOUND`, `QUOTE_EXPIRED`
- **Withdrawal Balance**: `WITHDRAWAL_INSUFFICIENT_BALANCE`, `WITHDRAWAL_INSUFFICIENT_BALANCE_FOR_FEE`
- **Withdrawal Address**: `WITHDRAWAL_INVALID_ADDRESS`, `WITHDRAWAL_NETWORK_NOT_SUPPORTED`
- **Withdrawal Amount**: `WITHDRAWAL_AMOUNT_BELOW_MINIMUM`, `WITHDRAWAL_AMOUNT_ABOVE_MAXIMUM`
- **Withdrawal 2FA**: `WITHDRAWAL_2FA_REQUIRED_TOTP`, `WITHDRAWAL_2FA_REQUIRED_SMS`, `WITHDRAWAL_2FA_REQUIRED_MULTI_STEPS`, `WITHDRAWAL_2FA_INVALID`, etc. (supports GOOGLE, EMAIL, FACE, SMS, ROAMING_FIDO steps)
- **OAuth**: `OAUTH_AUTHORIZATION_FAILED`, `OAUTH_TOKEN_EXCHANGE_FAILED` (FATAL)
- **Special**: `WITHDRAWAL_DRY_RUN_COMPLETE` — NOT an error, it's a success signal
### Error Extraction
```typescript
import { extractErrorCode, extractErrorTypeInfo, extractErrorResult, ERROR_CODES } from '@bluvo/sdk-ts';
const code = extractErrorCode(error); // ErrorCode | null
const info = extractErrorTypeInfo(error); // { knownCode, rawType }
const result = extractErrorResult(error); // unknown (multi-step 2FA data)
if (code === ERROR_CODES.WITHDRAWAL_2FA_REQUIRED_TOTP) {
// Show 2FA input
}
```
### Error States
- `oauth:error` — Recoverable OAuth error (user can retry)
- `oauth:fatal` — Non-recoverable OAuth error (wallet connection broken)
- `withdraw:error2FA` — 2FA required, user must submit code
- `withdraw:error2FAMultiStep` — Multi-step 2FA required
- `withdraw:fatal` — Non-recoverable withdrawal error
- `withdraw:errorBalance` — Insufficient balance
## Gotchas
1. **`toPlain()` needed for Next.js server actions.** The SDK returns class instances that Next.js cannot serialize. Wrap every server action return with `JSON.parse(JSON.stringify(result))`.
2. **QR code flow is auto-detected for `binance-web`.** When you call `startWithdrawalFlow({ exchange: 'binance-web', ... })`, it automatically routes to the QR code flow instead of opening an OAuth popup. The constant `QR_CODE_EXCHANGES = ['binance-web']` controls this.
3. **`autoRefreshQuotation` defaults to `true`.** Quotes auto-refresh when they expire. If you want manual control (showing an "expired" UI), set `autoRefreshQuotation: false` in options.
4. **Invalid state transitions are no-ops.** Sending an action in the wrong state returns the current state unchanged — no error is thrown. Check `getState().type` if you need to verify the transition happened.
5. **`multiStep2FA.mfa.verified` is the PRIMARY source of truth** for step verification status, not `step.status`. The `mfa.verified` object is updated by the backend and reflects the actual verification state. Supported keys: `GOOGLE`, `EMAIL`, `FACE`, `SMS`, `ROAMING_FIDO`.
6. **`WITHDRAWAL_DRY_RUN_COMPLETE` is NOT a real error.** It's a success signal indicating all multi-step 2FA steps are verified and the withdrawal is ready to be confirmed. The SDK handles it by transitioning to `withdraw:readyToConfirm`.
7. **OAuth window close detection uses 500ms polling interval.** There's a slight delay between the user closing the popup and the `oauth:window_closed_by_user` state transition.
8. **Cache has 15-second safety threshold.** QR code cache entries with less than 15 seconds remaining are treated as expired and ignored. Configurable via `cache.minRemainingLifetimeSec`.
9. **`startWithdrawalFlow` checks if wallet exists first.** If `getWalletByIdFn` returns a wallet, it automatically calls `resumeWithdrawalFlow` instead of opening OAuth.
10. **Trading callbacks are optional but required for auto-swap.** If `getTradableAssetsFn`, `placeOrderFn`, or `getOrderFn` are missing, the flow moves to a trade error state and returns `"Trading API callbacks are not configured"`.
11. **`placeTradeOrder()` is a one-shot action.** It calls the backend Place Order API once and returns the exchange order id/txid. Persist or hold that id in UI state before polling so refreshes do not accidentally place a second order.
12. **Filled trade polling refreshes balances by default.** `pollTradeOrder()` calls `loadWallet()` after a filled order unless `refreshWalletOnFilled: false` is passed. Because of that, the visible final state is normally `wallet:ready`, with `lastTradeOrderStatus.status === "filled"` still available in context.
13. **KuCoin validates all 2FA factors in ONE call (batch validation).** KuCoin's broker withdrawal API has no incremental per-factor verification — re-executing with a partial factor set (e.g. only the Google code when EMAIL + GOOGLE are required with `relation: 'AND'`) makes KuCoin answer `200000` with no ids and the challenge dies server-side (historically a fatal `KuCoin withdrawal failed: unexpected response shape`). The SDK therefore treats exchanges in `BATCH_2FA_VALIDATION_EXCHANGES` (currently `['kucoin']`) specially when `relation === 'AND'`: each `submit2FAMultiStep()` call stores the code in `collectedCodes` (`GOOGLE` → `twofa`, `EMAIL` → `emailCode`, `SMS` → `smsCode`) and marks that step `status: 'success'` **locally** so the UI advances — the withdrawal endpoint is NOT called and codes are NOT validated yet. Only the final missing code triggers a single execution with `bizNo` + all collected codes. Steps already `mfa.verified === true` count as satisfied; FACE/ROAMING_FIDO are excluded (poll-verified). Consequences: (a) for KuCoin a locally-`success` step does not mean the backend verified that code — an invalid code only surfaces after the final batch call; (b) with `relation: 'OR'` KuCoin behaves like other exchanges (one code → immediate re-execution).
## References
- Read `references/api-client.md` if you need to understand the underlying REST calls, error codes, or authentication configuration.
- Read `references/types.md` if you need the complete TypeScript type definitions for machine context, events, or state values.
- Read `references/state-transitions.md` if you need detailed guard conditions, async behavior, or sequence diagrams for specific flows.
- Read `../react/skill/references/qrcode-binance-web.md` for comprehensive QR code authentication flow implementation patterns including component rendering for `binance-web`.
- Read `../react/skill/references/multistep-2fa.md` for comprehensive multi-step 2FA implementation patterns including FACE polling, ROAMING_FIDO polling, dryRun pattern, and step verification.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!