diff --git a/.npmrc b/.npmrc
new file mode 100644
index 000000000..f157c4ea9
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,6 @@
+# npm 12+ defaults allow-git=none.
+# @fabric/core itself has no Fabric git dependencies, but consumers that install
+# core/http/hub from GitHub need `allow-git=all` so nested git dep preparation
+# (commit-SHA fetches) is not refused. Keep this file so the monorepo default
+# matches Hub / http / app packages.
+allow-git=all
diff --git a/API.md b/API.md
index 2c3c3660f..eeec6cda6 100644
--- a/API.md
+++ b/API.md
@@ -868,6 +868,8 @@ Author Schnorr over signingString (gossip).
* [new Bond()](#new_Bond_new)
* [.deploy()](#Contract+deploy) ⇒ String
* [.start()](#Contract+start) ⇒ [Contract](#Contract)
+ * [._taprootPolicyInputs([overrides])](#Contract+_taprootPolicyInputs) ⇒ object
+ * [.toAddress([network])](#Contract+toAddress) ⇒ string
* [._appendWarning(msg)](#Service+_appendWarning) ⇒ [Service](#Service)
* [.init()](#Service+init)
* [.tick()](#Service+tick) ⇒ Number
@@ -910,6 +912,29 @@ Start the Contract.
**Kind**: instance method of [Bond](#Bond)
**Returns**: [Contract](#Contract) - State "STARTED" iteration of the Contract.
+
+
+### bond.\_taprootPolicyInputs([overrides]) ⇒ object
+Shared spend-policy inputs for [#toTaprootContract](#toTaprootContract).
+Subclasses (e.g. Federation) may override to supply validators from state.
+
+**Kind**: instance method of [Bond](#Bond)
+
+| Param | Type |
+| --- | --- |
+| [overrides] | object |
+
+
+
+### bond.toAddress([network]) ⇒ string
+Bech32m P2TR address for this contract's spend policy.
+
+**Kind**: instance method of [Bond](#Bond)
+
+| Param | Type |
+| --- | --- |
+| [network] | string |
+
### bond.\_appendWarning(msg) ⇒ [Service](#Service)
@@ -2026,6 +2051,8 @@ Loads [State](#State) into memory.
* [new Contract()](#new_Contract_new)
* [.deploy()](#Contract+deploy) ⇒ String
* [.start()](#Contract+start) ⇒ [Contract](#Contract)
+ * [._taprootPolicyInputs([overrides])](#Contract+_taprootPolicyInputs) ⇒ object
+ * [.toAddress([network])](#Contract+toAddress) ⇒ string
* [._appendWarning(msg)](#Service+_appendWarning) ⇒ [Service](#Service)
* [.init()](#Service+init)
* [.tick()](#Service+tick) ⇒ Number
@@ -2070,6 +2097,29 @@ Start the Contract.
**Kind**: instance method of [Contract](#Contract)
**Overrides**: [start](#Service+start)
**Returns**: [Contract](#Contract) - State "STARTED" iteration of the Contract.
+
+
+### contract.\_taprootPolicyInputs([overrides]) ⇒ object
+Shared spend-policy inputs for [#toTaprootContract](#toTaprootContract).
+Subclasses (e.g. Federation) may override to supply validators from state.
+
+**Kind**: instance method of [Contract](#Contract)
+
+| Param | Type |
+| --- | --- |
+| [overrides] | object |
+
+
+
+### contract.toAddress([network]) ⇒ string
+Bech32m P2TR address for this contract's spend policy.
+
+**Kind**: instance method of [Contract](#Contract)
+
+| Param | Type |
+| --- | --- |
+| [network] | string |
+
### contract.\_appendWarning(msg) ⇒ [Service](#Service)
@@ -3535,6 +3585,7 @@ Create and manage sets of {Signer} instances with the Federation class.
* [.verify(msg, sig)](#Federation+verify) ⇒ Boolean
* [.createMultiSignature(msg)](#Federation+createMultiSignature) ⇒ Object
* [.verifyMultiSignature(multiSig, threshold)](#Federation+verifyMultiSignature) ⇒ Boolean
+ * [._taprootPolicyInputs([overrides])](#Federation+_taprootPolicyInputs) ⇒ object
@@ -3605,6 +3656,17 @@ Verifies a multi-signature against a message.
| multiSig | Object | | The multi-signature object |
| threshold | Number | 1 | Number of valid signatures required |
+
+
+### federation.\_taprootPolicyInputs([overrides]) ⇒ object
+Federation validators live on consensus state, not only constructor settings.
+
+**Kind**: instance method of [Federation](#Federation)
+
+| Param | Type |
+| --- | --- |
+| [overrides] | object |
+
## Filesystem
@@ -6220,10 +6282,13 @@ see [Message](#Message) wire vs friendly names and constants opcode
* [._buildPublishDocumentWireBuffers(documentId, body, rateSats)](#Peer+_buildPublishDocumentWireBuffers) ⇒ Array.<Buffer>
* [._announceLocalDocumentsToPeer(peerAddress)](#Peer+_announceLocalDocumentsToPeer)
* [._publishDocument(documentId, [content], [rateSats])](#Peer+_publishDocument)
- * [._handleDocumentRequestWire(message, origin, socket)](#Peer+_handleDocumentRequestWire)
+ * [._handleDocumentRequestWire(message, origin, socket, [options])](#Peer+_handleDocumentRequestWire)
* [._privateRelayDocumentRequest(parsed, origin, [originalMessage])](#Peer+_privateRelayDocumentRequest)
+ * [._sendPrivateRelayedDocumentRequest(msg, origin, parsed)](#Peer+_sendPrivateRelayedDocumentRequest) ⇒ boolean
* [._maybeReverseRelayFileSend(fileObj, origin)](#Peer+_maybeReverseRelayFileSend) ⇒ boolean
- * [._mergeContractPatchAllowList(contractId, object, publisherPubkeyHex)](#Peer+_mergeContractPatchAllowList)
+ * [._contractPublishSignerAuthorized(object, signerPubkeyHex)](#Peer+_contractPublishSignerAuthorized) ⇒ boolean
+ * [._registerContract(object, [publisherPubkeyHex])](#Peer+_registerContract) ⇒ boolean
+ * [._mergeContractPatchAllowList(contractId, object, [_publisherPubkeyHex])](#Peer+_mergeContractPatchAllowList)
* [._signerMayPatchContract(contractId, signerPubkeyHex)](#Peer+_signerMayPatchContract) ⇒ boolean
* [._startFabricPingKeepalive(socket, encryptWrite)](#Peer+_startFabricPingKeepalive)
* [.start()](#Peer+start)
@@ -6775,14 +6840,15 @@ Handle a Fabric [Message](#Message) buffer.
**Kind**: instance method of [Peer](#Peer)
**Returns**: [Peer](#Peer) - Instance of the Peer.
-| Param | Type | Default |
-| --- | --- | --- |
-| buffer | Buffer | |
-| [origin] | object \| null | |
-| [socket] | object \| null | |
-| [options] | Object | |
-| [options.relayDepth] | number | |
-| [options.skipRelayFlood] | boolean | |
+| Param | Type | Default | Description |
+| --- | --- | --- | --- |
+| buffer | Buffer | | |
+| [origin] | object \| null | | |
+| [socket] | object \| null | | |
+| [options] | Object | | |
+| [options.relayDepth] | number | | |
+| [options.skipRelayFlood] | boolean | | |
+| [options.peeledForward] | boolean | | true when delivered via [Peer#_handleP2PForward](Peer#_handleP2PForward) peel |
@@ -7091,18 +7157,22 @@ Store a document locally and gossip to peers.
-### peer.\_handleDocumentRequestWire(message, origin, socket)
+### peer.\_handleDocumentRequestWire(message, origin, socket, [options])
Handle inbound `DOCUMENT_REQUEST`: emit `documentRequest` / `DocumentRequest`, then either
send `P2P_FILE_SEND` (when [Peer#settings.autoFulfillDocumentRequests](Peer#settings.autoFulfillDocumentRequests)), queue for
operator approve, or relay when the document is not held.
+Peel / foreign-signed `P2P_RELAY` deliveries are local-observe only: never fulfill or
+queue against the TCP last hop, and never second-flood the inner under that hop.
+
**Kind**: instance method of [Peer](#Peer)
-| Param | Type |
-| --- | --- |
-| message | [Message](#Message) |
-| origin | Object |
-| socket | \* |
+| Param | Type | Default | Description |
+| --- | --- | --- | --- |
+| message | [Message](#Message) | | |
+| origin | Object | | |
+| socket | \* | | |
+| [options] | Object | | same delivery opts as [_handleFabricMessage](#Peer+_handleFabricMessage) |
@@ -7117,6 +7187,21 @@ Rewrite a budgeted DocumentRequest (privacy) and forward with reduced maxSats.
| origin | Object | |
| [originalMessage] | [Message](#Message) | |
+
+
+### peer.\_sendPrivateRelayedDocumentRequest(msg, origin, parsed) ⇒ boolean
+Deliver a rewritten private DocumentRequest without mesh broadcast.
+Preference: onion `relayPath` → explicit `nextPeer` → fan-out to TCP peers
+other than the inbound origin.
+
+**Kind**: instance method of [Peer](#Peer)
+
+| Param | Type | Description |
+| --- | --- | --- |
+| msg | [Message](#Message) | signed DocumentRequest |
+| origin | Object \| null | |
+| parsed | object | inbound request body |
+
### peer.\_maybeReverseRelayFileSend(fileObj, origin) ⇒ boolean
@@ -7130,20 +7215,48 @@ Forward a relayed `P2P_FILE_SEND` / key reveal back toward the buyer using rever
| fileObj | object |
| origin | Object |
+
+
+### peer.\_contractPublishSignerAuthorized(object, signerPubkeyHex) ⇒ boolean
+When a publish body declares authority arrays, the AMP wire signer must be
+one of them. Bodies with no authorities are allowed (observe-only; empty
+patch allow-list). Missing signer (local seed) is allowed.
+
+**Kind**: instance method of [Peer](#Peer)
+
+| Param | Type | Default |
+| --- | --- | --- |
+| object | object | |
+| signerPubkeyHex | string \| null | null |
+
+
+
+### peer.\_registerContract(object, [publisherPubkeyHex]) ⇒ boolean
+**Kind**: instance method of [Peer](#Peer)
+**Returns**: boolean - true when newly registered (or already present no-op)
+
+| Param | Type | Default |
+| --- | --- | --- |
+| object | object | |
+| [publisherPubkeyHex] | string \| null | null |
+
-### peer.\_mergeContractPatchAllowList(contractId, object, publisherPubkeyHex)
+### peer.\_mergeContractPatchAllowList(contractId, object, [_publisherPubkeyHex])
Build the set of pubkeys allowed to apply CONTRACT_MESSAGE ops for a newly
registered contract. Called only on first registration of a contract id —
-republishes must not invoke this (see [Peer#_registerContract](Peer#_registerContract)).
+republishes must not invoke this (see [_registerContract](#Peer+_registerContract)).
+Membership is taken **only** from body authority arrays (`parties`,
+`validators`, `owners`, `members`, `authorities`). The wire signer is never
+granted rights unless already listed there.
**Kind**: instance method of [Peer](#Peer)
-| Param | Type | Description |
-| --- | --- | --- |
-| contractId | string | |
-| object | object | contract publish body |
-| publisherPubkeyHex | string \| null | wire signer of the first publish |
+| Param | Type | Default | Description |
+| --- | --- | --- | --- |
+| contractId | string | | |
+| object | object | | contract publish body |
+| [_publisherPubkeyHex] | string \| null | null | ignored (kept for call-site compat) |
@@ -7640,10 +7753,13 @@ Parse an Object into a corresponding Fabric state.
* [._buildPublishDocumentWireBuffers(documentId, body, rateSats)](#Peer+_buildPublishDocumentWireBuffers) ⇒ Array.<Buffer>
* [._announceLocalDocumentsToPeer(peerAddress)](#Peer+_announceLocalDocumentsToPeer)
* [._publishDocument(documentId, [content], [rateSats])](#Peer+_publishDocument)
- * [._handleDocumentRequestWire(message, origin, socket)](#Peer+_handleDocumentRequestWire)
+ * [._handleDocumentRequestWire(message, origin, socket, [options])](#Peer+_handleDocumentRequestWire)
* [._privateRelayDocumentRequest(parsed, origin, [originalMessage])](#Peer+_privateRelayDocumentRequest)
+ * [._sendPrivateRelayedDocumentRequest(msg, origin, parsed)](#Peer+_sendPrivateRelayedDocumentRequest) ⇒ boolean
* [._maybeReverseRelayFileSend(fileObj, origin)](#Peer+_maybeReverseRelayFileSend) ⇒ boolean
- * [._mergeContractPatchAllowList(contractId, object, publisherPubkeyHex)](#Peer+_mergeContractPatchAllowList)
+ * [._contractPublishSignerAuthorized(object, signerPubkeyHex)](#Peer+_contractPublishSignerAuthorized) ⇒ boolean
+ * [._registerContract(object, [publisherPubkeyHex])](#Peer+_registerContract) ⇒ boolean
+ * [._mergeContractPatchAllowList(contractId, object, [_publisherPubkeyHex])](#Peer+_mergeContractPatchAllowList)
* [._signerMayPatchContract(contractId, signerPubkeyHex)](#Peer+_signerMayPatchContract) ⇒ boolean
* [._startFabricPingKeepalive(socket, encryptWrite)](#Peer+_startFabricPingKeepalive)
* [.start()](#Peer+start)
@@ -8195,14 +8311,15 @@ Handle a Fabric [Message](#Message) buffer.
**Kind**: instance method of [Peer](#Peer)
**Returns**: [Peer](#Peer) - Instance of the Peer.
-| Param | Type | Default |
-| --- | --- | --- |
-| buffer | Buffer | |
-| [origin] | object \| null | |
-| [socket] | object \| null | |
-| [options] | Object | |
-| [options.relayDepth] | number | |
-| [options.skipRelayFlood] | boolean | |
+| Param | Type | Default | Description |
+| --- | --- | --- | --- |
+| buffer | Buffer | | |
+| [origin] | object \| null | | |
+| [socket] | object \| null | | |
+| [options] | Object | | |
+| [options.relayDepth] | number | | |
+| [options.skipRelayFlood] | boolean | | |
+| [options.peeledForward] | boolean | | true when delivered via [Peer#_handleP2PForward](Peer#_handleP2PForward) peel |
@@ -8511,18 +8628,22 @@ Store a document locally and gossip to peers.
-### peer.\_handleDocumentRequestWire(message, origin, socket)
+### peer.\_handleDocumentRequestWire(message, origin, socket, [options])
Handle inbound `DOCUMENT_REQUEST`: emit `documentRequest` / `DocumentRequest`, then either
send `P2P_FILE_SEND` (when [Peer#settings.autoFulfillDocumentRequests](Peer#settings.autoFulfillDocumentRequests)), queue for
operator approve, or relay when the document is not held.
+Peel / foreign-signed `P2P_RELAY` deliveries are local-observe only: never fulfill or
+queue against the TCP last hop, and never second-flood the inner under that hop.
+
**Kind**: instance method of [Peer](#Peer)
-| Param | Type |
-| --- | --- |
-| message | [Message](#Message) |
-| origin | Object |
-| socket | \* |
+| Param | Type | Default | Description |
+| --- | --- | --- | --- |
+| message | [Message](#Message) | | |
+| origin | Object | | |
+| socket | \* | | |
+| [options] | Object | | same delivery opts as [_handleFabricMessage](#Peer+_handleFabricMessage) |
@@ -8537,6 +8658,21 @@ Rewrite a budgeted DocumentRequest (privacy) and forward with reduced maxSats.
| origin | Object | |
| [originalMessage] | [Message](#Message) | |
+
+
+### peer.\_sendPrivateRelayedDocumentRequest(msg, origin, parsed) ⇒ boolean
+Deliver a rewritten private DocumentRequest without mesh broadcast.
+Preference: onion `relayPath` → explicit `nextPeer` → fan-out to TCP peers
+other than the inbound origin.
+
+**Kind**: instance method of [Peer](#Peer)
+
+| Param | Type | Description |
+| --- | --- | --- |
+| msg | [Message](#Message) | signed DocumentRequest |
+| origin | Object \| null | |
+| parsed | object | inbound request body |
+
### peer.\_maybeReverseRelayFileSend(fileObj, origin) ⇒ boolean
@@ -8550,20 +8686,48 @@ Forward a relayed `P2P_FILE_SEND` / key reveal back toward the buyer using rever
| fileObj | object |
| origin | Object |
+
+
+### peer.\_contractPublishSignerAuthorized(object, signerPubkeyHex) ⇒ boolean
+When a publish body declares authority arrays, the AMP wire signer must be
+one of them. Bodies with no authorities are allowed (observe-only; empty
+patch allow-list). Missing signer (local seed) is allowed.
+
+**Kind**: instance method of [Peer](#Peer)
+
+| Param | Type | Default |
+| --- | --- | --- |
+| object | object | |
+| signerPubkeyHex | string \| null | null |
+
+
+
+### peer.\_registerContract(object, [publisherPubkeyHex]) ⇒ boolean
+**Kind**: instance method of [Peer](#Peer)
+**Returns**: boolean - true when newly registered (or already present no-op)
+
+| Param | Type | Default |
+| --- | --- | --- |
+| object | object | |
+| [publisherPubkeyHex] | string \| null | null |
+
-### peer.\_mergeContractPatchAllowList(contractId, object, publisherPubkeyHex)
+### peer.\_mergeContractPatchAllowList(contractId, object, [_publisherPubkeyHex])
Build the set of pubkeys allowed to apply CONTRACT_MESSAGE ops for a newly
registered contract. Called only on first registration of a contract id —
-republishes must not invoke this (see [Peer#_registerContract](Peer#_registerContract)).
+republishes must not invoke this (see [_registerContract](#Peer+_registerContract)).
+Membership is taken **only** from body authority arrays (`parties`,
+`validators`, `owners`, `members`, `authorities`). The wire signer is never
+granted rights unless already listed there.
**Kind**: instance method of [Peer](#Peer)
-| Param | Type | Description |
-| --- | --- | --- |
-| contractId | string | |
-| object | object | contract publish body |
-| publisherPubkeyHex | string \| null | wire signer of the first publish |
+| Param | Type | Default | Description |
+| --- | --- | --- | --- |
+| contractId | string | | |
+| object | object | | contract publish body |
+| [_publisherPubkeyHex] | string \| null | null | ignored (kept for call-site compat) |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29687cecf..ebadfd5a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,9 @@
# `@fabric/core` Changelog
Recent changes to Fabric Core.
+## 2026-08-06
+- **npm git deps:** add **`.npmrc`** with **`allow-git=all`** (and `report:install`) so the monorepo matches Hub/http/app consumers — npm 12+ nested git-dep preparation of commit SHAs fails under `allow-git=root`.
+
## 2026-07-29
JS-canonical protocol for 0.1.0; Lightning-style wire preimage; unsigned document binding; public-readiness cuts; Peer scoring; directed onion forward.
diff --git a/DEVELOPERS.md b/DEVELOPERS.md
index d573b4589..6a4a70e4c 100644
--- a/DEVELOPERS.md
+++ b/DEVELOPERS.md
@@ -23,7 +23,8 @@ Read **[VISION.md](VISION.md)** first for what Fabric is building, how **`@fabri
See also [`QUICKSTART.md`][quickstart-guide] for up-to-date instructions.
0. `nvm use 24.15.0` (install [`nvm`][nvm-official] if needed; matches `.nvmrc` / `package.json` engines)
-1. From a clone of this repo: `npm install` (or `npm install -g @fabric/core` to put `fabric` on your `PATH`)
+0b. Ensure **npm 12+** (`npm -v`). Node 24.15.0 may ship npm 11.x — upgrade with `npm install -g npm@12` (or newer) before installing. Downstream packages that install Fabric from GitHub need **`.npmrc` `allow-git=all`** (npm 12+); see Hub / `@fabric/http` / GoonCitizen.
+1. From a clone of this repo: `npm install` (or `npm install -g @fabric/core` to put `fabric` on your `PATH`).
2. (optional) `fabric setup` to generate a master key and local config
3. (optional) `fabric keygen` to generate a new master key without saving to disk (ephemeral)
4. Run `fabric` — the CLI entry is wired through `types/cli.js` and extends **`Service.FabricShell`**. **Contracts** (HTLCs, document sessions, programs, shell packs, …) are documented in **[docs/CONTRACTS.md](docs/CONTRACTS.md)**; terminal UX in **[docs/CLI.md](docs/CLI.md)**.
diff --git a/MESSAGES.md b/MESSAGES.md
index 13c433dd4..a1e2deed9 100644
--- a/MESSAGES.md
+++ b/MESSAGES.md
@@ -60,7 +60,7 @@ Primary sources:
| `BITCOIN_TRANSACTION_HASH` | 22100 | `0x5654` | Announces/propagates a Bitcoin transaction hash. |
| `LOG_MESSAGE` | 3235156080 | `0xc0d3f330` | Debug/log transport message for diagnostics. |
| `GENERIC_LIST` | 3235170158 | `0xc0d42e2e` | Generic list/queue-style payload container. |
-| `SIDECHAIN_STATE_PATCH` | 997 | `0x03e5` | Typed-field sidechain/registry update (`basisClock` / `basisDigest` / `catalogCanonical`). HTTP may map RFC6902 ↔ fields. |
+| `SIDECHAIN_STATE_PATCH` | 997 | `0x03e5` | Typed-field sidechain/registry update (`basisClock` / `basisDigest` / `catalogCanonical` / optional `patchesCanonical`). HTTP may map RFC6902 ↔ fields. |
| `DOCUMENT_PUBLISH` | 998 | `0x03e6` | Publishes a document descriptor/content reference. |
| `DOCUMENT_REQUEST` | 999 | `0x03e7` | Requests a document from peers/services. |
| `BLOCK_CANDIDATE` | 3 | `0x0003` | Candidate block announcement in peer coordination. |
diff --git a/docs/APPLICATION_NAMESPACES.md b/docs/APPLICATION_NAMESPACES.md
index 2cb305433..ae960505c 100644
--- a/docs/APPLICATION_NAMESPACES.md
+++ b/docs/APPLICATION_NAMESPACES.md
@@ -40,8 +40,24 @@ These `type` strings ride inside `CONTRACT_MESSAGE` (not outer opcodes):
| `GroupChat` | GoonCitizen Group Federation | Group channel chat |
| `GroupChange` | GoonCitizen Group Federation | Membership / meta |
| `GroupShare` | GoonCitizen Group Federation | Group-scoped shares (mission offers; `kind: GroupOffer` for opaque `fabric:` join offers) |
+| `GroupActivityTree` | GoonCitizen Group Federation | Merkle root + digests of cumulative history under a Group namespace |
+| `GroupJournalRequest` | GoonCitizen Group Federation | Request missing Statechain journal entries (`fromClock` → tip) |
+| `GroupJournalBatch` | GoonCitizen Group Federation | Catch-up batch of journal rows + tip Schnorr (`ContractStateTip`) |
+| `GroupStateJournal` | GoonCitizen Group Federation | Optional tip attestation: folded `stateDigest` signed to threshold |
+| `ContractCapabilityGrant` | Hub, GoonCitizen | Token-backed reader/signer grant (`OP_CONTRACT_READ` / `OP_CONTRACT_SIGN`) |
+| `ContractWithdrawalRequest` | Hub, GoonCitizen | Spend or decay-migrate from contract Taproot UTXO |
+| `ContractWithdrawalWitness` | Hub, GoonCitizen | Co-signer witness for withdrawal / migration |
| `GameStateSnapshot` | GoonCitizen → Hub sidechain | Cumulative analytics snapshot for Beacon seal (also listed under `ACTIVITY_TYPES`) |
+**Tip attestation:** journal tips use
+[`functions/contractStateSigning`](../functions/contractStateSigning.js)
+(`kind: ContractStateTip`, same k-of-n witness shape as Beacon epochs). Hub must
+track this module when sealing contract-namespace sidechains.
+
+**Taproot spend ladder:** [`functions/contractTaproot`](../functions/contractTaproot.js)
+builds deterministic P2TR trees from author-defined failover tiers (`after` / `until`
+decay + optional migrate). See DISTRIBUTED_EXECUTION.md.
+
### Shared activity / GenericMessage types
Not outer opcodes; not always `CONTRACT_MESSAGE` bodies. Catalogued as
@@ -71,5 +87,6 @@ it is hashed into the contract `Actor` id (GoonCitizen network genesis).
1. **New mesh features** use the outer types above — not new one-off opcodes per app.
2. **App-specific semantics** go in `CONTRACT_MESSAGE` body `type` + `object` under a published contract id.
3. **Ignore unknown namespaces** — never crash the Peer on unfamiliar `contract` ids.
-4. **Hub invite JSON** (`FederationContractInvite` v2) is the shared join/policy shape.
-5. Prefer importing names from `@fabric/core/functions/applicationNamespaces` rather than duplicating string literals.
+4. **Invite JSON** (`FederationContractInvite` v2) is the shared join/policy shape — parse/build lives in **`@fabric/http/functions/federationContractInvite`** (keep JSON bridges out of core).
+5. Prefer importing body-type names from `@fabric/core/functions/applicationNamespaces` rather than duplicating string literals.
+6. **`contract:message` events** expose `wireMessage` / `messageHex` so apps can attach bit-identical AMP frames to journal rows.
diff --git a/docs/DISTRIBUTED_EXECUTION.md b/docs/DISTRIBUTED_EXECUTION.md
index ba7274a6d..9bc947b02 100644
--- a/docs/DISTRIBUTED_EXECUTION.md
+++ b/docs/DISTRIBUTED_EXECUTION.md
@@ -20,6 +20,10 @@ Hub’s longer narrative (Beacon, delegation, signing rounds): hub.fabric.pub
|--------|------|
| `fabricCanonicalJson` | Deterministic digests (Actor / sidechain / Program) |
| `beaconFederationSigning` | Epoch commitment strings + federation witness verify |
+| `contractStateSigning` | Contract-namespace tip Schnorr (`ContractStateTip`); same witness shape as Beacon |
+| `contractTaproot` | Failover ladder → P2TR (`toAddress` / `toTaprootContract`); `after`/`until` decay + migrate |
+| `contractTierWhen` | Off-chain `when` predicates for tier activation |
+| `contractCapability` | Token issue/verify for `OP_CONTRACT_READ` / `OP_CONTRACT_SIGN` |
| `fabricProgramManifest` | Manifest v1 (`programId` / `programHash` / allowed types / optional `sidechainPolicy`) |
| **`sidechainState`** | Sealed JSON document: digests, RFC6902 patches, path policy, journal, snapshots, Beacon tip restore, contract namespace seals |
@@ -85,11 +89,12 @@ Accepted `CONTRACT_PUBLISH` ids reuse the **same** document helpers under
`SIDECHAIN_STATE_PATCH` uses the **same opcode / type name** across Peer, Beacon,
and HTTP. Core prefers **typed fields** (`basisClock`, `basisDigest`,
-`catalogCanonical`) via `messageBodyCodec` /
+`catalogCanonical`, optional `patchesCanonical`) via `messageBodyCodec` /
`functions/documentRegistrySidechain`. **RFC6902 JSON patch arrays are an
`@fabric/http` edge transform** (`messageBodyJsonBridge`) of those fields — not
-the core/simulator primary API. Digests of `sidechain/STATE` still use
-`fabricCanonicalJson` (digest ≠ wire body).
+the core/simulator primary API. Multi-op sequences round-trip through
+`patchesCanonical` (UTF-8 JSON array); `/registry` still seeds `catalogCanonical`.
+Digests of `sidechain/STATE` still use `fabricCanonicalJson` (digest ≠ wire body).
A dedicated numeric outer opcode may be allocated later.
diff --git a/docs/MESSAGE_BODY.md b/docs/MESSAGE_BODY.md
index 19d917c98..7dc4f4f7e 100644
--- a/docs/MESSAGE_BODY.md
+++ b/docs/MESSAGE_BODY.md
@@ -74,7 +74,8 @@ API: `types/message.js` body codec helpers (`Message.encodeBody` /
- HTTP / Bridge may map field structs ↔ JSON for REST and browsers
(`functions/messageBodyJsonBridge.js` in `@fabric/http`), including
RFC6902 patch arrays ↔ `SIDECHAIN_STATE_PATCH` fields
- (`basisClock` / `basisDigest` / `catalogCanonical`).
+ (`basisClock` / `basisDigest` / `catalogCanonical` / optional
+ `patchesCanonical` for multi-op fidelity).
- **Legacy:** object bodies without a registered schema still
`JSON.stringify` (deprecated transitional path for GenericMessage and
unmigrated types). New opcodes **must** ship a field schema.
diff --git a/functions/applicationNamespaces.js b/functions/applicationNamespaces.js
index d19d5aa61..d7d45693c 100644
--- a/functions/applicationNamespaces.js
+++ b/functions/applicationNamespaces.js
@@ -64,7 +64,19 @@ const CONTRACT_BODY_TYPES = Object.freeze({
GroupChange: 'GroupChange',
GroupShare: 'GroupShare',
/** Merkle root + digests of cumulative local history leaves under a Group namespace. */
- GroupActivityTree: 'GroupActivityTree'
+ GroupActivityTree: 'GroupActivityTree',
+ /** Request missing Statechain journal entries (fromClock → tip). */
+ GroupJournalRequest: 'GroupJournalRequest',
+ /** Catch-up batch of accepted journal rows + tip Schnorr (k-of-n members). */
+ GroupJournalBatch: 'GroupJournalBatch',
+ /** Optional tip attestation: folded stateDigest signed by member threshold. */
+ GroupStateJournal: 'GroupStateJournal',
+ /** Token-backed contract role grant (reader / signer). */
+ ContractCapabilityGrant: 'ContractCapabilityGrant',
+ /** Propose spend or decay-migrate from contract Taproot UTXO. */
+ ContractWithdrawalRequest: 'ContractWithdrawalRequest',
+ /** Co-signer witness for a withdrawal / migration. */
+ ContractWithdrawalWitness: 'ContractWithdrawalWitness'
});
/**
diff --git a/functions/contractCapability.js b/functions/contractCapability.js
new file mode 100644
index 000000000..f99a2a184
--- /dev/null
+++ b/functions/contractCapability.js
@@ -0,0 +1,118 @@
+'use strict';
+
+/**
+ * Contract-scoped Token capabilities (read-only member vs signer).
+ */
+
+const Key = require('../types/key');
+const Token = require('../types/token');
+
+const OP_CONTRACT_READ = 'OP_CONTRACT_READ';
+const OP_CONTRACT_SIGN = 'OP_CONTRACT_SIGN';
+
+/**
+ * @param {object} opts
+ * @param {object|Key} opts.issuerKey Fabric Key (or settings) of issuer
+ * @param {string} opts.subject Subject compressed pubkey
+ * @param {string} opts.contractId Contract / namespace id
+ * @param {string} [opts.capability=OP_CONTRACT_READ]
+ * @param {number} [opts.expiresInSeconds]
+ * @returns {string} Token.toSignedString()
+ */
+function issueContractCapability (opts = {}) {
+ const capability = opts.capability || OP_CONTRACT_READ;
+ if (capability !== OP_CONTRACT_READ && capability !== OP_CONTRACT_SIGN) {
+ throw new Error(`unsupported contract capability: ${capability}`);
+ }
+ const contractId = String(opts.contractId || '').trim().toLowerCase();
+ if (!contractId) throw new Error('contractId required');
+ const subject = String(opts.subject || '').trim().toLowerCase();
+ if (!subject) throw new Error('subject required');
+ const issuer = opts.issuerKey && typeof opts.issuerKey.sign === 'function'
+ ? opts.issuerKey
+ : new Key(opts.issuerKey || {});
+ const token = new Token({
+ capability,
+ issuer,
+ subject,
+ ctx: { contractId }
+ });
+ return token.toSignedString({
+ expiresInSeconds: opts.expiresInSeconds,
+ ctx: { contractId }
+ });
+}
+
+/**
+ * @param {string} tokenString
+ * @param {object} expect
+ * @param {string} expect.contractId
+ * @param {string} [expect.expectedCap]
+ * @param {string} [expect.subject]
+ * @param {Key|object} [expect.issuerKey] Verify Schnorr against this key (auth path)
+ * @param {boolean} [expect.allowUnverified] Opt-in parse-only (no sig check); result has verified:false
+ * @returns {{ cap: string, iss: string, sub: string, iat: number, exp: number, ctx?: object, verified?: boolean }|null}
+ */
+function verifyContractCapability (tokenString, expect = {}) {
+ const contractId = String(expect.contractId || '').trim().toLowerCase();
+ if (!contractId) return null;
+ let payload = null;
+ let verified = false;
+ if (expect.issuerKey) {
+ const key = expect.issuerKey && typeof expect.issuerKey.verify === 'function'
+ ? expect.issuerKey
+ : new Key(expect.issuerKey);
+ payload = Token.verifySigned(tokenString, key);
+ verified = !!payload;
+ } else if (expect.allowUnverified === true) {
+ // Parse-only: not authorization. Callers must not treat this as a verified grant.
+ if (!tokenString || typeof tokenString !== 'string') return null;
+ const parts = tokenString.split('.');
+ if (parts.length !== 2) return null;
+ try {
+ const { tryParseWireJson } = require('./wireJson');
+ const payloadStr = Token.base64UrlDecode(parts[0]);
+ const pr = tryParseWireJson(payloadStr);
+ if (!pr.ok) return null;
+ payload = pr.value;
+ if (!payload || payload.exp == null || Date.now() / 1000 > payload.exp) return null;
+ verified = false;
+ } catch (_) {
+ return null;
+ }
+ } else {
+ return null;
+ }
+ if (!payload) return null;
+ const ctxId = payload.ctx && payload.ctx.contractId
+ ? String(payload.ctx.contractId).trim().toLowerCase()
+ : '';
+ if (ctxId !== contractId) return null;
+ if (expect.expectedCap && payload.cap !== expect.expectedCap) return null;
+ if (expect.subject) {
+ const sub = String(payload.sub || '').trim().toLowerCase();
+ if (sub !== String(expect.subject).trim().toLowerCase()) return null;
+ }
+ return Object.assign({}, payload, { verified });
+}
+
+function roleToCapability (role) {
+ const r = String(role || '').toLowerCase();
+ if (r === 'signer' || r === 'sign') return OP_CONTRACT_SIGN;
+ return OP_CONTRACT_READ;
+}
+
+function capabilityToRole (cap) {
+ if (cap === OP_CONTRACT_SIGN) return 'signer';
+ if (cap === OP_CONTRACT_READ) return 'reader';
+ return null;
+}
+
+module.exports = {
+ OP_CONTRACT_READ,
+ OP_CONTRACT_SIGN,
+ issueContractCapability,
+ verifyContractCapability,
+ roleToCapability,
+ capabilityToRole
+};
diff --git a/functions/contractStateSigning.js b/functions/contractStateSigning.js
new file mode 100644
index 000000000..fc5f5d8e0
--- /dev/null
+++ b/functions/contractStateSigning.js
@@ -0,0 +1,143 @@
+'use strict';
+
+/**
+ * Contract-namespace tip attestation (k-of-n Schnorr).
+ *
+ * Same witness shape as {@link beaconFederationSigning}: members / validators
+ * Schnorr-sign a canonical tip string. Used by GoonCitizen Group Statechain
+ * journals today; Hub contract sidechains SHOULD reuse this when sealing
+ * per-namespace tips (keep Hub docs / RPC in sync if the tip kind changes).
+ *
+ * @see functions/beaconFederationSigning.js
+ * @see docs/APPLICATION_NAMESPACES.md
+ * @see docs/DISTRIBUTED_EXECUTION.md
+ */
+
+const crypto = require('crypto');
+const Key = require('../types/key');
+const fabricCanonicalJson = require('./fabricCanonicalJson');
+const { verifyFederationWitnessOnMessage } = require('./beaconFederationSigning');
+
+/** Canonical tip kind — Hub and apps must agree; bump only with protocol note. */
+const CONTRACT_STATE_TIP_KIND = 'ContractStateTip';
+
+/**
+ * UTF-8 string members Schnorr-sign for a contract-namespace tip.
+ * @param {object} fields
+ * @param {string} fields.contractId
+ * @param {number} fields.clock
+ * @param {string} fields.stateDigest
+ * @returns {string}
+ */
+function signingStringForContractStateTip (fields = {}) {
+ return fabricCanonicalJson({
+ version: 1,
+ kind: CONTRACT_STATE_TIP_KIND,
+ contractId: String(fields.contractId || '').trim().toLowerCase(),
+ clock: Number(fields.clock) || 0,
+ stateDigest: String(fields.stateDigest || '').trim().toLowerCase()
+ });
+}
+
+/**
+ * @param {string} contractId
+ * @param {number} clock
+ * @param {string} stateDigest
+ * @returns {Buffer}
+ */
+function tipMessageBuffer (contractId, clock, stateDigest) {
+ return Buffer.from(signingStringForContractStateTip({ contractId, clock, stateDigest }), 'utf8');
+}
+
+/**
+ * Sign a tip with a Fabric Key (or `{ xprv }` / Key-like).
+ * @param {object} keyOrSettings Key instance or settings for `new Key(...)`
+ * @param {string} contractId
+ * @param {number} clock
+ * @param {string} stateDigest
+ * @returns {{ pubkey: string, signature: string, message: string }}
+ */
+function signContractStateTip (keyOrSettings, contractId, clock, stateDigest) {
+ const key = keyOrSettings && typeof keyOrSettings.signSchnorr === 'function'
+ ? keyOrSettings
+ : new Key(keyOrSettings || {});
+ const message = signingStringForContractStateTip({ contractId, clock, stateDigest });
+ const signature = Buffer.from(key.signSchnorr(Buffer.from(message, 'utf8'))).toString('hex');
+ return { pubkey: key.pubkey, signature, message };
+}
+
+/**
+ * Verify k-of-n tip signatures (Federation witness shape).
+ * @param {string[]} validatorPubkeys
+ * @param {number} threshold
+ * @param {string} contractId
+ * @param {number} clock
+ * @param {string} stateDigest
+ * @param {{ [pubkey: string]: string }|{ signatures: object }} signaturesOrWitness
+ * @returns {boolean}
+ */
+function verifyContractStateTip (
+ validatorPubkeys,
+ threshold,
+ contractId,
+ clock,
+ stateDigest,
+ signaturesOrWitness
+) {
+ const thr = Number(threshold);
+ if (!Number.isInteger(thr) || thr < 1) {
+ throw new Error('threshold must be a positive integer');
+ }
+ const witness = signaturesOrWitness && signaturesOrWitness.signatures
+ && typeof signaturesOrWitness.signatures === 'object'
+ ? signaturesOrWitness
+ : { signatures: signaturesOrWitness || {} };
+ return verifyFederationWitnessOnMessage(
+ tipMessageBuffer(contractId, clock, stateDigest),
+ witness,
+ validatorPubkeys,
+ thr
+ );
+}
+
+/**
+ * Merge signature maps (pubkey → sig hex), last write wins per key.
+ * @param {...object} maps
+ * @returns {{ [pubkey: string]: string }}
+ */
+function mergeTipSignatures (...maps) {
+ const out = {};
+ for (const m of maps) {
+ if (!m || typeof m !== 'object') continue;
+ const src = m.signatures && typeof m.signatures === 'object' ? m.signatures : m;
+ for (const [pk, sig] of Object.entries(src)) {
+ if (typeof pk === 'string' && pk && typeof sig === 'string' && sig) {
+ out[pk] = sig;
+ }
+ }
+ }
+ return out;
+}
+
+/**
+ * Stable digest helper for tests / logging.
+ * @param {string} contractId
+ * @param {number} clock
+ * @param {string} stateDigest
+ * @returns {string} hex sha256 of tip message
+ */
+function tipDigestHex (contractId, clock, stateDigest) {
+ return crypto.createHash('sha256')
+ .update(tipMessageBuffer(contractId, clock, stateDigest))
+ .digest('hex');
+}
+
+module.exports = {
+ CONTRACT_STATE_TIP_KIND,
+ signingStringForContractStateTip,
+ tipMessageBuffer,
+ signContractStateTip,
+ verifyContractStateTip,
+ mergeTipSignatures,
+ tipDigestHex
+};
diff --git a/functions/contractTaproot.js b/functions/contractTaproot.js
new file mode 100644
index 000000000..288cca538
--- /dev/null
+++ b/functions/contractTaproot.js
@@ -0,0 +1,851 @@
+'use strict';
+
+/**
+ * Deterministic Contract → P2TR spend policy (failover ladder).
+ *
+ * Tiers compile to tapscript leaves (k-of-n + optional CSV/CLTV `after`).
+ * `until` expires tiers off-chain (and schedules decay migration leaves).
+ *
+ * @see docs/DISTRIBUTED_EXECUTION.md
+ * @see functions/contractTierWhen.js
+ */
+
+const bitcoin = require('bitcoinjs-lib');
+const ecc = require('../types/ecc');
+const bip341 = require('bitcoinjs-lib/src/payments/bip341');
+const { payments, networks, script, Psbt } = bitcoin;
+const { evaluateTierWhen } = require('./contractTierWhen');
+
+bitcoin.initEccLib(ecc);
+
+/** BIP341-style NUMS x-only internal key (script-path spend only). */
+const TAPROOT_INTERNAL_NUMS = Buffer.from(
+ '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0',
+ 'hex'
+);
+
+const DEFAULT_CSV_BLOCKS = 144;
+
+function networkForFabricName (name = '') {
+ const n = String(name || '').toLowerCase();
+ // Do not alias bare "test" — it is ambiguous (testnet tb1 vs regtest bcrt1).
+ if (n === 'regtest') return networks.regtest;
+ if (n === 'testnet' || n === 'signet') return networks.testnet;
+ return networks.bitcoin;
+}
+
+function parseCompressedPubkeysSorted (hexList) {
+ const out = [];
+ for (const h of hexList || []) {
+ const s = String(h || '').trim().toLowerCase();
+ if (!/^(02|03)[0-9a-f]{64}$/i.test(s)) {
+ throw new Error(`Invalid pubkey (expect 33-byte compressed hex): ${String(s).slice(0, 18)}…`);
+ }
+ out.push(Buffer.from(s, 'hex'));
+ }
+ out.sort((a, b) => a.compare(b));
+ const uniq = [];
+ for (const b of out) {
+ if (!uniq.length || uniq[uniq.length - 1].compare(b) !== 0) uniq.push(b);
+ }
+ return uniq;
+}
+
+function toXOnly (pubkey33) {
+ if (!Buffer.isBuffer(pubkey33) || pubkey33.length !== 33) return null;
+ if (pubkey33[0] !== 0x02 && pubkey33[0] !== 0x03) return null;
+ return pubkey33.subarray(1, 33);
+}
+
+function buildKOfNTapscript (sortedPubkeys33, threshold) {
+ const keys = sortedPubkeys33.map(toXOnly).filter(Boolean);
+ if (keys.length !== sortedPubkeys33.length) throw new Error('Internal pubkey parse error.');
+ const k = Number(threshold);
+ if (!Number.isInteger(k) || k < 1) {
+ throw new Error('threshold must be a positive integer');
+ }
+ if (k > keys.length) {
+ throw new Error(`threshold ${k} exceeds unique key count ${keys.length}`);
+ }
+ if (keys.length === 1) {
+ return script.compile([keys[0], script.OPS.OP_CHECKSIG]);
+ }
+ const chunks = [keys[0], script.OPS.OP_CHECKSIG];
+ for (let i = 1; i < keys.length; i++) {
+ chunks.push(keys[i], script.OPS.OP_CHECKSIGADD);
+ }
+ chunks.push(script.number.encode(k), script.OPS.OP_NUMEQUAL);
+ return script.compile(chunks);
+}
+
+/**
+ * Normalize a lock descriptor `{ type: 'csv'|'cltv', blocks?|height?|unix? }`.
+ * @returns {{ type: string, value: number }|null}
+ */
+function normalizeLock (raw) {
+ if (raw == null) return null;
+ if (typeof raw !== 'object') return null;
+ const type = String(raw.type || '').toLowerCase();
+ if (type === 'csv') {
+ const blocks = Math.floor(Number(raw.blocks) || 0);
+ // BIP68 relative-locktime low 16 bits (block-based CSV).
+ if (!Number.isFinite(blocks) || blocks < 1 || blocks > 65535) return null;
+ return { type: 'csv', value: blocks, blocks };
+ }
+ if (type === 'cltv') {
+ const value = Math.max(0, Math.floor(Number(raw.height != null ? raw.height : raw.unix) || 0));
+ if (!value) return null;
+ return { type: 'cltv', value, height: raw.height != null ? value : undefined, unix: raw.unix != null ? value : undefined };
+ }
+ return null;
+}
+
+function lockValue (lock) {
+ if (!lock) return 0;
+ return Number(lock.value || lock.blocks || lock.height || lock.unix) || 0;
+}
+
+/**
+ * Whether a relative/absolute lock is mature given chain tip context.
+ * CSV uses `utxoAgeBlocks` (confirmations / age of the UTXO).
+ * CLTV uses tip height or median time.
+ */
+function timelockMature (lock, ctx = {}) {
+ const n = normalizeLock(lock);
+ if (!n) return true; // null after => immediate
+ if (n.type === 'csv') {
+ const age = Number(ctx.utxoAgeBlocks);
+ if (!Number.isFinite(age)) return false;
+ return age >= n.value;
+ }
+ if (n.type === 'cltv') {
+ if (n.height != null || (lock && lock.height != null)) {
+ const tip = Number(ctx.tipHeight);
+ return Number.isFinite(tip) && tip >= n.value;
+ }
+ const mt = Number(ctx.medianTime);
+ return Number.isFinite(mt) && mt >= n.value;
+ }
+ return false;
+}
+
+function resolveKeyList (keysRef, keySets, publisher) {
+ if (keysRef === 'publisher') {
+ if (!publisher) throw new Error('publisher pubkey required for keys: publisher');
+ return [String(publisher).trim().toLowerCase()];
+ }
+ if (typeof keysRef === 'string') {
+ const set = keySets && keySets[keysRef];
+ if (!Array.isArray(set) || !set.length) {
+ throw new Error(`Unknown or empty keySet: ${keysRef}`);
+ }
+ return set.map((k) => String(k).trim().toLowerCase());
+ }
+ if (Array.isArray(keysRef)) {
+ return keysRef.map((k) => String(k).trim().toLowerCase());
+ }
+ throw new Error('tier.keys must be a keySet name, "publisher", or pubkey[]');
+}
+
+/**
+ * Synthesize a 2-tier ladder from legacy vault fields.
+ */
+function synthesizeDefaultLadder (opts = {}) {
+ const validators = Array.isArray(opts.validators)
+ ? opts.validators
+ : (Array.isArray(opts.validatorPubkeysHex) ? opts.validatorPubkeysHex : []);
+ const threshold = Math.max(1, Number(opts.threshold) || 1);
+ const publisher = opts.publisher
+ ? String(opts.publisher).trim().toLowerCase()
+ : (validators[0] ? String(validators[0]).trim().toLowerCase() : null);
+ const csvBlocks = Math.max(1, Number(opts.csvBlocks) || DEFAULT_CSV_BLOCKS);
+ const network = opts.network || opts.networkName || 'regtest';
+ if (!validators.length) throw new Error('synthesizeDefaultLadder requires validators');
+ if (!publisher) throw new Error('synthesizeDefaultLadder requires publisher');
+
+ return normalizeContractSpendPolicy({
+ version: 1,
+ network,
+ publisher,
+ keySets: {
+ full: validators.map((v) => String(v).trim().toLowerCase())
+ },
+ decay: { mode: 'policy' },
+ tiers: [
+ {
+ id: 't0-federation',
+ threshold,
+ keys: 'full',
+ after: null,
+ until: null
+ },
+ {
+ id: 't1-publisher',
+ threshold: 1,
+ keys: 'publisher',
+ after: { type: 'csv', blocks: csvBlocks },
+ until: null
+ }
+ ]
+ });
+}
+
+/**
+ * @param {object} raw
+ * @returns {object} normalized policy
+ */
+function normalizeContractSpendPolicy (raw = {}) {
+ if (!raw || typeof raw !== 'object') throw new Error('spend policy required');
+
+ if (!Array.isArray(raw.tiers) || !raw.tiers.length) {
+ if (raw.validators || raw.validatorPubkeysHex) {
+ return synthesizeDefaultLadder(raw);
+ }
+ throw new Error('spend policy requires tiers[] or validators');
+ }
+
+ const publisher = raw.publisher
+ ? String(raw.publisher).trim().toLowerCase()
+ : null;
+ const keySets = {};
+ if (raw.keySets && typeof raw.keySets === 'object') {
+ for (const [name, list] of Object.entries(raw.keySets)) {
+ keySets[name] = parseCompressedPubkeysSorted(list).map((b) => b.toString('hex'));
+ }
+ }
+
+ const tiers = [];
+ let prevAfterLock = null;
+ for (let i = 0; i < raw.tiers.length; i++) {
+ const t = raw.tiers[i] || {};
+ const keysHex = resolveKeyList(t.keys, keySets, publisher);
+ const pks = parseCompressedPubkeysSorted(keysHex);
+ const thr = Number(t.threshold);
+ if (!Number.isInteger(thr) || thr < 1) {
+ throw new Error(`tier[${i}] threshold must be a positive integer`);
+ }
+ if (thr > pks.length) {
+ throw new Error(`tier[${i}] threshold ${thr} exceeds unique key count ${pks.length}`);
+ }
+ const after = normalizeLock(t.after);
+ const until = normalizeLock(t.until);
+ const afterVal = lockValue(after);
+ if (after && prevAfterLock) {
+ if (after.type !== prevAfterLock.type) {
+ throw new Error(`tier[${i}] after lock type must match previous tier (${prevAfterLock.type})`);
+ }
+ if (afterVal < lockValue(prevAfterLock)) {
+ throw new Error(`tier[${i}] after must be >= previous tier after`);
+ }
+ } else if (!after && prevAfterLock) {
+ throw new Error(`tier[${i}] after must be >= previous tier after`);
+ }
+ if (until && after) {
+ if (until.type !== after.type) {
+ throw new Error(`tier[${i}] until lock type must match after (${after.type})`);
+ }
+ if (lockValue(until) <= afterVal) {
+ throw new Error(`tier[${i}] until must be strictly after after`);
+ }
+ }
+ if (after) prevAfterLock = after;
+ const id = t.id != null && String(t.id).trim()
+ ? String(t.id).trim().slice(0, 128)
+ : `t${i}`;
+ tiers.push({
+ id,
+ index: i,
+ threshold: thr,
+ keys: pks.map((b) => b.toString('hex')),
+ after,
+ until,
+ when: t.when && typeof t.when === 'object' ? t.when : null
+ });
+ }
+
+ const hasUntil = tiers.some((t) => t.until);
+ let decayMode = raw.decay && raw.decay.mode
+ ? String(raw.decay.mode).toLowerCase()
+ : (hasUntil ? 'both' : 'policy');
+ if (!['policy', 'migrate', 'both'].includes(decayMode)) decayMode = 'policy';
+
+ let migrateKeys = null;
+ let migrateThreshold = 1;
+ if (decayMode === 'migrate' || decayMode === 'both') {
+ const mk = (raw.decay && raw.decay.migrateKeys != null)
+ ? raw.decay.migrateKeys
+ : (keySets.mid ? 'mid' : (tiers[Math.min(1, tiers.length - 1)] ? tiers[Math.min(1, tiers.length - 1)].keys : tiers[0].keys));
+ const resolved = Array.isArray(mk) || typeof mk === 'string'
+ ? resolveKeyList(mk, keySets, publisher)
+ : mk;
+ const mpks = parseCompressedPubkeysSorted(resolved);
+ migrateKeys = mpks.map((b) => b.toString('hex'));
+ const mtRaw = raw.decay && raw.decay.migrateThreshold != null
+ ? Number(raw.decay.migrateThreshold)
+ : Math.ceil(mpks.length / 2);
+ if (!Number.isInteger(mtRaw) || mtRaw < 1) {
+ throw new Error('decay.migrateThreshold must be a positive integer');
+ }
+ if (mtRaw > mpks.length) {
+ throw new Error(`decay.migrateThreshold ${mtRaw} exceeds unique key count ${mpks.length}`);
+ }
+ migrateThreshold = mtRaw;
+ }
+
+ return {
+ version: 1,
+ network: String(raw.network || raw.networkName || 'regtest'),
+ publisher,
+ keySets,
+ decay: {
+ mode: decayMode,
+ migrateKeys,
+ migrateThreshold
+ },
+ tiers
+ };
+}
+
+/**
+ * Drop tiers whose until <= trigger lock; clear migrate leaf schedule for child.
+ */
+function policyAfterDecay (policy, atLock) {
+ const p = normalizeContractSpendPolicy(policy);
+ const trigger = normalizeLock(atLock) || atLock;
+ const triggerVal = lockValue(trigger);
+ const remaining = p.tiers.filter((t) => {
+ if (!t.until) return true;
+ return lockValue(t.until) > triggerVal;
+ }).map((t, i) => ({
+ id: t.id,
+ threshold: t.threshold,
+ keys: t.keys,
+ after: t.after,
+ until: t.until,
+ when: t.when,
+ index: i
+ }));
+ if (!remaining.length) {
+ throw new Error('policyAfterDecay would leave no spend tiers');
+ }
+ // Child policy: no migrate until it has its own until windows
+ const childHasUntil = remaining.some((t) => t.until);
+ return normalizeContractSpendPolicy({
+ version: 1,
+ network: p.network,
+ publisher: p.publisher,
+ keySets: Object.fromEntries(
+ Object.entries(p.keySets || {}).map(([k, v]) => [k, v])
+ ),
+ // Inline keys on tiers already resolved
+ decay: childHasUntil
+ ? {
+ mode: p.decay.mode === 'policy' ? 'policy' : p.decay.mode,
+ migrateKeys: p.decay.migrateKeys,
+ migrateThreshold: p.decay.migrateThreshold
+ }
+ : { mode: 'policy' },
+ tiers: remaining.map((t) => ({
+ id: t.id,
+ threshold: t.threshold,
+ keys: t.keys,
+ after: t.after,
+ until: t.until,
+ when: t.when
+ }))
+ });
+}
+
+function prependLock (lock, innerScript) {
+ const n = normalizeLock(lock);
+ if (!n) return innerScript;
+ if (n.type === 'csv') {
+ return script.compile([
+ script.number.encode(n.value),
+ script.OPS.OP_CHECKSEQUENCEVERIFY,
+ script.OPS.OP_DROP,
+ ...script.decompile(innerScript)
+ ]);
+ }
+ if (n.type === 'cltv') {
+ return script.compile([
+ script.number.encode(n.value),
+ script.OPS.OP_CHECKLOCKTIMEVERIFY,
+ script.OPS.OP_DROP,
+ ...script.decompile(innerScript)
+ ]);
+ }
+ return innerScript;
+}
+
+/**
+ * Build leaf descriptors for a normalized policy.
+ * @returns {{ kind: string, id: string, script: Buffer, after: object|null, tierIndex?: number, decayAt?: object }[]}
+ */
+function compileLeaves (policy) {
+ const p = normalizeContractSpendPolicy(policy);
+ const leaves = [];
+ for (const t of p.tiers) {
+ const pks = parseCompressedPubkeysSorted(t.keys);
+ let body = buildKOfNTapscript(pks, t.threshold);
+ body = prependLock(t.after, body);
+ leaves.push({
+ kind: 'spend',
+ id: t.id,
+ tierIndex: t.index,
+ script: Buffer.from(body),
+ after: t.after,
+ until: t.until,
+ threshold: t.threshold,
+ keys: t.keys
+ });
+ }
+
+ if (p.decay.mode === 'migrate' || p.decay.mode === 'both') {
+ const boundaries = [];
+ for (const t of p.tiers) {
+ if (!t.until) continue;
+ const u = t.until;
+ const key = `${u.type}:${lockValue(u)}`;
+ if (!boundaries.find((b) => b.key === key)) {
+ boundaries.push({ key, until: u });
+ }
+ }
+ boundaries.sort((a, b) => lockValue(a.until) - lockValue(b.until));
+ const mpks = parseCompressedPubkeysSorted(p.decay.migrateKeys || []);
+ if (mpks.length && boundaries.length) {
+ for (const b of boundaries) {
+ let body = buildKOfNTapscript(mpks, p.decay.migrateThreshold);
+ body = prependLock(b.until, body);
+ leaves.push({
+ kind: 'migrate',
+ id: `decay@${lockValue(b.until)}`,
+ script: Buffer.from(body),
+ after: b.until,
+ decayAt: b.until,
+ threshold: p.decay.migrateThreshold,
+ keys: p.decay.migrateKeys
+ });
+ }
+ }
+ }
+ return leaves;
+}
+
+/**
+ * bitcoinjs-lib Taptree is a binary tree of leaves (not a flat list of >2).
+ * @param {{ script: Buffer }[]} leaves
+ * @returns {object}
+ */
+function scriptTreeFromLeaves (leaves) {
+ if (!leaves.length) throw new Error('no leaves');
+ if (leaves.length === 1) return { output: leaves[0].script };
+ if (leaves.length === 2) {
+ return [{ output: leaves[0].script }, { output: leaves[1].script }];
+ }
+ // Balanced binary tree over leaf order (ladder order preserved left-to-right).
+ const mid = Math.ceil(leaves.length / 2);
+ return [
+ scriptTreeFromLeaves(leaves.slice(0, mid)),
+ scriptTreeFromLeaves(leaves.slice(mid))
+ ];
+}
+
+function buildControlBlockForLeaf (leaves, leafScript) {
+ const scriptTree = scriptTreeFromLeaves(leaves);
+ const hashTree = bip341.toHashTree(scriptTree);
+ const leafVersion = bip341.LEAF_VERSION_TAPSCRIPT;
+ const leafHash = bip341.tapleafHash({ output: leafScript, version: leafVersion });
+ const path = bip341.findScriptPath(hashTree, leafHash);
+ if (path === undefined) throw new Error('Leaf script not in tap tree.');
+ const outputKey = bip341.tweakKey(TAPROOT_INTERNAL_NUMS, hashTree.hash);
+ if (!outputKey) throw new Error('Taproot tweak failed.');
+ return Buffer.concat([
+ Buffer.from([leafVersion | outputKey.parity]),
+ TAPROOT_INTERNAL_NUMS,
+ ...path
+ ]);
+}
+
+/**
+ * @param {object} policy
+ * @returns {object}
+ */
+function buildContractTaproot (policy) {
+ const p = normalizeContractSpendPolicy(policy);
+ const leaves = compileLeaves(p);
+ const network = networkForFabricName(p.network);
+ const scriptTree = scriptTreeFromLeaves(leaves);
+ const pay = payments.p2tr({
+ internalPubkey: TAPROOT_INTERNAL_NUMS,
+ scriptTree,
+ network
+ });
+ if (!pay.address || !pay.output) throw new Error('p2tr did not produce address.');
+ return {
+ address: pay.address,
+ output: Buffer.isBuffer(pay.output) ? pay.output : Buffer.from(pay.output),
+ network: p.network,
+ policy: p,
+ leaves: leaves.map((l) => ({
+ kind: l.kind,
+ id: l.id,
+ tierIndex: l.tierIndex,
+ tapscriptHex: l.script.toString('hex'),
+ after: l.after,
+ until: l.until,
+ decayAt: l.decayAt,
+ threshold: l.threshold,
+ keys: l.keys
+ })),
+ internalPubkeyHex: TAPROOT_INTERNAL_NUMS.toString('hex')
+ };
+}
+
+function toAddress (policy) {
+ return buildContractTaproot(policy).address;
+}
+
+/**
+ * @param {object} policy
+ * @param {{ tipHeight?: number, medianTime?: number, utxoAgeBlocks?: number, tipClock?: number, contractState?: object }} ctx
+ */
+function selectActiveTiers (policy, ctx = {}) {
+ const p = normalizeContractSpendPolicy(policy);
+ const enforceUntil = p.decay.mode === 'policy' || p.decay.mode === 'both';
+ return p.tiers.filter((t) => {
+ if (!timelockMature(t.after, ctx)) return false;
+ if (enforceUntil && t.until && timelockMature(t.until, ctx)) return false;
+ if (!evaluateTierWhen(t.when, ctx)) return false;
+ return true;
+ });
+}
+
+function selectMigrateTarget (policy, ctx = {}) {
+ const p = normalizeContractSpendPolicy(policy);
+ if (p.decay.mode !== 'migrate' && p.decay.mode !== 'both') return null;
+ const boundaries = [];
+ for (const t of p.tiers) {
+ if (!t.until) continue;
+ if (!timelockMature(t.until, ctx)) continue;
+ boundaries.push(t.until);
+ }
+ if (!boundaries.length) return null;
+ // Prefer the largest mature until (most tiers expired)
+ boundaries.sort((a, b) => lockValue(b) - lockValue(a));
+ const at = boundaries[0];
+ const child = policyAfterDecay(p, at);
+ return {
+ decayAt: at,
+ childPolicy: child,
+ childAddress: toAddress(child)
+ };
+}
+
+function findP2trVoutForAddress (tx, paymentAddress, network) {
+ const addr = String(paymentAddress || '').trim();
+ if (!addr) return -1;
+ const want = Buffer.from(bitcoin.address.toOutputScript(addr, network));
+ for (let i = 0; i < tx.outs.length; i++) {
+ const sc = Buffer.from(tx.outs[i].script);
+ if (sc.equals(want)) return i;
+ }
+ return -1;
+}
+
+function prepareLeafPsbt (opts = {}) {
+ const {
+ networkName,
+ fundedTxHex,
+ vaultAddress,
+ leafScript,
+ leaves,
+ destinationAddress,
+ feeSats
+ } = opts;
+ const network = networkForFabricName(networkName);
+ const tx = bitcoin.Transaction.fromHex(String(fundedTxHex || '').trim());
+ const vout = findP2trVoutForAddress(tx, vaultAddress, network);
+ if (vout < 0) throw new Error('Funding tx has no P2TR output matching vault address.');
+ const out = tx.outs[vout];
+ const inputSats = typeof out.value === 'bigint' ? Number(out.value) : Number(out.value);
+ if (!Number.isFinite(inputSats) || inputSats <= 0) throw new Error('Invalid vault output value.');
+ const fee = Math.max(1, Math.round(Number(feeSats || 1000)));
+ const destSats = inputSats - fee;
+ if (destSats < 546) throw new Error('Amount after fee is below dust; lower fee or use a larger UTXO.');
+
+ let ms;
+ if (Buffer.isBuffer(leafScript)) {
+ ms = leafScript;
+ } else if (leafScript instanceof Uint8Array) {
+ ms = Buffer.from(leafScript);
+ } else {
+ ms = Buffer.from(String(leafScript || '').replace(/^0x/i, ''), 'hex');
+ }
+ if (!ms.length) throw new Error('leafScript is required.');
+
+ const dest = String(destinationAddress || '').trim();
+ if (!dest) throw new Error('destinationAddress is required.');
+
+ const afterLock = normalizeLock(opts.after);
+ let sequence = opts.sequence != null ? Number(opts.sequence) : undefined;
+ let locktime;
+ if (afterLock && afterLock.type === 'csv') {
+ if (sequence == null) sequence = afterLock.value;
+ } else if (afterLock && afterLock.type === 'cltv') {
+ locktime = afterLock.value;
+ // CLTV requires a non-final nSequence on the input.
+ if (sequence == null || sequence === 0xffffffff) sequence = 0xfffffffe;
+ }
+
+ const controlBlock = buildControlBlockForLeaf(leaves, ms);
+ const outScript = Buffer.isBuffer(out.script) ? out.script : Buffer.from(out.script);
+ const input = {
+ hash: tx.getId(),
+ index: vout,
+ witnessUtxo: {
+ script: Uint8Array.from(outScript),
+ value: BigInt(inputSats)
+ },
+ tapInternalKey: Uint8Array.from(TAPROOT_INTERNAL_NUMS),
+ tapLeafScript: [{
+ leafVersion: bip341.LEAF_VERSION_TAPSCRIPT,
+ script: Uint8Array.from(ms),
+ controlBlock: Uint8Array.from(controlBlock)
+ }]
+ };
+ if (sequence != null) input.sequence = Number(sequence);
+
+ const psbt = new Psbt({ network });
+ if (locktime != null) {
+ if (typeof psbt.setLocktime === 'function') psbt.setLocktime(Number(locktime));
+ else psbt.locktime = Number(locktime);
+ }
+ psbt.addInput(input);
+ psbt.addOutput({
+ address: dest,
+ value: BigInt(destSats)
+ });
+
+ return {
+ psbtBase64: psbt.toBase64(),
+ vaultAddress: String(vaultAddress || '').trim(),
+ vout,
+ inputSats,
+ destSats,
+ feeSats: fee,
+ tapscriptHex: ms.toString('hex'),
+ controlBlockHex: controlBlock.toString('hex'),
+ sequence: sequence != null ? Number(sequence) : undefined,
+ locktime: locktime != null ? Number(locktime) : undefined
+ };
+}
+
+/**
+ * Prepare withdrawal via a spend tier leaf.
+ */
+function prepareTierWithdrawalPsbt (opts = {}) {
+ const built = buildContractTaproot(opts.policy);
+ const ctx = opts.ctx || {};
+ const active = selectActiveTiers(built.policy, ctx);
+ let tier = null;
+ if (opts.tierId) {
+ tier = active.find((t) => t.id === opts.tierId);
+ if (!tier) {
+ const all = built.policy.tiers.find((t) => t.id === opts.tierId);
+ if (all && all.until && timelockMature(all.until, ctx) &&
+ (built.policy.decay.mode === 'policy' || built.policy.decay.mode === 'both')) {
+ throw new Error(`tier ${opts.tierId} expired (until mature); use migrate`);
+ }
+ throw new Error(`tier ${opts.tierId} is not active`);
+ }
+ } else {
+ tier = active[0];
+ }
+ if (!tier) throw new Error('No active spend tier');
+
+ const leaves = compileLeaves(built.policy);
+ const leaf = leaves.find((l) => l.kind === 'spend' && l.id === tier.id);
+ if (!leaf) throw new Error('spend leaf missing');
+
+ const result = prepareLeafPsbt({
+ networkName: built.network,
+ fundedTxHex: opts.fundedTxHex,
+ vaultAddress: opts.vaultAddress || built.address,
+ leafScript: leaf.script,
+ leaves,
+ destinationAddress: opts.destinationAddress,
+ feeSats: opts.feeSats,
+ after: tier.after
+ });
+ return {
+ ...result,
+ tierId: tier.id,
+ action: 'spend',
+ address: built.address,
+ signingNotes: 'Co-sign tapscript leaf for active non-expired tier. Witness: sigs then script then control block.'
+ };
+}
+
+/**
+ * Prepare decay migration PSBT (output must be child policy address).
+ */
+function prepareDecayMigrationPsbt (opts = {}) {
+ const built = buildContractTaproot(opts.policy);
+ const ctx = opts.ctx || {};
+ const target = selectMigrateTarget(built.policy, ctx);
+ if (!target) throw new Error('No mature decay migration available');
+
+ const leaves = compileLeaves(built.policy);
+ const leaf = leaves.find((l) => l.kind === 'migrate' && lockValue(l.decayAt) === lockValue(target.decayAt));
+ if (!leaf) throw new Error('migrate leaf missing for decay boundary');
+
+ const childAddr = target.childAddress;
+ if (opts.destinationAddress && String(opts.destinationAddress).trim() !== childAddr) {
+ throw new Error(`migration destination must be child address ${childAddr}`);
+ }
+
+ const result = prepareLeafPsbt({
+ networkName: built.network,
+ fundedTxHex: opts.fundedTxHex,
+ vaultAddress: opts.vaultAddress || built.address,
+ leafScript: leaf.script,
+ leaves,
+ destinationAddress: childAddr,
+ feeSats: opts.feeSats,
+ after: target.decayAt
+ });
+ return {
+ ...result,
+ action: 'migrate',
+ decayAt: target.decayAt,
+ childAddress: childAddr,
+ childPolicy: target.childPolicy,
+ address: built.address,
+ signingNotes: 'Decay migration: output is constrained to child toAddress(policyAfterDecay).'
+ };
+}
+
+/**
+ * Legacy Hub vault API: single k-of-n leaf (address-stable with prior Hub vault).
+ * Pass `{ failover: true }` or `publisher` + positive `csvBlocks` to use the full default ladder.
+ */
+function buildFederationVaultFromPolicy (opts = {}) {
+ const {
+ validatorPubkeysHex,
+ threshold,
+ networkName,
+ publisher,
+ csvBlocks,
+ failover
+ } = opts;
+ const pks = parseCompressedPubkeysSorted(validatorPubkeysHex);
+ if (!pks.length) throw new Error('At least one validator pubkey is required.');
+ const thr = Number(threshold);
+ if (!Number.isInteger(thr) || thr < 1) {
+ throw new Error('threshold must be a positive integer');
+ }
+ if (thr > pks.length) {
+ throw new Error(`threshold ${thr} exceeds unique key count ${pks.length}`);
+ }
+
+ if (failover || (publisher && csvBlocks != null && Number(csvBlocks) > 0)) {
+ const built = buildContractTaproot(synthesizeDefaultLadder({
+ validators: validatorPubkeysHex,
+ threshold: thr,
+ network: networkName,
+ publisher: publisher || pks[0].toString('hex'),
+ csvBlocks: csvBlocks != null ? csvBlocks : DEFAULT_CSV_BLOCKS
+ }));
+ const t0 = built.leaves.find((l) => l.kind === 'spend');
+ return {
+ address: built.address,
+ output: built.output,
+ multisigScript: Buffer.from(t0.tapscriptHex, 'hex'),
+ network: networkName,
+ threshold: thr,
+ validatorsSortedHex: pks.map((b) => b.toString('hex')),
+ internalPubkeyHex: TAPROOT_INTERNAL_NUMS.toString('hex'),
+ depositMaturityBlocks: DEFAULT_CSV_BLOCKS,
+ policy: built.policy,
+ leaves: built.leaves
+ };
+ }
+
+ const multisigScript = buildKOfNTapscript(pks, thr);
+ const network = networkForFabricName(networkName);
+ const pay = payments.p2tr({
+ internalPubkey: TAPROOT_INTERNAL_NUMS,
+ scriptTree: { output: multisigScript },
+ network
+ });
+ if (!pay.address || !pay.output) throw new Error('p2tr did not produce vault address.');
+ const msBuf = Buffer.isBuffer(multisigScript) ? multisigScript : Buffer.from(multisigScript);
+ const outBuf = Buffer.isBuffer(pay.output) ? pay.output : Buffer.from(pay.output);
+ return {
+ address: pay.address,
+ output: outBuf,
+ multisigScript: msBuf,
+ network: networkName,
+ threshold: thr,
+ validatorsSortedHex: pks.map((b) => b.toString('hex')),
+ internalPubkeyHex: TAPROOT_INTERNAL_NUMS.toString('hex'),
+ depositMaturityBlocks: DEFAULT_CSV_BLOCKS,
+ policy: null,
+ leaves: [{ kind: 'spend', id: 't0-federation', tapscriptHex: msBuf.toString('hex') }]
+ };
+}
+
+function buildVaultControlBlock (multisigScript) {
+ const ms = Buffer.isBuffer(multisigScript)
+ ? multisigScript
+ : Buffer.from(String(multisigScript || ''), 'hex');
+ return buildControlBlockForLeaf([{ script: ms }], ms);
+}
+
+function prepareVaultWithdrawalPsbt (opts = {}) {
+ // Legacy: single leaf tree
+ if (opts.multisigScript && !opts.policy) {
+ const ms = Buffer.isBuffer(opts.multisigScript)
+ ? opts.multisigScript
+ : Buffer.from(String(opts.multisigScript || '').replace(/^0x/i, ''), 'hex');
+ return {
+ ...prepareLeafPsbt({
+ networkName: opts.networkName,
+ fundedTxHex: opts.fundedTxHex,
+ vaultAddress: opts.vaultAddress,
+ leafScript: ms,
+ leaves: [{ script: ms }],
+ destinationAddress: opts.destinationAddress,
+ feeSats: opts.feeSats
+ }),
+ signingNotes: 'Validators partially sign the same PSBT input (tapscript leaf).'
+ };
+ }
+ return prepareTierWithdrawalPsbt(opts);
+}
+
+module.exports = {
+ TAPROOT_INTERNAL_NUMS,
+ DEFAULT_CSV_BLOCKS,
+ networkForFabricName,
+ parseCompressedPubkeysSorted,
+ buildKOfNTapscript,
+ normalizeLock,
+ timelockMature,
+ lockValue,
+ synthesizeDefaultLadder,
+ normalizeContractSpendPolicy,
+ policyAfterDecay,
+ compileLeaves,
+ buildContractTaproot,
+ toAddress,
+ selectActiveTiers,
+ selectMigrateTarget,
+ prepareTierWithdrawalPsbt,
+ prepareDecayMigrationPsbt,
+ buildFederationVaultFromPolicy,
+ prepareVaultWithdrawalPsbt,
+ buildVaultControlBlock,
+ buildControlBlockForLeaf
+};
diff --git a/functions/contractTierWhen.js b/functions/contractTierWhen.js
new file mode 100644
index 000000000..dd14b5965
--- /dev/null
+++ b/functions/contractTierWhen.js
@@ -0,0 +1,64 @@
+'use strict';
+
+/**
+ * Off-chain `when` predicates for contract spend tiers.
+ * Unknown ops fail closed (PSBT prep must not proceed).
+ */
+
+/**
+ * @param {object} state Contract / tip state bag
+ * @param {string} path JSON-pointer-ish `/a/b`
+ * @returns {*}
+ */
+function getPath (state, path) {
+ if (!path || typeof path !== 'string') return undefined;
+ const parts = path.replace(/^\//, '').split('/').filter(Boolean);
+ let cur = state;
+ for (const p of parts) {
+ if (cur == null || typeof cur !== 'object') return undefined;
+ if (!Object.prototype.hasOwnProperty.call(cur, p)) return undefined;
+ cur = cur[p];
+ }
+ return cur;
+}
+
+const OPS = Object.freeze({
+ tipClockGte (pred, ctx) {
+ const tip = Number(ctx && ctx.tipClock);
+ const need = Number(pred && pred.value);
+ return Number.isFinite(tip) && Number.isFinite(need) && tip >= need;
+ },
+ statePathEq (pred, ctx) {
+ if (!pred || pred.path == null || pred.path === '' || !Object.prototype.hasOwnProperty.call(pred, 'value')) {
+ return false;
+ }
+ const actual = getPath(ctx && ctx.contractState, pred.path);
+ return actual === pred.value;
+ }
+});
+
+/**
+ * @param {object|null|undefined} when
+ * @param {{ tipClock?: number, contractState?: object }} ctx
+ * @returns {boolean}
+ */
+function evaluateTierWhen (when, ctx = {}) {
+ if (when == null) return true;
+ if (typeof when !== 'object') return false;
+ const allOf = Array.isArray(when.allOf) ? when.allOf : null;
+ if (!allOf || !allOf.length) return false;
+ for (const pred of allOf) {
+ if (!pred || typeof pred !== 'object') return false;
+ const op = String(pred.op || '');
+ const fn = OPS[op];
+ if (typeof fn !== 'function') return false;
+ if (!fn(pred, ctx)) return false;
+ }
+ return true;
+}
+
+module.exports = {
+ evaluateTierWhen,
+ getPath,
+ OPS
+};
diff --git a/functions/documentRegistrySidechain.js b/functions/documentRegistrySidechain.js
index 90c0f0cdc..4667f5ee2 100644
--- a/functions/documentRegistrySidechain.js
+++ b/functions/documentRegistrySidechain.js
@@ -24,15 +24,18 @@ const {
const REGISTRY_PATH = '/registry';
const SIDECHAIN_STATE_PATCH_TYPE = sidechainState.SIDECHAIN_STATE_PATCH_TYPE;
-/** V1 field layout for SIDECHAIN_STATE_PATCH (catalog-oriented). */
+/** V1 field layout for SIDECHAIN_STATE_PATCH (catalog + optional RFC6902 fidelity). */
const SCHEMA_SIDECHAIN_STATE_PATCH = Object.freeze([
{ name: 'basisClock', type: 'u32' },
{ name: 'basisDigest', type: 'bytes32' },
- { name: 'catalogCanonical', type: 'string' }
+ { name: 'catalogCanonical', type: 'string' },
+ /** Empty string when absent; UTF-8 JSON array of RFC6902 ops (HTTP edge / multi-op). */
+ { name: 'patchesCanonical', type: 'string', optional: true }
]);
function _installSchema () {
- if (getBodySchema(SIDECHAIN_STATE_PATCH_TYPE)) return;
+ const existing = getBodySchema(SIDECHAIN_STATE_PATCH_TYPE);
+ if (existing && existing.length === SCHEMA_SIDECHAIN_STATE_PATCH.length) return;
registerBodySchema(SIDECHAIN_STATE_PATCH_TYPE, SCHEMA_SIDECHAIN_STATE_PATCH);
registerBodySchema('SidechainStatePatch', SCHEMA_SIDECHAIN_STATE_PATCH);
if (SIDECHAIN_STATE_PATCH_OPCODE != null) {
@@ -87,7 +90,8 @@ function encodeRegistryUpdateFields (state, catalog) {
return {
basisClock,
basisDigest: Buffer.from(digestHex, 'hex'),
- catalogCanonical: fabricCanonicalJson(catalog)
+ catalogCanonical: fabricCanonicalJson(catalog),
+ patchesCanonical: ''
};
}
@@ -120,15 +124,6 @@ function applyRegistryUpdateFields (state, fields, policy = null) {
if (!fields || typeof fields.catalogCanonical !== 'string' || !fields.catalogCanonical) {
return { ok: false, error: 'catalogCanonical required' };
}
- let catalog;
- try {
- catalog = JSON.parse(fields.catalogCanonical);
- } catch (err) {
- return { ok: false, error: 'catalogCanonical must be JSON' };
- }
- if (!catalog || typeof catalog !== 'object') {
- return { ok: false, error: 'invalid catalog' };
- }
const current = state || sidechainState.createInitialState();
const basisClock = fields.basisClock != null ? Number(fields.basisClock) : Number(current.clock) || 0;
@@ -145,6 +140,38 @@ function applyRegistryUpdateFields (state, fields, policy = null) {
}
}
+ // Prefer explicit RFC6902 sequence when present (HTTP multi-op fidelity).
+ const patchesRaw = fields.patchesCanonical != null ? String(fields.patchesCanonical).trim() : '';
+ if (patchesRaw) {
+ let patches;
+ try {
+ patches = JSON.parse(patchesRaw);
+ } catch (err) {
+ return { ok: false, error: 'patchesCanonical must be JSON' };
+ }
+ if (!Array.isArray(patches) || !patches.length) {
+ return { ok: false, error: 'patchesCanonical must be a non-empty RFC6902 array' };
+ }
+ const applied = sidechainState.applyPatchesToState(current, patches, policy);
+ if (!applied.ok) return applied;
+ return {
+ ok: true,
+ state: applied.state,
+ stateDigest: applied.newDigest,
+ basisDigest: applied.basisDigest
+ };
+ }
+
+ let catalog;
+ try {
+ catalog = JSON.parse(fields.catalogCanonical);
+ } catch (err) {
+ return { ok: false, error: 'catalogCanonical must be JSON' };
+ }
+ if (!catalog || typeof catalog !== 'object') {
+ return { ok: false, error: 'invalid catalog' };
+ }
+
// Internal reducer: content replace at /registry (HTTP may present this as RFC6902).
const patches = [{ op: current.content && current.content.registry ? 'replace' : 'add', path: REGISTRY_PATH, value: catalog }];
const applied = sidechainState.applyPatchesToState(current, patches, policy);
diff --git a/package-lock.json b/package-lock.json
index bec0bb76c..f2e7bf3cb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3492,9 +3492,9 @@
}
},
"node_modules/readdirp": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
- "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
+ "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/package.json b/package.json
index 76d5d4a38..aeb6909d5 100644
--- a/package.json
+++ b/package.json
@@ -99,7 +99,7 @@
"playnet": "node scripts/playnet.js",
"report:coverage": "npm run make:coverage && c8 report --reporter=text-lcov > reports/coverage.lcov",
"report:credits": "node scripts/report-credits.js > reports/credits.json",
- "report:install": "rm -rf node_modules && echo \"\n\" > package-lock.json && echo \"$ npm i\" > reports/install.log && npm i >> reports/install.log 2>&1",
+ "report:install": "rm -rf node_modules && echo \"\n\" > package-lock.json && echo \"$ npm i --allow-git=all\" > reports/install.log && npm i --allow-git=all >> reports/install.log 2>&1",
"report:install-ci": "bash scripts/report-install-ci.sh",
"report:warnings": "node scripts/gen-quality-reports.js warnings",
"report:deprecations": "node scripts/gen-quality-reports.js deprecations",
diff --git a/reports/TODO.txt b/reports/TODO.txt
index 89ed2f661..53f6fadaa 100644
--- a/reports/TODO.txt
+++ b/reports/TODO.txt
@@ -26,7 +26,6 @@
./types/key.js: // TODO: determine if this makes sense / needs to be private
./types/key.js: // TODO: evaluate compression when treating seed phrase as ascii
./types/key.js: // TODO: consider using sha256(masterprivkey) or sha256(sha256(...))?
-./types/chain.js: // TODO: _sortFees
./types/remote.js: // TODO: use onion address for secure mode
./types/remote.js: // TODO: warn about unexpected values
./types/remote.js: // TODO: should settings override protocol inclusion?
@@ -99,7 +98,6 @@
./types/oracle.js: // TODO: pre-populate
./types/node.js:// TODO: re-evaluate, remove
./types/node.js: this.trust(this.program, 'PROGRAM'); // TODO: debug why 'ready' events come twice?
-./types/block.js: // TODO: implement validators
./types/router.js:// TODO: re-define this class for Fabric messages
./types/router.js: // TODO: remove this.fabric.plugins call
./types/wallet.js: // TODO: export this.logs
@@ -108,8 +106,6 @@
./types/wallet.js: // TODO: update channels
./types/wallet.js: // TODO: parse as {@link Message}
./types/wallet.js: // TODO: store in this.messages
-./types/wallet.js: // TODO: validate destination is this wallet
-./types/wallet.js: // TODO: process transaction
./types/wallet.js: // TODO: test these outputs
./types/wallet.js: // TODO: use coin selection
./types/wallet.js: // TODO: use the MTX to select outputs
@@ -147,8 +143,6 @@
./types/peer.js: // TODO: switch to child pubkey
./types/peer.js: // TODO: consider making this a FabricMessageID
./types/peer.js: // TODO: output stream
-./types/peer.js: // TODO: reject and punish mis-behaving peers
-./types/peer.js: // TODO: reject and punish mis-behaving peers
./types/peer.js: // TODO: reconcile APIs for these methods
./types/entity.js: // TODO: use getters/setters to restrict access to these elements
./types/entity.js: // TODO: write up longer-form explanation as to why we use an Array here
@@ -182,7 +176,6 @@
./contracts/exchange.js: // TODO: reconcile API wth @fabric/doorman as appears at: https://github.com/FabricLabs/doorman
./contracts/node.js: // TODO: configure with input?
./contracts/setup.js: // TODO: replicate this program in C / ASM
-./contracts/setup.js: // TODO: remove from log output...
./tests/fabric.interface.js: // TODO: remove this case / rework messages
./tests/fabric.tree.js: assert.strictEqual(tree.root.toString('hex'), ''); // TODO: wat?
./tests/fabric.tree.js: assert.strictEqual(tree.root.toString('hex'), '906b5aaf65ae98f8c98848de5e81ba865659f16fd53aefa4c78b34176f068079'); // TODO: wat?
diff --git a/reports/install.log b/reports/install.log
index 9e0f78727..580573324 100644
--- a/reports/install.log
+++ b/reports/install.log
@@ -1,4 +1,4 @@
-$ npm i
+$ npm i --allow-git=all
npm warn deprecated crypto-js@4.2.0: Active development of CryptoJS has been discontinued. This library is no longer maintained.
npm warn deprecated uuid@8.3.2: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
npm notice run @fabric/core@0.1.0-RC1 install
diff --git a/tests/applicationNamespaces.unit.js b/tests/applicationNamespaces.unit.js
index 087e54a21..e66037c9d 100644
--- a/tests/applicationNamespaces.unit.js
+++ b/tests/applicationNamespaces.unit.js
@@ -31,7 +31,15 @@ describe('applicationNamespaces', function () {
assert.ok(CONTRACT_BODY_TYPES.FederationContractInvite);
assert.ok(CONTRACT_BODY_TYPES.MissionBroadcast);
assert.ok(CONTRACT_BODY_TYPES.GameStateSnapshot);
+ assert.ok(CONTRACT_BODY_TYPES.GroupActivityTree);
+ assert.ok(CONTRACT_BODY_TYPES.GroupJournalRequest);
+ assert.ok(CONTRACT_BODY_TYPES.GroupJournalBatch);
+ assert.ok(CONTRACT_BODY_TYPES.GroupStateJournal);
+ assert.ok(CONTRACT_BODY_TYPES.ContractCapabilityGrant);
+ assert.ok(CONTRACT_BODY_TYPES.ContractWithdrawalRequest);
+ assert.ok(CONTRACT_BODY_TYPES.ContractWithdrawalWitness);
assert.strictEqual(isKnownContractBodyType('GroupShare'), true);
+ assert.strictEqual(isKnownContractBodyType('GroupJournalBatch'), true);
assert.strictEqual(isKnownContractBodyType('GameStateSnapshot'), true);
assert.strictEqual(isKnownContractBodyType('NotAType'), false);
});
diff --git a/tests/contractCapability.unit.js b/tests/contractCapability.unit.js
new file mode 100644
index 000000000..77bc466f7
--- /dev/null
+++ b/tests/contractCapability.unit.js
@@ -0,0 +1,101 @@
+'use strict';
+
+const assert = require('assert');
+const Key = require('../types/key');
+const {
+ OP_CONTRACT_READ,
+ OP_CONTRACT_SIGN,
+ issueContractCapability,
+ verifyContractCapability,
+ roleToCapability,
+ capabilityToRole
+} = require('../functions/contractCapability');
+
+describe('contractCapability', function () {
+ it('issues and verifies a read token bound to contractId', function () {
+ const issuer = new Key();
+ const subject = new Key().pubkey;
+ const contractId = 'ab'.repeat(32);
+ const tok = issueContractCapability({
+ issuerKey: issuer,
+ subject,
+ contractId,
+ capability: OP_CONTRACT_READ
+ });
+ const payload = verifyContractCapability(tok, {
+ contractId,
+ expectedCap: OP_CONTRACT_READ,
+ subject,
+ issuerKey: issuer
+ });
+ assert.ok(payload);
+ assert.strictEqual(payload.cap, OP_CONTRACT_READ);
+ assert.strictEqual(payload.ctx.contractId, contractId);
+ assert.strictEqual(payload.verified, true);
+ });
+
+ it('rejects wrong contractId and wrong subject', function () {
+ const issuer = new Key();
+ const subject = new Key().pubkey;
+ const tok = issueContractCapability({
+ issuerKey: issuer,
+ subject,
+ contractId: 'aa'.repeat(32)
+ });
+ assert.strictEqual(verifyContractCapability(tok, {
+ contractId: 'bb'.repeat(32),
+ issuerKey: issuer
+ }), null);
+ assert.strictEqual(verifyContractCapability(tok, {
+ contractId: 'aa'.repeat(32),
+ subject: new Key().pubkey,
+ issuerKey: issuer
+ }), null);
+ });
+
+ it('requires issuerKey or allowUnverified; marks parse-only unverified', function () {
+ const issuer = new Key();
+ const contractId = 'cc'.repeat(32);
+ const tok = issueContractCapability({
+ issuerKey: issuer,
+ subject: new Key().pubkey,
+ contractId,
+ capability: OP_CONTRACT_SIGN
+ });
+ assert.strictEqual(verifyContractCapability(tok, { contractId }), null);
+
+ const forged = Buffer.from(JSON.stringify({
+ cap: OP_CONTRACT_SIGN,
+ sub: 'evil',
+ iss: 'x',
+ iat: 1,
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ ctx: { contractId }
+ })).toString('base64url') + '.00';
+
+ assert.strictEqual(verifyContractCapability(forged, { contractId }), null);
+ const parsed = verifyContractCapability(forged, {
+ contractId,
+ allowUnverified: true
+ });
+ assert.ok(parsed);
+ assert.strictEqual(parsed.verified, false);
+ assert.strictEqual(parsed.cap, OP_CONTRACT_SIGN);
+ });
+
+ it('maps roles and capabilities', function () {
+ assert.strictEqual(roleToCapability('signer'), OP_CONTRACT_SIGN);
+ assert.strictEqual(roleToCapability('reader'), OP_CONTRACT_READ);
+ assert.strictEqual(capabilityToRole(OP_CONTRACT_SIGN), 'signer');
+ assert.strictEqual(capabilityToRole(OP_CONTRACT_READ), 'reader');
+ });
+
+ it('rejects unsupported capability on issue', function () {
+ assert.throws(() => issueContractCapability({
+ issuerKey: new Key(),
+ subject: new Key().pubkey,
+ contractId: 'dd'.repeat(32),
+ capability: 'OP_OTHER'
+ }), /unsupported/);
+ });
+});
diff --git a/tests/contractStateSigning.unit.js b/tests/contractStateSigning.unit.js
new file mode 100644
index 000000000..4d9aec8fc
--- /dev/null
+++ b/tests/contractStateSigning.unit.js
@@ -0,0 +1,70 @@
+'use strict';
+
+const assert = require('assert');
+const Key = require('../types/key');
+const {
+ CONTRACT_STATE_TIP_KIND,
+ signingStringForContractStateTip,
+ signContractStateTip,
+ verifyContractStateTip,
+ mergeTipSignatures,
+ tipDigestHex
+} = require('../functions/contractStateSigning');
+
+describe('contractStateSigning', function () {
+ const contractId = 'abcd'.repeat(16);
+ const clock = 3;
+ const stateDigest = 'ef'.repeat(32);
+
+ it('builds a canonical ContractStateTip string', function () {
+ const s = signingStringForContractStateTip({ contractId, clock, stateDigest });
+ assert.ok(s.includes(CONTRACT_STATE_TIP_KIND));
+ assert.ok(s.includes(contractId));
+ assert.strictEqual(typeof tipDigestHex(contractId, clock, stateDigest), 'string');
+ assert.strictEqual(tipDigestHex(contractId, clock, stateDigest).length, 64);
+ });
+
+ it('signs and verifies 2-of-3 tips', function () {
+ const k1 = new Key();
+ const k2 = new Key();
+ const k3 = new Key();
+ const validators = [k1.pubkey, k2.pubkey, k3.pubkey];
+ const a = signContractStateTip(k1, contractId, clock, stateDigest);
+ const b = signContractStateTip(k2, contractId, clock, stateDigest);
+ const sigs = mergeTipSignatures(
+ { [a.pubkey]: a.signature },
+ { [b.pubkey]: b.signature }
+ );
+ assert.strictEqual(
+ verifyContractStateTip(validators, 2, contractId, clock, stateDigest, sigs),
+ true
+ );
+ assert.strictEqual(
+ verifyContractStateTip(validators, 3, contractId, clock, stateDigest, sigs),
+ false
+ );
+ assert.strictEqual(
+ verifyContractStateTip(validators, 2, contractId, clock + 1, stateDigest, sigs),
+ false
+ );
+ });
+
+ it('rejects invalid thresholds instead of defaulting to 1-of-n', function () {
+ const k1 = new Key();
+ const validators = [k1.pubkey];
+ const a = signContractStateTip(k1, contractId, clock, stateDigest);
+ const sigs = { [a.pubkey]: a.signature };
+ assert.throws(
+ () => verifyContractStateTip(validators, undefined, contractId, clock, stateDigest, sigs),
+ /threshold must be a positive integer/
+ );
+ assert.throws(
+ () => verifyContractStateTip(validators, 0, contractId, clock, stateDigest, sigs),
+ /threshold must be a positive integer/
+ );
+ assert.throws(
+ () => verifyContractStateTip(validators, 1.5, contractId, clock, stateDigest, sigs),
+ /threshold must be a positive integer/
+ );
+ });
+});
diff --git a/tests/contractTaproot.unit.js b/tests/contractTaproot.unit.js
new file mode 100644
index 000000000..33f889e6c
--- /dev/null
+++ b/tests/contractTaproot.unit.js
@@ -0,0 +1,404 @@
+'use strict';
+
+const assert = require('assert');
+const bitcoin = require('bitcoinjs-lib');
+const Key = require('../types/key');
+const Contract = require('../types/contract');
+const Federation = require('../types/federation');
+const {
+ synthesizeDefaultLadder,
+ normalizeContractSpendPolicy,
+ normalizeLock,
+ buildContractTaproot,
+ buildKOfNTapscript,
+ toAddress,
+ selectActiveTiers,
+ policyAfterDecay,
+ timelockMature,
+ networkForFabricName,
+ buildFederationVaultFromPolicy,
+ prepareTierWithdrawalPsbt,
+ parseCompressedPubkeysSorted
+} = require('../functions/contractTaproot');
+
+describe('contractTaproot', function () {
+ const keys = [];
+ before(function () {
+ for (let i = 0; i < 12; i++) keys.push(new Key());
+ });
+
+ function pk (i) { return keys[i].pubkey; }
+
+ it('synthesizes a stable default 2-tier ladder', function () {
+ const vals = [pk(0), pk(1), pk(2)];
+ const shuffled = [pk(2), pk(0), pk(1)];
+ const a = toAddress(synthesizeDefaultLadder({
+ validators: vals,
+ threshold: 2,
+ publisher: pk(0),
+ network: 'regtest',
+ csvBlocks: 144
+ }));
+ const b = toAddress(synthesizeDefaultLadder({
+ validators: shuffled,
+ threshold: 2,
+ publisher: pk(0),
+ network: 'regtest',
+ csvBlocks: 144
+ }));
+ assert.strictEqual(a, b);
+ assert.ok(a.startsWith('bcrt1'));
+ });
+
+ it('builds multi-tier failover with until; expired tiers leave active set', function () {
+ const policy = normalizeContractSpendPolicy({
+ network: 'regtest',
+ publisher: pk(0),
+ decay: { mode: 'both', migrateKeys: [pk(0), pk(1), pk(2)], migrateThreshold: 2 },
+ keySets: {
+ full: keys.slice(0, 12).map((k) => k.pubkey),
+ mid: keys.slice(0, 7).map((k) => k.pubkey),
+ emergency: keys.slice(0, 3).map((k) => k.pubkey)
+ },
+ tiers: [
+ { id: 't0', threshold: 7, keys: 'full', after: null, until: { type: 'csv', blocks: 100 } },
+ { id: 't1', threshold: 4, keys: 'mid', after: { type: 'csv', blocks: 100 }, until: { type: 'csv', blocks: 200 } },
+ { id: 't2', threshold: 2, keys: 'emergency', after: { type: 'csv', blocks: 200 }, until: null }
+ ]
+ });
+ const built = buildContractTaproot(policy);
+ assert.ok(built.address);
+ assert.ok(built.leaves.some((l) => l.kind === 'migrate'));
+
+ const early = selectActiveTiers(policy, { utxoAgeBlocks: 0 });
+ assert.strictEqual(early[0].id, 't0');
+
+ const mid = selectActiveTiers(policy, { utxoAgeBlocks: 150 });
+ assert.ok(!mid.find((t) => t.id === 't0'));
+ assert.strictEqual(mid[0].id, 't1');
+
+ const child = policyAfterDecay(policy, { type: 'csv', blocks: 100 });
+ assert.ok(!child.tiers.find((t) => t.id === 't0'));
+ assert.notStrictEqual(toAddress(child), built.address);
+ });
+
+ it('legacy Hub single-leaf vault address is stable', function () {
+ const vals = [pk(0), pk(1), pk(2)];
+ const a = buildFederationVaultFromPolicy({
+ validatorPubkeysHex: vals,
+ threshold: 2,
+ networkName: 'regtest'
+ });
+ const b = buildFederationVaultFromPolicy({
+ validatorPubkeysHex: [pk(2), pk(0), pk(1)],
+ threshold: 2,
+ networkName: 'regtest'
+ });
+ assert.strictEqual(a.address, b.address);
+ assert.ok(a.multisigScript.length);
+ assert.strictEqual(a.policy, null);
+ });
+
+ it('publisher + csvBlocks selects failover ladder without useLadder', function () {
+ const vals = [pk(0), pk(1), pk(2)];
+ const legacy = buildFederationVaultFromPolicy({
+ validatorPubkeysHex: vals,
+ threshold: 2,
+ networkName: 'regtest'
+ });
+ const ladder = buildFederationVaultFromPolicy({
+ validatorPubkeysHex: vals,
+ threshold: 2,
+ networkName: 'regtest',
+ publisher: pk(0),
+ csvBlocks: 144
+ });
+ assert.ok(ladder.policy);
+ assert.notStrictEqual(ladder.address, legacy.address);
+ assert.ok(ladder.leaves.length >= 2);
+ });
+
+ it('Contract.toAddress prefers overrides.spendLadder', function () {
+ const ladderA = synthesizeDefaultLadder({
+ validators: [pk(0), pk(1)],
+ threshold: 1,
+ publisher: pk(0),
+ network: 'regtest',
+ csvBlocks: 50
+ });
+ const ladderB = synthesizeDefaultLadder({
+ validators: [pk(2), pk(3)],
+ threshold: 1,
+ publisher: pk(2),
+ network: 'regtest',
+ csvBlocks: 50
+ });
+ const c = new Contract({ spendLadder: ladderA });
+ assert.strictEqual(c.toAddress(), toAddress(ladderA));
+ assert.strictEqual(
+ c.toTaprootContract({ spendLadder: ladderB }).address,
+ toAddress(ladderB)
+ );
+ });
+
+ it('Contract synthesizes ladder from settings / proposedPolicy / overrides', function () {
+ const expected = toAddress(synthesizeDefaultLadder({
+ validators: [pk(0), pk(1), pk(2)],
+ threshold: 2,
+ publisher: pk(0),
+ network: 'regtest',
+ csvBlocks: 80
+ }));
+
+ // settings.validators + publisher + csvBlocks (hits _taprootPolicyInputs)
+ const fromSettings = new Contract({
+ validators: [pk(0), pk(1), pk(2)],
+ threshold: 2,
+ publisher: pk(0),
+ network: 'regtest',
+ csvBlocks: 80
+ });
+ assert.strictEqual(fromSettings.toAddress(), expected);
+ assert.strictEqual(fromSettings.toAddress('regtest'), expected);
+
+ // proposedPolicy takes precedence over bare settings.validators
+ const fromProposed = new Contract({
+ validators: [pk(4)],
+ proposedPolicy: {
+ validators: [pk(0), pk(1), pk(2)],
+ threshold: 2
+ },
+ publisher: pk(0),
+ network: 'regtest',
+ csvBlocks: 80
+ });
+ assert.strictEqual(fromProposed.toAddress(), expected);
+
+ // consensus.validators when proposedPolicy absent
+ const fromConsensus = new Contract({
+ consensus: { validators: [pk(0), pk(1), pk(2)] },
+ threshold: 2,
+ creator: pk(0),
+ network: 'regtest',
+ csvBlocks: 80
+ });
+ assert.strictEqual(fromConsensus.toAddress(), expected);
+
+ // overrides win for validators / threshold / publisher / csvBlocks / network
+ const bare = new Contract({ validators: [pk(5)], network: 'bitcoin' });
+ const overridden = bare.toTaprootContract({
+ validators: [pk(0), pk(1), pk(2)],
+ threshold: 2,
+ publisher: pk(0),
+ network: 'regtest',
+ csvBlocks: 80
+ });
+ assert.strictEqual(overridden.address, expected);
+
+ // defaults: threshold 1, publisher = validators[0], DEFAULT_CSV_BLOCKS
+ const defaults = new Contract({
+ validators: [pk(0), pk(1)],
+ network: 'regtest'
+ });
+ const inputs = defaults._taprootPolicyInputs();
+ assert.strictEqual(inputs.threshold, 1);
+ assert.strictEqual(inputs.publisher, pk(0));
+ assert.ok(inputs.csvBlocks >= 1);
+ assert.ok(defaults.toAddress().startsWith('bcrt1'));
+
+ // spendLadder on settings, network from ladder when unset
+ const ladderOnly = synthesizeDefaultLadder({
+ validators: [pk(0), pk(1)],
+ threshold: 1,
+ publisher: pk(0),
+ network: 'regtest',
+ csvBlocks: 40
+ });
+ const withLadder = new Contract({ spendLadder: ladderOnly });
+ assert.strictEqual(
+ withLadder.toTaprootContract({}).address,
+ toAddress(ladderOnly)
+ );
+
+ // Defensive fallbacks in _taprootPolicyInputs (empty validators / no network)
+ const empty = new Contract({});
+ const emptyInputs = empty._taprootPolicyInputs();
+ assert.deepStrictEqual(emptyInputs.validators, []);
+ assert.strictEqual(emptyInputs.publisher, null);
+ assert.strictEqual(emptyInputs.network, 'regtest');
+ assert.strictEqual(emptyInputs.threshold, 1);
+ });
+
+ it('Federation.address uses toTaprootContract / consistent network', function () {
+ const federation = new Federation({
+ network: 'regtest',
+ threshold: 2,
+ publisher: pk(0),
+ csvBlocks: 144,
+ consensus: { validators: [pk(0), pk(1), pk(2)] }
+ });
+ const addr = federation.address;
+ assert.ok(addr.startsWith('bcrt1'));
+ assert.strictEqual(addr, federation.toAddress());
+ assert.strictEqual(
+ addr,
+ toAddress(synthesizeDefaultLadder({
+ validators: [pk(0), pk(1), pk(2)],
+ threshold: 2,
+ publisher: pk(0),
+ network: 'regtest',
+ csvBlocks: 144
+ }))
+ );
+ });
+
+ it('Federation._taprootPolicyInputs defaults, overrides, and empty reject', function () {
+ const fed = new Federation({
+ consensus: { validators: [pk(0), pk(1), pk(2)] }
+ });
+ const defaults = fed._taprootPolicyInputs();
+ assert.strictEqual(defaults.threshold, 2); // ceil(3/2)
+ assert.strictEqual(defaults.publisher, pk(0));
+ assert.strictEqual(defaults.network, 'regtest');
+
+ const overridden = fed._taprootPolicyInputs({
+ validators: [pk(0), pk(1)],
+ threshold: 1,
+ publisher: pk(1),
+ csvBlocks: 10,
+ network: 'regtest'
+ });
+ assert.strictEqual(overridden.threshold, 1);
+ assert.strictEqual(overridden.publisher, pk(1));
+ assert.strictEqual(overridden.csvBlocks, 10);
+
+ const empty = new Federation({ consensus: { validators: [] } });
+ assert.throws(
+ () => empty._taprootPolicyInputs(),
+ /at least one validator/
+ );
+
+ // content.validators null → [] fallback then reject
+ const nulled = new Federation({
+ consensus: { validators: [pk(0)] }
+ });
+ nulled._state.content.validators = null;
+ assert.throws(
+ () => nulled._taprootPolicyInputs(),
+ /at least one validator/
+ );
+
+ // settings.csvBlocks when overrides omit it; settings.threshold path
+ const withCsv = new Federation({
+ threshold: 1,
+ csvBlocks: 33,
+ consensus: { validators: [pk(0), pk(1)] }
+ });
+ const fromSettings = withCsv._taprootPolicyInputs();
+ assert.strictEqual(fromSettings.threshold, 1);
+ assert.strictEqual(fromSettings.csvBlocks, 33);
+ });
+
+ it('timelockMature for csv and cltv', function () {
+ assert.strictEqual(timelockMature(null, { utxoAgeBlocks: 0 }), true);
+ assert.strictEqual(timelockMature({ type: 'csv', blocks: 10 }, { utxoAgeBlocks: 9 }), false);
+ assert.strictEqual(timelockMature({ type: 'csv', blocks: 10 }, { utxoAgeBlocks: 10 }), true);
+ assert.strictEqual(timelockMature({ type: 'cltv', height: 100 }, { tipHeight: 99 }), false);
+ assert.strictEqual(timelockMature({ type: 'cltv', height: 100 }, { tipHeight: 100 }), true);
+ });
+
+ it('normalizeLock rejects CSV above 65535', function () {
+ assert.ok(normalizeLock({ type: 'csv', blocks: 65535 }));
+ assert.strictEqual(normalizeLock({ type: 'csv', blocks: 65536 }), null);
+ assert.strictEqual(normalizeLock({ type: 'csv', blocks: 0 }), null);
+ });
+
+ it('throws when threshold exceeds unique key count', function () {
+ const pks = parseCompressedPubkeysSorted([pk(0), pk(1), pk(0)]);
+ assert.strictEqual(pks.length, 2);
+ assert.throws(
+ () => buildKOfNTapscript(pks, 3),
+ /exceeds unique key count/
+ );
+ assert.throws(() => normalizeContractSpendPolicy({
+ network: 'regtest',
+ publisher: pk(0),
+ keySets: { full: [pk(0), pk(1)] },
+ tiers: [{ id: 't0', threshold: 3, keys: 'full' }]
+ }), /exceeds unique key count/);
+ });
+
+ it('rejects mixed csv/cltv lock ladders', function () {
+ assert.throws(() => normalizeContractSpendPolicy({
+ network: 'regtest',
+ publisher: pk(0),
+ keySets: { full: [pk(0), pk(1)] },
+ tiers: [
+ { id: 't0', threshold: 1, keys: 'full', after: { type: 'csv', blocks: 10 } },
+ { id: 't1', threshold: 1, keys: 'full', after: { type: 'cltv', height: 100 } }
+ ]
+ }), /lock type must match/);
+ });
+
+ it('networkForFabricName does not alias bare test to regtest', function () {
+ assert.strictEqual(networkForFabricName('regtest'), bitcoin.networks.regtest);
+ assert.strictEqual(networkForFabricName('testnet'), bitcoin.networks.testnet);
+ // Ambiguous "test" falls through to mainnet (same as unknown names).
+ assert.strictEqual(networkForFabricName('test'), bitcoin.networks.bitcoin);
+ });
+
+ it('selectActiveTiers honors when predicates', function () {
+ const policy = normalizeContractSpendPolicy({
+ network: 'regtest',
+ publisher: pk(0),
+ decay: { mode: 'policy' },
+ keySets: { full: [pk(0), pk(1)] },
+ tiers: [{
+ id: 'gated',
+ threshold: 1,
+ keys: 'full',
+ after: null,
+ when: { allOf: [{ op: 'tipClockGte', value: 5 }] }
+ }]
+ });
+ assert.strictEqual(selectActiveTiers(policy, { tipClock: 4 }).length, 0);
+ assert.strictEqual(selectActiveTiers(policy, { tipClock: 5 })[0].id, 'gated');
+ });
+
+ it('prepareTierWithdrawalPsbt sets locktime for CLTV leaves', function () {
+ const policy = normalizeContractSpendPolicy({
+ network: 'regtest',
+ publisher: pk(0),
+ decay: { mode: 'policy' },
+ keySets: { full: [pk(0), pk(1)] },
+ tiers: [{
+ id: 'cltv',
+ threshold: 1,
+ keys: 'full',
+ after: { type: 'cltv', height: 250 }
+ }]
+ });
+ const built = buildContractTaproot(policy);
+ const network = bitcoin.networks.regtest;
+ const funding = new bitcoin.Transaction();
+ funding.version = 2;
+ funding.addInput(Buffer.alloc(32), 0);
+ funding.addOutput(bitcoin.address.toOutputScript(built.address, network), 50_000n);
+ const dest = bitcoin.payments.p2wpkh({
+ pubkey: Buffer.from(pk(5), 'hex'),
+ network
+ }).address;
+
+ const prep = prepareTierWithdrawalPsbt({
+ policy,
+ fundedTxHex: funding.toHex(),
+ destinationAddress: dest,
+ feeSats: 1000,
+ ctx: { tipHeight: 250 },
+ tierId: 'cltv'
+ });
+ assert.strictEqual(prep.locktime, 250);
+ assert.ok(prep.sequence !== 0xffffffff);
+ assert.ok(prep.psbtBase64);
+ });
+});
diff --git a/tests/contractTierWhen.unit.js b/tests/contractTierWhen.unit.js
new file mode 100644
index 000000000..6f2fba601
--- /dev/null
+++ b/tests/contractTierWhen.unit.js
@@ -0,0 +1,44 @@
+'use strict';
+
+const assert = require('assert');
+const { evaluateTierWhen, getPath } = require('../functions/contractTierWhen');
+
+describe('contractTierWhen', function () {
+ it('getPath reads own properties only', function () {
+ const state = { a: { b: 1 } };
+ assert.strictEqual(getPath(state, '/a/b'), 1);
+ assert.strictEqual(getPath(state, '/__proto__'), undefined);
+ assert.strictEqual(getPath(state, '/constructor'), undefined);
+ assert.strictEqual(getPath(state, '/a/missing'), undefined);
+ assert.strictEqual(getPath(null, '/a'), undefined);
+ });
+
+ it('evaluateTierWhen fails closed on empty or malformed when', function () {
+ assert.strictEqual(evaluateTierWhen(null), true);
+ assert.strictEqual(evaluateTierWhen({ allOf: [] }), false);
+ assert.strictEqual(evaluateTierWhen({}), false);
+ assert.strictEqual(evaluateTierWhen({ allOf: [{ op: 'unknown' }] }), false);
+ assert.strictEqual(evaluateTierWhen('nope'), false);
+ });
+
+ it('tipClockGte and statePathEq', function () {
+ assert.strictEqual(evaluateTierWhen({
+ allOf: [{ op: 'tipClockGte', value: 3 }]
+ }, { tipClock: 3 }), true);
+ assert.strictEqual(evaluateTierWhen({
+ allOf: [{ op: 'tipClockGte', value: 4 }]
+ }, { tipClock: 3 }), false);
+
+ assert.strictEqual(evaluateTierWhen({
+ allOf: [{ op: 'statePathEq', path: '/status', value: 'open' }]
+ }, { contractState: { status: 'open' } }), true);
+
+ assert.strictEqual(evaluateTierWhen({
+ allOf: [{ op: 'statePathEq', path: '/status' }]
+ }, { contractState: { status: 'open' } }), false);
+
+ assert.strictEqual(evaluateTierWhen({
+ allOf: [{ op: 'statePathEq', value: 'open' }]
+ }, { contractState: { status: 'open' } }), false);
+ });
+});
diff --git a/tests/fabric.token.js b/tests/fabric.token.js
index 0d605bda0..6e4897650 100644
--- a/tests/fabric.token.js
+++ b/tests/fabric.token.js
@@ -81,6 +81,22 @@ describe('@fabric/core/types/token', function () {
assert.strictEqual(Token.verifySigned(signed, new Key()), null);
});
+ it('explicit ctx: null suppresses settings.ctx on signed payload', function () {
+ const issuer = new Key();
+ const token = new Token({
+ capability: 'OP_CONTRACT_READ',
+ issuer,
+ subject: 'sub',
+ ctx: { contractId: 'aa'.repeat(32) }
+ });
+ const withCtx = Token.verifySigned(token.toSignedString(), issuer);
+ assert.ok(withCtx.ctx);
+ assert.strictEqual(withCtx.ctx.contractId, 'aa'.repeat(32));
+ const without = Token.verifySigned(token.toSignedString({ ctx: null }), issuer);
+ assert.ok(without);
+ assert.strictEqual(without.ctx, undefined);
+ });
+
it('base64Url helpers round-trip string and buffer', function () {
const raw = '{"a":1}';
const enc = Token.base64UrlEncode(raw);
diff --git a/tests/functions.documentRegistrySidechain.js b/tests/functions.documentRegistrySidechain.js
index 6d92db36a..9d524cd06 100644
--- a/tests/functions.documentRegistrySidechain.js
+++ b/tests/functions.documentRegistrySidechain.js
@@ -8,7 +8,7 @@ const { getBodySchema } = require('../types/message');
describe('functions/documentRegistrySidechain', function () {
it('registers SIDECHAIN_STATE_PATCH body schema', function () {
assert.ok(getBodySchema(registry.SIDECHAIN_STATE_PATCH_TYPE));
- assert.strictEqual(getBodySchema(registry.SIDECHAIN_STATE_PATCH_TYPE).length, 3);
+ assert.strictEqual(getBodySchema(registry.SIDECHAIN_STATE_PATCH_TYPE).length, 4);
});
it('field round-trip encode/decode', function () {
@@ -23,6 +23,42 @@ describe('functions/documentRegistrySidechain', function () {
assert.ok(Buffer.isBuffer(back.basisDigest));
assert.strictEqual(back.basisDigest.toString('hex'), sidechainState.stateDigest(state));
assert.strictEqual(back.catalogCanonical, fields.catalogCanonical);
+ assert.strictEqual(back.patchesCanonical, '');
+ });
+
+ it('decodes legacy 3-field bodies without patchesCanonical', function () {
+ const { encodeBody, decodeBody } = require('../types/message');
+ const legacySchema = Object.freeze([
+ { name: 'basisClock', type: 'u32' },
+ { name: 'basisDigest', type: 'bytes32' },
+ { name: 'catalogCanonical', type: 'string' }
+ ]);
+ const legacy = encodeBody(legacySchema, {
+ basisClock: 0,
+ basisDigest: Buffer.alloc(32),
+ catalogCanonical: '{"version":1,"documents":{}}'
+ });
+ const back = decodeBody(registry.SCHEMA_SIDECHAIN_STATE_PATCH, legacy);
+ assert.strictEqual(back.catalogCanonical, '{"version":1,"documents":{}}');
+ assert.strictEqual(back.patchesCanonical, '');
+ });
+
+ it('applyRegistryUpdateFields honors multi-op patchesCanonical', function () {
+ let state = sidechainState.createInitialState();
+ const patches = [
+ { op: 'add', path: '/note', value: 'hello' },
+ { op: 'add', path: '/count', value: 2 }
+ ];
+ const fields = {
+ basisClock: 0,
+ basisDigest: Buffer.from(sidechainState.stateDigest(state), 'hex'),
+ catalogCanonical: '{}',
+ patchesCanonical: JSON.stringify(patches)
+ };
+ const applied = registry.applyRegistryUpdateFields(state, fields);
+ assert.ok(applied.ok);
+ assert.strictEqual(applied.state.content.note, 'hello');
+ assert.strictEqual(applied.state.content.count, 2);
});
it('applyInventoryToRegistry updates STATE digest without public RFC6902 API', function () {
diff --git a/types/contract.js b/types/contract.js
index 777347da6..71dd94868 100644
--- a/types/contract.js
+++ b/types/contract.js
@@ -330,6 +330,67 @@ class Contract extends Service {
script: this.contract
};
}
+
+ /**
+ * Build deterministic P2TR spend tree from settings.spendLadder or synthesized
+ * validators / publisher policy.
+ * @param {object} [overrides]
+ * @returns {object} {@link module:functions/contractTaproot.buildContractTaproot}
+ */
+ /**
+ * Shared spend-policy inputs for {@link #toTaprootContract}.
+ * Subclasses (e.g. Federation) may override to supply validators from state.
+ * @param {object} [overrides]
+ * @returns {object}
+ */
+ _taprootPolicyInputs (overrides = {}) {
+ const tap = require('../functions/contractTaproot');
+ const validators = overrides.validators
+ || (this.settings.proposedPolicy && this.settings.proposedPolicy.validators)
+ || (this.settings.consensus && this.settings.consensus.validators)
+ || this.settings.validators
+ || [];
+ const threshold = overrides.threshold != null
+ ? overrides.threshold
+ : ((this.settings.proposedPolicy && this.settings.proposedPolicy.threshold)
+ || this.settings.threshold
+ || 1);
+ const publisher = overrides.publisher
+ || this.settings.publisher
+ || this.settings.creator
+ || (validators[0] || null);
+ const network = overrides.network
+ || this.settings.network
+ || 'regtest';
+ const csvBlocks = overrides.csvBlocks != null
+ ? overrides.csvBlocks
+ : (this.settings.csvBlocks != null ? this.settings.csvBlocks : tap.DEFAULT_CSV_BLOCKS);
+ return { validators, threshold, publisher, network, csvBlocks };
+ }
+
+ toTaprootContract (overrides = {}) {
+ const tap = require('../functions/contractTaproot');
+ const ladder = overrides.spendLadder || this.settings.spendLadder || null;
+ if (ladder) {
+ const network = overrides.network || this.settings.network || ladder.network;
+ return tap.buildContractTaproot({ ...ladder, ...overrides, network });
+ }
+ const inputs = this._taprootPolicyInputs(overrides);
+ return tap.buildContractTaproot(tap.synthesizeDefaultLadder({
+ ...inputs,
+ ...overrides
+ }));
+ }
+
+ /**
+ * Bech32m P2TR address for this contract's spend policy.
+ * @param {string} [network]
+ * @returns {string}
+ */
+ toAddress (network) {
+ const built = this.toTaprootContract(network ? { network } : {});
+ return built.address;
+ }
}
module.exports = Contract;
diff --git a/types/federation.js b/types/federation.js
index 3a7f61b0a..35631e483 100644
--- a/types/federation.js
+++ b/types/federation.js
@@ -291,64 +291,34 @@ class Federation extends Contract {
return false;
}
- get address () {
- // Get the public keys of all validators
- const pubkeys = this._state.content.validators.map(pubkey => Buffer.from(pubkey, 'hex'));
-
- // Create the threshold script for majority of signers
- const threshold = Math.ceil(pubkeys.length / 2);
- const thresholdScript = bitcoin.script.compile([
- bitcoin.opcodes.OP_PUSHNUM_1 + threshold - 1,
- ...pubkeys.map(pubkey => Buffer.concat([
- Buffer.from([pubkey.length]),
- pubkey
- ])),
- bitcoin.opcodes.OP_PUSHNUM_1 + pubkeys.length,
- bitcoin.opcodes.OP_CHECKMULTISIG
- ]);
-
- // Create the taproot tree
- const tree = [
- {
- script: thresholdScript,
- weight: 1
- }
- ];
-
- // Add timeout condition if specified in settings
- if (this.settings.timeout) {
- const timeoutScript = bitcoin.script.compile([
- bitcoin.opcodes.OP_CHECKLOCKTIMEVERIFY,
- bitcoin.opcodes.OP_DROP,
- ...thresholdScript
- ]);
- tree.push({
- script: timeoutScript,
- weight: 1
- });
- }
-
- // Add contract condition if specified in settings
- if (this.settings.contract) {
- // If contract is a string, assume it's a script hex
- const contractScript = typeof this.settings.contract === 'string'
- ? Buffer.from(this.settings.contract, 'hex')
- : this.settings.contract;
-
- tree.push({
- script: contractScript,
- weight: 1
- });
+ /**
+ * Federation validators live on consensus state, not only constructor settings.
+ * @param {object} [overrides]
+ * @returns {object}
+ */
+ _taprootPolicyInputs (overrides = {}) {
+ const tap = require('../functions/contractTaproot');
+ const validators = overrides.validators
+ || (this._state.content.validators || []).slice();
+ if (!validators.length) {
+ throw new Error('Federation address requires at least one validator');
}
+ const threshold = overrides.threshold != null
+ ? Number(overrides.threshold)
+ : (this.settings.threshold != null
+ ? Number(this.settings.threshold)
+ : Math.ceil(validators.length / 2));
+ const publisher = overrides.publisher || this.settings.publisher || validators[0];
+ // Do not treat settings.timeout as a block count (units differ).
+ const csvBlocks = overrides.csvBlocks != null
+ ? Number(overrides.csvBlocks)
+ : (this.settings.csvBlocks != null ? Number(this.settings.csvBlocks) : tap.DEFAULT_CSV_BLOCKS);
+ const network = overrides.network || this.settings.network || 'regtest';
+ return { validators, threshold, publisher, network, csvBlocks };
+ }
- // Create the taproot output
- const output = bitcoin.payments.p2tr({
- internalPubkey: pubkeys[0].slice(1), // Use first validator's x-only pubkey
- scriptTree: tree,
- network: bitcoin.networks.bitcoin
- });
-
- return output.address;
+ get address () {
+ return this.toAddress();
}
}
diff --git a/types/message.js b/types/message.js
index 58f7d1d54..ac41bd724 100644
--- a/types/message.js
+++ b/types/message.js
@@ -1204,12 +1204,40 @@ function encodeBody (schema, fields = {}) {
return Buffer.concat(chunks, total);
}
+/**
+ * Default for an optional field when the wire body ends before that field.
+ * @param {{ type: string }} def
+ */
+function _optionalFieldDefault (def) {
+ switch (def.type) {
+ case 'string':
+ return '';
+ case 'bytes':
+ case 'message':
+ return Buffer.alloc(0);
+ case 'bytes32':
+ return Buffer.alloc(32);
+ case 'u8':
+ case 'u16':
+ case 'u32':
+ return 0;
+ case 'u64':
+ return 0n;
+ default:
+ return null;
+ }
+}
+
function decodeBody (schema, buffer) {
if (!Array.isArray(schema)) throw new TypeError('schema required');
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || []);
const out = {};
let offset = 0;
for (const def of schema) {
+ if (def.optional && offset >= buf.length) {
+ out[def.name] = _optionalFieldDefault(def);
+ continue;
+ }
if (offset > buf.length) {
throw new RangeError(`truncated body while reading field ${def.name}`);
}
diff --git a/types/peer.js b/types/peer.js
index f270b1975..04ecb1032 100644
--- a/types/peer.js
+++ b/types/peer.js
@@ -3193,7 +3193,13 @@ class Peer extends Service {
registered,
object: msg.object,
origin,
- signer: signerPubkeyHex || null
+ signer: signerPubkeyHex || null,
+ // Bit-identical AMP frame for journal / audit attach (apps ignore if unused).
+ wireMessage: wireMessage || null,
+ messageId: wireMessage && wireMessage.id ? wireMessage.id : null,
+ messageHex: wireMessage && typeof wireMessage.toBuffer === 'function'
+ ? wireMessage.toBuffer().toString('hex')
+ : null
});
// Same outermost-only flood rule as CONTRACT_PUBLISH / chat / gossip.
if (delivery.allowMeshRelay && origin && origin.name && wireMessage) {
@@ -4314,7 +4320,7 @@ class Peer extends Service {
* Preference: onion `relayPath` → explicit `nextPeer` → fan-out to TCP peers
* other than the inbound origin.
* @param {Message} msg signed DocumentRequest
- * @param {{ name?: string }|null} origin
+ * @param {{name: (string|undefined)}|null} origin
* @param {object} parsed inbound request body
* @returns {boolean}
*/
diff --git a/types/token.js b/types/token.js
index 1ff61df62..761effd2c 100644
--- a/types/token.js
+++ b/types/token.js
@@ -97,6 +97,11 @@ class Token {
iat,
exp
};
+ // Optional context (e.g. { contractId }). Explicit `ctx: null` suppresses settings.ctx.
+ const ctx = Object.prototype.hasOwnProperty.call(options, 'ctx')
+ ? options.ctx
+ : this.settings.ctx;
+ if (ctx != null && typeof ctx === 'object') payload.ctx = ctx;
const payloadStr = JSON.stringify(payload);
const payloadB64 = Token.base64UrlEncode(payloadStr);
const signature = this.issuer.sign(payloadStr);