Skip to content
Open
49 changes: 34 additions & 15 deletions src/components/modals/budget-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { Separator } from "@/components/ui/separator"
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 @@ -46,7 +46,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 @@ -64,8 +64,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 @@ -89,7 +96,7 @@ export function BudgetModal() {

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

Expand Down Expand Up @@ -200,12 +207,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 paid = await pollPaymentStatus(challenge.paymentHash)
const paid = await pollPaymentStatus(challenge.paymentHash, 20, 2000, controller.signal)
if (controller.signal.aborted) return
if (!paid) {
setError("Payment not detected. Try again.")
setStep("first-purchase")
Expand Down Expand Up @@ -282,10 +292,12 @@ export function BudgetModal() {
setPaymentHash(result.payment_hash)
setStep("invoice")

const manualController = new AbortController()
pollAbortRef.current = manualController
let confirming = false
const paid = await pollPaymentStatus(result.payment_hash, 100, 3000)
const paid = await pollPaymentStatus(result.payment_hash, 100, 3000, manualController.signal)
if (manualController.signal.aborted) return
if (!paid) {
if (intervalRef.current) clearInterval(intervalRef.current)
setError("Payment not detected. Try again.")
setStep("amount")
return
Expand Down Expand Up @@ -340,19 +352,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 @@ -682,7 +697,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 @@ -151,16 +151,23 @@ export async function topUpStatus(paymentHash: string): Promise<boolean> {
export async function pollPaymentStatus(
paymentHash: string,
maxAttempts = 20,
intervalMs = 2000
intervalMs = 2000,
signal?: AbortSignal
): Promise<boolean> {
for (let i = 0; i < maxAttempts; i++) {
if (signal?.aborted) return false
try {
const paid = await topUpStatus(paymentHash)
if (paid) return true
} catch {
// status check failed — keep polling
}
await new Promise((r) => setTimeout(r, intervalMs))
if (signal?.aborted) return 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 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