Skip to content
Merged
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
36 changes: 31 additions & 5 deletions src/app/(mobile-ui)/history/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useWebSocket } from '@/hooks/useWebSocket'
import { TRANSACTIONS } from '@/constants/query.consts'
import type { HistoryResponse } from '@/hooks/useTransactionHistory'
import { AccountType } from '@/interfaces'
import { completeHistoryEntry } from '@/utils/history.utils'

/**
* displays the user's transaction history with infinite scrolling and date grouping.
Expand All @@ -44,20 +45,45 @@ const HistoryPage = () => {
// Real-time updates via WebSocket
useWebSocket({
username: user?.user.username ?? undefined,
onHistoryEntry: (newEntry) => {
onHistoryEntry: async (newEntry) => {
console.log('[History] New transaction received via WebSocket:', newEntry)

// Update TanStack Query cache with new transaction
// Process the entry through completeHistoryEntry to format amounts and add computed fields
// This ensures WebSocket entries match the format of API-fetched entries
let completedEntry
try {
completedEntry = await completeHistoryEntry(newEntry)
} catch (error) {
console.error('[History] Failed to process WebSocket entry:', error)
Sentry.captureException(error, {
tags: { feature: 'websocket-history' },
extra: { entryType: newEntry.type, entryUuid: newEntry.uuid },
})

// Fallback: Use raw entry with minimal processing
completedEntry = {
...newEntry,
timestamp: new Date(newEntry.timestamp),
extraData: {
...newEntry.extraData,
usdAmount: newEntry.amount.toString(), // Best effort fallback
},
}
}

// Update TanStack Query cache with processed transaction
queryClient.setQueryData<InfiniteData<HistoryResponse>>(
[TRANSACTIONS, 'infinite', { limit: 20 }],
(oldData) => {
if (!oldData) return oldData

// Check if entry exists on ANY page to prevent duplicates
const existsAnywhere = oldData.pages.some((p) => p.entries.some((e) => e.uuid === newEntry.uuid))
const existsAnywhere = oldData.pages.some((p) =>
p.entries.some((e) => e.uuid === completedEntry.uuid)
)

if (existsAnywhere) {
console.log('[History] Duplicate transaction ignored:', newEntry.uuid)
console.log('[History] Duplicate transaction ignored:', completedEntry.uuid)
return oldData
}

Expand All @@ -68,7 +94,7 @@ const HistoryPage = () => {
if (index === 0) {
return {
...page,
entries: [newEntry, ...page.entries],
entries: [completedEntry, ...page.entries],
}
}
return page
Expand Down
63 changes: 53 additions & 10 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { STAR_STRAIGHT_ICON } from '@/assets'
import { useAuth } from '@/context/authContext'
import { useWebSocket } from '@/hooks/useWebSocket'
import type { HistoryEntry } from '@/hooks/useTransactionHistory'
import { completeHistoryEntry } from '@/utils/history.utils'

const MAX_QR_PAYMENT_AMOUNT = '2000'

Expand Down Expand Up @@ -138,7 +139,7 @@ export default function QRPayPage() {
}

const handleSimpleFiStatusUpdate = useCallback(
(entry: HistoryEntry) => {
async (entry: HistoryEntry) => {
if (!pendingSimpleFiPaymentId || entry.uuid !== pendingSimpleFiPaymentId) {
return
}
Expand All @@ -149,22 +150,64 @@ export default function QRPayPage() {

console.log('[SimpleFi WebSocket] Received status update:', entry.status)

// Process entry through completeHistoryEntry to format amounts correctly
let completedEntry
try {
completedEntry = await completeHistoryEntry(entry)
} catch (error) {
console.error('[SimpleFi WebSocket] Failed to process entry:', error)
captureException(error, {
tags: { feature: 'simplefi-websocket' },
extra: { entryUuid: entry.uuid },
})
setIsWaitingForWebSocket(false)
setPendingSimpleFiPaymentId(null)
setErrorMessage('We received an update, but failed to process it. Please check your history.')
setIsSuccess(false)
setLoadingState('Idle')
return
}

setIsWaitingForWebSocket(false)
setPendingSimpleFiPaymentId(null)

switch (entry.status) {
case 'approved':
switch (completedEntry.status) {
case 'approved': {
// Guard against missing currency or simpleFiPayment data
if (!completedEntry.currency?.code || !completedEntry.currency?.amount) {
console.error('[SimpleFi WebSocket] Currency data missing on approval')
captureException(new Error('SimpleFi payment approved but currency details missing'), {
extra: { entryUuid: completedEntry.uuid },
})
setErrorMessage('Payment approved, but details are incomplete. Please check your history.')
setIsSuccess(false)
setLoadingState('Idle')
break
}

if (!simpleFiPayment) {
console.error('[SimpleFi WebSocket] SimpleFi payment details missing on approval')
captureException(new Error('SimpleFi payment details missing on approval'), {
extra: { entryUuid: completedEntry.uuid },
})
setErrorMessage('Payment approved, but details are missing. Please check your history.')
setIsSuccess(false)
setLoadingState('Idle')
break
}

setSimpleFiPayment({
id: entry.uuid,
usdAmount: entry.amount,
currency: entry.currency!.code,
currencyAmount: entry.currency!.amount,
price: simpleFiPayment!.price,
address: simpleFiPayment!.address,
id: completedEntry.uuid,
usdAmount: completedEntry.extraData?.usdAmount || completedEntry.amount,
currency: completedEntry.currency.code,
currencyAmount: completedEntry.currency.amount,
price: simpleFiPayment.price,
address: simpleFiPayment.address,
})
setIsSuccess(true)
setLoadingState('Idle')
break
}

case 'expired':
case 'canceled':
Expand All @@ -175,7 +218,7 @@ export default function QRPayPage() {
break

default:
console.log('[SimpleFi WebSocket] Unknown status:', entry.status)
console.log('[SimpleFi WebSocket] Unknown status:', completedEntry.status)
}
},
[pendingSimpleFiPaymentId, simpleFiPayment, setLoadingState]
Expand Down
4 changes: 2 additions & 2 deletions src/components/Claim/Claim.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ export const Claim = ({}) => {
queryKey: ['sendLink', linkUrl],
queryFn: () => sendLinksApi.get(linkUrl),
enabled: !!linkUrl, // Only run when we have a link URL
retry: 3, // Retry 3 times for RPC sync issues
retryDelay: (attemptIndex) => (attemptIndex + 1) * 1000, // 1s, 2s, 3s (linear backoff)
retry: 4, // Retry a few times for DB replication lag + blockchain indexing
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // Exponential: 1s, 2s, 4s, 8s (total ~15s)
staleTime: 0, // Don't cache (one-time use per link)
gcTime: 0, // Garbage collect immediately after use
})
Expand Down
119 changes: 68 additions & 51 deletions src/components/Home/HomeHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { KycStatusItem } from '../Kyc/KycStatusItem'
import { isKycStatusItem, type KycHistoryEntry } from '@/hooks/useBridgeKycFlow'
import { useWallet } from '@/hooks/wallet/useWallet'
import { useUserInteractions } from '@/hooks/useUserInteractions'
import { completeHistoryEntry } from '@/utils/history.utils'

/**
* component to display a preview of the most recent transactions on the home page.
Expand Down Expand Up @@ -81,70 +82,86 @@ const HomeHistory = ({ isPublic = false, username }: { isPublic?: boolean; usern

useEffect(() => {
if (!isLoading && historyData?.entries) {
// Start with the fetched entries
const entries: Array<HistoryEntry | KycHistoryEntry> = [...historyData.entries]
// Process entries asynchronously to handle completeHistoryEntry
const processEntries = async () => {
// Start with the fetched entries
const entries: Array<HistoryEntry | KycHistoryEntry> = [...historyData.entries]

// process websocket entries: update existing or add new ones
// Sort by timestamp ascending to process oldest entries first
const sortedWsEntries = [...wsHistoryEntries].sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
)
sortedWsEntries.forEach((wsEntry) => {
const existingIndex = entries.findIndex((entry) => entry.uuid === wsEntry.uuid)
// process websocket entries: update existing or add new ones
// Sort by timestamp ascending to process oldest entries first
const sortedWsEntries = [...wsHistoryEntries].sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
)

if (existingIndex !== -1) {
// update existing entry with latest websocket data
if (wsEntry.extraData) {
wsEntry.extraData.usdAmount = wsEntry.amount.toString()
} else {
wsEntry.extraData = { usdAmount: wsEntry.amount.toString() }
// Process WebSocket entries through completeHistoryEntry to format amounts correctly
for (const wsEntry of sortedWsEntries) {
let completedEntry
try {
completedEntry = await completeHistoryEntry(wsEntry)
} catch (error) {
console.error('[HomeHistory] Failed to process WebSocket entry:', error)
Sentry.captureException(error, {
tags: { feature: 'websocket-home-history' },
extra: { entryType: wsEntry.type, entryUuid: wsEntry.uuid },
})

// Fallback: Use raw entry with minimal processing
completedEntry = {
...wsEntry,
timestamp: new Date(wsEntry.timestamp),
extraData: {
...wsEntry.extraData,
usdAmount: wsEntry.amount.toString(), // Best effort fallback
},
}
}
wsEntry.extraData.link = `${BASE_URL}/${wsEntry.recipientAccount.username || wsEntry.recipientAccount.identifier}?chargeId=${wsEntry.uuid}`
entries[existingIndex] = wsEntry
} else {
// add new entry if it doesn't exist
if (wsEntry.extraData) {
wsEntry.extraData.usdAmount = wsEntry.amount.toString()

const existingIndex = entries.findIndex((entry) => entry.uuid === completedEntry.uuid)

if (existingIndex !== -1) {
// update existing entry with latest websocket data
entries[existingIndex] = completedEntry
} else {
wsEntry.extraData = { usdAmount: wsEntry.amount.toString() }
// add new entry if it doesn't exist
entries.push(completedEntry)
}
wsEntry.extraData.link = `${BASE_URL}/${wsEntry.recipientAccount.username || wsEntry.recipientAccount.identifier}?chargeId=${wsEntry.uuid}`
entries.push(wsEntry)
}
})

// Add KYC status item if applicable and not on a public page
// and the user is viewing their own history
if (isSameUser && !isPublic) {
if (user?.user?.bridgeKycStatus && user.user.bridgeKycStatus !== 'not_started') {
entries.push({
isKyc: true,
timestamp: user.user.bridgeKycStartedAt ?? new Date(0).toISOString(),
uuid: 'bridge-kyc-status-item',
bridgeKycStatus: user.user.bridgeKycStatus,
// Add KYC status item if applicable and not on a public page
// and the user is viewing their own history
if (isSameUser && !isPublic) {
if (user?.user?.bridgeKycStatus && user.user.bridgeKycStatus !== 'not_started') {
entries.push({
isKyc: true,
timestamp: user.user.bridgeKycStartedAt ?? new Date(0).toISOString(),
uuid: 'bridge-kyc-status-item',
bridgeKycStatus: user.user.bridgeKycStatus,
})
}
user?.user.kycVerifications?.forEach((verification) => {
entries.push({
isKyc: true,
timestamp: verification.approvedAt ?? new Date(0).toISOString(),
uuid: verification.providerUserId ?? `${verification.provider}-${verification.mantecaGeo}`,
verification,
})
})
}
user?.user.kycVerifications?.forEach((verification) => {
entries.push({
isKyc: true,
timestamp: verification.approvedAt ?? new Date(0).toISOString(),
uuid: verification.providerUserId ?? `${verification.provider}-${verification.mantecaGeo}`,
verification,
})

// Sort entries by date in descending order
entries.sort((a, b) => {
const dateA = new Date(a.timestamp || 0).getTime()
const dateB = new Date(b.timestamp || 0).getTime()
return dateB - dateA
})
}

// Sort entries by date in descending order
entries.sort((a, b) => {
const dateA = new Date(a.timestamp || 0).getTime()
const dateB = new Date(b.timestamp || 0).getTime()
return dateB - dateA
})
// Limit to the most recent entries
setCombinedEntries(entries.slice(0, isPublic ? 20 : 5))
}

// Limit to the most recent entries
setCombinedEntries(entries.slice(0, isPublic ? 20 : 5))
processEntries()
}
}, [historyData, wsHistoryEntries, isPublic, user, isLoading])
}, [historyData, wsHistoryEntries, isPublic, user, isLoading, isSameUser])

const pendingRequests = useMemo(() => {
if (!combinedEntries.length) return []
Expand Down
15 changes: 14 additions & 1 deletion src/components/Payment/PaymentForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,14 @@ export const PaymentForm = ({
const dispatch = useAppDispatch()
const router = useRouter()
const { user, fetchUser } = useAuth()
const { requestDetails, chargeDetails, daimoError, error: paymentStoreError, attachmentOptions } = usePaymentStore()
const {
requestDetails,
chargeDetails,
daimoError,
error: paymentStoreError,
attachmentOptions,
currentView,
} = usePaymentStore()
const {
setShowExternalWalletFulfillMethods,
setExternalWalletFulfillMethod,
Expand Down Expand Up @@ -178,6 +185,11 @@ export const PaymentForm = ({
}, [dispatch, recipient])

useEffect(() => {
// Skip balance check if on CONFIRM or STATUS view (balance has been optimistically updated)
if (currentView === 'CONFIRM' || currentView === 'STATUS') {
return
}

dispatch(paymentActions.setError(null))

const currentInputAmountStr = String(inputTokenAmount)
Expand Down Expand Up @@ -261,6 +273,7 @@ export const PaymentForm = ({
selectedTokenData,
isExternalWalletConnected,
isExternalWalletFlow,
currentView,
])

// fetch token price
Expand Down
Loading