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 diff --git a/packages/overlays/topics/CHANGELOG.md b/packages/overlays/topics/CHANGELOG.md index 1a3e4c8b9..35f71f821 100644 --- a/packages/overlays/topics/CHANGELOG.md +++ b/packages/overlays/topics/CHANGELOG.md @@ -10,7 +10,15 @@ 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-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. 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/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..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,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,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__/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/packages/overlays/topics/src/__tests__/uoradpp.test.ts b/packages/overlays/topics/src/__tests__/uoradpp.test.ts new file mode 100644 index 000000000..73f950857 --- /dev/null +++ b/packages/overlays/topics/src/__tests__/uoradpp.test.ts @@ -0,0 +1,672 @@ +/** + * 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. + * + * 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' +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 { UoraDppStorage } from '../uoradpp/UoraDppStorage.js' +import { + anchorSigningPreimage, + assertAnchorSignature, + didKeyFromIdentityKey, + expectedLockingKey, + identityKeyFromDidKey, + readUoraAnchor, + 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 } } + +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, so nothing a caller does can be seen by the next. */ +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')) +} + +/** + * 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( + [...fields, signature], + UORA_ANCHOR_PROTOCOL, + keyId, + 'anyone', + true, + false + ) +} + +/** 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).toHaveLength(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) })).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 })).toHaveLength(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 })).toHaveLength(1) + expect(await ask({ issuer: MAKER, skip: 1 })).toHaveLength(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 })).toHaveLength(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 })).toHaveLength(2) + }) + + it('describes itself', async () => { + expect(await service.getMetaData()).toMatchObject({ name: 'UORA DPP Lookup Service' }) + 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([]) + } + }) +}) + +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).toHaveLength(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) + }) +}) diff --git a/packages/overlays/topics/src/index.ts b/packages/overlays/topics/src/index.ts index adf002b12..12608b60d 100644 --- a/packages/overlays/topics/src/index.ts +++ b/packages/overlays/topics/src/index.ts @@ -83,6 +83,28 @@ 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' +// `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, + 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/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 new file mode 100644 index 000000000..9df9e0a37 --- /dev/null +++ b/packages/overlays/topics/src/uoradpp/UoraDppLookupService.ts @@ -0,0 +1,140 @@ +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' +import docs from './UoraDppLookupDocs.md.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. + // + // 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 = + 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') + } + + return await this.storage.find(query) + } + + async getDocumentation(): Promise { + return docs + } + + 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..f79cf15a2 --- /dev/null +++ b/packages/overlays/topics/src/uoradpp/UoraDppStorage.ts @@ -0,0 +1,96 @@ +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 { + 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' }) + await this.records.createIndex({ anchoredBy: 1, createdAt: 1 }, { name: 'anchoredByIndex' }) + await this.records.createIndex( + { 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 + } + + /** + * 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/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 new file mode 100644 index 000000000..f75e39d9a --- /dev/null +++ b/packages/overlays/topics/src/uoradpp/UoraDppTopicManager.ts @@ -0,0 +1,98 @@ +import { AdmittanceInstructions, TopicManager } from '@bsv/overlay' +import { LockingScript } from '@bsv/sdk' +import { identifyPushDropOutputs } from '../shared/identifyPushDropOutputs.js' +import { assertAnchorSignature, readUoraAnchor } from './anchorFormat.js' +import docs from './UoraDppTopicDocs.md.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. + * + * "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 + * 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 docs + } + + 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/__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 new file mode 100644 index 000000000..79684db80 --- /dev/null +++ b/packages/overlays/topics/src/uoradpp/anchorFormat.ts @@ -0,0 +1,260 @@ +import { CachedKeyDeriver, LockingScript, ProtoWallet, PublicKey, PushDrop, Utils } from '@bsv/sdk' +import type { WalletProtocol } from '@bsv/sdk' + +/** + * 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-v3' + +/** + * 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 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 v3'] + +/** Fields before the appended signature. */ +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.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}")`) + 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. + * + * 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') + } + + return { + anchor: { digest, attestationId, issuer, issuerKey, subject, uoraType, anchoredBy }, + fields, + lockingPublicKey + } +} + +/** + * 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 + * 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') + // `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') +} 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 +} 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']) +})