diff --git a/docs/packages/overlays/overlay-topics.md b/docs/packages/overlays/overlay-topics.md index 73a3bb84e..e3344f5f7 100644 --- a/docs/packages/overlays/overlay-topics.md +++ b/docs/packages/overlays/overlay-topics.md @@ -4,7 +4,7 @@ title: '@bsv/overlay-topics' kind: package domain: overlays npm: '@bsv/overlay-topics' -version: '1.7.3' +version: '1.8.0' last_updated: '2026-09-15' last_verified: '2026-09-15' review_cadence_days: 30 @@ -208,36 +208,6 @@ const admittance = await manager.identifyAdmissibleOutputs(beef, []) - [Source on GitHub](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) - [npm](https://www.npmjs.com/package/@bsv/overlay-topics) -## Mandala admin chain and spender identity (1.7.3) - -Version 1.7.3 closes two admission holes in `tm_mandala`: - -- Admin actions are anchored to the chain of spends. A non-genesis action - (`issue`, `reissue`, `unpause`, `unfreeze`, `allowIdentity`, …) must spend a - prior the engine lists in `previousCoins`, and when the optional - `stateStore.isAdminOutpoint(assetId, txid, vout)` is supplied, one recorded - as an admin output of that asset. Re-deriving the lock key from - `details.counterparty` proved nothing, because BRC-42 lets the named - counterparty compute and spend that key itself. -- Spenders are named from the owner bound at admission - (`stateStore.getTokenRow`), not from input linkage. A supplied input linkage - is verified as proof with `verifyInputKeyLinkage` (`prover + L*G`): it must - control the coin being spent and agree with the stored owner, or the - transaction is rejected. Sanctions and access-mode screening therefore run - against the actual spender, and spends of sender-blinded receipts are no - longer refused. - -- Every token-shaped output must carry a linkage that verifies to the key it - is locked to. One that is missing, mismatched or unreadable rejects the whole - transaction with `output N: MandalaToken-decodable output with no verified - linkage`. Skipping it left a phantom coin: siblings were admitted, the - admission signed and the transaction broadcast with an unattested token - output inside it. - -Operators should supply `isAdminOutpoint` from their lookup store; without it -the prior check still requires the spent input to be one the engine admitted. -Topic and lookup identifiers, persisted schemas and query shapes are unchanged. - ## UORA v3 reader compatibility Version 1.7.2 aligns `readUoraAnchor` and `tm_uora_dpp` with the UORA v3 format: @@ -252,3 +222,33 @@ older readers may have admitted inputs that the format does not permit. Repository fixtures establish format compatibility; they do not establish an inventory of every deployed or historical anchor. Other topics, lookup query shapes and persisted schemas are unchanged. + +### Mandala admission and the 1.8.0 upgrade + +Use the same `MandalaStorageManager` for Mandala admission and lookup. The +reference store now implements `isAdminOutpoint(assetId, txid, outputIndex)` +against admitted admin history. Custom adapters must implement that predicate; +a missing verifier rejects non-genesis admin actions. Its optional TypeScript +member preserves source compatibility, not permission to bypass verification. +Never implement it as a constant `true`. + +Registration must omit `assetId` or use an empty string: the registration's own +outpoint defines its asset. Subsequent admin actions must spend a previously +admitted admin output for that same asset. Token spends require a stored owner +row matching the source outpoint, asset and amount. Optional input linkage +corroborates that owner and the source locking key; it cannot replace missing +state. Sender blinding and transfers without input linkage remain supported +when authoritative owner state is present. Linkage arrays require unique, +non-negative integer indices. + +Before upgrading an existing Mandala deployment, back up and audit its admin +history and token-owner records. Restore missing rows from verified admission +evidence before historical replay; do not infer authority from a submitted +payload. The engine identifies admissible outputs before sending spend +notifications, so normal admission can read the owner before lookup removes +the spent row. Custom replay adapters must preserve that ordering. These checks +do not retroactively validate old records. + +Coordinate the admission and lookup upgrade. Existing valid wire fields and +encodings are unchanged, and no database collection migration is required. +Keep the new admission checks enabled while repairing historical data. diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 506d00659..cd24f3b75 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -45,7 +45,7 @@ and clean-consumer tests remain the executable type authority. | `@bsv/overlay` | `2.2.1` | `2.3.1` | minor | [API and usage](../packages/overlays/overlay.md) | Existing Engine and TopicManager implementations remain valid. Lookup results default to 1,000 formulas; pass -1 only when an equivalent deployment bound exists. Topic managers whose validation creates provisional external state should implement abortAdmissibleOutputs, while read-only managers require no change. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/overlay-discovery-services` | `2.1.1` | `2.2.1` | minor | [API and usage](../packages/overlays/overlay-discovery-services.md) | Existing mainnet and testnet advertisers are unchanged. TTN operators pass chain ttn and provision the staging storage and overlay endpoints before advertising. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/overlay-express` | `2.5.0` | `2.6.1` | minor | [API and usage](../packages/overlays/overlay-express.md) | Existing mainnet and testnet servers are unchanged. TTN servers call configureNetwork('ttn'), configureArcade with the TTN endpoint, and configureChaintracks or configureChainTracker before engine initialization. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/overlay-topics` | `1.6.10` | `1.7.3` | minor | [API and usage](../packages/overlays/overlay-topics.md) | 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. | +| `@bsv/overlay-topics` | `1.6.10` | `1.8.0` | minor | [API and usage](../packages/overlays/overlay-topics.md) | 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. Mandala custom state adapters must implement isAdminOutpoint against admitted per-asset admin history and retain matching owner rows during admission. Registration must omit assetId or use an empty string. Back up and audit historical admin and ownership records before replay; missing records must be restored from verified admission evidence. Valid wire fields and encodings are unchanged, and no collection migration is needed. Wallets that reveal input linkage must reveal the spender's own child key. | | `@bsv/paymail` | `2.4.2` | `2.4.8` | patch | [API and usage](../packages/messaging/paymail.md) | Existing Paymail client APIs and protocol semantics are retained. Consumers provide one Express 4.18 or 5 runtime and matching type graph; browser bundles continue to exclude the server router implementation. Consumers of the former bundled Money Button or Tokenized specification documents must follow the authoritative links in docs/specs/README.md. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/payment-express-middleware` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/sdk` | `2.5.0` | `2.7.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. The optional x-bsv-payment-known-txids response header requires no consumer migration: absent or invalid-only values preserve existing payment creation. Recipients may advertise only transaction IDs they already possess and have validated, as comma-separated 64-character hexadecimal values; AuthFetch forwards at most 256 unique lowercase IDs through createAction options. Browser services must expose the optional response header through their existing CORS policy to enable this optimization. Invalid controller-signed overlay values and invalid identity certificates now fail closed instead of being returned or published; valid data and public method signatures are unchanged. Certificate responses must match a locally requested set; applications relying on unsolicited or unrequested certificates must request their intended set explicitly. Custom session stores must retain certificatePolicy and pendingCertificateRequests and coordinate concurrent writers. Existing sessions fall back to the configured handshake policy. Certificate callbacks remain post-validation observers, not veto hooks. BRC-29 wallets must return accepted: true before settlement is accepted; wire encodings and existing error identities remain unchanged. | @@ -316,8 +316,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/overlays/overlay-topics.md](../packages/overlays/overlay-topics.md) - Source: [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) -- Release note: 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. -- 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. +- Release note: 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. Version 1.8.0 requires verified per-asset admin history and matching stored token ownership for Mandala admission, assigns registrations to their own genesis outpoint, rejects ambiguous linkage indices, and normalizes in-memory sanctions key casing. Retains the complete token-output linkage verification from 1.7.3. +- 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. Mandala custom state adapters must implement isAdminOutpoint against admitted per-asset admin history and retain matching owner rows during admission. Registration must omit assetId or use an empty string. Back up and audit historical admin and ownership records before replay; missing records must be restored from verified admission evidence. Valid wire fields and encodings are unchanged, and no collection migration is needed. Wallets that reveal input linkage must reveal the spender's own child key. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | -------------------------------------- | --------------------- | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index b61129697..140a86ae4 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -61,7 +61,7 @@ authorized release action. | 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) | | 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) | | 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) | -| 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) | +| overlays | `@bsv/overlay-topics` | `1.8.0` | node-library | node-esm | node | `>=22` | [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) | | 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) | | 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) | | 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) | diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 9faeb12ae..492e394ec 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -201,7 +201,7 @@ export function buildMutationTargets(repositoryRoot) { packageDirectory: 'packages/overlays/topics', manifest: 'packages/overlays/topics/package.json', propertyTest: 'packages/overlays/topics/src/mandala/__tests/types.property.test.ts', - mutate: ['src/mandala/types.ts:72-78', 'src/admission/issuerPolicy.ts:36-39'], + mutate: ['src/mandala/types.ts:72-97', 'src/admission/issuerPolicy.ts:36-39'], ...jestTarget('jest.config.js', ['/src/mandala/__tests/types*.test.ts'], { esm: true }) diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 9370c2657..56a93483c 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -147,8 +147,8 @@ "name": "@bsv/overlay-topics", "publishedVersion": "1.6.10", "releaseType": "minor", - "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.", - "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." + "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. Version 1.8.0 requires verified per-asset admin history and matching stored token ownership for Mandala admission, assigns registrations to their own genesis outpoint, rejects ambiguous linkage indices, and normalizes in-memory sanctions key casing. Retains the complete token-output linkage verification from 1.7.3.", + "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. Mandala custom state adapters must implement isAdminOutpoint against admitted per-asset admin history and retain matching owner rows during admission. Registration must omit assetId or use an empty string. Back up and audit historical admin and ownership records before replay; missing records must be restored from verified admission evidence. Valid wire fields and encodings are unchanged, and no collection migration is needed. Wallets that reveal input linkage must reveal the spender's own child key." }, { "name": "@bsv/paymail", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index 0f9497721..5a058bcde 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -321,7 +321,7 @@ "@bsv/overlay": "2.3.1", "@bsv/overlay-discovery-services": "2.2.1", "@bsv/overlay-express": "2.6.1", - "@bsv/overlay-topics": "1.7.3", + "@bsv/overlay-topics": "1.8.0", "@bsv/sdk": "2.7.0", "@bsv/verifast": "0.3.5", "@bsv/btms": "1.2.2", diff --git a/infra/overlay-server/README.md b/infra/overlay-server/README.md index a1bd61d86..c4a566aa7 100644 --- a/infra/overlay-server/README.md +++ b/infra/overlay-server/README.md @@ -99,3 +99,14 @@ Pull requests and issues are welcome! Please open an issue to discuss any major ## License [Open BSV License Version 6](./LICENSE.txt) + +## Mandala state adapter compatibility + +The Mandala manager and lookup share one lazily initialized storage manager. +The admission adapter verifies admin outpoints against that store's per-asset +history, including the asset, transaction ID and output index. This wiring uses +the existing history API so it can compile with the currently locked package +and consume Overlay Topics 1.8.0's stricter admission contract on upgrade. +Before a deployed upgrade, follow the [Mandala migration guide](../../packages/overlays/topics/README.md#mandala-admission-and-the-180-upgrade) +and audit historical admin and owner records. Source publication does not +upgrade a running overlay or its locked dependencies automatically. diff --git a/infra/overlay-server/package.json b/infra/overlay-server/package.json index 8d46e9628..7ac466a06 100644 --- a/infra/overlay-server/package.json +++ b/infra/overlay-server/package.json @@ -26,7 +26,7 @@ "scripts": { "build": "tsc", "lint": "oxlint src --deny-warnings", - "test": "node --import tsx --test src/lifecycle.test.ts", + "test": "node --import tsx --test src/lifecycle.test.ts src/mandalaStateStore.test.ts", "start": "node --import ./dist/telemetry.js dist/index.js", "dev": "tsx src/index.ts" }, diff --git a/infra/overlay-server/src/index.ts b/infra/overlay-server/src/index.ts index 5defb9e43..3b5cb6759 100644 --- a/infra/overlay-server/src/index.ts +++ b/infra/overlay-server/src/index.ts @@ -1,3 +1,4 @@ +import { createMandalaStateStore } from './mandalaStateStore.js' import { WalletAdvertiser } from '@bsv/overlay-discovery-services' import OverlayExpress from '@bsv/overlay-express' import { @@ -440,6 +441,7 @@ const main = async () => { } return mandalaStorage } + const mandalaStateStore = createMandalaStateStore(requireMandalaStorage) server.configureTopicManager( 'tm_mandala', new MandalaTopicManager({ @@ -447,11 +449,7 @@ const main = async () => { screeningProvider: new InMemoryScreeningProvider([]), adminWallet: mandalaWallet, adminProtocolID: [2, 'mandala admin'] as [2, string], - stateStore: { - getAssetState: async assetId => await requireMandalaStorage().getAssetState(assetId), - getTokenRow: async (txid, outputIndex) => - await requireMandalaStorage().getTokenRow(txid, outputIndex) - } + stateStore: mandalaStateStore }) ) server.configureLookupServiceWithMongo('ls_mandala', db => { diff --git a/infra/overlay-server/src/mandalaStateStore.test.ts b/infra/overlay-server/src/mandalaStateStore.test.ts new file mode 100644 index 000000000..03a5acd18 --- /dev/null +++ b/infra/overlay-server/src/mandalaStateStore.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createMandalaStateStore } from './mandalaStateStore.js' + +test('resolves the shared store lazily and matches the exact admin history tuple', async () => { + const state = { assetId: 'asset' } + const token = { txid: 'token', outputIndex: 2 } + let ready = false + const store = { + async getAssetState(assetId: string) { + assert.equal(assetId, 'asset') + return state + }, + async getTokenRow(txid: string, index: number) { + assert.equal(txid, 'token') + assert.equal(index, 2) + return token + }, + async findAdminHistoryByAssetId() { + return [{ assetId: 'asset', txid: 'admin', outputIndex: 1 }] + } + } + type Store = ReturnType[0]> + const adapter = createMandalaStateStore(() => { + if (!ready) throw new Error('storage unavailable') + return store as unknown as Store + }) + await assert.rejects(adapter.isAdminOutpoint('asset', 'admin', 1), /storage unavailable/) + ready = true + assert.equal(await adapter.getAssetState('asset'), state) + assert.equal(await adapter.getTokenRow('token', 2), token) + assert.equal(await adapter.isAdminOutpoint('asset', 'admin', 1), true) + assert.equal(await adapter.isAdminOutpoint('other', 'admin', 1), false) + assert.equal(await adapter.isAdminOutpoint('asset', 'other', 1), false) + assert.equal(await adapter.isAdminOutpoint('asset', 'admin', 0), false) +}) diff --git a/infra/overlay-server/src/mandalaStateStore.ts b/infra/overlay-server/src/mandalaStateStore.ts new file mode 100644 index 000000000..0e6c12ed4 --- /dev/null +++ b/infra/overlay-server/src/mandalaStateStore.ts @@ -0,0 +1,23 @@ +import type { MandalaStorageManager } from '@bsv/overlay-topics' + +type MandalaStore = Pick< + MandalaStorageManager, + 'getAssetState' | 'getTokenRow' | 'findAdminHistoryByAssetId' +> + +/** Resolve lazily because Mongo lookup configuration initializes the shared store. */ +export function createMandalaStateStore(resolve: () => MandalaStore) { + return { + getAssetState: async (assetId: string) => await resolve().getAssetState(assetId), + getTokenRow: async (txid: string, outputIndex: number) => + await resolve().getTokenRow(txid, outputIndex), + // Use the existing history API to support the currently published store. + isAdminOutpoint: async (assetId: string, txid: string, outputIndex: number) => { + const history = await resolve().findAdminHistoryByAssetId(assetId) + return history.some( + entry => + entry.assetId === assetId && entry.txid === txid && entry.outputIndex === outputIndex + ) + } + } +} diff --git a/packages/overlays/topics/CHANGELOG.md b/packages/overlays/topics/CHANGELOG.md index 1b4334290..b43066bba 100644 --- a/packages/overlays/topics/CHANGELOG.md +++ b/packages/overlays/topics/CHANGELOG.md @@ -53,33 +53,17 @@ All notable changes to this project will be documented in this file. The format ### Security -- `tm_mandala`: anchor the admin chain to the chain of spends. `verifyAdminOutput` - re-derived the expected lock key from `details.counterparty`, which arrives in - the unauthenticated off-chain payload; by BRC-42 that key belongs to the named - counterparty, who can compute and spend it from their own root key plus the - overlay's public identity key, so any third party could reproduce the expected - `pubKeyHash`. The prior check accepted any input of the same transaction. - Together these admitted forged `unpause`, `unfreeze`, `allowIdentity` and — - because a verified admin output credits authorized issuance — forged `issue` - and `reissue`, an unbounded mint. A non-genesis action must now spend a prior - that the engine lists in `previousCoins` and, when the new optional - `stateStore.isAdminOutpoint` is supplied, one recorded as an admin output of - that asset. Delegation is unaffected: authority passes to whoever the next - admin output is locked to. -- `tm_mandala`: name a spend from the owner bound at admission rather than from - the submitted payload. `verifyKeyLinkage` returns `linkage.counterparty`, - which for an input is whoever *paid* that coin, not the spender, and can - never be checked against the coin being spent. Sanctions and access-mode - screening therefore ran against the wrong party, a submitter could steer - screening by choosing what to reveal, and omitting input linkages skipped - sender screening entirely. Spenders now come from `stateStore.getTokenRow`; a - supplied input linkage is verified as proof via the new - `verifyInputKeyLinkage` (`prover + L*G`), which must control the spent coin - and agree with the stored owner. This also unblocks sender blinding, whose - one-time key is never registry-admitted and would otherwise have caused every - spend of a blinded receipt to be refused. +- Version 1.8.0 requires admitted per-asset admin history for every non-genesis + Mandala action. The reference storage manager provides the verifier; custom + adapters must implement it. Registration uses its own genesis outpoint. +- Token spends require authoritative stored ownership matching the source + outpoint, asset and amount. Optional linkage corroborates the stored owner + and source key. Sender blinding remains supported. +- Reject duplicate or invalid linkage indices and normalize sanctions key + casing. Valid wire fields and encodings are unchanged. +- Back up and audit historical admin and ownership records before replay, and + coordinate admission and lookup upgrades. See the README migration guide. ---- ## [1.6.0] - 2026-07-10 diff --git a/packages/overlays/topics/README.md b/packages/overlays/topics/README.md index 1449fc9ba..f74f2e95c 100644 --- a/packages/overlays/topics/README.md +++ b/packages/overlays/topics/README.md @@ -65,6 +65,36 @@ Each topic ships a matching `*TopicManager` (admission rules for incoming transa Per-topic query types (`*Query`, `*Record`) are exported alongside. +### Mandala admission and the 1.8.0 upgrade + +Use the same `MandalaStorageManager` for Mandala admission and lookup. The +reference store now implements `isAdminOutpoint(assetId, txid, outputIndex)` +against admitted admin history. Custom adapters must implement that predicate; +a missing verifier rejects non-genesis admin actions. Its optional TypeScript +member preserves source compatibility, not permission to bypass verification. +Never implement it as a constant `true`. + +Registration must omit `assetId` or use an empty string: the registration's own +outpoint defines its asset. Subsequent admin actions must spend a previously +admitted admin output for that same asset. Token spends require a stored owner +row matching the source outpoint, asset and amount. Optional input linkage +corroborates that owner and the source locking key; it cannot replace missing +state. Sender blinding and transfers without input linkage remain supported +when authoritative owner state is present. Linkage arrays require unique, +non-negative integer indices. + +Before upgrading an existing Mandala deployment, back up and audit its admin +history and token-owner records. Restore missing rows from verified admission +evidence before historical replay; do not infer authority from a submitted +payload. The engine identifies admissible outputs before sending spend +notifications, so normal admission can read the owner before lookup removes +the spent row. Custom replay adapters must preserve that ordering. These checks +do not retroactively validate old records. + +Coordinate the admission and lookup upgrade. Existing valid wire fields and +encodings are unchanged, and no database collection migration is required. +Keep the new admission checks enabled while repairing historical data. + ### UMP identity reservations `UMPTopicManager` reserves each 32-byte presentation hash and recovery hash for diff --git a/packages/overlays/topics/package.json b/packages/overlays/topics/package.json index 227e6261a..5aade08ea 100644 --- a/packages/overlays/topics/package.json +++ b/packages/overlays/topics/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/overlay-topics", - "version": "1.7.3", + "version": "1.8.0", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/overlays/topics/src/__tests__/mandala.test.ts b/packages/overlays/topics/src/__tests__/mandala.test.ts index 79d0c5578..5a09fb75d 100644 --- a/packages/overlays/topics/src/__tests__/mandala.test.ts +++ b/packages/overlays/topics/src/__tests__/mandala.test.ts @@ -12,6 +12,16 @@ import { MongoClient } from 'mongodb' const protocolID: WalletProtocol = [2, 'mandala token'] const keyID = 'tkn' +// Authoritative rows for the existing, owned source coins built by each fixture. +const fixtureTokens = new Map() +beforeEach(() => fixtureTokens.clear()) +function rememberToken (source: Transaction, identityKey: string): void { + const txid = source.id('hex') + const decoded = MandalaToken.decode(source.outputs[0].lockingScript) + fixtureTokens.set(`${txid}.0`, { txid, outputIndex: 0, assetId: decoded.assetId, + amount: decoded.amount, identityKey, createdAt: new Date() }) +} + // Stub stateStore: getAssetState returns a fixed state; getTokenRow looks up an // optional fixture map keyed by `${txid}.${outputIndex}`. const stubStore = ( @@ -19,7 +29,7 @@ const stubStore = ( rows: Record = {} ): { getAssetState: (assetId: string) => Promise, getTokenRow: (txid: string, outputIndex: number) => Promise, isAdminOutpoint: (assetId: string, txid: string, outputIndex: number) => Promise } => ({ getAssetState: async () => state, - getTokenRow: async (t: string, i: number) => rows[`${t}.${i}`] ?? null, + getTokenRow: async (t: string, i: number) => rows[`${t}.${i}`] ?? fixtureTokens.get(`${t}.${i}`) ?? null, // These fixtures model legitimate issuer actions, so the prior each one // spends is a recorded admin-auth output of that asset. isAdminOutpoint: async () => true @@ -45,6 +55,7 @@ async function buildTransfer (opts: { sanctioned?: boolean } = {}) { // Prior coin: an existing MandalaToken of the same assetId + amount that this tx spends. const sourceTx = new Transaction() sourceTx.addOutput({ lockingScript: new MandalaToken().lock(assetId, 100, pkh), satoshis: 1 }) + rememberToken(sourceTx, receiverKey) // Transfer tx: spends the prior coin, re-creates 100 units to the receiver. Supply conserved. const tx = new Transaction() @@ -102,9 +113,9 @@ describe('MandalaTopicManager admin chain', () => { it('admits an issuance whose boundKey re-derives from the declared action details', async () => { const issuer = new ProtoWallet(PrivateKey.fromRandom()) const overlay = new ProtoWallet(PrivateKey.fromRandom()) - const adminProto: [number, string] = [2, 'mandala admin'] + const adminProto: WalletProtocol = [2, 'mandala admin'] - const actionDetails = { kind: 'register' as const, assetId: `${'c'.repeat(64)}.0` } + const actionDetails = { kind: 'register' as const } const tx = new Transaction() tx.addOutput({ lockingScript: await MandalaAdmin.lock({ wallet: issuer as any, data: actionDetails }), satoshis: 1 }) @@ -121,7 +132,7 @@ describe('MandalaTopicManager admin chain', () => { it('rejects an admin output whose action details do not re-derive the boundKey', async () => { const issuer = new ProtoWallet(PrivateKey.fromRandom()) const overlay = new ProtoWallet(PrivateKey.fromRandom()) - const adminProto: [number, string] = [2, 'mandala admin'] + const adminProto: WalletProtocol = [2, 'mandala admin'] const tx = new Transaction() tx.addOutput({ lockingScript: await MandalaAdmin.lock({ wallet: issuer as any, data: { kind: 'register', assetId: `${'c'.repeat(64)}.0` } }), satoshis: 1 }) const offChainValues = encodeLinkagePayload({ @@ -137,7 +148,7 @@ describe('MandalaTopicManager admin chain', () => { const receiver = new ProtoWallet(PrivateKey.fromRandom()) const overlay = new ProtoWallet(PrivateKey.fromRandom()) const issuer = new ProtoWallet(PrivateKey.fromRandom()) - const adminProto: [number, string] = [2, 'mandala admin'] + const adminProto: WalletProtocol = [2, 'mandala admin'] const { publicKey: receiverKey } = await receiver.getPublicKey({ identityKey: true }) const { publicKey: verifierKey } = await overlay.getPublicKey({ identityKey: true }) @@ -145,9 +156,7 @@ describe('MandalaTopicManager admin chain', () => { const pkh = Hash.hash160(Utils.toArray(derivedKey, 'hex')) const assetA = `${'a'.repeat(64)}.0` // minted with no inputs - const assetC = `${'c'.repeat(64)}.0` // the admin (register) asset - - const registerDetails = { kind: 'register' as const, assetId: assetC } + const registerDetails = { kind: 'register' as const } const tx = new Transaction() tx.addOutput({ lockingScript: new MandalaToken().lock(assetA, 100, pkh), satoshis: 1 }) // index 0: unbacked FT @@ -170,7 +179,7 @@ describe('MandalaTopicManager admin chain', () => { const receiver = new ProtoWallet(PrivateKey.fromRandom()) const overlay = new ProtoWallet(PrivateKey.fromRandom()) const issuer = new ProtoWallet(PrivateKey.fromRandom()) - const adminProto: [number, string] = [2, 'mandala admin'] + const adminProto: WalletProtocol = [2, 'mandala admin'] const { publicKey: receiverKey } = await receiver.getPublicKey({ identityKey: true }) const { publicKey: verifierKey } = await overlay.getPublicKey({ identityKey: true }) @@ -207,7 +216,7 @@ describe('MandalaTopicManager admin chain', () => { const receiver = new ProtoWallet(PrivateKey.fromRandom()) const overlay = new ProtoWallet(PrivateKey.fromRandom()) const issuer = new ProtoWallet(PrivateKey.fromRandom()) - const adminProto: [number, string] = [2, 'mandala admin'] + const adminProto: WalletProtocol = [2, 'mandala admin'] const { publicKey: receiverKey } = await receiver.getPublicKey({ identityKey: true }) const { publicKey: verifierKey } = await overlay.getPublicKey({ identityKey: true }) @@ -223,6 +232,7 @@ describe('MandalaTopicManager admin chain', () => { // Prior FT coin of 100 that gets partially burned. const ftPriorTx = new Transaction() ftPriorTx.addOutput({ lockingScript: new MandalaToken().lock(assetA, 100, pkh), satoshis: 1 }) + rememberToken(ftPriorTx, receiverKey) const redeemDetails = { kind: 'redeem' as const, assetId: assetA, amount: 30, priorOutpoint: `${adminPriorTx.id('hex')}.0` } @@ -244,7 +254,7 @@ describe('MandalaTopicManager admin chain', () => { }) describe('MandalaTopicManager control gate', () => { - const adminProto: [number, string] = [2, 'mandala admin'] + const adminProto: WalletProtocol = [2, 'mandala admin'] // Builds a peer transfer of `assetId`: a prior FT coin of `amount` to the // sender, re-created to `receiverKey`'s derived pkh. Returns the tx, the BEEF, @@ -264,6 +274,7 @@ describe('MandalaTopicManager control gate', () => { const sourceTx = new Transaction() sourceTx.addOutput({ lockingScript: new MandalaToken().lock(assetId, amount, pkh), satoshis: 1 }) + rememberToken(sourceTx, receiverKey) const tx = new Transaction() tx.addInput({ sourceTransaction: sourceTx, sourceOutputIndex: 0, sequence: 0xffffffff, unlockingScript: new Script() }) @@ -450,6 +461,7 @@ describe('MandalaTopicManager control gate', () => { priorTxC.addOutput({ lockingScript: await MandalaAdmin.lock({ wallet: issuer as any, data: priorDetailsC }), satoshis: 1 }) const ftPriorTx = new Transaction() ftPriorTx.addOutput({ lockingScript: new MandalaToken().lock(assetId, 50, pkh), satoshis: 1 }) + rememberToken(ftPriorTx, receiverKey) const reissueDetailsC = { kind: 'reissue' as const, assetId, amount: 50, outpoint: targetOutpoint, priorOutpoint: `${priorTxC.id('hex')}.0` } const txC = new Transaction() txC.addInput({ sourceTransaction: ftPriorTx, sourceOutputIndex: 0, sequence: 0xffffffff, unlockingScript: new Script() }) // FT input of the asset diff --git a/packages/overlays/topics/src/mandala/MandalaLookupService.ts b/packages/overlays/topics/src/mandala/MandalaLookupService.ts index c6a8a33e8..0afb7cc3e 100644 --- a/packages/overlays/topics/src/mandala/MandalaLookupService.ts +++ b/packages/overlays/topics/src/mandala/MandalaLookupService.ts @@ -102,7 +102,9 @@ export class MandalaLookupService implements LookupService { const entry = (parsed.admin ?? []).find((a) => a.index === outputIndex) if (entry == null) return const details = entry.actionDetails - const assetId = typeof details.assetId === 'string' && details.assetId !== '' ? details.assetId : `${txid}.${outputIndex}` + const assetId = details.kind !== 'register' && typeof details.assetId === 'string' && details.assetId !== '' + ? details.assetId + : `${txid}.${outputIndex}` const { height, offset } = txOrdering(tx) const admitSeq = await this.deps.storage.nextAdmitSeq() await this.deps.storage.appendAdminHistory({ diff --git a/packages/overlays/topics/src/mandala/MandalaStorageManager.ts b/packages/overlays/topics/src/mandala/MandalaStorageManager.ts index 2a3e76a18..cb1525067 100644 --- a/packages/overlays/topics/src/mandala/MandalaStorageManager.ts +++ b/packages/overlays/topics/src/mandala/MandalaStorageManager.ts @@ -141,6 +141,12 @@ export class MandalaStorageManager { await this.adminHistory.insertOne(entry) } + /** Match the asset and exact outpoint; the engine independently requires its spend. */ + async isAdminOutpoint (assetId: string, txid: string, outputIndex: number): Promise { + await this.ensureIndexes() + return await this.adminHistory.findOne({ assetId, txid, outputIndex }) !== null + } + async findAdminHistoryByAssetId (assetId: string): Promise { await this.ensureIndexes() return await this.adminHistory.find({ assetId }, { projection: { _id: 0 } }) diff --git a/packages/overlays/topics/src/mandala/MandalaTopicDocs.md.ts b/packages/overlays/topics/src/mandala/MandalaTopicDocs.md.ts index a2b906b61..2cdaec278 100644 --- a/packages/overlays/topics/src/mandala/MandalaTopicDocs.md.ts +++ b/packages/overlays/topics/src/mandala/MandalaTopicDocs.md.ts @@ -6,4 +6,35 @@ authorization chain, and screening both transfer parties against a sanctions lis Every token output and every admin-auth output must carry exactly 1 satoshi (token value is payload-denominated); violating transactions are rejected. Linkage data travels off-chain via \`offChainValues\` and is retained (encrypted). + +### Mandala admission and the 1.8.0 upgrade + +Use the same \`MandalaStorageManager\` for Mandala admission and lookup. The +reference store now implements \`isAdminOutpoint(assetId, txid, outputIndex)\` +against admitted admin history. Custom adapters must implement that predicate; +a missing verifier rejects non-genesis admin actions. Its optional TypeScript +member preserves source compatibility, not permission to bypass verification. +Never implement it as a constant \`true\`. + +Registration must omit \`assetId\` or use an empty string: the registration's own +outpoint defines its asset. Subsequent admin actions must spend a previously +admitted admin output for that same asset. Token spends require a stored owner +row matching the source outpoint, asset and amount. Optional input linkage +corroborates that owner and the source locking key; it cannot replace missing +state. Sender blinding and transfers without input linkage remain supported +when authoritative owner state is present. Linkage arrays require unique, +non-negative integer indices. + +Before upgrading an existing Mandala deployment, back up and audit its admin +history and token-owner records. Restore missing rows from verified admission +evidence before historical replay; do not infer authority from a submitted +payload. The engine identifies admissible outputs before sending spend +notifications, so normal admission can read the owner before lookup removes +the spent row. Custom replay adapters must preserve that ordering. These checks +do not retroactively validate old records. + +Coordinate the admission and lookup upgrade. Existing valid wire fields and +encodings are unchanged, and no database collection migration is required. +Keep the new admission checks enabled while repairing historical data. + ` diff --git a/packages/overlays/topics/src/mandala/MandalaTopicManager.ts b/packages/overlays/topics/src/mandala/MandalaTopicManager.ts index 5afe24628..b681c61b4 100644 --- a/packages/overlays/topics/src/mandala/MandalaTopicManager.ts +++ b/packages/overlays/topics/src/mandala/MandalaTopicManager.ts @@ -17,10 +17,9 @@ export interface MandalaTopicManagerDeps { /** * Has this topic already admitted `txid.outputIndex` as an admin-auth * output of `assetId`? This anchors the admin chain — see - * {@link MandalaTopicManager.priorAnchored}. Optional so existing - * deployments keep building; when it is absent the anchor falls back to - * requiring the prior to be a previously admitted coin of this topic, - * which is still strictly stronger than the old any-input check. + * {@link MandalaTopicManager.priorAnchored}. Optional for source compatibility; + * non-genesis admin admission fails closed when this verifier is absent. + * MandalaStorageManager provides the reference implementation. */ isAdminOutpoint?: (assetId: string, txid: string, outputIndex: number) => Promise } @@ -51,6 +50,17 @@ const splitOutpoint = (op: string): { txid: string, vout: number } | null => { export class MandalaTopicManager implements TopicManager { constructor (private readonly deps: MandalaTopicManagerDeps) {} + private admittedInputOutpoints (tx: Transaction, previousCoins: number[]): Set { + const indices = new Set() + for (const index of previousCoins) { + if (!Number.isInteger(index) || index < 0 || index >= tx.inputs.length || indices.has(index)) { + throw new Error('previousCoins must contain unique valid input indices') + } + indices.add(index) + } + return new Set([...indices].map(index => outpointOfInput(tx.inputs[index]))) + } + private async classifyOutputs ( tx: Transaction, payload: ReturnType & { admin?: Array<{ index: number, actionDetails: MandalaActionDetails }> }, @@ -126,37 +136,24 @@ export class MandalaTopicManager implements TopicManager { } /** - * Is this admin action anchored to the asset's admin chain? - * - * Authority on the admin chain is the CHAIN OF SPENDS, not key - * re-derivation. `details.counterparty` arrives in the unauthenticated - * off-chain payload, and BRC-42 derivation against a counterparty yields a - * key that counterparty can itself compute — and spend — from its own root - * key plus this overlay's PUBLIC identity key. So re-deriving the lock key - * proves nothing about who authored the action; a third party could - * otherwise forge `unpause`, `unfreeze`, `allowIdentity` and, because a - * verified admin output credits authorized issuance, `issue`/`reissue`. - * - * What does prove authorship is the prior: the action must SPEND the admin - * output this topic already admitted for that asset. Delegation still works, - * because whoever the new output is locked to holds authority next. - * - * `register` is exempt: its assetId is its own genesis outpoint, so it - * confers authority over nothing that already exists. + * Non-genesis actions must spend a previously admitted admin output of the + * same asset. Registration establishes authority only over its own genesis. + * Key linkage corroborates a lock; admitted history establishes authority. */ private async priorAnchored ( details: MandalaActionDetails, admittedInputs: Set ): Promise { - if (details.kind === 'register') return true + if (details.kind === 'register') return details.assetId === undefined || details.assetId === '' if (typeof details.priorOutpoint !== 'string' || details.priorOutpoint === '') return false if (!admittedInputs.has(details.priorOutpoint)) return false - const isAdminOutpoint = this.deps.stateStore.isAdminOutpoint - if (isAdminOutpoint == null) return true + if (typeof this.deps.stateStore.isAdminOutpoint !== 'function') { + throw new TypeError('Mandala admin admission requires stateStore.isAdminOutpoint') + } if (typeof details.assetId !== 'string' || details.assetId === '') return false const parts = splitOutpoint(details.priorOutpoint) if (parts == null) return false - return await isAdminOutpoint(details.assetId, parts.txid, parts.vout) + return await this.deps.stateStore.isAdminOutpoint(details.assetId, parts.txid, parts.vout) === true } private async verifyAdminOutput ( @@ -296,7 +293,8 @@ export class MandalaTopicManager implements TopicManager { /** * The identity spending token input `ci`, or `undefined` when the input is - * not a token coin or has no owner on record and no linkage. + * not a token coin. Missing or inconsistent authoritative ownership rejects + * admission; an optional linkage cannot replace the stored owner. * * Throws when a supplied linkage does not control the coin or names a party * other than the stored owner — either rejects the whole transaction. @@ -308,24 +306,38 @@ export class MandalaTopicManager implements TopicManager { ): Promise { const input = tx.inputs[ci] const src = input?.sourceTransaction?.outputs[input.sourceOutputIndex] - if (input == null || src == null) return undefined + if (input == null || src == null) throw new Error(`missing source output for admitted input ${ci}`) const decoded = decodeFtOutput(src.lockingScript) if (decoded == null) return undefined const txid = input.sourceTXID ?? input.sourceTransaction?.id('hex') ?? '' - const row = await this.deps.stateStore.getTokenRow(txid, input.sourceOutputIndex) - const stored = row?.identityKey ?? '' - - if (linkage == null) return stored === '' ? undefined : stored + const stored = await this.storedTokenOwner(txid, input.sourceOutputIndex, decoded) + if (linkage == null) return stored const v = await verifyInputKeyLinkage(linkage, this.deps.verifierWallet) if (!sameBytes(v.pubKeyHash, decoded.pubKeyHash)) { throw new Error(`input ${ci} linkage does not control the coin being spent`) } - if (stored !== '' && stored.toLowerCase() !== v.identityKey.toLowerCase()) { + if (stored !== v.identityKey.toLowerCase()) { throw new Error(`input ${ci} linkage names ${v.identityKey} but the coin is owned by ${stored}`) } - return v.identityKey + return stored + } + + private async storedTokenOwner ( + txid: string, + outputIndex: number, + decoded: { assetId: string, amount: number } + ): Promise { + const row = await this.deps.stateStore.getTokenRow(txid, outputIndex) + if (row == null || typeof row.identityKey !== 'string' || row.identityKey.trim() === '') { + throw new Error(`missing verified owner for token ${txid}.${outputIndex}`) + } + if (row.txid !== txid || row.outputIndex !== outputIndex || + row.assetId !== decoded.assetId || row.amount !== decoded.amount) { + throw new Error(`stored token metadata does not match ${txid}.${outputIndex}`) + } + return row.identityKey.toLowerCase() } private async anySanctioned ( @@ -455,9 +467,7 @@ export class MandalaTopicManager implements TopicManager { // Outpoints of inputs the engine says this topic previously admitted. // The admin chain is anchored to these; see priorAnchored. - const admittedInputs = new Set( - previousCoins.filter(ci => ci < tx.inputs.length).map(ci => outpointOfInput(tx.inputs[ci])) - ) + const admittedInputs = this.admittedInputOutpoints(tx, previousCoins) const { ftOutputs, adminIndices, authorizedIssuance, verifiedAdminAssetKinds } = await this.classifyOutputs(tx, payload as any, admittedInputs) diff --git a/packages/overlays/topics/src/mandala/__tests/MandalaAdminChain.test.ts b/packages/overlays/topics/src/mandala/__tests/MandalaAdminChain.test.ts index fa1c09840..f39ee0912 100644 --- a/packages/overlays/topics/src/mandala/__tests/MandalaAdminChain.test.ts +++ b/packages/overlays/topics/src/mandala/__tests/MandalaAdminChain.test.ts @@ -1,189 +1,111 @@ -import { MandalaTopicManager } from '../MandalaTopicManager.js' -import { InMemoryScreeningProvider, encodeLinkagePayload, MandalaLinkagePayload } from '../types.js' +import { jest } from '@jest/globals' +import { MandalaTopicManager, type MandalaTopicManagerDeps } from '../MandalaTopicManager.js' import { defaultAssetState } from '../AssetStateReducer.js' -import { MandalaAdmin, ADMIN_PROTOCOL, MandalaActionDetails } from '@bsv/templates' -import { ProtoWallet, PrivateKey, Hash, Utils, Transaction, P2PKH, UnlockingScript } from '@bsv/sdk' - -// Admin authority is the CHAIN OF SPENDS, not key re-derivation. -// -// verifyAdminOutput re-derives the expected lock key from details.counterparty, -// which arrives in the unauthenticated off-chain payload. By BRC-42 that key -// belongs to the named counterparty, who can compute AND spend it from their -// own root key plus the overlay's PUBLIC identity key. So a third party can -// always reproduce the expected pubKeyHash. What they cannot do is spend the -// admin output this topic already admitted — and that is what must be required. +import type { MandalaActionDetails } from '@bsv/templates' const assetId = `${'a'.repeat(64)}.0` - -const overlay = new ProtoWallet(PrivateKey.fromRandom()) -const issuer = new ProtoWallet(PrivateKey.fromRandom()) -const attacker = new ProtoWallet(PrivateKey.fromRandom()) - -const makeManager = (recordedAdminOutpoints: string[]): MandalaTopicManager => - new MandalaTopicManager({ - verifierWallet: overlay as any, - screeningProvider: new InMemoryScreeningProvider([]), - adminWallet: issuer as any, - adminProtocolID: ADMIN_PROTOCOL, - stateStore: { - getAssetState: async (id: string) => ({ ...defaultAssetState(id), isPaused: true }), - getTokenRow: async () => null, - isAdminOutpoint: async (a: string, txid: string, vout: number) => - recordedAdminOutpoints.includes(`${a}|${txid}.${vout}`) - } - }) - -const fundedTx = (): { tx: Transaction, outpoint: string } => { - const source = new Transaction() - source.addOutput({ satoshis: 1000, lockingScript: new P2PKH().lock(Hash.hash160(Utils.toArray('00', 'hex'))) }) - const tx = new Transaction() - tx.addInput({ sourceTransaction: source, sourceOutputIndex: 0, unlockingScript: new UnlockingScript() }) - return { tx, outpoint: `${source.id('hex')}.0` } -} - -/** The lock an ATTACKER can build, knowing only its own key and the overlay's public key. */ -const attackerForgedAdminLock = async (details: MandalaActionDetails): Promise => { - const { publicKey: issuerKey } = await issuer.getPublicKey({ identityKey: true }) - const { publicKey } = await attacker.getPublicKey({ - protocolID: ADMIN_PROTOCOL, - keyID: MandalaAdmin.commitment(details), - counterparty: issuerKey, - forSelf: true +const prior = `${'b'.repeat(64)}.1` +const details: MandalaActionDetails = { kind: 'pause', assetId, priorOutpoint: prior } + +function manager(verify?: MandalaTopicManagerDeps['stateStore']['isAdminOutpoint']) { + const stateStore = { + getAssetState: async () => defaultAssetState(assetId), + getTokenRow: async () => null, + ...(verify ? { isAdminOutpoint: verify } : {}) + } + const subject = new MandalaTopicManager({ + stateStore, + verifierWallet: {} as MandalaTopicManagerDeps['verifierWallet'], + adminWallet: {} as MandalaTopicManagerDeps['adminWallet'], + screeningProvider: { isSanctioned: async () => false }, + adminProtocolID: [2, 'mandala admin'] }) - return new P2PKH().lock(Hash.hash160(Utils.toArray(publicKey, 'hex'))) + return { subject, stateStore } } -const run = async ( - manager: MandalaTopicManager, - tx: Transaction, - details: MandalaActionDetails, - previousCoins: number[] -): Promise<{ outputsToAdmit: number[] }> => { - const payload: MandalaLinkagePayload = { - inputs: [], outputs: [], admin: [{ index: 0, actionDetails: details }] - } - return await manager.identifyAdmissibleOutputs(tx.toBEEF(), previousCoins, encodeLinkagePayload(payload)) as any -} +const anchored = (subject: MandalaTopicManager, action = details, admitted = new Set([prior])) => + (subject as any).priorAnchored(action, admitted) as Promise -describe('MandalaTopicManager admin chain anchoring', () => { - it('a third party can reproduce the expected admin lock, so the key alone proves nothing', async () => { - const { outpoint } = fundedTx() - const details: MandalaActionDetails = { - kind: 'unpause', assetId, priorOutpoint: outpoint, - counterparty: (await attacker.getPublicKey({ identityKey: true })).publicKey - } - // What the topic manager re-derives for this action: the admin wallet - // deriving AGAINST the named counterparty (forSelf defaults false), i.e. - // the counterparty's own child key. - const attackerKey = (await attacker.getPublicKey({ identityKey: true })).publicKey - const { publicKey: expectedByOverlay } = await issuer.getPublicKey({ - protocolID: ADMIN_PROTOCOL, - keyID: MandalaAdmin.commitment(details), - counterparty: attackerKey - }) - // What the attacker derives knowing only its own key and the issuer's - // PUBLIC key. BRC-42 symmetry makes these the same point — and the - // attacker holds its private half. - const { publicKey: derivedByAttacker } = await attacker.getPublicKey({ - protocolID: ADMIN_PROTOCOL, - keyID: MandalaAdmin.commitment(details), - counterparty: (await issuer.getPublicKey({ identityKey: true })).publicKey, - forSelf: true - }) - expect(derivedByAttacker).toEqual(expectedByOverlay) - }) - - it('refuses a forged admin action whose prior is not a recorded admin output', async () => { - const { tx, outpoint } = fundedTx() - const details: MandalaActionDetails = { - kind: 'unpause', assetId, priorOutpoint: outpoint, - counterparty: (await attacker.getPublicKey({ identityKey: true })).publicKey - } - tx.addOutput({ satoshis: 1, lockingScript: await attackerForgedAdminLock(details) }) - // Not admitted, so nothing folds into asset state and the pause stands. - const res = await run(makeManager([]), tx, details, [0]) - expect(res.outputsToAdmit).toEqual([]) +describe('Mandala admin authority contract', () => { + test('requires a state verifier for non-genesis admission', async () => { + await expect(anchored(manager().subject)).rejects.toThrow( + new TypeError('Mandala admin admission requires stateStore.isAdminOutpoint') + ) }) - it('refuses an admin action whose prior this topic never admitted as a coin', async () => { - const { tx, outpoint } = fundedTx() - const details: MandalaActionDetails = { kind: 'unpause', assetId, priorOutpoint: outpoint } - tx.addOutput({ satoshis: 1, lockingScript: await MandalaAdmin.lock({ wallet: issuer as any, data: details }) }) - // Recorded, but previousCoins is empty: the engine did not admit that spend. - const res = await run(makeManager([`${assetId}|${outpoint}`]), tx, details, []) - expect(res.outputsToAdmit).toEqual([]) + test('requires both the admitted input and the exact per-asset history entry', async () => { + const verify = jest.fn( + async (asset: string, txid: string, index: number) => + asset === assetId && `${txid}.${index}` === prior + ) + const { subject } = manager(verify) + expect(await anchored(subject)).toBe(true) + expect(verify).toHaveBeenCalledWith(assetId, 'b'.repeat(64), 1) + expect(await anchored(subject, details, new Set())).toBe(false) + expect(await anchored(subject, { ...details, assetId: `${'c'.repeat(64)}.0` })).toBe(false) }) - it('admits an action that spends the recorded admin output', async () => { - const { tx, outpoint } = fundedTx() - const details: MandalaActionDetails = { kind: 'unpause', assetId, priorOutpoint: outpoint } - tx.addOutput({ satoshis: 1, lockingScript: await MandalaAdmin.lock({ wallet: issuer as any, data: details }) }) - const res = await run(makeManager([`${assetId}|${outpoint}`]), tx, details, [0]) - expect(res.outputsToAdmit).toEqual([0]) + test('preserves a class-style store method receiver', async () => { + const { subject, stateStore } = manager() + Object.assign(stateStore, { + registered: prior, + async isAdminOutpoint( + this: { registered: string }, + _asset: string, + txid: string, + index: number + ) { + return this.registered === `${txid}.${index}` + } + }) + expect(await anchored(subject)).toBe(true) }) - it('lets authority transfer to whoever the next output is locked to', async () => { - const { tx, outpoint } = fundedTx() - const details: MandalaActionDetails = { - kind: 'unpause', assetId, priorOutpoint: outpoint, - counterparty: (await attacker.getPublicKey({ identityKey: true })).publicKey + test.each([undefined, '', 'not-an-outpoint', `${'b'.repeat(64)}.-1`, `${'b'.repeat(64)}.1.5`])( + 'rejects missing or unregistered prior %s', + async priorOutpoint => { + expect(await anchored(manager(async () => true).subject, { ...details, priorOutpoint })).toBe( + false + ) } - tx.addOutput({ satoshis: 1, lockingScript: await attackerForgedAdminLock(details) }) - // Same shape as the forgery above, but the prior IS the recorded admin - // output and IS spent — so this is legitimate delegation, not a forgery. - const res = await run(makeManager([`${assetId}|${outpoint}`]), tx, details, [0]) - expect(res.outputsToAdmit).toEqual([0]) - }) + ) - it('refuses a non-register action that names no prior at all', async () => { - const { tx } = fundedTx() - const details: MandalaActionDetails = { kind: 'unpause', assetId } - tx.addOutput({ satoshis: 1, lockingScript: await MandalaAdmin.lock({ wallet: issuer as any, data: details }) }) - const res = await run(makeManager([]), tx, details, [0]) - expect(res.outputsToAdmit).toEqual([]) + test('rejects malformed admitted outpoints and absent asset identifiers', async () => { + const { subject } = manager(async () => true) + for (const priorOutpoint of ['invalid', '.0', 'source.-1', 'source.NaN']) { + const admitted = new Set([priorOutpoint]) + expect(await anchored(subject, { ...details, priorOutpoint }, admitted)).toBe(false) + } + expect(await anchored(subject, { ...details, assetId: '' })).toBe(false) + expect(await anchored(subject, { ...details, assetId: undefined })).toBe(false) }) - it('refuses a prior it cannot parse as an outpoint, or one without an assetId to look up', async () => { - for (const priorOutpoint of ['not-an-outpoint', `${'b'.repeat(64)}.-1`, `${'b'.repeat(64)}.x`]) { - const { tx } = fundedTx() - // The spent input's outpoint is what admittedInputs holds; the payload's - // prior must equal it to pass the first check, so name the input's - // outpoint here and break only the parse in the assetId-less variant. - const details: MandalaActionDetails = { kind: 'unpause', assetId, priorOutpoint } - tx.addOutput({ satoshis: 1, lockingScript: await MandalaAdmin.lock({ wallet: issuer as any, data: details }) }) - const res = await run(makeManager([`${assetId}|${priorOutpoint}`]), tx, details, [0]) - expect(res.outputsToAdmit).toEqual([]) - } - const { tx, outpoint } = fundedTx() - const noAsset = { kind: 'unpause', priorOutpoint: outpoint } as unknown as MandalaActionDetails - tx.addOutput({ satoshis: 1, lockingScript: await MandalaAdmin.lock({ wallet: issuer as any, data: noAsset }) }) - const res = await run(makeManager([`${assetId}|${outpoint}`]), tx, noAsset, [0]) - expect(res.outputsToAdmit).toEqual([]) + test('propagates unavailable history instead of treating it as authority', async () => { + await expect( + anchored( + manager(async () => { + throw new Error('history unavailable') + }).subject + ) + ).rejects.toThrow('history unavailable') + expect(await anchored(manager(async () => false).subject)).toBe(false) }) - it('a store without isAdminOutpoint anchors on the spent prior alone', async () => { - const { tx, outpoint } = fundedTx() - const details: MandalaActionDetails = { kind: 'unpause', assetId, priorOutpoint: outpoint } - tx.addOutput({ satoshis: 1, lockingScript: await MandalaAdmin.lock({ wallet: issuer as any, data: details }) }) - const legacy = new MandalaTopicManager({ - verifierWallet: overlay as any, - screeningProvider: new InMemoryScreeningProvider([]), - adminWallet: issuer as any, - adminProtocolID: ADMIN_PROTOCOL, - stateStore: { - getAssetState: async (id: string) => ({ ...defaultAssetState(id), isPaused: true }), - getTokenRow: async () => null - } - }) - const res = await run(legacy, tx, details, [0]) - expect(res.outputsToAdmit).toEqual([0]) + test('registration has no prior but cannot claim an existing asset identifier', async () => { + const { subject } = manager() + expect(await anchored(subject, { kind: 'register' }, new Set())).toBe(true) + expect(await anchored(subject, { kind: 'register', assetId: '' }, new Set())).toBe(true) + expect(await anchored(subject, { kind: 'register', assetId }, new Set())).toBe(false) }) - it('needs no prior for a genesis register', async () => { - const { tx } = fundedTx() - const details: MandalaActionDetails = { kind: 'register', assetId } - tx.addOutput({ satoshis: 1, lockingScript: await MandalaAdmin.lock({ wallet: issuer as any, data: details }) }) - const res = await run(makeManager([]), tx, details, []) - expect(res.outputsToAdmit).toEqual([0]) + test('rejects duplicate or invalid engine input indices before conservation', () => { + const { subject } = manager() + const tx = { inputs: [{ sourceTXID: 'b'.repeat(64), sourceOutputIndex: 1 }] } + for (const indices of [[0, 0], [-1], [0.5], [1], [NaN]]) { + expect(() => (subject as any).admittedInputOutpoints(tx, indices)).toThrow( + 'unique valid input indices' + ) + } + expect((subject as any).admittedInputOutpoints(tx, [0])).toEqual(new Set([prior])) }) }) diff --git a/packages/overlays/topics/src/mandala/__tests/MandalaStorageManager.test.ts b/packages/overlays/topics/src/mandala/__tests/MandalaStorageManager.test.ts index ce1f98911..8624a5f93 100644 --- a/packages/overlays/topics/src/mandala/__tests/MandalaStorageManager.test.ts +++ b/packages/overlays/topics/src/mandala/__tests/MandalaStorageManager.test.ts @@ -71,6 +71,18 @@ describe('MandalaStorageManager admin state + history', () => { expect(await mgr.getAssetState('x.0')).toEqual(next) }) + it('confirms only the exact asset and admin-history outpoint', async () => { + const mgr = new MandalaStorageManager(db) + await mgr.appendAdminHistory({ assetId: 'a.0', txid: 'admin', outputIndex: 1, + actionDetails: { kind: 'pause', assetId: 'a.0' }, height: 1, offset: 0, admitSeq: 1, createdAt: new Date() }) + expect(await mgr.isAdminOutpoint('a.0', 'admin', 1)).toBe(true) + expect(await mgr.isAdminOutpoint('b.0', 'admin', 1)).toBe(false) + expect(await mgr.isAdminOutpoint('a.0', 'other', 1)).toBe(false) + expect(await mgr.isAdminOutpoint('a.0', 'admin', 0)).toBe(false) + await mgr.storeToken({ txid: 'token', outputIndex: 0, assetId: 'a.0', amount: 1, identityKey: 'owner', createdAt: new Date() }) + expect(await mgr.isAdminOutpoint('a.0', 'token', 0)).toBe(false) + }) + it('nextAdmitSeq is monotonic', async () => { const mgr = new MandalaStorageManager(db) const a = await mgr.nextAdmitSeq() diff --git a/packages/overlays/topics/src/mandala/__tests/MandalaTopicManager.test.ts b/packages/overlays/topics/src/mandala/__tests/MandalaTopicManager.test.ts index 886e80161..de739649d 100644 --- a/packages/overlays/topics/src/mandala/__tests/MandalaTopicManager.test.ts +++ b/packages/overlays/topics/src/mandala/__tests/MandalaTopicManager.test.ts @@ -82,7 +82,7 @@ describe('MandalaTopicManager 1-satoshi rule', () => { it('rejects a verified admin output carrying more than 1 satoshi', async () => { const { tx, priorOutpoint } = fundedTx() - const details: MandalaActionDetails = { kind: 'register', assetId, priorOutpoint } + const details: MandalaActionDetails = { kind: 'register', priorOutpoint } const adminScript = await MandalaAdmin.lock({ wallet: issuer as any, data: details }) tx.addOutput({ satoshis: 2, lockingScript: adminScript }) const payload: MandalaLinkagePayload = { diff --git a/packages/overlays/topics/src/mandala/__tests/SpendIdentity.test.ts b/packages/overlays/topics/src/mandala/__tests/SpendIdentity.test.ts index e9bebfda0..19741f260 100644 --- a/packages/overlays/topics/src/mandala/__tests/SpendIdentity.test.ts +++ b/packages/overlays/topics/src/mandala/__tests/SpendIdentity.test.ts @@ -62,7 +62,8 @@ async function build (opts: { const inputLinkage = await spender.revealSpecificKeyLinkage({ counterparty: payerKey, verifier: verifierKey, protocolID, keyID }) const outputLinkage = await spender.revealSpecificKeyLinkage({ counterparty: receiverKey, verifier: verifierKey, protocolID, keyID }) - const rows = opts.rows ?? {} + const sourceId = source.id('hex') + const rows = opts.rows ?? { [`${sourceId}.0`]: { txid: sourceId, outputIndex: 0, assetId, amount: 100, identityKey: await identity(spender), createdAt: new Date() } } const tm = new MandalaTopicManager({ verifierWallet: overlay as any, screeningProvider: new InMemoryScreeningProvider(opts.sanctioned ?? []), @@ -77,8 +78,8 @@ async function build (opts: { return { tm, beef: tx.toBEEF(), inputLinkage, outputLinkage, previousCoins: opts.extraP2pkhInput === true ? [0, 1] : [0] } } -const ownerRow = (identityKey: string): MandalaTokenRecord => - ({ txid: '', outputIndex: 0, assetId, amount: 100, identityKey } as unknown as MandalaTokenRecord) +const ownerRow = (identityKey: string, outpoint: string): MandalaTokenRecord => + ({ txid: outpoint.split('.')[0], outputIndex: 0, assetId, amount: 100, identityKey, createdAt: new Date() }) const payloadWith = (b: Built, withInputLinkage: boolean): number[] => encodeLinkagePayload({ @@ -87,7 +88,7 @@ const payloadWith = (b: Built, withInputLinkage: boolean): number[] => }) describe('MandalaTopicManager spend identity', () => { - it('names the spender from a linkage that controls the coin, and screens that identity', async () => { + it('screens the stored spender after optional linkage verification', async () => { const spenderKey = await identity(spender) const clean = await build() expect((await clean.tm.identifyAdmissibleOutputs(clean.beef, clean.previousCoins, payloadWith(clean, true))).outputsToAdmit).toEqual([0]) @@ -105,7 +106,7 @@ describe('MandalaTopicManager spend identity', () => { // source is deterministic across builds). const tx = Transaction.fromBEEF((await build()).beef) const outpoint = `${tx.inputs[0].sourceTXID ?? tx.inputs[0].sourceTransaction?.id('hex') ?? ''}.0` - const b2 = await build({ rows: { [outpoint]: ownerRow(spenderKey.toUpperCase()) } }) + const b2 = await build({ rows: { [outpoint]: ownerRow(spenderKey.toUpperCase(), outpoint) } }) expect((await b2.tm.identifyAdmissibleOutputs(b2.beef, b2.previousCoins, payloadWith(b2, true))).outputsToAdmit).toEqual([0]) }) @@ -119,28 +120,51 @@ describe('MandalaTopicManager spend identity', () => { it('rejects a linkage that names a party other than the stored owner', async () => { const tx = Transaction.fromBEEF((await build()).beef) const outpoint = `${tx.inputs[0].sourceTXID ?? tx.inputs[0].sourceTransaction?.id('hex') ?? ''}.0` - const b = await build({ rows: { [outpoint]: ownerRow(await identity(stranger)) } }) + const b = await build({ rows: { [outpoint]: ownerRow(await identity(stranger), outpoint) } }) await expect(b.tm.identifyAdmissibleOutputs(b.beef, b.previousCoins, payloadWith(b, true))) .rejects.toThrow('but the coin is owned by') }) - it('without a linkage, names the stored owner — and nobody when there is none', async () => { + it('without a linkage, screens the stored owner and rejects missing ownership', async () => { const strangerKey = await identity(stranger) const tx = Transaction.fromBEEF((await build()).beef) const outpoint = `${tx.inputs[0].sourceTXID ?? tx.inputs[0].sourceTransaction?.id('hex') ?? ''}.0` // Stored owner is sanctioned: rejected even though no linkage was supplied. - const owned = await build({ rows: { [outpoint]: ownerRow(strangerKey) }, sanctioned: [strangerKey] }) + const owned = await build({ rows: { [outpoint]: ownerRow(strangerKey, outpoint) }, sanctioned: [strangerKey] }) await expect(owned.tm.identifyAdmissibleOutputs(owned.beef, owned.previousCoins, payloadWith(owned, false))) .rejects.toThrow('sanctioned') - // No row, no linkage: nothing to screen, the transfer stands on its outputs. - const unknown = await build({ sanctioned: [strangerKey] }) - expect((await unknown.tm.identifyAdmissibleOutputs(unknown.beef, unknown.previousCoins, payloadWith(unknown, false))).outputsToAdmit).toEqual([0]) + const unknown = await build({ rows: {}, sanctioned: [strangerKey] }) + for (const withLinkage of [false, true]) { + await expect(unknown.tm.identifyAdmissibleOutputs(unknown.beef, unknown.previousCoins, payloadWith(unknown, withLinkage))) + .rejects.toThrow('missing verified owner') + } }) it('ignores a previous coin that is not a token output', async () => { const b = await build({ extraP2pkhInput: true }) expect((await b.tm.identifyAdmissibleOutputs(b.beef, b.previousCoins, payloadWith(b, true))).outputsToAdmit).toEqual([0]) }) + it('rejects blank owner identities in stored rows', async () => { + const tx = Transaction.fromBEEF((await build()).beef) + const outpoint = `${tx.inputs[0].sourceTXID ?? tx.inputs[0].sourceTransaction?.id('hex') ?? ''}.0` + for (const identityKey of ['', ' ']) { + const b = await build({ rows: { [outpoint]: ownerRow(identityKey, outpoint) } }) + await expect(b.tm.identifyAdmissibleOutputs(b.beef, b.previousCoins, payloadWith(b, false))) + .rejects.toThrow('missing verified owner') + } + }) + + it('rejects inconsistent stored token metadata', async () => { + const tx = Transaction.fromBEEF((await build()).beef) + const outpoint = `${tx.inputs[0].sourceTXID ?? tx.inputs[0].sourceTransaction?.id('hex') ?? ''}.0` + const row = ownerRow(await identity(spender), outpoint) + for (const changed of [{ txid: 'different' }, { outputIndex: 1 }, { assetId: 'different' }, { amount: 101 }]) { + const b = await build({ rows: { [outpoint]: { ...row, ...changed } } }) + await expect(b.tm.identifyAdmissibleOutputs(b.beef, b.previousCoins, payloadWith(b, false))) + .rejects.toThrow('stored token metadata does not match') + } + }) + }) diff --git a/packages/overlays/topics/src/mandala/__tests/UnlinkedTokenOutput.test.ts b/packages/overlays/topics/src/mandala/__tests/UnlinkedTokenOutput.test.ts index bff3ed660..951c84a43 100644 --- a/packages/overlays/topics/src/mandala/__tests/UnlinkedTokenOutput.test.ts +++ b/packages/overlays/topics/src/mandala/__tests/UnlinkedTokenOutput.test.ts @@ -1,5 +1,5 @@ import { MandalaTopicManager, unlinkedTokenReason } from '../MandalaTopicManager.js' -import { InMemoryScreeningProvider, encodeLinkagePayload, MandalaLinkagePayload } from '../types.js' +import { InMemoryScreeningProvider, encodeLinkagePayload, MandalaLinkagePayload, MandalaTokenRecord } from '../types.js' import { defaultAssetState } from '../AssetStateReducer.js' import { MandalaToken } from '@bsv/templates' import { ProtoWallet, PrivateKey, Hash, Utils, WalletProtocol, Transaction, P2PKH, UnlockingScript } from '@bsv/sdk' @@ -17,6 +17,9 @@ const receiver = new ProtoWallet(PrivateKey.fromRandom()) const other = new ProtoWallet(PrivateKey.fromRandom()) const overlay = new ProtoWallet(PrivateKey.fromRandom()) +const tokens = new Map() +beforeEach(() => tokens.clear()) + const manager = new MandalaTopicManager({ verifierWallet: overlay as any, screeningProvider: new InMemoryScreeningProvider([]), @@ -24,7 +27,7 @@ const manager = new MandalaTopicManager({ adminProtocolID: [2, 'mandala admin'], stateStore: { getAssetState: async () => defaultAssetState(assetId), - getTokenRow: async () => null, + getTokenRow: async (txid, outputIndex) => tokens.get(`${txid}.${outputIndex}`) ?? null, isAdminOutpoint: async () => true } }) @@ -38,6 +41,8 @@ async function transfer (amounts: number[]): Promise<{ tx: Transaction, linkageF const pkh = Hash.hash160(Utils.toArray(derived, 'hex')) const source = new Transaction() source.addOutput({ lockingScript: new MandalaToken().lock(assetId, 100, pkh), satoshis: 1 }) + const txid = source.id('hex') + tokens.set(`${txid}.0`, { txid, outputIndex: 0, assetId, amount: 100, identityKey: receiverKey, createdAt: new Date() }) const tx = new Transaction() tx.addInput({ sourceTransaction: source, sourceOutputIndex: 0, unlockingScript: new UnlockingScript() }) for (const amount of amounts) tx.addOutput({ lockingScript: new MandalaToken().lock(assetId, amount, pkh), satoshis: 1 }) diff --git a/packages/overlays/topics/src/mandala/__tests/types.property.test.ts b/packages/overlays/topics/src/mandala/__tests/types.property.test.ts index ce2e53fee..f73a6631f 100644 --- a/packages/overlays/topics/src/mandala/__tests/types.property.test.ts +++ b/packages/overlays/topics/src/mandala/__tests/types.property.test.ts @@ -51,8 +51,8 @@ const linkageEntry = fc.record({ const linkagePayload = fc .record({ - inputs: fc.array(linkageEntry, { maxLength: 8 }), - outputs: fc.array(linkageEntry, { maxLength: 8 }) + inputs: fc.uniqueArray(linkageEntry, { maxLength: 8, selector: entry => entry.index }), + outputs: fc.uniqueArray(linkageEntry, { maxLength: 8, selector: entry => entry.index }) }) .map(value => value as MandalaLinkagePayload) @@ -68,6 +68,19 @@ describe('overlay topic property tests', () => { ) }) + test('rejects duplicate payload indices without changing valid wire bytes', () => { + fc.assert( + fc.property(linkageEntry, entry => { + for (const key of ['inputs', 'outputs', 'admin']) { + const payload = { inputs: [], outputs: [], [key]: [entry, entry] } + expect(() => + decodeLinkagePayload(Array.from(new TextEncoder().encode(JSON.stringify(payload)))) + ).toThrow('unique non-negative integer indices') + } + }) + ) + }) + test('admits exactly the arbitrary token IDs present in an allowlist', () => { fc.assert( fc.property( diff --git a/packages/overlays/topics/src/mandala/__tests/types.test.ts b/packages/overlays/topics/src/mandala/__tests/types.test.ts index eea66a54d..4b68fc206 100644 --- a/packages/overlays/topics/src/mandala/__tests/types.test.ts +++ b/packages/overlays/topics/src/mandala/__tests/types.test.ts @@ -21,3 +21,19 @@ describe('mandala types', () => { expect(decodeLinkagePayload(encodeLinkagePayload(payload))).toEqual(payload) }) }) + + +describe('Mandala payload index validation', () => { + const decode = (value: unknown) => decodeLinkagePayload(Array.from(new TextEncoder().encode(JSON.stringify(value)))) + test.each([null, false, 1, 'text'])('rejects non-object payload %p', value => { + expect(() => decode(value)).toThrow('payload must be an object') + }) + test.each(['inputs', 'outputs', 'admin'])('validates %s indices', key => { + for (const entries of [null, {}, [null], [{ index: -1 }], [{ index: 0.5 }], [{ index: '0' }], [{ index: 0 }, { index: 0 }]]) { + expect(() => decode({ inputs: [], outputs: [], [key]: entries })).toThrow() + } + }) + test('canonicalizes screening key letter case', async () => { + expect(await new InMemoryScreeningProvider(['02AB']).isSanctioned('02ab')).toBe(true) + }) +}) diff --git a/packages/overlays/topics/src/mandala/types.ts b/packages/overlays/topics/src/mandala/types.ts index 75b0c8556..33e33696e 100644 --- a/packages/overlays/topics/src/mandala/types.ts +++ b/packages/overlays/topics/src/mandala/types.ts @@ -61,11 +61,11 @@ export interface ScreeningProvider { export class InMemoryScreeningProvider implements ScreeningProvider { private readonly banned: Set constructor (bannedIdentityKeys: PubKeyHex[] = []) { - this.banned = new Set(bannedIdentityKeys) + this.banned = new Set(bannedIdentityKeys.map(key => key.toLowerCase())) } async isSanctioned (identityKey: PubKeyHex): Promise { - return this.banned.has(identityKey) + return this.banned.has(identityKey.toLowerCase()) } } @@ -73,6 +73,23 @@ export const encodeLinkagePayload = (payload: MandalaLinkagePayload): number[] = return Utils.toArray(JSON.stringify(payload), 'utf8') } +function validateIndices (entries: unknown, label: string): void { + if (!Array.isArray(entries)) throw new Error(`Mandala ${label} must be an array`) + const seen = new Set() + for (const entry of entries) { + const index = entry?.index + if (!Number.isSafeInteger(index) || index < 0 || seen.has(index)) { + throw new Error(`Mandala ${label} must contain unique non-negative integer indices`) + } + seen.add(index) + } +} + export const decodeLinkagePayload = (bytes: number[]): MandalaLinkagePayload => { - return JSON.parse(Utils.toUTF8(bytes)) as MandalaLinkagePayload + const payload = JSON.parse(Utils.toUTF8(bytes)) as MandalaLinkagePayload + if (payload == null || typeof payload !== 'object') throw new Error('Mandala payload must be an object') + validateIndices(payload.inputs, 'inputs') + validateIndices(payload.outputs, 'outputs') + if (payload.admin !== undefined) validateIndices(payload.admin, 'admin') + return payload }