- Parameters/DEX_P2P -> Parameters/DEX_P2P_FiatOnOff (git mv, 5 files) — the folder is the P2P fiat on/off-ramp + order-book material, so the name now says what it is. - dApp/Betting dissolved: its subfolders lifted one level into dApp/ (Pari_P2P via git mv; the parallel session's untracked Pari_escrow via filesystem mv; Tote had already been lifted by that session). Empty Betting folder removed. Swept references with a FILESYSTEM grep rather than git grep, so untracked parallel files were covered too. Used the trailing-slash path form (DEX_P2P/ -> DEX_P2P_FiatOnOff/) so the substitution can't double-apply to the new name. Memory checked. Verified: all 527 links resolve. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| README.md | ||
P2P Order Book — Telegram Bot
Status: LIVE as @FiatMoneyExchangeBot, running as a self-healing Windows Scheduled Task DeviantOrderBookBot (auto-start at logon, survives reboots, restarts on crash; runs compiled dist/index.js via run-service.cmd, logs to logs/bot.log). Logic smoke-tested; typechecks.
Code location: telegram-orderbook/ (repo root, self-contained sub-project).
Date: 2026-07-02 (v2 — guided form, EUR/USD scope).
Markets: EUR/USD and EUR/RUB (multi-pair; add more via the QUOTES map in src/index.ts). Base is always EUR; the quote is chosen per order. See §6c.
Entry model: a bot-controlled guided form (buttons + prompts), so every order looks identical.
Trading layer: orders carry inventory that is reserved by a timed, two-party-confirmed deal handshake, with 5-star reputation, a cash+city option, and fixed or floating (central-bank ± offset) rates. See §6.
Relationship: the stripped-down catalogue / discovery layer (§3.1) of
Parameters/DEX_P2P_FiatOnOff/fiat-ramp-product.md.
Recent (built after v2, all LIVE — see code for detail):
- Full RU + EN i18n — per-user language (
/language, auto-offer on first/start), 368 keys × 2 locales insrc/locales/{en,ru}.ts,src/i18n.tst()/plural(); recipient-language DMs (dmLang);time.ts/format.tstakelang. Greek scaffolding not started. Terminology: order→«заявка», Cash→«Наличные» (label only; id staysCash). - Rate view curated to CBR·Yahoo·Tinkoff (Tinkoff shows real buy/sell) in
/rate; book header decluttered. - Recipient address book (
accountstable, multi-per-method incl. third parties; migrated frompayout_methods) — maker order picks which account receives (orders.account_id, snapshotted to deal); taker reuses saved accounts. - Text reviews (
reviewstable) after completed deals; optional per-deal minimum (orders.min_amount, wizard step + taker enforcement). - Mixed orders — receive & pay methods fully decoupled (supersedes the old single-method "settlement mode"). Each order carries two independent lists:
orders.recv_methods(JSON{method,account_id}[]— how the maker gets paid, receive-currency rails/cash) andorders.pay_methods(JSON{method}[]— how the maker pays out, pay-currency rails/cash; the taker supplies their own account for rails). There is no "same method" shortcut — the two legs are different currencies with disjoint rails (e.g. sell-EUR = receive RUB-by-bank + pay EUR-cash). Wizard = two currency-scoped multi-selects (recv → pay → city-if-cash → confirm); the taker single-selects one method per leg from what the maker offers (xr:/xp:;afterTakeAmount/afterRecvChosen/choosePay). Deal snapshotsmaker_recv_method+city. Legacymethod/account_idkept as denormalized primary; null arrays ⇒ legacy single-method behavior (no data migration). This also subsumes the settlement fix: takers can only request a pay method the maker actually offered. Non-makers still hit the request-access gate immediately on 🧾 Make order. - My Profile hub (
sendProfile, was/me) consolidates identity, phone, stats, rating, reviews, and a saved-payment-methods summary, with inline actions (Manage payment →sendPaymentMenu, Set phone, My reviews, Language). Phone stored inuser_profile(user_id,phone), captured via Telegram share-contact (message:contact) or typed. Bottom menu retired the standalone 💳 button and renamed 🏠 Home → 👤 My Profile (btn.profile; payment now reached from the profile //payment). Retiredbtn.pay/btn.homestill route from stale keyboards. - Full RU send/receive + role reframing: buy/sell/pay wording → получить/отправить across the make menu, Step 1/2, method steps, book columns; maker/taker → «Заявитель»/«Получатель» (taker sends first, so the anglicism-free label is a deliberate role name); account-holder wording split to «имя владельца счёта» to avoid collision.
- Legal notes in
/help: currency-exchange law (consultant.ru 173-ФЗ) + exchange≠transfer (VTB guide).
1. What was requested
"P2P order book on Telegram. Just an order book with entries / intents. No wiring with banking apps. 1 Telegram user can have 1 entry at a time on each side (e.g. selling EUR or buying EUR). Other users can private-message them."
Deliberately minimal: a directory of buy/sell intents, contact happens by DM, the bot brokers nothing.
2. What it is — and deliberately is not
| It is | It is not |
|---|---|
| a directory of buy/sell intents (discovery) | an escrow / custodian of funds |
a contact hand-off (@username / tg://user?id=) |
a party to any trade |
| a per-user, per-side order book with replace semantics | a converter / money transmitter |
| pure software, no funds ever touched | wired to any banking / payment rail |
This is the catalogue reduced to the bone — no escrow, no payment oracle, no compliance, no banking integration. Those are the engine of the larger fiat-ramp product and are explicitly out of scope here.
3. Core rule — one entry per side per user
Each Telegram user may hold one BUY and one SELL entry at a time. Posting again on the same side replaces the previous entry (it does not add a second).
Enforced structurally, not in application logic: the SQLite table uses a
composite PRIMARY KEY (user_id, side), and inserts use
ON CONFLICT (user_id, side) DO UPDATE. So the invariant can't be violated even
by a bug in the handler.
user 10can have(10, 'sell')and(10, 'buy')simultaneously — different sides, allowed.- A second
/sellfromuser 10overwrites(10, 'sell'); entry count stays at 2.
4. Data model
Single table, SQLite (orderbook.db, override with DB_PATH). Fields are
structured — the form fills each one, so orders are uniform:
CREATE TABLE orders (
user_id INTEGER NOT NULL, -- Telegram user id
username TEXT, -- @handle, may be null
display_name TEXT, -- "First Last", fallback for contact
side TEXT NOT NULL CHECK (side IN ('buy','sell')), -- relative to base (EUR)
base TEXT NOT NULL, -- 'EUR'
quote TEXT NOT NULL, -- 'USD'
amount REAL NOT NULL, -- amount of base (EUR), > 0
rate REAL NOT NULL, -- quote per 1 base (USD per 1 EUR), > 0
method TEXT NOT NULL, -- payment method (chosen from buttons / typed)
created_at INTEGER NOT NULL, -- unix seconds
PRIMARY KEY (user_id, side)
);
CREATE INDEX idx_orders_pair ON orders (base, quote, side);
The counter amount (e.g. USD received) is derived = amount × rate, not
stored — so it can never drift from the rate. base/quote are stored (not
hard-coded) so opening a second pair later is a constants change, not a schema
change.
5. The guided form (bot-controlled entry)
/sell (have EUR, want USD) or /buy (want EUR, pay USD) starts a short wizard.
State lives in a per-user grammy session (getSessionKey = from.id, so
drafts don't collide when several people fill the form in the same group):
- amount — text step; positive number of EUR.
- rate — text step; USD per 1 EUR.
- method — inline-button step. The
methodis where the MAKER receives funds — i.e. the rail the counterparty sends to — so the options key off the currency the maker receives (makerReceives(side) = sell→quote, buy→base), NOT the quote: Buy EUR (pay RUB) → EU rails (IBAN · Revolut · Wise · N26 · IRIS · Cash); Sell EUR (get RUB) → RU rails (SBP · Sberbank · Tinkoff · Alfa-Bank · VTB · Cash). Other → one free-text step, capped at 40 chars. Principle: each party only ever states how they want to RECEIVE (that's what the counterparty needs); how you send is your own business. The taker mirrors this viatrm:(takerReceives). - confirm — summary with the computed USD total + ✅ Confirm / ✖ Cancel buttons; only Confirm writes the row (upsert = replace-per-side).
All user text is HTML-escaped before rendering. Every posted order prints from
the same formatter, e.g. 🔴 SELL 500.00 EUR for 542.50 USD · 1.0850 · SEPA — @alice.
5c. Market reference rate & book navigation
- Reference rate (
src/rates.ts): a live, no-key, mid-market rate aggregated as the MEDIAN of several sources polled concurrently, chosen per pair so one bad/stale/throttled feed can't skew it (median of whatever responds):- EUR/USD — ECB/frankfurter.dev, open.er-api.com, fawaz currency-api (jsDelivr), floatrates (floatrates is best-effort).
- EUR/RUB — curated to 3 reliable sources: CBR (cbr-xml-daily.ru, the
central-bank anchor + official rate), Tinkoff (api.tinkoff.ru — the one source
with a real buy/sell spread, kept in
SourceQuote.buy/sell; its mid feeds the median), and Yahoo Finance (one international mid). ECB dropped RUB in 2022, so CBR — not ECB — is the anchor. Why only 3: RU per-bank feeds (Sberbank/Alfa/banki.ru/ sravni) are JS-rendered + bot-gated → only fragile scrapers, deliberately avoided; Google Finance has no public JSON. Wise/Coinbase/er-api/currency-api were dropped from RUB as redundant mids. 10-min cache, 5 s timeout, UA header (browser UA for Yahoo/Wise), stale-cache-on-failure. For floating orders the pair's central-bank rate (ECB / CBR) is tracked separately from the median. Shown on/book(median only, clean header) and a dedicated/ratecommand that lists each source — Tinkoff asbuy · sell, the central bank tagged (official), others as a single mid. The reference is an indicative mid; only Tinkoff exposes a real bid/ask.
- Cash / online filter: the book top row toggles All · 💵 Cash · 🌐 Online
(
bk:<quote>:<filter>; Cash =method==='Cash', Online = everything else), active one marked ●, with the header showing the active filter. So users who only want face-to-face cash meetups (or only online rails) see just those. - Interactive book (
/book): a reference-rate header, then a two-column tappable grid headed€ Buy EUR|$ Buy USD(two buttons per keyboard row — Telegram can't render real tables, so the 2-per-row inline keyboard IS the two columns). Columns are the TAKER's action, so a maker's order goes in the opposite column: a maker selling EUR (side='sell') appears under Buy EUR (taker gets EUR); a maker buying EUR / selling USD (side='buy') appears under Buy USD (taker gets USD). Each cell shows the amount in that column's currency — Buy EUR = base amount (EUR), Buy USD = base × rate (USD) — plus@rate(~marks floating), and stays tappable → detail. Uneven columns padded with·; header row shows per-column counts. Rate stays USD-per-EUR for comparability. (Detail view remains maker-oriented as the order's source of truth.) Cells carry currency symbols and 2-decimal values (amount in the column's currency, rate prefixed with the quote symbol) — e.g.€1,000.00 @₽89.50. - Column headers double as "post your own order" buttons (
new:<side>:<quote>): tapping € Buy EUR starts a BUY-EUR order, ₽ Buy RUB / $ Buy USD starts a SELL-EUR order, in that pair — jumping straight to the amount step. An empty book shows the same two create buttons instead of a dead end. - Tap an order → detail view (
callback o:<user_id>:<side>→ fresh DB lookup): the requested one-value-per-row layout —Sell: 200.00 EUR / For: 226.00 USD / Rate: 1.13 (−0.9% vs market) / Method: SEPA / Trader: @handle— plus a 💬 Message trader URL button (t.me/handle, or atg://user?id=fallback) and a 📖 Back to book button. If the order was removed meanwhile, the tap answers "That order was removed." - Sorted closest-to-reference first (
|rate − ref|ascending; falls back to price order when the reference is unavailable). Capped at 15 orders/side with an "…and N more" note (an honest, non-silent cap).
5b. Commands
| Command | Effect |
|---|---|
/sell |
Start the guided form to sell EUR for USD |
/buy |
Start the guided form to buy EUR with USD |
/book |
Show the order book, grouped by pair → side |
/mine |
Show caller's active orders |
/deals |
Show caller's active deals (requested/accepted) with countdown + confirm state |
/cancel |
Abort the form if mid-fill; else remove a posted order (/cancel buy|sell|all) |
/rate |
Live EUR/USD reference with the per-source breakdown + median |
/start, /help |
Usage |
Menu UI: the bot sets the bottom-left menu button to the commands list
(setChatMenuButton, restored on startup) and shows a persistent reply
keyboard (🧾 Make order · 📖 Book · 📋 My orders · 🤝 My deals · 💱 Rate · 💳 Payment · ❓ Help).
🧾 Make order opens a "what do you want to buy?" chooser (buyc:<cur>): Buy EUR
(→ buye:<quote> pay-with sub-choice → buy-EUR order), Buy USD / Buy RUB (→ sell-EUR
order in that pair). Replaces the old Sell/Buy buttons (the /sell /buy commands
remain as shortcuts). startNewOrder(side, quote) is the shared entry.
Button taps arrive as plain text and are routed before the wizard-step logic, so
they work from any state.
The command list is registered with Telegram on startup (setMyCommands) so it
appears in the client's command menu. Works in a private chat with the bot or in
any group the bot is added to.
6. Contact hand-off
The only "contact" the bot provides is a clickable link to the poster:
- has a username →
<a href="https://t.me/USERNAME">@USERNAME</a> - no username →
<a href="tg://user?id=ID">Display Name</a>(id deep-link fallback)
Rendering uses Telegram HTML parse mode with all user-supplied text escaped
(&, <, >) to prevent markup injection. Link previews are disabled on /book.
6b. Trading layer — timed deals, freeze, ratings, floating rate, cash
Added in the deals build. Design decisions (chosen by the operator): owner approves before anything freezes; both parties confirm completion; 5-star reputation.
Order inventory. An order has amount (total) and frozen. available = amount − frozen. The book and detail show available; detail shows total +
in-progress when frozen > 0.
Deal lifecycle (table deals, states requested → accepted → completed, plus
declined / expired / cancelled):
- Taker taps Start exchange → picks an amount (25/50/75/100% of available
quick-share buttons
tk:<value>, or ✏️ Other amount to type; ≤ available, clamped) → picks where they receive funds (trm:<method>in the currency they'll get —takerReceives= base for a sell-EUR order, quote for a buy-EUR order; RUB rails vs EU rails) → enters the recipient address (phone / IBAN / card / email; Cash skips it). Stored on the deal (taker_recv_method/taker_recv_value) and revealed to the maker once the taker has paid (formatTakerRecv). → picks a window: presets 1h / 3h / 12h / 24h, or 📅 Custom (a duration like90m/2h/3d, or an absolute meet date/timeYYYY-MM-DD HH:MM— good for cash meetups). Arequesteddeal is created (NO freeze yet) storingwindow_secXORmeet_at, and the owner is DM'd Accept/Decline with the proposed completion time. The effective rate is locked at request time. (time.tsparses/validates the window: 10 min–14 days, absolute must be >5 min ahead.) Timezone: an absolute meet time is resolved in the meet city's timezone (offlinecity-timezoneslookup → IANA zone;luxonfor DST-correct math), falling back to the server zone if the order has no city. The zone is stored on the deal (tz) so both parties always see the same wall-clock time for the meet place (e.g.Sun 5 Jul 18:00 GMT+2). Telegram exposes no user timezone, and relative windows need none — so city-derived is both sufficient and semantically correct (a meet time is local to where you meet). The taker can ✖ Withdraw a still-requesteddeal (dw:→cancelled). - Owner Accept →
freeze(amount), stateaccepted. The taker pays FIRST: they're DM'd the maker's payout details + ✅ I've sent the money (dc:) / ✖ Cancel; the maker is told to wait and only gets a Cancel button. Owner Decline →declined. - Taker taps "I've sent" →
taker_confirmed; now the maker is DM'd the taker's payout details + ✅ Completed. The maker's confirm is blocked until the taker has sent ("wait for the taker to send first"). This enforces the taker-pays-first order and reveals payout details sequentially (maker's on accept, taker's only after the taker pays). - Maker taps Completed →
finalize:amount −= deal.amount,frozen −= deal.amount(order deleted if drained), statecompleted; both DM'd 1–5⭐ rating buttons. Cancel (either party, while accepted) →unfreeze, statecancelled. All these actions are re-surfaced in/deals(one message per deal with its role-appropriate buttons viadealActionKeyboard), so an open deal is never a dead end even if the original DM scrolled away. - Sweeper (
setInterval60 s):requestedolder than 30 min →expired;acceptedpastexpires_at + 24 h→ auto-cancelled+unfreeze(releases stuck freezes). Window expiry itself does not force-cancel — parties confirm after it; the 24 h grace is the backstop.
Two paths, one deducts. Deduction of an order's listed amount happens ONLY
through the tracked Start exchange deal flow (taker amount → approve → freeze →
both confirm → finalize). The Message trader button is contact-only and does
NOT deduct — so a trade arranged purely by DM would leave the order listed at full
size. To keep the book honest, the maker has ✏️ Mark filled in /mine
(red:<side>:<base>:<quote> → enter an amount in the order's denom currency →
reduceOrder deducts it, up to the unfrozen available; closes the order if
drained). This is the maker-side answer to "how does the amount go down if the
taker never entered one".
Recipient accounts — address book (table accounts(id PK, user_id, method, value, name, created_at); migrated from the old one-per-method payout_methods, which is
dropped on first run). A user keeps multiple named recipient accounts — their own OR
a third party's they're paying — and several of the same method (e.g. two IBANs).
Each account = method (rail) + value (IBAN/phone/card) + recipient name (the
account-holder the counterparty sends to). Add flow: method → value → recipient name
(👤). Managed via 💳 Payment (/payment): list + 🗑 remove-by-id; each new entry is
a new row (not an overwrite).
- Maker side: the order picks which account receives — the wizard's receive step
shows the maker's saved accounts (♻️ reuse) or a rail to add a new one;
orders.account_idstores the choice (null for Cash). At deal creation the account is snapshotted into the deal (deals.maker_recv_value/name) so it's stable even if later edited/deleted. - Taker side: the take flow's receive step likewise offers saved accounts (
tacc:<id>, one-tap reuse) or a new entry (auto-saved to the book); snapshotted todeals.taker_recv_*. - On accept the taker is DM'd the maker's one chosen account (
formatMakerRecv); when the taker has paid, the maker is DM'd the taker's (formatTakerRecv). Never shown in the public book. Accessors:getAccounts/getAccount/addAccount/deleteAccount.
Ratings (table ratings, PK (deal_id, rater_id) → one per rater per deal,
re-rating overwrites). Only on completed deals; each party rates the other.
Trader shows ⭐ avg (count) · N trades on the order detail — N =
completedTradesForUser (completed deals as maker OR taker). /me shows your own
rating, completed trades, active orders, approval status, and ID. The web board's
Rating column also shows · Nt.
Text reviews (table reviews, PK (deal_id, reviewer_id) → one per reviewer per
deal, upsert). After a deal completes, the star-rating message carries a ✍️ Write a
review button (rv:<dealId>) → the user types a short note (≤300 chars, HTML-escaped)
→ addReview stores it with the reviewer's @username. On an order detail, a 📝
Reviews (N) button (rvw:<userId>) opens the trader's latest 10 — each shown as
⭐<stars> @reviewer · <date> + text (stars come from a LEFT JOIN on that reviewer's
rating for the same deal). /me shows your review count. Review-writing is its own
session state (session.review), cleared on menu tap / cancel.
Floating rate. rate_mode ∈ {fixed, floating}. Fixed: rate is the number.
Floating: rate is an offset added to the central-bank (ECB) rate — effective
rate is recomputed live from the ECB quote in the multi-source breakdown (falls
back to the median; a floating order whose rate can't be resolved is shown as
"floating" and can't be taken until it resolves). Offset may be negative.
Cash + city. Cash method opens a two-step country → city picker (src/cities.ts):
pick a country (🇬🇷/🇷🇺/🇨🇾/🇩🇪/🇪🇸/🇵🇹, or 🌍 Other / ⏭ Skip) → pick a curated popular city
or type your own. Typed input is canonicalized — normalized (diacritics stripped,
Latin+Cyrillic), matched against an alias table + Levenshtein fuzzy match, and snapped to
one canonical name so a place never fragments across spellings/scripts ("Athens" / "Athena"
/ "Athina" / "Афины" → Athens; "Москва" → Moscow). Genuinely new places pass through
title-cased. Callbacks cc:<code|__other|__skip|__back> and ci:<city|__other>; the city is
stored canonical (city), feeds cityToZone for meet-time resolution, and shows in the book.
New session state. Two flows share the per-user session: draft (order form,
now with rate_mode/rate_float/city steps) and take (entering a deal amount).
Caveat — not machine-tested: the live two-user tap-through (approve, dual confirm, rate) needs real humans; the DB state machine + money math + floating + ratings are unit-covered (see §9).
6e. Maker gating (approved makers only)
When ADMIN_ID is set, only approved users may POST orders (taking a deal is
open to everyone). canPostOrders(id) = ADMIN_ID===0 || isAdmin || isApprovedMaker,
enforced at the two draft creators (startWizard, startNewOrder) via
requireMaker. A blocked user gets a 🙋 Request access button → DMs the admin an
Approve / Deny (apr: / deny:); approval writes approved_makers and notifies
the user. Admin can also /approve <id>. /id reveals your Telegram ID (to set
ADMIN_ID). ADMIN_ID=0/unset ⇒ gating off (anyone posts). This underpins
taker-pays-first: makers are vetted, reputable users.
6c. Multi-pair (EUR/USD + EUR/RUB)
The bot supports multiple markets sharing base EUR; the quote is chosen per
order (QUOTES map in src/index.ts, each with its own payment methods and
central-bank source).
- Order key: primary key moved from
(user_id, side)to(user_id, side, base, quote)— a user can hold e.g. a sell EUR/USD and a sell EUR/RUB at once. (SQLite can't alter a PK in place; the migration drops & recreates the orders table — old single-pair orders were discarded, by choice.) - Amount presets: the amount step offers common buttons per currency
(
am:<value>) — EUR/USD100/200/300/500/1000, RUB5000/10000/15000/20000/30000/50000— plus ✏️ Other amount (type any) and Back. Typing a number still works too. - Rate step is two screens (not all-in-one): first Rate type? (
rm:fixed/rm:floating), then the matching preset ladder for that mode. - Rate picker (fixed): a symmetric ladder — ±2/1/0.5% presets paired per row
with the Market rate centred at the bottom, each labelled with the resulting
rate (
rp:<value>), plus ✏️ Custom rate (type an exact rate; typing also works). - Rate picker (floating): same symmetric ladder but each button is a margin over
the central-bank rate (
rf:<margin>), Market = CB, plus ✏️ Custom and manual typing. The margin type is per quote (floatIsAbsoluteinformat.ts): RUB = absolute (effective =CB + margin, e.g. CBR +0.50 ₽), USD/others = percent (CB × (1 + margin/100)).ratestores the margin (RUB rubles / else %); effective rate is recomputed live and the type is derived from the order's quote. Both fall back to type-a-number if no reference/CB rate is available. - Wizard: pick quote currency → amount unit → amount → rate → method → (payout / city) → confirm.
- Method ⇒ receiving details gate: after picking a non-cash method, confirm is
blocked until the maker has receiving details for that method. If they already
saved it (
hasPayoutFor), it skips to confirm; if they pick a method they haven't set, a payout step collects it (setPayoutMethod, ≥4 chars) and adds it to their payment profile, then confirms. Cash skips this (in-person → city step). Every step carries a ⬅ Back button. Steps are rendered by onepromptStep()used by forward transitions, Back, and Edit;prevStep()maps the step graph. - Edit from confirm: the confirm screen has ✅ Confirm · ✏️ Edit · ✖ Cancel.
Edit → pick a field (💶 Amount / 💱 Rate / 💳 Method) → change just that one and
return straight to the summary (via a
draft.editingflag: the field's normal "next step" is redirected toconfirm; Back during editing also returns to summary). The form is framed by intent (intentText), not raw side: asell-EUR order reads "buy RUB (pay EUR)", abuy-EUR order reads "buy EUR (pay RUB)" — so the "sell EUR" wording no longer confuses someone who tapped Buy RUB. - Order denomination (
denomon orders & deals): an order'samount/frozenare fixed in either EUR (denom='base') or the quote currency (denom='quote'), chosen at the amount step (au:base/au:quote). The other side is derived live from the rate (sides(denom, amt, eff)informat.ts: base = amt, quote = amt·rate; or quote = amt, base = amt÷rate). So a fixed 50 000 RUB order keeps the RUB fixed while EUR floats with the rate; a fixed 500 EUR order keeps EUR fixed while RUB floats. The distinction only bites for floating orders (fixed-rate orders have both sides constant). Freeze/finalize are unit-agnostic — they operate onamount/frozenin whateverdenomis, so the deal/inventory math is unchanged; only display/conversion became denom-aware. Deals inherit the order'sdenomand lock the rate at request time. - Book & Rate:
/bookand/ratefirst show a market chooser (EUR/USD | EUR/RUB), then the two-column grid / reference for that pair. Column headers use currency symbols (€ / $ / ₽). - /mine: lists orders across all pairs, each with a 🗑 Remove button (removal
moved here from
/cancel, which now only aborts an in-progress form; blocked while a deal is in flight).
EUR/RUB rate sources (src/rates.ts)
Per-pair source sets, aggregated by median:
- EUR/USD: ECB (frankfurter) · er-api · currency-api · floatrates; central bank = ECB.
- EUR/RUB: CBR (
cbr-xml-daily.ru, official) · Tinkoff (public API, buy/sell mid) · er-api · currency-api; central bank = CBR. (ECB dropped RUB in 2022, so it's excluded.)RateRef.centralBankcarries the designated anchor for floating orders.
Payment methods (per quote, QUOTES[...].methods, + auto Other…)
- EUR/USD (EU / international):
SEPA, IBAN, IRIS, Revolut, Wise, N26, Cash. - EUR/RUB:
SBP, Sberbank, Tinkoff, Alfa-Bank, VTB, Cash.
Method is a soft tag on the order; the actual receiving details live in the per-user payout blob (§ payment reveal). Geography note: IBAN/SEPA is the only pan-EU receive rail (incl. Germany); phone-based rails are national — IRIS 🇬🇷, Bizum 🇪🇸, MB WAY 🇵🇹 (EUR), SBP 🇷🇺 (RUB) — so "phone as a payment method" is not universal. (Sber/Alfa/VTB have no free rate API → payment methods only, not rate sources; Tinkoff + CBR cover RUB rate tracking.)
6d. Read-only web board (src/web.ts)
Optional embedded HTTP server (enable with WEB_PORT; started from index.ts via
startWeb()). Serves a read-only mirror of the public order book — the same
data the bot's /book shows and nothing more:
GET /— HTML board (dark theme, auto-refresh 30 s): per-pair reference rate, two columns (Buy EUR / Buy quote), each order's amounts, rate (~= floating), method, 💵cash/🌐online badge + city, trader@handle(t.me link), ⭐ rating, plus an aggregate stats bar (open orders · completed deals) and a "Trade on Telegram" CTA.GET /api/orders— the same data as JSON (CORS*).
Privacy: publicOrder() whitelists fields — it never emits user_id,
payout details, or deal internals (unit-tested: HTML/JSON contain no user_id/
payout). Payment details are exchanged in-bot only after a deal is accepted.
Binds all interfaces on WEB_PORT; public exposure (beyond localhost/LAN) needs a
reverse proxy / tunnel / VPS. BASE/QUOTES are mirrored from index.ts (small
duplication; candidate for a shared config module).
6f. Internationalization (planned)
One shared bot, per-user language (never a second bot — shared order book / liquidity).
Full plan, architecture, conventions, progress checklist, and a resume-after-compact
prompt live in telegram-orderbook/I18N.md. Languages: en base → ru → el,
one at a time. Staged: Phase 0 prereqs (lang store, t() layer, /language,
recipient-language safeSend, language-aware menu) → Phase 1 command menu → Phase 2
messages area-by-area. Not started yet. (Also: SEPA and IBAN merged into one
IBAN method — a SEPA transfer just needs the IBAN.)
7. Files
telegram-orderbook/
package.json # grammy + dotenv; type: module; tsx/tsc scripts
tsconfig.json # ES2022 / bundler resolution / strict
.env.example # BOT_TOKEN, optional DB_PATH
.gitignore # node_modules, .env, *.db*
README.md # setup + rules (user-facing)
src/
index.ts # bot, command handlers, arg parsing, startup
db.ts # node:sqlite layer, schema, prepared statements
rates.ts # live EUR/USD reference rate (multi-source median, cached)
web.ts # read-only public web board (HTML + JSON), sanitized
time.ts # window parsing (durations + meet dates), city→tz, luxon resolve/format
city-timezones.d.ts # ambient types for the city-timezones package
format.ts # HTML rendering of book / mine / detail, effective rate, ratings
8. Key technical decisions
- Stack: Node/TS + grammy (Telegram framework) — matches the repo's Node/TS surface.
- Storage: Node's built-in
node:sqlite(DatabaseSync, Node ≥ 22.5), notbetter-sqlite3.better-sqlite3needs a native build and failed here (no Visual Studio toolchain on the Windows box).node:sqliteneeds zero native compilation and is the same choice the main project already made insrc/token/registry-sqlite.ts. Data persists across restarts. - Invariant in the schema, not the code — see §3.
- Amount stored as text (validated numeric) so
500and0.5round-trip exactly without float drift; it's advertising copy, not settlement math. - One startup warning —
ExperimentalWarning: SQLite is an experimental feature— is harmless; suppress withNODE_OPTIONS=--no-warnings.
9. Verification performed
tsc --noEmit→ clean.- Logic smoke test (temp script, since exercising it needs no live bot) asserted:
- first SELL and first BUY are both new; user holds 2 entries (one per side);
- a second SELL replaces (returns "replaced", count stays 2, amount updated);
- a user with no username renders the
tg://user?id=fallback link; - a user with a username renders the
https://t.me/…link; /book EURgroups correctly;/minelists both sides;/cancel sellremoves; a second/cancel sellis a no-op; count drops to 1.- the confirm summary computes the USD total (500 × 1.085 = 542.50).
- Result: ALL SMOKE CHECKS PASSED. Temp test removed after the run.
- Live: token validated via
getMe, bot connected and polling as@FiatMoneyExchangeBot, command menu registered viagetMyCommands. - Not machine-tested: the interactive button/prompt flow itself (needs a real human tapping buttons in Telegram) — the logic behind each step is unit-covered, but tap-through is a manual check.
10. How to run
- @BotFather →
/newbot→ copy token. cp .env.example .env, paste token intoBOT_TOKEN.cd telegram-orderbook && npm install && npm run dev(ornpm start).
Production: npm run build → npm run serve (node dist/index.js).
11. Scope boundary & possible next steps
Out of scope by design: escrow, payment oracle / fiat-leg proof, dispute
resolution, reputation, geo/compliance, banking rails — all the engine of
fiat-ramp-product.md. This build is only the
directory.
Candidate extensions (not built):
- quote-asset / price field as structured data (currently free-form
terms); /find <currency>shortcut;- entry expiry / auto-stale of old postings;
- admin/group-moderator removal;
- pagination for large books;
- Telegram Mini App front-end over the same store (the distribution channel the fiat-ramp doc §6 flags).