diff --git a/web/README.md b/web/README.md index bec24a99..9185cb47 100644 --- a/web/README.md +++ b/web/README.md @@ -623,6 +623,19 @@ signing still requires the CDM writer profile. Live host validation and owner-approved upgrades are separate release gates. See [native contributions](../docs/design/native-contributions.md). +After a gift or tip is submitted, its sheet checks for a finalized contribution +without sending a second transaction. Automatic checks use a bounded backoff, +pause while the page is hidden, and stop after six retries to protect the shared +RPC. They can continue while the sheet is closed; reopening a saved contribution +or choosing `Check status again` starts another bounded reconciliation window. +After a reload, reopening the gift/tip action resumes the saved intent for the +connected payer. A finalized receipt is required for success; after submission, +a finalized reverted EVM receipt is required to report a network failure. If +Product supplies no usable transaction receipt or the read network remains +unavailable, Dotify cannot prove either outcome and keeps the intent pending; do +not clear browser storage or repeat the payment. The sheet shows a transaction +link when an EVM hash is available. + ### Listening and contribution actions The player separates audio controls from voluntary contributions and access diff --git a/web/e2e/artist-gift.spec.ts b/web/e2e/artist-gift.spec.ts index 9fcedd45..d8e1839a 100644 --- a/web/e2e/artist-gift.spec.ts +++ b/web/e2e/artist-gift.spec.ts @@ -24,7 +24,7 @@ async function review(page: Page, amount = '0.25', kind: 'gift' | 'tip' = 'gift' await contributionDialog(page).getByRole('button', { name: 'Review contribution', exact: true }).click(); } async function giftState(page: Page) { - return page.evaluate(() => Reflect.get(window, '__DOTIFY_E2E_DONATION__') as { sends: number; confirmed: boolean }); + return page.evaluate(() => Reflect.get(window, '__DOTIFY_E2E_DONATION__') as { sends: number; confirmed: boolean; finalizedReads?: number }); } for (const width of [320, 390, 430, 1440]) { test(`gift review and dated receipt at ${width}px preserve listening access`, async ({ page }, info) => { @@ -217,24 +217,52 @@ test('a pending contribution can close and reopen without another transfer', asy await openGift(page, '?e2eGift=pending'); await review(page, '0.1'); await page.getByRole('button', { name: 'Confirm gift · 0.1 PAS', exact: true }).click(); - await expect(contributionDialog(page).getByRole('status')).toContainText('Closing does not cancel'); + await expect(contributionDialog(page).getByRole('status')).toContainText('Waiting for confirmation'); await page.getByRole('button', { name: 'Close contribution' }).click(); await page.getByRole('button', { name: 'Send a gift', exact: true }).click(); - await expect(contributionDialog(page).getByRole('status')).toContainText('Closing does not cancel'); + await expect(contributionDialog(page).getByRole('status')).toContainText('Waiting for confirmation'); expect((await giftState(page)).sends).toBe(1); await page.evaluate(() => Reflect.get(window, '__DOTIFY_E2E_DONATION__').complete()); await expect(contributionDialog(page).getByRole('heading', { name: 'Gift sent', exact: true })).toBeVisible(); }); +test('a reload resumes the saved contribution without a new signature', async ({ page }) => { + await openGift(page, '?e2eGift=pending'); + await review(page, '0.1'); + await page.getByRole('button', { name: 'Confirm gift · 0.1 PAS', exact: true }).click(); + await expect(contributionDialog(page).getByRole('status')).toContainText('Waiting for confirmation'); + await expect + .poll( + () => + page.evaluate(() => { + const key = Object.keys(localStorage).find(item => item.startsWith('dotify.contribution.v1:')); + const raw = key ? localStorage.getItem(key) : null; + return raw ? (JSON.parse(raw) as { hash?: string }).hash : undefined; + }), + { timeout: 15000 } + ) + .toMatch(/^0x[\da-f]{64}$/i); + const saved = await page.evaluate(() => { + const key = Object.keys(localStorage).find(item => item.startsWith('dotify.contribution.v1:')); + return key ? { key, value: localStorage.getItem(key) } : null; + }); + expect(saved?.value).toBeTruthy(); + await page.reload(); + await page.locator('.catalogue-card .artist-text-button').first().click(); + await page.getByRole('main').getByRole('button', { name: 'Send a gift', exact: true }).click(); + await expect(contributionDialog(page).getByRole('status')).toContainText('Waiting for confirmation'); + await expect(amountField(page)).toHaveCount(0); + expect(await page.evaluate(key => localStorage.getItem(key), saved!.key)).toBe(saved!.value); + expect(await page.evaluate(() => Reflect.get(window, '__DOTIFY_E2E_DONATION__')?.sends ?? 0)).toBe(0); +}); test('an interrupted contribution is recovered without a second payment', async ({ page }) => { await openGift(page, '?e2eGift=delayed'); await review(page, '0.1'); await page.getByRole('button', { name: 'Confirm gift · 0.1 PAS', exact: true }).click(); - await expect(contributionDialog(page)).toContainText('Confirmation is taking longer than expected'); + await expect(contributionDialog(page).getByRole('status')).toContainText('Checking network finality'); await page.evaluate(() => { Reflect.get(window, '__DOTIFY_E2E_DONATION__').confirmed = true; }); - await page.getByRole('button', { name: 'Check payment status' }).click(); - await expect(contributionDialog(page).getByRole('heading', { name: 'Gift sent', exact: true })).toBeVisible(); + await expect(contributionDialog(page).getByRole('heading', { name: 'Gift sent', exact: true })).toBeVisible({ timeout: 15000 }); expect((await giftState(page)).sends).toBe(1); }); test('zero amounts and rejected signatures never show a successful receipt', async ({ page }) => { @@ -247,24 +275,22 @@ test('zero amounts and rejected signatures never show a successful receipt', asy await expect(contributionDialog(page)).not.toContainText('Gift sent'); }); -test('a mobile confirmation timeout stays actionable without exposing raw wallet errors', async ({ page }, info) => { +test('a mobile confirmation timeout keeps checking without exposing raw wallet errors', async ({ page }, info) => { await page.setViewportSize({ width: 320, height: 844 }); await openGift(page, '?e2eGift=timeout'); await review(page, '0.1'); await page.getByRole('button', { name: 'Confirm gift · 0.1 PAS', exact: true }).click(); const dialog = contributionDialog(page); - await expect(dialog).toContainText('Confirmation is taking longer than expected'); - await expect(dialog).toContainText('Checking status verifies this payment. It never sends another.'); - await expect(dialog.getByRole('button', { name: 'Check payment status' })).toBeVisible(); + await expect(dialog.getByRole('status')).toContainText('Checking network finality'); + await expect(dialog.getByRole('status')).toContainText('without sending another payment'); + const state = await giftState(page); + expect(state.sends).toBe(1); + expect(state.finalizedReads).toBe(1); + await expect(dialog.getByRole('button', { name: /Confirm gift|Confirming/ })).toHaveCount(0); await expect(dialog.getByRole('link', { name: 'View transaction' })).toBeVisible(); - const technicalError = dialog.locator('.contribution-technical code'); - await expect(technicalError).not.toBeVisible(); - await dialog.getByText('Technical details', { exact: true }).click(); - await expect(technicalError).toBeVisible(); - await expect(technicalError).toContainText('viem@2.55.19'); + await expect(dialog).not.toContainText('viem@2.55.19'); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); - const actions = dialog.locator('.contribution-result-actions'); - expect((await actions.boundingBox())!.width).toBeLessThanOrEqual((await dialog.boundingBox())!.width); - await page.screenshot({ path: info.outputPath('tip-timeout-mobile-320.png'), animations: 'disabled' }); - expect((await giftState(page)).sends).toBe(1); + const pending = dialog.locator('.contribution-pending'); + expect((await pending.boundingBox())!.width).toBeLessThanOrEqual((await dialog.boundingBox())!.width); + await page.screenshot({ path: info.outputPath('tip-pending-mobile-320.png'), animations: 'disabled' }); }); diff --git a/web/src/components/ArtistDonationButton.tsx b/web/src/components/ArtistDonationButton.tsx index 9de2cd5d..dc54ce2e 100644 --- a/web/src/components/ArtistDonationButton.tsx +++ b/web/src/components/ArtistDonationButton.tsx @@ -1,5 +1,5 @@ -import { CircleCheckBig, Coins, ExternalLink, HandHeart, X } from 'lucide-react'; -import { useLayoutEffect, useRef, useState } from 'react'; +import { CircleCheckBig, Coins, ExternalLink, HandHeart, LoaderCircle, X } from 'lucide-react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { formatEther, parseEther, parseAbi, zeroHash, type Address, type Hash } from 'viem'; import { musicRegistryAbi } from '../generated/contracts/musicRegistry'; import { contributionE2e } from '../e2e/contributionMock'; @@ -7,7 +7,15 @@ import { Dialog } from './Dialog'; import { useWalletContext, useSessionContext, useUiFeedback } from '../app/providers'; import type { CatalogTrack } from '../shared/types'; import { confirmSubmittedContribution, contributionReader, newContributionContext } from '../features/donations/contributions'; -import { runContribution, type ContributionIntent, type ContributionOutcome } from '../features/donations/contributionFlow'; +import { + readSavedContribution, + recoverSavedContribution, + runContribution, + type ContributionIntent, + type ContributionOutcome, + type ContributionScope +} from '../features/donations/contributionFlow'; +import { contributionReconciliationDelay, waitForContributionReconciliation } from '../features/donations/contributionReconciliation'; import { useContributionWriter } from '../features/donations/useContributionWriter'; import { nativeCurrencyForChain } from '../shared/config/contracts'; import { getBlockscoutTxUrl } from '../shared/utils/explorer'; @@ -33,9 +41,22 @@ function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) { const [outcome, setOutcome] = useState(); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); + const [pending, setPending] = useState(false); + const [checkingFinality, setCheckingFinality] = useState(false); + const [pendingHash, setPendingHash] = useState(); const [purpose, setPurpose] = useState(''); const [available, setAvailable] = useState(); const account = useRef(wallet.listenerEvmAddress); + const monitoring = useRef(false); + const monitorAbort = useRef(); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + monitorAbort.current?.abort(); + }; + }, []); useLayoutEffect(() => { account.current = wallet.listenerEvmAddress; return () => { @@ -49,6 +70,16 @@ function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) { const ContributionIcon = kind === 'tip' ? Coins : HandHeart; const ActionIcon = confirmed ? CircleCheckBig : ContributionIcon; const symbol = (wallet.expectedChainId ? nativeCurrencyForChain(wallet.expectedChainId, wallet.ethRpcUrl).symbol : '') || 'PAS'; + const network = wallet.expectedChainId ?? wallet.connectedWallet?.chainId; + const contributionScope: ContributionScope | undefined = + wallet.listenerEvmAddress && network + ? { + network, + sender: wallet.listenerEvmAddress, + runtime: track.id.split(':')[0] as Address, + context: { contentHash: kind === 'tip' ? (track.hash as Hash) : zeroHash } + } + : undefined; async function review() { if (busy) return; const reader = contributionReader(wallet.ethRpcUrl); @@ -134,41 +165,83 @@ function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) { setBusy(false); } } - async function send() { - if (!intent || busy) return; - setBusy(true); + async function monitor(prepared?: ContributionIntent) { + if (monitoring.current || !contributionScope) return; + const abort = new AbortController(); + monitorAbort.current = abort; + monitoring.current = true; + setPending(true); + setCheckingFinality(false); + if (prepared) setPendingHash(undefined); + setOutcome(undefined); setError(''); - const reader = contributionReader(wallet.ethRpcUrl); try { - const result = await runContribution({ - intent, - storage: localStorage, - currentAccount: () => account.current?.toLowerCase() === intent.sender.toLowerCase(), - send: async () => { - try { - if ((await reader.client.getChainId()) !== intent.network) throw new Error('The network changed.'); - const latest = await reader.quote(intent.runtime, intent.context, intent.amount); - if (latest.digest !== intent.quote.digest) throw new Error('The distribution changed. Review it again before sending.'); - if (account.current?.toLowerCase() !== intent.sender.toLowerCase()) - throw new Error('The account or listening context changed. Prepare this contribution again.'); - if (!writer.contributionCall) throw new Error('This wallet cannot submit contributions.'); - } catch (failure) { - throw new SupportNotSubmittedError(failure); + const reader = contributionReader(wallet.ethRpcUrl); + let first = true; + let retry = 0; + while (mounted.current && !abort.signal.aborted) { + const scope = prepared ?? contributionScope; + const check = { + confirm: (hash: Hash, id: Hash) => + confirmSubmittedContribution({ + mode: writer.contributionConfirmationMode, + runtime: scope.runtime, + hash, + id, + reader, + polling: writer.contributionConfirmationMode === 'finalized-event' ? { attempts: 1 } : undefined + }), + recover: (id: Hash) => reader.finalizedReceipt(scope.runtime, id), + currentAccount: () => account.current?.toLowerCase() === scope.sender.toLowerCase() + }; + const result = + prepared && first + ? await runContribution({ + intent: prepared, + storage: localStorage, + ...check, + send: async () => { + try { + if ((await reader.client.getChainId()) !== prepared.network) throw new Error('The network changed.'); + const latest = await reader.quote(prepared.runtime, prepared.context, prepared.amount); + if (latest.digest !== prepared.quote.digest) throw new Error('The distribution changed. Review it again before sending.'); + if (account.current?.toLowerCase() !== prepared.sender.toLowerCase()) + throw new Error('The account or listening context changed. Prepare this contribution again.'); + if (!writer.contributionCall) throw new Error('This wallet cannot submit contributions.'); + } catch (failure) { + throw new SupportNotSubmittedError(failure); + } + return writer.contributionCall!( + prepared.runtime, + 'musicGiftContribute', + [prepared.context, prepared.quote.digest, prepared.proof], + prepared.amount + ); + } + }) + : await recoverSavedContribution({ scope, storage: localStorage, ...check }); + first = false; + if (!mounted.current) return; + setPendingHash(result.hash); + if (result.status === 'uncertain') { + const delay = contributionReconciliationDelay(retry); + if (delay === undefined) { + setOutcome({ + ...result, + message: + 'Automatic checks are paused to protect shared network capacity. Check this saved contribution again when you are ready; no new payment will be sent.' + }); + setPending(false); + break; } - return writer.contributionCall!(intent.runtime, 'musicGiftContribute', [intent.context, intent.quote.digest, intent.proof], intent.amount); - }, - confirm: (hash, id) => - confirmSubmittedContribution({ - mode: writer.contributionConfirmationMode, - runtime: intent.runtime, - hash, - id, - reader - }), - recover: async id => (await reader.history(intent.runtime)).find(row => row.id === id) - }); - setOutcome(result); - if (result.status === 'confirmed' && result.receipt) { + retry += 1; + setCheckingFinality(true); + if (!(await waitForContributionReconciliation(delay, abort.signal))) return; + continue; + } + setOutcome(result); + setPending(false); + if (result.status !== 'confirmed' || !result.receipt) break; pushNotice({ tone: 'success', title: kind === 'tip' ? 'Tip sent' : 'Gift sent', @@ -177,18 +250,23 @@ function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) { ? `${formatEther(result.receipt.amount)} ${symbol} for “${track.title}”. The finalized receipt is ready.` : `${formatEther(result.receipt.amount)} ${symbol} for ${track.artist}. The finalized receipt is ready.` }); + if (result.receipt.room !== zeroHash) + session.socketRef.current?.request( + 'room:tip-notify', + { runtime: scope.runtime, hash: result.receipt.transactionHash }, + { timeoutMs: 15000 }, + () => {} + ); + break; } - if (result.receipt && result.receipt.room !== zeroHash) - session.socketRef.current?.request( - 'room:tip-notify', - { runtime: intent.runtime, hash: result.receipt.transactionHash }, - { timeoutMs: 15000 }, - () => {} - ); } catch (failure) { - setError(failure instanceof Error ? failure.message : 'Could not check the contribution.'); + if (mounted.current) { + setError(failure instanceof Error ? failure.message : 'Could not check the contribution.'); + setPending(false); + } } finally { - setBusy(false); + if (monitorAbort.current === abort) monitorAbort.current = undefined; + monitoring.current = false; } } return ( @@ -203,6 +281,17 @@ function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) { onClick={event => { event.currentTarget.focus(); setOpen(true); + if (!contributionScope) return; + try { + const saved = readSavedContribution(contributionScope, localStorage); + if (saved) { + setIntent(undefined); + setPendingHash(saved.hash); + void monitor(); + } + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'Could not read the saved contribution.'); + } }} >