Skip to content

Commit d467d83

Browse files
sirdeggenclaude
andauthored
fix(overlay-topics): reject unlinked Mandala token outputs; release 1.7.3 (#535)
* chore(overlay-topics): release 1.7.3 Bumps @bsv/overlay-topics to 1.7.3 for the Mandala admin-chain anchoring and spender-identity fixes, with the governed release notes, health baseline and package documentation (docs page, stack facts, API migrations) updated to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 7a4ffc6fc7a14e4cda2b91f72d04655f28ef7b1f) * fix(overlay-topics): reject any Mandala token output without a verified linkage verifyFtOutputs skipped a MandalaToken-shaped output whose linkage was missing, mismatched or unreadable, and conservationHolds summed only the admitted subset. A transaction could therefore carry an extra token output of any value, still have its siblings admitted, receive the overlay's admission signature and be broadcast: a phantom coin mined inside an attested transaction, credited by any offline verifier that stops its coverage walk at "this txid was admitted". Such an output now rejects the whole transaction with "output N: MandalaToken-decodable output with no verified linkage" (wire contract §6), byte-identical to the Go engine. Plain non-token outputs are unaffected. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 813d8b9 commit d467d83

10 files changed

Lines changed: 176 additions & 19 deletions

File tree

‎docs/packages/overlays/overlay-topics.md‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ title: '@bsv/overlay-topics'
44
kind: package
55
domain: overlays
66
npm: '@bsv/overlay-topics'
7-
version: '1.7.2'
8-
last_updated: '2026-08-27'
9-
last_verified: '2026-08-27'
7+
version: '1.7.3'
8+
last_updated: '2026-09-15'
9+
last_verified: '2026-09-15'
1010
review_cadence_days: 30
1111
repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics'
1212
status: stable
@@ -208,6 +208,36 @@ const admittance = await manager.identifyAdmissibleOutputs(beef, [])
208208
- [Source on GitHub](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics)
209209
- [npm](https://www.npmjs.com/package/@bsv/overlay-topics)
210210

211+
## Mandala admin chain and spender identity (1.7.3)
212+
213+
Version 1.7.3 closes two admission holes in `tm_mandala`:
214+
215+
- Admin actions are anchored to the chain of spends. A non-genesis action
216+
(`issue`, `reissue`, `unpause`, `unfreeze`, `allowIdentity`, …) must spend a
217+
prior the engine lists in `previousCoins`, and when the optional
218+
`stateStore.isAdminOutpoint(assetId, txid, vout)` is supplied, one recorded
219+
as an admin output of that asset. Re-deriving the lock key from
220+
`details.counterparty` proved nothing, because BRC-42 lets the named
221+
counterparty compute and spend that key itself.
222+
- Spenders are named from the owner bound at admission
223+
(`stateStore.getTokenRow`), not from input linkage. A supplied input linkage
224+
is verified as proof with `verifyInputKeyLinkage` (`prover + L*G`): it must
225+
control the coin being spent and agree with the stored owner, or the
226+
transaction is rejected. Sanctions and access-mode screening therefore run
227+
against the actual spender, and spends of sender-blinded receipts are no
228+
longer refused.
229+
230+
- Every token-shaped output must carry a linkage that verifies to the key it
231+
is locked to. One that is missing, mismatched or unreadable rejects the whole
232+
transaction with `output N: MandalaToken-decodable output with no verified
233+
linkage`. Skipping it left a phantom coin: siblings were admitted, the
234+
admission signed and the transaction broadcast with an unattested token
235+
output inside it.
236+
237+
Operators should supply `isAdminOutpoint` from their lookup store; without it
238+
the prior check still requires the spent input to be one the engine admitted.
239+
Topic and lookup identifiers, persisted schemas and query shapes are unchanged.
240+
211241
## UORA v3 reader compatibility
212242

213243
Version 1.7.2 aligns `readUoraAnchor` and `tm_uora_dpp` with the UORA v3 format:

‎docs/reference/package-api-migrations.md‎

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

‎docs/reference/stack-facts.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ authorized release action.
6161
| overlays | `@bsv/overlay` | `2.3.1` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay) |
6262
| overlays | `@bsv/overlay-discovery-services` | `2.2.1` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-discovery-services](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services) |
6363
| overlays | `@bsv/overlay-express` | `2.6.1` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-express](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-express) |
64-
| overlays | `@bsv/overlay-topics` | `1.7.2` | node-library | node-esm | node | `>=22` | [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) |
64+
| overlays | `@bsv/overlay-topics` | `1.7.3` | node-library | node-esm | node | `>=22` | [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) |
6565
| sdk | `@bsv/sdk` | `2.7.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) |
6666
| sdk | `@bsv/verifast` | `0.3.5` | wasm-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global, wasm-worker | browser, node, umd, wasm, worker | `>=22` | [packages/verifast](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/verifast) |
6767
| wallet | `@bsv/btms` | `1.2.2` | node-library | node-cjs, node-esm | node | `>=22` | [packages/wallet/btms](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms) |

‎governance/package-release-notes.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,8 @@
147147
"name": "@bsv/overlay-topics",
148148
"publishedVersion": "1.6.10",
149149
"releaseType": "minor",
150-
"summary": "Adds persistent first-writer reservations for UMP presentation and recovery hashes, aborts provisional claims after strict broadcast failure, keeps confirmed owners protected until successor indexing, retries transient initialization, marks one-time legacy bootstrap, and returns the newest bounded legacy candidates for verified lineage or an operator pin. It also retains the collection-index resilience and opt-in repair controls from the prior candidate. Standardizes first-party author metadata on the current BSV Association name. Aligns the UORA v3 reader with the versioned format, including compressed locking keys, exact drop tails, and printable UTF-8 fields.",
151-
"migration": "Existing topic and lookup identifiers remain unchanged. Production UMP overlays must give UMPTopicManager and the UMP lookup service Mongo-backed stores that use the same database, then roll out before updated wallet clients; the no-argument manager is bounded but intended only for isolated single-process use. The reservation and bootstrap-marker collections are additive and initialize from currently indexed UMP UTXOs; take a MongoDB backup before rollout. Legacy ambiguous rows remain visible and can be resolved with WAB pinning rather than deleted. Valid uora-anchor-v3 outputs retain their bytes, signatures, and admission result. Readers now reject nonconforming key, tail, and text shapes. Coordinate reader upgrades across nodes serving tm_uora_dpp and audit any previously indexed nonconforming outputs before rebuilding that topic. No wire prefix, export, or persisted schema changes are required."
150+
"summary": "Adds persistent first-writer reservations for UMP presentation and recovery hashes, aborts provisional claims after strict broadcast failure, keeps confirmed owners protected until successor indexing, retries transient initialization, marks one-time legacy bootstrap, and returns the newest bounded legacy candidates for verified lineage or an operator pin. It also retains the collection-index resilience and opt-in repair controls from the prior candidate. Standardizes first-party author metadata on the current BSV Association name. Aligns the UORA v3 reader with the versioned format, including compressed locking keys, exact drop tails, and printable UTF-8 fields. Anchors Mandala admin actions to the chain of spends and names token spenders from the owner bound at admission, verifying any input linkage as proof of control. Rejects any token-shaped output that lacks a verified linkage instead of skipping it.",
151+
"migration": "Existing topic and lookup identifiers remain unchanged. Production UMP overlays must give UMPTopicManager and the UMP lookup service Mongo-backed stores that use the same database, then roll out before updated wallet clients; the no-argument manager is bounded but intended only for isolated single-process use. The reservation and bootstrap-marker collections are additive and initialize from currently indexed UMP UTXOs; take a MongoDB backup before rollout. Legacy ambiguous rows remain visible and can be resolved with WAB pinning rather than deleted. Valid uora-anchor-v3 outputs retain their bytes, signatures, and admission result. Readers now reject nonconforming key, tail, and text shapes. Coordinate reader upgrades across nodes serving tm_uora_dpp and audit any previously indexed nonconforming outputs before rebuilding that topic. Mandala operators should supply the optional stateStore.isAdminOutpoint from their lookup store so admin actions anchor to recorded admin outputs; without it the prior must still be an input the engine admitted. Wallets that reveal input linkages must reveal the spender's own child key (prover + L*G). No wire prefix, export, or persisted schema changes are required."
152152
},
153153
{
154154
"name": "@bsv/paymail",

‎governance/repository-health/baselines.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@
321321
"@bsv/overlay": "2.3.1",
322322
"@bsv/overlay-discovery-services": "2.2.1",
323323
"@bsv/overlay-express": "2.6.1",
324-
"@bsv/overlay-topics": "1.7.2",
324+
"@bsv/overlay-topics": "1.7.3",
325325
"@bsv/sdk": "2.7.0",
326326
"@bsv/verifast": "0.3.5",
327327
"@bsv/btms": "1.2.2",

‎packages/overlays/topics/CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ All notable changes to this project will be documented in this file. The format
3838

3939
### Fixed
4040

41+
- `tm_mandala`: a `MandalaToken`-shaped output with no linkage at its index, a
42+
linkage that verifies to a different key than the output is locked to, or a
43+
linkage the verifier cannot open now REJECTS the whole transaction with
44+
`output N: MandalaToken-decodable output with no verified linkage` (wire
45+
contract §6, byte-identical to the Go engine). Previously `verifyFtOutputs`
46+
skipped such an output and `conservationHolds` summed only the admitted
47+
subset, so a transaction could carry an extra token output of any value,
48+
still have its siblings admitted, receive the admission signature and be
49+
broadcast — a phantom coin mined inside an attested transaction that an
50+
offline verifier stopping at "this txid was admitted" would credit.
51+
4152
- Version 1.7.2 aligns `tm_uora_dpp` with the versioned UORA v3 format: compressed locking keys, exact drop tails, and printable UTF-8 fields. Valid anchors retain their bytes and admission result. The shared reference fixture covers key and tail validation. Coordinate reader upgrades and audit previously indexed nonconforming outputs before rebuilding the topic; this change does not claim a complete inventory of historical anchors.
4253

4354
### Security

‎packages/overlays/topics/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@bsv/overlay-topics",
3-
"version": "1.7.2",
3+
"version": "1.7.3",
44
"sideEffects": false,
55
"engines": {
66
"node": ">=22"

‎packages/overlays/topics/src/__tests__/mandala.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,10 @@ describe('MandalaTopicManager', () => {
7474
await expect(tm.identifyAdmissibleOutputs(beef, [0], offChainValues)).rejects.toThrow('sanctioned')
7575
})
7676

77-
it('does not admit FT outputs lacking valid linkage', async () => {
77+
it('rejects the whole tx when an FT output lacks a linkage (never a silent skip)', async () => {
7878
const { tm, beef } = await buildTransfer()
79-
const result = await tm.identifyAdmissibleOutputs(beef, [0])
80-
expect(result.outputsToAdmit).toEqual([])
79+
await expect(tm.identifyAdmissibleOutputs(beef, [0]))
80+
.rejects.toThrow('output 0: MandalaToken-decodable output with no verified linkage')
8181
})
8282

8383
it('rejects an unbacked mint: FT output with no inputs and no admin authorization', async () => {

‎packages/overlays/topics/src/mandala/MandalaTopicManager.ts‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,18 +201,36 @@ export class MandalaTopicManager implements TopicManager {
201201
return { admitted: true }
202202
}
203203

204+
/**
205+
* Every token-shaped output must carry a linkage that verifies to the key
206+
* it is locked to; one that does not REJECTS the whole transaction.
207+
*
208+
* Skipping such an output instead (the previous behaviour) left a phantom
209+
* coin: `conservationHolds` sums only the admitted subset, so a transaction
210+
* could carry an extra `MandalaToken` output of any value, still have its
211+
* siblings admitted, still receive the overlay's admission signature and
212+
* still be broadcast — mined inside a transaction the overlay genuinely
213+
* attested to. An offline verifier that stops its coverage walk at "this
214+
* txid was admitted" then credits the phantom. The reason string is the
215+
* wire contract's (§6) and is byte-identical across engines.
216+
*/
204217
private async verifyFtOutputs (
205218
ftOutputs: FtOutput[],
206219
outputLinkage: Map<number, SpecificLinkage>
207220
): Promise<AdmittedFt[]> {
208221
const admittedFt: AdmittedFt[] = []
209222
for (const ft of ftOutputs) {
210223
const linkage = outputLinkage.get(ft.index)
211-
if (linkage == null) continue
212-
const verified = await verifyKeyLinkage(linkage, this.deps.verifierWallet)
213-
const matches = verified.pubKeyHash.length === ft.pubKeyHash.length &&
214-
verified.pubKeyHash.every((b, i) => b === ft.pubKeyHash[i])
215-
if (!matches) continue
224+
if (linkage == null) throw new Error(unlinkedTokenReason(ft.index))
225+
let verified: Awaited<ReturnType<typeof verifyKeyLinkage>>
226+
try {
227+
verified = await verifyKeyLinkage(linkage, this.deps.verifierWallet)
228+
} catch {
229+
// The verifier wallet is local, in-process crypto: a throw here is a
230+
// malformed linkage, which proves nothing about the output.
231+
throw new Error(unlinkedTokenReason(ft.index))
232+
}
233+
if (!sameBytes(verified.pubKeyHash, ft.pubKeyHash)) throw new Error(unlinkedTokenReason(ft.index))
216234
admittedFt.push({ index: ft.index, assetId: ft.assetId, amount: ft.amount, identityKey: verified.identityKey })
217235
}
218236
return admittedFt
@@ -493,6 +511,10 @@ export class MandalaTopicManager implements TopicManager {
493511
}
494512
}
495513

514+
/** Wire contract §6 — the same bytes on every engine. Do not reword. */
515+
export const unlinkedTokenReason = (index: number): string =>
516+
`output ${index}: MandalaToken-decodable output with no verified linkage`
517+
496518
function sameBytes (a: number[], b: number[]): boolean {
497519
return a.length === b.length && a.every((x, i) => x === b[i])
498520
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { MandalaTopicManager, unlinkedTokenReason } from '../MandalaTopicManager.js'
2+
import { InMemoryScreeningProvider, encodeLinkagePayload, MandalaLinkagePayload } from '../types.js'
3+
import { defaultAssetState } from '../AssetStateReducer.js'
4+
import { MandalaToken } from '@bsv/templates'
5+
import { ProtoWallet, PrivateKey, Hash, Utils, WalletProtocol, Transaction, P2PKH, UnlockingScript } from '@bsv/sdk'
6+
7+
// A token-shaped output the overlay cannot attribute to a verified linkage
8+
// rejects the WHOLE transaction. Skipping it admitted its siblings, signed the
9+
// admission and broadcast — a phantom coin mined inside an attested tx.
10+
11+
const protocolID: WalletProtocol = [2, 'mandala token']
12+
const keyID = 'tkn'
13+
const assetId = `${'a'.repeat(64)}.0`
14+
15+
const sender = new ProtoWallet(PrivateKey.fromRandom())
16+
const receiver = new ProtoWallet(PrivateKey.fromRandom())
17+
const other = new ProtoWallet(PrivateKey.fromRandom())
18+
const overlay = new ProtoWallet(PrivateKey.fromRandom())
19+
20+
const manager = new MandalaTopicManager({
21+
verifierWallet: overlay as any,
22+
screeningProvider: new InMemoryScreeningProvider([]),
23+
adminWallet: overlay as any,
24+
adminProtocolID: [2, 'mandala admin'],
25+
stateStore: {
26+
getAssetState: async () => defaultAssetState(assetId),
27+
getTokenRow: async () => null,
28+
isAdminOutpoint: async () => true
29+
}
30+
})
31+
32+
const identity = async (w: ProtoWallet): Promise<string> => (await w.getPublicKey({ identityKey: true })).publicKey
33+
34+
/** A conserved 100-unit transfer: one token input, `outputs` token outputs summing to 100. */
35+
async function transfer (amounts: number[]): Promise<{ tx: Transaction, linkageFor: (counterparty: ProtoWallet) => Promise<any> }> {
36+
const [receiverKey, verifierKey] = await Promise.all([identity(receiver), identity(overlay)])
37+
const { publicKey: derived } = await sender.getPublicKey({ protocolID, keyID, counterparty: receiverKey })
38+
const pkh = Hash.hash160(Utils.toArray(derived, 'hex'))
39+
const source = new Transaction()
40+
source.addOutput({ lockingScript: new MandalaToken().lock(assetId, 100, pkh), satoshis: 1 })
41+
const tx = new Transaction()
42+
tx.addInput({ sourceTransaction: source, sourceOutputIndex: 0, unlockingScript: new UnlockingScript() })
43+
for (const amount of amounts) tx.addOutput({ lockingScript: new MandalaToken().lock(assetId, amount, pkh), satoshis: 1 })
44+
tx.addOutput({ lockingScript: new P2PKH().lock(Hash.hash160(Utils.toArray('01', 'hex'))), satoshis: 500 })
45+
const linkageFor = async (counterparty: ProtoWallet): Promise<any> =>
46+
await sender.revealSpecificKeyLinkage({ counterparty: await identity(counterparty), verifier: verifierKey, protocolID, keyID })
47+
return { tx, linkageFor }
48+
}
49+
50+
const run = async (tx: Transaction, payload: MandalaLinkagePayload): Promise<{ outputsToAdmit: number[] }> =>
51+
await manager.identifyAdmissibleOutputs(tx.toBEEF(), [0], encodeLinkagePayload(payload)) as any
52+
53+
describe('MandalaTopicManager rejects any token output without a verified linkage', () => {
54+
it('names the reason exactly as the wire contract does', () => {
55+
expect(unlinkedTokenReason(3)).toBe('output 3: MandalaToken-decodable output with no verified linkage')
56+
})
57+
58+
it('admits a transfer whose every token output is linked', async () => {
59+
const { tx, linkageFor } = await transfer([60, 40])
60+
const linkage = await linkageFor(receiver)
61+
const res = await run(tx, { inputs: [], outputs: [{ index: 0, linkage }, { index: 1, linkage }] })
62+
expect(res.outputsToAdmit).toEqual([0, 1])
63+
})
64+
65+
it('rejects when one token output has no linkage, even though its siblings verify', async () => {
66+
const { tx, linkageFor } = await transfer([60, 40])
67+
const linkage = await linkageFor(receiver)
68+
await expect(run(tx, { inputs: [], outputs: [{ index: 0, linkage }] }))
69+
.rejects.toThrow(unlinkedTokenReason(1))
70+
})
71+
72+
it('rejects a linkage that verifies to a key other than the one the output is locked to', async () => {
73+
const { tx, linkageFor } = await transfer([100])
74+
const wrongParty = await linkageFor(other)
75+
await expect(run(tx, { inputs: [], outputs: [{ index: 0, linkage: wrongParty }] }))
76+
.rejects.toThrow(unlinkedTokenReason(0))
77+
})
78+
79+
it('rejects a malformed linkage the verifier cannot open', async () => {
80+
const { tx, linkageFor } = await transfer([100])
81+
const linkage = await linkageFor(receiver)
82+
const garbled = { ...linkage, encryptedLinkage: [1, 2, 3] }
83+
await expect(run(tx, { inputs: [], outputs: [{ index: 0, linkage: garbled }] }))
84+
.rejects.toThrow(unlinkedTokenReason(0))
85+
})
86+
87+
it('leaves plain, non-token outputs out of the rule', async () => {
88+
const { tx, linkageFor } = await transfer([100])
89+
const linkage = await linkageFor(receiver)
90+
// Output 1 is ordinary P2PKH change with no linkage: not this rule's business.
91+
const res = await run(tx, { inputs: [], outputs: [{ index: 0, linkage }] })
92+
expect(res.outputsToAdmit).toEqual([0])
93+
})
94+
})

0 commit comments

Comments
 (0)