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:
2026-07-26 21:44:39 +02:00
co-authored by Claude Opus 5
parent 7048fe7ea6
commit 0cf35147ad
13 changed files with 254 additions and 31 deletions
+2
View File
@@ -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):
+47
View File
@@ -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())
+8 -3
View File
@@ -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
View File
@@ -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()
+8 -3
View File
@@ -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,
+4 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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');
+109
View File
@@ -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';
}
+13 -3
View File
@@ -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]
+9 -4
View File
@@ -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)