Answer user-facing API failures with a machine-readable error code
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.<code>` 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.<code>` 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.<code>` 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):
|
||||
|
||||
@@ -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.<code>`), 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())
|
||||
@@ -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,
|
||||
|
||||
+11
-3
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+9
-4
@@ -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)
|
||||
|
||||
+7
-6
@@ -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)
|
||||
|
||||
|
||||
+3
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+24
-1
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user