Skip to content

Withdrawal Delay

TL;DR

  • Withdrawal delay = user-configurable security feature that adds a time hold before withdrawals are processed
  • Max: 1,440 hours (60 days). Requires 2FA to create or update.
  • Anti-tampering: Reducing a delay doesn't take effect immediately — it activates only after the previous delay period expires. This prevents an attacker from compromising an account, dropping the delay to 0, and withdrawing immediately.
  • Whitelisted addresses that have been in the address book longer than the delay duration bypass the delay
  • Internal transfers and fiat withdrawals are never delayed
  • Completely separate from the travel rule — travel rule is regulatory compliance, withdrawal delay is user-opted security

How It Works

When a user configures a withdrawal delay, every qualifying withdrawal gets a trigger_at timestamp set to NOW() + delay_hours. The withdrawal processor only picks up withdrawals where trigger_at <= NOW() or trigger_at IS NULL.

Creating a Delay

POST /wapi/v1/capital/withdrawals/delay
{
  "withdrawal_delay_hours": 24,
  "two_factor_token": "123456"
}
  • Requires 2FA verification
  • Max: 1,440 hours (60 days)
  • Returns 409 Conflict if a delay already exists
  • Takes effect immediately on creation
  • User receives email notification

Reading Current Delay

GET /wapi/v1/capital/withdrawals/delay

Returns:

  • current_withdrawal_delay_hours — the active delay
  • pending_withdrawal_delay_hours — a scheduled future delay (if being changed)
  • pending_withdrawal_delay_hours_enabled_at — when the pending delay activates

Returns 204 No Content if no delay is configured.

Updating a Delay

PUT /wapi/v1/capital/withdrawals/delay
{
  "withdrawal_delay_hours": 48,
  "two_factor_token": "123456"
}

This is where the anti-tampering logic kicks in — see next section.


Anti-Tampering: Staggered Activation

This is the most important security property of the system. When a delay is updated, the new value does not take effect immediately. Instead:

  1. The new delay activates only after the previous delay period expires
  2. During the transition period, the old delay continues to apply

Example attack scenario this prevents:

  1. Attacker compromises account (has credentials + 2FA device)
  2. Attacker changes 24-hour delay → 0 hours
  3. Without staggered activation: attacker withdraws immediately
  4. With staggered activation: the 0-hour delay won't activate for 24 hours — giving the real owner time to notice the email notification and lock the account

Database Design

sql
CREATE TABLE withdrawal_delay (
    id                                    SERIAL PRIMARY KEY,
    user_id                               INTEGER UNIQUE NOT NULL REFERENCES users(id),
    withdrawal_delay_hours_latest         INTEGER NOT NULL,
    withdrawal_delay_hours_latest_enabled_at TIMESTAMP NOT NULL,
    withdrawal_delay_hours_previous       INTEGER
);
  • withdrawal_delay_hours_latest — the most recently set delay value
  • withdrawal_delay_hours_latest_enabled_at — when that value becomes active
  • withdrawal_delay_hours_previous — the prior delay (used during the transition window)

The get_withdrawal_delay_by_user_id query uses SQL CASE logic to compute which delay is currently active vs pending based on whether _enabled_at has passed.


What Gets Delayed

The trigger_at calculation happens at withdrawal creation time via SQL logic in store/src/withdrawal.rs. The decision tree:

User has no delay configured?
  → trigger_at = NULL (no delay)

Internal transfer to user's own subaccount?
  → trigger_at = NULL (no delay)

Withdrawal to system fiat account address?
  → trigger_at = NULL (no delay)

Latest delay is active (enabled_at < NOW)?
  → Delay is 0 hours?
      → trigger_at = NULL (no delay)
  → Address is whitelisted AND whitelisted for longer than the delay duration?
      → trigger_at = NULL (no delay)
  → Otherwise:
      → trigger_at = NOW() + delay_hours

Latest delay is NOT yet active (still in transition)?
  → Use previous delay hours with the same logic above

Bypass Conditions Summary

ConditionDelayed?
No delay configuredNo
Internal transfer to own subaccountNo
Fiat account withdrawalNo
Delay set to 0 hoursNo
Whitelisted address held longer than delay durationNo
Everything elseYes

The whitelisted address bypass is important — it means addresses you've had in your address book for a long time (longer than your delay) are trusted and don't incur the wait. But a newly added address will always be subject to the full delay.


Processing Delayed Withdrawals

The withdrawal processor (get_withdrawal_with_signature_threshold) fetches withdrawals with:

sql
AND (w.trigger_at <= $4 OR w.trigger_at IS NULL)

Where $4 is NOW(). Withdrawals with a future trigger_at simply sit in the database until their time comes.


Email Notifications

EventTemplateInfo Sent
Delay enabledd-21fe268474374a868094807ab3e29a51New delay duration, effective-at timestamp
Withdrawal scheduledRelease timestamp (when trigger_at is set)

Key Files

FilePurpose
core/types/src/models/withdrawal_delay.rsWithdrawalDelay struct definition
api/types/src/withdrawal_delay.rsAPI request/response types
api/src/routes/withdrawal.rsAPI endpoints (GET/POST/PUT)
store/src/withdrawal_delay.rsDB operations (insert, update, get)
store/src/withdrawal.rstrigger_at calculation at withdrawal creation
store/migrations/20250903101932_withdrawal_delay_table.sqlTable schema
store/migrations/20250618175344_withdrawal_trigger_at.sqlAdded trigger_at column
clients/sendgrid/src/lib.rsEmail notification templates