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)