Withdraw Flow
Overview
DF withdrawals use a two-step flow:
- Backend signs — call
POST /spot/withdraw/request. The backend reserves the funds (freezes them inspot_balances) and signs an EIP-712SpotReleaseFundstyped-data message. - User submits on-chain — the caller passes the returned
signature,deadline, andamounttoZtdxSpotVault.withdraw()on BSC. The vault verifies the signature and transfers DF to the caller.
No funds leave the vault until step 2. The user controls when (and whether) to execute the on-chain step within the deadline window.
Lifecycle
1. POST /spot/withdraw/request
──────────────▶ Backend
│ (atomic DB transaction)
├─ spot_balances: available -= amount, frozen += amount
└─ spot_withdrawals: status = "signed", stores nonce + signature
│
▼ returns { id, signature, nonce, deadline, vault_address, ... }
2. User submits on-chain
──────────────▶ ZtdxSpotVault.withdraw(token, amount, deadline, signature)
│ vault verifies EIP-712 signature
│ vault transfers DF to msg.sender
└─ emits SpotWithdrawal(account, token, amount)
3. Backend listener (≥ 20 confirmations)
├─ spot_withdrawals.status = "confirmed"
└─ spot_balances.frozen -= amount
If the user does not submit on-chain before deadline, the expiry reaper marks the record expired and returns the funds to available. See Expiry Reaper below.
EIP-712 Domain & Primary Type
The backend signs using the following EIP-712 domain:
| Field | Value |
|---|---|
name | "ZTDX Spot Vault" |
version | "1" |
chainId | 97 (BSC Testnet) |
verifyingContract | 0x4Fe0b354c5865ee9deb979a99030d757ae47664a |
Primary type — SpotReleaseFunds:
struct SpotReleaseFunds {
address account; // caller's address (msg.sender on-chain)
address token; // DF token address
uint256 value; // amount in wei (DF has 18 decimals)
uint256 nonce; // per-(account, chain) monotonic nonce
uint256 deadline; // unix seconds; tx must execute before this
}
The account field is bound to the authenticated user's address. A third party cannot replay the signature for a different recipient — the vault checks msg.sender == account.
Vault ABI (withdraw function)
function withdraw(
address token, // DF token address
uint256 amount, // wei
uint256 deadline, // unix seconds (from response)
bytes calldata signature // 65-byte 0x-prefixed EIP-712 sig (from response)
) external;
Withdrawal Status
status | Meaning |
|---|---|
signed | Backend signed the EIP-712 release message. Funds are frozen. Awaiting on-chain submission. |
confirmed | On-chain SpotWithdrawal event observed (≥ 20 BSC confirmations). Funds have left the vault. |
expired | Signature deadline elapsed before on-chain submission. Funds returned to available. |
See also: Enums → Withdrawal Status.
Expiry Reaper
A background reaper task scans for signed withdrawal records whose deadline has passed. When it finds one:
spot_withdrawals.statusis set toexpired.spot_balances.frozenis decremented andspot_balances.availableis incremented by the frozen amount.
The reaper interval is controlled by SPOT_WITHDRAW_NONCE_TTL_SECS (currently 86 400 seconds = 24 hours). A deadline-passed signature is also rejected on-chain by the vault, so expired records cannot be submitted even before the reaper runs.
Code Example: complete a withdrawal end-to-end
import time
import requests
from web3 import Web3
BASE_URL = "https://api-sepolia.p99.world/api/v1"
JWT = "your_jwt_token"
BSC_RPC = "https://data-seed-prebsc-1-s1.binance.org:8545"
PRIVATE_KEY = "0x..." # caller's private key
DF_TOKEN = Web3.to_checksum_address("0x8063a43ed88397c1B10DA23dcC60ba1E7A0Bf555")
DF_DECIMALS = 18
# ── Step 1: request a withdrawal signature ───────────────────────────────────
resp = requests.post(
f"{BASE_URL}/spot/withdraw/request",
headers={"Authorization": f"Bearer {JWT}", "Content-Type": "application/json"},
json={"token": "DF", "amount": "100"},
timeout=5,
)
resp.raise_for_status()
sig_data = resp.json()
withdrawal_id = sig_data["id"]
signature = sig_data["signature"]
deadline = sig_data["deadline"]
vault_address = Web3.to_checksum_address(sig_data["vault_address"])
amount_decimal = sig_data["amount"]
print(f"Withdrawal id : {withdrawal_id}")
print(f"Deadline : {deadline} (expires in ~{deadline - int(time.time())} s)")
# ── Step 2: call vault.withdraw() on-chain ───────────────────────────────────
VAULT_ABI = [
{
"name": "withdraw",
"type": "function",
"inputs": [
{"name": "token", "type": "address"},
{"name": "amount", "type": "uint256"},
{"name": "deadline", "type": "uint256"},
{"name": "signature", "type": "bytes"},
],
"outputs": [],
"stateMutability": "nonpayable",
}
]
w3 = Web3(Web3.HTTPProvider(BSC_RPC))
account = w3.eth.account.from_key(PRIVATE_KEY)
vault = w3.eth.contract(address=vault_address, abi=VAULT_ABI)
amount_wei = int(float(amount_decimal) * 10 ** DF_DECIMALS)
tx = vault.functions.withdraw(
DF_TOKEN,
amount_wei,
deadline,
bytes.fromhex(signature.removeprefix("0x")),
).build_transaction({
"from": account.address,
"nonce": w3.eth.get_transaction_count(account.address),
"gas": 200_000,
"gasPrice": w3.to_wei("5", "gwei"),
"chainId": 97,
})
signed_tx = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
print(f"On-chain tx : {tx_hash.hex()}")
# ── Step 3: poll GET /spot/withdrawals/:id until confirmed ───────────────────
for attempt in range(60): # poll for up to 5 minutes
row = requests.get(
f"{BASE_URL}/spot/withdrawals/{withdrawal_id}",
headers={"Authorization": f"Bearer {JWT}"},
timeout=5,
).json()
print(f" attempt {attempt + 1}/60 — status={row['status']}")
if row["status"] in ("confirmed", "expired"):
break
time.sleep(5)
if row["status"] == "confirmed":
print(f"Withdrawal confirmed! block={row['block_number']} tx={row['tx_hash']}")
else:
print(f"Withdrawal ended with status={row['status']}")
Note:
amount_weimust be the exact value in wei that matches what the backend signed (i.e.amount_decimal * 10**18). Any mismatch causes the vault to reject the signature.
See also: POST /spot/withdraw/request, GET /spot/withdrawals, GET /spot/withdrawals/:id, General Info.