Skip to main content

Deposits & Withdrawals

Collateral lives in Sparky's on-chain Vault contract on each supported chain. Deposits are plain contract calls that the backend observes; withdrawals require a backend-issued EIP-712 signature so the Vault can verify the amount against your off-chain balance.

All endpoints on this page are under /api/v1 and require Authorization: Bearer <JWT> from the EIP-712 login. API-key sessions cannot withdraw (403 Forbidden).

Amounts

ContextFormatExample
REST request / responseHuman-readable decimal string"100.5"
Contract callsInteger in token units (USDT has 6 decimals)100500000
on-chain units = REST amount × 10^6

Only USDT is accepted as collateral today.

Deposit

Client
├─① POST /api/v1/deposit/prepare → Vault address + token address
├─② ERC-20 approve(vault_address, amount) (on-chain)
├─③ Vault.deposit(amount, referralCode) (on-chain) → emits Deposit
│ └─ backend indexer credits `available`
└─④ GET /api/v1/deposit/history → confirm it landed

1. Prepare

POST /api/v1/deposit/prepare
Content-Type: application/json
{ "token": "USDT", "amount": "100" }
FieldTypeRequiredNotes
tokenstringyesOnly "USDT"
amountstringyesHuman-readable USDT

Response — 200

{
"contract_address": "0xVaultContractAddress",
"token_address": "0xUSDTContractAddress",
"amount": "100",
"estimated_gas": 120000
}

2 – 3. On-chain calls

const amountWei = BigInt(Math.floor(parseFloat(amount) * 1e6));

const usdt = new ethers.Contract(token_address, ERC20_ABI, signer);
await (await usdt.approve(contract_address, amountWei)).wait();

// referralCode: bytes32(0) when you have none
const vault = new ethers.Contract(contract_address, VAULT_ABI, signer);
await (await vault.deposit(amountWei, ethers.ZeroHash)).wait();

The backend polls chain events roughly every block (about 12 s cadence); the balance appears once the Deposit event is indexed.

4. History

GET /api/v1/deposit/history
{
"deposits": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"token": "USDT",
"amount": "100.000000",
"tx_hash": "0xabc123...",
"status": "confirmed",
"created_at": 1700000000
}
]
}

Most recent 100 rows, newest first. status is confirmed once credited; created_at is Unix seconds.

Withdraw

Client
├─① POST /api/v1/withdraw/request → freezes funds, returns EIP-712 signature
├─② Vault.withdraw(user, amount, nonce, expiry, backend_signature) (on-chain, 1 h validity)
├─③ POST /api/v1/withdraw/{id}/confirm { tx_hash }
└─④ backend sees Withdraw event → frozen balance released, status = confirmed

1. Request

POST /api/v1/withdraw/request
Content-Type: application/json
{ "token": "USDT", "amount": "50" }

Response — 200

{
"withdraw_id": "550e8400-e29b-41d4-a716-446655440001",
"token": "0xUSDTContractAddress",
"amount": "50000000",
"backend_signature": "0x1234...abcd",
"nonce": 3,
"expiry": 1700003600,
"vault_address": "0xVaultContractAddress"
}
FieldNotes
amountAlready in on-chain units (× 10^6) — pass straight to the contract
nonceRead from Vault.withdrawNonces(user); single-use
expirynow + 3600 s; the signature is dead after this
backend_signatureEIP-712 signature over Withdraw(address user,uint256 amount,uint256 nonce,uint256 deadline)

Balance effect at request time: available -= X, frozen += X.

2. On-chain call

const vault = new ethers.Contract(vault_address, VAULT_ABI, signer);
await vault.withdraw(userAddress, amount, nonce, expiry, backend_signature);

3. Confirm

POST /api/v1/withdraw/{withdraw_id}/confirm
{ "tx_hash": "0xdef456..." }

Only valid while the record is in signed state. Moves it to submitted; the indexer moves it to confirmed when the Withdraw event lands.

Cancel

DELETE /api/v1/withdraw/{withdraw_id}/cancel

Only for signed records. Releases the frozen amount immediately (frozen -= X, available += X). Unsubmitted requests also expire automatically after 1 h (a 60 s sweeper marks them expired and releases funds).

Query

GET /api/v1/withdraw/{withdraw_id}
GET /api/v1/withdraw/history
GET /api/v1/withdraw/limit
{
"withdrawals": [
{
"id": "550e8400-...",
"token": "USDT",
"amount": "50.000000",
"nonce": 3,
"expiry": 1700003600,
"backend_signature": "0x1234...abcd",
"tx_hash": "0xdef456...",
"status": "confirmed",
"created_at": 1700000000
}
]
}
StatusMeaning
signedSignature issued, waiting for the on-chain call (1 h)
submittedtx_hash received, waiting for confirmation
confirmedWithdraw event indexed, funds left the system
cancelledCancelled by the user
failedOn-chain transaction failed
expiredSignature expired, funds unfrozen

Balance model

FieldMeaning
availableUsable for new orders or withdrawals
frozenLocked by open-order margin or an in-flight withdrawal
totalavailable + frozen
withdrawable = available + min(unrealized_pnl, 0)

Open losses reduce what you can withdraw; the request is rejected with 422 insufficient_balance (with available, frozen, unrealized_pnl, withdrawable, requested in details) if you ask for more.

Errors

HTTPerrorCause
400Bad amount, unsupported token
401Missing / expired JWT
403forbiddenAPI-key session attempted a withdrawal
404Unknown withdraw id
422insufficient_balanceRequested more than withdrawable
400withdrawal_expiredSignature past expiry
400invalid_statusConfirm / cancel on a record that is not signed

Vault interface (relevant parts)

event Deposit(address indexed user, uint256 amount, bytes32 referralCode);
event Withdraw(address indexed user, uint256 amount, uint256 nonce);

function deposit(uint256 amount, bytes32 referralCode) external;
function withdraw(address user, uint256 amount, uint256 nonce, uint256 expiry, bytes calldata signature) external;
function getBalance(address user) external view returns (uint256);
function withdrawNonces(address user) external view returns (uint256);

Notes:

  • Only one signed withdrawal may exist at a time; wait for confirmation or cancel before requesting another.
  • The indexer scans at most 1000 blocks per pass; during congestion crediting can lag.
  • Each nonce is single-use, which is what makes a stale signature unreplayable.