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>
95 lines
3.2 KiB
Python
95 lines
3.2 KiB
Python
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
|
|
from app.rounds.config import get_round_config
|
|
from app.rounds.events import broadcaster
|
|
from app.wallet.balance import recompute_balance
|
|
from app.wallet.hd import derive_user_key
|
|
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_signed_transaction
|
|
|
|
|
|
class WithdrawalError(ApiError):
|
|
pass
|
|
|
|
|
|
async def request_withdrawal(
|
|
session: AsyncSession, client: ElectrumClient, user: User, external_address: str, amount_sats: int
|
|
) -> Withdrawal:
|
|
config = await get_round_config(session)
|
|
if amount_sats < config.bet_amount_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(
|
|
select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None))
|
|
)
|
|
).all()
|
|
if sum(u.amount_sats for u in unspent) < amount_sats:
|
|
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())
|
|
utxos = [Utxo(u.txid, u.vout, u.amount_sats) for u in unspent]
|
|
|
|
try:
|
|
built = build_signed_transaction(
|
|
signing_key=user_key,
|
|
from_script=from_script,
|
|
utxos=utxos,
|
|
to_address=external_address,
|
|
amount_sats=amount_sats,
|
|
change_address=user.address,
|
|
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
|
)
|
|
except InsufficientFundsError as exc:
|
|
raise WithdrawalError(exc.code, str(exc)) from exc
|
|
|
|
await client.broadcast(built.raw_hex)
|
|
|
|
spent_by_key = {(u.txid, u.vout): u for u in unspent}
|
|
for spent in built.spent_utxos:
|
|
spent_by_key[(spent.txid, spent.vout)].spent_txid = built.txid
|
|
await recompute_balance(session, user.id)
|
|
|
|
withdrawal = Withdrawal(
|
|
user_id=user.id,
|
|
external_address=external_address,
|
|
amount_requested_sats=amount_sats,
|
|
amount_sent_sats=built.recipient_sats,
|
|
txid=built.txid,
|
|
status="broadcast",
|
|
)
|
|
session.add(withdrawal)
|
|
await session.flush()
|
|
session.add(
|
|
PendingTransaction(
|
|
kind="withdrawal",
|
|
withdrawal_id=withdrawal.id,
|
|
user_id=user.id,
|
|
current_txid=built.txid,
|
|
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
|
raw_tx_hex=built.raw_hex,
|
|
status="pending",
|
|
)
|
|
)
|
|
await write_audit_log(
|
|
session,
|
|
"withdrawal_sent",
|
|
{"txid": built.txid, "amount_sent_sats": built.recipient_sats, "external_address": external_address},
|
|
user_id=user.id,
|
|
)
|
|
|
|
await session.commit()
|
|
await session.refresh(withdrawal)
|
|
broadcaster.publish() # balance just went "pending" — nudge the dashboard to refetch
|
|
return withdrawal
|