From 0cf35147ad20885954ef9500362b4db24f404c9b Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 26 Jul 2026 21:44:39 +0200 Subject: [PATCH] Answer user-facing API failures with a machine-readable error code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard now speaks seven languages but every failure path still showed the API's raw English text ("insufficient balance", "current password is incorrect"), which is the most frequent and least forgiving part of the UI to leave untranslated. Rather than teach the API about locales, it keeps answering in one language and hands the client something to translate: `detail` becomes {code, message, params}, where message stays English for non-dashboard consumers (curl, tests) and code maps onto `error.` in i18n.js. An unknown code falls back to message, so a client older or newer than the server degrades to English instead of a blank toast. Domain exceptions (BetError, WithdrawalError) subclass the new ApiError and carry the code from where the failure actually happens; str(exc) is still the English message, so existing tests keep matching on it. Interpolated values travel in params rather than baked into the English sentence — amounts as *_sats, from which the frontend derives a *_plm sibling, so each language can place them wherever its grammar wants. admin.js reads detail.message defensively: the admin endpoints still return a bare string, but the shared auth dependencies now return the structured form. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 + app/api/errors.py | 47 +++++++++++++++ app/api/routes/bets.py | 11 +++- app/api/routes/users.py | 14 ++++- app/api/routes/withdrawals.py | 11 +++- app/auth/dependencies.py | 7 ++- app/auth/routes.py | 13 ++-- app/bets/service.py | 13 ++-- app/static/admin.js | 4 +- app/static/app.js | 25 +++++++- app/static/i18n.js | 109 ++++++++++++++++++++++++++++++++++ app/wallet/psbt_builder.py | 16 ++++- app/withdrawals/service.py | 13 ++-- 13 files changed, 254 insertions(+), 31 deletions(-) create mode 100644 app/api/errors.py diff --git a/CLAUDE.md b/CLAUDE.md index 862f578..5be8a97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,6 +138,8 @@ So worst case (last bet confirms right at the deadline) is ~3 block times end-to - **Every language must have exactly the same key set.** There is no fallback beyond `en`, and a missing key renders as the raw key string. - `/admin` is intentionally **not** translated (operator-facing, Italian only), and neither is `/guida` (serves `docs/guida-utente.md`). +**API error contract** (`app/api/errors.py`): the API is single-language by design. User-facing failures answer with a structured `detail` — `{"code", "message", "params"}` — where `message` is English for non-dashboard consumers and `code` is what the frontend maps onto `error.` in `i18n.js` (falling back to `message` for an unknown code). Domain exceptions (`BetError`, `WithdrawalError`) subclass `ApiError` and carry the code from where the failure actually happens; `str(exc)` is still the English message. When adding a user-facing error: give it a code, add `error.` to all 7 languages, and pass interpolated values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling automatically) rather than baking them into the English text. + ## Admin dashboard and test UI Two static single-page apps, served directly by FastAPI (`app/main.py` mounts `app/static/` and adds a dedicated `GET /admin` route) — no build step, no framework. Each page's HTML/CSS/JS are separate files (`index.html`/`style.css`/`app.js`, `admin.html`/`admin.css`/`admin.js`), served as plain static files (no bundler): diff --git a/app/api/errors.py b/app/api/errors.py new file mode 100644 index 0000000..e3e9830 --- /dev/null +++ b/app/api/errors.py @@ -0,0 +1,47 @@ +"""Machine-readable error codes for user-facing API failures. + +The dashboard is multilingual (app/static/i18n.js) but the API is not: every +message produced here stays English. What travels alongside it is a stable +`code` the client maps onto its own translated string (`error.`), falling +back to `message` for any code it doesn't recognize — so a non-dashboard +consumer (curl, tests, a future client) still gets something readable without +having to know the code table. + +`detail` is therefore an object rather than the FastAPI-default bare string: + + {"code": "insufficient_balance", "message": "insufficient balance", "params": {}} + +`params` carries the values interpolated into the message (amounts, limits) so +the translated string can place them wherever its own grammar needs them, +instead of the client having to parse them back out of the English text. +""" + +from typing import Any + +from fastapi import HTTPException + + +class ApiError(Exception): + """Domain-layer error carrying the code the client will translate. + + Subclassed per domain (BetError, WithdrawalError) so services keep raising + their own exception type. `str(exc)` is still the plain English message. + """ + + def __init__(self, code: str, message: str, **params: Any) -> None: + super().__init__(message) + self.code = code + self.message = message + self.params = params + + def as_detail(self) -> dict[str, Any]: + return {"code": self.code, "message": self.message, "params": self.params} + + +def http_error(status_code: int, code: str, message: str, **params: Any) -> HTTPException: + """HTTPException whose detail is the structured object described above.""" + return HTTPException(status_code, ApiError(code, message, **params).as_detail()) + + +def from_api_error(status_code: int, exc: ApiError) -> HTTPException: + return HTTPException(status_code, exc.as_detail()) diff --git a/app/api/routes/bets.py b/app/api/routes/bets.py index 4d34b8f..f671ff4 100644 --- a/app/api/routes/bets.py +++ b/app/api/routes/bets.py @@ -1,7 +1,8 @@ -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, Request, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession +from app.api.errors import from_api_error, http_error from app.auth.dependencies import get_current_user from app.bets.service import BetError, place_bet from app.db.models import User @@ -25,13 +26,17 @@ async def create_bet( ) -> BetResponse: listener = request.app.state.electrum_listener if listener.client is None: - raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "not connected to the network, try again shortly") + raise http_error( + status.HTTP_503_SERVICE_UNAVAILABLE, + "network_unavailable", + "not connected to the network, try again shortly", + ) async with request.app.state.user_locks.acquire(user.id): try: participant = await place_bet(session, listener.client, user) except BetError as exc: - raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + raise from_api_error(status.HTTP_400_BAD_REQUEST, exc) from exc return BetResponse( round_id=participant.round_id, diff --git a/app/api/routes/users.py b/app/api/routes/users.py index b5da306..3f46c06 100644 --- a/app/api/routes/users.py +++ b/app/api/routes/users.py @@ -1,8 +1,9 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, status from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.errors import http_error from app.auth.dependencies import get_current_user from app.auth.security import hash_password, verify_password from app.db.models import Round, RoundParticipant, User @@ -56,9 +57,16 @@ async def change_password( admin-only /admin/users/{id}/reset-password (which is for a user who's actually locked out and can't provide it).""" if not verify_password(body.current_password, user.password_hash): - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "current password is incorrect") + raise http_error( + status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect" + ) if len(body.new_password) < _MIN_PASSWORD_LENGTH: - raise HTTPException(status.HTTP_400_BAD_REQUEST, f"new password must be at least {_MIN_PASSWORD_LENGTH} characters") + raise http_error( + status.HTTP_400_BAD_REQUEST, + "password_too_short", + f"new password must be at least {_MIN_PASSWORD_LENGTH} characters", + minimum=_MIN_PASSWORD_LENGTH, + ) user.password_hash = hash_password(body.new_password) await session.commit() diff --git a/app/api/routes/withdrawals.py b/app/api/routes/withdrawals.py index 0cb3182..0927d98 100644 --- a/app/api/routes/withdrawals.py +++ b/app/api/routes/withdrawals.py @@ -1,7 +1,8 @@ -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, Request, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession +from app.api.errors import from_api_error, http_error from app.auth.dependencies import get_current_user from app.db.models import User from app.db.session import get_session @@ -31,7 +32,11 @@ async def create_withdrawal( ) -> WithdrawalResponse: listener = request.app.state.electrum_listener if listener.client is None: - raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "not connected to the network, try again shortly") + raise http_error( + status.HTTP_503_SERVICE_UNAVAILABLE, + "network_unavailable", + "not connected to the network, try again shortly", + ) async with request.app.state.user_locks.acquire(user.id): try: @@ -39,7 +44,7 @@ async def create_withdrawal( session, listener.client, user, body.external_address, body.amount_sats ) except WithdrawalError as exc: - raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + raise from_api_error(status.HTTP_400_BAD_REQUEST, exc) from exc return WithdrawalResponse( txid=withdrawal.txid, diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index d6a1d69..04503f4 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -1,8 +1,9 @@ -from fastapi import Depends, HTTPException, Request, status +from fastapi import Depends, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.errors import http_error from app.auth.security import decode_access_token from app.db.models import User from app.db.session import get_session @@ -17,11 +18,11 @@ async def get_current_user( try: user_id = decode_access_token(credentials.credentials) except Exception as exc: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token") from exc + raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "invalid token") from exc user = await session.scalar(select(User).where(User.id == user_id)) if user is None: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found") + raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "user not found") return user diff --git a/app/auth/routes.py b/app/auth/routes.py index 6cfcc6a..c9b58a9 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -1,9 +1,10 @@ -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, Request, status from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from app.api.errors import http_error from app.auth.security import create_access_token, hash_password, verify_password from app.db.models import User from app.db.session import get_session @@ -30,7 +31,7 @@ async def register( ) -> TokenResponse: existing = await session.scalar(select(User).where(User.username == body.username)) if existing is not None: - raise HTTPException(status.HTTP_409_CONFLICT, "username already taken") + raise http_error(status.HTTP_409_CONFLICT, "username_taken", "username already taken") password_hash = hash_password(body.password) @@ -54,7 +55,11 @@ async def register( request.app.state.electrum_listener.address_for_new_user(user.id, user.address) return TokenResponse(access_token=create_access_token(user.id), address=user.address) - raise HTTPException(status.HTTP_409_CONFLICT, "could not allocate a derivation index, retry") + raise http_error( + status.HTTP_409_CONFLICT, + "derivation_index_conflict", + "could not allocate a derivation index, retry", + ) class LoginRequest(BaseModel): @@ -66,5 +71,5 @@ class LoginRequest(BaseModel): async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)) -> TokenResponse: user = await session.scalar(select(User).where(User.username == body.username)) if user is None or not verify_password(body.password, user.password_hash): - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials") + raise http_error(status.HTTP_401_UNAUTHORIZED, "invalid_credentials", "invalid credentials") return TokenResponse(access_token=create_access_token(user.id), address=user.address) diff --git a/app/bets/service.py b/app/bets/service.py index 50a31a5..0e819b6 100644 --- a/app/bets/service.py +++ b/app/bets/service.py @@ -4,6 +4,7 @@ from embit import script from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.errors import ApiError from app.audit.log import write_audit_log from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent from app.electrum.client import ElectrumClient @@ -15,18 +16,18 @@ from app.wallet.hd import derive_pool_address, derive_user_key from app.wallet.psbt_builder import BuiltTransaction, InsufficientFundsError, Utxo, build_signed_transaction -class BetError(Exception): +class BetError(ApiError): pass async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant: round_ = await open_new_round_if_needed(session) if round_ is None: - raise BetError("no round open right now, please try again shortly") + raise BetError("no_round_open", "no round open right now, please try again shortly") config = await get_round_config(session) if not round_accepts_bets(round_, config.round_duration_seconds): - raise BetError("the current round is closing, please try again shortly") + raise BetError("round_closing", "the current round is closing, please try again shortly") already_playing = await session.scalar( select(RoundParticipant).where( @@ -34,7 +35,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) - ) ) if already_playing is not None: - raise BetError("you already have an active bet in the current round") + raise BetError("already_betting", "you already have an active bet in the current round") bet_amount = config.bet_amount_sats @@ -44,7 +45,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) - ) ).all() if sum(u.amount_sats for u in unspent) < bet_amount: - raise BetError("insufficient balance") + raise BetError("insufficient_balance", "insufficient balance", required_sats=bet_amount) user_key = derive_user_key(user.derivation_index) from_script = script.p2wpkh(user_key.to_public()) @@ -61,7 +62,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) - fee_rate_sat_vb=config.fee_rate_sat_vb, ) except InsufficientFundsError as exc: - raise BetError(str(exc)) from exc + raise BetError(exc.code, str(exc)) from exc await client.broadcast(built.raw_hex) diff --git a/app/static/admin.js b/app/static/admin.js index 1fb2e9a..dfc5023 100644 --- a/app/static/admin.js +++ b/app/static/admin.js @@ -26,7 +26,9 @@ async function callAdmin(method, path, body) { const headers = { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken }; const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined }); const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data.detail || res.statusText); + // detail is a bare string on the admin endpoints, but the shared dependencies + // (auth) answer with the structured {code, message} form of app/api/errors.py. + if (!res.ok) throw new Error(data.detail?.message || data.detail || res.statusText); return data; } diff --git a/app/static/app.js b/app/static/app.js index 00ccf22..00f3534 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -51,10 +51,33 @@ async function call(method, path, body) { clearTimeout(timeoutId); } const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data.detail || res.statusText); + if (!res.ok) throw new Error(apiErrorMessage(data.detail) || res.statusText); return data; } +// The API is single-language by design: it answers with a stable machine code +// plus an English message (app/api/errors.py), and picking the words is the +// client's job. Unknown code (older/newer server, an endpoint not converted +// yet) → show the English message rather than nothing. +function apiErrorMessage(detail) { + if (!detail) return null; + if (typeof detail === 'string') return detail; // endpoints still returning a bare string + return tOrNull('error.' + detail.code, errorParams(detail.params)) || detail.message || null; +} + +// Amounts cross the wire in sats (`*_sats`); every translated string wants PLM, +// so expose both and let each language's phrasing pick. Done generically here +// so a new *_sats param needs no client change. +function errorParams(params) { + const out = { ...(params || {}) }; + for (const [key, value] of Object.entries(params || {})) { + if (key.endsWith('_sats') && typeof value === 'number') { + out[key.slice(0, -5) + '_plm'] = value / SATS_PER_PLM; + } + } + return out; +} + function switchTab(name) { document.getElementById('tab-login').classList.toggle('active', name === 'login'); document.getElementById('tab-register').classList.toggle('active', name === 'register'); diff --git a/app/static/i18n.js b/app/static/i18n.js index db0b8f5..a5ad134 100644 --- a/app/static/i18n.js +++ b/app/static/i18n.js @@ -113,6 +113,23 @@ const TRANSLATIONS = { 'toast.roundWon': 'You won round #{id}! +{amount} PLM', 'toast.withdrawSent': 'Withdrawal sent.', + // Keyed by the `code` the API returns in its structured error detail + // (app/api/errors.py). A code with no entry here falls back to the API's + // own English `message`, so a new server-side error is never a blank toast. + 'error.network_unavailable': 'Not connected to the network, please try again shortly.', + 'error.no_round_open': 'No round is open right now, please try again shortly.', + 'error.round_closing': 'The current round is closing, please try again shortly.', + 'error.already_betting': 'You already have an active bet in the current round.', + 'error.insufficient_balance': 'Insufficient balance.', + 'error.amount_below_network_fee': 'The amount is too small to cover the network fee.', + 'error.amount_below_minimum': 'The minimum withdrawal amount is {minimum_plm} PLM.', + 'error.current_password_incorrect': 'The current password is incorrect.', + 'error.password_too_short': 'The new password must be at least {minimum} characters.', + 'error.username_taken': 'This username is already taken.', + 'error.invalid_credentials': 'Wrong username or password.', + 'error.derivation_index_conflict': 'Registration failed, please try again.', + 'error.session_expired': 'Session expired, please log in again.', + 'loading.creating': 'Creating…', 'loading.loggingIn': 'Logging in…', 'loading.sendingBet': 'Placing bet…', @@ -228,6 +245,20 @@ const TRANSLATIONS = { 'toast.roundWon': 'Hai vinto il round #{id}! +{amount} PLM', 'toast.withdrawSent': 'Withdrawal inviato.', + 'error.network_unavailable': 'Nessuna connessione alla rete, riprova tra poco.', + 'error.no_round_open': 'Nessun round aperto in questo momento, riprova tra poco.', + 'error.round_closing': 'Il round corrente si sta chiudendo, riprova tra poco.', + 'error.already_betting': 'Hai già una bet attiva nel round corrente.', + 'error.insufficient_balance': 'Saldo insufficiente.', + 'error.amount_below_network_fee': "L'importo è troppo basso per coprire la fee di rete.", + 'error.amount_below_minimum': "L'importo minimo di prelievo è {minimum_plm} PLM.", + 'error.current_password_incorrect': 'La password attuale non è corretta.', + 'error.password_too_short': 'La nuova password deve avere almeno {minimum} caratteri.', + 'error.username_taken': 'Questo username è già stato preso.', + 'error.invalid_credentials': 'Username o password errati.', + 'error.derivation_index_conflict': 'Registrazione non riuscita, riprova.', + 'error.session_expired': 'Sessione scaduta, accedi di nuovo.', + 'loading.creating': 'Creazione…', 'loading.loggingIn': 'Accesso…', 'loading.sendingBet': 'Invio bet…', @@ -343,6 +374,20 @@ const TRANSLATIONS = { 'toast.roundWon': '¡Has ganado la ronda #{id}! +{amount} PLM', 'toast.withdrawSent': 'Retiro enviado.', + 'error.network_unavailable': 'Sin conexión con la red, inténtalo de nuevo en un momento.', + 'error.no_round_open': 'No hay ninguna ronda abierta ahora mismo, inténtalo de nuevo en un momento.', + 'error.round_closing': 'La ronda actual se está cerrando, inténtalo de nuevo en un momento.', + 'error.already_betting': 'Ya tienes una apuesta activa en la ronda actual.', + 'error.insufficient_balance': 'Saldo insuficiente.', + 'error.amount_below_network_fee': 'El importe es demasiado pequeño para cubrir la comisión de red.', + 'error.amount_below_minimum': 'El importe mínimo de retiro es {minimum_plm} PLM.', + 'error.current_password_incorrect': 'La contraseña actual no es correcta.', + 'error.password_too_short': 'La nueva contraseña debe tener al menos {minimum} caracteres.', + 'error.username_taken': 'Este nombre de usuario ya está en uso.', + 'error.invalid_credentials': 'Usuario o contraseña incorrectos.', + 'error.derivation_index_conflict': 'No se pudo completar el registro, inténtalo de nuevo.', + 'error.session_expired': 'Sesión caducada, vuelve a iniciar sesión.', + 'loading.creating': 'Creando…', 'loading.loggingIn': 'Entrando…', 'loading.sendingBet': 'Enviando apuesta…', @@ -458,6 +503,20 @@ const TRANSLATIONS = { 'toast.roundWon': 'Vous avez gagné le round #{id} ! +{amount} PLM', 'toast.withdrawSent': 'Retrait envoyé.', + 'error.network_unavailable': 'Pas de connexion au réseau, réessayez dans un instant.', + 'error.no_round_open': "Aucun round n'est ouvert pour le moment, réessayez dans un instant.", + 'error.round_closing': 'Le round en cours est en train de se fermer, réessayez dans un instant.', + 'error.already_betting': 'Vous avez déjà une mise active dans le round en cours.', + 'error.insufficient_balance': 'Solde insuffisant.', + 'error.amount_below_network_fee': 'Le montant est trop faible pour couvrir les frais de réseau.', + 'error.amount_below_minimum': 'Le montant minimum de retrait est de {minimum_plm} PLM.', + 'error.current_password_incorrect': "Le mot de passe actuel n'est pas correct.", + 'error.password_too_short': 'Le nouveau mot de passe doit comporter au moins {minimum} caractères.', + 'error.username_taken': "Ce nom d'utilisateur est déjà pris.", + 'error.invalid_credentials': "Nom d'utilisateur ou mot de passe incorrect.", + 'error.derivation_index_conflict': "L'inscription a échoué, veuillez réessayer.", + 'error.session_expired': 'Session expirée, veuillez vous reconnecter.', + 'loading.creating': 'Création…', 'loading.loggingIn': 'Connexion…', 'loading.sendingBet': 'Envoi de la mise…', @@ -573,6 +632,20 @@ const TRANSLATIONS = { 'toast.roundWon': 'Du hast Runde #{id} gewonnen! +{amount} PLM', 'toast.withdrawSent': 'Auszahlung gesendet.', + 'error.network_unavailable': 'Keine Verbindung zum Netzwerk, bitte versuche es gleich erneut.', + 'error.no_round_open': 'Derzeit ist keine Runde offen, bitte versuche es gleich erneut.', + 'error.round_closing': 'Die laufende Runde wird gerade geschlossen, bitte versuche es gleich erneut.', + 'error.already_betting': 'Du hast bereits eine aktive Wette in der laufenden Runde.', + 'error.insufficient_balance': 'Nicht genügend Guthaben.', + 'error.amount_below_network_fee': 'Der Betrag ist zu klein, um die Netzwerkgebühr zu decken.', + 'error.amount_below_minimum': 'Der Mindestauszahlungsbetrag beträgt {minimum_plm} PLM.', + 'error.current_password_incorrect': 'Das aktuelle Passwort ist nicht korrekt.', + 'error.password_too_short': 'Das neue Passwort muss mindestens {minimum} Zeichen lang sein.', + 'error.username_taken': 'Dieser Benutzername ist bereits vergeben.', + 'error.invalid_credentials': 'Benutzername oder Passwort falsch.', + 'error.derivation_index_conflict': 'Registrierung fehlgeschlagen, bitte erneut versuchen.', + 'error.session_expired': 'Sitzung abgelaufen, bitte melde dich erneut an.', + 'loading.creating': 'Wird erstellt…', 'loading.loggingIn': 'Anmeldung…', 'loading.sendingBet': 'Wette wird gesendet…', @@ -688,6 +761,20 @@ const TRANSLATIONS = { 'toast.roundWon': 'Вы выиграли раунд #{id}! +{amount} PLM', 'toast.withdrawSent': 'Вывод средств отправлен.', + 'error.network_unavailable': 'Нет соединения с сетью, повторите попытку чуть позже.', + 'error.no_round_open': 'Сейчас нет открытого раунда, повторите попытку чуть позже.', + 'error.round_closing': 'Текущий раунд закрывается, повторите попытку чуть позже.', + 'error.already_betting': 'У вас уже есть активная ставка в текущем раунде.', + 'error.insufficient_balance': 'Недостаточно средств.', + 'error.amount_below_network_fee': 'Сумма слишком мала, чтобы покрыть комиссию сети.', + 'error.amount_below_minimum': 'Минимальная сумма вывода — {minimum_plm} PLM.', + 'error.current_password_incorrect': 'Текущий пароль указан неверно.', + 'error.password_too_short': 'Новый пароль должен содержать не менее {minimum} символов.', + 'error.username_taken': 'Это имя пользователя уже занято.', + 'error.invalid_credentials': 'Неверное имя пользователя или пароль.', + 'error.derivation_index_conflict': 'Не удалось завершить регистрацию, попробуйте ещё раз.', + 'error.session_expired': 'Сессия истекла, войдите снова.', + 'loading.creating': 'Создание…', 'loading.loggingIn': 'Вход…', 'loading.sendingBet': 'Отправка ставки…', @@ -803,6 +890,20 @@ const TRANSLATIONS = { 'toast.roundWon': '你赢得了第 {id} 回合!+{amount} PLM', 'toast.withdrawSent': '提现已发送。', + 'error.network_unavailable': '未连接到网络,请稍后重试。', + 'error.no_round_open': '当前没有开放的回合,请稍后重试。', + 'error.round_closing': '当前回合正在结束,请稍后重试。', + 'error.already_betting': '你在当前回合已有一笔有效下注。', + 'error.insufficient_balance': '余额不足。', + 'error.amount_below_network_fee': '金额太小,不足以支付网络手续费。', + 'error.amount_below_minimum': '最低提现金额为 {minimum_plm} PLM。', + 'error.current_password_incorrect': '当前密码不正确。', + 'error.password_too_short': '新密码长度至少需要 {minimum} 个字符。', + 'error.username_taken': '该用户名已被占用。', + 'error.invalid_credentials': '用户名或密码错误。', + 'error.derivation_index_conflict': '注册失败,请重试。', + 'error.session_expired': '会话已过期,请重新登录。', + 'loading.creating': '正在创建…', 'loading.loggingIn': '正在登录…', 'loading.sendingBet': '正在下注…', @@ -832,6 +933,14 @@ function t(key, params) { return interpolate(dict[key] ?? TRANSLATIONS.en[key] ?? key, params); } +// Like t(), but returns null instead of echoing the key back when nothing is +// defined for it — lets a caller fall back to a string of its own (e.g. the +// API's English message for an error code this build doesn't know yet). +function tOrNull(key, params) { + const str = TRANSLATIONS[currentLang]?.[key] ?? TRANSLATIONS.en[key]; + return str === undefined ? null : interpolate(str, params); +} + function currentDateLocale() { return DATE_LOCALES[currentLang] || 'en-US'; } diff --git a/app/wallet/psbt_builder.py b/app/wallet/psbt_builder.py index 251a8fe..84ddfd0 100644 --- a/app/wallet/psbt_builder.py +++ b/app/wallet/psbt_builder.py @@ -19,7 +19,13 @@ RBF_SEQUENCE = 0xFFFFFFFD class InsufficientFundsError(Exception): - pass + """`code` is the machine-readable identifier the API layer forwards to the + client so it can translate the failure (see app/api/errors.py); the message + itself stays English.""" + + def __init__(self, message: str, code: str = "insufficient_balance") -> None: + super().__init__(message) + self.code = code @dataclass @@ -80,7 +86,9 @@ def build_signed_transaction( fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb recipient_amount = amount_sats - fee if recipient_amount <= 0: - raise InsufficientFundsError("amount too small to cover the network fee") + raise InsufficientFundsError( + "amount too small to cover the network fee", code="amount_below_network_fee" + ) change = total_in - amount_sats # TransactionInput.txid is natural/display byte order (as in tx_hash from Electrum); @@ -145,7 +153,9 @@ def build_payout_transaction( fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change winner_amount = winner_share_sats - fee if winner_amount <= 0: - raise InsufficientFundsError("winner share too small to cover the network fee") + raise InsufficientFundsError( + "winner share too small to cover the network fee", code="winner_share_below_network_fee" + ) change = total_in - target vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected] diff --git a/app/withdrawals/service.py b/app/withdrawals/service.py index 578ba53..ba770c5 100644 --- a/app/withdrawals/service.py +++ b/app/withdrawals/service.py @@ -2,6 +2,7 @@ from embit import script from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.errors import ApiError from app.audit.log import write_audit_log from app.db.models import PendingTransaction, User, UtxoEvent, Withdrawal from app.electrum.client import ElectrumClient @@ -12,7 +13,7 @@ from app.wallet.hd import derive_user_key from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_signed_transaction -class WithdrawalError(Exception): +class WithdrawalError(ApiError): pass @@ -21,7 +22,11 @@ async def request_withdrawal( ) -> Withdrawal: config = await get_round_config(session) if amount_sats < config.bet_amount_sats: - raise WithdrawalError(f"amount below the minimum of {config.bet_amount_sats} sats") + raise WithdrawalError( + "amount_below_minimum", + f"amount below the minimum of {config.bet_amount_sats} sats", + minimum_sats=config.bet_amount_sats, + ) unspent = ( await session.scalars( @@ -29,7 +34,7 @@ async def request_withdrawal( ) ).all() if sum(u.amount_sats for u in unspent) < amount_sats: - raise WithdrawalError("insufficient balance") + raise WithdrawalError("insufficient_balance", "insufficient balance", required_sats=amount_sats) user_key = derive_user_key(user.derivation_index) from_script = script.p2wpkh(user_key.to_public()) @@ -46,7 +51,7 @@ async def request_withdrawal( fee_rate_sat_vb=config.fee_rate_sat_vb, ) except InsufficientFundsError as exc: - raise WithdrawalError(str(exc)) from exc + raise WithdrawalError(exc.code, str(exc)) from exc await client.broadcast(built.raw_hex)