Withdrawal Processing

Build / Integration Guide / Withdrawal Processing

6. Withdrawal Processing

🔐 Security Alert: Withdrawal processing involves the cold storage wallet. Implement proper authorization, rate limiting, and multi-signature approval processes.

1. Validate Withdrawal Request

Pseudocode

FUNCTION validateWithdrawalRequest(userId, destinationAddress, amount):
    // Step 1: Validate user authentication and KYC status
    user = database.getUser(userId)
    IF (user IS null):
        THROW "User not found"

    IF (NOT user.isKYCVerified):
        THROW "User not KYC verified"

    IF (user.isBlocked OR user.withdrawalsSuspended):
        THROW "User account restricted"

    // Step 2: Validate withdrawal amount
    IF (amount <= 0):
        THROW "Invalid withdrawal amount"

    minimumWithdrawal = getMinimumWithdrawalAmount()
    IF (amount < minimumWithdrawal):
        THROW "Amount below minimum withdrawal: " + minimumWithdrawal + " XTM"

    // Step 3: Check user balance (including fees)
    withdrawalFee = calculateWithdrawalFee(amount)
    totalRequired = amount + withdrawalFee

    IF (user.availableBalance < totalRequired):
        THROW "Insufficient balance"

    // Step 4: Validate destination address
    IF (NOT isValidTariAddress(destinationAddress)):
        THROW "Invalid Tari address format"

    // Step 5: Rate limiting and daily limits
    dailyWithdrawalLimit = getDailyWithdrawalLimit(user.kycLevel)
    todayWithdrawals = getTodayWithdrawals(userId)

    IF (todayWithdrawals + amount > dailyWithdrawalLimit):
        THROW "Daily withdrawal limit exceeded"

    // Step 6: Create withdrawal record
    withdrawalId = generateUniqueId()
    withdrawalRecord = {
        id: withdrawalId,
        userId: userId,
        destinationAddress: destinationAddress,
        amount: amount,
        fee: withdrawalFee,
        status: "pending_approval",
        createdAt: getCurrentTimestamp()
    }

    database.saveWithdrawal(withdrawalRecord)
    database.reserveUserBalance(userId, totalRequired)

    RETURN {
        withdrawalId: withdrawalId,
        amount: amount,
        fee: withdrawalFee,
        estimatedProcessingTime: "15-30 minutes"
    }

grpcurl

# Check the cold wallet's available balance before approving
grpcurl -plaintext localhost:18143 tari.rpc.Wallet/GetState

# Validate the destination address format via the base node
grpcurl -plaintext -d '{"address": "PLACEHOLDER_destination_address"}' 
  localhost:18142 tari.rpc.BaseNode/CheckAddress

Node.js

async function validateWithdrawalRequest(userId, destinationAddress, amount, db) {
  const user = await db.users.get(userId);
  if (!user) throw new Error('User not found');
  if (!user.isKYCVerified) throw new Error('User not KYC verified');
  if (user.isBlocked || user.withdrawalsSuspended) throw new Error('User account restricted');

  if (amount <= 0) throw new Error('Invalid withdrawal amount');

  const minimumWithdrawal = await getMinimumWithdrawalAmount();
  if (amount < minimumWithdrawal) {
    throw new Error(`Amount below minimum withdrawal: ${minimumWithdrawal} XTM`);
  }

  const fee = calculateWithdrawalFee(amount);
  if (user.availableBalance < amount + fee) {
    throw new Error('Insufficient balance');
  }

  if (!isValidTariAddress(destinationAddress)) {
    throw new Error('Invalid Tari address format');
  }

  const dailyLimit = getDailyWithdrawalLimit(user.kycLevel);
  const todayWithdrawals = await getTodayWithdrawals(userId);
  if (todayWithdrawals + amount > dailyLimit) {
    throw new Error('Daily withdrawal limit exceeded');
  }

  const withdrawalId = crypto.randomUUID();
  await db.withdrawals.create({
    id: withdrawalId,
    userId,
    destinationAddress,
    amount,
    fee,
    status: 'pending_approval',
  });
  await db.users.reserveBalance(userId, amount + fee);

  return { withdrawalId, amount, fee, estimatedProcessingTime: '15-30 minutes' };
}

