From 57a8cfda13f82f6d6a48de2fa9e2388c92625504 Mon Sep 17 00:00:00 2001 From: Kevin Nzeng Essimengane Date: Sun, 4 Oct 2026 20:04:02 +0200 Subject: [PATCH 1/4] fix(contributions): track pending gifts and tips until finality Keep a visible pending state and automatically recheck saved intents without resubmitting. Resume after reload, report only proven finalized EVM reverts as failures, and cover wallet, mobile, and recovery paths. --- web/README.md | 10 + web/e2e/artist-gift.spec.ts | 41 ++-- web/src/components/ArtistDonationButton.tsx | 209 ++++++++++++------ .../donations/contributionFlow.test.ts | 45 +++- .../features/donations/contributionFlow.ts | 95 ++++++-- .../features/donations/contributions.test.ts | 9 +- web/src/features/donations/contributions.ts | 9 +- web/src/styles/contributions.css | 36 ++- 8 files changed, 337 insertions(+), 117 deletions(-) diff --git a/web/README.md b/web/README.md index bec24a99..ad290035 100644 --- a/web/README.md +++ b/web/README.md @@ -623,6 +623,16 @@ 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 keeps checking for a finalized +contribution without sending a second transaction. The check continues while +the page is open, even if the sheet is closed; 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..b48691f8 100644 --- a/web/e2e/artist-gift.spec.ts +++ b/web/e2e/artist-gift.spec.ts @@ -217,24 +217,35 @@ 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 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 giftState(page)).sends).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 +258,20 @@ 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'); + 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' }); + 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' }); expect((await giftState(page)).sends).toBe(1); }); diff --git a/web/src/components/ArtistDonationButton.tsx b/web/src/components/ArtistDonationButton.tsx index 9de2cd5d..261350b5 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,14 @@ 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 { useContributionWriter } from '../features/donations/useContributionWriter'; import { nativeCurrencyForChain } from '../shared/config/contracts'; import { getBlockscoutTxUrl } from '../shared/utils/explorer'; @@ -33,9 +40,20 @@ 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 mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); useLayoutEffect(() => { account.current = wallet.listenerEvmAddress; return () => { @@ -49,6 +67,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 +162,62 @@ 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; + 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); - } - 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) { + const reader = contributionReader(wallet.ethRpcUrl); + let first = true; + while (mounted.current) { + const scope = prepared ?? contributionScope; + const check = { + confirm: (hash: Hash, id: Hash) => + confirmSubmittedContribution({ mode: writer.contributionConfirmationMode, runtime: scope.runtime, hash, id, reader }), + 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') { + setCheckingFinality(true); + await new Promise(resolve => setTimeout(resolve, 5000)); + continue; + } + setOutcome(result); + setPending(false); + if (result.status !== 'confirmed' || !result.receipt) break; pushNotice({ tone: 'success', title: kind === 'tip' ? 'Tip sent' : 'Gift sent', @@ -177,18 +226,22 @@ 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); + monitoring.current = false; } } return ( @@ -203,6 +256,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.'); + } }} >