Appearance
POST /api/withdraw
Browser-facing withdrawal endpoint on evm-bank (the money bank). The UI calls it directly — game-server no longer drives settlement or on-chain writes.
See the Chain of Custody ADR and the Custody Layer decision for the edge case this endpoint's forced path accepts and the contract change that removes it.
Auth
Dual auth model — voluntary vs forced:
Voluntary (UI-initiated)
Authorization: Bearer <JWT>
User chose to stand up. evm-bank verifies the JWT against the shared JWKS and linkage-checks the destination wallet.
Forced (server-initiated)
x-service-key: <service key> + body { forced: true, userId }
Triggered by game-server's sit-out timeout or by admin escape paths. Bypasses the linkage check — funds route to the last-permit signer for (userId, tableId).
CORS
Browser-facing for the voluntary path. CORS allowlist mirrors /api/deposit.
Request body
json
{
"tableId": "room-abc-123",
"destinationWallet": "0xabc…",
"forced": false,
"userId": "…"
}| Field | Required | Notes |
|---|---|---|
tableId | always | The room the user is withdrawing from. |
destinationWallet | voluntary: optional; forced: ignored | If omitted on voluntary, evm-bank defaults to the wallet that signed the last confirmed deposit for (userId, tableId). Checked against the user's linked wallets via auth-server. |
forced | forced only | Must be exactly true. Service-key auth required. |
userId | forced only | The user whose seat to clear. |
Processing
Forced variant
When forced: true + service-key auth, steps change:
- Skip JWT verification — userId comes from body.
- Skip the linkage check — destination is always the last-permit signer.
- Everything else identical.
Used by sit-out timeout (game-server → evm-bank) and admin-initiated escape flows. The UI never sets forced: true.
Idempotency
idempotencyKey = keccak256(userId : tableId : historyHash : chipAmount).
A second call with the same key:
confirmed→ returns the cached{ status: 'confirmed', txHash, replay: true }.submitted/pending→ returns the in-flight state.failed→ returns409 { status: 'failed', error }.
Settle-withdrawal callback failure mode
If the callback to game-server fails (game-server down, 5xx, network partition), the on-chain withdrawal still succeeded. The withdrawal_intents row stays in confirmed status until a retry worker (to be added alongside the existing credit-chips reconciler) picks it up. Until then, the user sees their chips gone from the UI (player_left broadcast never arrived) but the on-chain tx is final.
Response
Success
json
{
"status": "confirmed",
"txHash": "0x…",
"destination": "0x…",
"amount": 150,
"idempotencyKey": "0x…"
}Errors
| Status | error | Meaning |
|---|---|---|
| 400 | missing_table_id | Body is missing tableId. |
| 400 | wallet_not_linked | Voluntary path — destinationWallet is not one of the user's linked wallets. |
| 400 | nothing_to_withdraw | Player's current chipBalance is 0. |
| 400 | failed | On-chain submission reverted. error carries the revert reason. |
| 401 | missing_bearer_token | Voluntary path — no Authorization: Bearer header. |
| 401 | invalid_token | Voluntary path — JWT failed JWKS verification. |
| 401 | forced_withdraw_requires_service_key | Forced path — missing or bad x-service-key. |
| 404 | table_not_found | Unknown tableId. |
| 404 | not_at_table | No table_players row for (userId, tableId). |
| 409 | table_frozen | Room is in frozen status. |
| 409 | failed (replay of failed) | Previous attempt with the same idempotency key failed. |
| 503 | chain_not_configured | evm-bank is missing its chain RPC configuration. |
| 503 | no_active_deployment | No active contract deployment. |
| 503 | auth_service_not_configured | evm-bank is missing its auth-service configuration. |
Observability
The endpoint emits a single admin.withdraw OTel span per request with the following attributes (see apps/evm-bank/src/routes/withdraw.ts):
withdraw.userId— JWTsub(voluntary) or bodyuserId(forced)withdraw.tableIdwithdraw.forced— booleanwithdraw.table_idwithdraw.player_idwithdraw.chip_amountwithdraw.destinationauth.linked— boolean, or'skipped_forced'on forced pathwithdraw.history_hashwithdraw.idempotency_keywithdraw.tx_hash— populated after enqueueAndWaitwithdraw.result— one ofconfirmed,replay_confirmed,wallet_not_linked,nothing_to_withdraw,failed,room_not_found,not_at_table,table_frozen
The settle-withdrawal callback to game-server emits an outbound HTTP client span via @opentelemetry/instrumentation-undici — visible as a child of admin.withdraw.
See also
POST /api/deposit— the companion endpoint for funding.- Chain of Custody ADR — the design rules for the voluntary vs forced split.
- Custody Layer decision — how forced flows route through on-chain custody, removing the forced-to-unlinked-wallet edge case.
- Source:
apps/evm-bank/src/routes/withdraw.ts