Python

def validate_withdrawal_request(user_id, destination_address, amount, db):
    user = db.users.get(user_id)
    if user is None:
        raise ValueError("User not found")
    if not user.is_kyc_verified:
        raise ValueError("User not KYC verified")
    if user.is_blocked or user.withdrawals_suspended:
        raise ValueError("User account restricted")

    if amount <= 0:
        raise ValueError("Invalid withdrawal amount")

    minimum_withdrawal = get_minimum_withdrawal_amount()
    if amount < minimum_withdrawal:
        raise ValueError(f"Amount below minimum withdrawal: {minimum_withdrawal} XTM")

    fee = calculate_withdrawal_fee(amount)
    if user.available_balance < amount + fee:
        raise ValueError("Insufficient balance")

    if not is_valid_tari_address(destination_address):
        raise ValueError("Invalid Tari address format")

    daily_limit = get_daily_withdrawal_limit(user.kyc_level)
    today_withdrawals = get_today_withdrawals(user_id)
    if today_withdrawals + amount > daily_limit:
        raise ValueError("Daily withdrawal limit exceeded")

    withdrawal_id = generate_unique_id()
    db.withdrawals.save({
        "id": withdrawal_id,
        "user_id": user_id,
        "destination_address": destination_address,
        "amount": amount,
        "fee": fee,
        "status": "pending_approval",
    })
    db.users.reserve_balance(user_id, amount + fee)

    return {
        "withdrawal_id": withdrawal_id,
        "amount": amount,
        "fee": fee,
        "estimated_processing_time": "15-30 minutes",
    }

Rust

fn validate_withdrawal_request(
    user_id: &str,
    destination_address: &str,
    amount: u64,
    db: &Db,
) -> Result {
    let user = db.get_user(user_id).ok_or("User not found")?;
    if !user.is_kyc_verified {
        return Err("User not KYC verified".into());
    }
    if user.is_blocked || user.withdrawals_suspended {
        return Err("User account restricted".into());
    }
    if amount == 0 {
        return Err("Invalid withdrawal amount".into());
    }

    let fee = calculate_withdrawal_fee(amount);
    if user.available_balance < amount + fee {
        return Err("Insufficient balance".into());
    }
    if !is_valid_tari_address(destination_address) {
        return Err("Invalid Tari address format".into());
    }

    let daily_limit = get_daily_withdrawal_limit(user.kyc_level);
    let today_withdrawals = get_today_withdrawals(user_id);
    if today_withdrawals + amount > daily_limit {
        return Err("Daily withdrawal limit exceeded".into());
    }

    let withdrawal_id = generate_unique_id();
    db.save_withdrawal(&withdrawal_id, user_id, destination_address, amount, fee)?;
    db.reserve_balance(user_id, amount + fee)?;

    Ok(WithdrawalReceipt { withdrawal_id, amount, fee })
}

PHP

