feat(overlay-topics): add tm_uora_dpp / ls_uora_dpp for attestation anchors - #423
Open
mo-jaber wants to merge 8 commits into
Open
feat(overlay-topics): add tm_uora_dpp / ls_uora_dpp for attestation anchors#423mo-jaber wants to merge 8 commits into
mo-jaber wants to merge 8 commits into
Conversation
…nchors 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.
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.
`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.
mo-jaber
force-pushed
the
feat/uora-dpp-topic
branch
from
August 3, 2026 12:02
431c68d to
d1f0d21
Compare
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.
`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.
…t 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.
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.
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.
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
mo-jaber
marked this pull request as ready for review
August 3, 2026 14:03
mo-jaber
requested review from
BraydenLangley,
sirdeggen,
tonesnotes and
ty-everett
as code owners
August 3, 2026 14:03
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
Adds
tm_uora_dppandls_uora_dpp: admission and lookup for UORA attestation anchors, keyed on thedid:keyof the party that made the claim.Additive only. No existing topic, export, type, schema, dependency or behaviour changes.
Before reviewing this: the question that decides the rest
The defect this went through was not in this topic's logic.
PushDrop.locksignsfields.flat(), the field bytes run together, so its signature fixes the total string and never where one field ends.packages/sdk/src/script/templates/PushDrop.tsoffers no boundary-committing option, and several topic managers already onmainverify over the same concatenation.So the useful question is not "is this topic correct" but "should the SDK grow an opt-in boundary-committing signing mode, or does each format define its own preimage". That answer changes what this PR should look like.
@sirdeggen: I have written up which merged call sites are affected and how far each is actually exposed. Deliberately kept out of a public PR body; sending it to you directly. Either way the preimage is the same bytes, so this work is not wasted if the SDK later grows a helper.
Why this exists
The format
A 1-satoshi PushDrop output:
uora-anchor-v3did:keyissuer,issuerKey,subject,attestationIdordigest, narrowed optionally byuoraTypeoranchoredBy.The decision worth reviewing
Field 6 names the anchoring service, and the locking key must be its BRC-42 child at
[1, 'uora anchor v3'], key id the attestation id, counterpartyanyone. So an anchor names its author checkably by anyone holding only the transaction, with nothing configured. A shared node can carry anchors from a deployment it was never told about. This followscertmap.That derivation is not itself the proof, and saying so was the mistake. Counterparty
anyoneis exactly what makes it reproducible, so anybody can compute that key and lock an output to it. The step needing the service's private key is the signature.Why that mattered, in v2:
v3 changes only the preimage. Fields, order, derivation, counterparty and
forSelfare unchanged. The prefix moves because a reader has no other way to know which preimage to rebuild, and v2 is no longer admitted.Still not proved: that the party in field 3 made the claim. Anyone able to write an anchor can write any DID there. That is settled by the attestation's own signature, off chain. The class docblock and
getDocumentation()both say so, because the field invites the opposite reading.Compatibility
src/index.tsand added to thepack:checkallowlistuoraDppAnchors, created lazily@bsv/sdkusage stays inside the declared^2.1.6peer range; no dependency addedshared/identifyPushDropOutputshelperassertAnchorSignatureis exported alongsidereadUoraAnchordeliberately: the latter checks only what anybody can reproduce, so a consumer given it alone would believe it had checked authorship when it had notOne thing outside this package, which a reviewer should see rather than wave through: this PR widens a repository-wide gate. Four file shapes that emit nothing for coverage instrumentation (a barrel, a types module, two
*.md.tsdocumentation modules) are now exempt from patch coverage inscripts/patch-coverage.mjs..md.tsis excluded by shape (13 such files across four packages, none with a statement); the barrel and types module by exact path, becausecreate-bsv-app/src/index.tsis a CLI that readsprocess.argvand a shape rule would have dropped real code out of the gate. Tested both ways.Dependency evidence
No dependency changed. The only edit to
packages/overlays/topics/package.jsonis thepack:check --exportsallowlist, gaining the ten names this PR exports. The governance gate matches dependency files by filename rather than content, so the fields are completed rather than the section omitted.pack:check --exportsallowlist, whichscripts/check-package-artifact.mjsrequires for every exported name. No name was removed.@bsv/sdkstays inside the declared^2.1.6peer range; the new code calls onlyPushDrop,ProtoWallet,PublicKeyandUtils, includingUtils.Writer.mongodband@bsv/overlayare used ascertmapandsupplychainuse them. Node and browser targets untouched.pnpm-lock.yamlandpnpm-workspace.yamlare byte-identical tomain, because no manifest dependency field changed.pnpm install --frozen-lockfilesucceeds unmodified.main's. Both exact-head CodeQL analyses pass with no new alert, Dependency Review passes, Socket Security reports no new alert.pnpm --filter @bsv/overlay-topics testpasses: 40 suites, 426 tests.typecheckandlint --deny-warningsclean.pack:checkresolves every allowlisted export from the packed artifact, verifying conditional and wildcard exports, source maps, publint, strict type resolution and ESM consumers.tm_uora_dpp, so existing topics carry no new work. TheuoraDppAnchorscollection and its seven indexes are created lazily.@bsv/overlay-topicsonly, and no version bump is proposed. It stays at 1.6.8 with the changelog entry under## [Unreleased], because the format is not final. A patch bump belongs with whichever release picks it up.Evidence
typechecklint(oxlint--deny-warnings)format:checkpack:checktestThree tests worth reading:
refuses an anchor naming a service its locking key cannot come fromis one half of the format's claimis refused by the signature check, underthe boundary between the subject and the type, is the other half and the case v2 failed. Its vectors come from the shared fixture, so all three implementations refuse the same bytesaccepts a signature this repository cannot producereads the fixture the writer generated. Nothing here holds that key, so it passing is two implementations agreeing rather than one agreeing with itselfAlso fixed, each found while pinning the above:
assertAnchorSignature's failure branch was unreachable:verifySignaturethrows rather than returning a verdict, so a bad signature surfaced as the wallet's wording{ issuer: '' }reached Mongo as an empty filter: the table scan the guard exists to refuse, pageable withskipissuerKeyselects on its own but had no indexOn mainnet
a66141b1…6da:0. That is the anchor that reads 63 ways, so it is now evidence of the defect rather than of the format. It stays on chain and a v3 reader does not admit it.a842145d…5cd0:0: eight fields, a per-brand issuerdid:key, locking key deriving from field 6, signature verifying, topic manager admitting it.Status
The data specification behind this is not final and the group expects to revise it as the standards settle.
An earlier draft said one anchor existed so changing the layout "costs a paragraph rather than a migration". That was true of the layout and not of the guarantee. Doing it once cost three implementations, two copies of a shared fixture, a normative document and a reopened delivery item. Worth recording, because the cheapness of a change is not the cheapness of being wrong about what it proved.
Questions
@bsv/didinstead of hand-rollingdid:key? The reader needs about fifteen lines of multibase encoding.@bsv/didis a sibling workspace package exporting exactly this. The argument against is that it pullsqrcode, and an overlay index has no business shipping a QR library. Your call.tm_uora_dpphas an underscore where the convention here is unbroken (tm_supplychain,tm_walletconfig). It sits beside atm_dppthe programme already runs and the plan names it, so a rename is @sirdeggen's call rather than mine.specs/eventually? A stronger yes than before: the reason this was found late is that the format's only normative statement lived in another repository, restated in prose rather than pinned by a vector anyone could run. Not proposed in this PR.Provenance
tm_uora_dpptopic manager + DID-keyed lookup in ts-stackpackages/overlays/topics, rolled into our existing overlay infra, mainnet".uora,dppandproduct passportreturn nothing, and no open PR touchespackages/overlays/topics.