diff --git a/web/e2e/artist-gift.spec.ts b/web/e2e/artist-gift.spec.ts index ed37dee9..9fcedd45 100644 --- a/web/e2e/artist-gift.spec.ts +++ b/web/e2e/artist-gift.spec.ts @@ -37,14 +37,15 @@ for (const width of [320, 390, 430, 1440]) { expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ path: info.outputPath(`gift-review-${width}.png`), animations: 'disabled' }); await page.getByRole('button', { name: 'Confirm gift · 0.25 PAS', exact: true }).click(); - await expect(dialog).toContainText('Contribution confirmed'); + await expect(dialog.getByRole('heading', { name: 'Gift sent', exact: true })).toBeVisible(); await expect(dialog.locator('time')).toBeVisible(); expect((await giftState(page)).sends).toBe(1); const access = await page.evaluate(() => Reflect.get(window, '__DOTIFY_E2E_CLASSIC_UNLOCK__') as { paid: boolean; accessGranted: boolean }); expect(access.paid).toBe(false); expect(access.accessGranted).toBe(false); await page.getByRole('button', { name: 'Close contribution' }).click(); - await expect(page.getByRole('button', { name: 'Send a gift', exact: true })).toBeFocused(); + await expect(page.getByRole('button', { name: 'Gift sent. View receipt', exact: true })).toBeFocused(); + await expect(page.locator('.toast-card[data-tone="success"]')).toContainText('Gift sent'); }); } for (const width of [320, 390, 430, 1440]) { @@ -85,12 +86,20 @@ for (const width of [320, 390, 430, 1440]) { await expect(contributionDialog(page)).toContainText('Listening access stays unchanged'); await review(page, '0.5', 'tip'); await contributionDialog(page).getByRole('button', { name: 'Confirm tip · 0.5 PAS', exact: true }).click(); - await expect(contributionDialog(page)).toContainText('Contribution confirmed'); + const dialog = contributionDialog(page); + await expect(dialog.getByRole('heading', { name: 'Tip sent', exact: true })).toBeVisible(); + await expect(dialog).toContainText('0.5 PAS for “Deterministic Classic Unlock”. Finalized and recorded.'); const access = await page.evaluate(() => Reflect.get(window, '__DOTIFY_E2E_CLASSIC_UNLOCK__') as { paid: boolean; accessGranted: boolean }); expect(access.paid).toBe(false); expect(access.accessGranted).toBe(false); await page.getByRole('button', { name: 'Close contribution' }).click(); - await expect(tip).toBeFocused(); + const sentTip = page.getByRole('button', { name: 'Tip sent. View receipt', exact: true }); + await expect(sentTip).toBeFocused(); + await expect(sentTip).toHaveAttribute('data-confirmed', 'true'); + const notice = page.locator('.toast-card[data-tone="success"]'); + await expect(notice).toContainText('Tip sent'); + await expect(notice).toContainText('0.5 PAS for “Deterministic Classic Unlock”'); + await page.screenshot({ path: info.outputPath(`tip-confirmed-${width}.png`), animations: 'disabled' }); await page.getByRole('button', { name: 'Unlock listening', exact: true }).click(); await expect(page.getByTestId('classic-unlock-button')).toHaveAccessibleName('Pay 0.5 PAS to unlock Deterministic Classic Unlock'); }); @@ -99,7 +108,7 @@ test('a confirmed gift is visible in You with a dated exportable receipt', async await openGift(page); await review(page, '0.5'); await page.getByRole('button', { name: 'Confirm gift · 0.5 PAS', exact: true }).click(); - await expect(contributionDialog(page)).toContainText('Contribution confirmed'); + await expect(contributionDialog(page).getByRole('heading', { name: 'Gift sent', exact: true })).toBeVisible(); await page.getByRole('button', { name: 'Close contribution' }).click(); await page.getByRole('button', { name: 'You', exact: true }).click(); const history = page.locator('.contribution-history'); @@ -214,18 +223,18 @@ test('a pending contribution can close and reopen without another transfer', asy await expect(contributionDialog(page).getByRole('status')).toContainText('Closing does not cancel'); expect((await giftState(page)).sends).toBe(1); await page.evaluate(() => Reflect.get(window, '__DOTIFY_E2E_DONATION__').complete()); - await expect(contributionDialog(page)).toContainText('Contribution confirmed'); + await expect(contributionDialog(page).getByRole('heading', { name: 'Gift sent', exact: true })).toBeVisible(); }); 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 was interrupted'); + await expect(contributionDialog(page)).toContainText('Confirmation is taking longer than expected'); await page.evaluate(() => { Reflect.get(window, '__DOTIFY_E2E_DONATION__').confirmed = true; }); - await page.getByRole('button', { name: 'Check status · no new payment' }).click(); - await expect(contributionDialog(page)).toContainText('Contribution confirmed'); + await page.getByRole('button', { name: 'Check payment status' }).click(); + await expect(contributionDialog(page).getByRole('heading', { name: 'Gift sent', exact: true })).toBeVisible(); expect((await giftState(page)).sends).toBe(1); }); test('zero amounts and rejected signatures never show a successful receipt', async ({ page }) => { @@ -235,5 +244,27 @@ test('zero amounts and rejected signatures never show a successful receipt', asy await review(page, '1'); await page.getByRole('button', { name: 'Confirm gift · 1 PAS', exact: true }).click(); await expect(contributionDialog(page)).toContainText('No contribution was sent'); - await expect(contributionDialog(page)).not.toContainText('Contribution confirmed'); + await expect(contributionDialog(page)).not.toContainText('Gift sent'); +}); + +test('a mobile confirmation timeout stays actionable 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('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'); + 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); }); diff --git a/web/src/components/ArtistDonationButton.tsx b/web/src/components/ArtistDonationButton.tsx index 378dccce..9de2cd5d 100644 --- a/web/src/components/ArtistDonationButton.tsx +++ b/web/src/components/ArtistDonationButton.tsx @@ -1,4 +1,4 @@ -import { Coins, HandHeart, X } from 'lucide-react'; +import { CircleCheckBig, Coins, ExternalLink, HandHeart, X } from 'lucide-react'; import { useLayoutEffect, useRef, useState } from 'react'; import { formatEther, parseEther, parseAbi, zeroHash, type Address, type Hash } from 'viem'; import { musicRegistryAbi } from '../generated/contracts/musicRegistry'; @@ -6,7 +6,7 @@ import { contributionE2e } from '../e2e/contributionMock'; import { Dialog } from './Dialog'; import { useWalletContext, useSessionContext, useUiFeedback } from '../app/providers'; import type { CatalogTrack } from '../shared/types'; -import { contributionReader, newContributionContext } from '../features/donations/contributions'; +import { confirmSubmittedContribution, contributionReader, newContributionContext } from '../features/donations/contributions'; import { runContribution, type ContributionIntent, type ContributionOutcome } from '../features/donations/contributionFlow'; import { useContributionWriter } from '../features/donations/useContributionWriter'; import { nativeCurrencyForChain } from '../shared/config/contracts'; @@ -25,7 +25,7 @@ export function ArtistDonationButton(props: ContributionButtonProps) { function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) { const wallet = useWalletContext(); const session = useSessionContext(); - const { openWalletModal } = useUiFeedback(); + const { openWalletModal, pushNotice } = useUiFeedback(); const writer = useContributionWriter(); const [open, setOpen] = useState(false); const [amount, setAmount] = useState(''); @@ -43,7 +43,11 @@ function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) { }; }, [wallet.listenerEvmAddress]); const label = kind === 'tip' ? 'Tip this track' : 'Send a gift'; + const confirmed = outcome?.status === 'confirmed' && Boolean(outcome.receipt); + const actionLabel = confirmed ? (kind === 'tip' ? 'Tip sent. View receipt' : 'Gift sent. View receipt') : label; + const visibleActionLabel = confirmed ? (kind === 'tip' ? 'Tip sent' : 'Gift sent') : label; const ContributionIcon = kind === 'tip' ? Coins : HandHeart; + const ActionIcon = confirmed ? CircleCheckBig : ContributionIcon; const symbol = (wallet.expectedChainId ? nativeCurrencyForChain(wallet.expectedChainId, wallet.ethRpcUrl).symbol : '') || 'PAS'; async function review() { if (busy) return; @@ -153,20 +157,27 @@ function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) { } return writer.contributionCall!(intent.runtime, 'musicGiftContribute', [intent.context, intent.quote.digest, intent.proof], intent.amount); }, - confirm: async (hash, id) => { - try { - return await reader.receipt(intent.runtime, hash, id); - } catch (error) { - // Product can return a native extrinsic hash. The finalized event's - // intent identity also recovers its EVM receipt without another write. - const receipt = (await reader.history(intent.runtime)).find(row => row.id === id); - if (receipt) return receipt; - throw error; - } - }, + 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) { + pushNotice({ + tone: 'success', + title: kind === 'tip' ? 'Tip sent' : 'Gift sent', + message: + kind === 'tip' + ? `${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 && result.receipt.room !== zeroHash) session.socketRef.current?.request( 'room:tip-notify', @@ -185,19 +196,20 @@ function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) { @@ -301,8 +313,22 @@ function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) { )} {busy &&

You can close this window. Closing does not cancel a transaction.

} {outcome && ( -
-

{outcome.message}

+
+ {outcome.status === 'confirmed' && outcome.receipt ? ( +
+ +
+

{kind === 'tip' ? 'Tip sent' : 'Gift sent'}

+

+ {formatEther(outcome.receipt.amount)} {symbol} {kind === 'tip' ? `for “${track.title}”` : `for ${track.artist}`}. Finalized and recorded. +

+
+
+ ) : ( +

{outcome.message}

+ )} {outcome.receipt && ( <> @@ -321,27 +347,37 @@ function ContributionButton({ track, kind = 'gift' }: ContributionButtonProps) { )} - {outcome.hash && ( - - View transaction - - )} - {outcome.status === 'uncertain' ? ( - - ) : ( - + {outcome.technicalMessage && outcome.technicalMessage !== outcome.message && ( +
+ Technical details + {outcome.technicalMessage} +
)} + {outcome.status === 'uncertain' &&

Checking status verifies this payment. It never sends another.

} +
+ {outcome.hash && (writer.contributionConfirmationMode === 'evm-receipt' || outcome.receipt) && ( + + + )} + {outcome.status === 'uncertain' ? ( + + ) : ( + + )} +
)} {error &&

{error}

} diff --git a/web/src/e2e/contributionMock.ts b/web/src/e2e/contributionMock.ts index 6b42afe5..4e6c6794 100644 --- a/web/src/e2e/contributionMock.ts +++ b/web/src/e2e/contributionMock.ts @@ -45,7 +45,12 @@ export const contributionTestReader = { resolve(); }; }); - if (scenario === 'delayed' && !s.confirmed) throw new Error('Confirmation was interrupted. Check the status before sending again.'); + if ((scenario === 'delayed' || scenario === 'timeout') && !s.confirmed) + throw new Error( + scenario === 'timeout' + ? `Timed out while waiting for transaction "${E2E_CLASSIC_TX_HASH}" to be confirmed. Version: viem@2.55.19` + : 'Confirmation was interrupted. Check the status before sending again.' + ); s.confirmed = true; if (!s.receipt) throw new Error('No contribution'); return s.receipt; @@ -55,6 +60,9 @@ export const contributionTestReader = { if (s.historyError) throw new Error(s.historyError); if (s.receipts) return s.receipts.filter(row => row.runtime.toLowerCase() === runtime.toLowerCase()); return s.confirmed && s.receipt?.runtime.toLowerCase() === runtime.toLowerCase() ? [s.receipt] : []; + }, + async finalizedReceipt(runtime: Address, expectedId: Hash): Promise { + return (await contributionTestReader.history(runtime)).find(row => row.id === expectedId); } }; export async function contributionTestWrite(runtime: Address, method: string, args: readonly unknown[]) { diff --git a/web/src/features/donations/contributionFlow.test.ts b/web/src/features/donations/contributionFlow.test.ts index dd8ce2cc..c8f5ac94 100644 --- a/web/src/features/donations/contributionFlow.test.ts +++ b/web/src/features/donations/contributionFlow.test.ts @@ -52,7 +52,12 @@ describe('persistent contributions', () => { it('recovers after reload without resubmitting, even if the requested amount changed', async () => { const input = fixture(); input.confirm.mockRejectedValueOnce(new Error('timeout')); - expect((await runContribution(input)).status).toBe('uncertain'); + const interrupted = await runContribution(input); + expect(interrupted).toMatchObject({ + status: 'uncertain', + message: expect.stringContaining('taking longer than expected'), + technicalMessage: 'timeout' + }); expect(input.storage.getItem(contributionStorageKey(input.intent))).toContain(input.receipt.transactionHash); expect((await runContribution({ ...input, intent: { ...input.intent, amount: 99n } })).receipt?.amount).toBe(10n); expect(input.send).toHaveBeenCalledTimes(1); diff --git a/web/src/features/donations/contributionFlow.ts b/web/src/features/donations/contributionFlow.ts index 5218a054..fbc8e29b 100644 --- a/web/src/features/donations/contributionFlow.ts +++ b/web/src/features/donations/contributionFlow.ts @@ -13,7 +13,13 @@ export type ContributionIntent = { proof: Hash; }; type Saved = { id: Hash; amount: string; hash?: Hash }; -export type ContributionOutcome = { status: 'confirmed' | 'uncertain' | 'failed'; message: string; hash?: Hash; receipt?: ContributionReceipt }; +export type ContributionOutcome = { + status: 'confirmed' | 'uncertain' | 'failed'; + message: string; + technicalMessage?: string; + hash?: Hash; + receipt?: ContributionReceipt; +}; const operations = new Map>(); export function contributionStorageKey(intent: ContributionIntent) { @@ -65,10 +71,16 @@ export async function runContribution(input: { /* Preserve an uncertain reservation if storage is no longer writable. */ } } + const status = saved && !safe ? 'uncertain' : 'failed'; + const technicalMessage = error instanceof Error ? error.message : 'The contribution could not be checked.'; return { - status: saved && !safe ? 'uncertain' : 'failed', + status, hash: saved?.hash, - message: error instanceof Error ? error.message : 'The contribution could not be checked.' + message: + status === 'uncertain' + ? 'Confirmation is taking longer than expected. This contribution may already be finalized. Check its status before trying again.' + : technicalMessage, + technicalMessage: status === 'uncertain' ? technicalMessage : undefined }; } }; diff --git a/web/src/features/donations/contributions.test.ts b/web/src/features/donations/contributions.test.ts index e04d71a2..acef9d03 100644 --- a/web/src/features/donations/contributions.test.ts +++ b/web/src/features/donations/contributions.test.ts @@ -1,7 +1,63 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { encodeAbiParameters, encodeEventTopics as encodeTopics, zeroAddress, zeroHash, type Address, type Hash } from 'viem'; import { musicRoyaltiesAbi } from '../../generated/contracts/musicRoyalties'; -import { decodeContributions } from './contributions'; +import { confirmSubmittedContribution, decodeContributions, type ContributionReceipt } from './contributions'; + +const productRuntime = '0x1000000000000000000000000000000000000000' as Address; +const productHash = `0x${'11'.repeat(32)}` as Hash; +const productId = `0x${'22'.repeat(32)}` as Hash; +const receipt: ContributionReceipt = { + id: productId, + runtime: productRuntime, + contentHash: zeroHash, + sender: zeroAddress, + amount: 1n, + host: zeroAddress, + room: zeroHash, + campaign: zeroHash, + timestamp: 1, + transactionHash: productHash, + shares: [] +}; + +describe('contribution confirmation', () => { + it('confirms a finalized Product contribution by intent without waiting for an EVM receipt', async () => { + const reader = { + receipt: vi.fn(async () => receipt), + finalizedReceipt: vi.fn().mockResolvedValueOnce(undefined).mockResolvedValueOnce(receipt) + }; + const wait = vi.fn(async () => undefined); + + await expect( + confirmSubmittedContribution({ + mode: 'finalized-event', + runtime: productRuntime, + hash: productHash, + id: productId, + reader, + polling: { attempts: 2, intervalMs: 0, wait } + }) + ).resolves.toBe(receipt); + + expect(reader.receipt).not.toHaveBeenCalled(); + expect(reader.finalizedReceipt).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenCalledTimes(1); + }); + + it('keeps EVM receipt verification and can recover from its finalized event', async () => { + const failure = new Error('receipt provider delayed'); + const reader = { + receipt: vi.fn().mockRejectedValue(failure), + finalizedReceipt: vi.fn(async () => receipt) + }; + + await expect(confirmSubmittedContribution({ mode: 'evm-receipt', runtime: productRuntime, hash: productHash, id: productId, reader })).resolves.toBe( + receipt + ); + expect(reader.receipt).toHaveBeenCalledWith(productRuntime, productHash, productId); + expect(reader.finalizedReceipt).toHaveBeenCalledWith(productRuntime, productId); + }); +}); function encodeEventTopics(args: Parameters[0]): Hash[] { return encodeTopics(args).map(topic => { @@ -31,6 +87,7 @@ const share = (amount: bigint, paid: boolean, logIndex: number) => ({ topics: encodeEventTopics({ abi: musicRoyaltiesAbi, eventName: 'ContributionShare', args: { id, recipient } }), data: encodeAbiParameters([{ type: 'uint256' }, { type: 'uint8' }, { type: 'bool' }], [amount, 0, paid]) }); + describe('contribution ledger', () => { it('preserves block date and exact paid/pending shares without counting duplicate logs', () => { const paid = share(40n, true, 1), @@ -42,6 +99,7 @@ describe('contribution ledger', () => { expect(rows[0].shares.filter(s => s.paid).reduce((n, s) => n + s.amount, 0n)).toBe(40n); expect(rows[0].shares.filter(s => !s.paid && !s.claimed).reduce((n, s) => n + s.amount, 0n)).toBe(60n); }); + it('settles only pending shares after a claim and ignores receipts from another runtime', () => { const claim = { address: runtime, diff --git a/web/src/features/donations/contributions.ts b/web/src/features/donations/contributions.ts index 13dfdeeb..8a2b9e46 100644 --- a/web/src/features/donations/contributions.ts +++ b/web/src/features/donations/contributions.ts @@ -1,7 +1,18 @@ -import { decodeEventLog, encodeAbiParameters, keccak256, zeroAddress, zeroHash, type Address, type Hash, type ContractFunctionReturnType } from 'viem'; +import { + decodeEventLog, + encodeAbiParameters, + getAbiItem, + keccak256, + zeroAddress, + zeroHash, + type Address, + type Hash, + type ContractFunctionReturnType +} from 'viem'; import { musicRoyaltiesAbi } from '../../generated/contracts/musicRoyalties'; import { getPublicClient } from '../../shared/config/contracts'; import { contributionE2e, contributionTestReader } from '../../e2e/contributionMock'; +import type { ContributionConfirmationMode } from '../runtime/runtimePorts'; export type ContributionPolicy = ContractFunctionReturnType; export type ContributionQuote = ContractFunctionReturnType; @@ -37,6 +48,50 @@ export type ContributionReceipt = { transactionHash: Hash; shares: Array<{ recipient: Address; amount: bigint; role: number; paid: boolean; claimed: boolean }>; }; +const contributionReceivedEvent = getAbiItem({ abi: musicRoyaltiesAbi, name: 'ContributionReceived' }); +const contributionShareEvent = getAbiItem({ abi: musicRoyaltiesAbi, name: 'ContributionShare' }); + +export async function waitForFinalizedContribution( + read: () => Promise, + options: { attempts?: number; intervalMs?: number; wait?: (milliseconds: number) => Promise } = {} +): Promise { + const attempts = options.attempts ?? 10; + const intervalMs = options.intervalMs ?? 2_000; + const wait = options.wait ?? (milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))); + + for (let attempt = 0; attempt < attempts; attempt += 1) { + const receipt = await read(); + if (receipt) return receipt; + if (attempt + 1 < attempts) await wait(intervalMs); + } + + throw new Error('The finalized contribution is not visible from the read network yet. Check its status again; no new payment is needed.'); +} + +export async function confirmSubmittedContribution(input: { + mode: ContributionConfirmationMode; + runtime: Address; + hash: Hash; + id: Hash; + reader: { + receipt(runtime: Address, hash: Hash, expectedId: Hash): Promise; + finalizedReceipt(runtime: Address, expectedId: Hash): Promise; + }; + polling?: Parameters[1]; +}): Promise { + if (input.mode === 'finalized-event') { + return waitForFinalizedContribution(() => input.reader.finalizedReceipt(input.runtime, input.id), input.polling); + } + + try { + return await input.reader.receipt(input.runtime, input.hash, input.id); + } catch (error) { + const receipt = await input.reader.finalizedReceipt(input.runtime, input.id); + if (receipt) return receipt; + throw error; + } +} + export function contributionReader(rpc: string) { const client = getPublicClient(rpc); if (contributionE2e) return { ...contributionTestReader, client: { ...client, ...contributionTestReader.client } }; @@ -58,6 +113,26 @@ export function contributionReader(rpc: string) { if (!result) throw new Error('The receipt does not match this contribution.'); return result; }, + async finalizedReceipt(runtime: Address, expectedId: Hash): Promise { + const final = await client.getBlock({ blockTag: 'finalized' }); + const received = await client.getLogs({ + address: runtime, + event: contributionReceivedEvent, + args: { id: expectedId }, + fromBlock: 0n, + toBlock: final.number + }); + const source = received[received.length - 1]; + if (!source) return undefined; + const shares = await client.getLogs({ + address: runtime, + event: contributionShareEvent, + args: { id: expectedId }, + fromBlock: source.blockNumber, + toBlock: source.blockNumber + }); + return decodeContributions(runtime, [...received, ...shares]).find(row => row.id === expectedId); + }, async history(runtime: Address): Promise { const block = await client.getBlock({ blockTag: 'finalized' }); const logs = await client.getLogs({ diff --git a/web/src/features/runtime/productCdmRuntimeAdapter.test.ts b/web/src/features/runtime/productCdmRuntimeAdapter.test.ts index a1f43b52..1e105a9f 100644 --- a/web/src/features/runtime/productCdmRuntimeAdapter.test.ts +++ b/web/src/features/runtime/productCdmRuntimeAdapter.test.ts @@ -282,6 +282,7 @@ describe('createProductCdmRuntimeWriter', () => { const musicRegRegister = txMethod(); const musicRoyPayAccess = txMethod(); const musicRoyClaim = txMethod(); + const musicGiftContribute = txMethod(); const musicRegSetAccessMode = txMethod(); const musicRegDeactivate = txMethod(); const writer = createProductCdmRuntimeWriter({ @@ -293,6 +294,7 @@ describe('createProductCdmRuntimeWriter', () => { musicRegRegister, musicRoyPayAccess, musicRoyClaim, + musicGiftContribute, musicRegSetAccessMode, musicRegDeactivate }) @@ -329,6 +331,7 @@ describe('createProductCdmRuntimeWriter', () => { ) ).resolves.toBe(txHash); await expect(writer.claimRoyalty(runtime, splitRecipient)).resolves.toBe(txHash); + await expect(writer.contributionCall!(runtime, 'musicGiftContribute', [hash], 4_200_000_000_000_000_000n)).resolves.toBe(txHash); await expect( writer.setAccessMode(runtime, { contentHash: hash, @@ -342,6 +345,8 @@ describe('createProductCdmRuntimeWriter', () => { expect(musicRoyPayAccess.tx).toHaveBeenCalledWith(hash, { value: 30_000_000_000n, waitFor: 'finalized' }); expect(musicRoyClaim.tx).toHaveBeenCalledWith(splitRecipient); + expect(musicGiftContribute.tx).toHaveBeenCalledWith(hash, { value: 42_000_000_000n, waitFor: 'finalized' }); + expect(writer.contributionConfirmationMode).toBe('finalized-event'); expect(musicRegSetAccessMode.tx).toHaveBeenCalledWith(hash, 2, 0n, 1); expect(musicRegDeactivate.tx).toHaveBeenCalledWith(hash); }); diff --git a/web/src/features/runtime/productCdmRuntimeAdapter.ts b/web/src/features/runtime/productCdmRuntimeAdapter.ts index 826f387d..0593948a 100644 --- a/web/src/features/runtime/productCdmRuntimeAdapter.ts +++ b/web/src/features/runtime/productCdmRuntimeAdapter.ts @@ -296,6 +296,7 @@ export function createProductCdmRuntimeReader(deps: ProductCdmRuntimeAdapterDeps export function createProductCdmRuntimeWriter(deps: ProductCdmRuntimeWriterDeps): RuntimeWritePort { return { + contributionConfirmationMode: 'finalized-event', contributionCall(runtime, method, args, value = 0n) { return txContract(deps.contracts.getRuntimeContract(runtime), method, [ ...args, diff --git a/web/src/features/runtime/runtimePorts.ts b/web/src/features/runtime/runtimePorts.ts index b615c792..a98eb8d6 100644 --- a/web/src/features/runtime/runtimePorts.ts +++ b/web/src/features/runtime/runtimePorts.ts @@ -91,8 +91,15 @@ export interface RuntimeReadPort { export type PaymentLifecycle = 'signing' | 'broadcasting' | 'in-block' | 'finalized' | 'error'; export type PaymentObserver = (status: PaymentLifecycle, txHash?: Hash) => void; +export type ContributionConfirmationMode = 'evm-receipt' | 'finalized-event'; export interface RuntimeWritePort { + /** + * Product CDM returns a native extrinsic hash after finalization. It must be + * confirmed from the indexed contract event, not passed to an EVM receipt + * lookup. Viem returns an EVM transaction hash and can use the receipt path. + */ + contributionConfirmationMode: ContributionConfirmationMode; contributionCall?( runtime: Address, method: 'musicGiftContribute' | 'musicGiftSetPolicy' | 'musicGiftClaim', diff --git a/web/src/features/runtime/runtimeWriterProvider.test.ts b/web/src/features/runtime/runtimeWriterProvider.test.ts index 10e30e63..b8a3e05a 100644 --- a/web/src/features/runtime/runtimeWriterProvider.test.ts +++ b/web/src/features/runtime/runtimeWriterProvider.test.ts @@ -186,6 +186,7 @@ describe('createRuntimeWriter', () => { expect(productWriter.createRuntime).toHaveBeenCalledWith(factory); expect(productWriter.payForAccess).toHaveBeenCalledWith(intent); expect(productWriter.claimRoyalty).toHaveBeenCalledWith(runtime, productH160Address); + expect(writer.contributionConfirmationMode).toBe('finalized-event'); expect(createViemRuntimeWriter).not.toHaveBeenCalled(); expect(getViemWalletClient).not.toHaveBeenCalled(); }); diff --git a/web/src/features/runtime/runtimeWriterProvider.ts b/web/src/features/runtime/runtimeWriterProvider.ts index 3830842f..71db912e 100644 --- a/web/src/features/runtime/runtimeWriterProvider.ts +++ b/web/src/features/runtime/runtimeWriterProvider.ts @@ -181,6 +181,7 @@ export function createRuntimeWriter(deps: RuntimeWriterDeps): RuntimeWritePort { } return { + contributionConfirmationMode: config.kind === 'product-cdm' ? 'finalized-event' : 'evm-receipt', contributionCall: async (runtime, method, args, value) => { let port: RuntimeWritePort; try { diff --git a/web/src/features/runtime/viemRuntimeAdapter.ts b/web/src/features/runtime/viemRuntimeAdapter.ts index e0351034..b0fec583 100644 --- a/web/src/features/runtime/viemRuntimeAdapter.ts +++ b/web/src/features/runtime/viemRuntimeAdapter.ts @@ -617,6 +617,7 @@ export function createViemRuntimeWriter(deps: ViemRuntimeWriterDeps): RuntimeWri const { walletClient } = deps; return { + contributionConfirmationMode: 'evm-receipt', async contributionCall(runtime, method, args, value = 0n) { let data: Hash; try { diff --git a/web/src/styles/contributions.css b/web/src/styles/contributions.css index e0e38295..5833d700 100644 --- a/web/src/styles/contributions.css +++ b/web/src/styles/contributions.css @@ -49,6 +49,14 @@ .contribution-action.secondary-action:active:not(:disabled) { background: color-mix(in srgb, var(--artist) 22%, var(--surface)); } +.contribution-action.secondary-action[data-confirmed='true'] { + border-color: color-mix(in srgb, var(--success) 55%, transparent); + background: color-mix(in srgb, var(--success) 12%, var(--surface)); + color: var(--ink); +} +.contribution-action.secondary-action[data-confirmed='true'] svg { + color: var(--success); +} .contribution-action-unavailable.secondary-action:disabled { opacity: 1; border-style: dashed; @@ -277,6 +285,74 @@ .artist-gift-dialog h2 { overflow-wrap: anywhere; } +.contribution-result { + display: grid; + gap: 18px; +} +.contribution-result-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: start; + padding-block: 4px 18px; + border-bottom: 1px solid color-mix(in srgb, var(--success) 24%, var(--line)); +} +.contribution-result-mark { + display: grid; + width: 40px; + height: 40px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--success) 40%, transparent); + border-radius: 50%; + background: var(--success-soft); + color: var(--success); +} +.contribution-result-head h3 { + margin: 0; + color: var(--ink); + font: 600 var(--text-lg) / 1.2 var(--font-interface); +} +.contribution-result-head p { + margin: 5px 0 0; + color: var(--muted); + line-height: 1.5; +} +.contribution-recovery-note { + margin: 0; + color: var(--muted); + font-size: var(--text-sm); + line-height: 1.5; +} +.contribution-technical { + min-width: 0; +} +.contribution-technical code { + display: block; + max-width: 100%; + padding-top: 10px; + color: var(--muted); + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} +.contribution-result-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.contribution-result-actions > * { + display: inline-flex; + min-width: 0; + min-height: 44px; + align-items: center; + justify-content: center; + gap: 8px; + text-align: center; + overflow-wrap: anywhere; +} +.contribution-transaction-link { + text-decoration: none; +} .host-contributions { display: flex; gap: 12px; @@ -291,6 +367,9 @@ padding-inline-start: 10px; } @media (max-width: 560px) { + .contribution-result-actions { + grid-template-columns: minmax(0, 1fr); + } .contribution-policy-dates { grid-template-columns: minmax(0, 1fr); }