diff --git a/docs/README.md b/docs/README.md index c50c5c6..af47ee2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ Conceptual documents that help you understand why Dotify works the way it does. | [Architecture Overview](./explanation/architecture-overview.md) | All | How the six system layers (identity, IPFS, EVM, Bulletin, WebRTC, frontend) connect | | [Access Control Model](./explanation/access-control-model.md) | All | Human free vs Classic — what they mean for artists and listeners | | [Content Protection](./explanation/content-protection.md) | All | Audio encryption pipeline, what it protects, and what it does not | -| [Royalty Settlement](./explanation/royalty-settlement.md) | All | How DOT payments flow from listener wallet to artist wallet | +| [Royalty Settlement](./explanation/royalty-settlement.md) | All | How native runtime payments flow from listener wallet to artist wallet, and why Product CASH settlement remains a separate rail | | [Listening Rooms](./explanation/listening-rooms.md) | All | WebRTC peer-to-peer streaming, signaling protocol, known limitations | | [Product DevNet Architecture](./explanation/product-devnet-architecture.md) | Maintainers | Dual-host boundaries, Product account capabilities, rooms, storage, and the proposed contract port | diff --git a/docs/backlog/polkadot-product-readiness-and-killer-dapp-roadmap.md b/docs/backlog/polkadot-product-readiness-and-killer-dapp-roadmap.md index 2fa18b2..b3302ec 100644 --- a/docs/backlog/polkadot-product-readiness-and-killer-dapp-roadmap.md +++ b/docs/backlog/polkadot-product-readiness-and-killer-dapp-roadmap.md @@ -193,6 +193,11 @@ Goal: deepen the delivered Product mode one adapter at a time. hook. The tracked Product deployment still defaults to viem, but a `product-cdm` build now changes the runtime write adapter at the same seam as reads. +- Delivered on the next follow-up branch: make Classic unlock payments a typed + native runtime payment intent before they reach `RuntimeWritePort`. The code + now has an explicit unsupported `product-cash` rail, so future CASH work can + add receipt/bridge settlement without pretending CASH is already executable + through `msg.value`. - Settled: the chain question. Product DevNet is a preset over the Paseo system parachains (Asset Hub 1000, People 1004, Bulletin 1010) at EVM chain 420420417, not a separate network. Dotify's contracts are already there, @@ -201,8 +206,10 @@ Goal: deepen the delivered Product mode one adapter at a time. The SDK's `paseo` preset is Paseo Next (1500/1502), a different network, so `devnet` is the only environment Dotify can serve a catalog from. - Next: `pallet-revive` account mapping plus real host-signed transaction smoke - tests before Product writes can replace the EVM wallet path. This is now the - only gate left for Product contract mode. + tests before Product writes can replace the EVM wallet path. +- Next: confirm the Product-supported CASH settlement model before exposing any + native/CASH conversion, bridge, or receipt UI. Dotify must keep runtime access + grants tied to verifiable settlement evidence. - Next: run real Product host smoke tests for protected playback and capture the Product sr25519 request evidence. - Keep backend key delivery authoritative unless a Product-host design proves a diff --git a/docs/design/dotify-product-stack-alignment.md b/docs/design/dotify-product-stack-alignment.md index 99f50f1..fe5f6ec 100644 --- a/docs/design/dotify-product-stack-alignment.md +++ b/docs/design/dotify-product-stack-alignment.md @@ -174,10 +174,12 @@ host-signed payment receipt the runtime verifies, or an Asset-Hub-side entitlement credited from an attested People-chain transfer. Both need design work. Do not ship a payment path until this is settled. -Interim implementation note: Classic unlock now goes through Dotify's -`RuntimeWritePort`, so a build can switch from viem to Product CDM contract -handles without changing the listener UI. That is still native-value runtime -payment, not Product-native CASH settlement. +Interim implementation note: Classic unlock now creates a typed native runtime +payment intent and sends that through Dotify's `RuntimeWritePort`, so a build +can switch from viem to Product CDM contract handles without changing the +listener UI. That is still native-value runtime payment, not Product-native +CASH settlement. CASH is modeled as a separate unsupported rail until Product +confirms how People-chain CASH can settle Asset-Hub runtime entitlement. **Catalog metadata to Bulletin.** Release metadata, artwork, and manifests are small, immutable, and public. Exactly Bulletin's shape. The Fly read model diff --git a/docs/explanation/access-control-model.md b/docs/explanation/access-control-model.md index e9aceb2..dc118e5 100644 --- a/docs/explanation/access-control-model.md +++ b/docs/explanation/access-control-model.md @@ -66,9 +66,10 @@ level, it can access the track without paying. > "Pay once, play forever." -In Classic mode, a listener pays a fixed amount of DOT to unlock full playback. -The payment goes directly and immediately to the artist's wallet - no -intermediary, no payout schedule, no platform cut. +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. 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 @@ -76,7 +77,7 @@ track later flips away from Classic and back. **When to choose Classic:** -- You want to monetize your releases directly in DOT. +- You want to monetize your releases directly through the runtime-native token. - You prefer familiar pay-to-play economics. --- @@ -88,7 +89,7 @@ track later flips away from Classic and back. 3. An access gate is shown with the appropriate action: - **Signin gate** - listener has no wallet connected. They must connect first. - **Personhood gate** - wallet is connected but lacks the required PoP level. - - **Payment gate** - wallet is connected; DOT payment is required. + - **Payment gate** - wallet is connected; a runtime-native payment is required. The listener can choose a Free track, join a room hosted by someone with access, or satisfy the required gate. diff --git a/docs/explanation/architecture-overview.md b/docs/explanation/architecture-overview.md index 327bb2a..48b8675 100644 --- a/docs/explanation/architecture-overview.md +++ b/docs/explanation/architecture-overview.md @@ -76,7 +76,7 @@ Track selected → access checked │ └── No access? ──► Unlock/personhood gate shown, no protected audio │ - ├── Pay DOT → musicRoyPayAccess() → access granted + ├── Pay native → musicRoyPayAccess() → access granted └── Prove PoP → registrar confirms personhood → access granted ``` diff --git a/docs/explanation/product-devnet-architecture.md b/docs/explanation/product-devnet-architecture.md index 556b89e..5e0f82d 100644 --- a/docs/explanation/product-devnet-architecture.md +++ b/docs/explanation/product-devnet-architecture.md @@ -86,7 +86,7 @@ Socket.IO, and WebRTC signaling. | Join room link | No wallet | No wallet | Same | | Host room | Socket.IO + WebRTC | Product Desktop/web host: same. Product Mobile iOS: external-browser continuation until the host exposes Product WebRTC. | Keep until a multiparty replacement proves equivalent UX | | Product identity | Not applicable | App-scoped SS58/H160 | Host identity with explicit capability grants | -| Classic payment | Passkey/EVM wallet through `RuntimeWritePort` | Passkey/EVM wallet in the tracked build; Product CDM writer only when `VITE_DOTIFY_RUNTIME_ADAPTER=product-cdm` is explicitly enabled | CASH settlement after the Product payment rail is designed | +| Classic payment | Native runtime payment intent through `RuntimeWritePort`; the asset label is derived from the connected EVM `chainId` (`PAS` on Product DevNet/Paseo, `DOT` on a DOT-backed Polkadot Hub EVM chain) | Passkey/EVM wallet in the tracked build; Product CDM writer only when `VITE_DOTIFY_RUNTIME_ADAPTER=product-cdm` is explicitly enabled | CASH settlement after the Product payment rail is designed | | Protected key request | EIP-191 or session token | `product-sr25519-v1` when a Product account is connected; EIP-191 or session token otherwise | Frontend-host signed Product key/session requests, with captured host signing evidence | | Artist publication | viem/EVM | viem/EVM | Generated CDM contract adapter | | Personhood | Current on-chain policy source | No new claim | Privacy-preserving Product proof after verification | @@ -177,7 +177,7 @@ RuntimeWritePort registerTrack() setAccessMode() setReleaseActive() - payForAccess() + payForAccess(nativeRuntimeIntent) ``` Adapters: @@ -193,6 +193,22 @@ Adapters: - `CatalogApiAdapter`: the existing server-side read model, shared by both frontends. +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 +`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 +CDM adapters both submit that same intent as `musicRoyPayAccess(contentHash)` +plus `msg.value`. + +CASH is represented separately as an unsupported `product-cash` rail. That is +intentional. CASH lives on People chain while Dotify entitlements live in Asset +Hub runtimes, so the Product-native path needs an explicit receipt or bridge +model before any listener payment can execute. Dotify must not silently convert +CASH to native runtime value or mark access paid without runtime evidence. + The CDM adapter has one deliberate gap: royalty payment history is not read through Product contract handles because the current SDK surface exposes method queries and transactions, not the viem-style historical log query used diff --git a/docs/explanation/royalty-settlement.md b/docs/explanation/royalty-settlement.md index 591e2b8..c737785 100644 --- a/docs/explanation/royalty-settlement.md +++ b/docs/explanation/royalty-settlement.md @@ -6,7 +6,11 @@ ## How royalties work for artists -When a listener pays to unlock a Classic-access track, the DOT goes directly from their wallet to yours. There is no platform account, no holding period, and no payout schedule. The smart contract distributes the payment the moment the transaction is confirmed. +When a listener pays to unlock a Classic-access track, the configured chain's +native token goes directly from their wallet to yours. On the current Product +DevNet/Paseo Asset Hub runtime rail, that token is PAS. There is no platform +account, no holding period, and no payout schedule. The smart contract +distributes the payment the moment the transaction is confirmed. You can also split royalties with collaborators. When you register a track, you specify a list of recipient addresses and a share for each (expressed in basis points, where 10,000 = 100 %). The contract distributes the payment proportionally in the same transaction. @@ -22,7 +26,7 @@ SmartRuntime. For each entry you can see: - The track that was unlocked. - The listener's wallet address. -- The amount paid in DOT. +- The amount paid in the configured runtime-native token. - The date and time of the transaction. - A link to the transaction receipt on Blockscout. @@ -63,8 +67,17 @@ When a listener calls `musicRoyPayAccess(contentHash)`, the contract: 5. Emits `MusicRoyAccessPaid(contentHash, listener, amount)`. The `pricePlanck` field name is historical. The active EVM path stores and pays -prices as 18-decimal native token units, so the frontend uses `parseEther()` for -DOT input and `formatEther()` for display. +prices as 18-decimal native token units. The frontend uses `parseEther()` for +artist price input and `formatEther()` for display, but unlock payments are +built from the catalog's authoritative `pricePlanck` when present. `priceDot` +is never used as the source of truth for an on-chain Classic payment. The +frontend derives the payment symbol from the configured chain and wraps the +native amount as a typed runtime payment intent before submitting +`musicRoyPayAccess`. + +Product CASH settlement is deliberately not executable through this path. CASH +lives on People chain while Dotify runtime entitlements live on Asset Hub, so +that future flow needs an explicit Product-confirmed receipt or bridge design. ### Royalty event structure diff --git a/docs/index.html b/docs/index.html index f619627..b8d8c53 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1432,7 +1432,9 @@

Doors the artist can define

Rights and splits that can be audited

Runtime rules keep royalties, collaborator shares, and access decisions inspectable - without making chain mechanics the listener's first experience. + without making chain mechanics the listener's first experience. Classic payments now + move through an explicit native runtime intent, while Product CASH remains a separate + settlement rail until the cross-chain receipt model is verified.

@@ -1545,9 +1547,9 @@

Product DevNet now, sovereignty adapters next

current viem implementation, API-side Product sr25519 verification with frontend Product proof submission for protected key/session requests, and an opt-in CDM/PAPI path that can route runtime reads and write submissions through the Product host signer. The tracked - deployment still defaults to viem until account mapping, native value payment forwarding, - and host-signed transaction evidence are verified. Product Mobile in-app live audio pending - a host-exposed WebRTC capability, CASH settlement design, Humanity/Individuality proofs, + deployment still defaults to viem until account mapping, native payment forwarding, + host-signed transaction evidence, and Product CASH receipt design are verified. Product + Mobile in-app live audio pending a host-exposed WebRTC capability, Humanity/Individuality proofs, consented provenance, and ambassador mechanics remain sequenced behind verified security and privacy boundaries.

diff --git a/docs/product/ux-signature-flows.md b/docs/product/ux-signature-flows.md index 95b0549..2675993 100644 --- a/docs/product/ux-signature-flows.md +++ b/docs/product/ux-signature-flows.md @@ -115,6 +115,7 @@ sequenceDiagram participant API as Dotify Backend L->>UI: Click unlock full track + UI->>UI: Build native runtime payment intent UI->>L: Confirm Classic payment transaction L->>RT: musicRoyPayAccess(contentHash) + value RT-->>UI: Transaction confirmed @@ -125,6 +126,16 @@ sequenceDiagram UI-->>L: Full playback ``` +Classic unlock intentionally uses a native runtime payment intent today. The +visible asset symbol is derived from the connected EVM `chainId` and Dotify's +explicit Polkadot Hub native-currency metadata table (`PAS` on the current +Product DevNet/Paseo Asset Hub rail, `DOT` on a DOT-backed Polkadot Hub EVM +chain), not hard-coded as DOT. This is not an on-chain metadata fetch: standard +EVM JSON-RPC exposes `eth_chainId`, not native-token symbol metadata. +Product CASH settlement is a separate future rail; it must not be treated as +executable until Dotify has a Product-confirmed receipt or bridge model between +People chain CASH and Asset Hub runtime entitlement. + ## Human Free flow ```mermaid diff --git a/docs/reference/contracts-api.md b/docs/reference/contracts-api.md index 5eb1124..28c3a94 100644 --- a/docs/reference/contracts-api.md +++ b/docs/reference/contracts-api.md @@ -2,6 +2,13 @@ All contracts are deployed on **Paseo Asset Hub** (`chainId 420420417`). Source lives in `Dotify/contracts/evm/contracts/`. +Classic unlock payments settle through the runtime's native EVM `msg.value` +rail. The frontend fetches the connected EVM `chainId`, then derives the +displayed native asset from an explicit Polkadot Hub metadata table because +standard EVM JSON-RPC does not expose a native-currency symbol/decimals method. +The current Product DevNet/Paseo runtime rail displays `PAS`; a DOT-backed +Polkadot Hub EVM chain displays `DOT`. + > **Pre-fix deployment warning (2026-07-12):** this reference describes the > current source contract. The configured Paseo factory and indexed runtimes > still use the registry facet deployed before `musicRegRegister` became @@ -325,6 +332,10 @@ Pay for access to a Classic-mode track. field name is historical; Dotify now stores the price as 18-decimal Asset Hub EVM native units. +Frontend writes build an explicit native runtime payment intent before calling +this function. CASH is not passed through this runtime method; Product-native +CASH settlement requires a future receipt or bridge model. + On success: 1. Distributes `msg.value` across royalty splits (basis points). @@ -404,11 +415,12 @@ Introspection: `facets()`, `facetFunctionSelectors(address)`, `facetAddresses()` The Solidity field is still named `pricePlanck` for historical/Substrate context, but the active EVM path stores prices directly as 18-decimal native token units. The frontend uses viem's `parseEther()` and `formatEther()` helpers -for DOT display and `msg.value`. +for display and `msg.value`; the payment symbol comes from the configured EVM +chain's native currency (`PAS` on chain `420420417`). | Format | Example | | ------------------------------ | ------------------------- | -| DOT (display) | `0.5` | +| Native display amount | `0.5` | | Stored value / EVM `msg.value` | `500_000_000_000_000_000` | Frontend conversion: `src/utils/format.ts` → `dotToPlanck()` for input and diff --git a/docs/reference/hooks-api.md b/docs/reference/hooks-api.md index be80de0..28e1d30 100644 --- a/docs/reference/hooks-api.md +++ b/docs/reference/hooks-api.md @@ -20,6 +20,7 @@ const catalog = useCatalog({ directoryAddress, setShowWalletModal, setTransactionFeedback, + activeView, navigateToView, getActiveWalletClient, setBulletinManifestRef, @@ -61,13 +62,21 @@ const catalog = useCatalog({ | `checkTrackAccess` | `(track: CatalogTrack, address: 0x${string} \| null) => Promise` | Calls `musicAccCanAccess` on the artist's SmartRuntime. Returns `true` if access is granted. | | `handleAudioFile` | `(event: ChangeEvent) => Promise` | Hash, encrypt, and begin uploading an audio file. Updates `audioSource`, `fileHash`, `audioCID`. | | `handleCoverFile` | `(event: ChangeEvent) => void` | Begin uploading a cover image. Updates `coverSource`, `coverCID`. | -| `payForTrackAccess` | `(track: CatalogTrack) => Promise` | Submits `musicRoyPayAccess` with the track's price. On success, re-selects the track with full access. | +| `payForTrackAccess` | `(track: CatalogTrack, socketEmit?, setLocalStreamReady?, closeHostPeers?) => Promise` | Build and submit a Classic unlock payment, then restore full access on success. | | `setSelectedTrackId` | `(id: string) => void` | Direct setter, used when clearing the draft upload state. | | `setTrackInfo` | `(info: TrackInfo \| null) => void` | Direct setter, used by `useSession` to update from room track events. | | `setPlayerState` | `(state: PlayerState \| null) => void` | Direct setter, used by `useSession` to sync remote player state. | | `setAccessGate` | `(gate: AccessGate \| null) => void` | Direct setter, used to dismiss the access gate. | | `setCoverSource` | `(url: string) => void` | Direct setter, used when selecting a catalog track to update the player cover. | +`payForTrackAccess` builds its native runtime payment intent from the +authoritative on-chain `pricePlanck` when available; `priceDot` is display only. +The displayed payment symbol is derived from the configured chain's native +currency. If the user changes track or view while a transaction is pending, +success still records access but does not force navigation back to the player. +Room callers pass the same session callbacks as `selectTrack`, so an in-room +Classic unlock can refresh the host stream and room metadata. + #### Refs (pass-through to views) | Ref | Type | Description | diff --git a/docs/reference/types.md b/docs/reference/types.md index 667fbb4..00e967f 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -63,7 +63,7 @@ The access policy for a registered track. | Value | Meaning | | ------------ | ---------------------------------------- | | `human-free` | Unlocked by Polkadot Proof of Personhood | -| `classic` | Unlocked by DOT payment | +| `classic` | Unlocked by runtime-native payment | --- @@ -200,7 +200,7 @@ type TrackInfo = { updatedAt: number; // Unix ms timestamp imageRef?: string; // URL or IPFS ref for cover image audioRef?: string; // URL, IPFS ref, or Dotify encrypted audio ref - priceDot?: string; // Decimal DOT amount (Classic mode only) + priceDot?: string; // Legacy display amount label (Classic mode only) bulletinRef: string; // Bulletin archive ref, or empty string metadataRef?: string; // IPFS metadata ref (ipfs://) description?: string; @@ -225,7 +225,8 @@ type CatalogTrack = { artistAddress?: `0x${string}`; audioRef: string; imageRef: string; - priceDot: string; + priceDot: string; // Legacy display-only decimal amount + pricePlanck?: bigint; // Authoritative 18-decimal native amount when loaded from chain/API localUrl?: string; // Resolved playable URL (blob:, http:, etc.) duration?: number; hash: `0x${string}`; @@ -243,7 +244,12 @@ type CatalogTrack = { }; ``` -The full catalog entry for a track as used in the browse and player views. On-chain tracks have `source: 'artist'` and an `id` of the form `:`. +The full catalog entry for a track as used in the browse and player views. +On-chain tracks have `source: 'artist'` and an `id` of the form +`:`. `priceDot` is a legacy display field; +Classic unlock payments use `pricePlanck` when it is present so the submitted +`msg.value` matches the runtime's stored price exactly, and the visible payment +symbol comes from the configured chain's native currency. --- @@ -372,7 +378,7 @@ protected audio should play. `actionType` controls which CTA is shown: | `actionType` | Shown when | CTA | | ------------ | ---------------------------------- | ----------------------------------------------- | | `signin` | No wallet connected | "Use wallet to unlock" | -| `payment` | Wallet connected, Classic track | "Pay X DOT to unlock" | +| `payment` | Wallet connected, Classic track | "Pay X native token to unlock" | | `personhood` | Wallet connected, insufficient PoP | No payment CTA — user must obtain PoP off-chain | --- diff --git a/web/src/app/providers/CatalogProvider.tsx b/web/src/app/providers/CatalogProvider.tsx index b30edb6..92a8865 100644 --- a/web/src/app/providers/CatalogProvider.tsx +++ b/web/src/app/providers/CatalogProvider.tsx @@ -20,7 +20,7 @@ const CatalogContext = createContext(null); export function CatalogProvider({ children }: { children: ReactNode }) { const { ethRpcUrl, listenerEvmAddress, connectedWallet, getActiveWalletClient } = useWalletContext(); const { setShowWalletModal, setTransactionFeedback } = useUiFeedback(); - const { navigateToView } = useNavigation(); + const { activeView, navigateToView } = useNavigation(); const { setTitle, setAccessMode, setPriceDot, setPersonhoodLevel, setArtistName, setDescription, bulletinManifestRef } = useReleaseForm(); const directoryAddress = deployments.directory; @@ -32,6 +32,7 @@ export function CatalogProvider({ children }: { children: ReactNode }) { setShowWalletModal, setTransactionFeedback, setTitle, + activeView, navigateToView, getActiveWalletClient, setBulletinManifestRef: ref => { diff --git a/web/src/features/payments/paymentModel.test.ts b/web/src/features/payments/paymentModel.test.ts new file mode 100644 index 0000000..e87015d --- /dev/null +++ b/web/src/features/payments/paymentModel.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { + DOTIFY_CASH_ASSET, + DOTIFY_FALLBACK_NATIVE_RUNTIME_ASSET, + cashSettlementUnavailableReason, + classicTrackPaymentAmountPlanck, + createNativeRuntimeAccessPaymentIntent, + createUnsupportedCashAccessPaymentIntent, + nativeRuntimePaymentAssetFromChain +} from './paymentModel'; +import { formatWeiAsDot } from '../../shared/utils/format'; + +const runtimeAddress = '0x3000000000000000000000000000000000000000' as const; +const contentHash = `0x${'ab'.repeat(32)}` as const; +const nativeAsset = nativeRuntimePaymentAssetFromChain({ nativeCurrency: { name: 'Paseo', symbol: 'PAS', decimals: 18 } }); + +describe('payment model', () => { + it('derives the runtime native payment asset from the configured chain currency', () => { + expect(nativeAsset).toEqual({ + kind: 'native', + symbol: 'PAS', + decimals: 18, + settlement: 'runtime-msg-value' + }); + expect(nativeRuntimePaymentAssetFromChain(null)).toEqual(DOTIFY_FALLBACK_NATIVE_RUNTIME_ASSET); + }); + + it('creates an executable native runtime payment intent for Classic unlocks', () => { + expect( + createNativeRuntimeAccessPaymentIntent({ + runtimeAddress, + contentHash, + amountPlanck: 42n, + asset: nativeAsset + }) + ).toEqual({ + kind: 'track-access', + rail: 'runtime-native', + asset: nativeAsset, + runtimeAddress, + contentHash, + amountPlanck: 42n + }); + }); + + it('fails closed before submitting zero-value Classic unlock payments', () => { + expect(() => + createNativeRuntimeAccessPaymentIntent({ + runtimeAddress, + contentHash, + amountPlanck: 0n, + asset: nativeAsset + }) + ).toThrow(/positive native amount/); + }); + + it('uses the authoritative on-chain amount instead of the rounded display price', () => { + const onchainPricePlanck = 1_500_000_000_500_000_000n; + const displayPriceDot = formatWeiAsDot(onchainPricePlanck); + + expect(displayPriceDot).toBe('1.500000000'); + expect(classicTrackPaymentAmountPlanck({ priceDot: displayPriceDot, pricePlanck: onchainPricePlanck })).toBe(onchainPricePlanck); + }); + + it('keeps tiny positive on-chain prices payable even when display formatting rounds to zero', () => { + const onchainPricePlanck = 100_000_000n; + const displayPriceDot = formatWeiAsDot(onchainPricePlanck); + + expect(displayPriceDot).toBe('0.000000000'); + expect(classicTrackPaymentAmountPlanck({ priceDot: displayPriceDot, pricePlanck: onchainPricePlanck })).toBe(onchainPricePlanck); + }); + + it('falls back to the decimal display amount for non-registry tracks without a planck price', () => { + expect(classicTrackPaymentAmountPlanck({ priceDot: '0.5' })).toBe(500_000_000_000_000_000n); + }); + + it('models CASH access as an explicit unsupported future settlement rail', () => { + const intent = createUnsupportedCashAccessPaymentIntent({ + runtimeAddress, + contentHash, + requestedAmount: '1 CASH' + }); + + expect(intent).toMatchObject({ + kind: 'track-access', + rail: 'product-cash', + asset: DOTIFY_CASH_ASSET, + status: 'unsupported', + requestedAmount: '1 CASH' + }); + expect(intent.reason).toBe(cashSettlementUnavailableReason()); + expect(intent.reason).toMatch(/People chain/); + expect(intent.reason).toMatch(/Asset Hub/); + }); +}); diff --git a/web/src/features/payments/paymentModel.ts b/web/src/features/payments/paymentModel.ts new file mode 100644 index 0000000..6246782 --- /dev/null +++ b/web/src/features/payments/paymentModel.ts @@ -0,0 +1,112 @@ +import { parseEther, type Address, type Chain, type Hash } from 'viem'; +import type { CatalogTrack } from '../../shared/types'; + +export type DotifyNativeRuntimeAsset = { + kind: 'native'; + symbol: string; + decimals: number; + settlement: 'runtime-msg-value'; +}; + +export const DOTIFY_FALLBACK_NATIVE_RUNTIME_ASSET: DotifyNativeRuntimeAsset = { + kind: 'native', + symbol: 'UNIT', + decimals: 18, + settlement: 'runtime-msg-value' +} as const; + +export const DOTIFY_CASH_ASSET = { + kind: 'cash', + symbol: 'CASH', + settlement: 'pending-product-confirmation' +} as const; + +export type DotifyCashAsset = typeof DOTIFY_CASH_ASSET; +export type DotifyPaymentAsset = DotifyNativeRuntimeAsset | DotifyCashAsset; + +export type NativeRuntimeAccessPaymentIntent = { + kind: 'track-access'; + rail: 'runtime-native'; + asset: DotifyNativeRuntimeAsset; + runtimeAddress: Address; + contentHash: Hash; + amountPlanck: bigint; +}; + +export type CashAccessPaymentIntent = { + kind: 'track-access'; + rail: 'product-cash'; + asset: DotifyCashAsset; + runtimeAddress: Address; + contentHash: Hash; + requestedAmount: string; + status: 'unsupported'; + reason: string; +}; + +export type TrackAccessPaymentIntent = NativeRuntimeAccessPaymentIntent | CashAccessPaymentIntent; +export type ExecutableTrackAccessPaymentIntent = NativeRuntimeAccessPaymentIntent; + +export function nativeRuntimePaymentAssetFromChain(chain: Pick | null | undefined): DotifyNativeRuntimeAsset { + const currency = chain?.nativeCurrency; + const symbol = currency?.symbol?.trim() || DOTIFY_FALLBACK_NATIVE_RUNTIME_ASSET.symbol; + const configuredDecimals = currency?.decimals; + const decimals = + typeof configuredDecimals === 'number' && Number.isInteger(configuredDecimals) && configuredDecimals > 0 + ? configuredDecimals + : DOTIFY_FALLBACK_NATIVE_RUNTIME_ASSET.decimals; + + return { + kind: 'native', + symbol, + decimals, + settlement: 'runtime-msg-value' + }; +} + +export function classicTrackPaymentAmountPlanck(track: Pick): bigint { + return track.pricePlanck ?? parseEther(track.priceDot.trim() || '0'); +} + +export function createNativeRuntimeAccessPaymentIntent(input: { + runtimeAddress: Address; + contentHash: Hash; + amountPlanck: bigint; + asset: DotifyNativeRuntimeAsset; +}): NativeRuntimeAccessPaymentIntent { + if (input.amountPlanck <= 0n) { + throw new Error('Classic unlock payments require a positive native amount.'); + } + + return { + kind: 'track-access', + rail: 'runtime-native', + asset: input.asset, + runtimeAddress: input.runtimeAddress, + contentHash: input.contentHash, + amountPlanck: input.amountPlanck + }; +} + +export function createUnsupportedCashAccessPaymentIntent(input: { + runtimeAddress: Address; + contentHash: Hash; + requestedAmount: string; +}): CashAccessPaymentIntent { + // Design marker only: do not route this to UI or runtime writers until + // Product confirms CASH settlement evidence. + return { + kind: 'track-access', + rail: 'product-cash', + asset: DOTIFY_CASH_ASSET, + runtimeAddress: input.runtimeAddress, + contentHash: input.contentHash, + requestedAmount: input.requestedAmount, + status: 'unsupported', + reason: cashSettlementUnavailableReason() + }; +} + +export function cashSettlementUnavailableReason(): string { + return 'Product CASH settlement is not executable yet: CASH lives on People chain while Dotify runtime entitlements live on Asset Hub, and the receipt/bridge model still needs Product confirmation.'; +} diff --git a/web/src/features/runtime/productCdmRuntimeAdapter.test.ts b/web/src/features/runtime/productCdmRuntimeAdapter.test.ts index 3ea0a23..f2de93c 100644 --- a/web/src/features/runtime/productCdmRuntimeAdapter.test.ts +++ b/web/src/features/runtime/productCdmRuntimeAdapter.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { DOTIFY_FALLBACK_NATIVE_RUNTIME_ASSET, createNativeRuntimeAccessPaymentIntent } from '../payments/paymentModel'; import { ProductCdmRuntimeError, ProductCdmRuntimeUnsupportedOperationError, @@ -234,7 +235,16 @@ describe('createProductCdmRuntimeWriter', () => { royaltyShares: [10_000] }) ).resolves.toBe(txHash); - await expect(writer.payForAccess(runtime, hash, 3n)).resolves.toBe(txHash); + await expect( + writer.payForAccess( + createNativeRuntimeAccessPaymentIntent({ + runtimeAddress: runtime, + contentHash: hash, + amountPlanck: 3n, + asset: DOTIFY_FALLBACK_NATIVE_RUNTIME_ASSET + }) + ) + ).resolves.toBe(txHash); await expect( writer.setAccessMode(runtime, { contentHash: hash, diff --git a/web/src/features/runtime/productCdmRuntimeAdapter.ts b/web/src/features/runtime/productCdmRuntimeAdapter.ts index 0ea0956..fb9b789 100644 --- a/web/src/features/runtime/productCdmRuntimeAdapter.ts +++ b/web/src/features/runtime/productCdmRuntimeAdapter.ts @@ -257,9 +257,10 @@ export function createProductCdmRuntimeWriter(deps: ProductCdmRuntimeAdapterDeps // positional args followed by an optional options object, and `TxOptions` // carries `value?: bigint`. txContract spreads this array, so the call is // `musicRoyPayAccess.tx(contentHash, { value })` - the CDM equivalent of - // the viem writer's sibling `value` field. - payForAccess(runtimeAddress, contentHash, value) { - return txContract(deps.contracts.getRuntimeContract(runtimeAddress), 'musicRoyPayAccess', [contentHash, { value }]); + // the viem writer's sibling `value` field. Only native runtime intents are + // accepted here; Product CASH settlement needs a separate receipt path. + payForAccess(intent) { + return txContract(deps.contracts.getRuntimeContract(intent.runtimeAddress), 'musicRoyPayAccess', [intent.contentHash, { value: intent.amountPlanck }]); }, setAccessMode(runtimeAddress, update: RuntimeAccessPolicyUpdate) { diff --git a/web/src/features/runtime/runtimePorts.ts b/web/src/features/runtime/runtimePorts.ts index 5fd7622..bc14410 100644 --- a/web/src/features/runtime/runtimePorts.ts +++ b/web/src/features/runtime/runtimePorts.ts @@ -1,4 +1,5 @@ import type { Address, Hash } from 'viem'; +import type { ExecutableTrackAccessPaymentIntent } from '../payments/paymentModel'; import type { OnchainTrackRecord, RoyaltySplit } from '../../shared/types'; export type RuntimeDirectoryEntry = { @@ -83,7 +84,7 @@ export interface RuntimeWritePort { createRuntime(factoryAddress: Address): Promise; installRuntimeStep(factoryAddress: Address): Promise; registerTrack(runtimeAddress: Address, registration: RuntimeTrackRegistration): Promise; - payForAccess(runtimeAddress: Address, contentHash: Hash, value: bigint): Promise; + payForAccess(intent: ExecutableTrackAccessPaymentIntent): Promise; setAccessMode(runtimeAddress: Address, update: RuntimeAccessPolicyUpdate): Promise; setReleaseActive(runtimeAddress: Address, contentHash: Hash, active: boolean): Promise; waitForTransaction(txHash: Hash): Promise; diff --git a/web/src/features/runtime/runtimeWriterProvider.test.ts b/web/src/features/runtime/runtimeWriterProvider.test.ts index 24b7fa6..4543949 100644 --- a/web/src/features/runtime/runtimeWriterProvider.test.ts +++ b/web/src/features/runtime/runtimeWriterProvider.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DOTIFY_FALLBACK_NATIVE_RUNTIME_ASSET, createNativeRuntimeAccessPaymentIntent } from '../payments/paymentModel'; const txHash = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as const; const factory = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as const; @@ -76,6 +77,15 @@ function mockProductSigner(selectedAccount = productAccount()) { return { signerManager, signerManagerOptions, SignerManager, HostProvider }; } +function accessIntent(amountPlanck: bigint) { + return createNativeRuntimeAccessPaymentIntent({ + runtimeAddress: runtime, + contentHash: hash, + amountPlanck, + asset: DOTIFY_FALLBACK_NATIVE_RUNTIME_ASSET + }); +} + describe('createRuntimeWriter', () => { it('uses the viem writer by default and resolves the active wallet per write', async () => { const createRuntimeWriter = await loadProvider(); @@ -88,14 +98,15 @@ describe('createRuntimeWriter', () => { config: { kind: 'viem', productEnvironment: 'devnet' } }); - await expect(writer.payForAccess(runtime, hash, 42n)).resolves.toBe(txHash); + const intent = accessIntent(42n); + await expect(writer.payForAccess(intent)).resolves.toBe(txHash); await expect(writer.waitForTransaction(txHash)).resolves.toBeUndefined(); const { createViemRuntimeWriter } = await import('./viemRuntimeAdapter'); expect(createViemRuntimeWriter).toHaveBeenCalledTimes(2); expect(createViemRuntimeWriter).toHaveBeenCalledWith({ ethRpcUrl: 'https://rpc.example', walletClient }); expect(getViemWalletClient).toHaveBeenCalledTimes(2); - expect(viemWriter.payForAccess).toHaveBeenCalledWith(runtime, hash, 42n); + expect(viemWriter.payForAccess).toHaveBeenCalledWith(intent); }); it('explains that Product writes are absent when the build did not opt in', async () => { @@ -109,7 +120,7 @@ describe('createRuntimeWriter', () => { config: { kind: 'product-cdm', productEnvironment: 'devnet' } }); - await expect(writer.payForAccess(runtime, hash, 1n)).rejects.toThrow(/Product contract write adapter is not bundled/); + await expect(writer.payForAccess(accessIntent(1n))).rejects.toThrow(/Product contract write adapter is not bundled/); }); it('routes Product writes through CDM setup without falling back to viem', async () => { @@ -136,7 +147,8 @@ describe('createRuntimeWriter', () => { }); await expect(writer.createRuntime(factory)).resolves.toBe(txHash); - await expect(writer.payForAccess(runtime, hash, 7n)).resolves.toBe(txHash); + const intent = accessIntent(7n); + await expect(writer.payForAccess(intent)).resolves.toBe(txHash); const { createProductCdmContracts } = await import('./productCdmContracts'); const { createProductCdmRuntimeWriter } = await import('./productCdmRuntimeAdapter'); @@ -159,7 +171,7 @@ describe('createRuntimeWriter', () => { expect(createProductCdmRuntimeWriter).toHaveBeenCalledTimes(1); expect(createProductCdmRuntimeWriter).toHaveBeenCalledWith({ contracts: resolver }); expect(productWriter.createRuntime).toHaveBeenCalledWith(factory); - expect(productWriter.payForAccess).toHaveBeenCalledWith(runtime, hash, 7n); + expect(productWriter.payForAccess).toHaveBeenCalledWith(intent); expect(createViemRuntimeWriter).not.toHaveBeenCalled(); expect(getViemWalletClient).not.toHaveBeenCalled(); }); @@ -179,7 +191,7 @@ describe('createRuntimeWriter', () => { productAccount: { productId: 'dotify-test01.dot', publicKey: productPublicKey } }); - await expect(writer.payForAccess(runtime, hash, 1n)).rejects.toThrow(/Product CDM signer mismatch/); + await expect(writer.payForAccess(accessIntent(1n))).rejects.toThrow(/Product CDM signer mismatch/); expect(signerManager.destroy).toHaveBeenCalledTimes(1); expect(createProductCdmContracts).not.toHaveBeenCalled(); }); diff --git a/web/src/features/runtime/runtimeWriterProvider.ts b/web/src/features/runtime/runtimeWriterProvider.ts index 4a8059a..64b7be1 100644 --- a/web/src/features/runtime/runtimeWriterProvider.ts +++ b/web/src/features/runtime/runtimeWriterProvider.ts @@ -140,7 +140,7 @@ export function createRuntimeWriter(deps: RuntimeWriterDeps): RuntimeWritePort { createRuntime: factoryAddress => portForWrite().then(port => port.createRuntime(factoryAddress)), installRuntimeStep: factoryAddress => portForWrite().then(port => port.installRuntimeStep(factoryAddress)), registerTrack: (runtimeAddress, registration: RuntimeTrackRegistration) => portForWrite().then(port => port.registerTrack(runtimeAddress, registration)), - payForAccess: (runtimeAddress, contentHash, value) => portForWrite().then(port => port.payForAccess(runtimeAddress, contentHash, value)), + payForAccess: intent => portForWrite().then(port => port.payForAccess(intent)), setAccessMode: (runtimeAddress, update: RuntimeAccessPolicyUpdate) => portForWrite().then(port => port.setAccessMode(runtimeAddress, update)), setReleaseActive: (runtimeAddress, contentHash, active) => portForWrite().then(port => port.setReleaseActive(runtimeAddress, contentHash, active)), waitForTransaction: txHash => portForWrite().then(port => port.waitForTransaction(txHash)) diff --git a/web/src/features/runtime/viemRuntimeAdapter.test.ts b/web/src/features/runtime/viemRuntimeAdapter.test.ts index e05ef4c..8de04c4 100644 --- a/web/src/features/runtime/viemRuntimeAdapter.test.ts +++ b/web/src/features/runtime/viemRuntimeAdapter.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { DOTIFY_FALLBACK_NATIVE_RUNTIME_ASSET, createNativeRuntimeAccessPaymentIntent } from '../payments/paymentModel'; import { createViemRuntimeReader, createViemRuntimeWriter } from './viemRuntimeAdapter'; import type { OnchainTrackRecord } from '../../shared/types'; @@ -165,7 +166,16 @@ describe('createViemRuntimeWriter', () => { await expect(writer.createRuntime(factory)).resolves.toBe(`${txHash}:createRuntime`); await expect(writer.installRuntimeStep(factory)).resolves.toBe(`${txHash}:installRuntimeStep`); - await expect(writer.payForAccess(runtime, hash, 1n)).resolves.toBe(`${txHash}:musicRoyPayAccess`); + await expect( + writer.payForAccess( + createNativeRuntimeAccessPaymentIntent({ + runtimeAddress: runtime, + contentHash: hash, + amountPlanck: 1n, + asset: DOTIFY_FALLBACK_NATIVE_RUNTIME_ASSET + }) + ) + ).resolves.toBe(`${txHash}:musicRoyPayAccess`); await expect( writer.registerTrack(runtime, { contentHash: hash, @@ -196,5 +206,13 @@ describe('createViemRuntimeWriter', () => { await writer.waitForTransaction(txHash); expect(waitForTransactionReceipt).toHaveBeenCalledWith({ hash: txHash }); expect(writeContract).toHaveBeenCalledTimes(6); + expect(writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: runtime, + functionName: 'musicRoyPayAccess', + args: [hash], + value: 1n + }) + ); }); }); diff --git a/web/src/features/runtime/viemRuntimeAdapter.ts b/web/src/features/runtime/viemRuntimeAdapter.ts index a3dc284..7223d51 100644 --- a/web/src/features/runtime/viemRuntimeAdapter.ts +++ b/web/src/features/runtime/viemRuntimeAdapter.ts @@ -274,13 +274,13 @@ export function createViemRuntimeWriter(deps: ViemRuntimeWriterDeps): RuntimeWri }); }, - payForAccess(runtimeAddress, contentHash, value) { + payForAccess(intent) { return walletClient.writeContract({ - address: runtimeAddress, + address: intent.runtimeAddress, abi: musicRoyaltiesAbi, functionName: 'musicRoyPayAccess', - args: [contentHash], - value + args: [intent.contentHash], + value: intent.amountPlanck }); }, diff --git a/web/src/hooks/useCatalog.ts b/web/src/hooks/useCatalog.ts index e430c97..6d662d7 100644 --- a/web/src/hooks/useCatalog.ts +++ b/web/src/hooks/useCatalog.ts @@ -1,6 +1,6 @@ -import { useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { fetchAssetRef, fetchIpfsCid, getGatewayUrl } from '../services/pinata'; -import { getPublicClient } from '../shared/config/contracts'; +import { getPublicClient, resolveEvmChain } from '../shared/config/contracts'; import { decryptAudio, hexToBytes } from '../shared/utils/crypto'; import { formatWeiAsDot } from '../shared/utils/format'; import { isKeyServiceConfigured, requestContentKey, requestFreeContentKey, type KeyRequestPurpose } from '../services/keyService'; @@ -23,6 +23,12 @@ import { fetchAudioV2RangeThroughGateways, type AudioV2GatewayPhase, type AudioV import { pumpAudioV2ReadAhead } from '../features/catalog/audioV2Pipeline'; import { AudioV2ChunkAuthenticationError, routeAudioV2MseFailure } from '../features/catalog/audioV2Recovery'; import { runtimeAddressFromTrackId } from '../features/catalog/trackModel'; +import { + DOTIFY_FALLBACK_NATIVE_RUNTIME_ASSET, + classicTrackPaymentAmountPlanck, + createNativeRuntimeAccessPaymentIntent, + nativeRuntimePaymentAssetFromChain +} from '../features/payments/paymentModel'; import { decodeAccessMode, decodePersonhood } from '../features/runtime/accessEncoding'; import { resolveRuntimeAdapterConfig } from '../features/runtime/runtimeAdapterConfig'; import { createRuntimeReader } from '../features/runtime/runtimeReaderProvider'; @@ -59,7 +65,8 @@ import type { RegistryCatalogTrack, RoomPlaybackMode, TrackInfo, - TransactionFeedback + TransactionFeedback, + View } from '../shared/types'; import type { ConnectedWallet } from './useWallet'; @@ -226,6 +233,7 @@ function catalogApiReleaseToTrack(release: CatalogApiRelease): CatalogTrack { audioRef: release.audioRef, imageRef: resolveVisualAssetRef(release.imageRef, release.title), priceDot: release.priceDot, + pricePlanck: BigInt(release.priceWei), localUrl: resolveAudioAssetRef(release.audioRef), description: release.description, bulletinRef: release.bulletinRef, @@ -250,6 +258,7 @@ export type UseCatalogDeps = { directoryAddress: `0x${string}` | undefined; setShowWalletModal: (show: boolean) => void; setTransactionFeedback: (feedback: TransactionFeedback | null) => void; + activeView: View; navigateToView: (view: 'listen' | 'player' | 'rooms') => void; getActiveWalletClient: () => Promise>>; setBulletinManifestRef: (ref: string) => void; @@ -270,6 +279,7 @@ export function useCatalog(deps: UseCatalogDeps) { setShowWalletModal, setTransactionFeedback, setTitle, + activeView, navigateToView, getActiveWalletClient, setBulletinManifestRef, @@ -316,6 +326,7 @@ export function useCatalog(deps: UseCatalogDeps) { const [selectedTrackId, setSelectedTrackId] = useState(''); const [catalogAccessByTrackId, setCatalogAccessByTrackId] = useState>({}); const [catalogPaidAccessByTrackId, setCatalogPaidAccessByTrackId] = useState>({}); + const [nativeRuntimePaymentAsset, setNativeRuntimePaymentAsset] = useState(DOTIFY_FALLBACK_NATIVE_RUNTIME_ASSET); const [audioSource, setAudioSource] = useState(null); const [trackInfo, setTrackInfo] = useState(null); const [playerState, setPlayerState] = useState(null); @@ -332,6 +343,8 @@ export function useCatalog(deps: UseCatalogDeps) { const audioUploadRef = useRef | null>(null); const coverUploadRef = useRef | null>(null); const localAudioRef = useRef(null); + const selectedTrackIdRef = useRef(selectedTrackId); + const activeViewRef = useRef(activeView); const activeTrackSelectionRef = useRef<{ id: number; controller: AbortController } | null>(null); const nextTrackSelectionIdRef = useRef(0); const e2eClassicAccessGrantedRef = useRef(false); @@ -342,6 +355,32 @@ export function useCatalog(deps: UseCatalogDeps) { // never request keys at all (they only receive the WebRTC stream). const keyRequestPurposeRef = useRef('individual'); + useEffect(() => { + selectedTrackIdRef.current = selectedTrackId; + }, [selectedTrackId]); + + useEffect(() => { + activeViewRef.current = activeView; + }, [activeView]); + + useEffect(() => { + let cancelled = false; + + async function resolveNativeRuntimePaymentAsset() { + try { + const chain = await resolveEvmChain(ethRpcUrl); + if (!cancelled) setNativeRuntimePaymentAsset(nativeRuntimePaymentAssetFromChain(chain)); + } catch { + if (!cancelled) setNativeRuntimePaymentAsset(DOTIFY_FALLBACK_NATIVE_RUNTIME_ASSET); + } + } + + void resolveNativeRuntimePaymentAsset(); + return () => { + cancelled = true; + }; + }, [ethRpcUrl]); + function internalSetFileHash(hash: `0x${string}` | '') { setFileHashState(hash); } @@ -446,7 +485,7 @@ export function useCatalog(deps: UseCatalogDeps) { return { track, title: 'Support and open this track', - message: `"${track.title}" opens for ${track.priceDot} DOT. Review the split before confirming.`, + message: `"${track.title}" opens for ${track.priceDot} ${nativeRuntimePaymentAsset.symbol}. Review the split before confirming.`, hint: 'Nothing is sent until you confirm.', actionType: 'signin' }; @@ -472,7 +511,7 @@ export function useCatalog(deps: UseCatalogDeps) { return { track, title: 'Support and open this track', - message: `"${track.title}" opens after ${track.priceDot} DOT of support. Review the artist-defined split before confirming.`, + message: `"${track.title}" opens after ${track.priceDot} ${nativeRuntimePaymentAsset.symbol} of support. Review the artist-defined split before confirming.`, hint: 'The artist-owned runtime distributes the confirmed amount.', actionType: 'payment' }; @@ -918,6 +957,7 @@ export function useCatalog(deps: UseCatalogDeps) { outgoingAudio.pause(); } + selectedTrackIdRef.current = track.id; setSelectedTrackId(track.id); setTitle(track.title); setArtistName(track.artist); @@ -1009,7 +1049,16 @@ export function useCatalog(deps: UseCatalogDeps) { : 'This action still requires a passkey or EVM wallet while Dotify contract writes are being validated on the Product DevNet host signer.'; } - async function payForTrackAccess(track: CatalogTrack) { + async function payForTrackAccess( + track: CatalogTrack, + socketEmit?: (event: string, data: unknown) => void, + setLocalStreamReady?: (ready: boolean) => void, + closeHostPeers?: () => void + ) { + const unlockStartedTrackId = track.id; + const unlockStartedView = activeViewRef.current; + const shouldRestoreUnlockedTrack = () => selectedTrackIdRef.current === unlockStartedTrackId && activeViewRef.current === unlockStartedView; + if (!connectedWallet) { setAccessGate(buildAccessGateInfo(track)); setShowWalletModal(true); @@ -1032,7 +1081,7 @@ export function useCatalog(deps: UseCatalogDeps) { setTransactionFeedback({ tone: 'pending', title: 'Support being confirmed', - message: `Confirming ${track.priceDot} DOT of support to open "${track.title}".` + message: `Confirming ${track.priceDot} ${nativeRuntimePaymentAsset.symbol} of support to open "${track.title}".` }); await new Promise(resolve => window.setTimeout(resolve, 20)); e2eClassicAccessGrantedRef.current = true; @@ -1045,26 +1094,51 @@ export function useCatalog(deps: UseCatalogDeps) { message: `Full listening for "${track.title}" is now available to this wallet.`, txHash: E2E_CLASSIC_TX_HASH }); - await selectTrack(track, undefined, undefined, undefined); + if (shouldRestoreUnlockedTrack()) { + navigateToView('player'); + await selectTrack(track, socketEmit, setLocalStreamReady, closeHostPeers); + } return; } const runtimeAddress = runtimeAddressFromTrackId(track); - if (!runtimeAddress) return; - - const { dotToPlanck } = await import('../shared/utils/format'); + if (!runtimeAddress) { + setAccessGate(null); + setTransactionFeedback({ + tone: 'error', + title: 'Payment setup failed', + message: 'This track is missing its artist runtime address. Refresh the catalog and try again.' + }); + return; + } - const priceWei = dotToPlanck(track.priceDot); + let paymentIntent; + try { + const chain = await resolveEvmChain(ethRpcUrl); + paymentIntent = createNativeRuntimeAccessPaymentIntent({ + runtimeAddress, + contentHash: track.hash, + amountPlanck: classicTrackPaymentAmountPlanck(track), + asset: nativeRuntimePaymentAssetFromChain(chain) + }); + } catch (intentError) { + setTransactionFeedback({ + tone: 'error', + title: 'Payment setup failed', + message: intentError instanceof Error ? intentError.message : 'Unable to prepare this payment.' + }); + return; + } setAccessGate(null); setTransactionFeedback({ tone: 'pending', title: 'Support being confirmed', - message: `Confirming ${track.priceDot} DOT of support to open "${track.title}".` + message: `Confirming ${track.priceDot} ${paymentIntent.asset.symbol} of support to open "${track.title}".` }); try { - const txHash = await runtimeWriter.payForAccess(runtimeAddress, track.hash, priceWei); + const txHash = await runtimeWriter.payForAccess(paymentIntent); setTransactionFeedback({ tone: 'pending', title: 'Awaiting confirmation', message: 'Payment submitted.', txHash }); await runtimeWriter.waitForTransaction(txHash); @@ -1076,7 +1150,10 @@ export function useCatalog(deps: UseCatalogDeps) { message: `Full listening for "${track.title}" is now available to this wallet.`, txHash }); - await selectTrack(track, undefined, undefined, undefined); + if (shouldRestoreUnlockedTrack()) { + navigateToView('player'); + await selectTrack(track, socketEmit, setLocalStreamReady, closeHostPeers); + } } catch (payError) { const message = payError instanceof Error ? payError.message : 'Payment failed'; setTransactionFeedback({ tone: 'error', title: 'Payment failed', message }); @@ -1100,6 +1177,7 @@ export function useCatalog(deps: UseCatalogDeps) { audioRef: track.audioRef, imageRef, priceDot: formatWeiAsDot(track.pricePlanck), + pricePlanck: track.pricePlanck, localUrl, description: track.description, bulletinRef: track.metadataRef.startsWith('paseo-bulletin:') ? track.metadataRef : '', diff --git a/web/src/shared/config/contracts.test.ts b/web/src/shared/config/contracts.test.ts new file mode 100644 index 0000000..e845173 --- /dev/null +++ b/web/src/shared/config/contracts.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { nativeCurrencyForChain } from './contracts'; + +describe('nativeCurrencyForChain', () => { + it('labels the configured Product DevNet / Paseo Asset Hub EVM chain as PAS', () => { + expect(nativeCurrencyForChain(420420417, 'https://eth-rpc-testnet.polkadot.io')).toEqual({ + name: 'Paseo', + symbol: 'PAS', + decimals: 18 + }); + }); + + it('labels Polkadot Hub EVM as DOT when the connected chain id is DOT-backed', () => { + expect(nativeCurrencyForChain(420420419, 'https://hub-rpc.polkadot.io')).toEqual({ + name: 'Polkadot', + symbol: 'DOT', + decimals: 18 + }); + }); + + it('labels Kusama Hub EVM as KSM when the connected chain id is KSM-backed', () => { + expect(nativeCurrencyForChain(420420418, 'https://hub-rpc.kusama.io')).toEqual({ + name: 'Kusama', + symbol: 'KSM', + decimals: 18 + }); + }); + + it('keeps local development chains on the generic UNIT symbol', () => { + expect(nativeCurrencyForChain(31337, 'http://127.0.0.1:8545')).toEqual({ + name: 'Unit', + symbol: 'UNIT', + decimals: 18 + }); + }); +}); diff --git a/web/src/shared/config/contracts.ts b/web/src/shared/config/contracts.ts index 1d91248..8e0c364 100644 --- a/web/src/shared/config/contracts.ts +++ b/web/src/shared/config/contracts.ts @@ -73,6 +73,24 @@ export async function resolveEvmChain(ethRpcUrl: string): Promise { return resolveChain(ethRpcUrl); } +// EVM JSON-RPC gives us `eth_chainId`, but not a standard native-token +// metadata method. Dotify therefore derives the native payment label from the +// connected EVM chain id. Decimals stay at 18 because the current runtime write +// path settles through EVM `msg.value`. +const polkadotHubNativeCurrencyByChainId: Record = { + 420420417: { name: 'Paseo', symbol: 'PAS', decimals: 18 }, + 420420418: { name: 'Kusama', symbol: 'KSM', decimals: 18 }, + 420420419: { name: 'Polkadot', symbol: 'DOT', decimals: 18 } +}; + +export function nativeCurrencyForChain(chainId: number, ethRpcUrl: string): Chain['nativeCurrency'] { + const knownCurrency = polkadotHubNativeCurrencyByChainId[chainId]; + if (knownCurrency) return knownCurrency; + + const isLocalChain = ethRpcUrl.includes('localhost') || ethRpcUrl.includes('127.0.0.1'); + return isLocalChain ? { name: 'Unit', symbol: 'UNIT', decimals: 18 } : { name: 'Native token', symbol: 'UNIT', decimals: 18 }; +} + async function resolveChain(ethRpcUrl: string): Promise { if (!chainCache) { const chainId = await getPublicClient(ethRpcUrl).getChainId(); @@ -80,7 +98,7 @@ async function resolveChain(ethRpcUrl: string): Promise { chainCache = defineChain({ id: chainId, name: isLocalChain ? 'Local Polkadot Devnet' : 'Polkadot Hub TestNet', - nativeCurrency: { name: 'Unit', symbol: 'UNIT', decimals: 18 }, + nativeCurrency: nativeCurrencyForChain(chainId, ethRpcUrl), rpcUrls: { default: { http: [ethRpcUrl] } }, ...(isLocalChain ? {} : { blockExplorers: { default: { name: 'Blockscout', url: blockscoutBaseUrl } } }) }); diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 2f868d0..1489727 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -65,6 +65,7 @@ export type CatalogTrack = { audioRef: string; imageRef: string; priceDot: string; + pricePlanck?: bigint; localUrl?: string; duration?: number; hash: `0x${string}`; @@ -103,6 +104,7 @@ export type OnchainTrackRecord = { export type RegistryCatalogTrack = CatalogTrack & { artistAddress: `0x${string}`; + pricePlanck: bigint; registeredAtBlock: number; }; diff --git a/web/src/views/PlayerView.tsx b/web/src/views/PlayerView.tsx index 58447d3..5566536 100644 --- a/web/src/views/PlayerView.tsx +++ b/web/src/views/PlayerView.tsx @@ -84,7 +84,7 @@ export function PlayerView({ onShowCreateModal, onShowJoinModal }: PlayerViewPro const onCopySessionLink = session.copySessionLink; const onSetAccessGate = catalog.setAccessGate; const onPayForTrackAccess = (track: CatalogTrack) => { - void catalog.payForTrackAccess(track); + void catalog.payForTrackAccess(track, session.socketEmit, session.setLocalStreamReady, session.closeHostPeers); }; const onShowWalletModal = () => setShowWalletModal(true); const onNavigateToListen = () => navigateToView('listen');