function validateWithdrawalRequest(string $userId, string $destinationAddress, float $amount, PDO $db): array {
    $user = getUser($db, $userId);
    if (!$user) throw new RuntimeException('User not found');
    if (!$user['is_kyc_verified']) throw new RuntimeException('User not KYC verified');
    if ($user['is_blocked'] || $user['withdrawals_suspended']) throw new RuntimeException('User account restricted');

    if ($amount <= 0) throw new InvalidArgumentException('Invalid withdrawal amount');

    $minimumWithdrawal = getMinimumWithdrawalAmount();
    if ($amount < $minimumWithdrawal) {
        throw new RuntimeException("Amount below minimum withdrawal: {$minimumWithdrawal} XTM");
    }

    $fee = calculateWithdrawalFee($amount);
    if ($user['available_balance'] < $amount + $fee) {
        throw new RuntimeException('Insufficient balance');
    }

    if (!isValidTariAddress($destinationAddress)) {
        throw new RuntimeException('Invalid Tari address format');
    }

    $dailyLimit = getDailyWithdrawalLimit($user['kyc_level']);
    $todayWithdrawals = getTodayWithdrawals($db, $userId);
    if ($todayWithdrawals + $amount > $dailyLimit) {
        throw new RuntimeException('Daily withdrawal limit exceeded');
    }

    $withdrawalId = generateUniqueId();
    saveWithdrawal($db, $withdrawalId, $userId, $destinationAddress, $amount, $fee);
    reserveUserBalance($db, $userId, $amount + $fee);

    return [
        'withdrawal_id' => $withdrawalId,
        'amount' => $amount,
        'fee' => $fee,
        'estimated_processing_time' => '15-30 minutes',
    ];
}

2. Process Approved Withdrawals

🔒 Cold Storage Operation: This step requires access to the cold storage wallet. Ensure proper security protocols are followed.

Pseudocode

FUNCTION processApprovedWithdrawal(withdrawalRecord):
    // Step 1: Final validation checks
    IF (withdrawalRecord.status != "approved"):
        THROW "Withdrawal not approved for processing"

    // Step 2: Connect to cold storage wallet
    coldWallet = connectToColdWallet()
    IF (NOT coldWallet.isOnline()):
        THROW "Cold wallet not accessible"

    // Step 3: Sync wallet and verify balance
    coldWallet.sync()
    currentBalance = coldWallet.getBalance()
    totalRequired = withdrawalRecord.amount + withdrawalRecord.fee

    IF (currentBalance.available < totalRequired):
        THROW "Insufficient cold wallet balance"

    // Step 4: Create and validate payment recipient
    recipient = {
        address: withdrawalRecord.destinationAddress,
        amount: withdrawalRecord.amount,
        fee_per_gram: 25,
        payment_type: "ONE_SIDED",
        payment_id: withdrawalRecord.id
    }

    // Step 5: Execute the transaction
    updateWithdrawalStatus(withdrawalRecord.id, "processing")

    response = coldWallet.transfer({ recipients: [recipient] })
    result = response.results[0]

    IF (NOT result.is_success):
        THROW "Transaction failed: " + result.failure_message

    // Step 6: Update database records
    updateWithdrawalStatus(withdrawalRecord.id, "completed")
    deductReservedBalance(withdrawalRecord.userId, totalRequired)

    // Step 7: Notifications
    notifyUserWithdrawalCompleted(withdrawalRecord.userId, result.transaction_id)

    RETURN {
        success: true,
        transactionId: result.transaction_id
    }

grpcurl

# Check cold wallet balance before transferring
grpcurl -plaintext localhost:18143 tari.rpc.Wallet/GetState

# Execute a one-sided transfer to the destination address
grpcurl -plaintext -d '{
  "recipients": [{
    "address": "PLACEHOLDER_destination_address",
    "amount": "PLACEHOLDER_amount_in_microxtm",
    "fee_per_gram": 25,
    "payment_type": "ONE_SIDED"
  }]
}' localhost:18143 tari.rpc.Wallet/Transfer

Node.js

async function processApprovedWithdrawal(withdrawal, coldWallet, db) {
  if (withdrawal.status !== 'approved') {
    throw new Error('Withdrawal not approved for processing');
  }

  await coldWallet.sync();
  const balance = await coldWallet.getBalance();
  const totalRequired = withdrawal.amount + withdrawal.fee;

  if (balance.available < totalRequired) {
    throw new Error('Insufficient cold wallet balance');
  }

  await db.withdrawals.updateStatus(withdrawal.id, 'processing');

  const response = await coldWallet.transfer({
    recipients: [{
      address: withdrawal.destinationAddress,
      amount: withdrawal.amount,
      feePerGram: 25,
      paymentType: 'ONE_SIDED',
      paymentId: withdrawal.id,
    }],
  });

  const result = response.results[0];
  if (!result.isSuccess) {
    throw new Error(`Transaction failed: ${result.failureMessage}`);
  }

  await db.withdrawals.updateStatus(withdrawal.id, 'completed');
  await db.users.deductReservedBalance(withdrawal.userId, totalRequired);

  return { success: true, transactionId: result.transactionId };
}

