Withdrawal — Crypto Amount Confirmation
Problem
When a player withdraws USD via cryptocurrency, the current confirmation modal shows only USD amounts:
Cashout amount: $100.00
Fee (2%): $2.00
You receive: $98.00
Method: Bitcoin Wallet
The player has no idea how many BTC (or USDT, LTC, DOGE…) they will actually receive. This creates confusion about the conversion rate applied and erodes trust — especially for volatile coins like BTC where the rate can swing significantly between request and payout.
Goal: Before the player confirms the withdrawal, show the estimated crypto amount they will receive, the conversion rate used, and their destination wallet address.
Affected Withdrawal Methods
This feature applies to all crypto withdrawal methods, which fall into two provider paths with different conversion mechanisms.
Path A — BTCPay (on-chain, volatile coins)
withdrawalType | Coin | Provider |
|---|---|---|
bitcoin_transfer | BTC | btcpay |
litecoin_transfer | LTC | btcpay |
dogecoin_transfer | DOGE | btcpay |
BTCPay receives the net USD amount and converts to coin at payout time. It does expose a rates endpoint that returns the same rate source used for conversion:
GET /api/v1/stores/{storeId}/rates?currencyPairs=BTC_USD
Response: [{ "currencyPair": "BTC_USD", "rate": "96817.50" }] (rate as decimal string).
Using this endpoint for the estimate gives rates from the same provider BTCPay uses at payout, making the preview highly accurate — no external price oracle needed.
Path B — 0xProcessing (multi-coin, including stablecoins)
withdrawalType | Coin | Provider |
|---|---|---|
zerox_usdt | USDT | zeroxprocessing |
zerox_btc | BTC | zeroxprocessing |
zerox_ltc | LTC | zeroxprocessing |
zerox_eth | ETH | zeroxprocessing |
zerox_* | any coin in 0x coin list | zeroxprocessing |
0xProcessing already exposes GET /Api/ConvertToCrypto (used in zeroxProcessingService.convertUsdToCrypto()). The adapter calls this same API at payout time. The frontend estimate can reuse this endpoint for a highly accurate preview — especially for USDT, which is pegged 1:1 to USD and rarely differs from the preview.
USDT is a stablecoin: $98.00 net payout ≈ 98.00 USDT. Showing the estimate is still important so the player understands the method and can confirm their wallet address before submitting.
Target User Experience
BTCPay (BTC/LTC/DOGE) modal
┌─────────────────────────────────────────────────────┐
│ ✓ Confirm Cashout Request │
│ │
│ Cashout amount: $100.00 │
│ Platform fee (2%): −$2.00 │
│ You receive (USD): $98.00 │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Estimated crypto payout │ │
│ │ ≈ 0.00101234 BTC │ │
│ │ Rate: 1 BTC = $96,817.50 (live estimate) │ │
│ │ To: bc1q...a7fg │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ⚠ The final BTC amount is determined by BTCPay │
│ at payout time and may differ from this │
│ estimate. │
│ │
│ [ Cancel ] [ Confirm Cashout ] │
└─────────────────────────────────────────────────────┘
0xProcessing (USDT and other zerox_* coins) modal
┌─────────────────────────────────────────────────────┐
│ ✓ Confirm Cashout Request │
│ │
│ Cashout amount: $100.00 │
│ Platform fee (2%): −$2.00 │
│ You receive (USD): $98.00 │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Crypto payout │ │
│ │ ≈ 98.00 USDT │ │
│ │ (1 USDT ≈ $1.00 — stablecoin) │ │
│ │ To: 0x1234...5678 │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ⚠ Final amount confirmed by 0xProcessing at │
│ payout time. Small variance possible. │
│ │
│ [ Cancel ] [ Confirm Cashout ] │
└─────────────────────────────────────────────────────┘
Display rules
| Field | BTCPay path | 0xProcessing path |
|---|---|---|
| Estimated amount prefix | ≈ (estimate) | ≈ (highly accurate for stablecoins) |
| BTC/LTC decimal precision | 8 decimal places | 8 decimal places |
| DOGE decimal precision | 2 decimal places | 2 decimal places |
| USDT decimal precision | — | 2 decimal places |
| Rate line | 1 BTC = $X,XXX.XX (live estimate) | 1 USDT ≈ $1.00 — stablecoin (or 1 ETH = $X,XXX.XX) |
| Wallet address display | First 6 chars + ... + last 4 chars | Same |
| Disclaimer | BTCPay converts at payout time | 0xProcessing converts at payout time |
Current State (Code Reference)
Frontend — WalletWithdrawPage
File: kioskgaming_frontend/kiosk_frontend/src/features/finance/pages/WalletWithdrawPage.tsx
The current WithdrawalRequest interface does not include any crypto conversion fields:
interface WithdrawalRequest {
transactionId: string;
withdrawalType: 'bank_transfer' | 'bitcoin_transfer' | 'litecoin_transfer'
| 'dogecoin_transfer'; // zerox_* types not yet reflected here
amount: number; // gross USD
feeRate: number;
feeAmount: number;
netReceiveAmount: number; // net USD — shown as "You receive"
currency: string; // 'USD'
status: string;
bitcoinInfo?: { bitcoinAddress: string; bitcoinNetwork: string };
}
No crypto estimate is computed or displayed.
Backend — 0xProcessing Conversion API (already exists)
File: kioskgaming_backend/src/services/payment/zeroxProcessing/zeroxProcessingService.js
The function convertUsdToCrypto() calls GET /Api/ConvertToCrypto on the 0xProcessing API:
// Already used by zeroxProcessingWithdrawalAdapter.js at payout time:
const cryptoAmount = await zeroxProcessingService.convertUsdToCrypto({
usdAmount: netPayoutUsd,
outCurrency: 'USDT', // or 'BTC', 'ETH', etc.
});
// Returns: 98.05 (exact crypto units)
For the preview feature, the backend can expose this same call through a new endpoint so the frontend can get an accurate estimate before the player submits.
Backend — BTCPay Rates API (exists in Greenfield v1)
File: kioskgaming_backend/src/services/payment/BTCPay/btcPayService.js
BTCPay Greenfield API exposes a rates endpoint (added in v1.8.0, PR #4550):
GET /api/v1/stores/{storeId}/rates?currencyPairs=BTC_USD,LTC_USD,DOGE_USD
Response:
[
{ "currencyPair": "BTC_USD", "rate": "96817.50" },
{ "currencyPair": "LTC_USD", "rate": "82.45" },
{ "currencyPair": "DOGE_USD", "rate": "0.1234" }
]
rate is returned as a decimal string (not a number) to preserve precision — parse with parseFloat(). This is the same rate source BTCPay uses when it converts netPayoutUsd to crypto at payout time, so the preview will be highly accurate.
btcPayService.js currently calls /api/v1/stores/{storeId}/invoices and /api/v1/stores/{storeId}/invoices/{id}/payment-methods — the rates endpoint needs to be added.
Backend — withdrawalFeeService
File: kioskgaming_backend/src/services/payment/withdrawalFeeService.js
netPayoutUsd (the USD amount sent to the provider) is stored in transaction.metadata.netPayoutUsd. This is the input to conversion for both paths.
Implementation Plan
Step 1 — New backend endpoint: GET /api/payment/crypto-estimate
A single endpoint handles both provider paths. It takes the withdrawalType plus the net USD amount, and returns the estimated crypto amount.
GET /api/payment/crypto-estimate?withdrawalType=zerox_usdt&netUsd=98.00
GET /api/payment/crypto-estimate?withdrawalType=bitcoin_transfer&netUsd=98.00
Response:
{
"coin": "USDT",
"provider": "zeroxprocessing",
"estimatedCryptoAmount": 98.05,
"rateUsd": 1.0005,
"source": "zerox_convert_api",
"fetchedAt": "2026-05-09T10:31:00.000Z"
}
{
"coin": "BTC",
"provider": "btcpay",
"estimatedCryptoAmount": 0.00101234,
"rateUsd": 96817.50,
"source": "btcpay_rates_api",
"fetchedAt": "2026-05-09T10:31:00.000Z"
}
Auth: Player auth middleware (same as /api/payment/provider-fee-rate).
Internal routing logic:
async function getCryptoEstimate(withdrawalType, netUsdAmount) {
const isZerox = withdrawalType.startsWith('zerox_');
if (isZerox) {
// Use 0xProcessing's ConvertToCrypto — same API used at actual payout
const coin = withdrawalType.replace('zerox_', '').toUpperCase();
const cryptoAmount = await zeroxProcessingService.convertUsdToCrypto({
usdAmount: netUsdAmount,
outCurrency: coin,
});
const rateUsd = netUsdAmount / cryptoAmount;
return { coin, provider: 'zeroxprocessing', estimatedCryptoAmount: cryptoAmount,
rateUsd, source: 'zerox_convert_api', fetchedAt: new Date().toISOString() };
}
// BTCPay path: use BTCPay's own Greenfield Rates API
// GET /api/v1/stores/{storeId}/rates?currencyPairs=BTC_USD
// This is the same rate source BTCPay uses at payout time
const COIN_MAP = { bitcoin_transfer: 'BTC', litecoin_transfer: 'LTC', dogecoin_transfer: 'DOGE' };
const coin = COIN_MAP[withdrawalType];
const rateUsd = await btcPayService.getStoreRate(`${coin}_USD`); // cached 30s
const estimatedCryptoAmount = netUsdAmount / rateUsd;
return { coin, provider: 'btcpay', estimatedCryptoAmount, rateUsd,
source: 'btcpay_rates_api', fetchedAt: new Date().toISOString() };
}
Caching:
- 0xProcessing path: no additional cache needed.
- BTCPay path: cache the rates API response per currency pair for 30 seconds in Redis (or in-memory). No external dependency needed.
Step 2 — Add getStoreRate() to btcPayService.js
Extend the existing BTCPayService class with a method that calls the Greenfield Rates endpoint:
// In btcPayService.js — new method:
/**
* Fetch current exchange rate from BTCPay's own rate provider.
* Uses GET /api/v1/stores/{storeId}/rates?currencyPairs={pair}
* Rate is returned as a decimal string by the API; parsed to float here.
*
* @param {string} currencyPair - e.g. 'BTC_USD', 'LTC_USD', 'DOGE_USD'
* @returns {Promise<number>} USD per 1 coin (e.g. 96817.50 for BTC_USD)
*/
async getStoreRate(currencyPair) {
const endpoint = `${this.baseUrl}/api/v1/stores/${this.storeId}/rates`;
const response = await axios.get(endpoint, {
headers: { Authorization: `token ${this.apiKey}` },
params: { currencyPairs: currencyPair },
timeout: 10000,
});
const entry = response.data.find(r => r.currencyPair === currencyPair);
if (!entry) throw new Error(`Rate not found for ${currencyPair}`);
return parseFloat(entry.rate); // API returns string, e.g. "96817.50"
}
No new service file needed — this lives inside the existing btcPayService.js.
Step 3 — Frontend: call estimate endpoint before showing confirm modal
In WalletWithdrawPage.tsx, after the player fills in the amount and address but before the confirm modal opens:
async function handleOpenConfirmModal() {
const isCryptoMethod = withdrawalType !== 'bank_transfer';
let cryptoEstimate: CryptoEstimate | null = null;
if (isCryptoMethod) {
try {
cryptoEstimate = await apiClient.get(
`/api/payment/crypto-estimate?withdrawalType=${withdrawalType}&netUsd=${netReceiveAmount}`
);
} catch {
// Non-blocking: show modal anyway without the crypto estimate
cryptoEstimate = null;
}
}
setConfirmState({ gross, fee, net: netReceiveAmount, cryptoEstimate });
openConfirmModal();
}
TypeScript type:
interface CryptoEstimate {
coin: string; // 'BTC' | 'LTC' | 'DOGE' | 'USDT' | ...
provider: 'btcpay' | 'zeroxprocessing';
estimatedCryptoAmount: number;
rateUsd: number; // 1 coin = X USD
source: string; // 'zerox_convert_api' | 'coingecko'
fetchedAt: string; // ISO-8601
}
If cryptoEstimate is null, the modal renders without the estimate section. Do not block submission.
Step 4 — Frontend: update confirm modal rendering
Format helpers:
function formatCryptoAmount(amount: number, coin: string): string {
if (coin === 'DOGE') return `${amount.toFixed(2)} DOGE`;
if (coin === 'USDT') return `${amount.toFixed(2)} USDT`;
return `${amount.toFixed(8)} ${coin}`;
}
function formatRateLine(estimate: CryptoEstimate): string {
const isStablecoin = estimate.coin === 'USDT';
if (isStablecoin) return `1 ${estimate.coin} ≈ $1.00 — stablecoin`;
return `1 ${estimate.coin} = $${estimate.rateUsd.toLocaleString('en-US', { minimumFractionDigits: 2 })} (live estimate)`;
}
function formatDisclaimer(estimate: CryptoEstimate): string {
const providerName = estimate.provider === 'btcpay' ? 'BTCPay' : '0xProcessing';
return `Final amount confirmed by ${providerName} at payout time. Small variance possible.`;
}
function maskAddress(addr: string): string {
if (addr.length < 12) return addr;
return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
}
Modal section (render only when cryptoEstimate is present):
{cryptoEstimate && (
<div className="crypto-estimate-box">
<p className="label">Crypto payout</p>
<p className="crypto-amount">
≈ {formatCryptoAmount(cryptoEstimate.estimatedCryptoAmount, cryptoEstimate.coin)}
</p>
<p className="rate-line">{formatRateLine(cryptoEstimate)}</p>
<p className="wallet-line" title={destinationAddress}>
To: {maskAddress(destinationAddress)}
</p>
<p className="disclaimer">⚠ {formatDisclaimer(cryptoEstimate)}</p>
</div>
)}
Step 5 — Backend: store estimate snapshot on transaction
When the player submits the withdrawal, include the estimate snapshot in the request body so it is stored as an audit trail.
// POST /api/payment/withdrawal — new optional field:
{
amount: 100,
withdrawalType: 'zerox_usdt',
withdrawalAddress: '0x1234...',
cryptoEstimateSnapshot: {
coin: 'USDT',
rateUsd: 1.0005,
estimatedCryptoAmount: 98.05,
source: 'zerox_convert_api',
fetchedAt: '2026-05-09T10:31:00.000Z',
}
}
Store in transaction.metadata.cryptoEstimateSnapshot (nullable — absent for bank transfer or pre-feature transactions).
// paymentUserController.js — transaction creation metadata:
metadata: {
withdrawalAddress: { address, network, memo },
grossWithdrawalUsd: gross,
netPayoutUsd,
providerFeeMatchedMethodId: methodId,
cryptoEstimateSnapshot: req.body.cryptoEstimateSnapshot ?? null, // NEW
}
Never use cryptoEstimateSnapshot.estimatedCryptoAmount for any financial calculation on the backend. The actual payout amount is always computed server-side from netPayoutUsd at payout time.
Data Flow Summary
BTCPay (BTC/LTC/DOGE)
Player: $100 gross, BTC method
│
▼
GET /api/payment/provider-fee-rate → feeRate=2%, net=$98.00
│
Player clicks "Confirm"
│
▼
GET /api/payment/crypto-estimate?withdrawalType=bitcoin_transfer&netUsd=98.00
Backend → BTCPay: GET /api/v1/stores/{storeId}/rates?currencyPairs=BTC_USD (cached 30s)
→ rateUsd=96,817.50 (same rate source BTCPay uses at payout)
→ estimatedCryptoAmount = 98.00 / 96817.50 = 0.00101234 BTC
│
▼
Confirm modal shows:
$100 gross | $2 fee | $98 net
≈ 0.00101234 BTC at 1 BTC = $96,817.50
To: bc1q...a7fg
⚠ Final amount determined by BTCPay at payout time
│
Player clicks "Confirm Cashout"
│
▼
POST /api/payment/withdrawal
{ ..., cryptoEstimateSnapshot: { coin:'BTC', rateUsd:96817.50, ... } }
│
▼
PaymentTransaction created (status: pending)
metadata.cryptoEstimateSnapshot = { coin:'BTC', rateUsd:96817.50, ... }
│
Admin approves → btcPayWithdrawalAdapter.createPayout(netPayoutUsd=$98.00)
BTCPay converts $98 → BTC at its own live rate at payout time
0xProcessing (USDT and other zerox_* coins)
Player: $100 gross, USDT method (zerox_usdt)
│
▼
GET /api/payment/provider-fee-rate → feeRate=2%, net=$98.00
│
Player clicks "Confirm"
│
▼
GET /api/payment/crypto-estimate?withdrawalType=zerox_usdt&netUsd=98.00
Backend → 0xProcessing: GET /Api/ConvertToCrypto?InCurrency=USD&OutCurrency=USDT&InAmount=98
→ estimatedCryptoAmount = 98.05 USDT
→ rateUsd = 98.00 / 98.05 ≈ 0.9995
│
▼
Confirm modal shows:
$100 gross | $2 fee | $98 net
≈ 98.05 USDT (1 USDT ≈ $1.00 — stablecoin)
To: 0x1234...5678
⚠ Final amount confirmed by 0xProcessing at payout time
│
Player clicks "Confirm Cashout"
│
▼
POST /api/payment/withdrawal
{ ..., cryptoEstimateSnapshot: { coin:'USDT', rateUsd:0.9995, ... } }
│
▼
PaymentTransaction created (status: pending)
│
Admin approves → zeroxProcessingWithdrawalAdapter.createPayout()
→ calls convertUsdToCrypto($98, 'USDT') again at payout time
→ actual USDT amount may differ by <0.1% from the preview
Edge Cases
| Case | Handling |
|---|---|
| Estimate fetch times out or errors | Render confirm modal without the crypto section; allow submission |
| Player leaves modal open for > 2 minutes | Show "Rate may be stale — close and reopen to refresh" badge; do not auto-refresh (avoids race conditions) |
netReceiveAmount is $0 (100% fee) | Skip estimate fetch entirely; show $0 net only |
| USDT rate deviates from $1.00 by > 2% | This signals a 0xProcessing API anomaly; log a warning but still show the result |
zerox_* coin not in 0x coin list | convertUsdToCrypto will throw; endpoint returns 422 with reason: 'coin_not_supported' — frontend falls back to no-estimate mode |
| BTCPay testnet | bitcoinNetwork = 'testnet'; coin symbol stays BTC; estimate still shown with a (testnet) label |
New zerox_* coins added in future | No code change needed — zerox_ prefix detection handles any coin dynamically |
Fields Added
PaymentTransaction.metadata.cryptoEstimateSnapshot
interface CryptoEstimateSnapshot {
coin: string; // 'BTC' | 'LTC' | 'DOGE' | 'USDT' | 'ETH' | ...
rateUsd: number; // 1 coin = X USD at preview time
estimatedCryptoAmount: number;// what the player saw in the modal
source: 'zerox_convert_api' | 'btcpay_rates_api' | string;
fetchedAt: string; // ISO-8601
}
Nullable — absent for bank transfers or transactions created before this feature deployed.
New API endpoint
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/payment/crypto-estimate | Player auth | Returns estimated crypto amount for a given withdrawalType and netUsd. Queries 0xProcessing or price oracle depending on provider path. |
Query params: withdrawalType (required), netUsd (required, positive number).
Design Decisions
Why one unified endpoint (/crypto-estimate) instead of two?
The frontend only needs one call regardless of provider. The routing between 0xProcessing's ConvertToCrypto and the external price oracle is an implementation detail the frontend should not be aware of.
Why use each provider's own rate API instead of a third-party price oracle (e.g. CoinGecko)?
For zerox_* methods, the payout adapter calls convertUsdToCrypto() at execution time. Using the same API for the preview means the rate shown is as close as possible to the rate applied at payout.
For BTCPay methods, the Greenfield Rates endpoint (GET /api/v1/stores/{storeId}/rates) returns the same rate source BTCPay uses when converting USD to crypto at payout. This is more accurate than any third-party oracle — no external dependency is introduced, and the estimate comes from the same system that will execute the payout.
Why is the snapshot informational-only, not a locked rate?
Locking the rate would require the platform to absorb slippage between preview and payout. For volatile coins (BTC) this introduces financial risk. The disclaimer makes clear to the player that the final amount is confirmed at payout time.
Why store the snapshot in metadata, not a dedicated column?
The snapshot is audit data only — it participates in no financial computation. Storing it in the existing metadata JSONB field avoids a database migration.