Skip to content

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": "…"
}
FieldRequiredNotes
tableIdalwaysThe room the user is withdrawing from.
destinationWalletvoluntary: optional; forced: ignoredIf 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.
forcedforced onlyMust be exactly true. Service-key auth required.
userIdforced onlyThe user whose seat to clear.

Processing ​

Forced variant ​

When forced: true + service-key auth, steps change:

  1. Skip JWT verification — userId comes from body.
  2. Skip the linkage check — destination is always the last-permit signer.
  3. 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 → returns 409 { 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 ​

StatuserrorMeaning
400missing_table_idBody is missing tableId.
400wallet_not_linkedVoluntary path — destinationWallet is not one of the user's linked wallets.
400nothing_to_withdrawPlayer's current chipBalance is 0.
400failedOn-chain submission reverted. error carries the revert reason.
401missing_bearer_tokenVoluntary path — no Authorization: Bearer header.
401invalid_tokenVoluntary path — JWT failed JWKS verification.
401forced_withdraw_requires_service_keyForced path — missing or bad x-service-key.
404table_not_foundUnknown tableId.
404not_at_tableNo table_players row for (userId, tableId).
409table_frozenRoom is in frozen status.
409failed (replay of failed)Previous attempt with the same idempotency key failed.
503chain_not_configuredevm-bank is missing its chain RPC configuration.
503no_active_deploymentNo active contract deployment.
503auth_service_not_configuredevm-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 — JWT sub (voluntary) or body userId (forced)
  • withdraw.tableId
  • withdraw.forced — boolean
  • withdraw.table_id
  • withdraw.player_id
  • withdraw.chip_amount
  • withdraw.destination
  • auth.linked — boolean, or 'skipped_forced' on forced path
  • withdraw.history_hash
  • withdraw.idempotency_key
  • withdraw.tx_hash — populated after enqueueAndWait
  • withdraw.result — one of confirmed, 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