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
13 changes: 0 additions & 13 deletions src/app/actions/__tests__/api-headers-extended.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,17 +175,4 @@ describe('action functions should NOT include apiKey in body', () => {
expect(body).not.toBeNull()
expect(body).not.toHaveProperty('apiKey')
})

it('should not include apiKey in createOnrampForGuest body', async () => {
const { createOnrampForGuest } = require('@/app/actions/onramp')
await createOnrampForGuest({
amount: '100',
country: { id: 'US', name: 'United States', code: 'US' },
userId: 'user-123',
})

const body = getLastCallBody()
expect(body).not.toBeNull()
expect(body).not.toHaveProperty('apiKey')
})
})
12 changes: 0 additions & 12 deletions src/app/actions/__tests__/api-headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,18 +169,6 @@ describe('action functions Content-Type headers', () => {
const headers = getLastCallHeaders()
expect(headers['Content-Type']).toBe('application/json')
})

it('should include Content-Type in createOnrampForGuest', async () => {
const { createOnrampForGuest } = require('@/app/actions/onramp')
await createOnrampForGuest({
amount: '100',
country: { id: 'US', name: 'United States', code: 'US' },
userId: 'user-123',
})

const headers = getLastCallHeaders()
expect(headers['Content-Type']).toBe('application/json')
})
})

// Post-proxy-removal: web calls PEANUT_API_URL directly (same as native).
Expand Down
48 changes: 0 additions & 48 deletions src/app/actions/onramp.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
import { type CountryData } from '@/components/AddMoney/consts'
import { getCurrencyConfig } from '@/utils/bridge.utils'
import { getCurrencyPrice } from '@/app/actions/currency'
import { serverFetch } from '@/utils/api-fetch'

export interface CreateOnrampGuestParams {
amount: string
country: CountryData
userId: string
chargeId?: string
}

/**
* Cancel an on-ramp transfer.
*
Expand Down Expand Up @@ -39,41 +29,3 @@ export async function cancelOnramp(transferId: string): Promise<{ data?: { succe
return { error: 'An unexpected error occurred.' }
}
}

export async function createOnrampForGuest(
params: CreateOnrampGuestParams
): Promise<{ data?: { success: boolean }; error?: string }> {
try {
const { currency, paymentRail } = getCurrencyConfig(params.country.id, 'onramp')
const price = await getCurrencyPrice(currency)
const amount = (Number(params.amount) * price.buy).toFixed(2)

const response = await serverFetch('/bridge/onramp/create-for-guest', {
method: 'POST',
body: JSON.stringify({
amount,
userId: params.userId,
chargeId: params.chargeId,
source: {
currency,
paymentRail,
},
}),
})

const data = await response.json()

if (!response.ok) {
console.log('error', response)
return { error: data.error || 'Failed to create on-ramp transfer for guest.' }
}

return { data }
} catch (error) {
console.error('Error calling create on-ramp for guest API:', error)
if (error instanceof Error) {
return { error: error.message }
}
return { error: 'An unexpected error occurred.' }
}
}
30 changes: 25 additions & 5 deletions src/components/Home/HomeHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,20 @@ const HomeHistory = ({
// users who never got a card; only an issued card means they got through.
const { overview: rainOverview } = useRainCardOverview()

// WebSocket for real-time updates
// WebSocket for real-time updates.
//
// Always subscribe to the SIGNED-IN user's own channel, never the `username`
// prop. On a public profile that prop is the profile owner, and this used to
// open a socket on their channel — which streamed their charge activity and
// KYC state to whoever was looking. The socket is now authenticated and the
// server rejects a channel that isn't yours, so passing someone else's
// username would simply fail to connect.
//
// The list itself is unaffected: it still comes from useTransactionHistory
// above, keyed on `username` with filterMutualTxs, which is what makes a
// profile show transactions mutual to the viewer.
const { historyEntries: wsHistoryEntries } = useWebSocket({
username, // Pass the username to the WebSocket hook
username: user?.user.username ?? undefined,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
onHistoryEntry: useCallback(
(entry: HistoryEntry) => {
const isCompleted = entry.status?.toUpperCase() === 'COMPLETED'
Expand Down Expand Up @@ -123,6 +134,15 @@ const HomeHistory = ({
),
})

// Live entries only belong in the list when the list IS the signed-in
// user's. The socket is now always our OWN channel, so on someone else's
// profile `wsHistoryEntries` are the VIEWER's transactions — merging them
// would inject them into the profile owner's history.
const liveEntries = useMemo(
() => (isViewingOwnHistory ? wsHistoryEntries : []),
[isViewingOwnHistory, wsHistoryEntries]
)

// Combine fetched history with real-time updates
const [combinedEntries, setCombinedEntries] = useState<Array<any>>([])

Expand Down Expand Up @@ -175,7 +195,7 @@ const HomeHistory = ({

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

Expand Down Expand Up @@ -277,7 +297,7 @@ const HomeHistory = ({
}
}
return undefined
}, [historyData, wsHistoryEntries, user, isLoading, isViewingOwnHistory, cardInfo, rainOverview])
}, [historyData, liveEntries, user, isLoading, isViewingOwnHistory, cardInfo, rainOverview])

const pendingRequests = useMemo(() => {
if (!combinedEntries.length) return []
Expand Down Expand Up @@ -342,7 +362,7 @@ const HomeHistory = ({
}

// check source data directly — combinedEntries lags behind due to async processing
const hasSourceEntries = (historyData?.entries?.length ?? 0) > 0 || wsHistoryEntries.length > 0
const hasSourceEntries = (historyData?.entries?.length ?? 0) > 0 || liveEntries.length > 0

// Synthetic entries (KYC region rows, card-unlock) live in
// combinedEntries but NOT in source-side historyData/wsHistoryEntries.
Expand Down
74 changes: 73 additions & 1 deletion src/services/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type HistoryEntry } from '@/hooks/useTransactionHistory'
import { type PendingPerk } from '@/services/perks'
import { isDemoMode } from '@/utils/demo'
import { isCapacitor } from '@/utils/capacitor'
import { getSessionTokenForSocket } from '@/utils/auth-token'
export type { PendingPerk }

function isValidWsUrl(url: string): boolean {
Expand Down Expand Up @@ -47,6 +48,9 @@ export type WebSocketMessage = {
type:
| 'ping'
| 'pong'
// Handshake: the server sends one of these in reply to our `auth` frame.
| 'auth_ok'
| 'auth_error'
| 'history_entry'
| 'kyc_status_update'
| 'manteca_kyc_status_update'
Expand All @@ -64,6 +68,8 @@ export class PeanutWebSocket {
private reconnectTimeout: NodeJS.Timeout | null = null
private eventListeners: Map<string, Set<(data: any) => void>> = new Map()
private isConnected = false
/** Set when the server refused our credential; suppresses the reconnect loop. */
private authFailed = false
private reconnectAttempts = 0
private readonly maxReconnectAttempts = 5
private readonly reconnectDelay = 3000 // 3 seconds
Expand All @@ -82,6 +88,10 @@ export class PeanutWebSocket {
return
}

