diff --git a/docs/explanation/access-control-model.md b/docs/explanation/access-control-model.md index dc118e5..26cf6d4 100644 --- a/docs/explanation/access-control-model.md +++ b/docs/explanation/access-control-model.md @@ -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 diff --git a/docs/explanation/product-devnet-architecture.md b/docs/explanation/product-devnet-architecture.md index 5e0f82d..df6ecc4 100644 --- a/docs/explanation/product-devnet-architecture.md +++ b/docs/explanation/product-devnet-architecture.md @@ -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 diff --git a/web/e2e/artist-publish.spec.ts b/web/e2e/artist-publish.spec.ts index c3fb558..f06d3f1 100644 --- a/web/e2e/artist-publish.spec.ts +++ b/web/e2e/artist-publish.spec.ts @@ -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); @@ -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 }) => { @@ -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 }) => { diff --git a/web/e2e/classic-unlock.spec.ts b/web/e2e/classic-unlock.spec.ts index f135e91..6466edb 100644 --- a/web/e2e/classic-unlock.spec.ts +++ b/web/e2e/classic-unlock.spec.ts @@ -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); diff --git a/web/src/components/AccessGateOverlay.tsx b/web/src/components/AccessGateOverlay.tsx index 724999d..7d5113c 100644 --- a/web/src/components/AccessGateOverlay.tsx +++ b/web/src/components/AccessGateOverlay.tsx @@ -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; 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 ( {gate.track.accessMode === 'classic' && (
-
+
Total support - {gate.track.priceDot} DOT + {supportAmount}
@@ -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} )} {gate.actionType === 'signin' && onSignIn && ( diff --git a/web/src/components/AccountWalletModal.tsx b/web/src/components/AccountWalletModal.tsx index 86bdff4..d642ee3 100644 --- a/web/src/components/AccountWalletModal.tsx +++ b/web/src/components/AccountWalletModal.tsx @@ -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, diff --git a/web/src/components/StageRail.tsx b/web/src/components/StageRail.tsx index 0169778..13ccaf0 100644 --- a/web/src/components/StageRail.tsx +++ b/web/src/components/StageRail.tsx @@ -21,6 +21,7 @@ type StageRailProps = { tracks: CatalogTrack[]; accessByTrackId: Record; selectedTrackId: string; + nativePaymentSymbol: string; onOpenTrack: (track: CatalogTrack) => void; }; @@ -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(null); const lampRef = useRef(null); const frameRef = useRef(0); @@ -127,7 +128,9 @@ export function StageRail({ tracks, accessByTrackId, selectedTrackId, onOpenTrac {track.title} {track.artist} - {unlocked ? 'Opened for this wallet' : catalogAccessLabel(track)} + + {unlocked ? 'Opened for this wallet' : catalogAccessLabel(track, nativePaymentSymbol)} + ); diff --git a/web/src/components/WalletModal.tsx b/web/src/components/WalletModal.tsx index 58416bc..1064eb5 100644 --- a/web/src/components/WalletModal.tsx +++ b/web/src/components/WalletModal.tsx @@ -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; }) { @@ -226,7 +228,7 @@ export function WalletModal({ {track.title} - {track.artist} / {track.priceDot} DOT + {track.artist} / {track.priceDot} {nativePaymentSymbol} {shortenAddress(track.hash)} diff --git a/web/src/features/payments/paymentModel.test.ts b/web/src/features/payments/paymentModel.test.ts index e87015d..4ab6524 100644 --- a/web/src/features/payments/paymentModel.test.ts +++ b/web/src/features/payments/paymentModel.test.ts @@ -6,6 +6,7 @@ import { classicTrackPaymentAmountPlanck, createNativeRuntimeAccessPaymentIntent, createUnsupportedCashAccessPaymentIntent, + nativeRuntimeAmountLabel, nativeRuntimePaymentAssetFromChain } from './paymentModel'; import { formatWeiAsDot } from '../../shared/utils/format'; @@ -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({ diff --git a/web/src/features/payments/paymentModel.ts b/web/src/features/payments/paymentModel.ts index 6246782..63710ae 100644 --- a/web/src/features/payments/paymentModel.ts +++ b/web/src/features/payments/paymentModel.ts @@ -64,6 +64,10 @@ export function nativeRuntimePaymentAssetFromChain(chain: Pick): string { + return `${amount} ${asset.symbol}`; +} + export function classicTrackPaymentAmountPlanck(track: Pick): bigint { return track.pricePlanck ?? parseEther(track.priceDot.trim() || '0'); } diff --git a/web/src/hooks/useCatalog.ts b/web/src/hooks/useCatalog.ts index 6d662d7..d960126 100644 --- a/web/src/hooks/useCatalog.ts +++ b/web/src/hooks/useCatalog.ts @@ -1346,6 +1346,7 @@ export function useCatalog(deps: UseCatalogDeps) { setCatalogAccessByTrackId, catalogPaidAccessByTrackId, setCatalogPaidAccessByTrackId, + nativeRuntimePaymentAsset, usesCatalogApi, audioSource, setAudioSource: setResolvedAudioSource, diff --git a/web/src/shared/utils/format.ts b/web/src/shared/utils/format.ts index e99d356..3ce9558 100644 --- a/web/src/shared/utils/format.ts +++ b/web/src/shared/utils/format.ts @@ -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) { diff --git a/web/src/views/ArtistProfileView.tsx b/web/src/views/ArtistProfileView.tsx index e7963fc..b4c176f 100644 --- a/web/src/views/ArtistProfileView.tsx +++ b/web/src/views/ArtistProfileView.tsx @@ -11,6 +11,7 @@ type ArtistProfileViewProps = { catalogTracks: CatalogTrack[]; openRooms: OpenRoom[]; catalogAccessByTrackId: Record; + nativePaymentSymbol: string; onBack: () => void; onOpenTrack: (track: CatalogTrack) => void; onOpenArtistRoom: (track: CatalogTrack) => void; @@ -47,6 +48,7 @@ export function ArtistProfileView({ catalogTracks, openRooms, catalogAccessByTrackId, + nativePaymentSymbol, onBack, onOpenTrack, onOpenArtistRoom, @@ -191,7 +193,9 @@ export function ArtistProfileView({
{track.title} {track.description || 'A Dotify release ready for listening rooms and direct support.'} - {catalogAccessLabel(track)} + + {catalogAccessLabel(track, nativePaymentSymbol)} +
); diff --git a/web/src/views/ListenView.tsx b/web/src/views/ListenView.tsx index fe49556..cc40df4 100644 --- a/web/src/views/ListenView.tsx +++ b/web/src/views/ListenView.tsx @@ -17,6 +17,7 @@ type ListenViewProps = { soloListeningByTrackHash: SoloListeningByTrackHash; selectedTrackId: string; catalogAccessByTrackId: Record; + nativePaymentSymbol: string; onOpenTrack: (track: CatalogTrack) => void; onOpenArtist: (artistName: string) => void; onJoinRoom: (roomId: string) => void; @@ -36,6 +37,7 @@ export function ListenView({ soloListeningByTrackHash, selectedTrackId, catalogAccessByTrackId, + nativePaymentSymbol, onOpenTrack, onOpenArtist, onJoinRoom, @@ -316,11 +318,11 @@ export function ListenView({
{accessGranted ? : track.accessMode === 'classic' ? : } - {catalogAccessLabel(track)} + {catalogAccessLabel(track, nativePaymentSymbol)}
diff --git a/web/src/views/ListenerShell.tsx b/web/src/views/ListenerShell.tsx index 160408c..c6b9b6e 100644 --- a/web/src/views/ListenerShell.tsx +++ b/web/src/views/ListenerShell.tsx @@ -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); @@ -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} @@ -219,6 +221,7 @@ export function ListenerShell() { soloListeningByTrackHash={session.soloListeningByTrackHash} selectedTrackId={catalog.selectedTrackId} catalogAccessByTrackId={catalog.catalogAccessByTrackId} + nativePaymentSymbol={nativePaymentSymbol} onOpenTrack={openTrack} onOpenArtist={handleOpenArtistProfile} onJoinRoom={handleJoinRoomRequest} @@ -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, diff --git a/web/src/views/PlayerView.tsx b/web/src/views/PlayerView.tsx index 5566536..ca11d41 100644 --- a/web/src/views/PlayerView.tsx +++ b/web/src/views/PlayerView.tsx @@ -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'; @@ -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>([]); const [isQrProjectorOpen, setIsQrProjectorOpen] = useState(false); @@ -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' @@ -440,6 +443,7 @@ export function PlayerView({ onShowCreateModal, onShowJoinModal }: PlayerViewPro {accessGate && !isRoomGuest && ( onSetAccessGate(null)} onPay={ accessGate.actionType === 'payment' @@ -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' } diff --git a/web/src/views/YouView.tsx b/web/src/views/YouView.tsx index b22c050..b06e5ad 100644 --- a/web/src/views/YouView.tsx +++ b/web/src/views/YouView.tsx @@ -17,6 +17,7 @@ type YouViewProps = { unlockedTrackCount: number; supportedArtistCount: number; supportedArtists: AccountSupportedArtist[]; + nativePaymentSymbol: string; unlockedTracks: AccountUnlockedTrack[]; productionReadiness: ProductionReadinessPanelProps | null; onOpenArtistStudio: () => void; @@ -33,6 +34,7 @@ export function YouView({ unlockedTrackCount, supportedArtistCount, supportedArtists, + nativePaymentSymbol, unlockedTracks, productionReadiness, onOpenArtistStudio, @@ -88,7 +90,7 @@ export function YouView({ {track.title} - {track.artist} / {track.priceDot} DOT + {track.artist} / {track.priceDot} {nativePaymentSymbol} {shortenAddress(track.hash)} @@ -156,7 +158,7 @@ export function YouView({
{earnedDot} - DOT received + {nativePaymentSymbol} received
{shortenAddress(artistRuntimeAddress!)} diff --git a/web/src/views/artist/ArtistConsole.tsx b/web/src/views/artist/ArtistConsole.tsx index b103eb1..7b4d060 100644 --- a/web/src/views/artist/ArtistConsole.tsx +++ b/web/src/views/artist/ArtistConsole.tsx @@ -105,6 +105,7 @@ export function ArtistConsole() { const coverCID = catalog.coverCID; const audioCID = catalog.audioCID; const trackInfo = catalog.trackInfo; + const nativePaymentSymbol = catalog.nativeRuntimePaymentAsset.symbol; const artistTracks = catalog.allCatalogTracks.filter(track => isTrackManagedByArtist(track, activeEvmAddress, artistName)); const artistRegistrationAvailable = artistConsole.artistRegistrationAvailable; @@ -350,6 +351,7 @@ export function ArtistConsole() { artistRegistrationAvailable={artistRegistrationAvailable} artistSetupState={artistSetupState} artistTracks={artistTracks} + nativePaymentSymbol={nativePaymentSymbol} connectedWallet={connectedWallet} royaltyPayments={royaltyPayments} totalRoyaltyWei={totalRoyaltyWei} @@ -378,6 +380,7 @@ export function ArtistConsole() { accessMode={accessMode} personhoodLevel={personhoodLevel} priceDot={priceDot} + nativePaymentSymbol={nativePaymentSymbol} royaltyBps={royaltyBps} additionalRoyaltySplits={additionalRoyaltySplits} uploadToBulletinEnabled={uploadToBulletinEnabled} @@ -418,6 +421,7 @@ export function ArtistConsole() { onSetReleaseActive={onSetReleaseActive} releaseActionId={artistConsole.releaseActionId} arrivedReleaseId={arrivedReleaseId} + nativePaymentSymbol={nativePaymentSymbol} /> )} @@ -431,6 +435,7 @@ export function ArtistConsole() { totalRoyaltyWei={totalRoyaltyWei} uniqueRoyaltyListeners={uniqueRoyaltyListeners} paidRoyaltyTracks={paidRoyaltyTracks} + nativePaymentSymbol={nativePaymentSymbol} onSetExpandedRoyaltyPaymentId={onSetExpandedRoyaltyPaymentId} onRefreshRoyalties={onRefreshRoyalties} /> diff --git a/web/src/views/artist/NewReleaseTab.tsx b/web/src/views/artist/NewReleaseTab.tsx index 4fff520..210cce2 100644 --- a/web/src/views/artist/NewReleaseTab.tsx +++ b/web/src/views/artist/NewReleaseTab.tsx @@ -29,6 +29,7 @@ type NewReleaseTabProps = { accessMode: AccessMode; personhoodLevel: PersonhoodLevel; priceDot: string; + nativePaymentSymbol: string; royaltyBps: number; additionalRoyaltySplits: ReleaseRoyaltySplitDraft[]; uploadToBulletinEnabled: boolean; @@ -72,6 +73,7 @@ export function NewReleaseTab({ accessMode, personhoodLevel, priceDot, + nativePaymentSymbol, royaltyBps, additionalRoyaltySplits, uploadToBulletinEnabled, @@ -223,7 +225,7 @@ export function NewReleaseTab({