Skip to content

Commit ff925cb

Browse files
sirdeggenclaude
andcommitted
feat: add @bsv/overlay-topics canonical package with all 19 topic/lookup pairs
Aggregates topic managers and lookup services from overlay-express-examples, identity-services, did-services, registry-services, kvstore-services, and ump-services into a single canonical ESM TypeScript library. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 86c068a commit ff925cb

79 files changed

Lines changed: 4872 additions & 4 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "@bsv/overlay-topics",
3+
"version": "1.0.0",
4+
"description": "Canonical BSV overlay topic managers and lookup services",
5+
"type": "module",
6+
"main": "./dist/index.js",
7+
"types": "./dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"import": "./dist/index.js",
11+
"types": "./dist/index.d.ts"
12+
}
13+
},
14+
"scripts": {
15+
"build": "tsc",
16+
"lint": "ts-standard --fix src/**/*.ts"
17+
},
18+
"dependencies": {
19+
"@bsv/overlay": "^2.0.2",
20+
"@bsv/sdk": "^2.0.13",
21+
"mongodb": "^7.1.0"
22+
},
23+
"devDependencies": {
24+
"@types/node": "^24.0.3",
25+
"ts-standard": "^12.0.0",
26+
"typescript": "^5.8.3"
27+
},
28+
"license": "Open BSV",
29+
"author": "BSV Blockchain Association",
30+
"private": false
31+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {
2+
LookupService,
3+
LookupQuestion,
4+
LookupFormula,
5+
AdmissionMode,
6+
SpendNotificationMode,
7+
OutputAdmittedByTopic,
8+
OutputSpent
9+
} from '@bsv/overlay'
10+
import { AnyStorage } from './AnyStorage.js'
11+
import { Db } from 'mongodb'
12+
import { AnyQuery } from './types.js'
13+
14+
export class AnyLookupService implements LookupService {
15+
readonly admissionMode: AdmissionMode = 'locking-script'
16+
readonly spendNotificationMode: SpendNotificationMode = 'txid'
17+
18+
constructor(public storage: AnyStorage) { }
19+
20+
async outputAdmittedByTopic(payload: OutputAdmittedByTopic): Promise<void> {
21+
if (payload.mode !== 'locking-script') throw new Error('Invalid mode')
22+
const { txid, outputIndex } = payload
23+
if (payload.topic !== 'tm_anytx') return
24+
25+
try {
26+
await this.storage.storeRecord(txid, outputIndex)
27+
} catch (err) {
28+
console.error(`AnyLookupService: failed to index ${txid}.${outputIndex}`, err)
29+
}
30+
}
31+
32+
async outputSpent(payload: OutputSpent): Promise<void> {
33+
if (payload.mode !== 'txid') throw new Error('Invalid mode')
34+
const { topic, txid, outputIndex, spendingTxid } = payload
35+
if (topic !== 'tm_anytx') return
36+
await this.storage.spendRecord(txid, outputIndex, spendingTxid)
37+
}
38+
39+
async outputEvicted(txid: string, outputIndex: number): Promise<void> {
40+
await this.storage.deleteRecord(txid, outputIndex)
41+
}
42+
43+
async lookup(question: LookupQuestion): Promise<LookupFormula> {
44+
if (!question) throw new Error('A valid query must be provided!')
45+
if (question.service !== 'ls_anytx') throw new Error('Lookup service not supported!')
46+
47+
const {
48+
txid,
49+
limit = 50,
50+
skip = 0,
51+
startDate,
52+
endDate,
53+
sortOrder
54+
} = question.query as AnyQuery
55+
56+
if (limit < 0) throw new Error('Limit must be a non-negative number')
57+
if (skip < 0) throw new Error('Skip must be a non-negative number')
58+
59+
const from = startDate ? new Date(startDate) : undefined
60+
const to = endDate ? new Date(endDate) : undefined
61+
if (from && isNaN(from.getTime())) throw new Error('Invalid startDate provided!')
62+
if (to && isNaN(to.getTime())) throw new Error('Invalid endDate provided!')
63+
64+
if (txid) {
65+
const result = await this.storage.findByTxid(txid)
66+
return [result]
67+
}
68+
69+
return await this.storage.findAll(limit, skip, from, to, sortOrder || 'desc')
70+
}
71+
72+
async getDocumentation(): Promise<string> {
73+
return 'Any Lookup Service: lookup your outputs.'
74+
}
75+
76+
async getMetaData(): Promise<{
77+
name: string
78+
shortDescription: string
79+
iconURL?: string
80+
version?: string
81+
informationURL?: string
82+
}> {
83+
return {
84+
name: 'Any Lookup Service',
85+
shortDescription: 'Lookup your outputs.'
86+
}
87+
}
88+
}
89+
90+
// Factory
91+
export default (db: Db): AnyLookupService => new AnyLookupService(new AnyStorage(db))
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { Collection, Db } from 'mongodb'
2+
import { AnyRecord, UTXOReference } from './types.js'
3+
4+
// Implements a Lookup StorageEngine for Any
5+
export class AnyStorage {
6+
private readonly records: Collection<AnyRecord>
7+
8+
constructor(private readonly db: Db) {
9+
this.records = db.collection<AnyRecord>('anyRecords')
10+
this.createSearchableIndex()
11+
}
12+
13+
private async createSearchableIndex(): Promise<void> {
14+
await this.records.createIndex({ txid: 1 }, { name: 'txidIndex' })
15+
}
16+
17+
async storeRecord(txid: string, outputIndex: number): Promise<void> {
18+
await this.records.insertOne({
19+
txid,
20+
outputIndex,
21+
createdAt: new Date()
22+
})
23+
}
24+
25+
async spendRecord(txid: string, outputIndex: number, spendingTxid: string): Promise<void> {
26+
await this.records.updateOne({ txid, outputIndex }, { $set: { spendingTxid } })
27+
}
28+
29+
async deleteRecord(txid: string, outputIndex: number): Promise<void> {
30+
await this.records.deleteOne({ txid, outputIndex })
31+
}
32+
33+
async findByTxid(txid: string): Promise<UTXOReference | null> {
34+
if (!txid) return null
35+
return this.records.findOne(
36+
{ txid },
37+
{ projection: { txid: 1, outputIndex: 1 } }
38+
)
39+
}
40+
41+
async findAll(
42+
limit = 50,
43+
skip = 0,
44+
startDate?: Date,
45+
endDate?: Date,
46+
sortOrder: 'asc' | 'desc' = 'desc'
47+
): Promise<UTXOReference[]> {
48+
const query: any = {}
49+
if (startDate || endDate) {
50+
query.createdAt = {}
51+
if (startDate) query.createdAt.$gte = startDate
52+
if (endDate) query.createdAt.$lte = endDate
53+
}
54+
const sortDirection = sortOrder === 'asc' ? 1 : -1
55+
return this.records.find(query)
56+
.sort({ createdAt: sortDirection })
57+
.skip(skip)
58+
.limit(limit)
59+
.project<UTXOReference>({ txid: 1, outputIndex: 1 })
60+
.toArray()
61+
}
62+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { AdmittanceInstructions, TopicManager } from '@bsv/overlay'
2+
import { Transaction } from '@bsv/sdk'
3+
4+
/**
5+
* Topic manager for the simple "Any" messaging protocol.
6+
* Each valid output must satisfy the following rules:
7+
* 1. There are no rules.
8+
*/
9+
export default class AnyTopicManager implements TopicManager {
10+
async identifyAdmissibleOutputs(
11+
beef: number[],
12+
previousCoins: number[]
13+
): Promise<AdmittanceInstructions> {
14+
const outputsToAdmit: number[] = []
15+
16+
try {
17+
console.log('Any topic manager invoked')
18+
const parsedTx = Transaction.fromBEEF(beef)
19+
20+
if (!Array.isArray(parsedTx.outputs) || parsedTx.outputs.length === 0) {
21+
throw new Error('Missing parameter: outputs')
22+
}
23+
24+
// Admit all outputs
25+
outputsToAdmit.push(...Array.from({ length: parsedTx.outputs.length }, (_, i) => i))
26+
} catch (err) {
27+
if (outputsToAdmit.length === 0 && (!previousCoins || previousCoins.length === 0)) {
28+
console.error('Error identifying admissible outputs:', err)
29+
}
30+
}
31+
32+
return {
33+
outputsToAdmit,
34+
coinsToRetain: []
35+
}
36+
}
37+
38+
async getDocumentation(): Promise<string> {
39+
return 'Any Topic Manager: admits all transaction outputs.'
40+
}
41+
42+
async getMetaData(): Promise<{
43+
name: string
44+
shortDescription: string
45+
iconURL?: string
46+
version?: string
47+
informationURL?: string
48+
}> {
49+
return {
50+
name: 'Any Topic Manager',
51+
shortDescription: 'Any transaction is admitted by the Any Topic Manager.'
52+
}
53+
}
54+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export interface UTXOReference {
2+
txid: string
3+
outputIndex: number
4+
}
5+
6+
export interface AnyRecord {
7+
txid: string
8+
outputIndex: number
9+
createdAt: Date
10+
}
11+
12+
export interface AnyQuery {
13+
txid?: string
14+
limit?: number
15+
skip?: number
16+
startDate?: Date
17+
endDate?: Date
18+
sortOrder?: 'asc' | 'desc'
19+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { AppsStorageManager } from './AppsStorageManager.js'
2+
import { AdmissionMode, LookupFormula, LookupQuestion, LookupService, OutputAdmittedByTopic, OutputSpent, SpendNotificationMode } from '@bsv/overlay'
3+
import { PushDrop, Utils } from '@bsv/sdk'
4+
import { Db } from 'mongodb'
5+
import { AppCatalogQuery, PublishedAppMetadata } from './types.js'
6+
7+
class AppsLookupService implements LookupService {
8+
readonly admissionMode: AdmissionMode = 'locking-script'
9+
readonly spendNotificationMode: SpendNotificationMode = 'none'
10+
11+
private static readonly TOPIC = 'tm_apps'
12+
private static readonly SERVICE_ID = 'ls_apps'
13+
14+
constructor(public storageManager: AppsStorageManager) { }
15+
16+
async outputAdmittedByTopic(payload: OutputAdmittedByTopic): Promise<void> {
17+
if (payload.mode !== 'locking-script') throw new Error('Invalid payload')
18+
const { txid, outputIndex, topic, lockingScript } = payload
19+
if (topic !== AppsLookupService.TOPIC) return
20+
21+
const decoded = PushDrop.decode(lockingScript)
22+
if (decoded.fields.length !== 2) throw new Error('App token must have exactly one metadata field + signature')
23+
24+
const metadataJSON = Utils.toUTF8(decoded.fields[0])
25+
let metadata: PublishedAppMetadata
26+
try {
27+
metadata = JSON.parse(metadataJSON)
28+
} catch {
29+
throw new Error('Metadata field is not valid JSON')
30+
}
31+
if (metadata == null) throw new Error('App token must contain valid metadata')
32+
33+
await this.storageManager.storeRecord(txid, outputIndex, metadata)
34+
}
35+
36+
async outputSpent(payload: OutputSpent): Promise<void> {
37+
if (payload.mode !== 'none') throw new Error('Invalid payload')
38+
const { topic, txid, outputIndex } = payload
39+
if (topic !== AppsLookupService.TOPIC) return
40+
await this.storageManager.deleteRecord(txid, outputIndex)
41+
}
42+
43+
async outputEvicted(txid: string, outputIndex: number): Promise<void> {
44+
await this.storageManager.deleteRecord(txid, outputIndex)
45+
}
46+
47+
async lookup(question: LookupQuestion): Promise<LookupFormula> {
48+
if (question.query === undefined || question.query === null) throw new Error('A valid query must be provided!')
49+
if (question.service !== AppsLookupService.SERVICE_ID) throw new Error('Lookup service not supported!')
50+
51+
const query = (question.query as AppCatalogQuery)
52+
53+
if (query.domain) return await this.storageManager.findByDomain(query.domain, query.limit, query.skip, query.sortOrder)
54+
if (query.publisher) return await this.storageManager.findByPublisher(query.publisher, query.limit, query.skip, query.sortOrder)
55+
if (query.tags?.length) return await this.storageManager.findByTags(query.tags, query.limit, query.skip, query.sortOrder)
56+
if (query.category) return await this.storageManager.findByCategory(query.category, query.limit, query.skip, query.sortOrder)
57+
if (query.name) return await this.storageManager.findByNameFuzzy(query.name, query.limit, query.skip, query.sortOrder)
58+
if (query.outpoint) return await this.storageManager.findByOutpoint(query.outpoint)
59+
60+
return await this.storageManager.findAllApps(query.limit, query.skip, query.sortOrder)
61+
}
62+
63+
async getDocumentation(): Promise<string> {
64+
return 'Apps Lookup Service: find published Metanet Apps.'
65+
}
66+
67+
async getMetaData(): Promise<{
68+
name: string
69+
shortDescription: string
70+
iconURL?: string
71+
version?: string
72+
informationURL?: string
73+
}> {
74+
return {
75+
name: 'Apps Lookup Service',
76+
shortDescription: 'Find published Metanet Apps with ease.'
77+
}
78+
}
79+
}
80+
81+
export default (db: Db): AppsLookupService => new AppsLookupService(new AppsStorageManager(db))

0 commit comments

Comments
 (0)