deviant/Parameters/P2Porderbooktelegram
Local Dev 3a6d6736bc docs(reorg): rename DEX_P2P -> DEX_P2P_FiatOnOff; dissolve dApp/Betting
- 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>
2026-07-19 22:56:43 +02:00
..
README.md docs(reorg): rename DEX_P2P -> DEX_P2P_FiatOnOff; dissolve dApp/Betting 2026-07-19 22:56:43 +02:00

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 in src/locales/{en,ru}.ts, src/i18n.ts t()/plural(); recipient-language DMs (dmLang); time.ts/format.ts take lang. Greek scaffolding not started. Terminology: order→«заявка», Cash→«Наличные» (label only; id stays Cash).
  • Rate view curated to CBR·Yahoo·Tinkoff (Tinkoff shows real buy/sell) in /rate; book header decluttered.
  • Recipient address book (accounts table, multi-per-method incl. third parties; migrated from payout_methods) — maker order picks which account receives (orders.account_id, snapshotted to deal); taker reuses saved accounts.
  • Text reviews (reviews table) 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) and orders.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 snapshots maker_recv_method + city. Legacy method/account_id kept 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 in user_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). Retired btn.pay/btn.home still 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 10 can have (10, 'sell') and (10, 'buy') simultaneously — different sides, allowed.
  • A second /sell from user 10 overwrites (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):

  1. amount — text step; positive number of EUR.
  2. rate — text step; USD per 1 EUR.
  3. method — inline-button step. The method is 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 via trm: (takerReceives).
  4. 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 /rate command that lists each source — Tinkoff as buy · 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 a tg://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):

  1. 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 like 90m/2h/3d, or an absolute meet date/time YYYY-MM-DD HH:MM — good for cash meetups). A requested deal is created (NO freeze yet) storing window_sec XOR meet_at, and the owner is DM'd Accept/Decline with the proposed completion time. The effective rate is locked at request time. (time.ts parses/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 (offline city-timezones lookup → IANA zone; luxon for 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-requested deal (dw: → cancelled).
  2. Owner Accept → freeze(amount), state accepted. 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.
  3. 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).
  4. Maker taps Completed → finalize: amount −= deal.amount, frozen −= deal.amount (order deleted if drained), state completed; both DM'd 1–5⭐ rating buttons. Cancel (either party, while accepted) → unfreeze, state cancelled. All these actions are re-surfaced in /deals (one message per deal with its role-appropriate buttons via dealActionKeyboard), so an open deal is never a dead end even if the original DM scrolled away.
  5. Sweeper (setInterval 60 s): requested older than 30 min → expired; accepted past expires_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_id stores 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 to deals.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/USD 100/200/300/500/1000, RUB 5000/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 (floatIsAbsolute in format.ts): RUB = absolute (effective = CB + margin, e.g. CBR +0.50 ₽), USD/others = percent (CB × (1 + margin/100)). rate stores 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 one promptStep() 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.editing flag: the field's normal "next step" is redirected to confirm; Back during editing also returns to summary). The form is framed by intent (intentText), not raw side: a sell-EUR order reads "buy RUB (pay EUR)", a buy-EUR order reads "buy EUR (pay RUB)" — so the "sell EUR" wording no longer confuses someone who tapped Buy RUB.
  • Order denomination (denom on orders & deals): an order's amount/frozen are 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) in format.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 on amount/frozen in whatever denom is, so the deal/inventory math is unchanged; only display/conversion became denom-aware. Deals inherit the order's denom and lock the rate at request time.
  • Book & Rate: /book and /rate first 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.centralBank carries 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), not better-sqlite3. better-sqlite3 needs a native build and failed here (no Visual Studio toolchain on the Windows box). node:sqlite needs zero native compilation and is the same choice the main project already made in src/token/registry-sqlite.ts. Data persists across restarts.
  • Invariant in the schema, not the code — see §3.
  • Amount stored as text (validated numeric) so 500 and 0.5 round-trip exactly without float drift; it's advertising copy, not settlement math.
  • One startup warning — ExperimentalWarning: SQLite is an experimental feature — is harmless; suppress with NODE_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 EUR groups correctly; /mine lists both sides;
    • /cancel sell removes; a second /cancel sell is 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 via getMyCommands.
  • 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

  1. @BotFather → /newbot → copy token.
  2. cp .env.example .env, paste token into BOT_TOKEN.
  3. cd telegram-orderbook && npm install && npm run dev (or npm 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).