diff --git a/packages/app/src/Element/Modal.css b/packages/app/src/Element/Modal.css index bb02e1b7..1c4796c6 100644 --- a/packages/app/src/Element/Modal.css +++ b/packages/app/src/Element/Modal.css @@ -23,6 +23,7 @@ --border-color: var(--gray); } +.modal-body .tab.active, .modal-body button.secondary:hover { background-color: var(--gray); } @@ -33,4 +34,4 @@ .modal.spotlight { color: #fff; -} +} \ No newline at end of file diff --git a/packages/app/src/Hooks/useRates.tsx b/packages/app/src/Hooks/useRates.tsx new file mode 100644 index 00000000..f3710f15 --- /dev/null +++ b/packages/app/src/Hooks/useRates.tsx @@ -0,0 +1,33 @@ +import { bech32ToHex } from "@snort/shared"; +import { EventKind, ReplaceableNoteStore, RequestBuilder } from "@snort/system"; +import { useRequestBuilder } from "@snort/system-react"; +import { useMemo } from "react"; + +// Snort backend publishes rates +const SnortPubkey = "npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws"; + +export function useRates(symbol: string, leaveOpen = true) { + const sub = useMemo(() => { + const rb = new RequestBuilder(`rates:${symbol}`); + rb.withOptions({ + leaveOpen, + }); + rb.withFilter() + .kinds([1009 as EventKind]) + .authors([bech32ToHex(SnortPubkey)]) + .tag("d", [symbol]) + .limit(1); + return rb; + }, [symbol]); + + const data = useRequestBuilder(ReplaceableNoteStore, sub); + + const tag = data?.data?.tags.find(a => a[0] === "d" && a[1] === symbol); + return { + time: data.data?.created_at, + ask: Number(tag?.[2]), + bid: Number(tag?.[3]), + low: Number(tag?.[4]), + hight: Number(tag?.[5]), + }; +} diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx index a02150fd..ec3b9d7a 100644 --- a/packages/app/src/Pages/Layout.tsx +++ b/packages/app/src/Pages/Layout.tsx @@ -93,7 +93,7 @@ const NoteCreatorButton = () => { ); }; -const AccountHeader = () => { +export const AccountHeader = () => { const navigate = useNavigate(); const { formatMessage } = useIntl(); @@ -203,7 +203,7 @@ const AccountHeader = () => { ); }; -function LogoHeader() { +export function LogoHeader() { const { subscriptions } = useLogin(); const currentSubscription = getCurrentSubscription(subscriptions); diff --git a/packages/app/src/Pages/trading/create-offer.css b/packages/app/src/Pages/trading/create-offer.css new file mode 100644 index 00000000..a978b3ba --- /dev/null +++ b/packages/app/src/Pages/trading/create-offer.css @@ -0,0 +1,41 @@ +.create-offer h2 { + margin: 0; + font-weight: 600; + font-size: 16px; + line-height: 19px; +} + +.create-offer h3 { + margin: 0; + color: var(--font-secondary-color); + font-size: 11px; + letter-spacing: 0.11em; + font-weight: 600; + line-height: 13px; + text-transform: uppercase; +} + +.create-offer .premium { + display: flex; + border-radius: 12px; + border: 1px solid; + overflow: hidden; +} + +.create-offer .premium>div { + flex: 1; + text-align: center; + padding: 10px 4px; + cursor: pointer; +} + +.create-offer .premium>div:nth-of-type(1) { + min-width: 2em; +} +.create-offer .premium>div:nth-of-type(2) { + background-color: var(--success); +} + +.create-offer .premium>div:nth-of-type(3) { + background-color: var(--error); +} \ No newline at end of file diff --git a/packages/app/src/Pages/trading/create-offer.tsx b/packages/app/src/Pages/trading/create-offer.tsx new file mode 100644 index 00000000..76bc34cb --- /dev/null +++ b/packages/app/src/Pages/trading/create-offer.tsx @@ -0,0 +1,81 @@ +import "./create-offer.css"; +import { useState } from "react"; +import { FormattedMessage, FormattedNumber } from "react-intl"; + +import Tabs, { Tab } from "Element/Tabs"; +import { useRates } from "Hooks/useRates"; +import { unwrap } from "SnortUtils"; +import AsyncButton from "Element/AsyncButton"; + +export function CreateOffer() { + const [side, setSide] = useState(0); + const [premium, setPremium] = useState(1); + const [amount, setAmount] = useState(10); + const [fiat, setFiat] = useState("USD"); + const rate = useRates(`BTC${fiat}`, true); + + const tabs = [ + { + text: , + value: 0 + }, + { + text: , + value: 1 + } + ] as Array + + const rx = side === 0 ? rate.bid : rate.ask; + return
+

+ +

+
+

+ +

+ a.value === side))} tabs={tabs} setTab={t => setSide(t.value)} /> +
+
+

