diff --git a/app/create/page.tsx b/app/create/page.tsx index f4962f7..d887b08 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -10,6 +10,8 @@ import { useWallet } from '@/contexts/WalletContext'; import { createStream, isMock } from '@/lib/factory'; import { TOKENS_TESTNET, tokenLogoUrl } from '@/lib/tokens'; import { CopyHashButton } from '@/components/ui/CopyHashButton'; +import { FirstStreamNotificationPrompt } from '@/components/FirstStreamNotificationPrompt'; +import { hasCreatedStream, markFirstStreamCreated } from '@/lib/notifications'; import { checkRecipientBalance, checkRecipientExists } from '@/lib/soroban'; import { checkContractHasWithdraw } from '@/lib/contract-recipient-probe'; import { queryClient, refreshStreamData } from '@/lib/queryClient'; @@ -140,6 +142,9 @@ export default function CreatePage() { const [pending, setPending] = useState(false); const [txHash, setTxHash] = useState(null); const [error, setError] = useState(null); + // #810 — only offer the notification opt-in after the very first successful + // stream creation. + const [firstStreamCreated, setFirstStreamCreated] = useState(false); // Tracks the SEP-41 allowance pre-flight (#218) so the button label can // tell the user which on-chain step they're currently waiting on — an @@ -499,6 +504,11 @@ export default function CreatePage() { await invalidateTokenBalance(queryClient, publicKey, tokenAddr); setTxHash(hash); + // #810 — the notification prompt belongs after this moment of value, not + // on the landing page. Only surface it while the flag is still unset. + const isFirstStream = !hasCreatedStream(); + markFirstStreamCreated(); + if (isFirstStream) setFirstStreamCreated(true); try { localStorage.removeItem(DRAFT_KEY); } catch { /* storage is optional */ } // #362 — createStream now decodes the confirmed transaction's return // value, so we can deep-link straight to the new stream instead of @@ -528,6 +538,7 @@ export default function CreatePage() {

{txHash}

+ ); diff --git a/components/FirstStreamNotificationPrompt.tsx b/components/FirstStreamNotificationPrompt.tsx new file mode 100644 index 0000000..8f47b3a --- /dev/null +++ b/components/FirstStreamNotificationPrompt.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + dismissNotificationPermissionPrompt, + requestNotificationPermissionAfterFirstStream, + shouldRequestNotificationPermission, +} from '@/lib/notifications'; + +interface FirstStreamNotificationPromptProps { + /** True when this render follows the creation of the user's first stream. */ + firstStream: boolean; + className?: string; +} + +/** + * Contextual notification opt-in shown after a stream is created (#810). + * Asking here — rather than on first page load — means the request arrives + * right after the user has seen value, and the browser prompt is triggered by + * an explicit click instead of a cold landing-page visit. + */ +export function FirstStreamNotificationPrompt({ + firstStream, + className = '', +}: FirstStreamNotificationPromptProps) { + const [visible, setVisible] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setVisible(firstStream && shouldRequestNotificationPermission()); + }, [firstStream]); + + if (!visible) return null; + + const handleEnable = async () => { + setVisible(false); + setError(null); + const result = await requestNotificationPermissionAfterFirstStream(); + if (result === 'denied') { + setError('Notifications are blocked in your browser settings.'); + } + }; + + const handleDismiss = () => { + dismissNotificationPermissionPrompt(); + setVisible(false); + }; + + return ( +
+

Get notified when your stream pays out

+

+ We can let you know when funds are ready to withdraw and when a claim confirms. +

+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ ); +} diff --git a/components/OfflineTransactionSync.tsx b/components/OfflineTransactionSync.tsx index 22182ce..ebb1259 100644 --- a/components/OfflineTransactionSync.tsx +++ b/components/OfflineTransactionSync.tsx @@ -2,14 +2,15 @@ import { useEffect, useState } from 'react'; import { useWallet } from '@/contexts/WalletContext'; -import { flushQueuedTransactions, queuedTransactions } from '@/lib/offline-transactions'; +import { + flushQueuedTransactions, + queuedTransactions, + startOnlineSync, + stopOnlineSync, +} from '@/lib/offline-transactions'; /** - * #739 — Queue failed transactions and prompt retry upon reconnect. - * - * When a transaction fails due to network issues, it's queued and the user - * is prompted to retry when the connection is restored. Also tracks queued - * count and shows a notification when transactions are flushed. + * #739, #809 — Queue failed transactions, retry on reconnect, and notify user. */ export function OfflineTransactionSync() { const { publicKey, signTx } = useWallet(); @@ -19,16 +20,22 @@ export function OfflineTransactionSync() { useEffect(() => { if (!publicKey) return; - const updateCount = () => setQueuedCount(queuedTransactions().length); + startOnlineSync(signTx); + void flushQueuedTransactions(signTx); + + const updateCount = () => { + if (typeof queuedTransactions === 'function') { + setQueuedCount(queuedTransactions()?.length ?? 0); + } + }; const flush = async () => { - const pending = queuedTransactions().length; + const pending = typeof queuedTransactions === 'function' ? queuedTransactions()?.length ?? 0 : 0; if (pending > 0) { setShowRetryPrompt(true); } await flushQueuedTransactions(signTx); updateCount(); - // Hide prompt after successful flush setTimeout(() => setShowRetryPrompt(false), 3000); }; @@ -37,6 +44,7 @@ export function OfflineTransactionSync() { updateCount(); return () => { + stopOnlineSync(); window.removeEventListener('online', flush); window.removeEventListener('offline-transactions-changed', updateCount); }; diff --git a/components/__tests__/FirstStreamNotificationPrompt.test.tsx b/components/__tests__/FirstStreamNotificationPrompt.test.tsx new file mode 100644 index 0000000..c9e4024 --- /dev/null +++ b/components/__tests__/FirstStreamNotificationPrompt.test.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { FirstStreamNotificationPrompt } from '../FirstStreamNotificationPrompt'; +import { markFirstStreamCreated } from '@/lib/notifications'; + +const PROMPT_SHOWN_KEY = 'conduit:notification-prompt-shown'; + +interface NotificationMock { + permission: NotificationPermission; + requestPermission: ReturnType; +} + +function installNotification(permission: NotificationPermission, result?: NotificationPermission): NotificationMock { + const requestPermission = vi.fn(async () => { + const next = result ?? permission; + MockNotification.permission = next; + return next; + }); + class MockNotification { + static permission: NotificationPermission = permission; + static requestPermission = requestPermission; + constructor(_title: string, _options?: NotificationOptions) {} + } + (globalThis as unknown as { Notification: unknown }).Notification = MockNotification; + (window as unknown as { Notification: unknown }).Notification = MockNotification; + return { permission, requestPermission }; +} + +function uninstallNotification() { + delete (globalThis as unknown as { Notification?: unknown }).Notification; + delete (window as unknown as { Notification?: unknown }).Notification; +} + +function render(firstStream: boolean) { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(React.createElement(FirstStreamNotificationPrompt, { firstStream })); + }); + return { container, root }; +} + +function cleanup(root: ReturnType, container: HTMLElement) { + act(() => root.unmount()); + document.body.removeChild(container); +} + +describe('FirstStreamNotificationPrompt (#810)', () => { + beforeEach(() => { + window.localStorage.clear(); + markFirstStreamCreated(); + }); + + afterEach(() => { + uninstallNotification(); + window.localStorage.clear(); + }); + + it('renders nothing when this was not the first stream', () => { + installNotification('default'); + const { container, root } = render(false); + expect(container.textContent).toBe(''); + cleanup(root, container); + }); + + it('renders nothing when the browser does not support notifications', () => { + const { container, root } = render(true); + expect(container.textContent).toBe(''); + cleanup(root, container); + }); + + it('offers the opt-in after the first stream while permission is undecided', () => { + installNotification('default'); + const { container, root } = render(true); + expect(container.textContent).toContain('Get notified when your stream pays out'); + cleanup(root, container); + }); + + it('requests permission when the user opts in', async () => { + const notification = installNotification('default', 'granted'); + const { container, root } = render(true); + + const enable = Array.from(container.querySelectorAll('button')).find((b) => + b.textContent?.includes('Enable notifications'), + ) as HTMLButtonElement; + + await act(async () => { + enable.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(notification.requestPermission).toHaveBeenCalledTimes(1); + cleanup(root, container); + }); + + it('dismissing marks the prompt as shown and hides it', async () => { + const notification = installNotification('default'); + const { container, root } = render(true); + + const dismiss = Array.from(container.querySelectorAll('button')).find((b) => + b.textContent?.includes('Not now'), + ) as HTMLButtonElement; + + await act(async () => { + dismiss.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(notification.requestPermission).not.toHaveBeenCalled(); + expect(window.localStorage.getItem(PROMPT_SHOWN_KEY)).toBe('true'); + expect(container.textContent).toBe(''); + cleanup(root, container); + }); +}); diff --git a/components/__tests__/OfflineTransactionSync.test.tsx b/components/__tests__/OfflineTransactionSync.test.tsx new file mode 100644 index 0000000..e740813 --- /dev/null +++ b/components/__tests__/OfflineTransactionSync.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +const mockSignTx = vi.fn(); +let currentPublicKey: string | null = 'GTESTPUBLICKEY'; + +vi.mock('@/contexts/WalletContext', () => ({ + useWallet: () => ({ publicKey: currentPublicKey, signTx: mockSignTx }), +})); + +vi.mock('@/lib/offline-transactions', () => ({ + startOnlineSync: vi.fn(), + stopOnlineSync: vi.fn(), + flushQueuedTransactions: vi.fn().mockResolvedValue(undefined), +})); + +import { OfflineTransactionSync } from '../OfflineTransactionSync'; +import { + startOnlineSync, + stopOnlineSync, + flushQueuedTransactions, +} from '@/lib/offline-transactions'; + +function render() { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(React.createElement(OfflineTransactionSync)); + }); + return { container, root }; +} + +function cleanup(root: ReturnType, container: HTMLElement) { + act(() => { + root.unmount(); + }); + document.body.removeChild(container); +} + +describe('OfflineTransactionSync (#809)', () => { + beforeEach(() => { + vi.clearAllMocks(); + currentPublicKey = 'GTESTPUBLICKEY'; + }); + + it('binds the online listener and flushes once when a wallet is connected', async () => { + const { container, root } = render(); + await act(async () => { await Promise.resolve(); }); + + expect(startOnlineSync).toHaveBeenCalledWith(mockSignTx); + expect(flushQueuedTransactions).toHaveBeenCalledWith(mockSignTx); + + cleanup(root, container); + }); + + it('detaches the listener on unmount', () => { + const { container, root } = render(); + cleanup(root, container); + expect(stopOnlineSync).toHaveBeenCalledTimes(1); + }); + + it('does nothing for a disconnected wallet', () => { + currentPublicKey = null; + const { container, root } = render(); + + expect(startOnlineSync).not.toHaveBeenCalled(); + expect(flushQueuedTransactions).not.toHaveBeenCalled(); + + cleanup(root, container); + }); +}); diff --git a/lib/__tests__/stream-sort.test.ts b/lib/__tests__/stream-sort.test.ts index bd65d96..22540f8 100644 --- a/lib/__tests__/stream-sort.test.ts +++ b/lib/__tests__/stream-sort.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { sortStreams, isSortKey } from "../stream-sort"; +import { sortStreams, isSortKey, claimableBalance, compareClaimableDesc, CLAIMABLE_DESC } from "../stream-sort"; function row(ratePerSecond: bigint, endTime: number) { return { info: { ratePerSecond, endTime } }; @@ -9,6 +9,7 @@ describe("isSortKey", () => { it("accepts known sort keys", () => { expect(isSortKey("amount-asc")).toBe(true); expect(isSortKey("default")).toBe(true); + expect(isSortKey("claimable-desc")).toBe(true); }); it("rejects unknown values", () => { @@ -50,3 +51,129 @@ describe("sortStreams", () => { expect(rows).toEqual(copy); }); }); + +// ── #807 — accrued claimable balance sorting ───────────────────────────────── + +type ClaimRowOverrides = Partial<{ + ratePerSecond: bigint; + startTime: number; + endTime: number; + withdrawn: bigint; + paused: boolean; + pausedAt: number; + cancelled: boolean; + withdrawable: bigint; + claimable: bigint; +}>; + +/** Row carrying the full info needed to derive a claimable balance. */ +function claimRow(overrides: ClaimRowOverrides = {}) { + const { withdrawable, claimable, ...infoOverrides } = overrides; + return { + info: { + ratePerSecond: 1n, + startTime: 0, + endTime: 1_000, + withdrawn: 0n, + paused: false, + pausedAt: 0, + cancelled: false, + ...infoOverrides, + }, + ...(withdrawable !== undefined ? { withdrawable } : {}), + ...(claimable !== undefined ? { claimable } : {}), + }; +} + +describe("claimableBalance (#807)", () => { + it("derives accrued minus withdrawn", () => { + expect(claimableBalance(claimRow({ ratePerSecond: 10n, startTime: 0, endTime: 1_000, withdrawn: 2_500n }), 500)).toBe(2_500n); + }); + + it("caps accrual at the stream end time", () => { + expect(claimableBalance(claimRow({ ratePerSecond: 10n, startTime: 0, endTime: 100, withdrawn: 0n }), 10_000)).toBe(1_000n); + }); + + it("keeps accruing for open-ended streams", () => { + expect(claimableBalance(claimRow({ ratePerSecond: 3n, startTime: 0, endTime: 0, withdrawn: 0n }), 1_000)).toBe(3_000n); + }); + + it("freezes at pausedAt while paused", () => { + const row = claimRow({ ratePerSecond: 5n, startTime: 0, endTime: 1_000, paused: true, pausedAt: 100 }); + expect(claimableBalance(row, 900)).toBe(500n); + }); + + it("clamps to zero when the stream has not accrued yet", () => { + expect(claimableBalance(claimRow({ ratePerSecond: 5n, startTime: 1_000 }), 0)).toBe(0n); + }); + + it("clamps to zero when withdrawn exceeds accrual", () => { + expect(claimableBalance(claimRow({ ratePerSecond: 1n, startTime: 0, withdrawn: 9_999n }), 100)).toBe(0n); + }); + + it("prefers an explicit on-chain withdrawable/claimable value", () => { + expect(claimableBalance(claimRow({ withdrawable: 12_345n, ratePerSecond: 1n }), 0)).toBe(12_345n); + expect(claimableBalance(claimRow({ claimable: 4_000n, ratePerSecond: 1n }), 0)).toBe(4_000n); + expect(claimableBalance(claimRow({ withdrawable: -5n }), 0)).toBe(0n); + }); +}); + +describe("sortStreams — claimable descending (#807)", () => { + it("exposes the CLAIMABLE_DESC sort key", () => { + expect(CLAIMABLE_DESC).toBe("claimable-desc"); + expect(isSortKey(CLAIMABLE_DESC)).toBe(true); + }); + + it("sorts by explicit withdrawable balances, largest first", () => { + const rows = [ + claimRow({ withdrawable: 100n }), + claimRow({ withdrawable: 900n }), + claimRow({ withdrawable: 300n }), + ]; + const sorted = sortStreams(rows, "claimable-desc"); + expect(sorted.map((r) => r.withdrawable)).toEqual([900n, 300n, 100n]); + }); + + it("derives claimable balances from stream info when withdrawable is absent", () => { + const rows = [ + claimRow({ ratePerSecond: 10n, startTime: 0, endTime: 1_000, withdrawn: 0n }), // 5000 at now=500 + claimRow({ ratePerSecond: 1n, startTime: 0, endTime: 1_000, withdrawn: 0n }), // 500 + claimRow({ ratePerSecond: 5n, startTime: 0, endTime: 1_000, withdrawn: 0n }), // 2500 + ]; + const sorted = sortStreams(rows, "claimable-desc", 500); + expect(sorted.map((r) => r.info.ratePerSecond)).toEqual([10n, 5n, 1n]); + }); + + it("keeps the input order for equal balances (stable sort)", () => { + const rows = [ + claimRow({ withdrawable: 500n }), + claimRow({ withdrawable: 500n }), + claimRow({ withdrawable: 500n }), + ]; + const sorted = sortStreams(rows, "claimable-desc"); + expect(sorted).toEqual(rows); + }); + + it("does not mutate the input array", () => { + const rows = [claimRow({ withdrawable: 1n }), claimRow({ withdrawable: 2n })]; + const copy = [...rows]; + sortStreams(rows, "claimable-desc"); + expect(rows).toEqual(copy); + }); + + it("returns the array untouched for a single row or 'default'", () => { + const single = [claimRow({ withdrawable: 10n })]; + expect(sortStreams(single, "claimable-desc")).toBe(single); + + const rows = [claimRow({ withdrawable: 1n }), claimRow({ withdrawable: 2n })]; + expect(sortStreams(rows, "default")).toBe(rows); + }); +}); + +describe("compareClaimableDesc (#807)", () => { + it("orders the larger balance first and ties equally", () => { + expect(compareClaimableDesc(claimRow({ withdrawable: 9n }), claimRow({ withdrawable: 2n }), 0)).toBe(-1); + expect(compareClaimableDesc(claimRow({ withdrawable: 2n }), claimRow({ withdrawable: 9n }), 0)).toBe(1); + expect(compareClaimableDesc(claimRow({ withdrawable: 9n }), claimRow({ withdrawable: 9n }), 0)).toBe(0); + }); +}); diff --git a/lib/error-tracking.test.ts b/lib/error-tracking.test.ts index 6c73a93..4a3e370 100644 --- a/lib/error-tracking.test.ts +++ b/lib/error-tracking.test.ts @@ -13,6 +13,8 @@ import { captureError, withErrorTracking, initErrorTracking, + scrubSensitiveData, + scrubObject, } from './error-tracking' describe('error-tracking', () => { @@ -185,3 +187,109 @@ describe('error-tracking', () => { }) }) }) + +// ── #808 — secret key & transaction signature scrubbing ────────────────────── + +const SECRET_KEY = `S${'A'.repeat(55)}` +const PUBLIC_KEY = `G${'B'.repeat(55)}` +const HEX_SIGNATURE = 'a'.repeat(128) +const BASE64_SIGNATURE = `${'A'.repeat(86)}==` + +describe('scrubSensitiveData (#808)', () => { + it('redacts Stellar secret keys with [REDACTED]', () => { + expect(scrubSensitiveData(`secret=${SECRET_KEY}`)).toBe('secret=[REDACTED]') + }) + + it('redacts Stellar public keys', () => { + expect(scrubSensitiveData(PUBLIC_KEY)).toBe('[REDACTED_PUBLIC_KEY]') + }) + + it('redacts hex-encoded transaction signatures', () => { + expect(scrubSensitiveData(`sig=${HEX_SIGNATURE}`)).toBe('sig=[REDACTED]') + }) + + it('redacts base64-encoded 64-byte transaction signatures', () => { + expect(scrubSensitiveData(`sig:${BASE64_SIGNATURE}`)).toBe('sig:[REDACTED]') + }) + + it('redacts labelled signature and signed-transaction values of any encoding', () => { + expect(scrubSensitiveData('signature: "Zm9vYmFyYmF6cXV4MTIzNDU2Nzg5MA"')).toBe( + 'signature: [REDACTED]', + ) + expect(scrubSensitiveData('signedTransaction = AAAAdeadbeef1234567890')).toBe( + 'signedTransaction = [REDACTED]', + ) + expect(scrubSensitiveData('envelope_xdr: AAAABBBB')).toBe('envelope_xdr: [REDACTED]') + }) + + it('leaves non-sensitive text untouched', () => { + const text = 'RPC request timed out after 10000ms for account GCALLER' + expect(scrubSensitiveData(text)).toBe(text) + }) + + it('handles empty and falsy input without throwing', () => { + expect(scrubSensitiveData('')).toBe('') + expect(scrubSensitiveData(undefined as unknown as string)).toBeFalsy() + }) + + it('redacts multiple secrets in a single string', () => { + const scrubbed = scrubSensitiveData(`a ${SECRET_KEY} b ${HEX_SIGNATURE} c ${PUBLIC_KEY}`) + expect(scrubbed).not.toContain(SECRET_KEY) + expect(scrubbed).not.toContain(HEX_SIGNATURE) + expect(scrubbed).not.toContain(PUBLIC_KEY) + expect(scrubbed).toContain('[REDACTED]') + }) +}) + +describe('scrubObject (#808)', () => { + it('recursively scrubs strings in nested objects and arrays', () => { + const input = { + signature: BASE64_SIGNATURE, + nested: { secretKey: SECRET_KEY, list: [HEX_SIGNATURE, 'plain'] }, + } + expect(scrubObject(input)).toEqual({ + signature: '[REDACTED]', + nested: { secretKey: '[REDACTED]', list: ['[REDACTED]', 'plain'] }, + }) + }) + + it('returns non-object values unchanged', () => { + expect(scrubObject(42 as unknown as object)).toBe(42) + expect(scrubObject(null as unknown as object)).toBeNull() + }) +}) + +describe('captureError scrubbing (#808)', () => { + it('never logs a raw secret key to the console fallback', () => { + captureError(new Error(`submit failed for key ${SECRET_KEY}`)) + const loggedMessage = mockConsoleError.mock.calls.at(-1)?.[1] + expect(loggedMessage).not.toContain(SECRET_KEY) + expect(loggedMessage).toContain('[REDACTED]') + }) + + it('scrubs signatures out of context and breadcrumb data', async () => { + mockWithScope.mockReset() + mockCaptureException.mockReset() + vi.doMock('@sentry/nextjs', () => ({ + withScope: mockWithScope, + captureException: mockCaptureException, + })) + process.env.NEXT_PUBLIC_SENTRY_DSN = 'https://test@sentry.io/123' + const mod = await vi.importMock('./error-tracking') + mod.addBreadcrumb({ message: `sig ${HEX_SIGNATURE}`, data: { signature: BASE64_SIGNATURE } }) + mod.captureError(new Error('boom'), { extra: { signedXdr: HEX_SIGNATURE } }) + + expect(mockWithScope).toHaveBeenCalledTimes(1) + const scopeFn = mockWithScope.mock.calls[0]?.[0] + const scope = { setTag: vi.fn(), setExtra: vi.fn(), addBreadcrumb: vi.fn() } + scopeFn!(scope) + + const extraValue = scope.setExtra.mock.calls[0]?.[1] + expect(JSON.stringify(extraValue)).not.toContain(HEX_SIGNATURE) + + const crumb = scope.addBreadcrumb.mock.calls[0]?.[0] + expect(crumb.message).toBe('sig [REDACTED]') + expect(crumb.data).toEqual({ signature: '[REDACTED]' }) + delete process.env.NEXT_PUBLIC_SENTRY_DSN + }) +}) diff --git a/lib/error-tracking.ts b/lib/error-tracking.ts index 6d5fb76..c646abd 100644 --- a/lib/error-tracking.ts +++ b/lib/error-tracking.ts @@ -38,24 +38,53 @@ export const isErrorTrackingEnabled = !!SENTRY_DSN */ export function addBreadcrumb(crumb: ErrorBreadcrumb): void { if (!SENTRY_DSN) return - breadcrumbs.push(crumb) + // Scrub at insertion so a secret can never sit in the in-memory breadcrumb + // buffer waiting to be attached to a later error (#808). + const clean: ErrorBreadcrumb = { + ...crumb, + message: scrubSensitiveData(crumb.message), + } + if (crumb.data) clean.data = scrubObject(crumb.data) + breadcrumbs.push(clean) if (breadcrumbs.length > MAX_BREADCRUMBS) { breadcrumbs = breadcrumbs.slice(-MAX_BREADCRUMBS) } } -// ── PII & Key Scrubber (#752) ──────────────────────────────────────────────── +// ── PII & Key Scrubber (#752, #808) ────────────────────────────────────────── + +const REDACTED = '[REDACTED]'; const STELLAR_PUBLIC_KEY_REGEX = /\bG[A-Z2-7]{55}\b/g; const STELLAR_SECRET_KEY_REGEX = /\bS[A-Z2-7]{55}\b/g; +// Transaction signatures are 64-byte ed25519 values. Depending on how the +// wallet/SDK surfaces them in an error they show up either raw-hex encoded +// (128 chars) or base64 encoded (86 chars + '==' padding). +const HEX_TRANSACTION_SIGNATURE_REGEX = /(? scope.setExtra(k, v)) } - breadcrumbs.forEach(c => scope.addBreadcrumb({ ...c, message: scrubSensitiveData(c.message) })) + breadcrumbs.forEach(c => { + const payload: ErrorBreadcrumb = { ...c, message: scrubSensitiveData(c.message) } + if (c.data) payload.data = scrubObject(c.data) + scope.addBreadcrumb(payload) + }) Sentry.captureException(err) }) breadcrumbs = [] diff --git a/lib/notifications.test.ts b/lib/notifications.test.ts new file mode 100644 index 0000000..4fed23e --- /dev/null +++ b/lib/notifications.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + FIRST_STREAM_CREATED_KEY, + NOTIFICATION_PROMPT_SHOWN_KEY, + dismissNotificationPermissionPrompt, + getNotificationPermission, + hasCreatedStream, + hasPromptedForNotificationPermission, + isNotificationSupported, + markFirstStreamCreated, + notifyClaimConfirmed, + notifyStreamMilestone, + requestNotificationPermission, + requestNotificationPermissionAfterFirstStream, + shouldRequestNotificationPermission, +} from './notifications'; + +interface MockNotificationConstructor { + permission: NotificationPermission; + requestPermission: ReturnType; + instances: Array<{ title: string; options?: NotificationOptions }>; +} + +let mock: MockNotificationConstructor | null = null; + +function installNotification(permission: NotificationPermission, result?: NotificationPermission) { + const instances: Array<{ title: string; options?: NotificationOptions }> = []; + const requestPermission = vi.fn(async () => { + const next = result ?? permission; + MockNotification.permission = next; + return next; + }); + + class MockNotification { + static permission: NotificationPermission = permission; + static requestPermission = requestPermission; + constructor(public title: string, public options?: NotificationOptions) { + instances.push({ title, options }); + } + } + + (globalThis as unknown as { Notification: unknown }).Notification = MockNotification; + (window as unknown as { Notification: unknown }).Notification = MockNotification; + mock = { permission, requestPermission, instances }; + return mock; +} + +function uninstallNotification() { + delete (globalThis as unknown as { Notification?: unknown }).Notification; + delete (window as unknown as { Notification?: unknown }).Notification; + mock = null; +} + +describe('notifications — first-stream permission gating (#810)', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + uninstallNotification(); + window.localStorage.clear(); + }); + + it('tracks whether the user has created a stream', () => { + expect(hasCreatedStream()).toBe(false); + markFirstStreamCreated(); + expect(hasCreatedStream()).toBe(true); + expect(window.localStorage.getItem(FIRST_STREAM_CREATED_KEY)).toBe('true'); + }); + + it('reports unsupported when the Notifications API is missing', () => { + expect(isNotificationSupported()).toBe(false); + expect(getNotificationPermission()).toBe('unsupported'); + expect(shouldRequestNotificationPermission()).toBe(false); + }); + + it('does not ask before the first stream is created', async () => { + const notification = installNotification('default'); + expect(shouldRequestNotificationPermission()).toBe(false); + + const result = await requestNotificationPermissionAfterFirstStream(); + + expect(result).toBe('default'); + expect(notification.requestPermission).not.toHaveBeenCalled(); + // Gated-out calls must not burn the one-shot prompt flag. + expect(hasPromptedForNotificationPermission()).toBe(false); + }); + + it('asks once after the first stream, then remembers it was asked', async () => { + const notification = installNotification('default', 'granted'); + markFirstStreamCreated(); + expect(shouldRequestNotificationPermission()).toBe(true); + + const first = await requestNotificationPermissionAfterFirstStream(); + expect(first).toBe('granted'); + expect(notification.requestPermission).toHaveBeenCalledTimes(1); + expect(hasPromptedForNotificationPermission()).toBe(true); + + const second = await requestNotificationPermissionAfterFirstStream(); + expect(second).toBe('granted'); + expect(notification.requestPermission).toHaveBeenCalledTimes(1); + }); + + it('does not ask when permission was already granted or denied', async () => { + const granted = installNotification('granted'); + markFirstStreamCreated(); + expect(shouldRequestNotificationPermission()).toBe(false); + expect(await requestNotificationPermissionAfterFirstStream()).toBe('granted'); + expect(granted.requestPermission).not.toHaveBeenCalled(); + + uninstallNotification(); + window.localStorage.clear(); + const denied = installNotification('denied'); + markFirstStreamCreated(); + expect(shouldRequestNotificationPermission()).toBe(false); + expect(await requestNotificationPermissionAfterFirstStream()).toBe('denied'); + expect(denied.requestPermission).not.toHaveBeenCalled(); + }); + + it('stays silent after the prompt is dismissed', async () => { + const notification = installNotification('default'); + markFirstStreamCreated(); + dismissNotificationPermissionPrompt(); + + expect(shouldRequestNotificationPermission()).toBe(false); + await requestNotificationPermissionAfterFirstStream(); + expect(notification.requestPermission).not.toHaveBeenCalled(); + expect(window.localStorage.getItem(NOTIFICATION_PROMPT_SHOWN_KEY)).toBe('true'); + }); + + it('requestNotificationPermission is a low-level primitive that still asks when capable', async () => { + const notification = installNotification('default', 'granted'); + expect(await requestNotificationPermission()).toBe('granted'); + expect(notification.requestPermission).toHaveBeenCalledTimes(1); + }); + + it('returns unsupported from the request helper without the API', async () => { + expect(await requestNotificationPermissionAfterFirstStream()).toBe('unsupported'); + }); +}); + +describe('notifications — dispatch (#754)', () => { + beforeEach(() => window.localStorage.clear()); + afterEach(() => uninstallNotification()); + + it('does not dispatch when permission is not granted', () => { + installNotification('default'); + expect(notifyStreamMilestone('hello')).toBeNull(); + }); + + it('dispatches a milestone notification when permission is granted', () => { + const notification = installNotification('granted'); + const result = notifyStreamMilestone('Stream funded', { body: 'ready' }); + expect(result).not.toBeNull(); + expect(notification.instances).toHaveLength(1); + expect(notification.instances[0].title).toBe('Stream funded'); + expect(notification.instances[0].options?.body).toBe('ready'); + }); + + it('forwards claim confirmations through the milestone helper', () => { + const notification = installNotification('granted'); + notifyClaimConfirmed('1234567890', '10 XLM'); + expect(notification.instances).toHaveLength(1); + expect(notification.instances[0].options?.tag).toBe('claim-1234567890'); + }); +}); diff --git a/lib/notifications.ts b/lib/notifications.ts index ea89d8e..623707d 100644 --- a/lib/notifications.ts +++ b/lib/notifications.ts @@ -4,6 +4,52 @@ export type NotificationPermissionState = NotificationPermission | 'unsupported'; +/** localStorage flag written when the wallet creates its first stream (#810). */ +export const FIRST_STREAM_CREATED_KEY = 'conduit:first-stream-created'; + +/** localStorage flag written once the permission prompt has been offered (#810). */ +export const NOTIFICATION_PROMPT_SHOWN_KEY = 'conduit:notification-prompt-shown'; + +function readBooleanFlag(key: string): boolean { + if (typeof localStorage === 'undefined') return false; + try { + return localStorage.getItem(key) === 'true'; + } catch { + return false; + } +} + +function writeBooleanFlag(key: string, value: boolean): void { + if (typeof localStorage === 'undefined') return; + try { + localStorage.setItem(key, value ? 'true' : 'false'); + } catch { + /* storage is optional — a blocked write must not break the page */ + } +} + +/** + * Whether the user has created at least one stream. The notification prompt is + * gated on this so we only ask after value has been demonstrated (#810). + */ +export function hasCreatedStream(): boolean { + return readBooleanFlag(FIRST_STREAM_CREATED_KEY); +} + +/** Record that the wallet has now created a stream (#810). */ +export function markFirstStreamCreated(): void { + writeBooleanFlag(FIRST_STREAM_CREATED_KEY, true); +} + +/** Whether the contextual notification prompt has already been offered. */ +export function hasPromptedForNotificationPermission(): boolean { + return readBooleanFlag(NOTIFICATION_PROMPT_SHOWN_KEY); +} + +function markNotificationPrompted(): void { + writeBooleanFlag(NOTIFICATION_PROMPT_SHOWN_KEY, true); +} + export type NotificationChannel = 'email' | 'telegram' | 'browserPush'; export type NotificationEventKey = @@ -61,7 +107,6 @@ export const NOTIFICATION_EVENT_LABELS: Record { if (!isNotificationSupported()) return 'unsupported'; @@ -81,6 +148,26 @@ export async function requestNotificationPermission(): Promise { + if (!shouldRequestNotificationPermission()) return getNotificationPermission(); + markNotificationPrompted(); + return requestNotificationPermission(); +} + +/** + * Remember that the user dismissed the contextual prompt so it is not offered + * again (#810). + */ +export function dismissNotificationPermissionPrompt(): void { + markNotificationPrompted(); +} + /** * Dispatch a browser notification when a stream milestone or claim completes. */ diff --git a/lib/offline-transactions.test.ts b/lib/offline-transactions.test.ts index b189869..61b19c4 100644 --- a/lib/offline-transactions.test.ts +++ b/lib/offline-transactions.test.ts @@ -3,6 +3,9 @@ import { queueTransaction, queuedTransactions, flushQueuedTransactions, + startOnlineSync, + stopOnlineSync, + isOnlineSyncActive, type OfflineTransaction, } from './offline-transactions'; import * as streamModule from './stream'; @@ -45,6 +48,7 @@ describe('offline-transactions', () => { }); afterEach(() => { + stopOnlineSync(); store.clear(); }); @@ -318,3 +322,100 @@ describe('offline-transactions', () => { }); }); }); + +// ── #809 — automatic sync when the browser comes back online ──────────────── + +describe('offline-transactions — reconnect sync (#809)', () => { + const mockSignTx = vi.fn().mockResolvedValue('signed-tx-xdr'); + + beforeEach(() => { + store.clear(); + vi.clearAllMocks(); + Object.defineProperty(navigator, 'onLine', { value: true, configurable: true, writable: true }); + }); + + afterEach(() => { + stopOnlineSync(); + vi.restoreAllMocks(); + }); + + function queueWithdraw() { + return queueTransaction({ + kind: 'withdraw', + publicKey: 'G_SYNC', + streamAddress: 'C_SYNC', + amount: '1000', + }); + } + + it('flushes the queue when the browser fires online', async () => { + vi.mocked(streamModule.withdraw).mockResolvedValue({} as any); + queueWithdraw(); + startOnlineSync(mockSignTx); + + window.dispatchEvent(new Event('online')); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(streamModule.withdraw).toHaveBeenCalledTimes(1); + expect(queuedTransactions()).toEqual([]); + }); + + it('binds exactly one online listener across repeated calls', () => { + const addSpy = vi.spyOn(window, 'addEventListener'); + const secondSigner = vi.fn().mockResolvedValue('other-xdr'); + + startOnlineSync(mockSignTx); + startOnlineSync(secondSigner); + + const onlineRegistrations = addSpy.mock.calls.filter(([type]) => type === 'online'); + expect(onlineRegistrations).toHaveLength(1); + expect(isOnlineSyncActive()).toBe(true); + }); + + it('does nothing while the browser still reports offline', async () => { + Object.defineProperty(navigator, 'onLine', { value: false, configurable: true, writable: true }); + queueWithdraw(); + startOnlineSync(mockSignTx); + + window.dispatchEvent(new Event('online')); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(streamModule.withdraw).not.toHaveBeenCalled(); + expect(queuedTransactions()).toHaveLength(1); + }); + + it('stops sync after stopOnlineSync removes the listener', async () => { + vi.mocked(streamModule.withdraw).mockResolvedValue({} as any); + queueWithdraw(); + startOnlineSync(mockSignTx); + stopOnlineSync(); + + expect(isOnlineSyncActive()).toBe(false); + + window.dispatchEvent(new Event('online')); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(streamModule.withdraw).not.toHaveBeenCalled(); + expect(queuedTransactions()).toHaveLength(1); + }); + + it('does not start overlapping flushes for a burst of online events', async () => { + let resolveWithdraw: (value: unknown) => void = () => {}; + vi.mocked(streamModule.withdraw).mockImplementation( + () => new Promise((resolve) => { resolveWithdraw = resolve; }) as any, + ); + queueWithdraw(); + startOnlineSync(mockSignTx); + + window.dispatchEvent(new Event('online')); + window.dispatchEvent(new Event('online')); + + // Let the first (and only) flush reach the withdraw call. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(streamModule.withdraw).toHaveBeenCalledTimes(1); + + resolveWithdraw({}); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(streamModule.withdraw).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/offline-transactions.ts b/lib/offline-transactions.ts index fb51f2c..29a55fa 100644 --- a/lib/offline-transactions.ts +++ b/lib/offline-transactions.ts @@ -43,7 +43,9 @@ export function deadLetterTransactions(): OfflineTransaction[] { } export async function flushQueuedTransactions(signTx: SignTransaction): Promise { - if (!navigator.onLine) return; + // Guard SSR and the offline case: there is no point submitting while the + // browser reports no connectivity (#809). + if (typeof navigator === 'undefined' || navigator.onLine === false) return; for (const item of read()) { try { if (item.kind === 'withdraw') await withdraw(item.publicKey, item.streamAddress, BigInt(item.amount), signTx); @@ -63,6 +65,57 @@ export async function flushQueuedTransactions(signTx: SignTransaction): Promise< } } +// ── Automatic reconnect sync (#809) ───────────────────────────────────────── +// +// The signer lives in React state, but the `online` event is owned by the +// browser. Binding here (instead of inside an effect) keeps the reconnect +// behaviour in one place, testable without rendering, and lets a single +// listener survive re-renders. Callers keep the signer fresh by calling +// `startOnlineSync` whenever it changes and `stopOnlineSync` on unmount. + +let onlineSyncSigner: SignTransaction | null = null; +let onlineSyncHandler: (() => void) | null = null; +let onlineSyncInFlight = false; + +function handleOnlineEvent() { + const signTx = onlineSyncSigner; + // A burst of `online` events (e.g. flaky cellular signal) must not kick off + // overlapping flushes of the same queue. + if (!signTx || onlineSyncInFlight) return; + onlineSyncInFlight = true; + void flushQueuedTransactions(signTx).finally(() => { + onlineSyncInFlight = false; + }); +} + +/** + * Retry the offline transaction queue whenever the browser regains + * connectivity. Idempotent: repeated calls only refresh the signer. + */ +export function startOnlineSync(signTx: SignTransaction): void { + onlineSyncSigner = signTx; + if (typeof window === 'undefined') return; + if (!onlineSyncHandler) { + onlineSyncHandler = handleOnlineEvent; + window.addEventListener('online', onlineSyncHandler); + } +} + +/** Detach the `online` listener and forget the signer. */ +export function stopOnlineSync(): void { + onlineSyncSigner = null; + if (typeof window !== 'undefined' && onlineSyncHandler) { + window.removeEventListener('online', onlineSyncHandler); + } + onlineSyncHandler = null; + onlineSyncInFlight = false; +} + +/** Whether an `online` listener is currently bound (test/diagnostic helper). */ +export function isOnlineSyncActive(): boolean { + return onlineSyncHandler !== null; +} + // ── IndexedDB Storage Adapter for Draft Streams (#753) ───────────────────────── const IDB_NAME = 'conduit-offline-db'; diff --git a/lib/stream-sort.ts b/lib/stream-sort.ts index 57b7d1b..34ddda9 100644 --- a/lib/stream-sort.ts +++ b/lib/stream-sort.ts @@ -1,7 +1,15 @@ import type { StreamInfo } from "./stream"; /** Sort options for the /streams list (#548). */ -export type SortKey = "default" | "amount-asc" | "amount-desc" | "end-asc" | "end-desc"; +export type SortKey = "default" | "amount-asc" | "amount-desc" | "end-asc" | "end-desc" | "claimable-desc"; + +/** + * Sort key for "largest accrued claimable balance first" (#807). Recipients use + * it to prioritise withdrawing from the streams that have accumulated the most + * funds. + */ +export const CLAIMABLE_DESC: SortKey = "claimable-desc"; + export interface StreamSortCriterion { key: keyof T; direction?: "asc" | "desc"; } export const SORT_OPTIONS: { value: SortKey; label: string }[] = [ @@ -10,6 +18,7 @@ export const SORT_OPTIONS: { value: SortKey; label: string }[] = [ { value: "amount-desc", label: "Amount: High to Low" }, { value: "end-asc", label: "End date: Soonest" }, { value: "end-desc", label: "End date: Latest" }, + { value: CLAIMABLE_DESC, label: "Claimable: High to Low" }, ]; export function isSortKey(value: string | null): value is SortKey { @@ -22,12 +31,27 @@ function endValue(endTime: number): number { return endTime === 0 ? Infinity : endTime; } +/** + * The subset of {@link StreamInfo} needed to *derive* an accrued claimable + * balance. `startTime`, `withdrawn`, `paused` and `pausedAt` are optional so + * callers that only care about the cheaper amount/end-date sorts (and older + * fixtures) keep compiling. + */ +type SortableInfo = Pick & + Partial>; + +export interface SortableStream { + info: SortableInfo; + /** On-chain claimable balance, when the caller already fetched it. */ + withdrawable?: bigint; + /** Alias some callers use for the same value as {@link withdrawable}. */ + claimable?: bigint; +} + // Memoized comparator cache for multi-key stream sorting (#751) -const comparatorCache = new Map }>(a: T, b: T) => number>(); +const comparatorCache = new Map(a: T, b: T) => number>(); -function getComparator }>( - sort: SortKey, -): (a: T, b: T) => number { +function getComparator(sort: SortKey): (a: T, b: T) => number { const cached = comparatorCache.get(sort); if (cached) return cached; @@ -56,11 +80,65 @@ function getComparator }>( +/** + * Accrued claimable balance of a stream: how much the recipient can withdraw + * right now. + * + * When the caller supplies the on-chain `withdrawable` (or `claimable`) value, + * that is authoritative and is returned as-is. Otherwise the amount is derived + * from `ratePerSecond × elapsed − withdrawn`, honouring the end cap and the + * frozen `pausedAt` reference used elsewhere in the app (#807). + * + * Limitation: `StreamInfo` has no cancellation timestamp, so a cancelled stream + * is treated as if it kept accruing until `now`. Callers that need exact + * values for cancelled streams should pass the on-chain `withdrawable`. + */ +export function claimableBalance(row: T, now: number): bigint { + const explicit = row.withdrawable ?? row.claimable; + if (typeof explicit === "bigint") return explicit > 0n ? explicit : 0n; + + const { ratePerSecond, endTime } = row.info; + const startTime = row.info.startTime ?? 0; + const withdrawn = row.info.withdrawn ?? 0n; + const reference = row.info.paused && row.info.pausedAt ? row.info.pausedAt : now; + + let elapsed = Math.max(0, reference - startTime); + if (endTime > 0) elapsed = Math.min(elapsed, Math.max(0, endTime - startTime)); + + const accrued = ratePerSecond * BigInt(Math.floor(elapsed)); + const remaining = accrued - withdrawn; + return remaining > 0n ? remaining : 0n; +} + +/** + * Comparator for {@link CLAIMABLE_DESC}: largest accrued claimable balance + * first. `now` is passed in (rather than read from the clock inside the + * comparator) so the same snapshot is used for every comparison. + */ +export function compareClaimableDesc(a: T, b: T, now: number): number { + const aValue = claimableBalance(a, now); + const bValue = claimableBalance(b, now); + if (aValue === bValue) return 0; + return aValue > bValue ? -1 : 1; +} + +function sortStreamsByKey( rows: T[], sort: SortKey, + now: number = Math.floor(Date.now() / 1000), ): T[] { if (sort === "default" || rows.length <= 1) return rows; + + if (sort === CLAIMABLE_DESC) { + // BigInt arithmetic and Date.now() are evaluated once per row: doing it + // inside the comparator would recompute on every comparison and leave the + // ordering at the mercy of the clock. `index` keeps equal balances stable. + return rows + .map((row, index) => ({ row, index, value: claimableBalance(row, now) })) + .sort((a, b) => (a.value === b.value ? a.index - b.index : a.value > b.value ? -1 : 1)) + .map(({ row }) => row); + } + const comparator = getComparator(sort); return [...rows].sort(comparator); } @@ -81,16 +159,16 @@ export function sortStreamsByCriteria(rows: readonly T[], crit }).map(({ value }) => value); } -export function sortStreams }>( - rows: T[], sort: SortKey, +export function sortStreams( + rows: T[], sort: SortKey, now?: number, ): T[]; export function sortStreams( rows: readonly T[], criteria: readonly StreamSortCriterion[], ): T[]; -export function sortStreams }>( - rows: readonly T[], sortOrCriteria: SortKey | readonly StreamSortCriterion[], -): T[] { +export function sortStreams( + rows: readonly any[], sortOrCriteria: SortKey | readonly StreamSortCriterion[], now?: number, +): any[] { return Array.isArray(sortOrCriteria) ? sortStreamsByCriteria(rows, sortOrCriteria) - : sortStreamsByKey([...rows], sortOrCriteria); + : sortStreamsByKey([...rows], sortOrCriteria, now); }