@simplepg/dservice
Version:
JavaScript implementation of the SimplePage backend service
477 lines (376 loc) • 18.8 kB
JavaScript
import { TestEnvironmentEvm } from '@simplepg/test-utils';
import { IndexerService } from '../../src/services/indexer.js';
import { namehash } from 'viem/ens';
import { jest } from '@jest/globals';
import { createPublicClient, http } from 'viem';
import { getBlockNumber } from 'viem/actions';
const TEST_DATA = [
{ name: 'test1.eth', cid: 'bafybeieffej45qo3hqi3eggqoqwgjihscmij42hmhqy3u7se7vzgi7h2zm', resolver: 'resolver1' },
{ name: 'test2.eth', cid: 'bafybeicijwrpp5exzlbqpyqcmkbcmnrqxdouyremgq3eod23qufugk5ina', resolver: 'resolver2' }
];
class MockIpfsService {
constructor() {
this.pages = new Map();
this.lists = new Map();
this.latestBlockNumber = 0;
this.stagedPins = new Map(); // Track staged pins with timestamps
}
async getList(name, dataType) {
const listName = `spg_list_${name}`;
return this.lists.get(listName) || [];
}
async addToList(name, dataType, value) {
const listName = `spg_list_${name}`;
const list = this.lists.get(listName) || [];
if (!list.includes(value)) {
list.push(value);
this.lists.set(listName, list);
}
}
async removeFromList(name, dataType, value) {
const listName = `spg_list_${name}`;
const list = this.lists.get(listName) || [];
const index = list.indexOf(value);
if (index > -1) {
list.splice(index, 1);
this.lists.set(listName, list);
}
}
async getLatestBlockNumber() {
return this.latestBlockNumber;
}
async setLatestBlockNumber(blockNumber) {
this.latestBlockNumber = blockNumber;
}
async isPageFinalized(cid, domain, blockNumber) {
const page = this.pages.get(domain);
return page?.pinned || false;
}
async listFinalizedPages() {
return Array.from(this.pages.keys());
}
async finalizePage(cid, domain, blockNumber) {
this.pages.set(domain, { cid, pinned: true });
}
async nukePage(domain) {
this.pages.delete(domain);
}
async writeCar(fileBuffer, stageDomain) {
const timestamp = Math.floor(Date.now() / 1000);
const label = `spg_staged_${stageDomain}_${timestamp}`;
this.stagedPins.set(label, { timestamp, domain: stageDomain });
return 'mock-cid';
}
async pruneStagedPins() {
const now = Math.floor(Date.now() / 1000);
const maxAge = 60 * 60; // 1 hour in seconds
for (const [label, data] of this.stagedPins.entries()) {
if (now - data.timestamp > maxAge) {
this.stagedPins.delete(label);
}
}
}
getStagedPins() {
return Array.from(this.stagedPins.entries());
}
}
describe('Pages Indexer', () => {
let testEnv;
let deployments;
let indexer;
let ipfsMock;
beforeAll(async () => {
testEnv = new TestEnvironmentEvm();
deployments = await testEnv.start();
});
beforeEach(async () => {
ipfsMock = new MockIpfsService();
// Mock the key methods we want to track
jest.spyOn(ipfsMock, 'isPageFinalized');
jest.spyOn(ipfsMock, 'finalizePage');
jest.spyOn(ipfsMock, 'nukePage');
// Create a mock logger
const mockLogger = {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
};
// Initialize indexer with the deployed addresses and mock logger
indexer = new IndexerService({
rpcUrl: testEnv.url,
simplePageAddress: deployments.simplepage,
universalResolver: deployments.universalResolver,
startBlock: 1,
ipfsService: ipfsMock,
logger: mockLogger
})
});
afterEach(async () => {
jest.clearAllMocks();
await indexer.stop();
});
afterAll(async () => {
await testEnv.stop();
});
it('should index new pages and their contenthash', async () => {
await indexer.start()
// mint a page
for (const { name, cid, resolver } of TEST_DATA) {
testEnv.setResolver(deployments.universalResolver, name, deployments[resolver])
testEnv.mintPage(name, 1000, '0x70997970C51812dc3A010C7d01b50e0d17dc79C8')
}
for (const { name, cid, resolver } of TEST_DATA) {
testEnv.setContenthash(deployments[resolver], name, cid)
}
await new Promise(resolve => setTimeout(resolve, 1000));
await indexer.stop()
// Verify domains list
const domains = await ipfsMock.getList('domains', 'string')
expect(domains.length).toBe(TEST_DATA.length)
for (const { name } of TEST_DATA) {
expect(domains.find(d => d === name)).toBe(name)
}
// Verify resolvers list
const resolvers = await ipfsMock.getList('resolvers', 'address')
expect(resolvers.length).toBe(2) // Should have both resolvers
expect(resolvers).toContain(deployments.resolver1)
expect(resolvers).toContain(deployments.resolver2)
// Verify contenthash lists for each domain
for (const { name, cid } of TEST_DATA) {
const contenthashUpdates = await ipfsMock.getList(`contenthash_${name}`, 'string')
expect(contenthashUpdates.length).toBe(1)
const [blockNumber, contenthash] = contenthashUpdates[0].split('-')
expect(contenthash).toBe(cid)
}
})
it('should prune old staged pins', async () => {
// Create staged pins with different timestamps
const now = Math.floor(Date.now() / 1000);
// Add a recent staged pin (30 minutes old)
const recentTimestamp = now - (30 * 60);
ipfsMock.stagedPins.set(`spg_staged_test1.eth_${recentTimestamp}`, {
timestamp: recentTimestamp,
domain: 'test1.eth'
});
// Add an old staged pin (2 hours old)
const oldTimestamp = now - (2 * 60 * 60);
ipfsMock.stagedPins.set(`spg_staged_test2.eth_${oldTimestamp}`, {
timestamp: oldTimestamp,
domain: 'test2.eth'
});
// Verify initial state
expect(ipfsMock.getStagedPins().length).toBe(2);
// Run pruning
await ipfsMock.pruneStagedPins();
// Verify only the old pin was pruned
const remainingPins = ipfsMock.getStagedPins();
expect(remainingPins.length).toBe(1);
expect(remainingPins[0][0]).toContain('test1.eth');
});
it('should handle multiple contenthash updates for a single name', async () => {
await indexer.start();
// Setup initial page
const domain = 'test3.eth';
const resolver = 'resolver1';
testEnv.setResolver(deployments.universalResolver, domain, deployments[resolver]);
testEnv.mintPage(domain, 1000, '0x70997970C51812dc3A010C7d01b50e0d17dc79C8');
// Set multiple contenthash updates
const updates = [
{ cid: 'bafybeieffej45qo3hqi3eggqoqwgjihscmij42hmhqy3u7se7vzgi7h2zm', blockNumber: 1000 },
{ cid: 'bafybeicijwrpp5exzlbqpyqcmkbcmnrqxdouyremgq3eod23qufugk5ina', blockNumber: 1001 },
{ cid: 'bafybeieffej45qo3hqi3eggqoqwgjihscmij42hmhqy3u7se7vzgi7h2zm', blockNumber: 1002 }
];
for (const update of updates) {
testEnv.setContenthash(deployments[resolver], domain, update.cid);
}
await new Promise(resolve => setTimeout(resolve, 1000));
await indexer.stop();
// Verify contenthash updates were tracked
const contenthashUpdates = await ipfsMock.getList(`contenthash_${domain}`, 'string');
expect(contenthashUpdates.length).toBe(updates.length);
// Verify the latest contenthash is the one that was finalized
const latestUpdate = contenthashUpdates[contenthashUpdates.length - 1];
const [blockNumber, cid] = latestUpdate.split('-');
expect(cid).toBe(updates[updates.length - 1].cid);
});
it('should handle empty contenthash updates', async () => {
await indexer.start();
// Setup page without contenthash
const domain = 'test4.eth';
const resolver = 'resolver1';
testEnv.setResolver(deployments.universalResolver, domain, deployments[resolver]);
testEnv.mintPage(domain, 1000, '0x70997970C51812dc3A010C7d01b50e0d17dc79C8');
await new Promise(resolve => setTimeout(resolve, 1000));
await indexer.stop();
// Verify domain was tracked
const domains = await ipfsMock.getList('domains', 'string');
expect(domains).toContain(domain);
// Verify no contenthash updates were tracked
const contenthashUpdates = await ipfsMock.getList(`contenthash_${domain}`, 'string');
expect(contenthashUpdates.length).toBe(0);
});
it.skip('should handle resolver changes', async () => {
// TODO: We don't support listening for resolver changes yet
await indexer.start();
// Setup initial page with first resolver
const domain = 'test5.eth';
const initialResolver = 'resolver1';
const newResolver = 'resolver2';
testEnv.setResolver(deployments.universalResolver, domain, deployments[initialResolver]);
testEnv.mintPage(domain, 1000, '0x70997970C51812dc3A010C7d01b50e0d17dc79C8');
// Set contenthash with initial resolver
const initialCid = 'bafybeieffej45qo3hqi3eggqoqwgjihscmij42hmhqy3u7se7vzgi7h2zm';
testEnv.setContenthash(deployments[initialResolver], domain, initialCid);
// Change resolver
testEnv.setResolver(deployments.universalResolver, domain, deployments[newResolver]);
// Set contenthash with new resolver
const newCid = 'bafybeicijwrpp5exzlbqpyqcmkbcmnrqxdouyremgq3eod23qufugk5ina';
testEnv.setContenthash(deployments[newResolver], domain, newCid);
await new Promise(resolve => setTimeout(resolve, 1000));
await indexer.stop();
// Verify both resolvers are tracked
const resolvers = await ipfsMock.getList('resolvers', 'address');
expect(resolvers).toContain(deployments[initialResolver]);
expect(resolvers).toContain(deployments[newResolver]);
// Verify contenthash updates from both resolvers
const contenthashUpdates = await ipfsMock.getList(`contenthash_${domain}`, 'string');
expect(contenthashUpdates.length).toBe(2);
expect(contenthashUpdates[1].split('-')[1]).toBe(newCid);
});
it('should not store ipfs content of pages in the block-list', async () => {
await indexer.start();
// Setup block-list with a domain
const blockedDomain = 'blocked.eth';
await ipfsMock.addToList('block', 'string', blockedDomain);
// Setup page that should be blocked
const resolver = 'resolver1';
testEnv.setResolver(deployments.universalResolver, blockedDomain, deployments[resolver]);
testEnv.mintPage(blockedDomain, 1000, '0x70997970C51812dc3A010C7d01b50e0d17dc79C8');
// Set contenthash for blocked domain
const blockedCid = 'bafybeieffej45qo3hqi3eggqoqwgjihscmij42hmhqy3u7se7vzgi7h2zm';
testEnv.setContenthash(deployments[resolver], blockedDomain, blockedCid);
await new Promise(resolve => setTimeout(resolve, 1000));
await indexer.stop();
// Verify that isPageFinalized and finalizePage were not called for the blocked domain
expect(ipfsMock.isPageFinalized).not.toHaveBeenCalledWith(expect.anything(), blockedDomain, expect.anything());
expect(ipfsMock.finalizePage).not.toHaveBeenCalledWith(expect.anything(), blockedDomain, expect.anything());
// Verify no staged pins were created for the blocked domain
const stagedPins = ipfsMock.getStagedPins();
const blockedPins = stagedPins.filter(([label]) => label.includes(blockedDomain));
expect(blockedPins.length).toBe(0);
});
it('should remove ipfs content of already pinned page if added to block-list', async () => {
await indexer.start();
// Setup a domain and pin it first
const domain = 'test6.eth';
const resolver = 'resolver1';
testEnv.setResolver(deployments.universalResolver, domain, deployments[resolver]);
testEnv.mintPage(domain, 1000, '0x70997970C51812dc3A010C7d01b50e0d17dc79C8');
const cid = 'bafybeieffej45qo3hqi3eggqoqwgjihscmij42hmhqy3u7se7vzgi7h2zm';
testEnv.setContenthash(deployments[resolver], domain, cid);
await new Promise(resolve => setTimeout(resolve, 1000));
// Verify domain was initially finalized
expect(ipfsMock.finalizePage).toHaveBeenCalledWith(expect.anything(), domain, expect.anything());
// Clear mock calls to track new calls after adding to block-list
jest.clearAllMocks();
// Add domain to block-list
await ipfsMock.addToList('block', 'string', domain);
// // Trigger another indexing cycle
// await indexer.stop();
// await indexer.start();
await new Promise(resolve => setTimeout(resolve, 1000));
await indexer.stop();
// Verify that nukePage was called for the domain
expect(ipfsMock.nukePage).toHaveBeenCalledWith(domain);
// Verify that isPageFinalized and finalizePage are not called for the blocked domain
expect(ipfsMock.isPageFinalized).not.toHaveBeenCalledWith(expect.anything(), domain, expect.anything());
expect(ipfsMock.finalizePage).not.toHaveBeenCalledWith(expect.anything(), domain, expect.anything());
});
it('should only store ipfs content of pages in allow-list when allow-list is not empty', async () => {
await indexer.start();
// Setup allow-list with specific domains
const allowedDomain1 = 'allowed1.eth';
const allowedDomain2 = 'allowed2.eth';
const notAllowedDomain = 'notallowed.eth';
await ipfsMock.addToList('allow', 'string', allowedDomain1);
await ipfsMock.addToList('allow', 'string', allowedDomain2);
// Setup pages for all domains
const resolver = 'resolver1';
const domains = [allowedDomain1, allowedDomain2, notAllowedDomain];
for (const domain of domains) {
testEnv.setResolver(deployments.universalResolver, domain, deployments[resolver]);
testEnv.mintPage(domain, 1000, '0x70997970C51812dc3A010C7d01b50e0d17dc79C8');
const cid = 'bafybeieffej45qo3hqi3eggqoqwgjihscmij42hmhqy3u7se7vzgi7h2zm';
testEnv.setContenthash(deployments[resolver], domain, cid);
}
await new Promise(resolve => setTimeout(resolve, 1000));
await indexer.stop();
// Verify that isPageFinalized and finalizePage were not called for the not allowed domain
expect(ipfsMock.isPageFinalized).not.toHaveBeenCalledWith(expect.anything(), notAllowedDomain, expect.anything());
expect(ipfsMock.finalizePage).not.toHaveBeenCalledWith(expect.anything(), notAllowedDomain, expect.anything());
// Verify that isPageFinalized and finalizePage were called for allowed domains
expect(ipfsMock.finalizePage).toHaveBeenCalledWith(expect.anything(), allowedDomain1, expect.anything());
expect(ipfsMock.finalizePage).toHaveBeenCalledWith(expect.anything(), allowedDomain2, expect.anything());
// verify that only allowed domains are finalized
const finalizedPages = await ipfsMock.listFinalizedPages();
expect(finalizedPages.length).toBe(2);
expect(finalizedPages).toContain(allowedDomain1);
expect(finalizedPages).toContain(allowedDomain2);
expect(finalizedPages).not.toContain(notAllowedDomain);
}, 10000);
it.skip('should index and finalize pages on allow-list even if not registered via mintPage', async () => {
// TODO: not implemented yet
await indexer.start();
// Setup allow-list with a domain that hasn't been minted
const allowedDomain = 'allowed-unminted.eth';
await ipfsMock.addToList('allow', 'string', allowedDomain);
// Setup resolver for the domain (but don't mint the page)
const resolver = 'resolver1';
testEnv.setResolver(deployments.universalResolver, allowedDomain, deployments[resolver]);
// Set contenthash for the domain
const cid = 'bafybeieffej45qo3hqi3eggqoqwgjihscmij42hmhqy3u7se7vzgi7h2zm';
testEnv.setContenthash(deployments[resolver], allowedDomain, cid);
await new Promise(resolve => setTimeout(resolve, 1000));
await indexer.stop();
// Verify that the domain was tracked in the domains list
const domains = await ipfsMock.getList('domains', 'string');
expect(domains).toContain(allowedDomain);
// Verify that the resolver was tracked
const resolvers = await ipfsMock.getList('resolvers', 'address');
expect(resolvers).toContain(deployments[resolver]);
// Verify that contenthash updates were tracked
const contenthashUpdates = await ipfsMock.getList(`contenthash_${allowedDomain}`, 'string');
expect(contenthashUpdates.length).toBe(1);
const [blockNumber, contenthash] = contenthashUpdates[0].split('-');
expect(contenthash).toBe(cid);
// Verify that the page was finalized despite not being minted
expect(ipfsMock.finalizePage).toHaveBeenCalledWith(expect.anything(), allowedDomain, expect.anything());
// Verify that the page appears in finalized pages list
const finalizedPages = await ipfsMock.listFinalizedPages();
expect(finalizedPages).toContain(allowedDomain);
}, 10000);
it('should skip blocks below the stored block number and persist the new highest block', async () => {
// Set a record for old.eth
testEnv.setResolver(deployments.universalResolver, 'old.eth', deployments.resolver1);
testEnv.mintPage('old.eth', 1000, '0x70997970C51812dc3A010C7d01b50e0d17dc79C8');
testEnv.setContenthash(deployments.resolver1, 'old.eth', 'bafybeieffej45qo3hqi3eggqoqwgjihscmij42hmhqy3u7se7vzgi7h2zm');
// Get the actual current block number from the chain
const client = createPublicClient({ transport: http(testEnv.url) });
const currentBlock = Number(await getBlockNumber(client));
// Mint and set record for new.eth at a higher block
testEnv.setResolver(deployments.universalResolver, 'new.eth', deployments.resolver1);
testEnv.mintPage('new.eth', currentBlock + 1, '0x70997970C51812dc3A010C7d01b50e0d17dc79C8');
testEnv.setContenthash(deployments.resolver1, 'new.eth', 'bafybeicijwrpp5exzlbqpyqcmkbcmnrqxdouyremgq3eod23qufugk5ina');
// Set the block number on the ipfs mock to the current block
await ipfsMock.setLatestBlockNumber(currentBlock);
await indexer.start();
await new Promise(resolve => setTimeout(resolve, 1000));
await indexer.stop();
// Should only finalize new.eth
const finalizedPages = await ipfsMock.listFinalizedPages();
console.log('finalizedPages', finalizedPages)
expect(finalizedPages).toContain('new.eth');
expect(finalizedPages).not.toContain('old.eth');
});
});