Skip to content

Commit 0c7685f

Browse files
authored
Merge pull request #181 from ty-everett/codex/ban-aware-discovery-filter
Enforce bans across SHIP and SLAP discovery admission
2 parents 0308028 + 29e770d commit 0c7685f

6 files changed

Lines changed: 470 additions & 10 deletions

File tree

packages/overlays/overlay-express/mod.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export {
1111
} from './src/OverlayExpress.js'
1212
export { BanService, type BannedRecord } from './src/BanService.js'
1313
export { BanAwareLookupWrapper } from './src/BanAwareLookupWrapper.js'
14+
export { BanAwareTopicManager } from './src/BanAwareTopicManager.js'
15+
export { BanAwareSHIPStorage, BanAwareSLAPStorage } from './src/BanAwareDiscoveryStorage.js'
1416
export { JanitorService, type JanitorConfig, type JanitorReport, type HostHealthResult } from './src/JanitorService.js'
1517
export {
1618
OverlayMonitor,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import * as DiscoveryServices from '@bsv/overlay-discovery-services'
2+
import { BanService } from './BanService.js'
3+
4+
type SHIPStorage = InstanceType<typeof DiscoveryServices.SHIPStorage>
5+
type SLAPStorage = InstanceType<typeof DiscoveryServices.SLAPStorage>
6+
7+
/**
8+
* Prevents banned SHIP records from being written to discovery storage.
9+
*/
10+
export class BanAwareSHIPStorage {
11+
constructor (
12+
private readonly wrapped: SHIPStorage,
13+
private readonly banService: BanService,
14+
private readonly logger: typeof console = console
15+
) {}
16+
17+
async ensureIndexes (): Promise<void> {
18+
return await this.wrapped.ensureIndexes()
19+
}
20+
21+
async hasDuplicateRecord (identityKey: string, domain: string, topic: string): Promise<boolean> {
22+
return await this.wrapped.hasDuplicateRecord(identityKey, domain, topic)
23+
}
24+
25+
async storeSHIPRecord (txid: string, outputIndex: number, identityKey: string, domain: string, topic: string): Promise<void> {
26+
if (await this.banService.isOutpointBanned(txid, outputIndex)) {
27+
this.logger.log(`[BAN] Blocked banned outpoint ${txid}.${outputIndex} from SHIP storage`)
28+
return
29+
}
30+
if (await this.banService.isDomainBanned(domain)) {
31+
this.logger.log(`[BAN] Blocked banned domain ${domain} from SHIP storage (${txid}.${outputIndex})`)
32+
return
33+
}
34+
return await this.wrapped.storeSHIPRecord(txid, outputIndex, identityKey, domain, topic)
35+
}
36+
37+
async deleteSHIPRecord (txid: string, outputIndex: number): Promise<void> {
38+
return await this.wrapped.deleteSHIPRecord(txid, outputIndex)
39+
}
40+
41+
async findRecord (...args: Parameters<SHIPStorage['findRecord']>): ReturnType<SHIPStorage['findRecord']> {
42+
return await this.wrapped.findRecord(...args)
43+
}
44+
45+
async findAll (...args: Parameters<SHIPStorage['findAll']>): ReturnType<SHIPStorage['findAll']> {
46+
return await this.wrapped.findAll(...args)
47+
}
48+
}
49+
50+
/**
51+
* Prevents banned SLAP records from being written to discovery storage.
52+
*/
53+
export class BanAwareSLAPStorage {
54+
constructor (
55+
private readonly wrapped: SLAPStorage,
56+
private readonly banService: BanService,
57+
private readonly logger: typeof console = console
58+
) {}
59+
60+
async ensureIndexes (): Promise<void> {
61+
return await this.wrapped.ensureIndexes()
62+
}
63+
64+
async hasDuplicateRecord (identityKey: string, domain: string, service: string): Promise<boolean> {
65+
return await this.wrapped.hasDuplicateRecord(identityKey, domain, service)
66+
}
67+
68+
async storeSLAPRecord (txid: string, outputIndex: number, identityKey: string, domain: string, service: string): Promise<void> {
69+
if (await this.banService.isOutpointBanned(txid, outputIndex)) {
70+
this.logger.log(`[BAN] Blocked banned outpoint ${txid}.${outputIndex} from SLAP storage`)
71+
return
72+
}
73+
if (await this.banService.isDomainBanned(domain)) {
74+
this.logger.log(`[BAN] Blocked banned domain ${domain} from SLAP storage (${txid}.${outputIndex})`)
75+
return
76+
}
77+
return await this.wrapped.storeSLAPRecord(txid, outputIndex, identityKey, domain, service)
78+
}
79+
80+
async deleteSLAPRecord (txid: string, outputIndex: number): Promise<void> {
81+
return await this.wrapped.deleteSLAPRecord(txid, outputIndex)
82+
}
83+
84+
async findRecord (...args: Parameters<SLAPStorage['findRecord']>): ReturnType<SLAPStorage['findRecord']> {
85+
return await this.wrapped.findRecord(...args)
86+
}
87+
88+
async findAll (...args: Parameters<SLAPStorage['findAll']>): ReturnType<SLAPStorage['findAll']> {
89+
return await this.wrapped.findAll(...args)
90+
}
91+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { TopicManager } from '@bsv/overlay'
2+
import { AdmittanceInstructions, PushDrop, Transaction, Utils } from '@bsv/sdk'
3+
import { BanService } from './BanService.js'
4+
5+
/**
6+
* Filters banned SHIP or SLAP advertisement outputs before the overlay engine
7+
* admits them into topic storage.
8+
*/
9+
export class BanAwareTopicManager implements TopicManager {
10+
constructor (
11+
private readonly wrapped: TopicManager,
12+
private readonly banService: BanService,
13+
private readonly protocol: 'SHIP' | 'SLAP',
14+
private readonly logger: typeof console = console
15+
) {}
16+
17+
async identifyAdmissibleOutputs (
18+
beef: number[],
19+
previousCoins: number[],
20+
offChainValues?: number[],
21+
mode?: 'historical-tx' | 'current-tx' | 'historical-tx-no-spv'
22+
): Promise<AdmittanceInstructions> {
23+
const instructions = await this.wrapped.identifyAdmissibleOutputs(beef, previousCoins, offChainValues, mode)
24+
if (instructions.outputsToAdmit.length === 0) return instructions
25+
26+
let tx: Transaction
27+
try {
28+
tx = Transaction.fromBEEF(beef)
29+
} catch {
30+
return instructions
31+
}
32+
33+
const txid = tx.id('hex')
34+
const outputsToAdmit: number[] = []
35+
36+
for (const outputIndex of instructions.outputsToAdmit) {
37+
if (await this.banService.isOutpointBanned(txid, outputIndex)) {
38+
this.logger.log(`[BAN] Blocked banned outpoint ${txid}.${outputIndex} from ${this.protocol} topic admittance`)
39+
continue
40+
}
41+
42+
const output = tx.outputs[outputIndex]
43+
if (output === undefined) continue
44+
45+
try {
46+
const result = PushDrop.decode(output.lockingScript)
47+
if (result.fields.length >= 3 && Utils.toUTF8(result.fields[0]) === this.protocol) {
48+
const domain = Utils.toUTF8(result.fields[2])
49+
if (await this.banService.isDomainBanned(domain)) {
50+
this.logger.log(`[BAN] Blocked banned domain ${domain} from ${this.protocol} topic admittance (${txid}.${outputIndex})`)
51+
continue
52+
}
53+
}
54+
} catch {
55+
// Non PushDrop outputs remain subject to the wrapped manager's decision.
56+
}
57+
58+
outputsToAdmit.push(outputIndex)
59+
}
60+
61+
return {
62+
...instructions,
63+
outputsToAdmit
64+
}
65+
}
66+
67+
async identifyNeededInputs (beef: number[], offChainValues?: number[]): Promise<Array<{ txid: string, outputIndex: number }>> {
68+
if (typeof this.wrapped.identifyNeededInputs === 'function') {
69+
return await this.wrapped.identifyNeededInputs(beef, offChainValues)
70+
}
71+
return []
72+
}
73+
74+
async getDocumentation (): Promise<string> {
75+
return await this.wrapped.getDocumentation()
76+
}
77+
78+
async getMetaData (): Promise<{
79+
name: string
80+
shortDescription: string
81+
iconURL?: string
82+
version?: string
83+
informationURL?: string
84+
}> {
85+
return await this.wrapped.getMetaData()
86+
}
87+
}

packages/overlays/overlay-express/src/OverlayExpress.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import { v4 as uuidv4 } from 'uuid'
3737
import { JanitorService, type JanitorReport } from './JanitorService.js'
3838
import { BanService } from './BanService.js'
3939
import { BanAwareLookupWrapper } from './BanAwareLookupWrapper.js'
40+
import { BanAwareTopicManager } from './BanAwareTopicManager.js'
41+
import { BanAwareSHIPStorage, BanAwareSLAPStorage } from './BanAwareDiscoveryStorage.js'
4042
import { ReorgSseAdapter, type ReorgHandlerInput } from './ReorgStream.js'
4143
import { Wallet, WalletSigner, WalletStorageManager, Services } from '@bsv/wallet-toolbox-client'
4244
import { createAuthMiddleware, type AuthRequest } from '@bsv/auth-express-middleware'
@@ -622,9 +624,9 @@ export default class OverlayExpress {
622624
* By default, auto-configures SHIP and SLAP unless autoConfigureShipSlap = false
623625
* Then it merges in any advanced engine config from `this.engineConfig`.
624626
*
625-
* When a BanService is available (from configureMongo), SHIP and SLAP lookup
626-
* services are automatically wrapped with BanAwareLookupWrapper to prevent
627-
* GASP from re-syncing banned tokens.
627+
* When a BanService is available (from configureMongo), auto-configured SHIP
628+
* and SLAP managers, discovery storage, and lookup services are wrapped so
629+
* banned outputs are not admitted or indexed.
628630
*
629631
* @param autoConfigureShipSlap - Whether to auto-configure SHIP and SLAP services (default: true)
630632
*/
@@ -634,12 +636,20 @@ export default class OverlayExpress {
634636
if (autoConfigureShipSlap) {
635637
this.configureTopicManager('tm_ship', new DiscoveryServices.SHIPTopicManager())
636638
this.configureTopicManager('tm_slap', new DiscoveryServices.SLAPTopicManager())
637-
this.configureLookupServiceWithMongo('ls_ship', (db) => new DiscoveryServices.SHIPLookupService(
638-
new DiscoveryServices.SHIPStorage(db)
639-
))
640-
this.configureLookupServiceWithMongo('ls_slap', (db) => new DiscoveryServices.SLAPLookupService(
641-
new DiscoveryServices.SLAPStorage(db)
642-
))
639+
this.configureLookupServiceWithMongo('ls_ship', (db) => {
640+
const storage = new DiscoveryServices.SHIPStorage(db)
641+
const storageForLookup = this.banService === undefined
642+
? storage
643+
: new BanAwareSHIPStorage(storage, this.banService, this.logger)
644+
return new DiscoveryServices.SHIPLookupService(storageForLookup as any)
645+
})
646+
this.configureLookupServiceWithMongo('ls_slap', (db) => {
647+
const storage = new DiscoveryServices.SLAPStorage(db)
648+
const storageForLookup = this.banService === undefined
649+
? storage
650+
: new BanAwareSLAPStorage(storage, this.banService, this.logger)
651+
return new DiscoveryServices.SLAPLookupService(storageForLookup as any)
652+
})
643653
}
644654

645655
this.wrapBanAwareServices()
@@ -680,9 +690,16 @@ export default class OverlayExpress {
680690
this.logger.log(chalk.green('Engine has been configured.'))
681691
}
682692

683-
/** Wrap SHIP/SLAP with ban-aware filter if BanService is configured. */
693+
/** Wrap SHIP/SLAP managers and services with ban-aware filters if BanService is configured. */
684694
private wrapBanAwareServices (): void {
685695
if (this.banService === undefined) return
696+
for (const key of ['tm_ship', 'tm_slap'] as const) {
697+
if (this.managers[key] !== undefined) {
698+
const label = key === 'tm_ship' ? 'SHIP' : 'SLAP'
699+
this.managers[key] = new BanAwareTopicManager(this.managers[key], this.banService, label, this.logger)
700+
this.logger.log(chalk.blue(`${label} topic manager wrapped with ban-aware filter.`))
701+
}
702+
}
686703
for (const key of ['ls_ship', 'ls_slap'] as const) {
687704
if (this.services[key] !== undefined) {
688705
const label = key === 'ls_ship' ? 'SHIP' : 'SLAP'
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, it, expect, jest, beforeEach } from '@jest/globals'
2+
import { BanService } from '../BanService.js'
3+
import { BanAwareSHIPStorage, BanAwareSLAPStorage } from '../BanAwareDiscoveryStorage.js'
4+
5+
describe('BanAwareDiscoveryStorage', () => {
6+
let mockBanService: jest.Mocked<BanService>
7+
let mockLogger: any
8+
9+
beforeEach(() => {
10+
jest.clearAllMocks()
11+
mockBanService = {
12+
isOutpointBanned: jest.fn<any>().mockResolvedValue(false),
13+
isDomainBanned: jest.fn<any>().mockResolvedValue(false)
14+
} as any
15+
mockLogger = {
16+
log: jest.fn()
17+
}
18+
})
19+
20+
describe('BanAwareSHIPStorage', () => {
21+
it('should block banned outpoints', async () => {
22+
const wrapped = {
23+
storeSHIPRecord: jest.fn<any>().mockResolvedValue(undefined)
24+
}
25+
mockBanService.isOutpointBanned.mockResolvedValue(true)
26+
const storage = new BanAwareSHIPStorage(wrapped as any, mockBanService, mockLogger)
27+
28+
await storage.storeSHIPRecord('txid', 1, 'identity', 'https://good.example', 'tm_users')
29+
30+
expect(wrapped.storeSHIPRecord).not.toHaveBeenCalled()
31+
expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('banned outpoint'))
32+
})
33+
34+
it('should block banned domains', async () => {
35+
const wrapped = {
36+
storeSHIPRecord: jest.fn<any>().mockResolvedValue(undefined)
37+
}
38+
mockBanService.isDomainBanned.mockResolvedValue(true)
39+
const storage = new BanAwareSHIPStorage(wrapped as any, mockBanService, mockLogger)
40+
41+
await storage.storeSHIPRecord('txid', 1, 'identity', 'https://banned.example', 'tm_users')
42+
43+
expect(wrapped.storeSHIPRecord).not.toHaveBeenCalled()
44+
expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('banned domain'))
45+
})
46+
47+
it('should delegate when not banned', async () => {
48+
const wrapped = {
49+
storeSHIPRecord: jest.fn<any>().mockResolvedValue(undefined)
50+
}
51+
const storage = new BanAwareSHIPStorage(wrapped as any, mockBanService, mockLogger)
52+
53+
await storage.storeSHIPRecord('txid', 1, 'identity', 'https://good.example', 'tm_users')
54+
55+
expect(wrapped.storeSHIPRecord).toHaveBeenCalledWith('txid', 1, 'identity', 'https://good.example', 'tm_users')
56+
})
57+
58+
it('should delegate read and delete methods', async () => {
59+
const wrapped = {
60+
ensureIndexes: jest.fn<any>().mockResolvedValue(undefined),
61+
hasDuplicateRecord: jest.fn<any>().mockResolvedValue(true),
62+
deleteSHIPRecord: jest.fn<any>().mockResolvedValue(undefined),
63+
findRecord: jest.fn<any>().mockResolvedValue([{ txid: 'a', outputIndex: 1 }]),
64+
findAll: jest.fn<any>().mockResolvedValue([{ txid: 'b', outputIndex: 2 }])
65+
}
66+
const storage = new BanAwareSHIPStorage(wrapped as any, mockBanService, mockLogger)
67+
68+
await storage.ensureIndexes()
69+
await expect(storage.hasDuplicateRecord('identity', 'https://good.example', 'tm_users')).resolves.toBe(true)
70+
await storage.deleteSHIPRecord('txid', 1)
71+
await expect(storage.findRecord({ domain: 'https://good.example' })).resolves.toEqual([{ txid: 'a', outputIndex: 1 }])
72+
await expect(storage.findAll(10, 0, 'desc')).resolves.toEqual([{ txid: 'b', outputIndex: 2 }])
73+
})
74+
})
75+
76+
describe('BanAwareSLAPStorage', () => {
77+
it('should block banned outpoints', async () => {
78+
const wrapped = {
79+
storeSLAPRecord: jest.fn<any>().mockResolvedValue(undefined)
80+
}
81+
mockBanService.isOutpointBanned.mockResolvedValue(true)
82+
const storage = new BanAwareSLAPStorage(wrapped as any, mockBanService, mockLogger)
83+
84+
await storage.storeSLAPRecord('txid', 1, 'identity', 'https://good.example', 'ls_users')
85+
86+
expect(wrapped.storeSLAPRecord).not.toHaveBeenCalled()
87+
expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('banned outpoint'))
88+
})
89+
90+
it('should block banned domains', async () => {
91+
const wrapped = {
92+
storeSLAPRecord: jest.fn<any>().mockResolvedValue(undefined)
93+
}
94+
mockBanService.isDomainBanned.mockResolvedValue(true)
95+
const storage = new BanAwareSLAPStorage(wrapped as any, mockBanService, mockLogger)
96+
97+
await storage.storeSLAPRecord('txid', 1, 'identity', 'https://banned.example', 'ls_users')
98+
99+
expect(wrapped.storeSLAPRecord).not.toHaveBeenCalled()
100+
expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('banned domain'))
101+
})
102+
103+
it('should delegate when not banned', async () => {
104+
const wrapped = {
105+
storeSLAPRecord: jest.fn<any>().mockResolvedValue(undefined)
106+
}
107+
const storage = new BanAwareSLAPStorage(wrapped as any, mockBanService, mockLogger)
108+
109+
await storage.storeSLAPRecord('txid', 1, 'identity', 'https://good.example', 'ls_users')
110+
111+
expect(wrapped.storeSLAPRecord).toHaveBeenCalledWith('txid', 1, 'identity', 'https://good.example', 'ls_users')
112+
})
113+
114+
it('should delegate read and delete methods', async () => {
115+
const wrapped = {
116+
ensureIndexes: jest.fn<any>().mockResolvedValue(undefined),
117+
hasDuplicateRecord: jest.fn<any>().mockResolvedValue(true),
118+
deleteSLAPRecord: jest.fn<any>().mockResolvedValue(undefined),
119+
findRecord: jest.fn<any>().mockResolvedValue([{ txid: 'a', outputIndex: 1 }]),
120+
findAll: jest.fn<any>().mockResolvedValue([{ txid: 'b', outputIndex: 2 }])
121+
}
122+
const storage = new BanAwareSLAPStorage(wrapped as any, mockBanService, mockLogger)
123+
124+
await storage.ensureIndexes()
125+
await expect(storage.hasDuplicateRecord('identity', 'https://good.example', 'ls_users')).resolves.toBe(true)
126+
await storage.deleteSLAPRecord('txid', 1)
127+
await expect(storage.findRecord({ domain: 'https://good.example' })).resolves.toEqual([{ txid: 'a', outputIndex: 1 }])
128+
await expect(storage.findAll(10, 0, 'desc')).resolves.toEqual([{ txid: 'b', outputIndex: 2 }])
129+
})
130+
})
131+
})

0 commit comments

Comments
 (0)