Python

def process_approved_withdrawal(withdrawal, cold_wallet, db):
    if withdrawal.status != "approved":
        raise RuntimeError("Withdrawal not approved for processing")

    cold_wallet.sync()
    balance = cold_wallet.get_balance()
    total_required = withdrawal.amount + withdrawal.fee

    if balance.available < total_required:
        raise RuntimeError("Insufficient cold wallet balance")

    db.withdrawals.update_status(withdrawal.id, "processing")

    response = cold_wallet.transfer(recipients=[{
        "address": withdrawal.destination_address,
        "amount": withdrawal.amount,
        "fee_per_gram": 25,
        "payment_type": "ONE_SIDED",
        "payment_id": withdrawal.id,
    }])

    result = response.results[0]
    if not result.is_success:
        raise RuntimeError(f"Transaction failed: {result.failure_message}")

    db.withdrawals.update_status(withdrawal.id, "completed")
    db.users.deduct_reserved_balance(withdrawal.user_id, total_required)

    return {"success": True, "transaction_id": result.transaction_id}

Rust

async fn process_approved_withdrawal(
    withdrawal: &WithdrawalRecord,
    cold_wallet: &mut ColdWallet,
    db: &Db,
) -> Result {
    if withdrawal.status != "approved" {
        return Err("Withdrawal not approved for processing".into());
    }

    cold_wallet.sync().await?;
    let balance = cold_wallet.get_balance().await?;
    let total_required = withdrawal.amount + withdrawal.fee;

    if balance.available < total_required {
        return Err("Insufficient cold wallet balance".into());
    }

    db.update_withdrawal_status(&withdrawal.id, "processing").await?;

    let response = cold_wallet.transfer(vec![Recipient {
        address: withdrawal.destination_address.clone(),
        amount: withdrawal.amount,
        fee_per_gram: 25,
        payment_type: PaymentType::OneSided,
        payment_id: withdrawal.id.clone(),
    }]).await?;

    let result = &response.results[0];
    if !result.is_success {
        return Err(format!("Transaction failed: {}", result.failure_message));
    }

    db.update_withdrawal_status(&withdrawal.id, "completed").await?;
    db.deduct_reserved_balance(&withdrawal.user_id, total_required).await?;

    Ok(TransferResult { success: true, transaction_id: result.transaction_id.clone() })
}

PHP

function processApprovedWithdrawal(array $withdrawal, ColdWallet $coldWallet, PDO $db): array {
    if ($withdrawal['status'] !== 'approved') {
        throw new RuntimeException('Withdrawal not approved for processing');
    }

    $coldWallet->sync();
    $balance = $coldWallet->getBalance();
    $totalRequired = $withdrawal['amount'] + $withdrawal['fee'];

    if ($balance['available'] < $totalRequired) {
        throw new RuntimeException('Insufficient cold wallet balance');
    }

    updateWithdrawalStatus($db, $withdrawal['id'], 'processing');

    $response = $coldWallet->transfer([[
        'address' => $withdrawal['destination_address'],
        'amount' => $withdrawal['amount'],
        'fee_per_gram' => 25,
        'payment_type' => 'ONE_SIDED',
        'payment_id' => $withdrawal['id'],
    ]]);

    $result = $response['results'][0];
    if (!$result['is_success']) {
        throw new RuntimeException('Transaction failed: ' . $result['failure_message']);
    }

    updateWithdrawalStatus($db, $withdrawal['id'], 'completed');
    deductReservedBalance($db, $withdrawal['user_id'], $totalRequired);

    return ['success' => true, 'transaction_id' => $result['transaction_id']];
}