Skip to content

feat(overlay-topics): add tm_uora_dpp / ls_uora_dpp for attestation anchors - #423

Open
mo-jaber wants to merge 8 commits into
mainfrom
feat/uora-dpp-topic
Open

feat(overlay-topics): add tm_uora_dpp / ls_uora_dpp for attestation anchors#423
mo-jaber wants to merge 8 commits into
mainfrom
feat/uora-dpp-topic

Conversation

@mo-jaber

@mo-jaber mo-jaber commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Context, FYI: DPP on BSV: full context, architecture and open decisions. Background on the programme, optional. This PR stands on its own.

Summary

Adds tm_uora_dpp and ls_uora_dpp: admission and lookup for UORA attestation anchors, keyed on the did:key of 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.lock signs fields.flat(), the field bytes run together, so its signature fixes the total string and never where one field ends.
  • Any format with two adjacent variable-length fields whose boundary nothing else pins inherits that.
  • This PR fixes its own format by signing a length-prefixed preimage. It cannot fix the general case: packages/sdk/src/script/templates/PushDrop.ts offers no boundary-committing option, and several topic managers already on main verify 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

  • A digital product passport records claims about one object over its life, each with a different author.
  • The claims cannot go on chain: personal data, and a battery fleet produces one per service event. Only a digest is anchored.
  • But a digest alone cannot be indexed by author. An overlay holding a hash of a document it has never seen knows something existed and cannot say whose claim it was.
  • So "what has this party attested" has nowhere to be answered. That is what this topic is for.

The format

A 1-satoshi PushDrop output:

# Field
0 uora-anchor-v3
1 SHA-256 of the attestation's RFC 8785 canonical form
2 attestation id
3 the issuer's did:key
4 subject: the product passport identifier
5 UORA attestation type
6 the anchoring service's identity key
7 signature over fields 0 to 6, each preceded by its length as a varint
  • Lookups take issuer, issuerKey, subject, attestationId or digest, narrowed optionally by uoraType or anchoredBy.
  • Answers are the outputs as BEEF, so a caller checks them against the chain rather than trusting the index.
  • Anchors are leaves: never spent, nothing retained. Every valid anchor in a transaction is admitted, so a service can batch.

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, counterparty anyone. 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 follows certmap.

That derivation is not itself the proof, and saying so was the mistake. Counterparty anyone is 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:

  • Four of the seven fields have their boundaries pinned independently: the prefix is a fixed literal, the digest is 64 hex characters, and the attestation id and anchoring key are fixed by the derivation.
  • The subject and the type are neither, and they are adjacent.
  • So any holder could re-cut that boundary into a different subject and type, copy the signature verbatim, and be admitted. The signature verified because the concatenation was identical; the derivation passed because the two fields it pins are the two a re-cut leaves alone. No private key required.
  • The one v2 anchor on mainnet reads 63 ways.

v3 changes only the preimage. Fields, order, derivation, counterparty and forSelf are 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

  • New exports registered in src/index.ts and added to the pack:check allowlist
  • New Mongo collection uoraDppAnchors, created lazily
  • @bsv/sdk usage stays inside the declared ^2.1.6 peer range; no dependency added
  • Built on the existing shared/identifyPushDropOutputs helper
  • assertAnchorSignature is exported alongside readUoraAnchor deliberately: the latter checks only what anybody can reproduce, so a consumer given it alone would believe it had checked authorship when it had not

One 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.ts documentation modules) are now exempt from patch coverage in scripts/patch-coverage.mjs. .md.ts is excluded by shape (13 such files across four packages, none with a statement); the barrel and types module by exact path, because create-bsv-app/src/index.ts is a CLI that reads process.argv and 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.json is the pack:check --exports allowlist, 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.

  • Release notes and necessity: Not applicable, nothing was added, removed or version-changed. The manifest edit extends the existing pack:check --exports allowlist, which scripts/check-package-artifact.mjs requires for every exported name. No name was removed.
  • Runtime, build, and peer compatibility: Unchanged. @bsv/sdk stays inside the declared ^2.1.6 peer range; the new code calls only PushDrop, ProtoWallet, PublicKey and Utils, including Utils.Writer. mongodb and @bsv/overlay are used as certmap and supplychain use them. Node and browser targets untouched.
  • Deduplicated lockfile: Unaffected. pnpm-lock.yaml and pnpm-workspace.yaml are byte-identical to main, because no manifest dependency field changed. pnpm install --frozen-lockfile succeeds unmodified.
  • Audit and CodeQL: No third-party code enters the graph, so the audited surface is main's. Both exact-head CodeQL analyses pass with no new alert, Dependency Review passes, Socket Security reports no new alert.
  • Package and consumer tests: pnpm --filter @bsv/overlay-topics test passes: 40 suites, 426 tests. typecheck and lint --deny-warnings clean. pack:check resolves every allowlisted export from the packed artifact, verifying conditional and wildcard exports, source maps, publint, strict type resolution and ESM consumers.
  • Bundle and performance impact: No third-party bytes added. The topic is inert unless an operator configures tm_uora_dpp, so existing topics carry no new work. The uoraDppAnchors collection and its seven indexes are created lazily.
  • Affected public package versions: @bsv/overlay-topics only, 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

