Skip to main content

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)

withdrawalTypeCoinProvider
bitcoin_transferBTCbtcpay
litecoin_transferLTCbtcpay
dogecoin_transferDOGEbtcpay

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)

withdrawalTypeCoinProvider
zerox_usdtUSDTzeroxprocessing
zerox_btcBTCzeroxprocessing
zerox_ltcLTCzeroxprocessing
zerox_ethETHzeroxprocessing
zerox_*any coin in 0x coin listzeroxprocessing

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.

note

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

FieldBTCPay path0xProcessing path
Estimated amount prefix (estimate) (highly accurate for stablecoins)
BTC/LTC decimal precision8 decimal places8 decimal places
DOGE decimal precision2 decimal places2 decimal places
USDT decimal precision2 decimal places
Rate line1 BTC = $X,XXX.XX (live estimate)1 USDT ≈ $1.00 — stablecoin (or 1 ETH = $X,XXX.XX)
Wallet address displayFirst 6 chars + ... + last 4 charsSame
DisclaimerBTCPay converts at payout time0xProcessing 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
}
danger

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

CaseHandling
Estimate fetch times out or errorsRender confirm modal without the crypto section; allow submission
Player leaves modal open for > 2 minutesShow "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 listconvertUsdToCrypto will throw; endpoint returns 422 with reason: 'coin_not_supported' — frontend falls back to no-estimate mode
BTCPay testnetbitcoinNetwork = 'testnet'; coin symbol stays BTC; estimate still shown with a (testnet) label
New zerox_* coins added in futureNo 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

MethodPathAuthDescription
GET/api/payment/crypto-estimatePlayer authReturns 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.