Skip to main content

Withdraw

DF withdrawals are a two-step flow: the backend signs an EIP-712 release authorization, then the user submits the signed message on-chain to pull funds from the vault.

1. POST /spot/withdraw/request
───────────────▶ Backend

▼ (atomic)
spot_balances: available -= amount, frozen += amount
spot_withdrawals: status = "signed", store signature + nonce

▼ returns { signature, nonce, deadline, vault_address, ... }
2. User submits on-chain
user.wallet.signAndSend(ZtdxSpotVault.withdraw(token, amount, deadline, signature))

▼ vault verifies signature, transfers tokens
SpotWithdrawal event emitted

▼ backend listener (≥ 20 confirmations)
spot_withdrawals.status = "confirmed"
spot_balances.frozen -= amount

If the user does not submit on-chain within SPOT_WITHDRAW_NONCE_TTL_SECS (currently 24 h), a reaper task marks the row expired and returns the frozen funds to available. The signature is then unusable on-chain (deadline has passed).

Status Values

statusMeaning
signedSignature issued by the backend; funds are frozen. Awaiting on-chain submission.
confirmedOn-chain SpotWithdrawal observed. Funds left the vault.
expiredDeadline elapsed before submission. Funds returned to available.

Request a Withdrawal Signature

Reserves the funds, signs the release authorization, and returns the data the user needs to call the vault.

Authentication Required

JWT only. API-key callers receive 403 API Key permission denied.

Authorization: Bearer <JWT>
POST /spot/withdraw/request

Request Body

{ "token": "DF", "amount": "100" }
FieldTypeRequiredDescription
tokenstringYesMVP only supports DF.
amountstring / numberYesDecimal amount. Must be ≥ SPOT_WITHDRAW_MIN_AMOUNT_DF (currently 1).

Response — 200 OK

{
"id": "9f2a-...-uuid",
"nonce": 42,
"signature": "0x...65 bytes hex...",
"deadline": 1778402000,
"vault_address": "0x4Fe0b354c5865ee9deb979a99030d757ae47664a",
"chain_id": 97,
"amount": "100"
}
FieldTypeDescription
idstringUUID of the withdrawal row (use with GET /spot/withdrawals/:id).
noncenumberPer-(user, chain) monotonic nonce to prevent replay.
signaturestring65-byte 0x-prefixed EIP-712 signature. Pass to withdraw() verbatim.
deadlinenumberUnix seconds. The on-chain call must execute strictly before this.
vault_addressstringContract to call withdraw() on.
chain_idnumberNetwork the signature targets (97 = BSC Testnet).
amountstringEchoes the requested amount.

Error Responses

HTTPerrorCause
400unsupported token: <symbol>Token other than DF.
400amount below minimum 1Below SPOT_WITHDRAW_MIN_AMOUNT_DF.
400insufficient balanceSpot available < amount.
400invalid user addressAuthenticated address fails parsing (should not happen in practice).
403API Key permission denied: withdraws not allowedCaller authenticated via API Key.
503spot subsystem disabledServer has SPOT_ENABLED=false.
500internal / sign failed / signer unavailableServer error.

On-Chain Submission

The user calls the vault's withdraw method with the data returned above:

function withdraw(
address token, // DF token address
uint256 amount, // wei (DF = 18 decimals)
uint256 deadline, // from response, unix seconds
bytes calldata signature
) external;

The vault verifies the signature against the EIP-712 typed data:

SpotReleaseFunds(address account, address token, uint256 value, uint256 nonce, uint256 deadline)

with the domain ("ZTDX Spot Vault", "1", chainId=97, verifyingContract=vaultAddress). The account field equals the calling msg.sender, so a third party cannot replay the signature for a different recipient.


List My Withdrawals

GET /spot/withdrawals

Query Parameters

ParameterTypeDefaultDescription
statusstring(all)Filter: signed / confirmed / expired.
limitnumber501–200; clamped server-side.

Response — 200 OK

[
{
"id": "9f2a-...-uuid",
"token": "DF",
"amount": "100",
"fee": "0",
"chain_id": 97,
"nonce": 42,
"status": "confirmed",
"deadline": 1778402000,
"tx_hash": "0xabc...",
"block_number": 106400000,
"requested_at": 1778315530,
"confirmed_at": 1778316040
}
]
FieldTypeDescription
idstringWithdrawal UUID.
tokenstringAlways DF in MVP.
amountstringRequested amount.
feestringWithdrawal fee (currently 0).
chain_idnumberTarget chain.
noncenumberEIP-712 nonce.
statusstringsigned / confirmed / expired.
deadlinenumberUnix seconds — signature validity.
tx_hashstring | nullSet once status = confirmed.
block_numbernumber | nullSet once status = confirmed.
requested_atnumberUnix seconds — backend signed the message.
confirmed_atnumber | nullUnix seconds — listener observed the on-chain event.

Get One Withdrawal

GET /spot/withdrawals/:id

Returns the same WithdrawalView shape as above, scoped to the authenticated user's row. Returns 404 not found if the id does not belong to the caller.


Code Example

import requests

BASE = "https://api-sepolia.p99.world/api/v1"
JWT = "your_jwt_token"

# 1. Reserve + sign
sig = requests.post(
f"{BASE}/spot/withdraw/request",
headers={"Authorization": f"Bearer {JWT}", "Content-Type": "application/json"},
json={"token": "DF", "amount": "100"},
).json()
print(sig)
# { "id": "...", "nonce": 42, "signature": "0x...", "deadline": 1778402000, ... }

# 2. Submit on-chain (pseudo-code; use ethers/web3.py)
# vault.withdraw(DF_TOKEN, parse_wei(sig["amount"], 18), sig["deadline"], sig["signature"])

# 3. Poll status
import time
for _ in range(60):
row = requests.get(
f"{BASE}/spot/withdrawals/{sig['id']}",
headers={"Authorization": f"Bearer {JWT}"},
).json()
print(row["status"])
if row["status"] in ("confirmed", "expired"):
break
time.sleep(5)

Operational Notes

  • Each user has a per-chain monotonic nonce. Concurrent withdraws from the same user serialize on the spot-balance row lock (FOR UPDATE).
  • fee is currently always 0. The schema reserves the column for a future fee policy.
  • Native-token (BNB) withdrawals are not supported in MVP.