Skip to content
Open
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
49 changes: 34 additions & 15 deletions src/components/modals/budget-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { Button } from "@/components/ui/button"
import { useModalStore } from "@/stores/modal-store"
import { useUserStore } from "@/stores/user-store"
import { isSphinx, hasWebLN, payInvoice, payL402, topUpLsat, topUpConfirm, fetchTransactionHistory, pollPaymentStatus, fetchBuyLsatChallenge, TransactionRow } from "@/lib/sphinx"
import { getActionDisplayLabel, getActionBadgeColor } from "@/lib/transaction-display"
import { getActionDisplayLabel, getActionBadgeColor, isViewGrantRow } from "@/lib/transaction-display"
import { isMocksEnabled, MOCK_TRANSACTIONS } from "@/lib/mock-data"
import { cookieStorage } from "@/lib/cookie-storage"
import { api } from "@/lib/api"
Expand Down Expand Up @@ -45,7 +45,7 @@ export function BudgetModal() {
const [paymentRequest, setPaymentRequest] = useState("")
const [paymentHash, setPaymentHash] = useState("")
const [copied, setCopied] = useState(false)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const pollAbortRef = useRef<AbortController | null>(null)

// First-purchase state (non-Sphinx, non-WebLN, no existing L402)
const [firstPurchaseAmount, setFirstPurchaseAmount] = useState<number>(1000)
Expand All @@ -63,8 +63,15 @@ export function BudgetModal() {
? budget.toLocaleString()
: "--"

const cancelPoll = useCallback(() => {
pollAbortRef.current?.abort()
pollAbortRef.current = null
setLoading(false)
setError("")
}, [])

const resetState = useCallback(() => {
if (intervalRef.current) clearInterval(intervalRef.current)
cancelPoll()
setStep("balance")
setAmount(null)
setPaymentRequest("")
Expand All @@ -88,7 +95,7 @@ export function BudgetModal() {

useEffect(() => {
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
pollAbortRef.current?.abort()
}
}, [])

Expand Down Expand Up @@ -199,12 +206,15 @@ export function BudgetModal() {
}
setError("")
setLoading(true)
const controller = new AbortController()
pollAbortRef.current = controller
try {
const challenge = await fetchBuyLsatChallenge(firstPurchaseAmount)
setFirstPurchaseRequest(challenge.invoice)
setStep("first-invoice")

const paymentStatus = await pollPaymentStatus(challenge.paymentHash)
const paymentStatus = await pollPaymentStatus(challenge.paymentHash, 20, 2000, controller.signal)
if (controller.signal.aborted) return
if (!paymentStatus.paid) {
setError("Payment not detected. Try again.")
setStep("first-purchase")
Expand Down Expand Up @@ -281,10 +291,12 @@ export function BudgetModal() {
setPaymentHash(result.payment_hash)
setStep("invoice")

const manualController = new AbortController()
pollAbortRef.current = manualController
let confirming = false
const paymentStatus = await pollPaymentStatus(result.payment_hash, 100, 3000)
const paymentStatus = await pollPaymentStatus(result.payment_hash, 100, 3000, manualController.signal)
if (manualController.signal.aborted) return
if (!paymentStatus.paid) {
if (intervalRef.current) clearInterval(intervalRef.current)
setError("Payment not detected. Try again.")
setStep("amount")
return
Expand Down Expand Up @@ -339,19 +351,22 @@ export function BudgetModal() {
<DialogTitle className="font-heading text-lg tracking-wide flex items-center gap-2">
{step !== "balance" && (
<button
aria-label="Go back"
onClick={() => {
if (step === "invoice" && intervalRef.current)
clearInterval(intervalRef.current)
if (step === "history" || step === "manage-token") {
if (step === "first-invoice") {
cancelPoll()
setStep("first-purchase")
} else if (step === "invoice") {
cancelPoll()
setPaymentRequest("")
setStep("amount")
} else if (step === "history" || step === "manage-token") {
setStep("balance")
} else if (step === "restore") {
setStep("manage-token")
} else if (step === "first-invoice") {
setStep("first-purchase")
} else {
setStep(step === "invoice" ? "amount" : "balance")
setStep(step === "amount" ? "balance" : "balance")
if (step === "amount") setAmount(null)
if (step === "invoice") setPaymentRequest("")
}
setError("")
}}
Expand Down Expand Up @@ -662,7 +677,11 @@ export function BudgetModal() {
) : (
<div className="max-h-72 overflow-y-auto space-y-1 pr-1">
{transactions
.filter(tx => tx.action !== 'refund' && tx.action !== 'boost_refund')
.filter(tx =>
tx.action !== 'refund' &&
tx.action !== 'boost_refund' &&
!isViewGrantRow(tx)
)
.map((tx, i) => (
<div key={i} className="flex items-center justify-between rounded-md px-3 py-2 bg-muted/20">
<div className="flex items-center gap-2">
Expand Down
151 changes: 151 additions & 0 deletions src/lib/__tests__/budget-modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ vi.mock("@/lib/mock-data", () => ({
vi.mock("@/lib/transaction-display", () => ({
getActionDisplayLabel: vi.fn((action: string) => action),
getActionBadgeColor: vi.fn(() => ""),
isViewGrantRow: vi.fn((tx: { action: string; amount: number }) =>
tx.action === "purchase" && Number(tx.amount) === 0
),
}))

// --- API mock ---
Expand Down Expand Up @@ -313,3 +316,151 @@ describe("BudgetModal Manage Token flow", () => {
expect(cookieStorage.getItem("l402")).toBeNull()
})
})

describe("BudgetModal back-nav poll cancellation", () => {
beforeEach(() => {
vi.clearAllMocks()
mockBudget = 500
mockRefreshBalance.mockResolvedValue(undefined)
mockIsSphinx.mockReturnValue(false)
mockHasWebLN.mockReturnValue(false)
mockApiGet.mockReset()
cookieStorage.removeItem("l402")
})

it("clicking Back from first-invoice aborts the poll, resets loading, returns to first-purchase with no error", async () => {
// pollPaymentStatus never resolves unless signal is aborted
mockPollPaymentStatus.mockImplementation(
(_hash: string, _max: number, _interval: number, signal?: AbortSignal) =>
new Promise<boolean>((resolve) => {
if (signal) {
signal.addEventListener("abort", () => resolve(false))
}
// Never resolves on its own (simulates long poll)
})
)
mockFetchBuyLsatChallenge.mockResolvedValue({
invoice: "lnbcbuy999",
baseMacaroon: "mac999",
paymentHash: "hash999",
id: "lsat999",
})

render(<BudgetModal />)

// Start first-purchase flow
fireEvent.click(screen.getByText("Top Up"))
await waitFor(() => expect(screen.getByText("Get Started")).toBeInTheDocument())

fireEvent.click(screen.getByText("Generate Invoice"))

// Wait for first-invoice step (QR shown)
await waitFor(() => expect(screen.getByText("Pay Invoice")).toBeInTheDocument())

// Click Back — should abort poll and return to first-purchase
const backBtn = screen.getByRole("button", { name: "Go back" })
fireEvent.click(backBtn)

await waitFor(() => expect(screen.getByText("Get Started")).toBeInTheDocument())

// No error text
expect(screen.queryByText(/Payment not detected/)).not.toBeInTheDocument()
expect(screen.queryByText(/Processing/)).not.toBeInTheDocument()
})

it("clicking Back from invoice step aborts the poll and returns to amount step with no error", async () => {
// Set up existing L402 for manual QR flow
cookieStorage.setItem("l402", JSON.stringify({ macaroon: "mac123", preimage: "" }))
mockTopUpLsat.mockResolvedValue({ payment_request: "lnbctest456", payment_hash: "hash456" })

mockPollPaymentStatus.mockImplementation(
(_hash: string, _max: number, _interval: number, signal?: AbortSignal) =>
new Promise<boolean>((resolve) => {
if (signal) {
signal.addEventListener("abort", () => resolve(false))
}
})
)

render(<BudgetModal />)

// Navigate to amount step
fireEvent.click(screen.getByText("Top Up"))
await waitFor(() => expect(screen.getByPlaceholderText("Custom amount")).toBeInTheDocument())

// Select preset amount
fireEvent.click(screen.getByText("50"))

// Click Generate Invoice → goes to invoice step
fireEvent.click(screen.getByText("Generate Invoice"))

// Wait for invoice step
await waitFor(() => expect(screen.getByText("Pay Invoice")).toBeInTheDocument())

// Click Back
const backBtn = screen.getByRole("button", { name: "Go back" })
fireEvent.click(backBtn)

await waitFor(() => expect(screen.getByPlaceholderText("Custom amount")).toBeInTheDocument())

expect(screen.queryByText(/Payment not detected/)).not.toBeInTheDocument()
expect(screen.queryByText(/Processing/)).not.toBeInTheDocument()
})
})

describe("BudgetModal history view-grant filtering", () => {
beforeEach(() => {
vi.clearAllMocks()
mockBudget = 500
mockRefreshBalance.mockResolvedValue(undefined)
mockIsSphinx.mockReturnValue(false)
mockHasWebLN.mockReturnValue(false)
mockApiGet.mockReset()
cookieStorage.setItem("l402", JSON.stringify({ macaroon: "mac123", preimage: "" }))
})

it("does not render zero-amount purchase (view-grant) rows in History", async () => {
mockFetchTransactionHistory.mockResolvedValue({
transactions: [
{ action: "purchase", type: "debit", amount: 0, created_at: null },
{ action: "top_up", type: "credit", amount: 500, created_at: null },
],
scope: "token",
})

render(<BudgetModal />)

fireEvent.click(screen.getByText("History"))

await waitFor(() => {
expect(screen.getByText("top_up")).toBeInTheDocument()
})

// Zero-amount purchase should be filtered out
expect(screen.queryByText("-0 sats")).not.toBeInTheDocument()
// top_up row should be present
expect(screen.getByText("+500 sats")).toBeInTheDocument()
})

it("renders non-zero purchase rows in History", async () => {
mockFetchTransactionHistory.mockResolvedValue({
transactions: [
{ action: "purchase", type: "debit", amount: 10, created_at: null },
{ action: "purchase", type: "debit", amount: 0, created_at: null },
],
scope: "token",
})

render(<BudgetModal />)

fireEvent.click(screen.getByText("History"))

await waitFor(() => {
// The non-zero purchase row should be visible
expect(screen.getByText("-10 sats")).toBeInTheDocument()
})

// The zero-amount purchase should NOT appear
expect(screen.queryByText("-0 sats")).not.toBeInTheDocument()
})
})
32 changes: 32 additions & 0 deletions src/lib/__tests__/transaction-display.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest"
import { isViewGrantRow } from "../transaction-display"

describe("isViewGrantRow", () => {
it("returns true for a zero-amount purchase (view-grant row)", () => {
expect(isViewGrantRow({ action: "purchase", type: "debit", amount: 0 })).toBe(true)
})

it("returns false for a non-zero purchase", () => {
expect(isViewGrantRow({ action: "purchase", type: "debit", amount: 10 })).toBe(false)
})

it("returns false for a zero-amount top_up", () => {
expect(isViewGrantRow({ action: "top_up", type: "credit", amount: 0 })).toBe(false)
})

it("returns false for a zero-amount payout", () => {
expect(isViewGrantRow({ action: "payout", type: "credit", amount: 0 })).toBe(false)
})

it("returns false for a zero-amount boost", () => {
expect(isViewGrantRow({ action: "boost", type: "debit", amount: 0 })).toBe(false)
})

it("handles string amounts — returns true for '0' purchase", () => {
expect(isViewGrantRow({ action: "purchase", type: "debit", amount: "0" as unknown as number })).toBe(true)
})

it("handles string amounts — returns false for '10' purchase", () => {
expect(isViewGrantRow({ action: "purchase", type: "debit", amount: "10" as unknown as number })).toBe(false)
})
})
11 changes: 9 additions & 2 deletions src/lib/sphinx/payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,16 +153,23 @@ export async function topUpStatus(paymentHash: string): Promise<PaymentStatus> {
export async function pollPaymentStatus(
paymentHash: string,
maxAttempts = 20,
intervalMs = 2000
intervalMs = 2000,
signal?: AbortSignal
): Promise<PaymentStatus> {
for (let i = 0; i < maxAttempts; i++) {
if (signal?.aborted) return { paid: false }
try {
const status = await topUpStatus(paymentHash)
if (status.paid) return status
} catch {
// status check failed — keep polling
}
await new Promise((r) => setTimeout(r, intervalMs))
if (signal?.aborted) return { paid: false }
// Abortable sleep — wakes immediately on cancel rather than waiting full intervalMs
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, intervalMs)
signal?.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
})
}
return { paid: false }
}
Expand Down
15 changes: 15 additions & 0 deletions src/lib/transaction-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,18 @@ export function getActionDisplayLabel(action: string): string {
export function getActionBadgeColor(action: string): string {
return ACTION_BADGE_COLORS[action] ?? ACTION_BADGE_COLORS.other
}

export interface TransactionRow {
action: string
type: 'debit' | 'credit'
amount: number | string
}

/**
* Returns true for synthetic zero-amount purchase rows written by addNodeV2 /
* addContentV2 to grant submitters free re-access. These should not appear in
* the user-facing transaction history.
*/
export function isViewGrantRow(tx: TransactionRow): boolean {
return tx.action === 'purchase' && Number(tx.amount) === 0
}
Loading