Skip to main content

Withdraw Flow

Overview

DF withdrawals use a two-step flow:

  1. Backend signs — call POST /spot/withdraw/request. The backend reserves the funds (freezes them in spot_balances) and signs an EIP-712 SpotReleaseFunds typed-data message.
  2. User submits on-chain — the caller passes the returned signature, deadline, and amount to ZtdxSpotVault.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:

FieldValue
name"ZTDX Spot Vault"
version"1"
chainId97 (BSC Testnet)
verifyingContract0x4Fe0b354c5865ee9deb979a99030d757ae47664a

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

statusMeaning
signedBackend signed the EIP-712 release message. Funds are frozen. Awaiting on-chain submission.
confirmedOn-chain SpotWithdrawal event observed (≥ 20 BSC confirmations). Funds have left the vault.
expiredSignature 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.status is set to expired.
  • spot_balances.frozen is decremented and spot_balances.available is 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_wei must 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.