diff --git a/docs/operations/deployment-configuration.md b/docs/operations/deployment-configuration.md index 0d07286a..d8e6df81 100644 --- a/docs/operations/deployment-configuration.md +++ b/docs/operations/deployment-configuration.md @@ -192,13 +192,18 @@ Required Product values: | `VITE_BULLETIN_WS_URL` | `wss://bulletin-paseo.tservices.es:8443` | | `VITE_PINATA_GATEWAY` | `https://gateway.pinata.cloud` | | `VITE_IPFS_READ_GATEWAYS` | `https://ipfs.io,https://dweb.link,https://devnet-ipfs.api.polkadotcommunity.foundation,https://bulletin-kubo.tservices.es:9443` | -| Product executable `appVersion` | `[0, 1, 35]` in `web/polkadot-app-deploy.config.ts` | +| Product executable `appVersion` | `[0, 1, 36]` in `web/polkadot-app-deploy.config.ts` | The Product executable version is part of the published Product manifest. Bump it whenever the Product bundle changes runtime behavior, host SDK integration, permissions, metadata, or cache-sensitive assets. A new CID alone proves the bundle changed on-chain, but the mobile host can still use executable metadata when deciding whether to refresh a previously opened app. +Version `[0, 1, 36]` invalidates the Product host cache for finalized tip +read-back. It filters contribution logs by concrete indexed topics because the +DevNet EVM RPC rejects null topic placeholders; the matching intent must still +have a finalized on-chain event before Dotify reports success. Preserve any +pending contribution journal before clearing an older host cache. Version `[0, 1, 34]` invalidates Product host caches for the verified room-tip affordance introduced after `[0, 1, 33]`. Version `[0, 1, 33]` prepares the artist earnings/workspace, native contributions, and Product CDM named-result diff --git a/web/polkadot-app-deploy.config.ts b/web/polkadot-app-deploy.config.ts index dce790d9..508299ab 100644 --- a/web/polkadot-app-deploy.config.ts +++ b/web/polkadot-app-deploy.config.ts @@ -10,7 +10,7 @@ export default { { kind: 'app', path: './dist-product', - appVersion: [0, 1, 35] + appVersion: [0, 1, 36] } ] }; diff --git a/web/src/features/donations/contributions.reader.test.ts b/web/src/features/donations/contributions.reader.test.ts new file mode 100644 index 00000000..91b0c41e --- /dev/null +++ b/web/src/features/donations/contributions.reader.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { encodeAbiParameters, encodeEventTopics, zeroAddress, zeroHash, type Address, type Hash } from 'viem'; +import { musicRoyaltiesAbi } from '../../generated/contracts/musicRoyalties'; + +const rpc = vi.hoisted(() => ({ getBlock: vi.fn(), request: vi.fn() })); +vi.mock('../../shared/config/contracts', () => ({ getPublicClient: () => rpc })); + +import { contributionReader } from './contributions'; + +const runtime = '0x1000000000000000000000000000000000000000' as Address; +const sender = '0x2000000000000000000000000000000000000000' as Address; +const id = `0x${'11'.repeat(32)}` as Hash; +const hash = `0x${'22'.repeat(32)}` as Hash; +const contentHash = `0x${'33'.repeat(32)}` as Hash; +const blockHash = `0x${'44'.repeat(32)}` as Hash; + +function rpcLog(eventName: 'ContributionReceived' | 'ContributionShare', logIndex: string) { + return { + address: runtime, + blockHash, + blockNumber: '0x7b', + logIndex, + removed: false, + transactionHash: hash, + transactionIndex: '0x0', + topics: encodeEventTopics({ + abi: musicRoyaltiesAbi, + eventName, + args: eventName === 'ContributionReceived' ? { id, contentHash, sender } : { id, recipient: sender } + }), + data: + eventName === 'ContributionReceived' + ? encodeAbiParameters( + [{ type: 'uint256' }, { type: 'address' }, { type: 'bytes32' }, { type: 'bytes32' }, { type: 'bytes32' }, { type: 'uint64' }], + [3n, zeroAddress, zeroHash, zeroHash, zeroHash, 1700000000n] + ) + : encodeAbiParameters([{ type: 'uint256' }, { type: 'uint8' }, { type: 'bool' }], [3n, 0, true]) + }; +} + +describe('finalized contribution reader', () => { + beforeEach(() => { + rpc.getBlock.mockReset().mockResolvedValue({ number: 125n }); + rpc.request.mockReset(); + }); + + it('queries Product DevNet with only concrete indexed topics and decodes the receipt', async () => { + rpc.request.mockResolvedValueOnce([rpcLog('ContributionReceived', '0x0')]).mockResolvedValueOnce([rpcLog('ContributionShare', '0x1')]); + + const receipt = await contributionReader('unused').finalizedReceipt(runtime, id); + + expect(receipt).toMatchObject({ id, runtime, contentHash, sender, amount: 3n, transactionHash: hash, timestamp: 1700000000000 }); + expect(receipt?.shares).toEqual([{ recipient: sender, amount: 3n, role: 0, paid: true, claimed: false }]); + expect(rpc.request).toHaveBeenCalledTimes(2); + expect(rpc.request.mock.calls[0][0]).toMatchObject({ + method: 'eth_getLogs', + params: [{ address: runtime, topics: [expect.any(String), id], fromBlock: '0x0', toBlock: '0x7d' }] + }); + expect(rpc.request.mock.calls[1][0]).toMatchObject({ + method: 'eth_getLogs', + params: [{ address: runtime, topics: [expect.any(String), id], fromBlock: '0x7b', toBlock: '0x7b' }] + }); + }); + + it('does not invent a receipt when the finalized intent has no event', async () => { + rpc.request.mockResolvedValueOnce([]); + + await expect(contributionReader('unused').finalizedReceipt(runtime, id)).resolves.toBeUndefined(); + expect(rpc.request).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/features/donations/contributions.ts b/web/src/features/donations/contributions.ts index 8a2b9e46..104b1393 100644 --- a/web/src/features/donations/contributions.ts +++ b/web/src/features/donations/contributions.ts @@ -1,8 +1,11 @@ import { decodeEventLog, encodeAbiParameters, + encodeEventTopics, + formatLog, getAbiItem, keccak256, + toHex, zeroAddress, zeroHash, type Address, @@ -51,6 +54,12 @@ export type ContributionReceipt = { const contributionReceivedEvent = getAbiItem({ abi: musicRoyaltiesAbi, name: 'ContributionReceived' }); const contributionShareEvent = getAbiItem({ abi: musicRoyaltiesAbi, name: 'ContributionShare' }); +function contributionIdTopics(eventName: 'ContributionReceived' | 'ContributionShare', id: Hash): [Hash, Hash] { + const [eventTopic, idTopic] = encodeEventTopics({ abi: musicRoyaltiesAbi, eventName, args: { id } }); + if (typeof eventTopic !== 'string' || typeof idTopic !== 'string') throw new Error('Could not filter contribution logs by intent.'); + return [eventTopic, idTopic]; +} + export async function waitForFinalizedContribution( read: () => Promise, options: { attempts?: number; intervalMs?: number; wait?: (milliseconds: number) => Promise } = {} @@ -115,22 +124,31 @@ export function contributionReader(rpc: string) { }, async finalizedReceipt(runtime: Address, expectedId: Hash): Promise { const final = await client.getBlock({ blockTag: 'finalized' }); - const received = await client.getLogs({ - address: runtime, - event: contributionReceivedEvent, - args: { id: expectedId }, - fromBlock: 0n, - toBlock: final.number - }); + // Product DevNet rejects null topic placeholders emitted by getLogs({ args: { id } }). + const received = ( + await client.request({ + method: 'eth_getLogs', + params: [ + { address: runtime, topics: contributionIdTopics(contributionReceivedEvent.name, expectedId), fromBlock: '0x0', toBlock: toHex(final.number) } + ] + }) + ).map(log => formatLog(log)); const source = received[received.length - 1]; if (!source) return undefined; - const shares = await client.getLogs({ - address: runtime, - event: contributionShareEvent, - args: { id: expectedId }, - fromBlock: source.blockNumber, - toBlock: source.blockNumber - }); + if (source.blockNumber === null) throw new Error('The finalized contribution log has no block number.'); + const shares = ( + await client.request({ + method: 'eth_getLogs', + params: [ + { + address: runtime, + topics: contributionIdTopics(contributionShareEvent.name, expectedId), + fromBlock: toHex(source.blockNumber), + toBlock: toHex(source.blockNumber) + } + ] + }) + ).map(log => formatLog(log)); return decodeContributions(runtime, [...received, ...shares]).find(row => row.id === expectedId); }, async history(runtime: Address): Promise {