From 2ab00b4491b0f639de9bba7f42853a2aaed12c90 Mon Sep 17 00:00:00 2001 From: Mohammad Jaber <49678859+mo-jaber@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:17:28 +0400 Subject: [PATCH 1/8] feat(overlay-topics): add tm_uora_dpp / ls_uora_dpp for attestation anchors Admission and lookup for UORA attestation anchors in the `uora-anchor-v2` format, keyed on the `did:key` of the party that made the claim. A UORA attestation is a claim one party makes about one product: who made it, who it passed to, what happened to it. The claims never go on chain, both because they can carry personal data and because a digest is 32 bytes whatever the claim's size, which is what keeps per-event anchoring affordable at fleet scale. The anchor carries the digest plus the issuer, subject, type and anchoring service in the clear, so an index has something to key on. What admission proves: the seven fields were sealed together by the key locking the output, and that key is the BRC-42 child of the anchoring service named in field 6 under counterparty `anyone`. Producing an output satisfying both needs that service's private key, so every admitted anchor names its author checkably, by anyone holding the transaction, with nothing configured. That last part is why the service key is a field rather than a constructor argument: a shared node serving several deployments would otherwise need amending whenever one was added, and a reader holding only the transaction could not attribute it at all. `anchorServiceKeys` narrows what an instance carries and is documented as a preference about what to index, not a boundary. What it does not prove: that the party in field 3 made the claim. That is a did:key copied onto the chain as given, and anyone able to write an anchor can write any DID into it. What a claim is worth is settled by the attestation's own signature, off chain. Both the topic manager docblock and `getDocumentation()` say so, because the field invites the opposite reading. Anchors are leaves: never spent, no predecessor, no transition rules, nothing retained. Every valid anchor in a transaction is admitted rather than exactly one, which leaves a service free to batch without this topic changing. Additive only. No existing topic, export, schema, dependency or behaviour changes. Built on the existing `shared/identifyPushDropOutputs` helper and follows `certmap`'s pattern of validating an output against an operator the token itself names. 27 tests: 411 in the package, up from 384. Two worth reading are `refuses an anchor naming a service its locking key cannot come from`, which is the format's whole claim, and `does not reuse a fields array`, which pins that PushDrop.lock appends the signature to the array it is handed. --- packages/overlays/topics/CHANGELOG.md | 7 +- packages/overlays/topics/README.md | 1 + packages/overlays/topics/package.json | 2 +- .../topics/src/__tests__/uoradpp.test.ts | 424 ++++++++++++++++++ packages/overlays/topics/src/index.ts | 14 + .../src/uoradpp/UoraDppLookupService.ts | 143 ++++++ .../topics/src/uoradpp/UoraDppStorage.ts | 88 ++++ .../topics/src/uoradpp/UoraDppTopicManager.ts | 108 +++++ .../topics/src/uoradpp/anchorFormat.ts | 208 +++++++++ packages/overlays/topics/src/uoradpp/types.ts | 58 +++ 10 files changed, 1051 insertions(+), 2 deletions(-) create mode 100644 packages/overlays/topics/src/__tests__/uoradpp.test.ts create mode 100644 packages/overlays/topics/src/uoradpp/UoraDppLookupService.ts create mode 100644 packages/overlays/topics/src/uoradpp/UoraDppStorage.ts create mode 100644 packages/overlays/topics/src/uoradpp/UoraDppTopicManager.ts create mode 100644 packages/overlays/topics/src/uoradpp/anchorFormat.ts create mode 100644 packages/overlays/topics/src/uoradpp/types.ts diff --git a/packages/overlays/topics/CHANGELOG.md b/packages/overlays/topics/CHANGELOG.md index 1a3e4c8b9..30c9ae587 100644 --- a/packages/overlays/topics/CHANGELOG.md +++ b/packages/overlays/topics/CHANGELOG.md @@ -10,7 +10,12 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] ### Added -- (Include new features or significant user-visible enhancements here.) + +- `tm_uora_dpp` / `ls_uora_dpp`: admission and lookup for UORA attestation + anchors (`uora-anchor-v2`), keyed on the `did:key` of the party that made the + claim. Anchors name their anchoring service in the output and lock to its + BRC-42 child, so an instance attributes one with nothing configured. Additive: + no existing topic, export, schema or behaviour changes. ### Changed diff --git a/packages/overlays/topics/README.md b/packages/overlays/topics/README.md index 0f3d620d5..673428731 100644 --- a/packages/overlays/topics/README.md +++ b/packages/overlays/topics/README.md @@ -53,6 +53,7 @@ Each topic ships a matching `*TopicManager` (admission rules for incoming transa | `tm_protomap` / `ls_protomap` | `ProtoMapTopicManager` | `createProtoMapLookupService` | | `tm_slackthread` / `ls_slackthread` | `SlackThreadsTopicManager` | `createSlackThreadsLookupService` | | `tm_supplychain` / `ls_supplychain` | `SupplyChainTopicManager` | `createSupplyChainLookupService` | +| `tm_uora_dpp` / `ls_uora_dpp` | `UoraDppTopicManager` | `createUoraDppLookupService` | | `tm_uhrp` / `ls_uhrp` | `UHRPTopicManager` | `createUHRPLookupService` | | `tm_users` / `ls_users` | `UMPTopicManager` | `createUMPLookupService` | | `tm_tokendemo` / `ls_tokendemo` | `TokenDemoTopicManager` | `createTokenDemoLookupService` | diff --git a/packages/overlays/topics/package.json b/packages/overlays/topics/package.json index 0269ff707..a2017006c 100644 --- a/packages/overlays/topics/package.json +++ b/packages/overlays/topics/package.json @@ -29,7 +29,7 @@ "build": "tsc", "format:check": "pnpm --workspace-root exec prettier --check \"packages/overlays/topics/{README.md,jest.config.js,package.json,tsconfig.json}\" \"packages/overlays/topics/src/index.ts\"", "lint": "oxlint src jest.config.js --deny-warnings", - "pack:check": "pnpm build && node ../../../scripts/check-package-artifact.mjs . --modes esm --exports AnyTopicManager,AppsTopicManager,BTMSTopicManager,BasketMapTopicManager,Bsv21LookupService,Bsv21StorageManager,Bsv21TopicManager,CertMapTopicManager,DIDTopicManager,DesktopIntegrityTopicManager,DstasLookupService,DstasStorageManager,DstasTopicManager,FractionalizeTopicManager,HelloWorldTopicManager,IdentityTopicManager,InMemoryScreeningProvider,KVStoreTopicManager,MandalaLookupService,MandalaStorageManager,MandalaTopicManager,MessageBoxTopicManager,MonsterBattleTopicManager,ProtoMapTopicManager,SlackThreadsTopicManager,StasLookupService,StasStorageManager,StasTopicManager,SupplyChainTopicManager,TokenDemoTopicManager,UHRPTopicManager,UMPTopicManager,WalletConfigTopicManager,allowlistIssuerPolicy,btmsProtocol,createAnyLookupService,createAppsLookupService,createBTMSLookupService,createBasketMapLookupService,createBsv21LookupService,createCertMapLookupService,createDIDLookupService,createDesktopIntegrityLookupService,createDstasLookupService,createFractionalizeLookupService,createHelloWorldLookupService,createIdentityLookupService,createKVStoreLookupService,createMandalaLookupService,createMessageBoxLookupService,createMonsterBattleLookupService,createProtoMapLookupService,createSlackThreadsLookupService,createStasLookupService,createSupplyChainLookupService,createTokenDemoLookupService,createUHRPLookupService,createUMPLookupService,createWalletConfigLookupService,deserializeWalletProtocol,kvProtocol,verifyKeyLinkage", + "pack:check": "pnpm build && node ../../../scripts/check-package-artifact.mjs . --modes esm --exports AnyTopicManager,AppsTopicManager,BTMSTopicManager,BasketMapTopicManager,Bsv21LookupService,Bsv21StorageManager,Bsv21TopicManager,CertMapTopicManager,DIDTopicManager,DesktopIntegrityTopicManager,DstasLookupService,DstasStorageManager,DstasTopicManager,FractionalizeTopicManager,HelloWorldTopicManager,IdentityTopicManager,InMemoryScreeningProvider,KVStoreTopicManager,MandalaLookupService,MandalaStorageManager,MandalaTopicManager,MessageBoxTopicManager,MonsterBattleTopicManager,ProtoMapTopicManager,SlackThreadsTopicManager,StasLookupService,StasStorageManager,StasTopicManager,SupplyChainTopicManager,TokenDemoTopicManager,UHRPTopicManager,UMPTopicManager,UORA_ANCHOR_PREFIX,UORA_ANCHOR_PROTOCOL,UoraDppTopicManager,WalletConfigTopicManager,allowlistIssuerPolicy,btmsProtocol,createAnyLookupService,createAppsLookupService,createBTMSLookupService,createBasketMapLookupService,createBsv21LookupService,createCertMapLookupService,createDIDLookupService,createDesktopIntegrityLookupService,createDstasLookupService,createFractionalizeLookupService,createHelloWorldLookupService,createIdentityLookupService,createKVStoreLookupService,createMandalaLookupService,createMessageBoxLookupService,createMonsterBattleLookupService,createProtoMapLookupService,createSlackThreadsLookupService,createStasLookupService,createSupplyChainLookupService,createTokenDemoLookupService,createUHRPLookupService,createUMPLookupService,createUoraDppLookupService,createWalletConfigLookupService,deserializeWalletProtocol,didKeyFromIdentityKey,expectedLockingKey,identityKeyFromDidKey,kvProtocol,readUoraAnchor,verifyKeyLinkage", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --watchman=false", "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --runInBand --watchman=false", "test:property": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --watchman=false --runTestsByPath src/mandala/__tests/types.property.test.ts", diff --git a/packages/overlays/topics/src/__tests__/uoradpp.test.ts b/packages/overlays/topics/src/__tests__/uoradpp.test.ts new file mode 100644 index 000000000..41dadd74e --- /dev/null +++ b/packages/overlays/topics/src/__tests__/uoradpp.test.ts @@ -0,0 +1,424 @@ +/** + * Integration tests for UoraDppTopicManager and UoraDppLookupService. + * + * The test that carries the format is `refuses an anchor naming a service its + * locking key cannot come from`. Everything else is structure around it: an + * anchor is admissible because it proves who wrote it, and it proves that by + * locking to the BRC-42 child of the key it names, under counterparty `anyone` + * so a third party can reproduce the derivation. + * + * A second one worth reading is `does not reuse a fields array`. `PushDrop.lock` + * appends the signature to the array it is handed, so a caller that builds + * fields once and locks twice signs the previous signature and writes an output + * nothing can read. + */ + +import { MongoMemoryServer } from 'mongodb-memory-server' +import { Db, MongoClient } from 'mongodb' +import { + LockingScript, + P2PKH, + PrivateKey, + ProtoWallet, + PushDrop, + Transaction, + Utils +} from '@bsv/sdk' +import type { WalletInterface } from '@bsv/sdk' +import { LookupQuestion, OutputAdmittedByTopic } from '@bsv/overlay' +import UoraDppTopicManager from '../uoradpp/UoraDppTopicManager.js' +import createUoraDppLookupService, { + UoraDppLookupService +} from '../uoradpp/UoraDppLookupService.js' +import { + didKeyFromIdentityKey, + expectedLockingKey, + identityKeyFromDidKey, + readUoraAnchor, + UORA_ANCHOR_PREFIX, + UORA_ANCHOR_PROTOCOL +} from '../uoradpp/anchorFormat.js' + +const mongoMemoryServerOptions = { instance: { launchTimeout: 60000 } } + +const servicePriv = PrivateKey.fromHex('77'.repeat(32)) +const SERVICE_KEY = servicePriv.toPublicKey().toString() +const serviceWallet = new ProtoWallet(servicePriv) as unknown as WalletInterface + +const strangerPriv = PrivateKey.fromHex('66'.repeat(32)) +const STRANGER_KEY = strangerPriv.toPublicKey().toString() +const strangerWallet = new ProtoWallet(strangerPriv) as unknown as WalletInterface + +const MAKER = didKeyFromIdentityKey(PrivateKey.fromHex('88'.repeat(32)).toPublicKey().toString()) +const RECYCLER = didKeyFromIdentityKey(PrivateKey.fromHex('89'.repeat(32)).toPublicKey().toString()) + +const CELL = 'https://id.gs1.org/01/09506000134352/21/CELL-1' +const JACKET = 'https://id.gs1.org/01/09506000134352/21/JACKET-1' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface Claim { + digest: string + attestationId: string + issuer: string + subject: string + uoraType: string + anchoredBy: string +} + +function claim(overrides: Partial = {}): Claim { + return { + digest: 'a'.repeat(64), + attestationId: `${CELL}/state-1`, + issuer: MAKER, + subject: CELL, + uoraType: 'Origin', + anchoredBy: SERVICE_KEY, + ...overrides + } +} + +/** A fresh array every call: PushDrop.lock pushes the signature into it. */ +function fieldsFor(one: Claim): number[][] { + return [ + UORA_ANCHOR_PREFIX, + one.digest, + one.attestationId, + one.issuer, + one.subject, + one.uoraType, + one.anchoredBy + ].map(value => Utils.toArray(value, 'utf8')) +} + +async function anchorScript( + one: Claim = claim(), + wallet: WalletInterface = serviceWallet, + keyId = one.attestationId +): Promise { + return await new PushDrop(wallet).lock( + fieldsFor(one), + UORA_ANCHOR_PROTOCOL, + keyId, + 'anyone', + true + ) +} + +/** A transaction with an input, which `identifyPushDropOutputs` requires. */ +function txWith(...scripts: LockingScript[]): Transaction { + const source = new Transaction() + source.addOutput({ lockingScript: new LockingScript([]), satoshis: 10000 }) + const tx = new Transaction() + tx.addInput({ + sourceTransaction: source, + sourceOutputIndex: 0, + unlockingScript: new LockingScript([]) + }) + for (const lockingScript of scripts) tx.addOutput({ lockingScript, satoshis: 1 }) + return tx +} + +function p2pkhOutput(): LockingScript { + return new P2PKH().lock(PrivateKey.fromRandom().toPublicKey().toHash()) +} + +// --------------------------------------------------------------------------- +// did:key +// --------------------------------------------------------------------------- + +describe('did:key encoding', () => { + it('round-trips a compressed secp256k1 key', () => { + const key = PrivateKey.fromHex('11'.repeat(32)).toPublicKey().toString() + expect(identityKeyFromDidKey(didKeyFromIdentityKey(key))).toBe(key) + expect(didKeyFromIdentityKey(key).startsWith('did:key:zQ3s')).toBe(true) + }) + + it('refuses another curve, a bad encoding and a DID it cannot read', () => { + // A well-formed Ed25519 did:key, which differs by two bytes at the front. + expect( + identityKeyFromDidKey('did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK') + ).toBeUndefined() + expect(identityKeyFromDidKey('did:web:example.com')).toBeUndefined() + expect(identityKeyFromDidKey('did:key:zNOTBASE58!!')).toBeUndefined() + expect(() => didKeyFromIdentityKey(`02${'ff'.repeat(32)}`)).toThrow() + }) +}) + +// --------------------------------------------------------------------------- +// UoraDppTopicManager +// --------------------------------------------------------------------------- + +describe('UoraDppTopicManager', () => { + const manager = new UoraDppTopicManager() + + it('admits a well-formed anchor', async () => { + const result = await manager.identifyAdmissibleOutputs( + txWith(await anchorScript()).toBEEF(), + [] + ) + expect(result).toEqual({ outputsToAdmit: [0], coinsToRetain: [] }) + }) + + it('reproduces the locking key from the service key the output names', async () => { + const { anchor, lockingPublicKey } = readUoraAnchor(await anchorScript()) + expect(anchor.anchoredBy).toBe(SERVICE_KEY) + expect(lockingPublicKey.toString()).toBe( + expectedLockingKey(SERVICE_KEY, `${CELL}/state-1`) + ) + }) + + it('refuses an anchor naming a service its locking key cannot come from', async () => { + // Signed and sealed correctly by the key that locks it, and lying in field + // 6 about who that is. Producing one that passes needs the named service's + // private key, which is the whole of the attribution proof. + const lying = await anchorScript(claim({ anchoredBy: STRANGER_KEY }), serviceWallet) + expect(() => readUoraAnchor(lying)).toThrow(/not derived from the anchoring service/) + const result = await manager.identifyAdmissibleOutputs(txWith(lying).toBEEF(), []) + expect(result.outputsToAdmit).toEqual([]) + }) + + it('admits an anchor from a service it was never told about', async () => { + // The reason field 6 exists. A shared instance carries anchors from a + // deployment nobody configured it for, and still says whose each one is. + const theirs = await anchorScript(claim({ anchoredBy: STRANGER_KEY }), strangerWallet) + const result = await manager.identifyAdmissibleOutputs(txWith(theirs).toBEEF(), []) + expect(result.outputsToAdmit).toEqual([0]) + expect(readUoraAnchor(theirs).anchor.anchoredBy).toBe(STRANGER_KEY) + }) + + it('narrows to named services when an instance asks it to', async () => { + const narrowed = new UoraDppTopicManager([SERVICE_KEY]) + const theirs = await anchorScript(claim({ anchoredBy: STRANGER_KEY }), strangerWallet) + expect( + (await narrowed.identifyAdmissibleOutputs(txWith(theirs).toBEEF(), [])).outputsToAdmit + ).toEqual([]) + expect( + (await narrowed.identifyAdmissibleOutputs(txWith(await anchorScript()).toBEEF(), [])) + .outputsToAdmit + ).toEqual([0]) + }) + + it('refuses a digest that is not 64 lower-case hex', async () => { + for (const digest of ['A'.repeat(64), 'a'.repeat(63), 'not a digest']) { + const bad = await anchorScript(claim({ digest })) + expect(() => readUoraAnchor(bad)).toThrow(/digest/) + } + }) + + it('refuses an issuer that is not a secp256k1 did:key', async () => { + const bad = await anchorScript(claim({ issuer: 'did:web:example.com' })) + expect(() => readUoraAnchor(bad)).toThrow(/did:key/) + }) + + it('refuses a field edited after signing', async () => { + const script = await anchorScript() + const chunks = script.chunks.map(chunk => ({ ...chunk })) + const digestChunk = chunks.find( + chunk => chunk.data !== undefined && Utils.toUTF8(chunk.data) === 'a'.repeat(64) + ) + expect(digestChunk).toBeDefined() + digestChunk!.data = Utils.toArray('b'.repeat(64), 'utf8') + const result = await manager.identifyAdmissibleOutputs( + txWith(new LockingScript(chunks)).toBEEF(), + [] + ) + expect(result.outputsToAdmit).toEqual([]) + }) + + it('refuses an anchor locked under a key id that is not its attestation id', async () => { + const wrong = await anchorScript(claim(), serviceWallet, 'some-other-id') + expect(() => readUoraAnchor(wrong)).toThrow(/not derived from the anchoring service/) + }) + + it('admits every anchor in a transaction, so anchors may be batched', async () => { + const beef = txWith( + await anchorScript(claim({ attestationId: `${CELL}/a`, digest: '1'.repeat(64) })), + await anchorScript(claim({ attestationId: `${CELL}/b`, digest: '2'.repeat(64) })), + await anchorScript(claim({ attestationId: `${CELL}/c`, digest: '3'.repeat(64) })) + ).toBEEF() + expect(await manager.identifyAdmissibleOutputs(beef, [])).toEqual({ + outputsToAdmit: [0, 1, 2], + coinsToRetain: [] + }) + }) + + it('picks anchors out from among ordinary outputs, and never retains', async () => { + const beef = txWith( + p2pkhOutput(), + await anchorScript(), + p2pkhOutput(), + await anchorScript(claim({ attestationId: `${CELL}/two`, digest: '4'.repeat(64) })) + ).toBEEF() + const result = await manager.identifyAdmissibleOutputs(beef, [7]) + expect(result.outputsToAdmit).toEqual([1, 3]) + // Anchors are leaves: nothing is ever retained, whatever came in. + expect(result.coinsToRetain).toEqual([]) + }) + + it('admits nothing from bytes that are not a transaction', async () => { + expect(await manager.identifyAdmissibleOutputs([1, 2, 3], [])).toEqual({ + outputsToAdmit: [], + coinsToRetain: [] + }) + }) + + it('does not reuse a fields array, because PushDrop.lock pushes into it', async () => { + const fields = fieldsFor(claim()) + const before = fields.length + await new PushDrop(serviceWallet).lock( + fields, + UORA_ANCHOR_PROTOCOL, + `${CELL}/state-1`, + 'anyone', + true + ) + expect(fields.length).toBe(before + 1) + }) + + it('describes itself', async () => { + expect(await manager.getMetaData()).toMatchObject({ name: 'UORA DPP Topic Manager' }) + const docs = await manager.getDocumentation() + expect(docs).toContain(UORA_ANCHOR_PREFIX) + expect(docs).toContain('did:key') + }) +}) + +// --------------------------------------------------------------------------- +// UoraDppLookupService +// --------------------------------------------------------------------------- + +describe('UoraDppLookupService', () => { + let mongo: MongoMemoryServer + let client: MongoClient + let db: Db + let service: UoraDppLookupService + + beforeAll(async () => { + mongo = await MongoMemoryServer.create(mongoMemoryServerOptions) + client = new MongoClient(mongo.getUri()) + await client.connect() + db = client.db('uoradpp_test') + }, 90000) + + afterAll(async () => { + await client.close() + await mongo.stop() + }) + + beforeEach(async () => { + await db.collection('uoraDppAnchors').deleteMany({}) + service = createUoraDppLookupService(db) + await admit(claim({ attestationId: 'att-1', issuer: MAKER, subject: CELL }), 'a'.repeat(64)) + await admit(claim({ attestationId: 'att-2', issuer: MAKER, subject: JACKET }), 'b'.repeat(64)) + await admit( + claim({ attestationId: 'att-3', issuer: RECYCLER, subject: CELL, uoraType: 'Disposition' }), + 'c'.repeat(64) + ) + }) + + async function admit(one: Claim, txid: string): Promise { + const payload: OutputAdmittedByTopic = { + mode: 'locking-script', + topic: 'tm_uora_dpp', + txid, + outputIndex: 0, + satoshis: 1, + lockingScript: await anchorScript(one) + } + await service.outputAdmittedByTopic(payload) + } + + async function ask(query: unknown): Promise { + const formula = await service.lookup({ service: 'ls_uora_dpp', query } as LookupQuestion) + return (formula as Array<{ txid: string }>).map(entry => entry.txid) + } + + it('answers what one party has attested, across subjects', async () => { + const answers = await ask({ issuer: MAKER }) + expect(answers.sort()).toEqual(['a'.repeat(64), 'b'.repeat(64)]) + }) + + it('answers the same question keyed on the raw identity key', async () => { + const key = PrivateKey.fromHex('89'.repeat(32)).toPublicKey().toString() + expect(await ask({ issuerKey: key })).toEqual(await ask({ issuer: RECYCLER })) + }) + + it('finds every claim about one product, whoever made it', async () => { + expect((await ask({ subject: CELL })).sort()).toEqual(['a'.repeat(64), 'c'.repeat(64)]) + }) + + it('finds one attestation, and answers a digest held in hand', async () => { + expect(await ask({ attestationId: 'att-2' })).toEqual(['b'.repeat(64)]) + expect((await ask({ digest: 'a'.repeat(64) })).length).toBe(3) + }) + + it('narrows by type and by anchoring service, and refuses to select on either', async () => { + expect(await ask({ subject: CELL, uoraType: 'Disposition' })).toEqual(['c'.repeat(64)]) + expect((await ask({ issuer: MAKER, anchoredBy: SERVICE_KEY })).length).toBe(2) + await expect( + service.lookup({ service: 'ls_uora_dpp', query: { uoraType: 'Origin' } } as LookupQuestion) + ).rejects.toThrow(/issuer, issuerKey, subject, attestationId or digest/) + }) + + it('refuses an empty query and a foreign service', async () => { + await expect( + service.lookup({ service: 'ls_uora_dpp', query: {} } as LookupQuestion) + ).rejects.toThrow() + await expect( + service.lookup({ service: 'ls_other', query: { issuer: MAKER } } as LookupQuestion) + ).rejects.toThrow(/not supported/) + }) + + it('pages, and caps what a caller can ask for', async () => { + expect((await ask({ issuer: MAKER, limit: 1 })).length).toBe(1) + expect((await ask({ issuer: MAKER, skip: 1 })).length).toBe(1) + await expect( + service.lookup({ + service: 'ls_uora_dpp', + query: { issuer: MAKER, limit: -1 } + } as LookupQuestion) + ).rejects.toThrow(/non-negative/) + }) + + it('indexes nothing from a topic it does not serve', async () => { + const other = createUoraDppLookupService(db) + await other.outputAdmittedByTopic({ + mode: 'locking-script', + topic: 'tm_supplychain', + txid: 'f'.repeat(64), + outputIndex: 0, + satoshis: 1, + lockingScript: await anchorScript() + }) + expect(await ask({ attestationId: `${CELL}/state-1` })).toEqual([]) + }) + + it('drops an evicted output and keeps a spent one', async () => { + // An anchor should never be spent, and if one is, the digest still sat at + // that point in the chain's order, so the record stays. + await service.outputSpent({ + mode: 'txid', + topic: 'tm_uora_dpp', + txid: 'a'.repeat(64), + outputIndex: 0, + spendingTxid: 'd'.repeat(64) + }) + expect((await ask({ issuer: MAKER })).length).toBe(2) + + await service.outputEvicted('a'.repeat(64), 0) + expect(await ask({ issuer: MAKER })).toEqual(['b'.repeat(64)]) + }) + + it('stores one record per outpoint however often it arrives', async () => { + await admit(claim({ attestationId: 'att-1', issuer: MAKER, subject: CELL }), 'a'.repeat(64)) + expect((await ask({ issuer: MAKER })).length).toBe(2) + }) + + it('describes itself', async () => { + expect(await service.getMetaData()).toMatchObject({ name: 'UORA DPP Lookup Service' }) + expect(await service.getDocumentation()).toContain('did:key') + }) +}) diff --git a/packages/overlays/topics/src/index.ts b/packages/overlays/topics/src/index.ts index adf002b12..ad955586d 100644 --- a/packages/overlays/topics/src/index.ts +++ b/packages/overlays/topics/src/index.ts @@ -83,6 +83,20 @@ export type { SupplyChainRecord } from './supplychain/types.js' export { default as SupplyChainTopicManager } from './supplychain/SupplyChainTopicManager.js' export { default as createSupplyChainLookupService } from './supplychain/SupplyChainLookupService.js' +// uoradpp +export type { UoraDppRecord, UoraDppQuery } from './uoradpp/types.js' +export { + UORA_ANCHOR_PREFIX, + UORA_ANCHOR_PROTOCOL, + didKeyFromIdentityKey, + expectedLockingKey, + identityKeyFromDidKey, + readUoraAnchor +} from './uoradpp/anchorFormat.js' +export type { UoraAnchor } from './uoradpp/anchorFormat.js' +export { default as UoraDppTopicManager } from './uoradpp/UoraDppTopicManager.js' +export { default as createUoraDppLookupService } from './uoradpp/UoraDppLookupService.js' + // uhrp export type { UHRPRecord } from './uhrp/types.js' export { default as UHRPTopicManager } from './uhrp/UHRPTopicManager.js' diff --git a/packages/overlays/topics/src/uoradpp/UoraDppLookupService.ts b/packages/overlays/topics/src/uoradpp/UoraDppLookupService.ts new file mode 100644 index 000000000..f6afce4b3 --- /dev/null +++ b/packages/overlays/topics/src/uoradpp/UoraDppLookupService.ts @@ -0,0 +1,143 @@ +import { + AdmissionMode, + LookupFormula, + LookupQuestion, + LookupService, + OutputAdmittedByTopic, + OutputSpent, + SpendNotificationMode +} from '@bsv/overlay' +import { Db } from 'mongodb' +import { readUoraAnchor } from './anchorFormat.js' +import { UoraDppQuery } from './types.js' +import { UoraDppStorage } from './UoraDppStorage.js' + +const TOPIC = 'tm_uora_dpp' +const SERVICE = 'ls_uora_dpp' + +/** + * `ls_uora_dpp`: attestation anchors, keyed on the party that made the claim. + * + * The question this exists for is "what has this party attested", asked with a + * `did:key` and nothing else. Four other selectors come free from the same + * fields: the subject (every claim about one product, from every party), the + * attestation id, the digest (given an attestation in hand, has anyone anchored + * exactly this), and the anchoring service. + * + * ## What the answer is + * + * Outputs, as BRC-24 requires, so a caller receives the anchors as chain data + * and checks them without trusting this index. That matters more here than for + * most topics: this index is derived entirely from the outputs it returns, so + * an index that lied would be caught by the caller reading the same outputs. + * + * The answer is **not** the attestations. Those are never on chain, so a caller + * who wants a claim itself fetches it from the issuing registry and checks its + * canonical digest against the anchor. The anchor is the proof; the registry is + * merely convenient. + */ +export class UoraDppLookupService implements LookupService { + readonly admissionMode: AdmissionMode = 'locking-script' + readonly spendNotificationMode: SpendNotificationMode = 'txid' + + constructor(public storage: UoraDppStorage) {} + + async outputAdmittedByTopic(payload: OutputAdmittedByTopic): Promise { + if (payload.mode !== 'locking-script') throw new Error('Invalid mode') + const { topic, txid, outputIndex, lockingScript } = payload + if (topic !== TOPIC) return + try { + const { anchor } = readUoraAnchor(lockingScript) + await this.storage.storeRecord({ txid, outputIndex, ...anchor }) + } catch (error) { + // Admission already validated this output, so a failure here means the + // topic manager and this reader disagree. Indexing half an anchor would + // be worse than indexing none. + console.error(`UoraDppLookupService: failed to index ${txid}.${outputIndex}`, error) + } + } + + /** + * An anchor is a leaf and should never be spent. If one is, the claim it + * carries is unaffected: the digest sat at that point in the chain's order + * whatever later became of the satoshi. So nothing is recorded here. + */ + async outputSpent(payload: OutputSpent): Promise { + if (payload.mode !== 'txid') throw new Error('Invalid mode') + } + + async outputNoLongerRetainedInHistory( + txid: string, + outputIndex: number, + topic: string + ): Promise { + if (topic !== TOPIC) return + await this.storage.deleteRecord(txid, outputIndex) + } + + async outputEvicted(txid: string, outputIndex: number): Promise { + await this.storage.deleteRecord(txid, outputIndex) + } + + async lookup(question: LookupQuestion): Promise { + if (question === undefined || question === null) throw new Error('A valid query is required!') + if (question.service !== SERVICE) throw new Error('Lookup service not supported!') + + const query = (question.query ?? {}) as UoraDppQuery + if (query.limit !== undefined && query.limit < 0) { + throw new Error('Limit must be a non-negative number') + } + if (query.skip !== undefined && query.skip < 0) { + throw new Error('Skip must be a non-negative number') + } + + // `uoraType` and `anchoredBy` narrow but cannot select: either alone is + // every anchor of a common type, which is a table scan wearing a query. + const selective = + query.issuer !== undefined || + query.issuerKey !== undefined || + query.subject !== undefined || + query.attestationId !== undefined || + query.digest !== undefined + if (!selective) { + throw new Error('Query must provide issuer, issuerKey, subject, attestationId or digest') + } + + return await this.storage.find(query) + } + + async getDocumentation(): Promise { + return [ + 'UORA DPP Lookup Service: attestation anchors, keyed on the issuing party.', + '', + 'Query with at least one of issuer (a did:key), issuerKey (the same key as', + 'hex), subject (a product passport id), attestationId or digest. uoraType', + 'and anchoredBy narrow any of those and cannot select on their own. All', + 'are exact matches; limit and skip page the answer and limit is capped.', + '', + 'Answers are anchor outputs, so a caller verifies them against the chain', + 'rather than trusting this index. The attestations themselves are never on', + 'chain: fetch one from the issuing registry and check its canonical digest', + "against the anchor's." + ].join('\n') + } + + async getMetaData(): Promise<{ + name: string + shortDescription: string + iconURL?: string + version?: string + informationURL?: string + }> { + return { + name: 'UORA DPP Lookup Service', + shortDescription: 'Find attestation anchors by the party that made the claim', + version: '1.0.0' + } + } +} + +function create(db: Db): UoraDppLookupService { + return new UoraDppLookupService(new UoraDppStorage(db)) +} +export default create diff --git a/packages/overlays/topics/src/uoradpp/UoraDppStorage.ts b/packages/overlays/topics/src/uoradpp/UoraDppStorage.ts new file mode 100644 index 000000000..c252f833c --- /dev/null +++ b/packages/overlays/topics/src/uoradpp/UoraDppStorage.ts @@ -0,0 +1,88 @@ +import { Collection, Db, Filter } from 'mongodb' +import { UoraDppQuery, UoraDppRecord } from './types.js' +import { UTXOReference } from '../any/types.js' + +/** Answers stay bounded whatever a caller asks for. */ +export const MAX_UORA_RESULTS = 500 + +const SELECTABLE = [ + 'issuer', + 'issuerKey', + 'subject', + 'attestationId', + 'digest', + 'anchoredBy', + 'uoraType' +] as const + +export class UoraDppStorage { + private readonly records: Collection + private indexInit?: Promise + + constructor(private readonly db: Db) { + this.records = db.collection('uoraDppAnchors') + } + + private async ensureIndexes(): Promise { + if (this.indexInit === undefined) { + this.indexInit = (async () => { + await this.records.createIndex({ issuer: 1, createdAt: 1 }, { name: 'issuerIndex' }) + await this.records.createIndex({ subject: 1, createdAt: 1 }, { name: 'subjectIndex' }) + await this.records.createIndex({ attestationId: 1 }, { name: 'attestationIdIndex' }) + await this.records.createIndex({ digest: 1 }, { name: 'digestIndex' }) + await this.records.createIndex({ anchoredBy: 1, createdAt: 1 }, { name: 'anchoredByIndex' }) + await this.records.createIndex( + { txid: 1, outputIndex: 1 }, + { name: 'outpointIndex', unique: true } + ) + })() + } + return await this.indexInit + } + + /** + * Upsert rather than insert. An overlay may be handed the same transaction + * twice, and an anchor is immutable, so the second arrival is the same fact + * rather than a second one. + */ + async storeRecord(record: Omit): Promise { + await this.ensureIndexes() + await this.records.updateOne( + { txid: record.txid, outputIndex: record.outputIndex }, + { $set: { ...record, createdAt: new Date() } }, + { upsert: true } + ) + } + + async deleteRecord(txid: string, outputIndex: number): Promise { + await this.ensureIndexes() + await this.records.deleteOne({ txid, outputIndex }) + } + + /** + * Exact matches only, and at least one selector is required by the lookup + * service before this is called. + */ + async find(query: UoraDppQuery): Promise { + await this.ensureIndexes() + const filter: Record = {} + for (const key of SELECTABLE) { + const value = query[key] + if (typeof value === 'string' && value !== '') filter[key] = value + } + const limit = bounded(query.limit) + const skip = query.skip !== undefined && query.skip > 0 ? Math.floor(query.skip) : 0 + return await this.records + .find(filter as Filter) + .sort({ createdAt: 1 }) + .skip(skip) + .limit(limit) + .project({ txid: 1, outputIndex: 1 }) + .toArray() + } +} + +function bounded(limit: number | undefined): number { + if (limit === undefined || !Number.isFinite(limit) || limit <= 0) return MAX_UORA_RESULTS + return Math.min(Math.floor(limit), MAX_UORA_RESULTS) +} diff --git a/packages/overlays/topics/src/uoradpp/UoraDppTopicManager.ts b/packages/overlays/topics/src/uoradpp/UoraDppTopicManager.ts new file mode 100644 index 000000000..40936e504 --- /dev/null +++ b/packages/overlays/topics/src/uoradpp/UoraDppTopicManager.ts @@ -0,0 +1,108 @@ +import { AdmittanceInstructions, TopicManager } from '@bsv/overlay' +import { LockingScript } from '@bsv/sdk' +import { identifyPushDropOutputs } from '../shared/identifyPushDropOutputs.js' +import { assertAnchorSignature, readUoraAnchor, UORA_ANCHOR_PREFIX } from './anchorFormat.js' + +/** + * `tm_uora_dpp`: admission for UORA attestation anchors. + * + * A UORA attestation is a claim one party makes about one product. The claim + * itself is never on chain; the anchor is its digest, plus the issuer, the + * subject, the type and the anchoring service, in the clear so an index can be + * keyed on them. + * + * ## What admission proves, and what it does not + * + * **Proved.** The seven fields were sealed together by the key locking the + * output, and that key is the BRC-42 child of the anchoring service named in + * field 6 under counterparty `anyone`. Producing an output that satisfies both + * needs that service's private key, so every admitted anchor names its author + * checkably, by anyone holding the transaction, with nothing configured. + * + * **Not proved.** That the party in field 3 made the claim. That is a `did:key` + * copied onto the chain as given, and anyone able to write an anchor can write + * any DID into it. What a claim is worth is settled by the attestation's own + * signature, which is off chain by design. This topic is a finding aid, and its + * value is that what it finds can be checked without it. + * + * ## Why an instance need not be configured + * + * Field 6 carries the anchoring service rather than the reader being told which + * services to expect. A shared node serving several deployments would otherwise + * need amending whenever one was added, and a reader holding only the + * transaction could not attribute it at all. `anchorServiceKeys` narrows what + * this instance carries and is a preference about what to index, not a + * boundary: every anchor it admits says whose it is either way. + * + * ## Anchors are leaves + * + * Never spent, no predecessor, no transition rules, nothing retained, so + * `previousCoins` is not consulted. Every valid anchor in a transaction is + * admitted rather than exactly one, which leaves a service free to batch a + * fleet's worth into a single transaction without this topic changing. + */ +export default class UoraDppTopicManager implements TopicManager { + private readonly accepted: readonly string[] + + constructor(anchorServiceKeys: readonly string[] = []) { + this.accepted = anchorServiceKeys.filter(key => key !== '') + } + + async identifyAdmissibleOutputs( + beef: number[], + previousCoins: number[] + ): Promise { + return await identifyPushDropOutputs({ + beef, + previousCoins, + validateOutput: async (lockingScript: LockingScript) => { + const { anchor, fields } = readUoraAnchor(lockingScript) + await assertAnchorSignature(fields, anchor.anchoredBy, anchor.attestationId) + if (this.accepted.length > 0 && !this.accepted.includes(anchor.anchoredBy)) { + throw new Error('this instance does not carry anchors from that service') + } + }, + onRejectedOutput: (outputIndex, error) => { + console.debug(`[UoraDppTopicManager] Skipping output ${outputIndex}: ${String(error)}`) + } + }) + } + + async getDocumentation(): Promise { + return [ + `UORA DPP Topic Manager: attestation anchors in the ${UORA_ANCHOR_PREFIX} format.`, + '', + 'A 1-satoshi PushDrop output carrying, in order: the version prefix, the', + 'SHA-256 digest of the attestation in lower-case hex, the attestation id,', + "the issuer's did:key, the subject passport id, the UORA attestation type,", + "and the anchoring service's identity key. PushDrop appends a signature", + 'over those seven by the key that locks the output.', + '', + 'Admitted when all seven parse, the issuer resolves to a compressed', + 'secp256k1 key, the signature checks out, and the locking key is the', + "BRC-42 child of field 6 at protocol [1, 'uora anchor v2'], key id the", + "attestation id, counterparty 'anyone'. That derivation is reproducible by", + 'anyone holding the output, so every admitted anchor names its author.', + '', + 'The issuer in field 3 is carried, not proved: whether that party made the', + "claim is settled by the attestation's own signature, which is off chain.", + '', + 'Anchors are leaves: never spent, nothing retained. Every valid anchor in a', + 'transaction is admitted, so anchors may be batched.' + ].join('\n') + } + + async getMetaData(): Promise<{ + name: string + shortDescription: string + iconURL?: string + version?: string + informationURL?: string + }> { + return { + name: 'UORA DPP Topic Manager', + shortDescription: 'Attestation anchors for digital product passports', + version: '1.0.0' + } + } +} diff --git a/packages/overlays/topics/src/uoradpp/anchorFormat.ts b/packages/overlays/topics/src/uoradpp/anchorFormat.ts new file mode 100644 index 000000000..e86a70f13 --- /dev/null +++ b/packages/overlays/topics/src/uoradpp/anchorFormat.ts @@ -0,0 +1,208 @@ +import { CachedKeyDeriver, LockingScript, ProtoWallet, PublicKey, PushDrop, Utils } from '@bsv/sdk' +import type { WalletProtocol } from '@bsv/sdk' + +/** + * Reading and validating a `uora-anchor-v2` output. + * + * The format is specified independently of this implementation; this file is a + * reader for it, written so the topic manager and the lookup service cannot + * disagree about what an anchor is. + */ + +/** Marks the output as this format and versions the field layout. */ +export const UORA_ANCHOR_PREFIX = 'uora-anchor-v2' + +/** + * The BRC-42 child the output is locked under, with the attestation id as key + * id and counterparty `anyone`. + * + * Counterparty `anyone` is the whole design. The shared secret is ECDH(1, P), + * so a third party holding only the anchoring service's public identity key can + * reproduce the locking key, and producing an output that matches needs the + * private half. That is what makes an anchor attributable to a named service by + * anybody, rather than only by the service itself. + * + * The key id being the attestation id rather than a constant means every anchor + * locks to a different key, so anchors by one service are not linkable to each + * other by inspection, while each remains linkable to that service by anyone + * who asks. That trade is deliberate. + */ +export const UORA_ANCHOR_PROTOCOL: WalletProtocol = [1, 'uora anchor v2'] + +/** Fields before the signature `PushDrop.lock` appends. */ +export const UORA_ANCHOR_FIELD_COUNT = 7 + +/** + * Bounds on the free-text fields. An overlay admits from whoever can reach it, + * and an index keyed on a field a stranger controls is a field a stranger can + * make expensive. Generous enough that no honest value approaches them: a GS1 + * Digital Link with a long serial is under 200 characters. + */ +const MAX_ATTESTATION_ID = 256 +const MAX_SUBJECT = 512 +const MAX_TYPE = 64 + +const HEX_64 = /^[0-9a-f]{64}$/ +const COMPRESSED_KEY = /^0[23][0-9a-f]{64}$/ + +/** Multicodec prefix for a compressed secp256k1 public key: varint 0xe7. */ +const SECP256K1_PUB_MULTICODEC = [0xe7, 0x01] +const DID_KEY_PREFIX = 'did:key:z' + +const anyone = new CachedKeyDeriver('anyone') + +/** One anchor, fully read and fully checked. */ +export interface UoraAnchor { + digest: string + attestationId: string + issuer: string + issuerKey: string + subject: string + uoraType: string + anchoredBy: string +} + +/** + * The compressed secp256k1 key inside a `did:key`, or undefined. + * + * Refuses another curve rather than returning bytes that would fail later: an + * Ed25519 `did:key` is well formed and a meaningless secp256k1 key, and the + * difference is two bytes at the front. Also refuses a non-canonical encoding, + * because the SDK reduces `x >= p` rather than rejecting it and would otherwise + * hand back a key that indexes under a DID nobody else computes. + */ +export function identityKeyFromDidKey(did: string): string | undefined { + if (!did.startsWith(DID_KEY_PREFIX)) return undefined + let bytes: number[] + try { + bytes = Utils.fromBase58(did.slice(DID_KEY_PREFIX.length)) + } catch { + return undefined + } + if (bytes[0] !== SECP256K1_PUB_MULTICODEC[0] || bytes[1] !== SECP256K1_PUB_MULTICODEC[1]) { + return undefined + } + const key = bytes.slice(2) + if (key.length !== 33) return undefined + const hex = Utils.toHex(key) + return canonicalKey(hex) ? hex : undefined +} + +/** The inverse, so a writer and a reader cannot drift on the encoding. */ +export function didKeyFromIdentityKey(identityKeyHex: string): string { + if (!canonicalKey(identityKeyHex)) throw new Error('not a canonical compressed public key') + const key = PublicKey.fromString(identityKeyHex) + return `${DID_KEY_PREFIX}${Utils.toBase58([ + ...SECP256K1_PUB_MULTICODEC, + ...(key.encode(true) as number[]) + ])}` +} + +function canonicalKey(hex: string): boolean { + if (!COMPRESSED_KEY.test(hex)) return false + try { + return PublicKey.fromString(hex).toString() === hex + } catch { + return false + } +} + +/** The key an anchor for this attestation id must lock to, given the service. */ +export function expectedLockingKey(anchorServiceKey: string, attestationId: string): string { + return anyone.derivePublicKey(UORA_ANCHOR_PROTOCOL, attestationId, anchorServiceKey).toString() +} + +/** UTF-8 that round-trips, so a field that is not text is not read as text. */ +function text(bytes: number[]): string | undefined { + let decoded: string + try { + decoded = Utils.toUTF8(bytes) + } catch { + return undefined + } + if (decoded === '') return undefined + return Utils.toHex(Utils.toArray(decoded, 'utf8')) === Utils.toHex(bytes) ? decoded : undefined +} + +/** + * Read and validate one output, or throw. + * + * Throwing rather than returning undefined so it composes with + * `identifyPushDropOutputs`, whose `validateOutput` reports the reason it + * rejected each output. Every message names what was wrong, because an operator + * reading a log wants to know whether a submission was malformed or simply not + * this format. + * + * `PushDrop.decode` is safe here specifically because no field may be empty: + * the decoder renders an empty push as a single zero byte, which would make the + * signature preimage one byte too long, and this format rejects empty fields + * before that can matter. + */ +export function readUoraAnchor(lockingScript: LockingScript): { + anchor: UoraAnchor + fields: number[][] + lockingPublicKey: PublicKey +} { + const { fields, lockingPublicKey } = PushDrop.decode(lockingScript) + if (fields.length !== UORA_ANCHOR_FIELD_COUNT + 1) { + throw new Error( + `expected ${UORA_ANCHOR_FIELD_COUNT} fields and a signature, found ${fields.length}` + ) + } + + const parts = fields.slice(0, UORA_ANCHOR_FIELD_COUNT).map(field => text(field)) + if (parts.some(part => part === undefined)) throw new Error('a field is empty or not UTF-8') + const [prefix, digest, attestationId, issuer, subject, uoraType, anchoredBy] = parts as string[] + + if (prefix !== UORA_ANCHOR_PREFIX) throw new Error(`not an anchor output (prefix "${prefix}")`) + if (!HEX_64.test(digest)) throw new Error('the digest is not 64 lower-case hex characters') + if (attestationId.length > MAX_ATTESTATION_ID) throw new Error('the attestation id is too long') + if (subject.length > MAX_SUBJECT) throw new Error('the subject is too long') + if (uoraType.length > MAX_TYPE) throw new Error('the attestation type is too long') + + const issuerKey = identityKeyFromDidKey(issuer) + if (issuerKey === undefined) throw new Error('the issuer is not a secp256k1 did:key') + + if (!canonicalKey(anchoredBy)) { + throw new Error('the anchoring service is not a canonical compressed key') + } + /* + * The attribution check, and it is part of being well formed rather than a + * policy on top of it. An output naming an anchoring service its locking key + * cannot derive from is claiming something untrue about itself. Producing one + * that passes needs that service's private key, which is the entire proof. + */ + if (expectedLockingKey(anchoredBy, attestationId) !== lockingPublicKey.toString()) { + throw new Error('the locking key is not derived from the anchoring service named in field 6') + } + + return { + anchor: { digest, attestationId, issuer, issuerKey, subject, uoraType, anchoredBy }, + fields, + lockingPublicKey + } +} + +/** + * The appended signature, checked against the service the output names. + * + * Separate from `readUoraAnchor` because it is the only asynchronous step and + * the only one the lookup service does not need: by the time an output is being + * indexed the topic manager has already checked it. + */ +export async function assertAnchorSignature( + fields: number[][], + anchoredBy: string, + attestationId: string +): Promise { + const signature = fields.at(-1) + if (signature === undefined) throw new Error('the anchor carries no signature') + const { valid } = await new ProtoWallet('anyone').verifySignature({ + data: fields.slice(0, -1).flat(), + signature, + counterparty: anchoredBy, + protocolID: UORA_ANCHOR_PROTOCOL, + keyID: attestationId + }) + if (!valid) throw new Error('the anchor fields were not signed by the anchoring service') +} diff --git a/packages/overlays/topics/src/uoradpp/types.ts b/packages/overlays/topics/src/uoradpp/types.ts new file mode 100644 index 000000000..f894812bf --- /dev/null +++ b/packages/overlays/topics/src/uoradpp/types.ts @@ -0,0 +1,58 @@ +/** + * `uora-anchor-v2`: an on-chain anchor for a UORA attestation. + * + * A UORA attestation is a claim one party makes about one product: who made it, + * who it passed to, what happened to it. The claims themselves never go on + * chain, both because they can carry personal data and because a digest is 32 + * bytes whatever the claim's size, which is what keeps per-event anchoring + * affordable at fleet scale. What goes on chain is the digest, plus enough in + * the clear for an index to be keyed on it. + * + * See `UoraDppTopicManager` for the admission rules and what they prove. + */ + +/** One admitted anchor, flattened for the queries `ls_uora_dpp` answers. */ +export interface UoraDppRecord { + txid: string + outputIndex: number + /** Lower-case hex SHA-256 of the attestation's canonical form (RFC 8785). */ + digest: string + /** The issuing registry's identifier for the attestation this digest covers. */ + attestationId: string + /** The party that made the claim, as a `did:key`. Carried, not proved. */ + issuer: string + /** The same key as hex, so a caller holding a chain key need not encode one. */ + issuerKey: string + /** What the claim is about: a product passport identifier. */ + subject: string + /** The UORA attestation type, verbatim and unvalidated against any list. */ + uoraType: string + /** The anchoring service that wrote the output. Proved, not merely carried. */ + anchoredBy: string + createdAt: Date +} + +/** + * What a caller may select on. + * + * Every field is an exact match. There is deliberately no prefix or regex + * search: an unanchored pattern over an index whose contents anyone can write + * is the cheapest denial of service an overlay offers. + */ +export interface UoraDppQuery { + /** The claiming party's `did:key`. The question this topic exists for. */ + issuer?: string + /** The same party as a compressed secp256k1 key in hex. */ + issuerKey?: string + /** A product passport identifier: every claim about it, from every party. */ + subject?: string + attestationId?: string + /** Given an attestation in hand, has anyone anchored exactly this. */ + digest?: string + /** The anchoring service. Narrows; cannot select on its own. */ + anchoredBy?: string + /** The UORA attestation type. Narrows; cannot select on its own. */ + uoraType?: string + limit?: number + skip?: number +} From 41d8be7a7c7fdee262c5c7785cd7611a3b4b448d Mon Sep 17 00:00:00 2001 From: Mohammad Jaber <49678859+mo-jaber@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:06:36 +0400 Subject: [PATCH 2/8] style(overlay-topics): clear the nine Sonar findings on tm_uora_dpp The repository quality gate requires zero new Sonar findings on the exact head, which is stricter than SonarCloud's own aggregate verdict. - S5906 x7: use toHaveLength rather than asserting on .length - S6606: use ??= for the lazy index promise - S7765: use includes() rather than some() for a value check Also folds one line in the test file that Prettier wanted on one line. No behaviour changes. --- .../topics/src/__tests__/uoradpp.test.ts | 18 +++++++------- .../topics/src/uoradpp/UoraDppStorage.ts | 24 +++++++++---------- .../topics/src/uoradpp/anchorFormat.ts | 2 +- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/packages/overlays/topics/src/__tests__/uoradpp.test.ts b/packages/overlays/topics/src/__tests__/uoradpp.test.ts index 41dadd74e..8ba489b98 100644 --- a/packages/overlays/topics/src/__tests__/uoradpp.test.ts +++ b/packages/overlays/topics/src/__tests__/uoradpp.test.ts @@ -165,9 +165,7 @@ describe('UoraDppTopicManager', () => { it('reproduces the locking key from the service key the output names', async () => { const { anchor, lockingPublicKey } = readUoraAnchor(await anchorScript()) expect(anchor.anchoredBy).toBe(SERVICE_KEY) - expect(lockingPublicKey.toString()).toBe( - expectedLockingKey(SERVICE_KEY, `${CELL}/state-1`) - ) + expect(lockingPublicKey.toString()).toBe(expectedLockingKey(SERVICE_KEY, `${CELL}/state-1`)) }) it('refuses an anchor naming a service its locking key cannot come from', async () => { @@ -275,7 +273,7 @@ describe('UoraDppTopicManager', () => { 'anyone', true ) - expect(fields.length).toBe(before + 1) + expect(fields).toHaveLength(before + 1) }) it('describes itself', async () => { @@ -352,12 +350,12 @@ describe('UoraDppLookupService', () => { it('finds one attestation, and answers a digest held in hand', async () => { expect(await ask({ attestationId: 'att-2' })).toEqual(['b'.repeat(64)]) - expect((await ask({ digest: 'a'.repeat(64) })).length).toBe(3) + expect(await ask({ digest: 'a'.repeat(64) })).toHaveLength(3) }) it('narrows by type and by anchoring service, and refuses to select on either', async () => { expect(await ask({ subject: CELL, uoraType: 'Disposition' })).toEqual(['c'.repeat(64)]) - expect((await ask({ issuer: MAKER, anchoredBy: SERVICE_KEY })).length).toBe(2) + expect(await ask({ issuer: MAKER, anchoredBy: SERVICE_KEY })).toHaveLength(2) await expect( service.lookup({ service: 'ls_uora_dpp', query: { uoraType: 'Origin' } } as LookupQuestion) ).rejects.toThrow(/issuer, issuerKey, subject, attestationId or digest/) @@ -373,8 +371,8 @@ describe('UoraDppLookupService', () => { }) it('pages, and caps what a caller can ask for', async () => { - expect((await ask({ issuer: MAKER, limit: 1 })).length).toBe(1) - expect((await ask({ issuer: MAKER, skip: 1 })).length).toBe(1) + expect(await ask({ issuer: MAKER, limit: 1 })).toHaveLength(1) + expect(await ask({ issuer: MAKER, skip: 1 })).toHaveLength(1) await expect( service.lookup({ service: 'ls_uora_dpp', @@ -406,7 +404,7 @@ describe('UoraDppLookupService', () => { outputIndex: 0, spendingTxid: 'd'.repeat(64) }) - expect((await ask({ issuer: MAKER })).length).toBe(2) + expect(await ask({ issuer: MAKER })).toHaveLength(2) await service.outputEvicted('a'.repeat(64), 0) expect(await ask({ issuer: MAKER })).toEqual(['b'.repeat(64)]) @@ -414,7 +412,7 @@ describe('UoraDppLookupService', () => { it('stores one record per outpoint however often it arrives', async () => { await admit(claim({ attestationId: 'att-1', issuer: MAKER, subject: CELL }), 'a'.repeat(64)) - expect((await ask({ issuer: MAKER })).length).toBe(2) + expect(await ask({ issuer: MAKER })).toHaveLength(2) }) it('describes itself', async () => { diff --git a/packages/overlays/topics/src/uoradpp/UoraDppStorage.ts b/packages/overlays/topics/src/uoradpp/UoraDppStorage.ts index c252f833c..e4988388e 100644 --- a/packages/overlays/topics/src/uoradpp/UoraDppStorage.ts +++ b/packages/overlays/topics/src/uoradpp/UoraDppStorage.ts @@ -24,19 +24,17 @@ export class UoraDppStorage { } private async ensureIndexes(): Promise { - if (this.indexInit === undefined) { - this.indexInit = (async () => { - await this.records.createIndex({ issuer: 1, createdAt: 1 }, { name: 'issuerIndex' }) - await this.records.createIndex({ subject: 1, createdAt: 1 }, { name: 'subjectIndex' }) - await this.records.createIndex({ attestationId: 1 }, { name: 'attestationIdIndex' }) - await this.records.createIndex({ digest: 1 }, { name: 'digestIndex' }) - await this.records.createIndex({ anchoredBy: 1, createdAt: 1 }, { name: 'anchoredByIndex' }) - await this.records.createIndex( - { txid: 1, outputIndex: 1 }, - { name: 'outpointIndex', unique: true } - ) - })() - } + this.indexInit ??= (async () => { + await this.records.createIndex({ issuer: 1, createdAt: 1 }, { name: 'issuerIndex' }) + await this.records.createIndex({ subject: 1, createdAt: 1 }, { name: 'subjectIndex' }) + await this.records.createIndex({ attestationId: 1 }, { name: 'attestationIdIndex' }) + await this.records.createIndex({ digest: 1 }, { name: 'digestIndex' }) + await this.records.createIndex({ anchoredBy: 1, createdAt: 1 }, { name: 'anchoredByIndex' }) + await this.records.createIndex( + { txid: 1, outputIndex: 1 }, + { name: 'outpointIndex', unique: true } + ) + })() return await this.indexInit } diff --git a/packages/overlays/topics/src/uoradpp/anchorFormat.ts b/packages/overlays/topics/src/uoradpp/anchorFormat.ts index e86a70f13..7013b02b0 100644 --- a/packages/overlays/topics/src/uoradpp/anchorFormat.ts +++ b/packages/overlays/topics/src/uoradpp/anchorFormat.ts @@ -151,7 +151,7 @@ export function readUoraAnchor(lockingScript: LockingScript): { } const parts = fields.slice(0, UORA_ANCHOR_FIELD_COUNT).map(field => text(field)) - if (parts.some(part => part === undefined)) throw new Error('a field is empty or not UTF-8') + if (parts.includes(undefined)) throw new Error('a field is empty or not UTF-8') const [prefix, digest, attestationId, issuer, subject, uoraType, anchoredBy] = parts as string[] if (prefix !== UORA_ANCHOR_PREFIX) throw new Error(`not an anchor output (prefix "${prefix}")`) From d1f0d2179c1b5f32e147d870d3bea720c2904ce2 Mon Sep 17 00:00:00 2001 From: Mohammad Jaber <49678859+mo-jaber@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:41:05 +0400 Subject: [PATCH 3/8] fix(overlay-topics)!: sign uora anchor fields behind their lengths `PushDrop.lock` signs `fields.flat()`, the field bytes run together with nothing between them. A signature over that fixes the total byte string and not where one field ends and the next begins. Four of the anchor's seven boundaries are pinned by other checks: the prefix is a fixed literal, the digest is exactly 64 hex characters, and the attestation id and the anchoring key are both fixed by the locking-key derivation. The subject and the type are neither, and they are adjacent. So any holder of an anchor could re-cut that one boundary into a different subject and type, copy the signature bytes verbatim, and pass every check this reader made. The derivation check does not catch it, because the two fields it pins are the two a re-cut leaves alone. The single v2 anchor on mainnet reads 63 ways. v3 changes only the preimage: each field is preceded by its length as a varint, so any other split is different bytes and the signature stops verifying. The prefix moves with it because a reader has no other way to know which preimage to rebuild, and v2 is no longer admitted. Fields, order, derivation, counterparty and forSelf are all unchanged. The format no longer uses PushDrop's built-in signature. A writer signs the preimage itself and appends it as the eighth field, locking with `includeSignature: false`. Also in this change, each found while pinning the above: - `assertAnchorSignature` is now exported. It is the only step that needs the anchoring service's private key, and a consumer given `readUoraAnchor` without it would believe it had checked authorship when it had not. - Its failure branch was unreachable: `verifySignature` throws rather than returning a verdict, so a bad signature surfaced as the wallet's wording instead of a reason naming the anchor. - The lookup selectivity guard tested for a key being present while storage required a non-empty string, so `{ issuer: '' }` passed the guard and reached Mongo as an empty filter: the table scan the guard exists to refuse, pageable with `skip`. - `issuerKey` selects on its own but had no index. - A rejected index build was memoised, so one unlucky moment disabled the collection for the life of the process. The shared fixture is regenerated and now carries `boundaryShifted`: the pinned output re-cut, with the signature copied across. Every entry must be refused. The v2 fixture had no negative vectors at all, which is how a malleable format passed its own conformance suite. --- packages/overlays/topics/CHANGELOG.md | 9 +- packages/overlays/topics/package.json | 2 +- .../topics/src/__tests__/uoradpp.test.ts | 120 +++++++++++++++++- packages/overlays/topics/src/index.ts | 8 ++ .../src/uoradpp/UoraDppLookupService.ts | 18 ++- .../topics/src/uoradpp/UoraDppStorage.ts | 12 +- .../topics/src/uoradpp/UoraDppTopicManager.ts | 26 +++- .../uoradpp/__tests__/anchor-v3-fixture.ts | 88 +++++++++++++ .../topics/src/uoradpp/anchorFormat.ts | 88 ++++++++++--- 9 files changed, 332 insertions(+), 39 deletions(-) create mode 100644 packages/overlays/topics/src/uoradpp/__tests__/anchor-v3-fixture.ts diff --git a/packages/overlays/topics/CHANGELOG.md b/packages/overlays/topics/CHANGELOG.md index 30c9ae587..35f71f821 100644 --- a/packages/overlays/topics/CHANGELOG.md +++ b/packages/overlays/topics/CHANGELOG.md @@ -12,10 +12,13 @@ All notable changes to this project will be documented in this file. The format ### Added - `tm_uora_dpp` / `ls_uora_dpp`: admission and lookup for UORA attestation - anchors (`uora-anchor-v2`), keyed on the `did:key` of the party that made the + anchors (`uora-anchor-v3`), keyed on the `did:key` of the party that made the claim. Anchors name their anchoring service in the output and lock to its - BRC-42 child, so an instance attributes one with nothing configured. Additive: - no existing topic, export, schema or behaviour changes. + BRC-42 child, so an instance attributes one with nothing configured. The + anchor signature covers each field behind its own length, so it commits to + where every field ends; `uora-anchor-v2`, which signed the fields run + together and so left the subject/type boundary movable by any holder, is not + admitted. Additive: no existing topic, export, schema or behaviour changes. ### Changed diff --git a/packages/overlays/topics/package.json b/packages/overlays/topics/package.json index a2017006c..c0a9e250e 100644 --- a/packages/overlays/topics/package.json +++ b/packages/overlays/topics/package.json @@ -29,7 +29,7 @@ "build": "tsc", "format:check": "pnpm --workspace-root exec prettier --check \"packages/overlays/topics/{README.md,jest.config.js,package.json,tsconfig.json}\" \"packages/overlays/topics/src/index.ts\"", "lint": "oxlint src jest.config.js --deny-warnings", - "pack:check": "pnpm build && node ../../../scripts/check-package-artifact.mjs . --modes esm --exports AnyTopicManager,AppsTopicManager,BTMSTopicManager,BasketMapTopicManager,Bsv21LookupService,Bsv21StorageManager,Bsv21TopicManager,CertMapTopicManager,DIDTopicManager,DesktopIntegrityTopicManager,DstasLookupService,DstasStorageManager,DstasTopicManager,FractionalizeTopicManager,HelloWorldTopicManager,IdentityTopicManager,InMemoryScreeningProvider,KVStoreTopicManager,MandalaLookupService,MandalaStorageManager,MandalaTopicManager,MessageBoxTopicManager,MonsterBattleTopicManager,ProtoMapTopicManager,SlackThreadsTopicManager,StasLookupService,StasStorageManager,StasTopicManager,SupplyChainTopicManager,TokenDemoTopicManager,UHRPTopicManager,UMPTopicManager,UORA_ANCHOR_PREFIX,UORA_ANCHOR_PROTOCOL,UoraDppTopicManager,WalletConfigTopicManager,allowlistIssuerPolicy,btmsProtocol,createAnyLookupService,createAppsLookupService,createBTMSLookupService,createBasketMapLookupService,createBsv21LookupService,createCertMapLookupService,createDIDLookupService,createDesktopIntegrityLookupService,createDstasLookupService,createFractionalizeLookupService,createHelloWorldLookupService,createIdentityLookupService,createKVStoreLookupService,createMandalaLookupService,createMessageBoxLookupService,createMonsterBattleLookupService,createProtoMapLookupService,createSlackThreadsLookupService,createStasLookupService,createSupplyChainLookupService,createTokenDemoLookupService,createUHRPLookupService,createUMPLookupService,createUoraDppLookupService,createWalletConfigLookupService,deserializeWalletProtocol,didKeyFromIdentityKey,expectedLockingKey,identityKeyFromDidKey,kvProtocol,readUoraAnchor,verifyKeyLinkage", + "pack:check": "pnpm build && node ../../../scripts/check-package-artifact.mjs . --modes esm --exports AnyTopicManager,AppsTopicManager,BTMSTopicManager,BasketMapTopicManager,Bsv21LookupService,Bsv21StorageManager,Bsv21TopicManager,CertMapTopicManager,DIDTopicManager,DesktopIntegrityTopicManager,DstasLookupService,DstasStorageManager,DstasTopicManager,FractionalizeTopicManager,HelloWorldTopicManager,IdentityTopicManager,InMemoryScreeningProvider,KVStoreTopicManager,MandalaLookupService,MandalaStorageManager,MandalaTopicManager,MessageBoxTopicManager,MonsterBattleTopicManager,ProtoMapTopicManager,SlackThreadsTopicManager,StasLookupService,StasStorageManager,StasTopicManager,SupplyChainTopicManager,TokenDemoTopicManager,UHRPTopicManager,UMPTopicManager,UORA_ANCHOR_PREFIX,UORA_ANCHOR_PROTOCOL,UoraDppTopicManager,WalletConfigTopicManager,allowlistIssuerPolicy,anchorSigningPreimage,assertAnchorSignature,btmsProtocol,createAnyLookupService,createAppsLookupService,createBTMSLookupService,createBasketMapLookupService,createBsv21LookupService,createCertMapLookupService,createDIDLookupService,createDesktopIntegrityLookupService,createDstasLookupService,createFractionalizeLookupService,createHelloWorldLookupService,createIdentityLookupService,createKVStoreLookupService,createMandalaLookupService,createMessageBoxLookupService,createMonsterBattleLookupService,createProtoMapLookupService,createSlackThreadsLookupService,createStasLookupService,createSupplyChainLookupService,createTokenDemoLookupService,createUHRPLookupService,createUMPLookupService,createUoraDppLookupService,createWalletConfigLookupService,deserializeWalletProtocol,didKeyFromIdentityKey,expectedLockingKey,identityKeyFromDidKey,kvProtocol,readUoraAnchor,verifyKeyLinkage", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --watchman=false", "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --runInBand --watchman=false", "test:property": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --watchman=false --runTestsByPath src/mandala/__tests/types.property.test.ts", diff --git a/packages/overlays/topics/src/__tests__/uoradpp.test.ts b/packages/overlays/topics/src/__tests__/uoradpp.test.ts index 8ba489b98..1c59495d4 100644 --- a/packages/overlays/topics/src/__tests__/uoradpp.test.ts +++ b/packages/overlays/topics/src/__tests__/uoradpp.test.ts @@ -7,10 +7,12 @@ * locking to the BRC-42 child of the key it names, under counterparty `anyone` * so a third party can reproduce the derivation. * - * A second one worth reading is `does not reuse a fields array`. `PushDrop.lock` - * appends the signature to the array it is handed, so a caller that builds - * fields once and locks twice signs the previous signature and writes an output - * nothing can read. + * The other one to read is `is refused by the signature check`, under `the + * boundary between the subject and the type`. It pins the defect that made this + * format a v3: a signature over the fields run together fixes the bytes and not + * where any field ends, so two adjacent fields whose boundary nothing else pins + * can be re-cut by anyone holding the output. Those vectors come from the shared + * fixture, so all three implementations refuse the same bytes. */ import { MongoMemoryServer } from 'mongodb-memory-server' @@ -31,6 +33,8 @@ import createUoraDppLookupService, { UoraDppLookupService } from '../uoradpp/UoraDppLookupService.js' import { + anchorSigningPreimage, + assertAnchorSignature, didKeyFromIdentityKey, expectedLockingKey, identityKeyFromDidKey, @@ -38,6 +42,7 @@ import { UORA_ANCHOR_PREFIX, UORA_ANCHOR_PROTOCOL } from '../uoradpp/anchorFormat.js' +import { ANCHOR_V3_FIXTURE } from '../uoradpp/__tests__/anchor-v3-fixture.js' const mongoMemoryServerOptions = { instance: { launchTimeout: 60000 } } @@ -80,7 +85,7 @@ function claim(overrides: Partial = {}): Claim { } } -/** A fresh array every call: PushDrop.lock pushes the signature into it. */ +/** A fresh array every call, so nothing a caller does can be seen by the next. */ function fieldsFor(one: Claim): number[][] { return [ UORA_ANCHOR_PREFIX, @@ -93,17 +98,31 @@ function fieldsFor(one: Claim): number[][] { ].map(value => Utils.toArray(value, 'utf8')) } +/** + * Builds an anchor the way a writer must: sign the length-prefixed preimage, + * then lock with `includeSignature: false` and the signature already among the + * fields. `PushDrop.lock`'s own signature covers `fields.flat()`, which does not + * commit to where a field ends, and using it is what made v2 re-cuttable. + */ async function anchorScript( one: Claim = claim(), wallet: WalletInterface = serviceWallet, keyId = one.attestationId ): Promise { + const fields = fieldsFor(one) + const { signature } = await wallet.createSignature({ + data: anchorSigningPreimage(fields), + protocolID: UORA_ANCHOR_PROTOCOL, + keyID: keyId, + counterparty: 'anyone' + }) return await new PushDrop(wallet).lock( - fieldsFor(one), + [...fields, signature], UORA_ANCHOR_PROTOCOL, keyId, 'anyone', - true + true, + false ) } @@ -420,3 +439,90 @@ describe('UoraDppLookupService', () => { expect(await service.getDocumentation()).toContain('did:key') }) }) + +describe('the shared fixture, which is the contract with the writer', () => { + const F = ANCHOR_V3_FIXTURE + const manager = new UoraDppTopicManager() + const script = (): LockingScript => LockingScript.fromHex(F.lockingScript) + + /* + * The same constant is committed in the writer's repository and in the app's, + * because none of the three can import the others: this reader is meant to + * drop into a shared overlay instance that has never heard of the service that + * writes anchors. Byte-identity is the whole of the agreement. If the format + * moves on one side and not the others, one of these suites goes red. + */ + it('reads the anchor the writer pinned', () => { + const { anchor } = readUoraAnchor(script()) + expect(anchor.digest).toBe(F.digest) + expect(anchor.attestationId).toBe(F.attestationId) + expect(anchor.issuer).toBe(F.issuerDid) + expect(anchor.subject).toBe(F.subject) + expect(anchor.uoraType).toBe(F.uoraType) + expect(anchor.anchoredBy).toBe(F.anchoredBy) + }) + + it('accepts a signature this repository cannot produce', async () => { + // Nothing here holds the writer's key. The signature in the fixture was + // made in the other repository, so this passing is a real agreement between + // two implementations rather than one implementation agreeing with itself. + const { anchor, fields } = readUoraAnchor(script()) + await expect( + assertAnchorSignature(fields, anchor.anchoredBy, anchor.attestationId) + ).resolves.toBeUndefined() + }) + + it('admits it', async () => { + const admitted = await manager.identifyAdmissibleOutputs(txWith(script()).toBEEF(), []) + expect(admitted.outputsToAdmit).toEqual([0]) + }) +}) + +describe('the boundary between the subject and the type', () => { + const F = ANCHOR_V3_FIXTURE + const manager = new UoraDppTopicManager() + + /* + * The forgery v2 admitted, and the only reason this format has a v3. + * + * Each entry is the pinned output with that one boundary re-cut and the + * signature bytes copied across untouched. It is not ordinary tampering: no + * field content changes and no signature byte changes, only where one field is + * said to stop and the next to start. v2 signed the fields run together, so it + * could not see the difference and accepted every one. The v2 anchor on + * mainnet reads 63 ways. + */ + it('changes no content and no signature, only where a field ends', () => { + const genuine = readUoraAnchor(LockingScript.fromHex(F.lockingScript)) + expect(F.boundaryShifted.length).toBeGreaterThan(0) + for (const hex of F.boundaryShifted) { + const { fields } = PushDrop.decode(LockingScript.fromHex(hex)) + expect(fields.slice(0, -1).flat()).toEqual(genuine.fields.slice(0, -1).flat()) + expect(fields.at(-1)).toEqual(genuine.fields.at(-1)) + expect(fields[4]).not.toEqual(genuine.fields[4]) + } + }) + + it('is refused by the signature check', async () => { + for (const hex of F.boundaryShifted) { + const { anchor, fields } = readUoraAnchor(LockingScript.fromHex(hex)) + // It still reads as well formed: the re-cut leaves the four fields that + // `readUoraAnchor` pins exactly where they were, which is why the locking + // key still derives and why this check has to be the one that catches it. + expect(anchor.anchoredBy).toBe(F.anchoredBy) + await expect( + assertAnchorSignature(fields, anchor.anchoredBy, anchor.attestationId) + ).rejects.toThrow(/not signed by the anchoring service/) + } + }) + + it('is not admitted', async () => { + for (const hex of F.boundaryShifted) { + const admitted = await manager.identifyAdmissibleOutputs( + txWith(LockingScript.fromHex(hex)).toBEEF(), + [] + ) + expect(admitted.outputsToAdmit).toEqual([]) + } + }) +}) diff --git a/packages/overlays/topics/src/index.ts b/packages/overlays/topics/src/index.ts index ad955586d..12608b60d 100644 --- a/packages/overlays/topics/src/index.ts +++ b/packages/overlays/topics/src/index.ts @@ -85,9 +85,17 @@ export { default as createSupplyChainLookupService } from './supplychain/SupplyC // uoradpp export type { UoraDppRecord, UoraDppQuery } from './uoradpp/types.js' +// `assertAnchorSignature` is exported alongside `readUoraAnchor` on purpose. +// `readUoraAnchor` checks that an output is well formed and that its locking key +// derives from the service it names, which anybody can reproduce; the signature +// is the only step needing that service's private key. A consumer given the +// first without the second would believe it had checked authorship when it had +// not, which is precisely how the v2 format's guarantee was overstated. export { UORA_ANCHOR_PREFIX, UORA_ANCHOR_PROTOCOL, + anchorSigningPreimage, + assertAnchorSignature, didKeyFromIdentityKey, expectedLockingKey, identityKeyFromDidKey, diff --git a/packages/overlays/topics/src/uoradpp/UoraDppLookupService.ts b/packages/overlays/topics/src/uoradpp/UoraDppLookupService.ts index f6afce4b3..946d91bf0 100644 --- a/packages/overlays/topics/src/uoradpp/UoraDppLookupService.ts +++ b/packages/overlays/topics/src/uoradpp/UoraDppLookupService.ts @@ -93,12 +93,20 @@ export class UoraDppLookupService implements LookupService { // `uoraType` and `anchoredBy` narrow but cannot select: either alone is // every anchor of a common type, which is a table scan wearing a query. + // + // Tested the same way the storage layer uses them, which is a non-empty + // string, rather than merely being present. Testing for presence let + // `{ issuer: '' }` through: it satisfied the guard, then the storage layer + // dropped it for not being a usable string, and what reached Mongo was an + // empty filter. The caller got the table scan this guard exists to refuse, + // and could page the whole collection with `skip`. + const selects = (value: unknown): boolean => typeof value === 'string' && value !== '' const selective = - query.issuer !== undefined || - query.issuerKey !== undefined || - query.subject !== undefined || - query.attestationId !== undefined || - query.digest !== undefined + selects(query.issuer) || + selects(query.issuerKey) || + selects(query.subject) || + selects(query.attestationId) || + selects(query.digest) if (!selective) { throw new Error('Query must provide issuer, issuerKey, subject, attestationId or digest') } diff --git a/packages/overlays/topics/src/uoradpp/UoraDppStorage.ts b/packages/overlays/topics/src/uoradpp/UoraDppStorage.ts index e4988388e..f79cf15a2 100644 --- a/packages/overlays/topics/src/uoradpp/UoraDppStorage.ts +++ b/packages/overlays/topics/src/uoradpp/UoraDppStorage.ts @@ -26,6 +26,9 @@ export class UoraDppStorage { private async ensureIndexes(): Promise { this.indexInit ??= (async () => { await this.records.createIndex({ issuer: 1, createdAt: 1 }, { name: 'issuerIndex' }) + // `issuerKey` selects on its own, so it needs its own index. Without one + // every lookup by identity key was a collection scan and a sort. + await this.records.createIndex({ issuerKey: 1, createdAt: 1 }, { name: 'issuerKeyIndex' }) await this.records.createIndex({ subject: 1, createdAt: 1 }, { name: 'subjectIndex' }) await this.records.createIndex({ attestationId: 1 }, { name: 'attestationIdIndex' }) await this.records.createIndex({ digest: 1 }, { name: 'digestIndex' }) @@ -34,7 +37,14 @@ export class UoraDppStorage { { txid: 1, outputIndex: 1 }, { name: 'outpointIndex', unique: true } ) - })() + })().catch(error => { + // A failed build must not be remembered as a finished one. Leaving the + // rejected promise in place made one unlucky moment disable the + // collection's reads and writes for the life of the process; clearing it + // lets the next caller try again. + this.indexInit = undefined + throw error + }) return await this.indexInit } diff --git a/packages/overlays/topics/src/uoradpp/UoraDppTopicManager.ts b/packages/overlays/topics/src/uoradpp/UoraDppTopicManager.ts index 40936e504..92af16095 100644 --- a/packages/overlays/topics/src/uoradpp/UoraDppTopicManager.ts +++ b/packages/overlays/topics/src/uoradpp/UoraDppTopicManager.ts @@ -19,6 +19,15 @@ import { assertAnchorSignature, readUoraAnchor, UORA_ANCHOR_PREFIX } from './anc * needs that service's private key, so every admitted anchor names its author * checkably, by anyone holding the transaction, with nothing configured. * + * "Sealed together" is doing exact work here, and it is what v2 could not + * deliver. The signature covers each field behind its own length, so it fixes + * where every field ends as well as what they contain. v2 signed the field + * bytes run together, which fixed only the total string: the subject and the + * type are adjacent and neither has its boundary pinned by any other check, so + * a holder could re-cut that boundary into a different subject and type, keep + * the signature, and be admitted. The derivation check does not catch it, + * because the two fields it pins are the two a re-cut leaves alone. + * * **Not proved.** That the party in field 3 made the claim. That is a `did:key` * copied onto the chain as given, and anyone able to write an anchor can write * any DID into it. What a claim is worth is settled by the attestation's own @@ -75,14 +84,23 @@ export default class UoraDppTopicManager implements TopicManager { 'A 1-satoshi PushDrop output carrying, in order: the version prefix, the', 'SHA-256 digest of the attestation in lower-case hex, the attestation id,', "the issuer's did:key, the subject passport id, the UORA attestation type,", - "and the anchoring service's identity key. PushDrop appends a signature", - 'over those seven by the key that locks the output.', + "and the anchoring service's identity key. An eighth field is a signature", + 'by the key that locks the output, over those seven fields with each one', + 'preceded by its length as a varint. The length prefixes are what commit', + 'the signature to where every field ends, and not merely to the bytes they', + 'run to when concatenated.', '', 'Admitted when all seven parse, the issuer resolves to a compressed', 'secp256k1 key, the signature checks out, and the locking key is the', - "BRC-42 child of field 6 at protocol [1, 'uora anchor v2'], key id the", + "BRC-42 child of field 6 at protocol [1, 'uora anchor v3'], key id the", "attestation id, counterparty 'anyone'. That derivation is reproducible by", - 'anyone holding the output, so every admitted anchor names its author.', + 'anyone holding the output, so it identifies the named service but proves', + 'nothing on its own; the signature is the step needing that service\'s', + 'private key, and so the step that makes an admitted anchor name its author.', + '', + 'uora-anchor-v2 is not admitted. Its signature covered the fields run', + 'together, so the boundary between the subject and the type could be moved', + 'by any holder while the signature still verified.', '', 'The issuer in field 3 is carried, not proved: whether that party made the', "claim is settled by the attestation's own signature, which is off chain.", diff --git a/packages/overlays/topics/src/uoradpp/__tests__/anchor-v3-fixture.ts b/packages/overlays/topics/src/uoradpp/__tests__/anchor-v3-fixture.ts new file mode 100644 index 000000000..233509741 --- /dev/null +++ b/packages/overlays/topics/src/uoradpp/__tests__/anchor-v3-fixture.ts @@ -0,0 +1,88 @@ +/** + * One `uora-anchor-v3` output, pinned byte for byte, and the forgeries a + * reader must refuse. + * + * **This file is duplicated verbatim** in the app repository at + * `packages/overlay-topics/test/anchor-v3-fixture.ts` and in ts-stack at + * `packages/overlays/topics/src/uoradpp/__tests__/anchor-v3-fixture.ts`. It is + * the only thing holding the halves of this format together: this repository + * writes the output and the others read it, and none can import the other, + * because a reader is meant to drop into a shared overlay instance that has + * never heard of this service. + * + * A fixture is not an elegant contract. It is, however, the one kind that fails + * loudly: change the field order, the canonicalisation, the protocol string, + * the counterparty or the signing preimage on any side, and a suite goes red + * with a diff you can read. + * + * `boundaryShifted` is the half the v2 fixture did not have. Every entry is + * this output with the subject/type boundary re-cut and the signature copied + * unchanged. Under v2 all of them verified, because the signature covered the + * field bytes run together and so said nothing about where a field ended. Under + * v3 the preimage puts each field behind its length, so every entry below must + * be rejected. A fixture with only positive vectors certifies that a format + * accepts what it should, and never that it refuses what it must. + * + * Regenerate with `scripts/generate-anchor-v3-fixture.ts`, and change the + * prefix when the format changes: every published anchor verifies against the + * layout below. + * + * The keys are `77`/`88` repeated to 32 bytes. They are test keys, published + * here on purpose, and nothing derived from them will ever hold a satoshi. + */ + +export const ANCHOR_V3_FIXTURE = { + /** + * `PrivateKey.fromHex('77'.repeat(32))`, the anchoring service. Carried in + * field 6 of the output, so a reader attributes the anchor with nothing + * configured. + */ + anchoredBy: '037962d45b38e8bcf82fa8efa8432a01f20c9a53e24c7d3f11df197cb8e70926da', + /** `PrivateKey.fromHex('88'.repeat(32))`, the issuing brand. */ + issuerKey: '021617d38ed8d8657da4d4761e8057bc396ea9e4b9d29776d4be096016dbd2509b', + issuerDid: 'did:key:zQ3shNu2oFTbeqexYeunD36my3aQNqEWq8mWD2ACRqQLEipcz', + + subject: 'https://id.gs1.org/01/09506000134352/21/B59E82284DEE', + attestationId: 'https://id.gs1.org/01/09506000134352/21/B59E82284DEE/state-1', + uoraType: 'Origin', + + /** The attestation exactly as the app posts it, signed fields included. */ + attestation: { + "passportId": "https://id.gs1.org/01/09506000134352/21/B59E82284DEE", + "recordId": "state-1", + "uora_type": "Origin", + "timestamp": "2026-08-01T09:00:00.000Z", + "issuer": "did:key:zQ3shNu2oFTbeqexYeunD36my3aQNqEWq8mWD2ACRqQLEipcz", + "issuerKeyId": "acct/user_fixture", + "profile": "battery", + "profile_version": 2 + }, + + /** RFC 8785 over the attestation above. The bytes that get hashed. */ + canonical: + "{\"issuer\":\"did:key:zQ3shNu2oFTbeqexYeunD36my3aQNqEWq8mWD2ACRqQLEipcz\",\"issuerKeyId\":\"acct/user_fixture\",\"passportId\":\"https://id.gs1.org/01/09506000134352/21/B59E82284DEE\",\"profile\":\"battery\",\"profile_version\":2,\"recordId\":\"state-1\",\"timestamp\":\"2026-08-01T09:00:00.000Z\",\"uora_type\":\"Origin\"}", + /** SHA-256 of those bytes, which is what field 1 carries. */ + digest: 'e4f663b7e87a40b601716e873a2dec3a37b6a45bd0c8557cb25310ab4e736d11', + + /** The BRC-42 child the output locks to, reproducible from the service key. */ + lockingKey: '030a95609c4125c2f38ec2980c64b3b1af224e65774e48951000ca20d69f8dd371', + + /** + * The whole output. Deterministic because ECDSA here uses RFC 6979 nonces, + * so this hex is reproducible on any machine from the keys above. + */ + lockingScript: + '21030a95609c4125c2f38ec2980c64b3b1af224e65774e48951000ca20d69f8dd371ac0e756f72612d616e63686f722d763340653466363633623765383761343062363031373136653837336132646563336133376236613435626430633835353763623235333130616234653733366431313c68747470733a2f2f69642e6773312e6f72672f30312f30393530363030303133343335322f32312f4235394538323238344445452f73746174652d31396469643a6b65793a7a513373684e75326f465462657165785965756e4433366d793361514e71455771386d57443241435271514c456970637a3468747470733a2f2f69642e6773312e6f72672f30312f30393530363030303133343335322f32312f423539453832323834444545064f726967696e423033373936326434356233386538626366383266613865666138343332613031663230633961353365323463376433663131646631393763623865373039323664614630440220402e21938ba9314a50232cfbaf22144e1b9807c669fe296a28b78bafe6dade9802206ef8cc003a9beec8d1fb9c74145421f98060ad1481a33b75fcdfc636fe43227e6d6d6d6d', + + /** + * Forgeries. The output above with the subject/type boundary moved and the + * signature bytes untouched. Every one of these verified under v2. A reader + * that admits any of them has lost the guarantee the format exists to make. + */ + boundaryShifted: [ + '21030a95609c4125c2f38ec2980c64b3b1af224e65774e48951000ca20d69f8dd371ac0e756f72612d616e63686f722d763340653466363633623765383761343062363031373136653837336132646563336133376236613435626430633835353763623235333130616234653733366431313c68747470733a2f2f69642e6773312e6f72672f30312f30393530363030303133343335322f32312f4235394538323238344445452f73746174652d31396469643a6b65793a7a513373684e75326f465462657165785965756e4433366d793361514e71455771386d57443241435271514c456970637a2e68747470733a2f2f69642e6773312e6f72672f30312f30393530363030303133343335322f32312f4235394538320c3238344445454f726967696e423033373936326434356233386538626366383266613865666138343332613031663230633961353365323463376433663131646631393763623865373039323664614630440220402e21938ba9314a50232cfbaf22144e1b9807c669fe296a28b78bafe6dade9802206ef8cc003a9beec8d1fb9c74145421f98060ad1481a33b75fcdfc636fe43227e6d6d6d6d', + '21030a95609c4125c2f38ec2980c64b3b1af224e65774e48951000ca20d69f8dd371ac0e756f72612d616e63686f722d763340653466363633623765383761343062363031373136653837336132646563336133376236613435626430633835353763623235333130616234653733366431313c68747470733a2f2f69642e6773312e6f72672f30312f30393530363030303133343335322f32312f4235394538323238344445452f73746174652d31396469643a6b65793a7a513373684e75326f465462657165785965756e4433366d793361514e71455771386d57443241435271514c456970637a3368747470733a2f2f69642e6773312e6f72672f30312f30393530363030303133343335322f32312f423539453832323834444507454f726967696e423033373936326434356233386538626366383266613865666138343332613031663230633961353365323463376433663131646631393763623865373039323664614630440220402e21938ba9314a50232cfbaf22144e1b9807c669fe296a28b78bafe6dade9802206ef8cc003a9beec8d1fb9c74145421f98060ad1481a33b75fcdfc636fe43227e6d6d6d6d', + '21030a95609c4125c2f38ec2980c64b3b1af224e65774e48951000ca20d69f8dd371ac0e756f72612d616e63686f722d763340653466363633623765383761343062363031373136653837336132646563336133376236613435626430633835353763623235333130616234653733366431313c68747470733a2f2f69642e6773312e6f72672f30312f30393530363030303133343335322f32312f4235394538323238344445452f73746174652d31396469643a6b65793a7a513373684e75326f465462657165785965756e4433366d793361514e71455771386d57443241435271514c456970637a3568747470733a2f2f69642e6773312e6f72672f30312f30393530363030303133343335322f32312f4235394538323238344445454f05726967696e423033373936326434356233386538626366383266613865666138343332613031663230633961353365323463376433663131646631393763623865373039323664614630440220402e21938ba9314a50232cfbaf22144e1b9807c669fe296a28b78bafe6dade9802206ef8cc003a9beec8d1fb9c74145421f98060ad1481a33b75fcdfc636fe43227e6d6d6d6d', + '21030a95609c4125c2f38ec2980c64b3b1af224e65774e48951000ca20d69f8dd371ac0e756f72612d616e63686f722d763340653466363633623765383761343062363031373136653837336132646563336133376236613435626430633835353763623235333130616234653733366431313c68747470733a2f2f69642e6773312e6f72672f30312f30393530363030303133343335322f32312f4235394538323238344445452f73746174652d31396469643a6b65793a7a513373684e75326f465462657165785965756e4433366d793361514e71455771386d57443241435271514c456970637a3868747470733a2f2f69642e6773312e6f72672f30312f30393530363030303133343335322f32312f4235394538323238344445454f72696702696e423033373936326434356233386538626366383266613865666138343332613031663230633961353365323463376433663131646631393763623865373039323664614630440220402e21938ba9314a50232cfbaf22144e1b9807c669fe296a28b78bafe6dade9802206ef8cc003a9beec8d1fb9c74145421f98060ad1481a33b75fcdfc636fe43227e6d6d6d6d', + ], +} as const diff --git a/packages/overlays/topics/src/uoradpp/anchorFormat.ts b/packages/overlays/topics/src/uoradpp/anchorFormat.ts index 7013b02b0..79684db80 100644 --- a/packages/overlays/topics/src/uoradpp/anchorFormat.ts +++ b/packages/overlays/topics/src/uoradpp/anchorFormat.ts @@ -2,15 +2,21 @@ import { CachedKeyDeriver, LockingScript, ProtoWallet, PublicKey, PushDrop, Util import type { WalletProtocol } from '@bsv/sdk' /** - * Reading and validating a `uora-anchor-v2` output. + * Reading and validating a `uora-anchor-v3` output. * * The format is specified independently of this implementation; this file is a * reader for it, written so the topic manager and the lookup service cannot * disagree about what an anchor is. + * + * v2 is not read here, and deliberately so. Its signature covered the field + * bytes run together, which fixes the total string and not where any field + * ends, so the boundary between the subject and the type could be re-cut by + * anyone holding the output while the signature still verified. v3 changes only + * the preimage. See `anchorSigningPreimage`. */ /** Marks the output as this format and versions the field layout. */ -export const UORA_ANCHOR_PREFIX = 'uora-anchor-v2' +export const UORA_ANCHOR_PREFIX = 'uora-anchor-v3' /** * The BRC-42 child the output is locked under, with the attestation id as key @@ -22,14 +28,15 @@ export const UORA_ANCHOR_PREFIX = 'uora-anchor-v2' * private half. That is what makes an anchor attributable to a named service by * anybody, rather than only by the service itself. * - * The key id being the attestation id rather than a constant means every anchor - * locks to a different key, so anchors by one service are not linkable to each - * other by inspection, while each remains linkable to that service by anyone - * who asks. That trade is deliberate. + * The key id is the attestation id rather than a constant, so every anchor locks + * to a different key. That does not hide which service wrote them: field 6 + * carries the anchoring service's identity key in the clear, so grouping a + * service's anchors is trivial by reading it. What the per-anchor key id buys is + * that the locking keys themselves share no visible structure. */ -export const UORA_ANCHOR_PROTOCOL: WalletProtocol = [1, 'uora anchor v2'] +export const UORA_ANCHOR_PROTOCOL: WalletProtocol = [1, 'uora anchor v3'] -/** Fields before the signature `PushDrop.lock` appends. */ +/** Fields before the appended signature. */ export const UORA_ANCHOR_FIELD_COUNT = 7 /** @@ -169,8 +176,14 @@ export function readUoraAnchor(lockingScript: LockingScript): { /* * The attribution check, and it is part of being well formed rather than a * policy on top of it. An output naming an anchoring service its locking key - * cannot derive from is claiming something untrue about itself. Producing one - * that passes needs that service's private key, which is the entire proof. + * cannot derive from is claiming something untrue about itself. + * + * This check on its own proves nothing about authorship: counterparty + * `anyone` is what makes the derivation reproducible, so anybody can compute + * this key and lock an output to it. What needs the service's private key is + * the signature, checked separately in `assertAnchorSignature`. Reading this + * check as the proof is the mistake that let v2 through: it holds on a re-cut + * anchor, because the two fields it pins are the two the re-cut leaves alone. */ if (expectedLockingKey(anchoredBy, attestationId) !== lockingPublicKey.toString()) { throw new Error('the locking key is not derived from the anchoring service named in field 6') @@ -184,7 +197,37 @@ export function readUoraAnchor(lockingScript: LockingScript): { } /** - * The appended signature, checked against the service the output names. + * The bytes a v3 signature covers: every field preceded by its own length, so + * the boundaries between fields are part of what is signed. + * + * v2 signed the fields run together, with nothing between them. That fixes the + * total byte string and not where one field ends and the next begins. Four of + * the seven boundaries are pinned anyway: the prefix is a fixed literal, the + * digest is exactly 64 hex characters, and the attestation id and the anchoring + * key are both fixed by the locking-key derivation. The subject and the type are + * neither, and they are adjacent, so any holder of an anchor could re-cut that + * one boundary into a different subject and type, copy the signature verbatim, + * and pass every check this file made. The one v2 anchor on mainnet admits 63 + * such readings of itself. + * + * Length prefixes close it: any other split is different bytes, so the signature + * no longer verifies. This is deliberately not `PushDrop.lock`'s built-in + * signature, which signs `fields.flat()` and has no option to commit to + * boundaries; a writer signs this preimage and appends it as the last field. + */ +export function anchorSigningPreimage(fields: number[][]): number[] { + const writer = new Utils.Writer() + for (const field of fields) { + writer.writeVarIntNum(field.length) + writer.write(field) + } + return writer.toArray() +} + +/** + * The appended signature, checked against the service the output names. This is + * the step that needs the anchoring service's private key, and so the only one + * that proves who wrote the anchor. * * Separate from `readUoraAnchor` because it is the only asynchronous step and * the only one the lookup service does not need: by the time an output is being @@ -197,12 +240,21 @@ export async function assertAnchorSignature( ): Promise { const signature = fields.at(-1) if (signature === undefined) throw new Error('the anchor carries no signature') - const { valid } = await new ProtoWallet('anyone').verifySignature({ - data: fields.slice(0, -1).flat(), - signature, - counterparty: anchoredBy, - protocolID: UORA_ANCHOR_PROTOCOL, - keyID: attestationId - }) + // `verifySignature` throws on a bad signature rather than returning a verdict, + // so the failure has to be caught to become this format's own error. Left + // uncaught, the branch below was unreachable and a reader saw the wallet's + // wording instead of a reason that names the anchor. + let valid = false + try { + ;({ valid } = await new ProtoWallet('anyone').verifySignature({ + data: anchorSigningPreimage(fields.slice(0, -1)), + signature, + counterparty: anchoredBy, + protocolID: UORA_ANCHOR_PROTOCOL, + keyID: attestationId + })) + } catch { + valid = false + } if (!valid) throw new Error('the anchor fields were not signed by the anchoring service') } From 5bd2ed325b21c15d99b3677bad7096dc4594885b Mon Sep 17 00:00:00 2001 From: Mohammad Jaber <49678859+mo-jaber@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:18:06 +0400 Subject: [PATCH 4/8] refactor(overlay-topics): move the uora_dpp docs out of their methods The Sonar gate refuses any new duplication above three percent and this pair sat at 3.3, on a 44-line block the tool matched between the topic manager and the lookup service. The prose was not the duplicate. What matched was the shape around it: `getDocumentation` returning a joined array, then the five-field metadata return type written inline. That type is declared inline in fifty files here because `TopicManager` declares it inline too, so the house pattern is not the thing to change. Shortening the method bodies is, and it breaks the run. Both strings move to `*Docs.md.ts` beside the classes, which is what `message-box` already does with its own. That is the better arrangement regardless of the gate: a page of prose in a method body reads as incidental, and for most consumers this text is the only description of the format they will ever see. The served text is unchanged apart from one clarification in the lookup service's copy, which now says a selector must be a non-empty string. That was already true of the code and was worth saying, since the guard that enforces it is the one this branch had to correct. --- .../src/uoradpp/UoraDppLookupDocs.md.ts | 19 ++++++++++ .../src/uoradpp/UoraDppLookupService.ts | 15 +------- .../topics/src/uoradpp/UoraDppTopicDocs.md.ts | 37 +++++++++++++++++++ .../topics/src/uoradpp/UoraDppTopicManager.ts | 34 ++--------------- 4 files changed, 61 insertions(+), 44 deletions(-) create mode 100644 packages/overlays/topics/src/uoradpp/UoraDppLookupDocs.md.ts create mode 100644 packages/overlays/topics/src/uoradpp/UoraDppTopicDocs.md.ts diff --git a/packages/overlays/topics/src/uoradpp/UoraDppLookupDocs.md.ts b/packages/overlays/topics/src/uoradpp/UoraDppLookupDocs.md.ts new file mode 100644 index 000000000..55cac3d4f --- /dev/null +++ b/packages/overlays/topics/src/uoradpp/UoraDppLookupDocs.md.ts @@ -0,0 +1,19 @@ +/** + * What `ls_uora_dpp` serves to a caller asking what it can be asked. + * + * Kept beside the topic manager's copy rather than folded into it: the two + * answer different questions, and a reader querying the index should not have + * to read admission rules to find out what a valid query looks like. + */ +export default `UORA DPP Lookup Service: attestation anchors, keyed on the issuing party. + +Query with at least one of issuer (a did:key), issuerKey (the same key as +hex), subject (a product passport id), attestationId or digest. Each must be +a non-empty string. uoraType and anchoredBy narrow any of those and cannot +select on their own. All are exact matches; limit and skip page the answer +and limit is capped. + +Answers are anchor outputs, so a caller verifies them against the chain +rather than trusting this index. The attestations themselves are never on +chain: fetch one from the issuing registry and check its canonical digest +against the anchor's.` diff --git a/packages/overlays/topics/src/uoradpp/UoraDppLookupService.ts b/packages/overlays/topics/src/uoradpp/UoraDppLookupService.ts index 946d91bf0..9df9e0a37 100644 --- a/packages/overlays/topics/src/uoradpp/UoraDppLookupService.ts +++ b/packages/overlays/topics/src/uoradpp/UoraDppLookupService.ts @@ -11,6 +11,7 @@ import { Db } from 'mongodb' import { readUoraAnchor } from './anchorFormat.js' import { UoraDppQuery } from './types.js' import { UoraDppStorage } from './UoraDppStorage.js' +import docs from './UoraDppLookupDocs.md.js' const TOPIC = 'tm_uora_dpp' const SERVICE = 'ls_uora_dpp' @@ -115,19 +116,7 @@ export class UoraDppLookupService implements LookupService { } async getDocumentation(): Promise { - return [ - 'UORA DPP Lookup Service: attestation anchors, keyed on the issuing party.', - '', - 'Query with at least one of issuer (a did:key), issuerKey (the same key as', - 'hex), subject (a product passport id), attestationId or digest. uoraType', - 'and anchoredBy narrow any of those and cannot select on their own. All', - 'are exact matches; limit and skip page the answer and limit is capped.', - '', - 'Answers are anchor outputs, so a caller verifies them against the chain', - 'rather than trusting this index. The attestations themselves are never on', - 'chain: fetch one from the issuing registry and check its canonical digest', - "against the anchor's." - ].join('\n') + return docs } async getMetaData(): Promise<{ diff --git a/packages/overlays/topics/src/uoradpp/UoraDppTopicDocs.md.ts b/packages/overlays/topics/src/uoradpp/UoraDppTopicDocs.md.ts new file mode 100644 index 000000000..c791b2a51 --- /dev/null +++ b/packages/overlays/topics/src/uoradpp/UoraDppTopicDocs.md.ts @@ -0,0 +1,37 @@ +import { UORA_ANCHOR_PREFIX } from './anchorFormat.js' + +/** + * What `tm_uora_dpp` serves to a consumer asking what it admits. + * + * Kept out of the class for the reason `message-box` keeps its own out: a page + * of prose inside a method body is read as incidental, and this text is the + * only description of the format most consumers will ever see. + */ +export default `UORA DPP Topic Manager: attestation anchors in the ${UORA_ANCHOR_PREFIX} format. + +A 1-satoshi PushDrop output carrying, in order: the version prefix, the +SHA-256 digest of the attestation in lower-case hex, the attestation id, +the issuer's did:key, the subject passport id, the UORA attestation type, +and the anchoring service's identity key. An eighth field is a signature +by the key that locks the output, over those seven fields with each one +preceded by its length as a varint. The length prefixes are what commit +the signature to where every field ends, and not merely to the bytes they +run to when concatenated. + +Admitted when all seven parse, the issuer resolves to a compressed +secp256k1 key, the signature checks out, and the locking key is the +BRC-42 child of field 6 at protocol [1, 'uora anchor v3'], key id the +attestation id, counterparty 'anyone'. That derivation is reproducible by +anyone holding the output, so it identifies the named service but proves +nothing on its own; the signature is the step needing that service's +private key, and so the step that makes an admitted anchor name its author. + +uora-anchor-v2 is not admitted. Its signature covered the fields run +together, so the boundary between the subject and the type could be moved +by any holder while the signature still verified. + +The issuer in field 3 is carried, not proved: whether that party made the +claim is settled by the attestation's own signature, which is off chain. + +Anchors are leaves: never spent, nothing retained. Every valid anchor in a +transaction is admitted, so anchors may be batched.` diff --git a/packages/overlays/topics/src/uoradpp/UoraDppTopicManager.ts b/packages/overlays/topics/src/uoradpp/UoraDppTopicManager.ts index 92af16095..f75e39d9a 100644 --- a/packages/overlays/topics/src/uoradpp/UoraDppTopicManager.ts +++ b/packages/overlays/topics/src/uoradpp/UoraDppTopicManager.ts @@ -1,7 +1,8 @@ import { AdmittanceInstructions, TopicManager } from '@bsv/overlay' import { LockingScript } from '@bsv/sdk' import { identifyPushDropOutputs } from '../shared/identifyPushDropOutputs.js' -import { assertAnchorSignature, readUoraAnchor, UORA_ANCHOR_PREFIX } from './anchorFormat.js' +import { assertAnchorSignature, readUoraAnchor } from './anchorFormat.js' +import docs from './UoraDppTopicDocs.md.js' /** * `tm_uora_dpp`: admission for UORA attestation anchors. @@ -78,36 +79,7 @@ export default class UoraDppTopicManager implements TopicManager { } async getDocumentation(): Promise { - return [ - `UORA DPP Topic Manager: attestation anchors in the ${UORA_ANCHOR_PREFIX} format.`, - '', - 'A 1-satoshi PushDrop output carrying, in order: the version prefix, the', - 'SHA-256 digest of the attestation in lower-case hex, the attestation id,', - "the issuer's did:key, the subject passport id, the UORA attestation type,", - "and the anchoring service's identity key. An eighth field is a signature", - 'by the key that locks the output, over those seven fields with each one', - 'preceded by its length as a varint. The length prefixes are what commit', - 'the signature to where every field ends, and not merely to the bytes they', - 'run to when concatenated.', - '', - 'Admitted when all seven parse, the issuer resolves to a compressed', - 'secp256k1 key, the signature checks out, and the locking key is the', - "BRC-42 child of field 6 at protocol [1, 'uora anchor v3'], key id the", - "attestation id, counterparty 'anyone'. That derivation is reproducible by", - 'anyone holding the output, so it identifies the named service but proves', - 'nothing on its own; the signature is the step needing that service\'s', - 'private key, and so the step that makes an admitted anchor name its author.', - '', - 'uora-anchor-v2 is not admitted. Its signature covered the fields run', - 'together, so the boundary between the subject and the type could be moved', - 'by any holder while the signature still verified.', - '', - 'The issuer in field 3 is carried, not proved: whether that party made the', - "claim is settled by the attestation's own signature, which is off chain.", - '', - 'Anchors are leaves: never spent, nothing retained. Every valid anchor in a', - 'transaction is admitted, so anchors may be batched.' - ].join('\n') + return docs } async getMetaData(): Promise<{ From e0367458a9fa55f246b2544ebabe3ec88f9bb1a1 Mon Sep 17 00:00:00 2001 From: Mohammad Jaber <49678859+mo-jaber@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:36:27 +0400 Subject: [PATCH 5/8] fix(docs): let the credentials example compile without the one above it `check-documentation-examples.mjs` selects only the fences whose import closure reaches a changed package, then concatenates the survivors. The credentials fence used `PrivateKey` without importing it, borrowing the import from the SDK fence above, and the guide told the reader that was fine because "the compiler combines all marked fences into one consumer module". That holds only when both fences are selected. Any change touching `@bsv/overlay-topics` or `@bsv/did` and not `@bsv/sdk` selects the credentials fence alone, and it fails with `TS2304: Cannot find name 'PrivateKey'` for a reason that has nothing to do with the boundary the example exists to test. This branch is such a change, which is how it surfaced; the trap has been waiting for whichever PR got there first. The fence now declares the value it needs. An import would have collided with the SDK fence's own on every run where both are selected, which is the trap one layer along. The guide's claim is corrected to say what the script does, since that sentence is what invites the next one. Verified both ways: selecting `@bsv/overlay-topics` alone compiles 2 examples, and selecting it with `@bsv/sdk` compiles 8. --- docs/guides/compiled-package-examples.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/guides/compiled-package-examples.md b/docs/guides/compiled-package-examples.md index 6efcb4cbb..5a51057fb 100644 --- a/docs/guides/compiled-package-examples.md +++ b/docs/guides/compiled-package-examples.md @@ -44,8 +44,14 @@ void connectExampleWallet import { BsvDid, type DidDocument } from '@bsv/did' import type { DIDQuery } from '@bsv/overlay-topics' +// Declared rather than derived from `PrivateKey`, so this fence compiles on its +// own. Only fences importing a changed package are selected, so the one above +// is absent whenever `@bsv/sdk` is unchanged, and a fence that reaches across to +// it fails for reasons that have nothing to do with the boundary under test. +declare const examplePublicKeyDer: number[] + const exampleDidDocument: DidDocument = BsvDid.toDidDocument( - BsvDid.fromPublicKey(PrivateKey.fromRandom().toPublicKey().toDER() as number[]) + BsvDid.fromPublicKey(examplePublicKeyDer) ) const acceptDidLookup = (query: DIDQuery): DIDQuery => query @@ -53,9 +59,11 @@ void exampleDidDocument void acceptDidLookup ``` -The compiler combines all marked fences into one consumer module, so imports -from earlier examples are available here just as they would be in one -application. +The compiler combines the *selected* fences into one consumer module, and on a +pull request it selects only the fences whose import closure reaches a changed +package. So an earlier fence's imports are available only when that fence +happened to be selected too, and a fence that leans on one is broken by any +change that does not touch it. Each fence declares everything it names. ## Messaging From 67b4a78a3208b2ab106630e54a3dff98a5f8df18 Mon Sep 17 00:00:00 2001 From: Mohammad Jaber <49678859+mo-jaber@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:53:48 +0400 Subject: [PATCH 6/8] test(overlay-topics): cover the refusals this branch added and did not test Patch coverage came in at 86.84% against a 90% target, and the thirty uncovered points were almost exactly the paths added while fixing the format: the ones that refuse something. Now covered, and each of these is a test that should have arrived with the fix rather than after it: - a failed index build is not remembered. The stub refuses once, the first call rejects, and the second reaches Mongo again. Had the rejection been memoised the second call would reject without trying, which is the bug the `.catch` was added for - an output whose field count is not seven and a signature - an anchoring service that is not a canonical compressed key, and one that is not hex at all. The second matters because `canonicalKey` round-trips through `PublicKey`, which throws rather than returning something falsy, so the refusal has to survive an exception - a negative `skip`, beside the negative `limit` already covered - a failure to index is logged rather than thrown, because the engine has already admitted the output by then and throwing would fail a submission the topic manager accepted - a record is forgotten when the overlay stops retaining it, and left alone when the topic is somebody else's Line coverage across `uoradpp` is 98.49%, and the package is 424 tests. One path is left uncovered on purpose: `text()` returning undefined for an empty field. `PushDrop.decode` maps an empty push back to a single 0x00 byte, so a reader never sees the empty string, and a test asserting otherwise would be asserting against the decoder rather than the format. --- .../topics/src/__tests__/uoradpp.test.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/packages/overlays/topics/src/__tests__/uoradpp.test.ts b/packages/overlays/topics/src/__tests__/uoradpp.test.ts index 1c59495d4..b4d763776 100644 --- a/packages/overlays/topics/src/__tests__/uoradpp.test.ts +++ b/packages/overlays/topics/src/__tests__/uoradpp.test.ts @@ -32,6 +32,7 @@ import UoraDppTopicManager from '../uoradpp/UoraDppTopicManager.js' import createUoraDppLookupService, { UoraDppLookupService } from '../uoradpp/UoraDppLookupService.js' +import { UoraDppStorage } from '../uoradpp/UoraDppStorage.js' import { anchorSigningPreimage, assertAnchorSignature, @@ -526,3 +527,146 @@ describe('the boundary between the subject and the type', () => { } }) }) + +describe('what readUoraAnchor refuses', () => { + const manager = new UoraDppTopicManager() + + it('refuses an output that is not this many fields', async () => { + // Two fields and no signature. The count is checked before anything is + // read, because a short output has no field 6 to attribute it by and + // guessing which fields are missing is how a reader invents an anchor. + const script = await new PushDrop(serviceWallet).lock( + [Utils.toArray(UORA_ANCHOR_PREFIX, 'utf8'), Utils.toArray('a'.repeat(64), 'utf8')], + UORA_ANCHOR_PROTOCOL, + `${CELL}/state-1`, + 'anyone', + true, + false + ) + expect(() => readUoraAnchor(script)).toThrow(/fields and a signature/) + const result = await manager.identifyAdmissibleOutputs(txWith(script).toBEEF(), []) + expect(result.outputsToAdmit).toEqual([]) + }) + + it('refuses an anchoring service that is not a canonical compressed key', async () => { + // Right shape, not a point on the curve. `canonicalKey` round-trips the hex + // through `PublicKey` rather than pattern-matching it, so this is refused + // for being unusable rather than for looking wrong. + const script = await anchorScript(claim({ anchoredBy: `02${'ff'.repeat(32)}` })) + expect(() => readUoraAnchor(script)).toThrow(/canonical compressed key/) + }) +}) + +describe('UoraDppLookupService, at its edges', () => { + it('refuses a negative skip as well as a negative limit', async () => { + const service = createUoraDppLookupService({ + collection: () => ({}) as never + } as unknown as Db) + await expect( + service.lookup({ + service: 'ls_uora_dpp', + query: { issuer: MAKER, skip: -1 } + } as LookupQuestion) + ).rejects.toThrow(/Skip must be a non-negative number/) + }) + + it('refuses an anchoring service that is not hex at all', async () => { + // `canonicalKey` round-trips through `PublicKey`, which throws rather than + // returning something falsy for input that is not a key. The refusal has to + // survive that, or an unparseable field reaches the derivation check and + // fails there with a message about the wrong thing. + const script = await anchorScript(claim({ anchoredBy: 'not-a-key' })) + expect(() => readUoraAnchor(script)).toThrow(/canonical compressed key/) + }) + + it('forgets a record the overlay stops retaining, and only for its own topic', async () => { + const deleted: Array<[string, number]> = [] + const db = { + collection: () => + ({ + createIndex: async () => 'ok', + deleteOne: async (filter: { txid: string; outputIndex: number }) => { + deleted.push([filter.txid, filter.outputIndex]) + return {} + } + }) as never + } as unknown as Db + const service = createUoraDppLookupService(db) + + await service.outputNoLongerRetainedInHistory('f'.repeat(64), 0, 'tm_something_else') + expect(deleted).toEqual([]) + + await service.outputNoLongerRetainedInHistory('f'.repeat(64), 0, 'tm_uora_dpp') + expect(deleted).toEqual([['f'.repeat(64), 0]]) + }) + + it('reports a failure to index rather than throwing at the overlay', async () => { + // The engine has already admitted the output by the time this is called, so + // throwing here would fail a submission the topic manager accepted. An + // unreadable output is logged and skipped instead. + const errors: unknown[] = [] + const original = console.error + console.error = (...args: unknown[]): void => { + errors.push(args) + } + try { + const service = createUoraDppLookupService({ + collection: () => ({}) as never + } as unknown as Db) + await service.outputAdmittedByTopic({ + mode: 'locking-script', + topic: 'tm_uora_dpp', + txid: 'e'.repeat(64), + outputIndex: 0, + satoshis: 1, + lockingScript: p2pkhOutput() + } as OutputAdmittedByTopic) + expect(errors.length).toBe(1) + } finally { + console.error = original + } + }) +}) + +describe('UoraDppStorage, when Mongo will not build an index', () => { + /* + * The lazy build is memoised so it happens once. Memoising the *failure* was + * the bug: a rejected promise left in place made one unlucky moment disable + * the collection's reads and writes for the life of the process, with every + * later caller awaiting the same rejection. + */ + function dbThatFailsIndexes(failures: number): { db: Db; calls: () => number } { + let attempts = 0 + const collection = { + createIndex: async () => { + attempts += 1 + if (attempts <= failures) throw new Error('index build refused') + return 'ok' + }, + updateOne: async () => ({}), + deleteOne: async () => ({}), + find: () => ({ + sort: () => ({ + skip: () => ({ limit: () => ({ project: () => ({ toArray: async () => [] }) }) }) + }) + }) + } + return { + db: { collection: () => collection } as unknown as Db, + calls: () => attempts + } + } + + it('does not remember a failed build, so the next caller tries again', async () => { + const { db, calls } = dbThatFailsIndexes(1) + const storage = new UoraDppStorage(db) + + await expect(storage.find({ issuer: MAKER })).rejects.toThrow(/index build refused/) + expect(calls()).toBe(1) + + // The retry is the whole point: had the rejection been kept, this would + // reject with the same error without touching Mongo again. + await expect(storage.find({ issuer: MAKER })).resolves.toEqual([]) + expect(calls()).toBeGreaterThan(1) + }) +}) From c87f08d8fa3844a78bda8c76e172c81a8a84b80d Mon Sep 17 00:00:00 2001 From: Mohammad Jaber <49678859+mo-jaber@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:01:04 +0400 Subject: [PATCH 7/8] test(overlay-topics): use toHaveLength in the test added for coverage The zero-findings gate flagged S5906 at the one assertion added in the last commit, which is the same rule cleared at the top of this branch. Writing `expect(errors.length).toBe(1)` again, in a test written to raise coverage, is exactly the drift a gate that admits no new findings exists to catch. --- packages/overlays/topics/src/__tests__/uoradpp.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/overlays/topics/src/__tests__/uoradpp.test.ts b/packages/overlays/topics/src/__tests__/uoradpp.test.ts index b4d763776..73f950857 100644 --- a/packages/overlays/topics/src/__tests__/uoradpp.test.ts +++ b/packages/overlays/topics/src/__tests__/uoradpp.test.ts @@ -621,7 +621,7 @@ describe('UoraDppLookupService, at its edges', () => { satoshis: 1, lockingScript: p2pkhOutput() } as OutputAdmittedByTopic) - expect(errors.length).toBe(1) + expect(errors).toHaveLength(1) } finally { console.error = original } From 72288acb5a2a28b9b46373d5181a9229d4b7ac24 Mon Sep 17 00:00:00 2001 From: Mohammad Jaber <49678859+mo-jaber@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:14:17 +0400 Subject: [PATCH 8/8] fix(ci): let uninstrumentable files clear the patch coverage gate The coverage gate refuses missing files before it looks at the percentage, so this branch failed at 92.98% against a target of 90 for four files LCOV will never contain: the package barrel, the uoradpp types module, and the two documentation modules. None of them can be made to appear. A barrel of `export ... from` compiles to re-export bindings and no statements; a module of `interface` and `type` emits nothing at all; a `*.md.ts` is one exported template literal. I confirmed this rather than assumed it by adding a test that imports the barrel: 426 tests pass, and `src/index.ts` is still absent from LCOV. That test is kept, because a package whose public surface is never loaded by its own suite is worth loading once. So this is the mechanism the exclusion list already exists for, and its comment already describes: "no executable statements for Jest/Istanbul to instrument". `*.md.ts` is excluded by shape. Thirteen such files exist across four packages and not one holds a statement, so the next package to add one should not have to find this out. The barrel and the types module are excluded by exact path, deliberately not by the names `index.ts` and `types.ts`. `create-bsv-app`'s entry point is a CLI that reads `process.argv` and branches on it, and a shape-based rule would have dropped it out of this gate silently. The suite's existing case already asserts an `index.ts` stays governed; the case added here asserts that the one with logic in it still does. This widens a repository-wide gate, which is worth a reviewer's attention even though the four paths are narrow. --- .../src/__tests__/publicsurface.test.ts | 29 +++++++++++++++++++ scripts/patch-coverage.mjs | 20 ++++++++++++- scripts/patch-coverage.test.mjs | 26 +++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 packages/overlays/topics/src/__tests__/publicsurface.test.ts diff --git a/packages/overlays/topics/src/__tests__/publicsurface.test.ts b/packages/overlays/topics/src/__tests__/publicsurface.test.ts new file mode 100644 index 000000000..1e5bf2e3c --- /dev/null +++ b/packages/overlays/topics/src/__tests__/publicsurface.test.ts @@ -0,0 +1,29 @@ +/** + * The package's own entry point, loaded. + * + * Every other suite imports the modules directly, so nothing here had ever + * imported `src/index.ts` and a consumer's view of this package went untested: + * a name dropped from the barrel, or a circular import between two topics, + * would have shown up first for whoever installed it. + */ +import * as topics from '../index.js' + +describe('the package entry point', () => { + it('exports the uora_dpp surface a consumer resolves', () => { + expect(typeof topics.UoraDppTopicManager).toBe('function') + expect(typeof topics.createUoraDppLookupService).toBe('function') + expect(typeof topics.readUoraAnchor).toBe('function') + expect(typeof topics.assertAnchorSignature).toBe('function') + expect(typeof topics.anchorSigningPreimage).toBe('function') + expect(typeof topics.expectedLockingKey).toBe('function') + expect(typeof topics.didKeyFromIdentityKey).toBe('function') + expect(typeof topics.identityKeyFromDidKey).toBe('function') + expect(topics.UORA_ANCHOR_PREFIX).toBe('uora-anchor-v3') + expect(topics.UORA_ANCHOR_PROTOCOL).toEqual([1, 'uora anchor v3']) + }) + + it('serves documentation for both halves of the topic', async () => { + const manager = new topics.UoraDppTopicManager() + await expect(manager.getDocumentation()).resolves.toContain(topics.UORA_ANCHOR_PREFIX) + }) +}) diff --git a/scripts/patch-coverage.mjs b/scripts/patch-coverage.mjs index 695ae3f07..96f8bffe3 100644 --- a/scripts/patch-coverage.mjs +++ b/scripts/patch-coverage.mjs @@ -20,7 +20,25 @@ const EXCLUDED_SOURCE_PATTERNS = [ // Benchmark orchestration and type-only IndexedDB schema declarations have // no executable statements for Jest/Istanbul to instrument. /packages\/sdk\/scripts\/run-benchmarks\.js$/, - /packages\/wallet\/wallet-toolbox\/src\/storage\/schema\/StorageIdbSchema\.ts$/ + /packages\/wallet\/wallet-toolbox\/src\/storage\/schema\/StorageIdbSchema\.ts$/, + // A `*.md.ts` module is one exported template literal, which is a convention + // this repository already keeps in thirteen files across four packages and + // none of which holds a statement. Matched by shape rather than by path, + // because the shape is what makes it uninstrumentable and the next package to + // add one should not have to discover this. + /\.md\.ts$/, + // A barrel of `export ... from` compiles to re-export bindings and no + // statements, and a module of `interface` and `type` emits nothing at all, so + // neither reaches LCOV however thoroughly it is imported. Adding a test that + // loads the barrel does not change that, which is worth recording because it + // is the obvious first thing to try. + // + // Listed by exact path, not as `index.ts` and `types.ts`, because those names + // do carry statements elsewhere: `packages/helpers/create-bsv-app/src/index.ts` + // is a CLI that reads `process.argv` and branches on it, and excluding it by + // shape would quietly drop real code out of this gate. + /packages\/overlays\/topics\/src\/index\.ts$/, + /packages\/overlays\/topics\/src\/uoradpp\/types\.ts$/ ] function normalizedPath(value) { diff --git a/scripts/patch-coverage.test.mjs b/scripts/patch-coverage.test.mjs index 2afbd0b63..1602c32f7 100644 --- a/scripts/patch-coverage.test.mjs +++ b/scripts/patch-coverage.test.mjs @@ -101,3 +101,29 @@ diff --git a/packages/helpers/example/src/index.ts b/packages/helpers/example/sr assert.deepEqual([...changed.keys()], ['packages/helpers/example/src/index.ts']) }) + +test('patch coverage ignores documentation modules and named barrels, but not every index', () => { + const changed = + changedLinesFromDiff(`diff --git a/packages/overlays/topics/src/uoradpp/UoraDppTopicDocs.md.ts b/packages/overlays/topics/src/uoradpp/UoraDppTopicDocs.md.ts ++++ b/packages/overlays/topics/src/uoradpp/UoraDppTopicDocs.md.ts +@@ -0,0 +1,24 @@ +diff --git a/packages/overlays/overlay-express/src/generalGuide.md.ts b/packages/overlays/overlay-express/src/generalGuide.md.ts ++++ b/packages/overlays/overlay-express/src/generalGuide.md.ts +@@ -0,0 +1,57 @@ +diff --git a/packages/overlays/topics/src/index.ts b/packages/overlays/topics/src/index.ts ++++ b/packages/overlays/topics/src/index.ts +@@ -0,0 +1,8 @@ +diff --git a/packages/overlays/topics/src/uoradpp/types.ts b/packages/overlays/topics/src/uoradpp/types.ts ++++ b/packages/overlays/topics/src/uoradpp/types.ts +@@ -0,0 +1,58 @@ +diff --git a/packages/helpers/create-bsv-app/src/index.ts b/packages/helpers/create-bsv-app/src/index.ts ++++ b/packages/helpers/create-bsv-app/src/index.ts +@@ -0,0 +1,40 @@ +`) + + // The last one is the point of this test. `create-bsv-app`'s entry point is a + // CLI that reads `process.argv` and branches on it, so excluding barrels by + // the name `index.ts` rather than by path would drop real code out of this + // gate without anybody noticing. + assert.deepEqual([...changed.keys()], ['packages/helpers/create-bsv-app/src/index.ts']) +})