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
7 changes: 6 additions & 1 deletion docs/operations/deployment-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion web/polkadot-app-deploy.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default {
{
kind: 'app',
path: './dist-product',
appVersion: [0, 1, 35]
appVersion: [0, 1, 36]
}
]
};
71 changes: 71 additions & 0 deletions web/src/features/donations/contributions.reader.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
46 changes: 32 additions & 14 deletions web/src/features/donations/contributions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {
decodeEventLog,
encodeAbiParameters,
encodeEventTopics,
formatLog,
getAbiItem,
keccak256,
toHex,
zeroAddress,
zeroHash,
type Address,
Expand Down Expand Up @@ -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<ContributionReceipt | undefined>,
options: { attempts?: number; intervalMs?: number; wait?: (milliseconds: number) => Promise<void> } = {}
Expand Down Expand Up @@ -115,22 +124,31 @@ export function contributionReader(rpc: string) {
},
async finalizedReceipt(runtime: Address, expectedId: Hash): Promise<ContributionReceipt | undefined> {
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<ContributionReceipt[]> {
Expand Down
Loading