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