Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"preview": "vite preview",
"lint": "eslint",
"format": "prettier --write \"**/*.{ts,tsx}\"",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@creit.tech/stellar-wallets-kit": "^2.4.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,17 @@ import { useOracleCandles } from "../../hooks/useOracleCandles"
import { useLiveBar } from "../../hooks/useLiveBar"
import { usePositions } from "../../hooks/usePositions"
import { useOrders } from "../../hooks/useOrders"
import { useWalletStore } from "@/features/wallet/store/wallet-store"
import {
buildCandleOptions,
buildChartOptions,
getChartPalette,
positionLineColor,
} from "../../lib/chart-theme"
import type { CandlestickData, IChartApi, IPriceLine, ISeriesApi, UTCTimestamp } from "lightweight-charts"
import type { OhlcBar } from "../../lib/oracle"
import { formatUsd } from "@/shared/lib/format"
import { ENV } from "@/app/config/env"
import type { CandlestickData, IChartApi, IPriceLine, ISeriesApi, UTCTimestamp } from "lightweight-charts"
import type { OhlcBar } from "../../lib/oracle"
import { useWalletStore } from "@/features/wallet/store/wallet-store"

type ChartLine = {
id: string
Expand Down
12 changes: 7 additions & 5 deletions apps/web/src/features/trade/components/orderbook/DepthLadder.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useState } from "react"

import { cn } from "@workspace/ui/lib/utils"
import { useOrderBook } from "../../hooks/useOrderBook"
import type {OrderBookLevel} from "../../hooks/useOrderBook";
import { formatUsd } from "@/shared/lib/format"

import { useOrderBook } from "../../hooks/useOrderBook"
import { useRecentTrades } from "../../hooks/useRecentTrades"
import { useState } from "react"
import type { OrderBookLevel } from "../../hooks/useOrderBook"

