Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/explanation/access-control-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ level, it can access the track without paying.

In Classic mode, a listener pays a fixed amount of the configured chain's
native token to unlock full playback. On the current Product DevNet/Paseo Asset
Hub runtime rail, that token is PAS. The payment goes directly and immediately
to the artist's wallet - no intermediary, no payout schedule, no platform cut.
Hub runtime rail, that token is PAS; a DOT-backed Polkadot Hub EVM chain would
display DOT instead. The payment goes directly and immediately to the artist's
wallet - no intermediary, no payout schedule, no platform cut.

Once a listener has paid for a track, their wallet is recorded on-chain. They
can return and play the track at any time without paying again, even if the
Expand Down
3 changes: 2 additions & 1 deletion docs/explanation/product-devnet-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@ Adapters:
Classic unlock no longer passes a loose `(runtimeAddress, contentHash, value)`
triple into runtime writers. The catalog hook creates a typed native runtime
payment intent first: asset symbol derived from the configured EVM chain
(`PAS` on the current Product DevNet/Paseo Asset Hub chain), rail
(`PAS` on the current Product DevNet/Paseo Asset Hub chain, `DOT` on a
DOT-backed Polkadot Hub EVM chain), rail
`runtime-native`, runtime address, content hash, and 18-decimal native amount.
The amount comes from the runtime or catalog API `pricePlanck` value when
available; `priceDot` is only the rounded display string. The viem and Product
Expand Down
8 changes: 5 additions & 3 deletions web/e2e/artist-publish.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ declare global {
const fixtureDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures');
const audioFixture = path.join(fixtureDir, 'artist-release.wav');
const coverFixture = path.join(fixtureDir, 'artist-cover.svg');
const E2E_NATIVE_PAYMENT_SYMBOL = 'PAS';
const E2E_ARTIST_SHARE_PERCENT = '72.5';

async function readArtistPublishState(page: Page) {
return page.evaluate(() => window.__DOTIFY_E2E_ARTIST_PUBLISH__ as ArtistPublishE2eState | undefined);
Expand Down Expand Up @@ -76,12 +78,12 @@ async function completeReleaseDraft(page: Page) {
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByTestId('release-access-select').selectOption('classic');
await page.getByTestId('release-price-input').fill('0.75');
await page.getByTestId('release-royalty-input').fill('7250');
await page.getByTestId('release-royalty-input').fill(E2E_ARTIST_SHARE_PERCENT);

await page.getByRole('button', { name: 'Continue' }).click();
const reviewPanel = page.locator('.release-review');
await expect(reviewPanel.getByText('E2E Published Signal')).toBeVisible();
await expect(reviewPanel.getByText('0.75 DOT')).toBeVisible();
await expect(reviewPanel.getByText(`0.75 ${E2E_NATIVE_PAYMENT_SYMBOL}`)).toBeVisible();
}

test('artist can create a runtime, publish a release, and see it in the listener catalog', async ({ page }) => {
Expand All @@ -102,7 +104,7 @@ test('artist can create a runtime, publish a release, and see it in the listener
const publishedCard = page.getByTestId('track-card').filter({ hasText: 'E2E Published Signal' });
await expect(publishedCard).toContainText('E2E Artist');
await expect(publishedCard).toContainText('A deterministic artist publish e2e release.');
await expect(publishedCard).toContainText('0.75 DOT');
await expect(publishedCard).toContainText(`0.75 ${E2E_NATIVE_PAYMENT_SYMBOL}`);
});

test('artist onboarding handles a missing wallet without enabling profile creation', async ({ page }) => {
Expand Down
6 changes: 4 additions & 2 deletions web/e2e/classic-unlock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,20 @@ async function readClassicUnlockState(page: Page) {
return page.evaluate(() => window.__DOTIFY_E2E_CLASSIC_UNLOCK__ as ClassicUnlockE2eState | undefined);
}

const E2E_NATIVE_PAYMENT_SYMBOL = 'PAS';

test('Classic track stays locked before payment and unlocks full playback after payment', async ({ page }) => {
await page.goto('/');

const trackCard = page.getByTestId('track-card');
await expect(trackCard).toContainText('Deterministic Classic Unlock');
await expect(trackCard).toContainText('0.5 DOT');
await expect(trackCard).toContainText(`0.5 ${E2E_NATIVE_PAYMENT_SYMBOL}`);

await page.getByTestId('track-card-open').click();

await expect(page.getByTestId('locked-player-state')).toContainText('Listening closed');
await expect(page.getByTestId('access-warning')).toContainText('Support and open this track');
await expect(page.getByTestId('access-warning')).toContainText('0.5 DOT');
await expect(page.getByTestId('access-warning')).toContainText(`0.5 ${E2E_NATIVE_PAYMENT_SYMBOL}`);

const beforePayment = await readClassicUnlockState(page);
expect(beforePayment?.fullKeyRequests ?? 0).toBe(0);
Expand Down
12 changes: 8 additions & 4 deletions web/src/components/AccessGateOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import { LockKeyhole } from 'lucide-react';
import { Dialog } from './Dialog';
import type { AccessGate } from '../shared/types';
import { nativeRuntimeAmountLabel, type DotifyNativeRuntimeAsset } from '../features/payments/paymentModel';

export function AccessGateOverlay({
gate,
nativePaymentAsset,
onDismiss,
onPay,
onSignIn
}: {
gate: AccessGate;
nativePaymentAsset: Pick<DotifyNativeRuntimeAsset, 'symbol'>;
onDismiss: () => void;
onPay?: () => void;
onSignIn?: () => void;
}) {
const configuredSplitBps = gate.track.royaltySplits.reduce((total, split) => total + split.bps, 0);
const artistRemainderBps = Math.max(0, 10_000 - configuredSplitBps);
const supportAmount = nativeRuntimeAmountLabel(gate.track.priceDot, nativePaymentAsset);

return (
<Dialog
Expand All @@ -39,9 +43,9 @@ export function AccessGateOverlay({
</div>
{gate.track.accessMode === 'classic' && (
<section className='access-gate-receipt' aria-label={`Support summary for ${gate.track.title}`}>
<div className='access-gate-price' aria-label={`Support amount ${gate.track.priceDot} DOT`}>
<div className='access-gate-price' aria-label={`Support amount ${supportAmount}`}>
<span>Total support</span>
<strong>{gate.track.priceDot} DOT</strong>
<strong>{supportAmount}</strong>
</div>
<dl>
<div>
Expand Down Expand Up @@ -75,9 +79,9 @@ export function AccessGateOverlay({
type='button'
data-testid='classic-unlock-button'
onClick={onPay}
aria-label={`Support the artist and open ${gate.track.title} for ${gate.track.priceDot} DOT`}
aria-label={`Support the artist and open ${gate.track.title} for ${supportAmount}`}
>
Support and open - {gate.track.priceDot} DOT
Support and open - {supportAmount}
</button>
)}
{gate.actionType === 'signin' && onSignIn && (
Expand Down
1 change: 1 addition & 0 deletions web/src/components/AccountWalletModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function AccountWalletModal() {
supportingCount={supportedArtists.length}
unlockedCount={paidTracks.length}
supportedArtists={supportedArtists}
nativePaymentSymbol={catalog.nativeRuntimePaymentAsset.symbol}
paidTracks={paidTracks.map(track => ({
id: track.id,
title: track.title,
Expand Down
7 changes: 5 additions & 2 deletions web/src/components/StageRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type StageRailProps = {
tracks: CatalogTrack[];
accessByTrackId: Record<string, boolean>;
selectedTrackId: string;
nativePaymentSymbol: string;
onOpenTrack: (track: CatalogTrack) => void;
};

Expand All @@ -29,7 +30,7 @@ const ARC_DROP_PX = 26;
const ARC_TILT_DEG = 5;
const ARC_SCALE_LOSS = 0.07;

export function StageRail({ tracks, accessByTrackId, selectedTrackId, onOpenTrack }: StageRailProps) {
export function StageRail({ tracks, accessByTrackId, selectedTrackId, nativePaymentSymbol, onOpenTrack }: StageRailProps) {
const railRef = useRef<HTMLDivElement>(null);
const lampRef = useRef<HTMLDivElement>(null);
const frameRef = useRef(0);
Expand Down Expand Up @@ -127,7 +128,9 @@ export function StageRail({ tracks, accessByTrackId, selectedTrackId, onOpenTrac
<span className='stage-copy'>
<strong>{track.title}</strong>
<span>{track.artist}</span>
<small data-access={unlocked ? 'granted' : 'locked'}>{unlocked ? 'Opened for this wallet' : catalogAccessLabel(track)}</small>
<small data-access={unlocked ? 'granted' : 'locked'}>
{unlocked ? 'Opened for this wallet' : catalogAccessLabel(track, nativePaymentSymbol)}
</small>
</span>
</button>
);
Expand Down
4 changes: 3 additions & 1 deletion web/src/components/WalletModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,14 @@ export function WalletModal({
supportingCount = 0,
unlockedCount = 0,
supportedArtists = [],
nativePaymentSymbol,
paidTracks = [],
onOpenAccountDetails
}: {
supportingCount?: number;
unlockedCount?: number;
supportedArtists?: WalletSupportedArtist[];
nativePaymentSymbol: string;
paidTracks?: WalletPaidTrack[];
onOpenAccountDetails?: () => void;
}) {
Expand Down Expand Up @@ -226,7 +228,7 @@ export function WalletModal({
<span>
<strong>{track.title}</strong>
<small>
{track.artist} / {track.priceDot} DOT
{track.artist} / {track.priceDot} {nativePaymentSymbol}
</small>
</span>
<code>{shortenAddress(track.hash)}</code>
Expand Down
8 changes: 8 additions & 0 deletions web/src/features/payments/paymentModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
classicTrackPaymentAmountPlanck,
createNativeRuntimeAccessPaymentIntent,
createUnsupportedCashAccessPaymentIntent,
nativeRuntimeAmountLabel,
nativeRuntimePaymentAssetFromChain
} from './paymentModel';
import { formatWeiAsDot } from '../../shared/utils/format';
Expand All @@ -25,6 +26,13 @@ describe('payment model', () => {
expect(nativeRuntimePaymentAssetFromChain(null)).toEqual(DOTIFY_FALLBACK_NATIVE_RUNTIME_ASSET);
});

it('formats visible native runtime amounts with the resolved chain symbol', () => {
expect(nativeRuntimeAmountLabel('0.5', nativeAsset)).toBe('0.5 PAS');
expect(nativeRuntimeAmountLabel('1.25', nativeRuntimePaymentAssetFromChain({ nativeCurrency: { name: 'Polkadot', symbol: 'DOT', decimals: 18 } }))).toBe(
'1.25 DOT'
);
});

it('creates an executable native runtime payment intent for Classic unlocks', () => {
expect(
createNativeRuntimeAccessPaymentIntent({
Expand Down
4 changes: 4 additions & 0 deletions web/src/features/payments/paymentModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export function nativeRuntimePaymentAssetFromChain(chain: Pick<Chain, 'nativeCur
};
}

export function nativeRuntimeAmountLabel(amount: string, asset: Pick<DotifyNativeRuntimeAsset, 'symbol'>): string {
return `${amount} ${asset.symbol}`;
}

export function classicTrackPaymentAmountPlanck(track: Pick<CatalogTrack, 'priceDot' | 'pricePlanck'>): bigint {
return track.pricePlanck ?? parseEther(track.priceDot.trim() || '0');
}
Expand Down
1 change: 1 addition & 0 deletions web/src/hooks/useCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1346,6 +1346,7 @@ export function useCatalog(deps: UseCatalogDeps) {
setCatalogAccessByTrackId,
catalogPaidAccessByTrackId,
setCatalogPaidAccessByTrackId,
nativeRuntimePaymentAsset,
usesCatalogApi,
audioSource,
setAudioSource: setResolvedAudioSource,
Expand Down
8 changes: 4 additions & 4 deletions web/src/shared/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,14 @@ export function accessModeLabelFromState(mode: AccessMode) {
return mode === 'human-free' ? 'Free for verified humans' : 'Full song';
}

export function catalogAccessLabel(track: CatalogTrack) {
export function catalogAccessLabel(track: CatalogTrack, nativePaymentSymbol = 'native token') {
if (track.accessMode === 'free') return 'Free for everyone';
return track.accessMode === 'classic' ? `${track.priceDot} DOT` : 'Free for verified humans';
return track.accessMode === 'classic' ? `${track.priceDot} ${nativePaymentSymbol}` : 'Free for verified humans';
}

export function catalogAccessAriaLabel(track: CatalogTrack, hasAccess: boolean) {
export function catalogAccessAriaLabel(track: CatalogTrack, hasAccess: boolean, nativePaymentSymbol = 'native token') {
const status = hasAccess ? 'Access already available' : 'Access required';
return `${status}: ${catalogAccessLabel(track)}`;
return `${status}: ${catalogAccessLabel(track, nativePaymentSymbol)}`;
}

export function describeArtistRegistrationError(error: unknown) {
Expand Down
6 changes: 5 additions & 1 deletion web/src/views/ArtistProfileView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type ArtistProfileViewProps = {
catalogTracks: CatalogTrack[];
openRooms: OpenRoom[];
catalogAccessByTrackId: Record<string, boolean>;
nativePaymentSymbol: string;
onBack: () => void;
onOpenTrack: (track: CatalogTrack) => void;
onOpenArtistRoom: (track: CatalogTrack) => void;
Expand Down Expand Up @@ -47,6 +48,7 @@ export function ArtistProfileView({
catalogTracks,
openRooms,
catalogAccessByTrackId,
nativePaymentSymbol,
onBack,
onOpenTrack,
onOpenArtistRoom,
Expand Down Expand Up @@ -191,7 +193,9 @@ export function ArtistProfileView({
<div>
<strong>{track.title}</strong>
<span>{track.description || 'A Dotify release ready for listening rooms and direct support.'}</span>
<small aria-label={catalogAccessAriaLabel(track, hasCatalogAccess)}>{catalogAccessLabel(track)}</small>
<small aria-label={catalogAccessAriaLabel(track, hasCatalogAccess, nativePaymentSymbol)}>
{catalogAccessLabel(track, nativePaymentSymbol)}
</small>
</div>
</article>
);
Expand Down
6 changes: 4 additions & 2 deletions web/src/views/ListenView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type ListenViewProps = {
soloListeningByTrackHash: SoloListeningByTrackHash;
selectedTrackId: string;
catalogAccessByTrackId: Record<string, boolean>;
nativePaymentSymbol: string;
onOpenTrack: (track: CatalogTrack) => void;
onOpenArtist: (artistName: string) => void;
onJoinRoom: (roomId: string) => void;
Expand All @@ -36,6 +37,7 @@ export function ListenView({
soloListeningByTrackHash,
selectedTrackId,
catalogAccessByTrackId,
nativePaymentSymbol,
onOpenTrack,
onOpenArtist,
onJoinRoom,
Expand Down Expand Up @@ -316,11 +318,11 @@ export function ListenView({
<div
className='catalogue-access-line'
data-access={accessGranted ? 'granted' : 'locked'}
aria-label={catalogAccessAriaLabel(track, hasCatalogAccess)}
aria-label={catalogAccessAriaLabel(track, hasCatalogAccess, nativePaymentSymbol)}
>
<span>
{accessGranted ? <CircleCheckBig size={15} /> : track.accessMode === 'classic' ? <Wallet size={15} /> : <KeyRound size={15} />}
{catalogAccessLabel(track)}
{catalogAccessLabel(track, nativePaymentSymbol)}
</span>
<ArrowRight size={15} aria-hidden='true' />
</div>
Expand Down
4 changes: 4 additions & 0 deletions web/src/views/ListenerShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export function ListenerShell() {
const soloTrackHash = playback.transport.playing && !roomId ? (selectedTrack?.hash ?? null) : null;
const showProductionReadinessPanel = isProductionReadinessPanelEnabled({ VITE_DOTIFY_DEBUG_PANEL: import.meta.env.VITE_DOTIFY_DEBUG_PANEL });
const connectedWallet = walletState.status === 'connected' ? walletState.wallet : null;
const nativePaymentSymbol = catalog.nativeRuntimePaymentAsset.symbol;

useEffect(() => {
session.setSoloListeningTrack(soloTrackHash);
Expand Down Expand Up @@ -204,6 +205,7 @@ export function ListenerShell() {
catalogTracks={catalog.catalogTracks}
openRooms={session.openRooms}
catalogAccessByTrackId={catalog.catalogAccessByTrackId}
nativePaymentSymbol={nativePaymentSymbol}
onBack={() => setPublicArtistName(null)}
onOpenTrack={openTrack}
onOpenArtistRoom={handleOpenArtistRoom}
Expand All @@ -219,6 +221,7 @@ export function ListenerShell() {
soloListeningByTrackHash={session.soloListeningByTrackHash}
selectedTrackId={catalog.selectedTrackId}
catalogAccessByTrackId={catalog.catalogAccessByTrackId}
nativePaymentSymbol={nativePaymentSymbol}
onOpenTrack={openTrack}
onOpenArtist={handleOpenArtistProfile}
onJoinRoom={handleJoinRoomRequest}
Expand Down Expand Up @@ -255,6 +258,7 @@ export function ListenerShell() {
unlockedTrackCount={paidTracks.length}
supportedArtistCount={supportedArtists.length}
supportedArtists={supportedArtists}
nativePaymentSymbol={nativePaymentSymbol}
unlockedTracks={paidTracks.map(track => ({
id: track.id,
title: track.title,
Expand Down
8 changes: 6 additions & 2 deletions web/src/views/PlayerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { isPolicyManagedTrack, trackHasAccess } from '../features/access/accessP
import { isChosenDisplayName } from '../features/identity/walletIdentity';
import { roomPresenceCount } from '../features/rooms/roomState';
import { playbackStatusLabel, transportProgressPercent } from '../features/player/playbackStatus';
import { nativeRuntimeAmountLabel } from '../features/payments/paymentModel';
import { useCatalogContext, useSessionContext, usePlaybackContext, useUiFeedback, useNavigation, useReleaseForm } from '../app/providers';
import type { CatalogTrack } from '../shared/types';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
Expand Down Expand Up @@ -95,6 +96,8 @@ export function PlayerView({ onShowCreateModal, onShowJoinModal }: PlayerViewPro

const effectiveAccessMode = trackInfo?.accessMode ?? selectedTrack?.accessMode ?? accessMode;
const effectivePriceDot = trackInfo?.priceDot ?? selectedTrack?.priceDot ?? priceDot;
const nativePaymentAsset = catalog.nativeRuntimePaymentAsset;
const effectivePaymentAmount = nativeRuntimeAmountLabel(effectivePriceDot, nativePaymentAsset);
const [reactions, setReactions] = useState<Array<{ id: string; emoji: string; x: number; senderName: string; self: boolean }>>([]);
const [isQrProjectorOpen, setIsQrProjectorOpen] = useState(false);

Expand Down Expand Up @@ -123,7 +126,7 @@ export function PlayerView({ onShowCreateModal, onShowJoinModal }: PlayerViewPro
? 'Live room stream'
: effectiveAccessMode === 'classic'
? needsTrackAccess
? `${effectivePriceDot} DOT`
? effectivePaymentAmount
: 'Opened for this wallet'
: effectiveAccessMode === 'free'
? 'Free for everyone'
Expand Down Expand Up @@ -440,6 +443,7 @@ export function PlayerView({ onShowCreateModal, onShowJoinModal }: PlayerViewPro
{accessGate && !isRoomGuest && (
<AccessGateOverlay
gate={accessGate}
nativePaymentAsset={nativePaymentAsset}
onDismiss={() => onSetAccessGate(null)}
onPay={
accessGate.actionType === 'payment'
Expand Down Expand Up @@ -718,7 +722,7 @@ export function PlayerView({ onShowCreateModal, onShowJoinModal }: PlayerViewPro
? 'Streamed by the host'
: effectiveAccessMode === 'classic'
? needsTrackAccess
? `${effectivePriceDot} DOT to open`
? `${effectivePaymentAmount} to open`
: 'Full track opened'
: 'Open in this room'
}
Expand Down
Loading
Loading