// A caller reconnecting deliberately (e.g. after a fresh login) gets a
// clean slate — the previous credential rejection should not stick.
this.authFailed = false

try {
const fullUrl = new URL(this.path, this.url).toString()

Expand Down Expand Up @@ -133,11 +143,41 @@ export class PeanutWebSocket {
}
}

private handleOpen(): void {
/**
* The server subscribes this socket to nothing until it receives an `auth`
* frame carrying a session JWT that resolves to the user in the path, so
* send it immediately on open. Async because native reads the token from
* the native cookie jar — which is exactly why auth is a message frame and
* not a header or query param.
*/
private async handleOpen(): Promise<void> {
this.isConnected = true
this.reconnectAttempts = 0
this.startPingInterval()
this.emit('connect', null)

const token = await getSessionTokenForSocket()
if (!token) {
// No session to present. The server would drop us on its auth
// deadline anyway; close now and do not retry, or we would spin
// reconnecting a socket that can never authenticate.
this.authFailed = true
this.emit('auth_error', { reason: 'no-session' })
this.disconnect()
// `disconnect()` nulls onclose before closing, so handleClose — the
// only other place that emits 'disconnect' — never runs on this
// path. Emit it here or consumers that track connect/disconnect
// pairs (useWebSocket maps them straight onto its status) stay
// stuck reporting "connected" forever, with no reconnect scheduled
// to correct it. We already emitted 'connect' above: the socket did
// open, we are closing it again.
this.emit('disconnect', { code: 0, reason: 'no-session' })
return
}

if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: 'auth', token }))
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private handleMessage(event: MessageEvent): void {
Expand All @@ -150,6 +190,20 @@ export class PeanutWebSocket {
this.emit('pong', null)
break

case 'auth_ok':
// Handshake accepted — event frames start flowing now.
this.authFailed = false
this.emit('auth_ok', null)
break

case 'auth_error':
// The credential was refused, not the transport. Reconnecting
// would present the same bad token again, so mark it
// permanent; handleClose reads this to skip the backoff loop.
this.authFailed = true
this.emit('auth_error', message.data ?? null)
break

case 'history_entry':
if (message.data) {
// Process history entry
Expand Down Expand Up @@ -210,11 +264,29 @@ export class PeanutWebSocket {
}
}

/** Server-side auth close codes (see peanut-api-ts routes/charges-ws). */
private static readonly CLOSE_AUTH_FAILED = 4401
private static readonly CLOSE_AUTH_TIMEOUT = 4408

private handleClose(event: CloseEvent): void {
this.isConnected = false
this.clearPingInterval()
this.emit('disconnect', { code: event.code, reason: event.reason })

const authRejected =
this.authFailed ||
event.code === PeanutWebSocket.CLOSE_AUTH_FAILED ||
event.code === PeanutWebSocket.CLOSE_AUTH_TIMEOUT

// An auth rejection arrives as a non-clean close, so without this check
// it would fall straight into the 5x exponential-backoff loop and
// hammer the API with a credential already known to be bad.
if (authRejected) {
this.authFailed = true
this.emit('auth_error', { code: event.code, reason: event.reason })
return
}

if (!event.wasClean) {
this.scheduleReconnect()
}
Expand Down
Loading
Loading