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) && (
+
+
+ View transaction
+
+ )}
+ {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);
}