+ +

+ +
+
+
+

+ +

+
+
+ +
+
setPremium(s => s + 1)}>+
+
setPremium(s => s - 1)}>-
+
+
+
+

+ +

+ setAmount(Number(e.target.value))} /> +
+
+

+ +

+ , + rate: + }} /> +
+
+ { }}> + + +
+} \ No newline at end of file diff --git a/packages/app/src/Pages/trading/index.tsx b/packages/app/src/Pages/trading/index.tsx new file mode 100644 index 00000000..dea64583 --- /dev/null +++ b/packages/app/src/Pages/trading/index.tsx @@ -0,0 +1,9 @@ +import { RouteObject } from "react-router-dom"; +import { OfferList } from "./offers"; + +export const TradingRoutes = [ + { + path: "/trading", + element: + } +] as Array \ No newline at end of file diff --git a/packages/app/src/Pages/trading/offers.tsx b/packages/app/src/Pages/trading/offers.tsx new file mode 100644 index 00000000..b223a7a0 --- /dev/null +++ b/packages/app/src/Pages/trading/offers.tsx @@ -0,0 +1,39 @@ +import { SnortContext } from "@snort/system-react" +import Modal from "Element/Modal"; +import { useContext, useEffect, useState } from "react" +import { FormattedMessage } from "react-intl"; +import { TradeOffer } from "trading"; +import { MostroTrading } from "trading/mostro"; +import { CreateOffer } from "./create-offer"; + +export function OfferList() { + const system = useContext(SnortContext); + const [offers, setOffers] = useState>([]); + const [newOffer, setNewOffer] = useState(false); + + useEffect(() => { + const mostro = new MostroTrading(system, "7590450f6b4d2c6793cacc8c0894e2c6bd2e8a83894912e79335f8f98436d2d8"); + mostro.listOffers().then(o => setOffers(o)); + }, []); + + return <> +
+

+ +

+ + {newOffer && setNewOffer(false)}> + + } +
+ +
{JSON.stringify(offers, undefined, 2)}
+
+ {offers.map(v =>
+ {v.side} +
)} +
+ +} \ No newline at end of file diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index 35b5d009..67308994 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -46,6 +46,7 @@ import { preload, RelayMetrics, UserCache, UserRelays } from "Cache"; import { LoginStore } from "Login"; import { SnortDeckLayout } from "Pages/DeckLayout"; import FreeNostrAddressPage from "./Pages/FreeNostrAddressPage"; +import { TradingRoutes } from "Pages/trading"; const WasmQueryOptimizer = { expandFilter: (f: ReqFilter) => { @@ -191,6 +192,7 @@ export const router = createBrowserRouter([ ...NewUserRoutes, ...WalletRoutes, ...SubscribeRoutes, + ...TradingRoutes, { path: "/debug", element: , @@ -204,6 +206,7 @@ export const router = createBrowserRouter([ { path: "/deck", element: , + children: RootTabRoutes, loader: async () => { if (!didInit) { didInit = true; @@ -211,8 +214,7 @@ export const router = createBrowserRouter([ } return null; }, - children: RootTabRoutes, - }, + } ]); const root = ReactDOM.createRoot(unwrap(document.getElementById("root"))); diff --git a/packages/app/src/trading/index.ts b/packages/app/src/trading/index.ts new file mode 100644 index 00000000..aada5409 --- /dev/null +++ b/packages/app/src/trading/index.ts @@ -0,0 +1,14 @@ +export interface TradingPlatform { + listOffers(): Promise>; +} + +export const enum TradeCurrency { + BTC = "BTC", + EUR = "EUR", +} + +export interface TradeOffer { + side: "BUY" | "SELL"; + currency: TradeCurrency; + rate: number; +} diff --git a/packages/app/src/trading/mostro.ts b/packages/app/src/trading/mostro.ts new file mode 100644 index 00000000..2771caa3 --- /dev/null +++ b/packages/app/src/trading/mostro.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { EventKind, RequestBuilder, SystemInterface, TaggedNostrEvent } from "@snort/system"; +import { TradeOffer, TradingPlatform } from "."; + +const MostroOfferKind = 30_000 as EventKind; + +export class MostroTrading implements TradingPlatform { + #system: SystemInterface; + + constructor( + system: SystemInterface, + readonly pubkey: string, + ) { + this.#system = system; + } + + async listOffers(): Promise { + const rb = new RequestBuilder("list-offers"); + rb.withFilter().kinds([MostroOfferKind]).authors([this.pubkey]); + + const offers = (await this.#system.Fetch(rb)) as Array; + return offers.map(v => { + const order = JSON.parse(v.content) as NewOrder; + return { + side: order.kind === OrderKind.Buy ? "BUY" : "SELL", + } as TradeOffer; + }); + } +} + +const enum MostroMessageAction { + Order = "Order", + TakeSell = "TakeSell", + TakeBuy = "TakeBuy", + PayInvoice = "PayInvoice", + FiatSent = "FiatSent", + Release = "Release", + Cancel = "Cancel", + CooperativeCancelInitiatedByYou = "CooperativeCancelInitiatedByYou", + CooperativeCancelInitiatedByPeer = "CooperativeCancelInitiatedByPeer", + DisputeInitiatedByYou = "DisputeInitiatedByYou", + DisputeInitiatedByPeer = "DisputeInitiatedByPeer", + CooperativeCancelAccepted = "CooperativeCancelAccepted", + BuyerInvoiceAccepted = "BuyerInvoiceAccepted", + SaleCompleted = "SaleCompleted", + PurchaseCompleted = "PurchaseCompleted", + HoldInvoicePaymentAccepted = "HoldInvoicePaymentAccepted", + HoldInvoicePaymentSettled = "HoldInvoicePaymentSettled", + HoldInvoicePaymentCanceled = "HoldInvoicePaymentCanceled", + WaitingSellerToPay = "WaitingSellerToPay", + WaitingBuyerInvoice = "WaitingBuyerInvoice", + AddInvoice = "AddInvoice", + BuyerTookOrder = "BuyerTookOrder", + RateUser = "RateUser", + CantDo = "CantDo", + Received = "Received", + Dispute = "Dispute", + AdminCancel = "AdminCancel", + AdminSettle = "AdminSettle", +} + +interface MostroMessage { + version: number; + order_id?: string; + pubkey?: string; + action: MostroMessageAction; + content?: T; +} + +const enum OrderKind { + Buy = "Buy", + Sell = "Sell", +} + +const enum OrderStatus { + Active = "Active", + Canceled = "Canceled", + CanceledByAdmin = "CanceledByAdmin", + SettledByAdmin = "SettledByAdmin", + CompletedByAdmin = "CompletedByAdmin", + Dispute = "Dispute", + Expired = "Expired", + FiatSent = "FiatSent", + SettledHoldInvoice = "SettledHoldInvoice", + Pending = "Pending", + Success = "Success", + WaitingBuyerInvoice = "WaitingBuyerInvoice", + WaitingPayment = "WaitingPayment", + CooperativelyCanceled = "CooperativelyCanceled", +} + +interface NewOrder { + id?: string; + kind: OrderKind; + status: OrderStatus; + amount: number; + fiat_code: string; + fiat_amount: number; + payment_method: string; + premium: number; + master_buyer_pubkey?: string; + master_seller_pubkey?: string; + buyer_invoice?: string; + created_at?: number; +}