type Props = {
symbol: string | undefined
Expand Down Expand Up @@ -217,9 +219,9 @@ export function DepthLadder({ symbol, compact = false }: Props) {
const { trades } = useRecentTrades(symbol)

// OB-054: Determine reference price — prefer last trade, fall back to mid price
const lastTradePrice = trades[0]?.price ?? null
const lastTradePrice = trades[0]?.price
const referencePrice = lastTradePrice ?? midPrice
const referencePriceLabel = lastTradePrice !== null ? "Last" : midPrice !== null ? "Mid" : "Mark"
const referencePriceLabel = lastTradePrice ? "Last" : midPrice ? "Mid" : "Mark"

// OB-056: Cumulative depth inspection state
const [hoveredLevel, setHoveredLevel] = useState<{ level: OrderBookLevel; side: "bid" | "ask" } | null>(null)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, useRef } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -132,8 +132,8 @@ export function ConfirmationDialog({
sizeUsd,
entryPrice,
leverage,
triggerPrice: triggerPrice ?? "",
collateralAmount: fromAmount ?? "",
triggerPrice,
collateralAmount: fromAmount,
}

if (!inputSnapshotRef.current) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ describe("useSourceFreshness", () => {
const { result, rerender } = renderHook(
({ symbol }: { symbol: string | undefined }) => useSourceFreshness(symbol),
{
initialProps: { symbol: "BTC" as string | undefined },
initialProps: { symbol: "BTC" },
},
)
expect(result.current).not.toBeNull()
Expand Down
176 changes: 176 additions & 0 deletions apps/web/src/shared/hooks/useTxFeedback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { useCallback, useRef } from "react"
import { toast } from "@workspace/ui/components/toast"
import { explorerTxUrl } from "@/app/config/network"

// #732: Explicit lifecycle events for transaction feedback without English message matching
export type TxFeedbackEvent =
| { type: "signing"; message: string }
| { type: "signed" }
| { type: "submitting"; message: string }
| { type: "submitted"; hash: string; description?: string }
| { type: "confirming" }
| { type: "confirmed"; hash: string }
| { type: "ordering"; message: string }
| { type: "order_settled"; message: string; description?: string }
| { type: "error"; message: string }
| { type: "unknown_outcome"; hash?: string }

// #732: Each attempt gets its own stable toast ID; concurrent transactions don't interfere
type TxAttempt = {
readonly attemptId: string
readonly createdAt: number
toastId: string | null
status: "pending" | "signed" | "submitted" | "confirmed" | "settled" | "failed"
hash: string | null
}

export function useTxFeedback() {
const attemptRef = useRef<TxAttempt | null>(null)

const createAttempt = useCallback((attemptId: string): TxAttempt => {
const attempt: TxAttempt = {
attemptId,
createdAt: Date.now(),
toastId: null,
status: "pending",
hash: null,
}
attemptRef.current = attempt
return attempt
}, [])

const handleEvent = useCallback((event: TxFeedbackEvent) => {
const attempt = attemptRef.current
if (!attempt) return

switch (event.type) {
case "signing":
attempt.toastId = toast.show({
id: attempt.attemptId,
message: event.message,
variant: "transaction-progress",
duration: 0,
persistent: true,
})
break

case "signed":
// Signing complete, ready for submission
break

case "submitting":
if (attempt.toastId) {
toast.show({
id: attempt.attemptId,
message: event.message,
variant: "transaction-progress",
duration: 0,
persistent: true,
})
}
break

case "submitted":
attempt.hash = event.hash
attempt.status = "submitted"
if (attempt.toastId) {
toast.show({
id: attempt.attemptId,
message: "Confirming on ledger...",
variant: "transaction-progress",
duration: 0,
persistent: true,
description: event.description,
})
}
break

case "confirming":
// Ledger confirmation in progress
break

case "confirmed":
attempt.hash = event.hash
attempt.status = "confirmed"
if (attempt.toastId) {
toast.show({
id: attempt.attemptId,
message: "Transaction confirmed",
variant: "transaction-progress",
duration: 0,
persistent: true,
})
}
break

case "ordering":
// For order settlement flow
if (attempt.toastId) {
toast.show({
id: attempt.attemptId,
message: event.message,
variant: "transaction-progress",
duration: 0,
persistent: true,
})
}
break

case "order_settled":
attempt.status = "settled"
if (attempt.toastId) {
const explorerLink = attempt.hash
? `<a href="${explorerTxUrl(attempt.hash)}" target="_blank" rel="noreferrer" class="text-xs text-primary hover:underline">View transaction →</a>`
: null

toast.show({
id: attempt.attemptId,
message: event.message,
variant: "success",
duration: 4000,
description: event.description || explorerLink,
})
}
break

case "error":
attempt.status = "failed"
if (attempt.toastId) {
toast.error(event.message, { id: attempt.attemptId })
} else {
toast.error(event.message)
}
break

case "unknown_outcome":
// #733: Unknown submission outcome — status should be looked up by hash if available
attempt.status = "submitted"
if (attempt.toastId) {
toast.show({
id: attempt.attemptId,
message: "Submission status unknown",
description: "Checking ledger...",
variant: "transaction-progress",
duration: 0,
persistent: true,
})
}
break
}
}, [])

const getCurrentAttempt = useCallback((): TxAttempt | null => {
return attemptRef.current
}, [])

const clearAttempt = useCallback(() => {
attemptRef.current = null
}, [])

return {
createAttempt,
handleEvent,
getCurrentAttempt,
clearAttempt,
}
}
94 changes: 93 additions & 1 deletion apps/web/src/shared/hooks/useTxSubmit.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useCallback, useState } from "react"
import { useCallback, useState, useRef } from "react"
import { toast } from "@workspace/ui/components/toast"
import { sendAndPoll } from "@/lib/tx-builder"
import { explorerTxUrl } from "@/app/config/network"
import { useTxFeedback } from "./useTxFeedback"

export type SubmitTxOptions = {
loadingMessage: string
Expand Down Expand Up @@ -79,30 +80,121 @@ export function useTxSubmit() {
txHash: null,
error: null,
})
const feedback = useTxFeedback()
// #733: Track attempt identity to detect and prevent duplicate submissions
const attemptIdRef = useRef<string | null>(null)
// #733: Guard against double-click, Enter+click, component rerender triggering multiple submissions
const submitInProgressRef = useRef(false)

const submit = useCallback(
async (getSignedXdr: () => Promise<string>, options: SubmitTxOptions): Promise<string> => {
// #733: Prevent duplicate submission (double-click, accidental Enter+click, rerender)
if (submitInProgressRef.current) {
throw new Error("Submission already in progress")
}
submitInProgressRef.current = true

setState({ isLoading: true, txHash: null, error: null })
try {
const hash = await submitTx(getSignedXdr, options)
setState({ isLoading: false, txHash: hash, error: null })
submitInProgressRef.current = false
return hash
} catch (error) {
setState({
isLoading: false,
txHash: null,
error: error instanceof Error ? error.message : "Transaction failed",
})
submitInProgressRef.current = false
throw error
}
},
[],
)

// #732: Extended submit that integrates with transaction feedback controller
const submitWithFeedback = useCallback(
async (getSignedXdr: () => Promise<string>, options: SubmitTxOptions): Promise<string> => {
const attemptId = `txn-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
attemptIdRef.current = attemptId

const attempt = feedback.createAttempt(attemptId)

// #733: Prevent duplicate submission with attempt tracking
if (submitInProgressRef.current) {
feedback.handleEvent({
type: "error",
message: "Transaction already being submitted. Please wait.",
})
throw new Error("Submission already in progress")
}
submitInProgressRef.current = true

try {
feedback.handleEvent({ type: "signing", message: options.loadingMessage })

const hash = options.execute
? await options.execute()
: await (async () => {
const signedXdr = await getSignedXdr()
feedback.handleEvent({ type: "submitting", message: "Submitting transaction..." })

try {
const response = await sendAndPoll(signedXdr)
return response.hash
} catch (submitError) {
// #733: Unknown submission outcome — signal for status lookup
const errorMsg = submitError instanceof Error ? submitError.message : "Submission failed"
if (errorMsg.includes("unknown")) {
feedback.handleEvent({ type: "unknown_outcome" })
}
throw submitError
}
})()

feedback.handleEvent({
type: "submitted",
hash,
description: options.successDescription?.(hash),
})

await options.onSuccess?.(hash)

feedback.handleEvent({
type: "order_settled",
message: options.successMessage,
description: options.successDescription?.(hash),
})

setState({ isLoading: false, txHash: hash, error: null })
submitInProgressRef.current = false
return hash
} catch (error) {
const message = options.onError?.(error) ?? "Transaction failed"
feedback.handleEvent({ type: "error", message })
setState({
isLoading: false,
txHash: null,
error: message,
})
submitInProgressRef.current = false
throw error
} finally {
// #733: Keep attempt ID for status lookup in case of unknown outcomes
// Do NOT clear immediately — let status lookup use it if needed
}
},
[feedback],
)

return {
submit,
// #732 & #733: Extended submit with transaction feedback controller
submitWithFeedback,
isLoading: state.isLoading,
txHash: state.txHash,
error: state.error,
feedback,
}
}