UNPKG

@bsv/overlay

Version:
1,320 lines (1,226 loc) 67.2 kB
// Note: References Engine from dist due to issues with compiled code references in Engine.ts import { Engine } from '../../dist/cjs/src/Engine.js' import { LookupService } from '../LookupService' import { TopicManager } from '../TopicManager' import { Storage } from '../storage/Storage' import { Transaction, Utils, TaggedBEEF, AdmittanceInstructions, STEAK } from '@bsv/sdk' import { Output } from '../Output' import { SyncConfiguration } from '../SyncConfiguration' import { Advertiser } from '../Advertiser' const mockChainTracker = { isValidRootForHeight: jest.fn(async () => true), currentHeight: jest.fn(async () => 800000) } // Call the method that would normally trigger syncAdvertisements const engineSubmit = jest.fn(async (taggedBEEF: TaggedBEEF, onSteakReady: any, mode?: string): Promise<STEAK> => { const result: STEAK = { tm_helloworld: { outputsToAdmit: [], coinsToRetain: [], coinsRemoved: [] } } return result }) const BRC62Hex = '0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000' const exampleTX = Transaction.fromHexBEEF(BRC62Hex) const exampleBeef = exampleTX.toBEEF() const exampleTXID = exampleTX.id('hex') const examplePreviousTXID = '3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac' let mockTopicManager: TopicManager, mockLookupService: LookupService, mockStorageEngine: Storage const mockOutput: Output = { txid: exampleTXID, outputIndex: 0, outputScript: exampleTX.outputs[0].lockingScript.toBinary(), topic: 'hello', satoshis: exampleTX.outputs[0].satoshis, beef: exampleBeef, spent: false, outputsConsumed: [], consumedBy: [], score: 1234567890 } const invalidHostingUrls = [ 'http://example.com', // Invalid: http 'https://localhost:3000', // Invalid: localhost 'https://192.168.1.1', // Invalid: internal private IP 'https://127.0.0.1', // Invalid: loopback IP 'https://0.0.0.0', // Invalid: non-routable IP 'http://172.16.0.1', // Invalid: private IP '[::1]' ] const validHostingUrls = [ 'https://example.com', // Valid: public URL 'https://8.8.8.8', // Valid: public routable IP 'https://255.255.255.255' // Valid: public routable IP ] const mockAdvertiser: Advertiser = { createAdvertisements: jest.fn(async (): Promise<TaggedBEEF> => ({ beef: [0, 1, 2], topics: [] })), revokeAdvertisements: jest.fn(async (): Promise<TaggedBEEF> => ({ beef: [0, 1, 2], topics: [] })), findAllAdvertisements: jest.fn(async () => []), parseAdvertisement: jest.fn() } describe('BSV Overlay Services Engine', () => { beforeEach(() => { jest.clearAllMocks() mockTopicManager = { identifyAdmissibleOutputs: jest.fn(async (): Promise<AdmittanceInstructions> => ({ outputsToAdmit: [0], coinsToRetain: [] })), getDocumentation: async () => 'Topical Documentation', getMetaData: async () => ({ name: 'Mock Manager', shortDescription: 'Mock Short Manager Description' }) } mockLookupService = { outputAdmittedByTopic: jest.fn(), outputSpent: jest.fn(), lookup: jest.fn(), outputNoLongerRetainedInHistory: jest.fn(), outputEvicted: jest.fn(), admissionMode: 'locking-script', spendNotificationMode: 'none', getDocumentation: async () => 'Service Documentation', getMetaData: async () => ({ name: 'Mock Service', shortDescription: 'Mock Short Service Description' }) } mockStorageEngine = { doesAppliedTransactionExist: jest.fn(async () => false), insertAppliedTransaction: jest.fn(), insertOutput: jest.fn(), findOutput: jest.fn(async () => null), findOutputsForTransaction: jest.fn(async () => []), markUTXOAsSpent: jest.fn(), updateConsumedBy: jest.fn(), updateTransactionBEEF: jest.fn(), deleteOutput: jest.fn(), findUTXOsForTopic: jest.fn(), updateLastInteraction: jest.fn(), getLastInteraction: jest.fn(async () => 0) } }) it('engine.syncAdvertisements should return void when invalid hostingURL is provided', async () => { for (const url of invalidHostingUrls) { const engine = new Engine( { tm_helloworld: mockTopicManager }, { ls_helloworld: mockLookupService }, mockStorageEngine, mockChainTracker, url, // Invalid hostingURL ['tracker1'], // shipTrackers ['tracker2'], // slapTrackers undefined, mockAdvertiser, undefined ) engine.submit = engineSubmit // Call the method that would normally trigger syncAdvertisements const result = await engine.syncAdvertisements() expect(result).toBeUndefined() // Verify that Advertiser methods are NOT called expect(mockAdvertiser.createAdvertisements).not.toHaveBeenCalled() expect(mockAdvertiser.findAllAdvertisements).not.toHaveBeenCalledWith('SHIP') // Assuming 'SHIP' is expected } }) it('should allow engine.syncAdvertisements to proceed with valid hostingURLs', async () => { const mockAdvertiser: Advertiser = { createAdvertisements: jest.fn().mockResolvedValue({ tag: 'MOCK_TAG', data: Buffer.from('mock data') }), findAllAdvertisements: jest.fn().mockResolvedValue([]), revokeAdvertisements: jest.fn().mockResolvedValue({ tag: 'MOCK_REVOKE_TAG', data: Buffer.from('revocation data') }), parseAdvertisement: jest.fn().mockReturnValue({ protocol: 'SHIP', topicOrServiceName: 'mock-topic', timestamp: Date.now() }) } for (const url of validHostingUrls) { const engine = new Engine( { tm_helloworld: mockTopicManager }, { ls_helloworld: mockLookupService }, mockStorageEngine, mockChainTracker, url, // Valid hostingURL ['tracker1'], // shipTrackers ['tracker2'], // slapTrackers undefined, mockAdvertiser, // Pass the mock Advertiser undefined ) engine.submit = engineSubmit await engine.syncAdvertisements() expect(engine.submit).toHaveBeenCalled() // Verify that Advertiser methods are called expect(mockAdvertiser.createAdvertisements).toHaveBeenCalled() expect(mockAdvertiser.findAllAdvertisements).toHaveBeenCalledWith('SHIP') // Assuming 'SHIP' is expected } }) it('refreshes old unproven transaction proofs before eviction', async () => { const storage = { ...mockStorageEngine, findUnprovenAppliedTransactions: jest.fn(async () => [ { txid: exampleTXID, topic: 'Hello', firstSeenHeight: 799000, outputs: [{ txid: exampleTXID, outputIndex: 0 }] } ]) } const engine = new Engine( { tm_helloworld: mockTopicManager }, { ls_helloworld: mockLookupService }, storage, mockChainTracker, 'https://example.com' ) const merklePath = {} as any ;(engine as any).handleNewMerkleProof = jest.fn(async () => undefined) const report = await engine.refreshUnprovenTransactionProofs({ thresholdBlocks: 144, proofProvider: jest.fn(async () => ({ merklePath, blockHeight: 799900 })) }) expect(report.refreshedTransactions).toBe(1) expect(report.missingProofs).toBe(0) expect((engine as any).handleNewMerkleProof).toHaveBeenCalledWith(exampleTXID, merklePath, 799900) }) it('evicts provider-invalidated applied transactions', async () => { const deleteAppliedTransaction = jest.fn(async () => undefined) const deleteOutput = jest.fn(async () => undefined) const engine = new Engine( { tm_helloworld: mockTopicManager }, { ls_helloworld: mockLookupService }, { ...mockStorageEngine, findOutputsForTransaction: jest.fn(async () => [ { ...mockOutput, topic: 'Hello' } ]), deleteOutput, deleteAppliedTransaction }, mockChainTracker, 'https://example.com' ) const report = await engine.evictAppliedTransaction(exampleTXID, { reason: 'DOUBLE_SPEND_ATTEMPTED' }) expect(report.evictedTransactions).toBe(1) expect(report.evictedOutputs).toBe(1) expect(mockLookupService.outputEvicted).toHaveBeenCalledWith(exampleTXID, 0) expect(deleteOutput).toHaveBeenCalledWith(exampleTXID, 0, 'Hello') expect(deleteAppliedTransaction).toHaveBeenCalledWith(exampleTXID, 'Hello') }) it('Uses SHIP sync configuration by default if no syncConfiguration was provided', () => { const engine = new Engine( { tm_helloworld: mockTopicManager }, { ls_helloworld: mockLookupService }, mockStorageEngine, mockChainTracker, undefined, // hostingURL ['tracker1'], // shipTrackers ['tracker2'], // slapTrackers undefined, undefined, undefined ) expect(engine.syncConfiguration).toEqual({ tm_helloworld: 'SHIP' }) }) it('Does not set sync method to "SHIP" for topic managers set to false in the syncConfiguration', () => { const syncConfiguration: SyncConfiguration = { tm_helloworld: false } const engine = new Engine( { tm_helloworld: mockTopicManager }, { ls_helloworld: mockLookupService }, mockStorageEngine, mockChainTracker, undefined, // hostingURL ['tracker1'], // shipTrackers ['tracker2'], // slapTrackers undefined, undefined, syncConfiguration ) expect(engine.syncConfiguration).toEqual({ tm_helloworld: false }) }) it('Combines existing trackers with provided shipTrackers and slapTrackers, ensuring no duplicates', () => { const syncConfiguration: SyncConfiguration = { tm_ship: ['existingTracker1'], tm_slap: ['existingTracker2'] } const engine = new Engine( { tm_ship: mockTopicManager, tm_slap: mockTopicManager }, { ls_ship: mockLookupService, ls_slap: mockLookupService }, mockStorageEngine, mockChainTracker, undefined, // hostingURL ['tracker1', 'existingTracker1'], // shipTrackers ['tracker2', 'existingTracker2'], // slapTrackers undefined, undefined, syncConfiguration ) expect(engine.syncConfiguration).toEqual({ tm_ship: ['existingTracker1', 'tracker1'], tm_slap: ['existingTracker2', 'tracker2'] }) }) it('Sets undefined topic managers in syncConfiguration to sync method of "SHIP" by default', () => { const syncConfiguration: SyncConfiguration = { tm_helloworld: 'SHIP' } const engine = new Engine( { tm_helloworld: mockTopicManager, tm_ship: mockTopicManager, tm_slap: mockTopicManager }, { ls_helloworld: mockLookupService, ls_ship: mockLookupService, ls_slap: mockLookupService }, mockStorageEngine, mockChainTracker, undefined, // hostingURL ['tracker1'], // shipTrackers ['tracker2'], // slapTrackers undefined, undefined, syncConfiguration ) expect(engine.syncConfiguration).toEqual({ tm_helloworld: 'SHIP', tm_ship: ['tracker1'], tm_slap: ['tracker2'] }) }) describe('handleNewMerkleProof tests', () => { it('0 simple proof', async () => { const beef = beef27c8f1 const txid = txid27c8f const tx = Transaction.fromHexBEEF(beef) const output27c8fZero: Output = { txid, outputIndex: 0, outputScript: tx.outputs[0].lockingScript.toBinary(), topic: 'hello', satoshis: tx.outputs[0].satoshis, beef: tx.toBEEF(), spent: false, outputsConsumed: [], consumedBy: [], score: Date.now() } mockLookupService.lookup = jest.fn(async () => [{ txid, outputIndex: 0, history: 1 }]) let newBEEF: number[] = [] mockStorageEngine.findOutput = jest.fn(async () => output27c8fZero) mockStorageEngine.findOutputsForTransaction = jest.fn(async () => [output27c8fZero]) mockStorageEngine.updateTransactionBEEF = jest.fn(async (txid: string, beef: number[]) => { newBEEF = beef }) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) const merklePath = Transaction.fromHexBEEF(beef27c8f0).merklePath if (merklePath == null) throw new Error('improper test setup') await engine.handleNewMerkleProof(txid, merklePath) expect(newBEEF.length > 0 && newBEEF.length < beef.length / 2).toBe(true) }) it('1 recurse proof', async () => { const outputs: Output[] = [] const findOutput = (txid: string, outputIndex: number, includeBEEF?: boolean): Output => { const i = outputs.findIndex(o => o.txid === txid && o.outputIndex === outputIndex) if (i < 0) throw new Error(`missing output ${txid} ${outputIndex}`) return outputs[i] } const addConsumingOutput = (tx: Transaction, outputIndex: number, consumes?: Output): Output => { const o: Output = { txid: tx.id('hex'), outputIndex, outputScript: tx.outputs[outputIndex].lockingScript.toBinary(), topic: 'hello', satoshis: tx.outputs[outputIndex].satoshis, beef: tx.toBEEF(), spent: false, outputsConsumed: [], consumedBy: [], score: Date.now() } if (consumes != null) { const c = findOutput(consumes.txid, consumes.outputIndex) c.consumedBy.push(o) o.outputsConsumed.push(c) } outputs.push(o) return o } mockLookupService.lookup = jest.fn(async () => [{ txid: txid17d182, outputIndex: 0, history: 1 }]) const newBEEF: Record<string, string> = {} mockStorageEngine.findOutput = jest.fn(async (txid: string, outputIndex: number, topic?: string, spent?: boolean, includeBEEF?: boolean) => { return findOutput(txid, outputIndex, true) }) mockStorageEngine.findOutputsForTransaction = jest.fn(async (txid: string, includeBEEF?: boolean) => { const os = outputs.filter(o => o.txid === txid) return os }) mockStorageEngine.updateTransactionBEEF = jest.fn(async (txid: string, beef: number[]) => { newBEEF[txid] = Utils.toHex(beef) }) const engine = new Engine({ Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker) /* txid 17d1829ba8424b97369ee8b528ee8b65d9d4b9c08d037d224a7be7c025f78f78 output 0 1196 sats txid 9426201003b01e5bb66e9240a1cc337174238e816a25a06ba532fa67af4457d7 output 0 1197 sats txid 509f5ef79c18504fdfe21e67608f56bacb8d8a200634e46f60a9dc8430dcd6e9 output 0 1198 sats txid 877734db8eb917d0e2174a4bdddc085b34f67cedd6b94628c4feb1b979a2b2c5 output 0 1199 sats txid 37abad7168d47d4e107d0f9f96813a4feef6ea346482fce554c3fade85d45409 output 0 1200 sats txid d786db393ec7f1315bee2cbd3aa47634394f57c861feed8c075563308f14c237 output 0 1568 sats txid a7d4cac391f5b3d8dedaacb3cd8b5afac54df2d8e043ae0452abcfe190686494 output 17 1132 sats */ const tx17d182 = Transaction.fromHexBEEF(beef17d1824) const tx942620 = tx17d182.inputs[0].sourceTransaction const tx509f5e = tx942620.inputs[0].sourceTransaction const tx877734 = tx509f5e.inputs[0].sourceTransaction const tx37abad = tx877734.inputs[0].sourceTransaction const output37abadZero = addConsumingOutput(tx37abad, 0) const output877734Zero = addConsumingOutput(tx877734, 0, output37abadZero) const output509f5eZero = addConsumingOutput(tx509f5e, 0, output877734Zero) const output942620Zero = addConsumingOutput(tx942620, 0, output509f5eZero) addConsumingOutput(tx17d182, 0, output942620Zero) const mp37abad = Transaction.fromHexBEEF(beef37abad0).merklePath await engine.handleNewMerkleProof(txid37abad, mp37abad) expect(Object.keys(newBEEF).length).toBe(0) const mp877734 = Transaction.fromHexBEEF(beef8777340).merklePath await engine.handleNewMerkleProof(txid877734, mp877734) expect(newBEEF[`${txid877734}`]).toBe(beef8777340) expect(newBEEF[`${txid509f5e}`].length).toBeGreaterThan(beef509f5e0.length) expect(newBEEF[`${txid942620}`].length).toBeGreaterThan(beef9426200.length) expect(newBEEF[`${txid17d182}`].length).toBeGreaterThan(beef17d1820.length) expect(Object.keys(newBEEF).length).toBe(4) const mp509f5e = Transaction.fromHexBEEF(beef509f5e0).merklePath await engine.handleNewMerkleProof(txid509f5e, mp509f5e) expect(newBEEF[`${txid877734}`]).toBe(beef8777340) expect(newBEEF[`${txid509f5e}`]).toBe(beef509f5e0) expect(newBEEF[`${txid942620}`].length).toBeGreaterThan(beef9426200.length) expect(newBEEF[`${txid17d182}`].length).toBeGreaterThan(beef17d1820.length) expect(Object.keys(newBEEF).length).toBe(4) const mp942620 = Transaction.fromHexBEEF(beef9426200).merklePath await engine.handleNewMerkleProof(txid942620, mp942620) expect(newBEEF[`${txid877734}`]).toBe(beef8777340) expect(newBEEF[`${txid509f5e}`]).toBe(beef509f5e0) expect(newBEEF[`${txid942620}`]).toBe(beef9426200) expect(newBEEF[`${txid17d182}`].length).toBeGreaterThan(beef17d1820.length) expect(Object.keys(newBEEF).length).toBe(4) const mp17d182 = Transaction.fromHexBEEF(beef17d1820).merklePath await engine.handleNewMerkleProof(txid17d182, mp17d182) expect(newBEEF[`${txid877734}`]).toBe(beef8777340) expect(newBEEF[`${txid509f5e}`]).toBe(beef509f5e0) expect(newBEEF[`${txid942620}`]).toBe(beef9426200) expect(newBEEF[`${txid17d182}`]).toBe(beef17d1820) expect(Object.keys(newBEEF).length).toBe(4) }) }) describe('submit', () => { it('Throws an error if the user submits to a topic that is not supported', async () => { const engine = new Engine( { Hello: mockTopicManager }, {}, mockStorageEngine, mockChainTracker ) await expect(engine.submit({ beef: exampleBeef, topics: ['hello'] })).rejects.toHaveProperty('message', 'This server does not support this topic: hello') }) it('Verifies the BEEF for the provided transaction', async () => { const engine = new Engine( { Hello: mockTopicManager }, {}, mockStorageEngine, mockChainTracker ) await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) expect(mockChainTracker.isValidRootForHeight).toHaveBeenCalledWith('bb6f640cc4ee56bf38eb5a1969ac0c16caa2d3d202b22bf3735d10eec0ca6e00', 814435) }) it('Throws an error if an invalid envelope is provided', async () => { const engine = new Engine( { Hello: mockTopicManager }, {}, mockStorageEngine, mockChainTracker ) mockChainTracker.isValidRootForHeight.mockReturnValueOnce(Promise.resolve(false)) await expect(engine.submit({ beef: exampleBeef, topics: ['Hello'] })).rejects.toHaveProperty('message', 'Invalid merkle path for transaction 3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac') }) describe('Broadcast gating (PHASE 2)', () => { const makeBroadcaster = (): { broadcast: jest.Mock } => ({ broadcast: jest.fn(async () => ({ status: 'success', txid: exampleTXID, message: 'ok' })) }) const makeEngine = (broadcaster: any): Engine => new Engine( { Hello: mockTopicManager }, {}, mockStorageEngine, mockChainTracker, undefined, undefined, undefined, broadcaster ) it('broadcasts when at least one topic admits outputs', async () => { const broadcaster = makeBroadcaster() const engine = makeEngine(broadcaster) await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) expect(broadcaster.broadcast).toHaveBeenCalledTimes(1) }) it('never broadcasts a transaction every topic manager rejected', async () => { mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => ({ outputsToAdmit: [], coinsToRetain: [] })) const broadcaster = makeBroadcaster() const engine = makeEngine(broadcaster) await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) expect(broadcaster.broadcast).not.toHaveBeenCalled() }) it('never broadcasts when topic validation throws, even with tracked coins consumed', async () => { // The rejected tx spends previously-admitted coins — a throw must // still gate the broadcast (this is the rejected-transfer shape). mockStorageEngine.findOutput = jest.fn(async () => mockOutput) mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => { throw new Error('rule violation') }) const broadcaster = makeBroadcaster() const engine = makeEngine(broadcaster) await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) expect(broadcaster.broadcast).not.toHaveBeenCalled() }) it('broadcasts a consume-only transaction that retains nothing (history purge)', async () => { // e.g. a KVStore remove that also purges history: previously-admitted // coins are consumed, nothing admitted, nothing retained. That is an // acceptance, not a rejection (rejection = throw). mockStorageEngine.findOutput = jest.fn(async () => mockOutput) mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => ({ outputsToAdmit: [], coinsToRetain: [] })) const broadcaster = makeBroadcaster() const engine = makeEngine(broadcaster) await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) expect(broadcaster.broadcast).toHaveBeenCalledTimes(1) }) it('still broadcasts a duplicate (previously accepted) transaction', async () => { mockStorageEngine.doesAppliedTransactionExist = jest.fn(async () => true) const broadcaster = makeBroadcaster() const engine = makeEngine(broadcaster) await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) expect(broadcaster.broadcast).toHaveBeenCalledTimes(1) }) it('broadcasts when outputs are consumed but none admitted (coinsToRetain only)', async () => { mockStorageEngine.findOutput = jest.fn(async () => mockOutput) mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => ({ outputsToAdmit: [], coinsToRetain: [0] })) const broadcaster = makeBroadcaster() const engine = makeEngine(broadcaster) await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) expect(broadcaster.broadcast).toHaveBeenCalledTimes(1) }) }) describe('For each topic being processed', () => { it('Checks for duplicate transactions', async () => { const engine = new Engine( { Hello: mockTopicManager }, {}, mockStorageEngine, mockChainTracker ) await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) expect(mockStorageEngine.doesAppliedTransactionExist).toHaveBeenCalledWith({ txid: exampleTXID, topic: 'Hello' }) }) it('Does not process the output if the transaction was already applied to the topic', async () => { mockStorageEngine.doesAppliedTransactionExist = jest.fn(async () => true) const engine = new Engine( { Hello: mockTopicManager }, {}, mockStorageEngine, mockChainTracker ) await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) expect(mockStorageEngine.findOutput).not.toHaveBeenCalled() expect(mockStorageEngine.markUTXOAsSpent).not.toHaveBeenCalled() expect(mockStorageEngine.insertOutput).not.toHaveBeenCalled() }) describe('For each input of the transaction', () => { it('Acquires the appropriate previous topical UTXOs from the storage engine', async () => { // Mock findUTXO to return a UTXO mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { Hello: mockTopicManager }, {}, mockStorageEngine, mockChainTracker ) // Submit the utxo await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) expect(mockStorageEngine.findOutput).toHaveBeenCalled() }) it('Includes the appropriate previous topical UTXOs when they are returned from the storage engine', async () => { // Mock findUTXO to return a UTXO mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { Hello: mockTopicManager }, {}, mockStorageEngine, mockChainTracker ) // Submit the utxo await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) expect(mockStorageEngine.findOutput).toHaveBeenCalled() expect(mockStorageEngine.markUTXOAsSpent).toHaveBeenCalledWith(exampleTXID, 0, 'Hello') }) }) it('Identifies admissible outputs with the appropriate topic manager', async () => { // Mock findUTXO to return a UTXO mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { Hello: mockTopicManager }, {}, mockStorageEngine, mockChainTracker ) await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) expect(engine.managers.Hello.identifyAdmissibleOutputs).toHaveBeenCalledWith( exampleBeef, [0], undefined, 'current-tx' ) }) describe('When previous UTXOs were retained by the topic manager', () => { it('Notifies all lookup services about the output being spent (not deleted, see the comment about this in deleteUTXODeep)', async () => { // Mock findUTXO to return a UTXO mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Submit the utxo await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) expect(engine.lookupServices.Hello.outputSpent).toHaveBeenCalledWith({ mode: 'none', txid: exampleTXID, outputIndex: 0, topic: 'Hello' }) }) }) describe('When previous UTXOs were not retained by the topic manager', () => { it('Marks the UTXO as stale, deleting all stale UTXOs by calling deleteUTXODeep', async () => { // Mock findUTXO to return a UTXO mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Submit the utxo await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) // Test that previous UTXOs are deleted expect(mockStorageEngine.deleteOutput).toHaveBeenCalledWith(exampleTXID, 0, 'hello') expect(mockLookupService.outputNoLongerRetainedInHistory).toHaveBeenCalledWith(exampleTXID, 0, 'hello') }) it('Notifies all lookup services about the output being spent (the notification about the actual deletion will come from deleteUTXODeep)', async () => { // Mock findUTXO to return a UTXO mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Submit the utxo await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) // Was the lookup service notified of the output deletion? expect(mockLookupService.outputNoLongerRetainedInHistory).toHaveBeenCalledWith(exampleTXID, 0, 'hello') }) }) it('Adds admissible UTXOs to the storage engine', async () => { // Mock findUTXO to return a UTXO mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Submit the utxo await engine.submit({ beef: exampleBeef, topics: ['hello'] }) // Test the new UTXO was added expect(mockStorageEngine.insertOutput).toHaveBeenCalledWith(expect.objectContaining({ ...mockOutput, score: expect.any(Number) })) }) it('Notifies lookup services about incoming admissible UTXOs', async () => { // Mock findUTXO to return a UTXO mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Submit the utxo await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) // Test the lookup service was notified of the new UTXO expect(mockLookupService.outputAdmittedByTopic).toHaveBeenCalledWith({ mode: 'locking-script', txid: exampleTXID, satoshis: 26172, outputIndex: 0, lockingScript: exampleTX.outputs[0].lockingScript, topic: 'Hello' }) }) describe('For each consumed UTXO', () => { it('Finds the UTXO', async () => { // Mock findUTXO to return a UTXO mockStorageEngine.findOutput = jest.fn(async () => mockOutput) mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => { return { outputsToAdmit: [0], coinsToRetain: [0] } }) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Submit the utxo await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) // Test a storage engine lookup happens for the utxo consumed expect(mockStorageEngine.findOutput).toHaveBeenCalledWith(examplePreviousTXID, 0, 'Hello') }) it('Updates the UTXO to reflect that it is now additionally consumed by the newly-created UTXOs', async () => { // Mock findUTXO to return a UTXO mockStorageEngine.findOutput = jest.fn(async () => mockOutput) mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => { return { outputsToAdmit: [0], coinsToRetain: [0] } }) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Submit the utxo await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) // Test the consumedBy data is updated expect(mockStorageEngine.updateConsumedBy).toHaveBeenCalledWith(examplePreviousTXID, 0, 'Hello', [{ txid: exampleTXID, outputIndex: 0 }]) }) }) it('Inserts a new applied transaction to avoid de-duplication', async () => { // Mock findUTXO to return a UTXO mockStorageEngine.findOutput = jest.fn(async () => mockOutput) mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => { return { outputsToAdmit: [0], coinsToRetain: [0] } }) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Submit the utxo await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) // Test the tx is inserted expect(mockStorageEngine.insertAppliedTransaction).toHaveBeenCalledWith(expect.objectContaining({ txid: exampleTXID, topic: 'Hello' })) }) it('Returns a correct set of admitted topics and outputs', async () => { // Mock findUTXO to return a UTXO mockStorageEngine.insertOutput = jest.fn() mockStorageEngine.findOutput = jest.fn(async () => mockOutput) mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => { return { outputsToAdmit: [0], coinsToRetain: [0] } }) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Submit the utxo const results = await engine.submit({ beef: exampleBeef, topics: ['Hello'] }) // Test the correct outputs are admitted expect(results).toEqual({ Hello: { outputsToAdmit: [0], coinsToRetain: [0], coinsRemoved: [] } }) }) }) describe('lookup', () => { it('Throws an error if no lookup service has this provider name', async () => { const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Perform a lookup request await expect(engine.lookup({ service: 'HelloWorld', query: { name: 'Bob' } })).rejects.toThrow() }) it('Calls the lookup function from the lookup service', async () => { // TODO: Make the default storage engine return something...? mockLookupService.lookup = jest.fn(async () => [{ txid: exampleTXID, outputIndex: 0 }]) mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Perform a lookup request await engine.lookup({ service: 'Hello', query: { name: 'Bob' } }) expect(mockLookupService.lookup).toHaveBeenCalledWith({ service: 'Hello', query: { name: 'Bob' } }) }) it('Uses batched output loading when the storage engine supports it', async () => { mockLookupService.lookup = jest.fn(async () => [{ txid: 'mockTXID', outputIndex: 0, history: undefined }]) mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const findOutputsByOutpoints = jest.fn(async () => [mockOutput]) ;(mockStorageEngine).findOutputsByOutpoints = findOutputsByOutpoints const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) await engine.lookup({ service: 'Hello', query: { name: 'Bob' } }) expect(findOutputsByOutpoints).toHaveBeenCalledWith([{ txid: 'mockTXID', outputIndex: 0 }], true) expect(mockStorageEngine.findOutput).not.toHaveBeenCalled() }) describe('For each returned result', () => { it('Finds the identified UTXO by its txid and vout', async () => { // TODO: Make the default storage engine return something...? mockLookupService.lookup = jest.fn(async () => [{ txid: 'mockTXID', outputIndex: 0, history: undefined }]) mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Perform a lookup request await engine.lookup({ service: 'Hello', query: { name: 'Bob' } }) expect(mockStorageEngine.findOutput).toHaveBeenCalledWith( 'mockTXID', 0, undefined, undefined, true ) }) it('Calls getUTXOHistory with the correct UTXO and history parameters', async () => { mockLookupService.lookup = jest.fn(async () => [{ txid: 'mockTXID', outputIndex: 0, history: undefined }]) mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) engine.getUTXOHistory = jest.fn(async () => { return mockOutput }) // Perform a lookup request await engine.lookup({ service: 'Hello', query: { name: 'Bob' } }) expect(engine.getUTXOHistory).toHaveBeenCalledWith( mockOutput, undefined, 0, expect.anything() ) }) }) it('Returns the correct set of hydrated results', async () => { mockLookupService.lookup = jest.fn(async () => [{ txid: 'mockTXID', outputIndex: 0, history: undefined }]) mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) engine.getUTXOHistory = jest.fn(async () => { return mockOutput }) // Perform a lookup request const results = await engine.lookup({ service: 'Hello', query: { name: 'Bob' } }) expect(results).toEqual({ outputs: [{ beef: mockOutput.beef, outputIndex: mockOutput.outputIndex }], type: 'output-list' }) }) }) describe('getUTXOHistory', () => { it('Returns the given output if there is no history selector', async () => { // Already tested above return true }) it('Invokes the history selector function with the correct data', async () => { const mockedHistorySelector = jest.fn(async (beef, outputIndex, currentDepth) => { if (currentDepth !== 2) { return true } return false }) mockLookupService.lookup = jest.fn(async () => [{ txid: 'mockTXID', outputIndex: 0, history: mockedHistorySelector }]) mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Perform a lookup request await engine.lookup({ service: 'Hello', query: { name: 'Bob' } }) expect(mockedHistorySelector).toHaveBeenCalledWith( mockOutput.beef, mockOutput.outputIndex, 0 ) expect(mockedHistorySelector).toHaveBeenCalled() }) it('Returns undefined if history should not be traversed', async () => { const mockedHistorySelector = jest.fn(async (beef, outputIndex, currentDepth) => { return false }) mockLookupService.lookup = jest.fn(async () => [{ txid: 'mockTXID', outputIndex: 0, history: mockedHistorySelector }]) mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Perform a lookup request const results = await engine.lookup({ service: 'Hello', query: { name: 'Bob' } }) expect(mockedHistorySelector).toHaveBeenCalledWith( mockOutput.beef, mockOutput.outputIndex, 0 ) expect(results).toEqual({ outputs: [], type: 'output-list' }) }) it('Returns undefined if the history selector is a number, and less than the current depth', async () => { mockLookupService.lookup = jest.fn(async () => [{ txid: 'mockTXID', outputIndex: 0, history: -1 }]) mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Perform a lookup request const results = await engine.lookup({ service: 'Hello', query: { name: 'Bob' } }) expect(results).toEqual({ outputs: [], type: 'output-list' }) }) it('Returns the current output even if history should be traversed, if the current output is part of a transaction that does not consume any previous topical UTXOs', async () => { mockLookupService.lookup = jest.fn(async () => [{ txid: 'mockTXID', outputIndex: 0, history: 1 }]) mockStorageEngine.findOutput = jest.fn(async () => mockOutput) const engine = new Engine( { Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker ) // Perform a lookup request const results = await engine.lookup({ service: 'Hello', query: { name: 'Bob' } }) expect(results).toEqual({ outputs: [{ beef: mockOutput.beef, outputIndex: mockOutput.outputIndex }], type: 'output-list' }) }) // it('Traversing history, calls findOutput with any output consumed by this UTXO', async () => { // mockLookupService.lookup = jest.fn(async () => [{ // txid: 'mockTXID', // outputIndex: 0, // history: 1 // }]) // mockStorageEngine.findOutput = jest.fn(async () => ({ // ...mockOutput, // outputsConsumed: [{ // txid: examplePreviousTXID, // outputIndex: 0 // }] // })) // const engine = new Engine( // { // Hello: mockTopicManager // }, // { // Hello: mockLookupService // }, // mockStorageEngine, // mockChainTracker // ) // // Perform a lookup request // await engine.lookup({ // service: 'Hello', // query: { name: 'Bob' } // }) // expect(mockStorageEngine.findOutput).toHaveBeenCalledWith() // }) // it('Returns the correct envelope based on the history traversal process', async () => { // // TODO: Come up with some test data for a simple case, but different than the above code. // }) // it('Returns the correct envelope based on the history traversal process for a more complex multi-layer multi-output graph', async () => { // // TODO: Come up with some test data to test the history traversal process better // }) }) // describe('deleteUTXODeep', () => { // it('Finds UTXO by ID if no output was provided', async () => { // }) // it('Finds UTXO by TXID and VOUT if no ID was provided', async () => { // }) // it('Throws an error if no output can be found', async () => { // }) // describe('When no more UTXOs are consumed by this UTXO', () => { // it('Deletes the UTXO by ID from the storage engine', async () => { // }) // it('Notifies all lookup services about the deletion of the UTXO', async () => { // }) // }) // it('For each UTXO that this UTXO consumes, finds the consumed UTXO by its ID and removes reference to this one', async () => { // }) }) }) // beef27c8f_1 is 1 level deep, i.e. proofs on inputs const beef27c8f1 = '0100beef01feafcf0c000802020044ef36fc6c79af360b13f2164c6e772e6e5e2083a54d72894f34710fe6a68cf80302792c8c563b17721744d8f101d79e873aae62816d0fd47dd9d90ada219c1b252d010000059e83aae2cfc6a35477566439dd9d58969cc41a134864c671c9b844f2a409a60101004991c129bba819832d6dd315c3a2065bd31b1a60aff8192be335f83731641e690101002f87724a7387df82417902266d2a4fb2fee857451e963a352ce8acbb279aa9eb010100a9e9eb55d20cef840bacbdb26ad8d6e7680ef7653364830b1e770fa140ece6bb010100f0ecd03fff2df21b0034d331cade50152781be9052dcec54eb95c963ef1cdcbf0101006fdb2c01a266e761a32f53b3ad0fabbca31ae234d31d85d9af9bbd624b321c9b0101008108cad77aabf7b0ee371f42afd773cada534244535f7108c7590df2b9d5f8a2020100000006e9847b33c5d666ccee1dccd52235489d8d58bf432be1b8c0cc7c13af80f10703030000006b483045022100fe8a18e043d693246a56c9b32ff553bea89a0bbe389bc55e8244044dcd865de3022065245c30fbbcc6820e4761048ed28c587bf41ece1933dfb85b8fa8c3433d93504121022f2ac22e73a55aa4ff21e905f66959200642ae74716544f834a48af5aa53fb27ffffffff7e8823d7e3e5565fdb9e53c015ad5b63bc6dd6ee1a9be7f7a8e105c903783d45050000006b483045022100e6332a6459350ff2486a39b6d79cdc0b65b6d52cf898fc457e45880c0db01b2a02206e34126bc81e6f03c6de90278788d6b2be3a3f5d7bd85e6442bbea90b8a5e2394121029780a1a98be5ff17a3228feb830e3bc32395ebecc5cf6de08f7a5685faf66c07ffffffff7e8823d7e3e5565fdb9e53c015ad5b63bc6dd6ee1a9be7f7a8e105c903783d45040000006b483045022100dfba71c6c33d2366b2202ea136271fd2f3a26c0a7295f6dd6018fbe12fc48708022052387ca532247bbfffef4b076fd2fbbdebe85b3cf53c73004897cd07be5d1ad6412103f735eb69c9f0b3c90dd2798b709e12a5b3d176a4690bd2a31ec616af9ecd47b5ffffffff7e8823d7e3e5565fdb9e53c015ad5b63bc6dd6ee1a9be7f7a8e105c903783d45020000006b483045022100f941b74c5da1e7f0ce4d1c08745e4d6b8ef56656199505caeff4b74f54890cec02201eaa0d01112497c89ebe4818687c5f3305629a83abd3b0bf5135feee68f57ca0412103fb0ec51f9e6e45b213cb1deff921e3322624cfb6d4a4ba5dc699867cf163b736ffffffff7e8823d7e3e5565fdb9e53c015ad5b63bc6dd6ee1a9be7f7a8e105c903783d45030000006a47304402205a3a2848350fdc19cae73243bea16dfc1e8fbbd5cecde4b5c88a57790505cebb022043db51d04998d52ec394d0e945a76e9922dd63caffb882242bec2ced2a35c7f1412102bae3e2f47dceaed7db2a212e059a00674a0087e3395b92d9f12cdbefdbc84efaffffffffad49b6f41125ffadab03cb2dcc24caf2c897c2cd4aed6e0a3b5c003f2dde757a030000006b48