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 Conflictif a delay already exists - Takes effect immediately on creation
- User receives email notification
Reading Current Delay
GET /wapi/v1/capital/withdrawals/delayReturns:
current_withdrawal_delay_hours— the active delaypending_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:
- The new delay activates only after the previous delay period expires
- During the transition period, the old delay continues to apply
Example attack scenario this prevents:
- Attacker compromises account (has credentials + 2FA device)
- Attacker changes 24-hour delay → 0 hours
- Without staggered activation: attacker withdraws immediately
- 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
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 valuewithdrawal_delay_hours_latest_enabled_at— when that value becomes activewithdrawal_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 aboveBypass Conditions Summary
| Condition | Delayed? |
|---|---|
| No delay configured | No |
| Internal transfer to own subaccount | No |
| Fiat account withdrawal | No |
| Delay set to 0 hours | No |
| Whitelisted address held longer than delay duration | No |
| Everything else | Yes |
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:
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
| Event | Template | Info Sent |
|---|---|---|
| Delay enabled | d-21fe268474374a868094807ab3e29a51 | New delay duration, effective-at timestamp |
| Withdrawal scheduled | — | Release timestamp (when trigger_at is set) |
Key Files
| File | Purpose |
|---|---|
core/types/src/models/withdrawal_delay.rs | WithdrawalDelay struct definition |
api/types/src/withdrawal_delay.rs | API request/response types |
api/src/routes/withdrawal.rs | API endpoints (GET/POST/PUT) |
store/src/withdrawal_delay.rs | DB operations (insert, update, get) |
store/src/withdrawal.rs | trigger_at calculation at withdrawal creation |
store/migrations/20250903101932_withdrawal_delay_table.sql | Table schema |
store/migrations/20250618175344_withdrawal_trigger_at.sql | Added trigger_at column |
clients/sendgrid/src/lib.rs | Email notification templates |