Lookup Services
Adding a Lookup Service to a Backend
This deep-dive shows you how to build and wire up a custom Lookup Service for the BSV Overlay Services Engine, using the backend-project-template as your starting point and extending the functionality provided by your Topic Manager (see the Topic Manager guide).
Why Lookup Services Matter
A Lookup Service stores the outputs admitted by your Topic Manager so that front-ends and other clients can query on-chain data efficiently. With it, you can:
- Persist data from transactions to be used from a frontend application
- Provide paginated, filtered queries for UTXOs on-chain
- Enforce access controls and authentication
Prerequisites
Before you begin, ensure you have:
- Completed the Topic Manager guide
- Node.js and Docker
- LARS & CARS installed and initialized
- A BRC-100 wallet such as BSV Desktop installed and running
- Git (optional, but recommended)
Structure your overlay backend
Start from the project you used for your Topic Manager. You have two options:
Option 1: Manual setup
- Follow the Option 1: Quickstart in Topic Manager.
- Under
backend/src, add a newlookup-services/folder alongsidetopic-managers/.
1cd backend/src
2mkdir -p lookup-services
3touch lookup-services/DemoStorage.ts
4touch lookup-services/DemoLookupServiceFactory.tsOption 2: Clone the template
Clone the backend or full-stack template and re-run the Topic Manager setup:
1git clone https://github.com/p2ppsr/backend-project-template.git my-project
2cd my-project/backend
3npm installThen follow your Topic Manager guide to register DemoTopicManager before proceeding.
After setup, your directory should resemble:
1my-project/
2├── backend/
3│ ├── src/
4│ │ ├── lookup-services/
5│ │ │ ├── DemoLookupService.ts
6│ │ │ └── DemoStorage.ts
7│ │ ├── topic-managers/
8│ │ │ ├── DemoTopicDocs.md.ts
9│ │ │ └── DemoTopicManager.ts
10│ │ └── types.ts
11│ └── package.json
12├── deployment-info.json
13└── package.jsonImplement the Storage Layer
Your DemoStorage.ts defines how to persist, remove, and retrieve admitted outputs in your database. Here’s the template from the backend-project-template modified slightly for this demo:
1// src/lookup-services/DemoStorage.ts
2import { Collection, Db } from 'mongodb'
3import { DemoRecord, UTXOReference } from '../types'
4
5export class DemoStorage {
6 private readonly records: Collection<DemoRecord>
7
8 constructor(private readonly db: Db) {
9 this.records = db.collection<DemoRecord>('demo_record')
10 }
11
12 async storeRecord(txid: string, outputIndex: number): Promise<void> {
13 await this.records.insertOne({ txid, outputIndex, createdAt: new Date() })
14 }
15
16 async deleteRecord(txid: string, outputIndex: number): Promise<void> {
17 await this.records.deleteOne({ txid, outputIndex })
18 }
19
20 async findAll(): Promise<UTXOReference[]> {
21 return this.records
22 .find({})
23 .project<UTXOReference>({ txid: 1, outputIndex: 1 })
24 .toArray()
25 .then(results => results.map(r => ({ txid: r.txid, outputIndex: r.outputIndex })))
26 }
27}Customize for Hello-Tokens
For our hello-token example, we’ll store the message field too:
1async storeRecord(
2 txid: string,
3 outputIndex: number,
4 message: string
5): Promise<void> {
6 await this.records.insertOne({
7 txid,
8 outputIndex,
9 message,
10 createdAt: new Date()
11 })
12}And add a custom query for the messages that we store:
1async findByMessage(
2 message: string,
3 limit = 50,
4 skip = 0,
5 sortOrder: 'asc' | 'desc' = 'desc'
6): Promise<UTXOReference[]> {
7 const direction = sortOrder === 'asc' ? 1 : -1
8 return this.records
9 .find({ $text: { $search: message } }, { projection: { txid: 1, outputIndex: 1, createdAt: 1 } })
10 .sort({ createdAt: direction })
11 .skip(skip)
12 .limit(limit)
13 .toArray()
14 .then(results => results.map(r => ({ txid: r.txid, outputIndex: r.outputIndex })))
15}Implement the DemoLookupServiceFactory
Below we break the factory into focused pieces. You’ll facilitate the functionality of your lookup service to store and query the transaction data of hello-tokens by combining these pieces with the template.
1. Configure admission & spend modes
These two properties tell the overlay engine how this service receives events. For our hello-token example, we admit via the locking script and request no data within spend notifications:
1export class DemoLookupService implements LookupService {
2 readonly admissionMode: AdmissionMode = 'locking-script'
3 readonly spendNotificationMode: SpendNotificationMode = 'none'
4
5 constructor(private storage: DemoStorage) {}
6 // …
7}These values control what data is provided within event payloads.
2. Persist admitted outputs
When a new UTXO is admitted by your Topic Manager, the engine calls outputAdmittedByTopic. Decode the PushDrop payload, validate it, then store it:
1/** Called when Topic Manager admits an output */
2async outputAdmittedByTopic(payload: OutputAdmittedByTopic): Promise<void> {
3 // 1. Ensure the engine is using the correct admission mode
4 if (payload.mode !== this.admissionMode) throw new Error('Invalid mode')
5
6 // 2. Only handle our demo topic, change to fit your topic manager's name
7 if (payload.topic !== 'tm_demo') throw new Error(`Unexpected topic: ${payload.topic}`)
8
9 try {
10 // 3. Decode the PushDrop “hello token”
11 const result = PushDrop.decode(payload.lockingScript)
12 const message = Utils.toUTF8(result.fields[0])
13 if (message.length < 2) throw new Error('Invalid hello-token: message too short')
14
15 // 4. Persist for future lookup
16 await this.storage.storeRecord(payload.txid, payload.outputIndex, message)
17 } catch (err) {
18 console.error(`DemoLookupService: failed to index ${payload.txid}.${payload.outputIndex}`, err)
19 }
20}The
payloadincludestxid,outputIndex, and rawlockingScript.
3. Respond to lookup queries
Clients invoke lookup(...) to retrieve matching UTXOs. Here we branch by message keyword or return all records:
1/** Answers a lookup query */
2async lookup(question: LookupQuestion): Promise<LookupAnswer | LookupFormula> {
3 if (!question) throw new Error('A valid query must be provided!')
4 if (question.service !== 'ls_demo') throw new Error('Lookup service not supported!')
5
6 const { message, limit = 50, skip = 0 } = question.query as DemoQuery
7
8 // Basic validation
9 if (limit < 0 || skip < 0) throw new Error('Limit and skip must be non-negative')
10
11 // 1. If a message filter is provided, use the message search
12 if (message) {
13 return this.storage.findByMessage(message, limit, skip)
14 }
15
16 // 2. Otherwise, return all records
17 return this.storage.findAll()
18}Every output returned here includes
txidandoutputIndex; frontends can decode the BEEF to extract token fields.
With these pieces in place, your DemoLookupService is ready to be registered in DemoLookupServiceFactory.ts. The last step is to add the factory to your deployment-info.json:
1{
2 "schema": "bsv-app",
3 "schemaVersion": "1.0",
4 "topicManagers": {
5 "tm_demo": "./backend/src/topic-managers/DemoTopicManager.ts"
6 },
7 "lookupServices": {
8 "ls_demo": {
9 "serviceFactory": "./backend/src/lookup-services/DemoLookupServiceFactory.ts",
10 "hydrateWith": "mongo"
11 }
12 },
13 …,
14}Querying from your Front-End
Here’s how your React app can query admitted hello-tokens:
1import { LookupResolver } from '@bsv/overlay'
2import { Transaction, PushDrop, Utils } from '@bsv/sdk'
3
4export async function queryTokens(message: string): Promise<void> {
5 const resolver = new LookupResolver({ networkPreset: 'local' }) // or mainnet/testnet
6 const response = await resolver.query({
7 service: 'ls_demo',
8 query: { message }
9 })
10
11 if (response.type !== 'output-list') throw new Error('Expected output-list')
12
13 response.outputs.forEach(({ beef, outputIndex }) => {
14 const tx = Transaction.fromBEEF(beef)
15 const decoded = PushDrop.decode(tx.outputs[outputIndex].lockingScript)
16 console.log('Message:', Utils.toUTF8(decoded.fields[0]))
17 })
18}Congratulations! You now have a fully functional overlay system—Topic Manager and Lookup Service—ready to deploy with LARS or CARS. Happy building!