Gate Result
typecheck clean
lint (oxlint --deny-warnings) clean
format:check clean
pack:check verified
test 40 suites, 426 tests
patch coverage 92.98% against a 90% target
Sonar exact-head zero new findings, 0.0% new duplication

Three tests worth reading:

  • refuses an anchor naming a service its locking key cannot come from is one half of the format's claim
  • is refused by the signature check, under the 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 bytes
  • accepts a signature this repository cannot produce reads the fixture the writer generated. Nothing here holds that key, so it passing is two implementations agreeing rather than one agreeing with itself

Also fixed, each found while pinning the above:

  • assertAnchorSignature's failure branch was unreachable: verifySignature throws rather than returning a verdict, so a bad signature surfaced as the wallet's wording
  • the lookup selectivity guard tested for a key being present while storage required a non-empty string, so { issuer: '' } 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

On mainnet

  • The v2 rail was proved end to end at 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.
  • v3 has since been proved at a842145d…5cd0:0: eight fields, a per-brand issuer did:key, locking key deriving from field 6, signature verifying, topic manager admitting it.
  • The check that matters: 56 re-cuts of the subject/type boundary carrying the original signature verbatim were each refused, where the same attack on the v2 anchor accepted 63. Read from a public explorer, not from the issuing service.

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

  1. The SDK question above, which is the one that matters.
  2. @bsv/did instead of hand-rolling did:key? The reader needs about fifteen lines of multibase encoding. @bsv/did is a sibling workspace package exporting exactly this. The argument against is that it pulls qrcode, and an overlay index has no business shipping a QR library. Your call.
  3. Topic naming. tm_uora_dpp has an underscore where the convention here is unbroken (tm_supplychain, tm_walletconfig). It sits beside a tm_dpp the programme already runs and the plan names it, so a rename is @sirdeggen's call rather than mine.
  4. Does the anchor format belong in 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

  • Implements a deliverable named in a BSV Association programme plan of 12 June 2026, "DPP on BSV, Plan and Next Steps", by @sirdeggen: "a new tm_uora_dpp topic manager + DID-keyed lookup in ts-stack packages/overlays/topics, rolled into our existing overlay infra, mainnet".
  • The writer lives in a separate BSV Association service. Its format document is implementable without access to either repository, and the implementations are pinned by a fixture committed verbatim in each, since none can import the others. That fixture now carries negative vectors as well as a positive one; having only the latter is how a malleable format passed its own conformance suite.
  • Both other implementations have since shipped v3 to their default branches. This PR is the last of the three to move, deliberately: the writer had to go first because it is the only side that can produce the bytes the other two pin to.
  • No overlapping work: searches for uora, dpp and product passport return nothing, and no open PR touches packages/overlays/topics.

…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
mo-jaber force-pushed the feat/uora-dpp-topic branch from 431c68d to d1f0d21 Compare August 3, 2026 12:02
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.
@sonarqubecloud

sonarqubecloud Bot commented Aug 3, 2026

Copy link
Copy Markdown

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.05970% with 16 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...ckages/overlays/topics/src/uoradpp/anchorFormat.ts 81.25% 2 Missing and 10 partials ⚠️
...verlays/topics/src/uoradpp/UoraDppLookupService.ts 87.09% 0 Missing and 4 partials ⚠️

📢 Thoughts on this report? Let us know!

@mo-jaber
mo-jaber marked this pull request as ready for review August 3, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants