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);