diff --git a/.codacy.yml b/.codacy.yml index b056d52f8..de48f99e3 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -13,7 +13,11 @@ engines: - "functions/cliDocumentExchange.js" - "functions/contractSidechainLocal.js" - "functions/fabricSetup.js" + - "functions/fabricHomeEnv.js" + - "functions/fabricWalletIdentity.js" + - "functions/fabricMessageCollection.js" - "types/environment.js" + - "types/filesystem.js" # Codacy Opengrep (Semgrep fork) may re-report the same path-construction patterns. opengrep: exclude_paths: @@ -22,7 +26,11 @@ engines: - "functions/cliDocumentExchange.js" - "functions/contractSidechainLocal.js" - "functions/fabricSetup.js" + - "functions/fabricHomeEnv.js" + - "functions/fabricWalletIdentity.js" + - "functions/fabricMessageCollection.js" - "types/environment.js" + - "types/filesystem.js" # Cppcheck still scanned src/ despite root exclude_paths; tool-specific paths are reliable for PR gates. cppcheck: exclude_paths: diff --git a/AUDIT.md b/AUDIT.md index 071c2246d..9defc4b36 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -13,7 +13,7 @@ audit report. | Peer misbehavior / scoring | Implemented (integrity, pin, session, contract ops, logical-register, nest cap, temp ban) — see [SECURITY.md](SECURITY.md) | | P2P_RELAY amplification | Mitigated: bit-identical outer forward + nest depth cap + relay-as-is pin; gossip hop remains advisory | | Chat mesh amplify | Mitigated: per-origin relay budget (`CHAT_MAX_RELAYS_*`) | -| Oversize wire frames | Mitigated: drop before parse/crypto (`HEADER_SIZE + MAX_MESSAGE_SIZE`) | +| Oversize / undersize wire frames | Mitigated: drop before parse/crypto (`< HEADER_SIZE` or `> HEADER_SIZE + MAX_MESSAGE_SIZE`); unparseable buffers drop without score/ban | | Inventory HTLC address spoof | Mitigated: rebuild+match (`validateInventoryHtlcOffer`); AMP signer binding when present | | Paid `/confirm` without L1 proof | Mitigated: fail-closed local verify or Hub `ConfirmInventoryHtlcPayment` (`cliDocumentExchange`) | | Key reveal from hash echo | Mitigated: `authorizeDocumentKeyReveal` requires `settlementId`/`txid`; `forceReveal` opt-in only; inbound reveal requires key preimage + claim-after-open | @@ -21,12 +21,12 @@ audit report. | Sealed multi-seller blob plans | Mitigated: `pickBlobPlan` locks sealed docs to one seller | | Blob/sealed memory growth | Mitigated: pending transfer / sealed / relay-route caps | | Directed onion (`P2P_FORWARD`) | Implemented (peel/forward, TTL, bounce guard; peel skips last-hop hard disconnect; unified `meshDeliveryContext` — no session/alias rebind / mesh side-effects / DocumentRequest fulfill on peel) — **no hop encryption**; see [docs/P2P_FORWARD.md](docs/P2P_FORWARD.md) | -| Unknown AMP opcodes | Mitigated: decode as `UNKNOWN_MESSAGE` (not `P2P_BASE_MESSAGE`); observe-only. First-class mesh/session types ignored when smuggled via generic JSON carrier — see [tests/protocol-v1/](tests/protocol-v1/README.md) | +| Unknown AMP opcodes | Mitigated: decode as `UNKNOWN_MESSAGE` (not `P2P_BASE_MESSAGE`); observe-only. First-class mesh/session types ignored when smuggled via generic JSON carrier — see [tests/fabric.peer.adversarial.js](tests/fabric.peer.adversarial.js) | | L1 document HTLC helpers | Implemented in core; treat as RC reference, not mainnet-hardened market | | Application contract sandbox | **Not** claimed — `Machine.define` binds host JS; see [PUBLIC_API.md](PUBLIC_API.md) | | External security review | **Outstanding** before dropping “experimental” language | | npm audit (runtime) | Clean after `uuid@11.1.1` override (jayson); track `npm run report:security` → `reports/SECURITY-AUDIT.md` for docs-toolchain noise | -| Strict Protocol V1 adversarial suite | Landed — [`tests/protocol-v1/`](tests/protocol-v1/README.md) matrix + typed NOISE storm; expand if new mesh opcodes appear | +| Strict Protocol V1 adversarial suite | Landed — [`tests/fabric.peer.adversarial.js`](tests/fabric.peer.adversarial.js); expand if new mesh opcodes appear | ## Known gaps (do not paper over) @@ -35,10 +35,10 @@ audit report. 3. **PROTOCOL history** — Older root protocol sketches were stale; [PROTOCOL.md](PROTOCOL.md) now redirects to [docs/MESSAGE_BODY.md](docs/MESSAGE_BODY.md). 4. **CLI `settings/local.js`** — Gitignored operator overlay. Packaged installs should ship/copy [settings/local.example.js](settings/local.example.js); shell loads example when local is absent. 5. **Exact Node pin** — `engines.node` is `24.15.0`. Documented for CI parity; broad consumers may need a range in a later release. -6. **Adversarial completeness** — Historical fuzz (`randomAmpFrame`, `P2P_BASE_MESSAGE` chaos) proves crash resilience more than semantic malice. Prefer expanding [`tests/protocol-v1/`](tests/protocol-v1/README.md) (well-formed signed frames × delivery modes) before claiming mesh-wide adversarial hardness. Unregistered AMP opcodes must remain `UNKNOWN_MESSAGE` (not aliased to `P2P_BASE_MESSAGE`). +6. **Adversarial completeness** — Historical fuzz (`randomAmpFrame`, `P2P_BASE_MESSAGE` chaos) proves crash resilience more than semantic malice. Prefer expanding [`tests/fabric.peer.adversarial.js`](tests/fabric.peer.adversarial.js) (well-formed signed frames × delivery modes) before claiming mesh-wide adversarial hardness. Unregistered AMP opcodes must remain `UNKNOWN_MESSAGE` (not aliased to `P2P_BASE_MESSAGE`). 7. ~~**Peer `_selfDialSuppressUntil`**~~ — FIFO-capped (default 256; `settings.selfDialSuppressMax`); expired entries still drop on read. Self-dial suppress from peering offers/announces uses **verified AMP signer** only (`meta.verifiedPubkey`), not attacker-controlled `obj.pubkey`. 8. **Eager `messageHex`** — hot paths still materialize hex wire forms eagerly; laziness / cache invalidation is outstanding performance work, not a correctness claim. -9. ~~**Chat / onion seal AAD**~~ — tip + participant AES-GCM AAD lands in `groupChatSeal` / `onionChatSeal` (see [docs/ARC.md](docs/ARC.md) §8). +9. ~~**Chat / onion seal AAD**~~ — tip + participant AES-GCM AAD lands in `groupChatSeal` / `onionChatSeal` (see [docs/CONTRACTS.md](docs/CONTRACTS.md) §8). 10. **Public API short names** — `contractId` and similar remain until a coordinated rename to `contractIdentifier`-style identifiers (Author Style in `AGENTS.md`). 11. **Blinded-execution decisions** — accept/reject require BIP340 over `decisionSigningMessage` v2 (`sessionId` / `proposalMerkleRoot` / `decision` / `at`). Same actor/proposal/decision/`at` replays are idempotent; a different `at` conflicts. Composition remains a scaffold (not Yao GC). 12. **Fabric coin-type dual path** — protocol identity is **7777** on Bitcoin mainnet and **7778** on all other networks (`fabricCoinTypeForNetwork` / `Identity#network`). Default Peer / Identity (regtest) stays on **7778**. Hub / Passport / extension callers that hard-code `m/44'/7778'/…` should switch to `fabricIdentityDerivationPath(…, network)` when targeting mainnet; re-derive any keys previously treated as “mainnet” under 7778. @@ -46,9 +46,10 @@ audit report. 14. **Outstanding ARC / Peer follow-ups** — coordinated `contractId` → `contractIdentifier` rename; eager `messageHex` laziness; regenerate `API.md` field docs for `SCHEMA_P2P_PEER_GOSSIP` / `tryParseMessageBody` / `resolveSpend` opts when next running `npm run make:api`. Journal / re-fold caps and blinded-execution `at` bind are landed (see ARC §8). 15. ~~**Beacon/ARC `CONTRACT_PUBLISH` authority collector**~~ — `collectContractAuthorityPubkeys` walks nested `members.signers` / `spendPolicy.validators` (not only top-level arrays), so Beacon genesis no longer fail-opens first-claim to any AMP signer. 16. ~~**Peering self-suppress trust**~~ — candidate host/port validation + default-port normalize on self-key refuse + NOISE self-check fail-closed; offer/announce enqueue suppresses only when `verifiedPubkey` (AMP signer) is our key. Advertised `obj.pubkey` is informational. -17. **OP_RETURN hallmarks** — core short-format encode/verify (`functions/fabricHallmark`); Hub publish/scan is operator opt-in. Hallmarks are not a BIP; `npm run report:bip-compliance` last run **2026-08-13** (mean stack **2.47**, 37 BIPs, no grade movement vs 2026-08-12). Remaining: Hub mainnet hallmark policy gates; BIP suite gaps (49/69/85/322/48/370/352/388) in `reports/bip-compliance.md`. +17. **OP_RETURN hallmarks** — core short-format encode/verify (`functions/fabricHallmark`); Hub publish/scan is operator opt-in. Hallmarks are not a BIP; `npm run report:bip-compliance` last run **2026-08-17** (mean stack **2.68**, 37 BIPs; core Full **10**, Absent **5**; Hub Strong **13**, Absent **7**). Remaining: Hub mainnet hallmark policy gates; core Absent is **77 / 370 / 352 / 322 / 388**. Wallet slice: Hub `bitcoin:` URIs through core BIP-21 (Hub 321 Strong); BIP-49 nested-SegWit helper + BIP-371 taproot PSBT bags (core Strong). Default funds path remains BIP-44. See `reports/bip-compliance.md`. 18. ~~**Blinded-execution `at` bind**~~ — `decisionSigningMessage` v2 includes `at`; `recordProposalDecision` requires it and rejects timestamp swaps under a valid signature. 19. ~~**GroupChangeProposal roster bind**~~ — `signingStringForGroupChangeProposal` v2 includes canonical `members` / `signers` so BIP340 votes cannot be reused on a colliding `id` with a swapped roster. +20. **MuSig2 is n-of-n** — BIP-327 is implemented; t-of-n Bitcoin spends remain Taproot script-path (`CHECKSIGADD`). FROST / ChillDKG / ROAST are not in-tree. Interactive `P2P_MUSIG_*` sessions are directed TCP only (`Peer#startMusig2`, `functions/musig2Session`). **Inbound auto-sign is off by default** (`musig2.autoAccept: false`) so a connected peer cannot use this node's identity key as a signing oracle. **Default `synthesizeDefaultLadder` for n≥2 now uses a MuSig2 internal key** (new address vs historical NUMS). Pass `internalKeyMode: 'nums'` to keep the old script-path-only address. Existing UTXOs at a NUMS vault stay there; they are not migrated by a policy rebuild. ### PR #183 review triage (feature/rsi) diff --git a/CHANGELOG.md b/CHANGELOG.md index a69ffcb43..9a3ae7674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,40 @@ # `@fabric/core` Changelog Recent changes to Fabric Core. +## 2026-08-16 +- **PR #185 review (HEAD `0b8ce4b`):** PSBT `toIntegerSats` rejects `null` / `false` / blank strings (no zero-sat coerce). `musig2.sign` zeros the secret nonce before `getSessionValues`; `createPartial` / failed `aggregatePartials` / `Peer.stop()` wipe stored sessions. BIP-371 `tapLeafScriptEntry` requires the control-block masked leaf version to match. Filesystem TOCTOU without Node `openat` and unlink-without-follow for planted store symlinks stay as-is. +- **PR #185 Codacy gate (`types/filesystem.js`):** Semgrep/Opengrep exclude the Filesystem type (same class as `fabricSetup` / `environment`). Containment + `O_NOFOLLOW` stay covered by `tests/fabric.filesystem.js`; Codacy ignores `nosemgrep`. BIP-371 `tapLeafScriptEntry` requires control-block length `33+32*m` and an even leaf version. `npm run home-env` returns exit 1 when writes throw. PSBT outputs reject non-integer satoshi values. +- **PR #185 Filesystem O_NOFOLLOW:** `readFile` / `writeFile` refuse a final-component symlink and a parent whose `realpath` left the store root. `delete` unlinks a planted symlink without following it. `writeFile` opens with `O_NOFOLLOW` when the platform defines it (no `touch()`-then-follow). Tests in `tests/fabric.filesystem.js`. +- **PR #185 wallet BIP slice (no new files):** BIP-49 nested-SegWit payment helpers live in [`functions/bip69.js`](functions/bip69.js) (`p2shP2wpkhPayment` / `p2shP2wpkhAddress`); `Key.deriveAddress(..., 'p2sh-p2wpkh')` uses them. Default funds path remains BIP-44. BIP-371 Taproot PSBT input bags (`tapInternalKey` / `tapLeafScript`) live in [`functions/inventoryHtlc.js`](functions/inventoryHtlc.js); contract-vault leaf spends reuse that export. +- **PR #185 home-env:** `npm run home-env` no longer stamps `FABRIC_SEED` / `FABRIC_MNEMONIC` next to `FABRIC_XPRV` in `~/.fabric/env`. +- **PR #185 Filesystem path escape:** `writeFile` / `readFile` / `delete` reject names that resolve outside the store root (`../`, absolute paths). Nested writes under the root still work. Symlink `O_NOFOLLOW` remains deferred. Tests in `tests/fabric.filesystem.js`. +- **PR #185 Codacy gate (`14d3d3a7`):** `npm run docs:message-types` reads/writes literal `MESSAGES.md` (chdir to repo root) so Semgrep does not flag `path.join` + `fs.readFileSync`/`writeFileSync` as two criticals. Do not Semgrep-exclude `types/message.js`. +- **Message type consolidation:** [`MESSAGES.md`](MESSAGES.md) decode-order table is generated from `WIRE_TYPE_DECODE_ORDER` (`npm run docs:message-types`). Mocha asserts the marked catalog matches the live decode order. +- **AMP opcode split:** Lightning Fabric-Message types move to `0x2000–0x2013` (`POLICY.md`). Fabric keeps the low numbers (`P2P_PING` `0x12`, `P2P_INSTRUCTION` `0x20`, `P2P_GENERIC` `0x80`, …). Duplicate opcodes fail at `Message` load. `P2P_INSTRUCTION`, `P2P_STATE_COMMITTMENT`, and `P2P_SESSION_ACK` are first-class decode names. Encode name round-trips to the decode label. `Message.fromVector` no longer swallows constructor errors. +- **Generic application contract chain fixture:** `tests/fabric.hub.mesh.integration.js` publishes a deterministic ARC genesis, covers every `GroupChange` action (`member.add` / `member.remove` / `members.set` / `signers.set` / `update`, plus reader-role add and proposal+vote), gossips the AMP frames across a 3-node loopback mesh, and locks `tests/fixtures/application-contract-chain.json`. Replay must match collection `root` and folded `stateDigest` (`UPDATE_FIXTURE=1` to regenerate). +- **PR #185 review follow-ups (after `9938917`):** `Filesystem.publish` throws when `writeFile` returns false (no phantom `files` entry / success hash). Write errors emit `warning` unless an `error` listener is present. Inbound Peer socket errors use the same `listenerCount('error')` fallback as outbound. Local MuSig2 initiators still create their own partial when `autoAccept` is off (inbound auto-sign stays gated). BIP-125 rejects non-integer / out-of-range nSequence. `classifyChatSurface(null)` is `unknown`, not shoutbox. Invalid-length `Buffer`/`Uint8Array` seeds throw `Seed must be 16–64 bytes` instead of `mnemonicToSeedSync`. +- **Crash-report follow-ups (not the heap root):** `State.serialize` treats `null` as `{}` (HTTP `Store._POST` of an empty body was `Cannot read properties of null (reading '@type')`). `Store._POST` rejects a null body. Bitcoin `addnode` RPC **[-23] already added** is success, not a warning. +- **Filesystem publish:** do not retain file bodies in `_state.documents` / Actor copies, and do not `synchronize()` (full tree re-read + `commit()`) after every write. Hub `fs.publish('STATE', …)` plus Bitcoin block documents were cloning each snapshot into the heap until V8 mark-compact OOM. `hashes` / `leaves` still hash from disk. `ingest()` also stops keeping the body. `commit()` stringifies `_state.content` once (no `this.state` clone). Write errors no longer interpolate the file body. + +## 2026-08-15 +- **PR #185 Codacy gate:** BIP-69 uint32 / BIP-85 purpose and hardened-index max use `Number('…')` (same as `CLTV_TIMESTAMP_THRESHOLD`) so `no-loss-of-precision` does not fail the PR. Collection CLI paths resolve with cwd-containment for relative names (`resolveCollectionFilePath`); Semgrep/Opengrep exclude `functions/fabricMessageCollection.js` like the other path-hardened helpers (Codacy ignores `nosemgrep`). +- **PR #185 security / review follow-ups:** `musig2.autoAccept` defaults to **false** (explicit `true` opt-in). Inbound `P2P_MUSIG_START` does not create a session or emit identity-key partials unless auto-accept is on — the identity key is not a remote signing oracle. `createFromStart` no longer falls back to an unparsed remote body. `maxSessions` / `sessionTtlMs` reject non-finite and `< 1` values (negative caps no longer infinite-loop). BIP-21 parse rejects unsupported `req-*` parameters. Static server guards malformed `decodeURIComponent` and does not `writeHead` after headers are sent. `report-credits` only reads lockfile paths under `node_modules`. `IDENTITY.md` notes explicit integer coin types vs network-name 7777/7778. Tests live in `tests/fabric.peer.js`, `tests/fabric.key.js`, `tests/functions.inventoryHtlc.js`, `tests/cli.nonrender.js`. +- **PR #185 review follow-ups:** export `fabricIdentityAccountPath` and `resolveFabricIdentityCoinType` from `constants.js` (account node `m/44'/{7777|7778}'/n'`; derivation path is that node plus `/0/index`). BIP-44 receive/change reuse the purpose-path helper (`purpose` 44). First-class AMP frames keep the **wire name** as `genericBody.type` so JSON `type: 98` on `P2P_INVENTORY_RESPONSE` cannot enqueue a peering candidate (`tests/fabric.peer.adversarial.js`). `Message.fromRaw` copies ArrayBuffer views with `byteOffset`/`byteLength`. `Collection.getLatest` reads the last ordered id (map, not `items.length`). Service `ChatMessage` relays sign with `node.key` when present. `Key` classifies a legacy `xprv`/`mnemonic` in `seed` (FABRIC_SEED) instead of PBKDF2-hashing the string. `musig2.asBuf` rejects non-hex; `verifyFinalSignature` returns `{ ok: false }` without a session; message-collection CLI reports read failures as exit 2. `backupWallet` / setup helpers use union JSDoc returns (no TypeScript `?:`). +- **Fabric Message collections:** `functions/fabricMessageCollection` stores ordered AMP frames (`Message.toBuffer()` hex) as the canonical share format for contract journals, Discord / `GroupDataShare` packs, and peer replay. Dedupes by body hash, drops body-hash mismatches, JSON/JSONL save+restore, `replay` / `replayFold`. CLI: `npm run messages` (`functions/fabricMessageCollection.js`). Docs: `MESSAGES.md`. Not a replacement for Hub `messages/*.json` activity logs. + +- **BIP-327 MuSig2:** first-party `functions/musig2` locked to the official BIP vectors (`tests/vectors/bip327.json`). n-of-n Schnorr only — not a t-of-n replacement. **Default** `synthesizeDefaultLadder` / federation vault for **n≥2** uses the MuSig2 aggregate as the Taproot internal key (cooperative key-path; **new address** vs historical NUMS). k-of-n `OP_CHECKSIGADD` and CSV soft-tier stay script-path. Pass `internalKeyMode: 'nums'` to keep the NUMS-only address. Control blocks / PSBTs use the real internal key. Off-chain Beacon / GroupChange stay independent BIP-340 counts. **`P2P_MUSIG_*` is dispatched** on directed TCP (`functions/musig2Session` + `Peer#startMusig2`): START → ACCEPT / RECEIVE_COUNTER → SEND_PROPOSAL → REPLY → ACCEPT_PROPOSAL. Peel / foreign `P2P_RELAY` and generic carriers are ignored; AMP signer must be a listed participant; aggnonce is recomputed locally. + ## 2026-08-14 +- **BIP helpers (compliance low-hanging fruit):** first-party `functions/bip69` (vin/vout lexicographic sort, official examples; singleton prevouts are validated), `functions/bip85` (HMAC entropy + BIP-39/WIF/XPRV/HEX vectors), `functions/bip21` (bitcoin: encode/parse; inventory HTLC funding hints), `functions/bip67` (`sortPubkeysBip67`; Taproot k-of-n, ARC spend keys, Beacon validators), `functions/bip125` (opt-in RBF nSequence helpers; HTLC refunds stay locktime-only MAX-1). Named BIP-48/49/84/86 path templates in `constants.js`; **default funds path remains BIP-44** `m/44'/0'/0'/0/0`. Bitcoin `_buildPSBT` applies BIP-69 before `addInput` / `addOutput`. +- **Peer generic dispatch:** JSON `type: 98` (AMP `P2P_PEERING_OFFER`) is coerced via `Message.canonicalTypeName` and dispatched instead of `Unhandled Generic Message: 98`. Unhandled defaults no longer `JSON.stringify` the full body. `_registerActor` / `_registerContract` debug lines are names-only and gated on `settings.debug`. `_connect` derived-key summary reads `Key.pubkey` (`settings.public` is unset after `derive({ xprv })`, which logged `(no public key)` on the correct `m/44'/7778'/0'/0/0` path). +- **Chat shoutbox:** Fabric TUI sends/receives first-class UTF-8 `P2P_CHAT_MESSAGE` (Peer `{ text }` + AMP signer, `P2P_PEER_ALIAS` nicknames). Shared helper `functions/fabricChatText`. Cap matches Peer `P2P_CHAT_MAX_CHARS` (2000). +- **Peer/Service memory:** `Service.commit()` emits a compact `{ type, clock, id, patchCount }` and caps `history` (`SERVICE_COMMIT_HISTORY_MAX` / `commitHistoryMax`). It no longer wraps full `state` in an Actor (JSON.parse of Hub collections on every write). Peer isolates `settings.state` maps (no Hub `collections` alias), does not `commit()` on `_writeFabric` / `_registerActor` / every inbound frame, and drops ephemeral `host:port` actors on `_disconnect` / `_destroyFabric`. `Peer#beat` no longer snapshots full state. +- **Operator home env:** `FABRIC_SEED` is the raw BIP32 seed **hex** (16–64 bytes); `FABRIC_MNEMONIC` is the BIP39 phrase; `FABRIC_XPRV` remains preferred. `Key` `FROM_SEED` accepts hex (legacy mnemonic in `seed` still works) and sets `status = 'seeded'`. `Environment` reads `FABRIC_MNEMONIC`. Helpers: `functions/fabricKeyMaterial`, `functions/fabricHomeEnv` (`~/.fabric/env` + `~/.fabric/hub-admin-token` Schnorr `OP_IDENTITY` admin Token), `functions/fabricWalletIdentity` (sealed `wallet.json` via `FABRIC_PASSWORD`). JSDoc on those helpers and `fabricChatText.chatActorIdOf` avoids TypeScript `?:`. `loadIdentityFromWalletFile` uses `loadWallet({ fromFile: true })` so leftover `FABRIC_SEED` cannot replace `wallet.json`. Semgrep/Opengrep exclude those home-env path helpers (Codacy ignores `nosemgrep`). Chaos fuzz prefers a live connection, lands signed frames before hostile AMP, and fails the playnet storm on unexpected peer errors. `npm run home-env` writes those files without printing secrets. +- **RC1 first-tier contract:** `tests/fabric.peer.adversarial.js` locks the four production-readiness green domains (wire integrity, gossip/peering bounds, scoring/bans, identity/wallets) at the hostile-mesh bar. Peer drops **undersize** (`< HEADER_SIZE`) and unparseable frames before parse — truncated NOISE chunks are not body-hash mismatches and do not hard-ban. +- **IdentityCrossSign lift:** `functions/identityCrossSign.js`, `fabricIdentitySchnorr.js`, `identityCrossSignVerify.js` are the canonical gossip strings and BIP340 helpers. HTTP site-login / device-link stay in `@fabric/http` and re-export these leaves. `signCrossSign` binds `localPubkey` to `fabricKey.pubkey` (raw HD `Key` and Passport `{ privateKeyHex, xpub }` round-trip). Canonical strings require compressed or x-only hex pubkeys (no `:` in fields); builders keep only a 64-hex nonce; `signCrossSign` rejects unknown `kind`. `fabricIdentityIdFromPubkeyHex` requires a compressed 66-hex public key. JSDoc on those helpers covers arities / `ok` unions. `createdAt` is unsigned. +- **Peer dial storms:** `_fillPeerSlots` waits `PEER_CANDIDATE_RETRY_MS` (60s) before redialing the same candidate; refused TCP no longer constructs NOISE (shared handshake EventEmitter was leaking listeners). Transient `ECONNREFUSED` is recognized from the error message when Node omits `error.code`. `_connect` skips in-flight `_outboundDialTargets` so overlapping `connectTo` / reconnect cannot open duplicate sockets before `connections[target]` is set. Dial keys are canonical `host:port` (IPv6 bracketed), so `pubkey@host:port` cannot open a second TCP/NOISE session beside `host:port`. Candidate retry timestamps are pruned on queue eviction / expiry and capped to `maxCandidates`; `candidateRetryMs` must be finite and `> 0`. `_disconnect` and inbound encrypt-end / banned-static paths tear down NOISE. Outbound `connect` setup is try/caught so a handshake throw cannot leave the dial target stuck. +- **Wallet writes:** `_writeWalletDocument` uses a pid + random tmp suffix so concurrent writes in one process cannot clobber the same `.tmp`. +- **Peer `contract:message`:** `messageHex` is a getter so hot paths that ignore the wire hex skip `toBuffer()`. - **Tests:** coverage for `--password=VALUE` (`functions/cliPasswordArgv`), GroupChange `signers.set` vote bind, wallet atomic write / touchWallet truncate, advertised vs verified peering suppress, Beacon persist-fail retain, `Environment.stop()` key wipe, and related lock/setup fail-closed paths. - **Codacy (PR #183):** timing-safe setup password confirm; setup TUI treats cancel as a non-string password (no `== null`); hallmark hex length uses a literal class (no `new RegExp`); tier `when` paths skip `__proto__`/`constructor`/`prototype`; BIP65 lock constants avoid 32-bit hex literals; Semgrep/Opengrep exclude path-hardened `fabricSetup` / `environment` (containment already tested). - **Shutdown / create:** `Environment.stop()` calls `lockWallet()` so seed/xprv/plaintext keys wipe even when the idle-lock handler was never installed; `touchWallet` creates a missing file with exclusive `wx` and does not truncate on `EEXIST`. @@ -27,7 +60,7 @@ Recent changes to Fabric Core. - **ARC resolveSpend network:** no silent `regtest` default — `resolveSpend` requires `overrides.network` or genesis `spendPolicy.network` (fail closed). - **ARC delivery sync filter:** `isSyncTrackedType` / `primitives.deliverySync` — MessageReceived/MessageReceipt 2PC applies to any sync-tracked CONTRACT_MESSAGE body type (not GroupChat-only). ACKs never open a new pending row; default excludes `GroupJournalRequest`. - **ARC delivery ACKs:** `MessageReceived` / `MessageReceipt` are first-class `CONTRACT_MESSAGE` entries (stored + relayable AMP bytes) but excluded from tip clock / `stateDigest`. Ingest folds them into the `contractmessagecommits` 2PC sidecar (`applyDeliveryAckEntry`). -- **ARC resolveSpend + withdrawals:** add `functions/contractSpend.js` (`normalizeArcGenesis`, `resolveSpend`, tip-bound `ContractWithdrawalRequest` / `Witness`, `prepareWithdrawalFromRequest`). Accumulate folds withdrawals and rejects stale tip bindings. See [`docs/ARC.md`](docs/ARC.md). +- **ARC resolveSpend + withdrawals:** add `functions/contractSpend.js` (`normalizeArcGenesis`, `resolveSpend`, tip-bound `ContractWithdrawalRequest` / `Witness`, `prepareWithdrawalFromRequest`). Accumulate folds withdrawals and rejects stale tip bindings. See [`docs/CONTRACTS.md`](docs/CONTRACTS.md). - **ARC accumulate authz (F1):** `contractMessageAccumulate.ingestMessageBuffer` rejects `GroupChange` and other signer mutations unless the AMP author is in genesis/tip signers or presents a verified `OP_CONTRACT_SIGN` Token. Unknown body types fail closed; genesis `primitives.messageTypes` is enforced; tip carries optional `bitcoinBlockHash` / height. - **Wire version:** keep **`VERSION_NUMBER = 0x01`** for the 0.1.x pre-release (aligned with C `FABRIC_MESSAGE_VERSION`). The 208-byte header + Lightning-style `preimage` is the V1 frame under `0x01` — not a protocol bump to `0x02`. Legacy paths (176-byte sketches, `preimage = SHA256(body)`, first-class types via `P2P_BASE_MESSAGE`) remain handled where needed and are **deprecated**. - **Supply-chain:** remove **`allow-git=all`** from core **`.npmrc`** and **`report:install`**. `@fabric/core` has no git deps; keep npm 12+ default `allow-git=none`. Hub / `@fabric/http` / apps that install Fabric from GitHub keep `allow-git=all` in *their* `.npmrc`. diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 560cd9b1c..6a7547962 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -34,10 +34,10 @@ Working from a **git checkout** (not the global package) is best when you are ch ## Repository layout | Path | Role | |------|------| -| `types/` | ES6 **classes** — `Actor`, `Peer`, `Service`, `Store`, `Message`, etc. CommonJS (`require`) throughout. | +| `types/` | ES6 **classes** — `Actor`, `Peer`, `Service`, `Store`, `Message`, etc. CommonJS (`require`) throughout. Cross-package homes: **[Types & services layering](#types--services-layering-suite)**. | | `services/` | Long-running **integrations** (Bitcoin RPC, Lightning stubs, ZMQ, …) built on `Service`. | | `contracts/` | Language snippets, traces, and tooling (e.g. type dependency graph). | -| `scripts/` | CLI entrypoints, doc helpers (`list-jsdoc-type-files.js`, `remove-legacy-types.sh`). | +| `scripts/` | CLI entrypoints, doc helpers (`list-jsdoc-type-files.js`, `remove-legacy-types.sh`). **`functions/fabricMessageCollection.js`** collects / replays AMP `FabricMessageCollection` documents (`functions/fabricMessageCollection.js`). | | `tests/` | Mocha suites; run with `npm test`. | | `settings/` | Default and environment-specific config; `settings/deprecations.js` holds legacy aliases. | | `assets/` | **Generated** browser bundles; rebuild with `npm run build` after type changes. | @@ -184,7 +184,7 @@ The Fabric CLI (`npm i -g @fabric/core`) is the reference shell. Discussion happ #### 3. Resources Fabric’s decentralized “web” is built around the **Resource** type: a committed agreement to deliver data (often with payment), frequently using HTLC-style flows on the chosen L2. -These are sometimes called **Application Resource Contracts (ARCs)** — the allowed storylines for a contract. Wire + tip + spend: [`docs/ARC.md`](docs/ARC.md). +These are sometimes called **Application Resource Contracts (ARCs)** — the allowed storylines for a contract. Wire + tip + spend: [`docs/CONTRACTS.md`](docs/CONTRACTS.md#application-resource-contracts-arc). ##### Example resource `resources/document.json`: @@ -358,3 +358,280 @@ Short list of documentation improvements (edit here as items land): - [ ] Commit and Publish [quickstart-guide]: QUICKSTART.md + +## Types & services layering (suite) +Canonical map of `types/` and `services/` across **`@fabric/core`**, **`@fabric/http`**, **`@fabric/hub`**, **`@fabric/passport`**, and **GoonCitizen**. Use this before adding a new class, subclassing Hub, or copying an identity/session helper. + +**This file is not a ship list.** Owner go-ahead is still required. Core class-count reduction lives in **[PRODUCTION_MARCH.md](docs/PRODUCTION_MARCH.md)**; this file is the *cross-package* home for overlapping APIs. + +| Field | Value | +|-------|--------| +| Last reviewed | 2026-08-15 | +| Trees scanned | `types/` + `services/` (Passport: `src/types/`, `src/background/`, `src/crypto/`, `src/utils/`) | +| Local checkouts | `~/fabric-clean`, `~/fabric-http`, `~/hub.fabric.pub`, `~/fabric-browser-extension`, `~/star-citizen-live` (`~/goon.vc` insight only) | +| Crypto lift | IdentityCrossSign + identity Schnorr + verify live in `@fabric/core/functions/*` (follow-up after [#183](https://github.com/FabricLabs/fabric/pull/183) merge). HTTP / Hub re-export those leaves; GoonCitizen keeps a browser-safe local copy for the dashboard bundle. | +| Core `npm test` | 2026-08-14: gossip-network `verifiedPubkey` expect fixed; remaining work is identity lift + OUTSTANDING queue (not this suite fail). | + +--- + +### How future agents should use this +1. **Name collision ≠ same type.** Same export name in two packages is usually a stub, a UI twin, or a legacy fork. Check the [collision table](#name-collisions) first. +2. **Prefer `functions/` re-exports over new classes.** Shared wire/session helpers already lift into `@fabric/http` (and Hub re-exports them). See [success pattern](#success-pattern). +3. **Compose, do not subclass Hub** for a new application. GoonCitizen `LiveRelay` is the live app; `StarCitizen extends Hub` is legacy. +4. After you add, delete, or re-home a type/service, update this file in the **same PR** ([refresh procedure](#refresh-procedure)). + +--- + +### Layering (who owns what) +```text +@fabric/core protocol primitives + chain integrations + types/ Actor, Service, Store, Peer, Message, Key, Identity, + Chain, Contract, Federation, Collection, Resource, Wallet, … + services/ Bitcoin, Lightning (primary); ZMQ, Redis, MQTT, Text, … + +@fabric/http HTTP / WebSocket / SPA / browser surface + types/ HTTPServer, SPA, Site, App, Compiler (webpack), Bridge, + Sandbox, Remote (HTTP subclass), Resource (HTTP verbs) + functions/ site-login, device-link, peering HTTP, chat normalize, + httpSharedMode, federation invite, oracle attestation + (no services/) + +@fabric/hub rendezvous operator (reference app) + services/hub.js orchestrator: Peer + HTTPServer + Bitcoin + Beacon + RPC + services/ Payjoin, Peering, Setup, Fabric (HTTP Remote), Email, Challenge + types/ thin SPA/Site/Compiler skins; LightningChannel; BridgeMessageCollection + +@fabric/passport browser extension wallet / identity chrome + src/types/ UI interfaces + incomplete Key fork (do not grow) + (no services/) Hub is a HTTP/WS client, not a class dependency + +GoonCitizen application (star-citizen-live) + services/LiveRelay what npm start runs (EventEmitter, own HTTP) + services/ FabricNetwork (Peer adapter), Chat/Group/Mission/Payout/Snapshot + types/ Store façade over core Store; Group/Mission domain types +``` + +**Require graph (do not invert):** +```text +core ◄── http ◄── hub + ◄── passport (Key/Message/site-login clients) + ◄── GoonCitizen (Peer, Store, http functions) +hub ◄── GoonCitizen (Bitcoin proxy, identityCluster re-export, Hub HTTP) +hub ◄── goon.vc GoonVC extends Hub (hosted edge; insight only) +``` + +Core never `require`s `@fabric/http` or `@fabric/hub`. + +--- + +### Success pattern +Shared *behavior* that is not a protocol class belongs in **`@fabric/http/functions/*`** (browser-safe when possible) or **`@fabric/core/functions/*`** (crypto / AMP / contract). Downstream packages **re-export** the same function object. + +Hub already guards this with `tests/liftedApis.exports.test.js` (allowlist, `httpSharedMode`, device-link Origin helpers, oracle attestation, pubkey, chat normalize). GoonCitizen does the same for several http helpers (`functions/httpSharedMode.js`, `fabricSiteLoginVerify.js`, `fabricDeviceLinkProtocol.js`, …). + +**Do this:** +```js +module.exports = require('@fabric/http/functions/httpSharedMode'); +``` + +**Do not do this:** copy the file into a second repo, or add a new `types/Foo.js` that collides with core `Foo`. + +--- + +### Name collisions + +| Name | Canonical | Other copy | Relation | Action | +|------|-----------|------------|----------|--------| +| **Identity** | core `types/identity.js` (Actor, BIP32) | http `types/identity.js` (12-line Key stub) | stub | delete/rename http stub; callers use core | +| **Key** | core `types/key.js` (Schnorr) | Passport `src/types/key.ts` (elliptic, stub `toBech32`) | incomplete fork | Passport should use core Key | +| **Wallet** | core `types/wallet.js` | http `types/wallet.js` (legacy bcoin) | duplicate | keep core; migrate/rename http | +| **Peer** | core `types/peer.js` (~5k LOC AMP/TCP) | http `types/peer.js` (PeerJS stub) | stub | delete http stub | +| **Swarm** | core `Peer.Swarm` | http `types/swarm.js` (documented no-op) | stub | delete http stub | +| **Channel** | core `types/channel.js` (payment) | http `types/channel.js` (`` UI) | name only | rename http element | +| **Compiler** | core `types/compiler.js` (JS/minsc AST) | http `types/compiler.js` (webpack SPA); Hub/goon subclass http | different jobs | keep both; document as HTTPCompiler vs contract compiler | +| **Router** | core `types/router.js` (chat `!commands`) | http `types/router.js` (path-to-regexp) | name only | rename if touching | +| **Remote** | core `types/remote.js` (base WS) | http `types/remote.js` **extends** core | healthy subclass | keep | +| **Resource** | core `types/resource.js` (Store) | http `types/resource.js` **extends** Fabric.Resource | healthy subclass | keep | +| **Component** | core `types/component.js` (jade stub, broken `super`) | http `types/component.js` (real custom element) | core dead | delete/ignore core stub; UI lives in http | +| **Hub** | **hub** `services/hub.js` (~14k LOC Service) | http `types/hub.js` (Oracle + Express toy) | **naming trap** | deprecate/rename http (`LegacyHttpHub`); never merge | +| **Queue** | core `types/queue.js` | Hub `types/queue.js` (richer, unused at runtime) | stale fork | Hub runtime uses core Worker; delete or re-export core | +| **Worker** | core `types/worker.js` | Hub `types/worker.js` | stale fork | hub.js requires **core** Worker | +| **Challenge** | core `types/challenge.js` (State) | Hub `services/challenge.js` (storage-contract cadence) | name only | leave; different jobs | +| **Store** | core `types/store.js` (Actor, async paths) | GoonCitizen `types/Store.js` (**composes** core) | healthy façade | do not merge into core | +| **SPA / Site / Compiler** | http types | Hub + goon.vc thin `_renderWith` skins | healthy extend | skins stay in apps | +| **Fabric** | core `types/fabric.js` (facade Service) | Hub `services/fabric.js` (HTTP Remote sync) | name only | leave | + +--- + +### Per-package inventory +#### `@fabric/core` — `types/` (protocol spine) +**Keep as the public spine** (aligns with PRODUCTION_MARCH + `types/fabric.js` statics): `Actor`, `Service` (+ `FabricShell`), `Store`, `Peer` (+ `Peer.Swarm`), `Message`, `Key`, `Identity`, `Chain`, `Block`, `Contract`, `Federation`, `Collection`, `Resource`, `State`, `Machine`, `Program`, `Circuit`, `Wallet`, `Worker`, `Remote`, `Hash256`, `Entity` (+ `Transition`), `Tree`, `Beacon`, `Filesystem`. + +**Services (keep):** `services/bitcoin.js`, `services/lightning.js`. Others (`zmq`, `redis`, `mqtt`, `text`/`txt`, `exchange`, `local`, `turntable`) are integrations — do not reimplement in Hub or GoonCitizen. + +**Message collections:** `functions/fabricMessageCollection.js` is the share/replay helper for ordered AMP hex (journals, Discord packs, catch-up). GoonCitizen re-exports it; do not fork a second JSON-as-canonical log. Hub `messages/*.json` stays an activity log. + +**Core stubs / low-value leaves** (do not copy downstream): `types/component.js`, `types/network.js`, `types/observer.js`, `types/transaction.js`. Class-count work: PRODUCTION_MARCH (`Scribe` is already a deprecation alias). + +#### `@fabric/http` — `types/` (HTTP/SPA only) + +**Stay in http:** `server` (`HTTPServer`), `spa`, `site`, `app`, `compiler` (webpack), `bridge`, `sandbox`, `browser`, `client`, `element`, `avatar`, `JSONCall`, `distributedExecutionHttp`, `resource` (HTTP verbs), `remote` (HTTP client). + +**Delete or stop exporting when touching:** `identity.js`, `peer.js`, `swarm.js` stubs. **Rename if touching:** `channel.js` (UI), `hub.js` (legacy Oracle hub). + +Package main (`types/web.js`) exports `Server`, `Client`, `SPA`, `Avatar` — not the stubs. + +#### `@fabric/hub` — operator + +| File | Verdict | +|------|---------| +| `services/hub.js` | Keep as orchestrator; carve more *Hub-only* services (Payjoin/Peering/Setup pattern). Do not fold into core or http. | +| `services/payjoin.js` | Hub-only BIP77/78 | +| `services/peering.js` | Hub HTTP + OracleAttestation (uses http functions) | +| `services/setup.js` | Admin token bootstrap | +| `services/fabric.js` | HTTP Remote sync — keep name locally, do not confuse with core `Fabric` | +| `services/email.js`, `challenge.js` | Hub-only | +| `types/spa.js`, `site.js`, `compiler.js` | Thin skins over http — keep | +| `types/lightningChannel.js` | Extends core Channel — keep until a second LN UI needs it in core | +| `types/bridgeMessageCollection.js` | Hub WS message map — keep | +| `types/queue.js`, `worker.js` | Stale forks — delete or re-export core | + +#### `@fabric/passport` — extension + +No top-level `types/` or `services/`. Treat as a **client** of core Key/Message and http site-login / device-link. + +| Path | Verdict | +|------|---------| +| `src/types/key.ts` | **Do not grow.** Incomplete elliptic Key; `toBech32()` is a stub. Target: `@fabric/core/types/key`. | +| `src/types/identity.ts` | UI `IIdentity` / chain allowlist — not core Identity. Keep local. | +| `src/background/encryptedDatastore.ts` | chrome.storage AES-GCM — parallel to `Store.openEncrypted`, not a merge candidate. | +| `src/utils/identityCrossSign.ts` | **Re-export** of `@fabric/core/functions/identityCrossSign`. | +| `src/utils/fabricSiteLoginSign.ts`, `fabricDeviceLinkSign.ts` | Clients of http — keep as clients. | +| `src/fabric/bitcoinService.ts` | Hub HTTP Bitcoin client — keep. | + +#### GoonCitizen — application + +| File | Verdict | +|------|---------| +| `services/LiveRelay.js` | **What runs.** EventEmitter + REST/dashboard. Do not extend Hub. | +| `services/FabricNetwork.js` | Constructs core `Peer`; app `CONTRACT_MESSAGE` conveniences. Adapter, not a second Peer. | +| `services/ChatManager.js` / `GroupManager.js` / `MissionManager.js` / `PayoutManager.js` / `SnapshotManager.js` | Application. Share wire helpers only (chat normalize, federation invite JSON). | +| `types/Store.js` | Composes core Store; sync `get/put/all/del` on `/collections/`. Correct. Extract a generic façade only if a second consumer appears. | +| `types/Group.js`, `Mission.js`, `MissionApplication.js` | Domain; Group wraps core `Federation`. | +| `services/StarCitizen.js` | **Legacy** `extends Hub`. mocha/history only. Do not use as a template. | +| `types/StarCitizenAPI.js` | Legacy stub. Not LiveRelay. | + +**goon.vc (insight):** `GoonSPA` / `GoonSite` / `HTMLCompiler` mirror Hub skins over http. `GoonVC extends Hub` is the hosted-edge pattern — not a model for new desktop apps. + +--- + +### Common API slices (ranked) +Payoff vs risk for *sharing* an API. Implement only with owner go-ahead; small PRs; re-export, don’t copy. + +| Rank | Slice | Home | Payoff | Risk | Notes | +|------|-------|------|--------|------|-------| +| 1 | **IdentityCrossSign** (+ revoke) canonical strings + Schnorr verify | **core** `functions/identityCrossSign` + `identityCrossSignVerify` + `fabricIdentitySchnorr` | High | Low | **Implemented locally 2026-08-14; not in [#183](https://github.com/FabricLabs/fabric/pull/183).** Downstream re-exports already exist. Pin/link core before those CI jobs. Site-login HTTP stays in http. | +| 2 | **Site-login + device-link** | **http** (already) | High | Low–med | GC/Passport/Hub must wrap http; do not fork verify. Possession-proof redeem is the security outstanding, not a new type. | +| 3 | **Passport Key → core Key** | **core** Key | High (crypto) | Med | Drop elliptic class; keep `IKey` UI state in Passport. | +| 4 | **Retire Hub Queue/Worker forks** | **core** | Med | Low | Runtime already uses core Worker. | +| 5 | **httpSharedMode** | **http** (already) | Med | Low | Hub + GC re-export; keep the test that they are the same function. | +| 6 | **Rename http `types/hub.js`** | **http** | Med (docs trap) | Low | Product Hub is `@fabric/hub`. | +| 7 | **CollectionStore façade** | optional core helper | Med if 2nd consumer | Med | Today only GoonCitizen `types/Store.js`. | +| 8 | **AppPeerSession** (identity → Peer, message log, peering offer) | core or http helper | Med | Med–high | Extract from FabricNetwork *without* GC publish methods. | +| 9 | **Document inventory item / publish envelope** | core (`publishedDocumentEnvelope`) + http shapes | Med | Med | Hub document market vs GC `localDocuments` / offers stay separate catalogs. | +| 10 | **SPA Compiler factory** | http | Low | Low | Hub/goon skins stay in apps. | +| 11 | **Carve `services/hub.js`** | hub services | High long-term | **High** | Continue Payjoin/Peering/Setup; no big-bang. | +| 12 | **LightningChannel → core** | hub until 2nd consumer | Low | Low | | + +--- + +### Do not consolidate + +These look like duplicates and are not: + +| Temptation | Why not | +|------------|---------| +| Merge **LiveRelay** into Hub / subclass Hub for GoonCitizen | Different products. LiveRelay is the live entry. `StarCitizen extends Hub` is legacy. | +| Collapse **FabricNetwork** into **Peer** | Adapter + app message types. Peer stays protocol. | +| Put **ChatManager** in core/Hub | Discord keys, local tags, bridged rails are app-specific. Share `fabricChatNormalize` only. | +| Merge GoonCitizen **Store** into core **Store** | Sync collection façade vs async path Store. Composition is the point. | +| Merge http **Compiler** into core **Compiler** | Webpack/HTML vs minsc/JS AST. | +| Merge http **Channel** into core **Channel** | UI element vs payment channel. | +| Fold Hub **Payjoin / Bitcoin UI / Beacon** into core | Operator rendezvous. Core already has `services/bitcoin` + `types/beacon`. | +| New `@fabric/app-peer` package | Extra pin surface. Prefer a function module in core/http first. | +| Invert requires (core → http) | Breaks the library/CLI story. | + +--- + +### Healthy subclass edges (copy this pattern) + +```text +http.Resource → core Resource (Fabric.Resource) +http.Remote → core Remote +http.JSONCall → core Message +Hub.LightningChannel → core Channel +Hub.SPA/Site/Compiler → http SPA/Site/Compiler +GoonCitizen.Store composes core Store (does not extend) +GoonCitizen.FabricNetwork constructs core Peer +GoonCitizen.Group uses core Federation +GoonCitizen.Mission extends core Entity +``` + +If a new type does not fit one of: **extend the layer below**, **compose it**, or **stay application-only** — stop and update this file instead of inventing a parallel class. + +--- + +### Refresh procedure + +When types/services change in any of the five trees, run this in the same change: + +1. `rg '^(class |module\\.exports)' types services` (Passport: `src/types` + `src/background`) in each checkout. +2. Confirm `require('@fabric/…')` still matches the [require graph](#layering-who-owns-what). New core `require('@fabric/http')` is a bug. +3. Update the [collision table](#name-collisions) if you add or delete a same-named export. +4. If you lift a helper, add a Hub/GoonCitizen re-export **and** a same-function assertion (see `tests/liftedApis.exports.test.js`). +5. Bump **Last reviewed** at the top. Do not fork this document into Hub or GoonCitizen — point at it. + +**Sibling paths on this machine:** + +| Package | Checkout | +|---------|----------| +| `@fabric/core` | `/Users/eric/fabric-clean` | +| `@fabric/http` | `/Users/eric/fabric-http` | +| `@fabric/hub` | `/Users/eric/hub.fabric.pub` | +| `@fabric/passport` | `/Users/eric/fabric-browser-extension` | +| GoonCitizen | `/Users/eric/star-citizen-live` | +| goon.vc (insight) | `/Users/eric/goon.vc` | + +--- + +### Remaining work +Not a ship list. Owner go-ahead still required. Detail for Core also lives in [OUTSTANDING.md](docs/OUTSTANDING.md). + +#### Core [#183](https://github.com/FabricLabs/fabric/pull/183) (edit existing PR files only) +- **Do not add** identity leaves, this file, or `package.json` smoke `require()`s for those leaves (CodeRabbit ~150-file cap; smoke would fail without the files). +- Existing OUTSTANDING items: type-tree keep/remove, `messageHex` laziness, `contractId` → `contractIdentifier`, genesis journal cap, blinded-execution scaffold, third-party review of peer/HTLC. + +#### Follow-up Core PR (new files) +Identity leaves (`functions/identityCrossSign.js`, `fabricIdentitySchnorr.js`, `identityCrossSignVerify.js`) and tests in `tests/fabric.identity.js` are already on #185. Pin or `npm link` core in http / Hub / Passport / GoonCitizen. Sidecar `.d.ts` files were dropped for the GitHub 150-file review cap. + +#### Pending tests (not failures) +- 3× Lightning `_makeRPCRequest` (JSON-RPC line-delimited / error / timeout) — skipped unit stubs. +- 1× `dials configured peers and emits CONTRACT_PUBLISH` (`tests/fabric.hub.mesh.integration.js` — `playnet contract publish live`) — skipped unless a live mnemonic is configured. + +#### Downstream (already edited in those repos) +Keep the re-exports. Remaining ranked slices: site-login possession-proof (http/Hub), Passport Key class, Hub Queue/Worker delete, CollectionStore only if a second consumer appears. + +--- + +### Related docs + +| Doc | Role | +|-----|------| +| [OUTSTANDING.md](docs/OUTSTANDING.md) | Core security queue + suite status | +| [PRODUCTION_MARCH.md](docs/PRODUCTION_MARCH.md) | Core *class count* keep/fold/remove | +| [DEVELOPERS.md](DEVELOPERS.md) | Core `types/` vs `services/` layout, Store, Fabric facade | +| [AGENTS.md](AGENTS.md) | Agent lifecycle (`Service` start/stop) | +| Hub `docs/PRODUCTION_MARCH.md` | Copy of core march + Hub RC scope | +| Hub `tests/liftedApis.exports.test.js` | Re-export contract for shared functions | +| GoonCitizen `AGENTS.md` §3–§4 | What LiveRelay actually runs | +| GoonCitizen `DECISIONS.md` D-009/D-010/D-011/D-013 | Why Fabric conventions, Peer, site-login, device-link exist | diff --git a/IDENTITY.md b/IDENTITY.md index 7f1021e97..cb704785b 100644 --- a/IDENTITY.md +++ b/IDENTITY.md @@ -11,10 +11,14 @@ The Fabric Identity Protocol is a decentralized identifier for Fabric-speaking n 3. `m = sha256(derived_pubkey)` 4. `id = bech32m("id", m) // "id" taken as ASCII bytes` -**Coin types:** Fabric protocol identity uses SLIP-44–style coin types **`7777` (mainnet)** and -**`7778` (everything else)**. Helpers: `fabricCoinTypeForNetwork`, `fabricIdentityDerivationPath` -in `constants.js`. Default development / Peer identity (when `network` is omitted or non-mainnet) -stays on **7778** so playnet fixtures remain stable. +**Coin types:** Fabric protocol identity uses SLIP-44–style coin types **`7777` (Bitcoin mainnet +network name)** and **`7778` (any other network name, or when `network` is omitted)**. +`resolveFabricIdentityCoinType` still honours an **explicit integer** coin type when the +caller passes one (validated); it does not rewrite that integer to 7778. Helpers: +`fabricCoinTypeForNetwork`, `resolveFabricIdentityCoinType` (network name or explicit integer), +`fabricIdentityAccountPath` (`m/44'/{coin}'/account'`), `fabricIdentityDerivationPath` +(`account path` + `/0/index`) in `constants.js`. Default development / Peer identity +(when `network` is omitted or a non-mainnet name) stays on **7778** so playnet fixtures remain stable. **Funds vs identity:** Bitcoin receive / change keys use BIP44 coin type **`0`** from the **same master** (`m/44'/0'/account'/change/index`). Never reuse the Fabric `7777`/`7778` identity diff --git a/MESSAGES.md b/MESSAGES.md index 5be6829f7..98254e794 100644 --- a/MESSAGES.md +++ b/MESSAGES.md @@ -1,56 +1,126 @@ # Fabric Messages +Canonical opcode catalog for `@fabric/core`. The decode-order table below is generated from `types/message.js` `WIRE_TYPE_DECODE_ORDER` (`npm run docs:message-types`). Purpose notes follow that table. -## Original Table +**V1 body rule:** AMP bodies are **typed field layouts** (C-like memory), not JSON. +See [`docs/MESSAGE_BODY.md`](docs/MESSAGE_BODY.md). JSON bridging is `@fabric/http`. -``` -TYPE|NAME -====|==== -0000| -0001|GENERIC_MESSAGE -0002|PEER_ANNOUNCE -0003|PEER_PING -0004|PEER_PONG -0005| -0006| -0007| -0008| -0009| -000a| -000b| -000c| -000d| -000e| -000f| -0010| -001f| # 31 -0020| # 32 -0021|BITCOIN_BLOCK_HASH -0022|BITCOIN_TRANSACTION_HASH -002f| # 47 -0030| # 48 -0031|FABRIC_STATE -0032|FABRIC_DOCUMENT -0033|FABRIC_DELTA -``` +## V1 freeze -## Expanded Catalog +Wire version stays `0x01` (208-byte header). Consolidation means: -This file enumerates known message types used in `@fabric/core` as of this repo state. +1. **One decode name per opcode.** Encode aliases are allowed; they must round-trip to that decode label. +2. **Typed field bodies** on mesh-originated types. JSON belongs at `@fabric/http`. Exceptions: chat/alias UTF-8, `P2P_RELAY` raw inner AMP, `JSON_CALL` at the Hub edge. +3. **Application events stay inside `CONTRACT_MESSAGE`.** Do not give MissionBroadcast, GroupChat, IdentityCrossSign, etc. their own outer opcodes. +4. **Do not pack Fabric types into a gapless `0x01, 0x02, …` sequence** — that would break playnet. -**V1 body rule:** AMP bodies are **typed field layouts** (C-like memory), not JSON. -See [`docs/MESSAGE_BODY.md`](docs/MESSAGE_BODY.md). JSON bridging is `@fabric/http`. +Lightning AMP types live in `0x2000–0x2013` (`POLICY.md`). Fabric keeps the low numbers (`P2P_PING` `0x12`, `P2P_INSTRUCTION` `0x20`, `P2P_GENERIC` `0x80`, …). Duplicate opcodes fail at `Message` module load. + +## Dual carriers (do not merge numbers) + +| Keep | Transitional / other | Rule | +|---|---|---| +| `P2P_CHAT_MESSAGE` `0x68` | `CHAT_MESSAGE` `0x67` | Originate mesh shoutbox on `0x68` only. `0x67` is Hub cache. | +| `P2P_DOCUMENT_PUBLISH` `0x5a` | `DOCUMENT_PUBLISH` `998` | Two jobs (mesh pricing vs document descriptor). Keep both opcodes. | +| `P2P_INVENTORY_REQUEST` / `RESPONSE` | JSON `INVENTORY_*` on `P2P_BASE_MESSAGE` / `GENERIC_MESSAGE` | Originate new inventory on the first-class opcodes. | +| `CONTRACT_PUBLISH` / `CONTRACT_MESSAGE` / `CONTRACT_PROPOSAL` | `P2P_CONTRACT_*` encode aliases | Decode names stay `CONTRACT_*`. Correct. | + +## Not registered (by design) + +| Name | Notes | +|---|---| +| `HEARTBEAT` | POLICY / examples only. Constructor logs if named. Do not register. | +| `JSONBlob` `15104` | Hub registry + some tests use `GENERIC_MESSAGE + 1`. Core `Message.types` has no `JSON_BLOB`. Do not add until core owns it. | +| `P2P_STATE_ANNOUNCE` | Generic JSON body `type`, not an opcode. Handled in `Peer._handleGenericMessage`. | + +## Decode-order freeze + + + +This catalog: **76** decode names, **76** unique opcodes, **13** with a registered field schema, **20** Lightning AMP rows. Regenerated by `npm run docs:message-types`. Do not hand-edit the table. + +| Decode name | Dec | Hex | Encode aliases | Field schema | Family | +|---|---:|---|---|---|---| +| `BITCOIN_BLOCK` | 21000 | `0x5208` | `BitcoinBlock` | no | bitcoin | +| `BITCOIN_BLOCK_HASH` | 21100 | `0x526c` | `BitcoinBlockHash` | no | bitcoin | +| `BITCOIN_TRANSACTION` | 22000 | `0x55f0` | `BitcoinTransaction` | no | bitcoin | +| `BITCOIN_TRANSACTION_HASH` | 22100 | `0x5654` | `BitcoinTransactionHash` | no | bitcoin | +| `LOG_MESSAGE` | 3235156080 | `0xc0d49070` | `FabricLogMessage`, `FabricServiceLogMessage`, `GenericLogMessage`, `LogMessage` | no | legacy | +| `GENERIC_LIST` | 3235170158 | `0xc0d4c76e` | `GenericList`, `GenericQueue`, `GenericTransferQueue` | no | legacy | +| `GENERIC_MESSAGE` | 15103 | `0x3aff` | `GenericMessage` | no | hub | +| `SIDECHAIN_STATE_PATCH` | 997 | `0x03e5` | `SidechainStatePatch` | yes | contracts | +| `DOCUMENT_PUBLISH` | 998 | `0x03e6` | `DocumentPublish` | no | documents | +| `DOCUMENT_REQUEST` | 999 | `0x03e7` | `DocumentRequest` | no | documents | +| `BLOCK_CANDIDATE` | 3 | `0x0003` | `BlockCandidate` | no | legacy | +| `P2P_PING` | 18 | `0x0012` | `Ping` | yes | mesh | +| `P2P_PONG` | 19 | `0x0013` | `Pong` | yes | mesh | +| `P2P_GENERIC` | 128 | `0x0080` | `Generic` | no | legacy | +| `P2P_CHAIN_SYNC_REQUEST` | 85 | `0x0055` | `ChainSyncRequest` | no | mesh | +| `P2P_FLUSH_CHAIN` | 86 | `0x0056` | `FlushChain` | no | mesh | +| `P2P_INVENTORY_REQUEST` | 87 | `0x0057` | `InventoryRequest` | no | documents | +| `P2P_INVENTORY_RESPONSE` | 88 | `0x0058` | `InventoryResponse` | no | documents | +| `P2P_FILE_SEND` | 89 | `0x0059` | `FileSend` | no | documents | +| `P2P_DOCUMENT_PUBLISH` | 90 | `0x005a` | `DocumentPricingPublish` | no | documents | +| `P2P_PEER_ALIAS` | 91 | `0x005b` | `PeerAlias` | no | mesh | +| `P2P_PEER_ANNOUNCE` | 92 | `0x005c` | `PeerAnnounce` | yes | mesh | +| `P2P_PEER_GOSSIP` | 97 | `0x0061` | `PeerGossip` | yes | mesh | +| `P2P_PEERING_OFFER` | 98 | `0x0062` | `PeeringOffer` | yes | mesh | +| `P2P_SESSION_OFFER` | 93 | `0x005d` | `SessionOffer` | no | session | +| `P2P_SESSION_OPEN` | 94 | `0x005e` | `SessionOpen` | no | session | +| `P2P_SESSION_ACK` | 16896 | `0x4200` | — | no | session | +| `P2P_MUSIG_START` | 16928 | `0x4220` | `MusigStart` | yes | session | +| `P2P_MUSIG_ACCEPT` | 16929 | `0x4221` | `MusigAccept` | yes | session | +| `P2P_MUSIG_RECEIVE_COUNTER` | 16930 | `0x4222` | `MusigReceiveCounter` | yes | session | +| `P2P_MUSIG_SEND_PROPOSAL` | 16931 | `0x4223` | `MusigSendProposal` | yes | session | +| `P2P_MUSIG_REPLY_TO_PROPOSAL` | 16932 | `0x4224` | `MusigReplyToProposal` | yes | session | +| `P2P_MUSIG_ACCEPT_PROPOSAL` | 16933 | `0x4225` | `MusigAcceptProposal` | yes | session | +| `CONTRACT_PUBLISH` | 95 | `0x005f` | `P2P_CONTRACT_PUBLISH` | no | contracts | +| `CONTRACT_MESSAGE` | 96 | `0x0060` | `P2P_CONTRACT_MESSAGE` | no | contracts | +| `P2P_IDENT_REQUEST` | 1 | `0x0001` | `IdentityRequest` | no | session | +| `P2P_IDENT_RESPONSE` | 17 | `0x0011` | `IdentityResponse` | no | session | +| `P2P_BASE_MESSAGE` | 49 | `0x0031` | `PeerMessage` | no | legacy | +| `P2P_STATE_ROOT` | 48 | `0x0030` | `StateRoot` | no | legacy | +| `P2P_STATE_COMMITTMENT` | 50 | `0x0032` | `StateCommitment` | no | legacy | +| `P2P_STATE_CHANGE` | 51 | `0x0033` | `StateChange` | no | legacy | +| `P2P_STATE_REQUEST` | 41 | `0x0029` | `StateRequest` | no | legacy | +| `P2P_TRANSACTION` | 57 | `0x0039` | `Transaction` | no | legacy | +| `P2P_CALL` | 66 | `0x0042` | `Call` | no | legacy | +| `P2P_RELAY` | 67 | `0x0043` | — | no | mesh | +| `P2P_MESSAGE_RECEIPT` | 68 | `0x0044` | — | no | hub | +| `P2P_FORWARD` | 69 | `0x0045` | — | yes | mesh | +| `PEER_CANDIDATE` | 9 | `0x0009` | `PeerCandidate` | no | legacy | +| `SESSION_START` | 2 | `0x0002` | `StartSession` | no | legacy | +| `CHAT_MESSAGE` | 103 | `0x0067` | `ChatMessage` | no | hub | +| `P2P_CHAT_MESSAGE` | 104 | `0x0068` | — | no | mesh | +| `JSON_CALL` | 16000 | `0x3e80` | `JSONCall` | no | hub | +| `JSON_PATCH` | 1024 | `0x0400` | `JSONPatch` | no | hub | +| `CONTRACT_PROPOSAL` | 138 | `0x008a` | `ContractProposal`, `P2P_CONTRACT_PROPOSAL` | no | contracts | +| `P2P_START_CHAIN` | 33 | `0x0021` | `StartChain` | no | legacy | +| `P2P_INSTRUCTION` | 32 | `0x0020` | `PeerInstruction` | no | session | +| `LIGHTNING_INIT` | 8192 | `0x2000` | `LightningInit` | no | lightning | +| `LIGHTNING_ERROR` | 8193 | `0x2001` | `LightningError` | no | lightning | +| `LIGHTNING_OPEN_CHANNEL` | 8194 | `0x2002` | `OpenChannel` | no | lightning | +| `LIGHTNING_ACCEPT_CHANNEL` | 8195 | `0x2003` | `AcceptChannel` | no | lightning | +| `LIGHTNING_FUNDING_CREATED` | 8196 | `0x2004` | `FundingCreated` | no | lightning | +| `LIGHTNING_FUNDING_SIGNED` | 8197 | `0x2005` | `FundingSigned` | no | lightning | +| `LIGHTNING_CHANNEL_READY` | 8198 | `0x2006` | `ChannelReady` | no | lightning | +| `LIGHTNING_SHUTDOWN` | 8199 | `0x2007` | `Shutdown` | no | lightning | +| `LIGHTNING_CLOSING_SIGNED` | 8200 | `0x2008` | `ClosingSigned` | no | lightning | +| `LIGHTNING_UPDATE_ADD_HTLC` | 8201 | `0x2009` | `UpdateAddHTLC` | no | lightning | +| `LIGHTNING_UPDATE_FULFILL_HTLC` | 8202 | `0x200a` | `UpdateFulfillHTLC` | no | lightning | +| `LIGHTNING_UPDATE_FAIL_HTLC` | 8203 | `0x200b` | `UpdateFailHTLC` | no | lightning | +| `LIGHTNING_COMMITMENT_SIGNED` | 8204 | `0x200c` | `CommitmentSigned` | no | lightning | +| `LIGHTNING_REVOKE_AND_ACK` | 8205 | `0x200d` | `RevokeAndAck` | no | lightning | +| `LIGHTNING_CHANNEL_ANNOUNCEMENT` | 8206 | `0x200e` | `ChannelAnnouncement` | no | lightning | +| `LIGHTNING_NODE_ANNOUNCEMENT` | 8207 | `0x200f` | `NodeAnnouncement` | no | lightning | +| `LIGHTNING_CHANNEL_UPDATE` | 8208 | `0x2010` | `ChannelUpdate` | no | lightning | +| `LIGHTNING_WARNING` | 8209 | `0x2011` | `LightningWarning` | no | lightning | +| `LIGHTNING_PING` | 8210 | `0x2012` | `LightningPing` | no | lightning | +| `LIGHTNING_PONG` | 8211 | `0x2013` | `LightningPong` | no | lightning | -Scope: -- **Wire types** are the canonical opcode-backed AMP message types decoded by `types/message.js`. -- **Generic payload types** are transitional `GenericMessage` / legacy UTF-8 JSON body `type` values routed by peers/services until field schemas are registered. + -Primary sources: -- `constants.js` -- `types/message.js` (`WIRE_TYPE_DECODE_ORDER`) -- `types/peer.js` (`_handleGenericMessage` routing) -- `DEVELOPERS.md` (receipt semantics) +## 1) Canonical Wire Types (purpose notes) -## 1) Canonical Wire Types (Opcode-Backed) | Name | Code (dec) | Code (hex) | Purpose | |---|---:|---:|---| @@ -58,8 +128,8 @@ Primary sources: | `BITCOIN_BLOCK_HASH` | 21100 | `0x526c` | Announces/propagates a Bitcoin block hash. | | `BITCOIN_TRANSACTION` | 22000 | `0x55f0` | Carries a Bitcoin transaction payload. | | `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. | +| `LOG_MESSAGE` | 3235156080 | `0xc0d49070` | Debug/log transport message for diagnostics. | +| `GENERIC_LIST` | 3235170158 | `0xc0d4c76e` | Generic list/queue-style payload container. | | `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. | @@ -79,12 +149,20 @@ Primary sources: | `P2P_PEERING_OFFER` | 98 | `0x0062` | First-class peering-capacity offer. Relayed bit-identical; candidate queue + hop/rate limits. | | `P2P_SESSION_OFFER` | 93 | `0x005d` | Initiates peer session handshake. | | `P2P_SESSION_OPEN` | 94 | `0x005e` | Accepts/completes peer session handshake. | +| `P2P_SESSION_ACK` | 16896 | `0x4200` | Session handshake ack (not mesh-flooded). | +| `P2P_MUSIG_START` | 16928 | `0x4220` | Directed BIP-327 session open: `sessionId` + `msg` + packed pubkeys + initiator `pubnonce`. | +| `P2P_MUSIG_ACCEPT` | 16929 | `0x4221` | First co-signer `pubnonce` (n=2 complete after this). | +| `P2P_MUSIG_RECEIVE_COUNTER` | 16930 | `0x4222` | Remaining signers’ `pubnonce` (n>2). | +| `P2P_MUSIG_SEND_PROPOSAL` | 16931 | `0x4223` | Coordinator publishes recomputed `aggnonce` (must match local `nonceAgg`). | +| `P2P_MUSIG_REPLY_TO_PROPOSAL` | 16932 | `0x4224` | 32-byte partial signature. | +| `P2P_MUSIG_ACCEPT_PROPOSAL` | 16933 | `0x4225` | 64-byte aggregated BIP-340 signature. | | `CONTRACT_PUBLISH` | 95 | `0x005f` | Publishes a contract definition; registers it under a deterministic `Actor` id (the contract **namespace**). Emits `contract:publish` and relays. | | `CONTRACT_MESSAGE` | 96 | `0x0060` | Namespaced contract event. Body MUST carry `contract: `; dispatch routes by that namespace (emits `contract:message`). State-patch `ops` apply only to locally registered contracts; unknown ids are app-consumed, not fatal. | | `P2P_IDENT_REQUEST` | 1 | `0x0001` | Requests identity material from counterparty. | | `P2P_IDENT_RESPONSE` | 17 | `0x0011` | Returns identity material to requester. | | `P2P_BASE_MESSAGE` | 49 | `0x0031` | Baseline/default Fabric wire message type. | | `P2P_STATE_ROOT` | 48 | `0x0030` | Announces state root/snapshot anchor. | +| `P2P_STATE_COMMITTMENT` | 50 | `0x0032` | State commitment (legacy spelling of the wire name). | | `P2P_STATE_CHANGE` | 51 | `0x0033` | Announces incremental state change/delta. | | `P2P_STATE_REQUEST` | 41 | `0x0029` | Requests state data from a peer. | | `P2P_TRANSACTION` | 57 | `0x0039` | Generic transaction-style message in P2P flow. | @@ -100,26 +178,27 @@ Primary sources: | `JSON_PATCH` | 1024 | `0x0400` | RFC6902-style JSON patch operation payload. | | `CONTRACT_PROPOSAL` | 138 | `0x008a` | Contract proposal (batched messages + Merkle + patch context). | | `P2P_START_CHAIN` | 33 | `0x0021` | Starts/announces chain bootstrap flow. | -| `LIGHTNING_WARNING` | 1 | `0x0001` | Encoding alias for opcode `0x0001` (canonical decode label is `P2P_IDENT_REQUEST` via first-match in `types/message.js`). | -| `LIGHTNING_INIT` | 16 | `0x0010` | Lightning initialization handshake message. | -| `LIGHTNING_ERROR` | 17 | `0x0011` | Lightning protocol error message. | -| `LIGHTNING_PING` | 18 | `0x0012` | Encoding alias for opcode `0x0012` (canonical decode label is `P2P_PING`). | -| `LIGHTNING_PONG` | 19 | `0x0013` | Encoding alias for opcode `0x0013` (canonical decode label is `P2P_PONG`). | -| `LIGHTNING_OPEN_CHANNEL` | 32 | `0x0020` | Proposes opening a Lightning channel. | -| `LIGHTNING_ACCEPT_CHANNEL` | 33 | `0x0021` | Accepts channel open parameters. | -| `LIGHTNING_FUNDING_CREATED` | 34 | `0x0022` | Announces channel funding transaction creation. | -| `LIGHTNING_FUNDING_SIGNED` | 35 | `0x0023` | Returns/acknowledges funding signatures. | -| `LIGHTNING_CHANNEL_READY` | 36 | `0x0024` | Declares channel ready for routing/HTLC operations. | -| `LIGHTNING_SHUTDOWN` | 38 | `0x0026` | Initiates cooperative channel shutdown. | -| `LIGHTNING_CLOSING_SIGNED` | 39 | `0x0027` | Exchanges closing signatures. | -| `LIGHTNING_UPDATE_ADD_HTLC` | 128 | `0x0080` | Adds an HTLC to commitment state. | -| `LIGHTNING_UPDATE_FULFILL_HTLC` | 130 | `0x0082` | Fulfills HTLC with preimage/proof. | -| `LIGHTNING_UPDATE_FAIL_HTLC` | 131 | `0x0083` | Fails/cancels HTLC. | -| `LIGHTNING_COMMITMENT_SIGNED` | 132 | `0x0084` | Commitment signature exchange in channel updates. | -| `LIGHTNING_REVOKE_AND_ACK` | 133 | `0x0085` | Revocation + acknowledgment in Lightning state machine. | -| `LIGHTNING_CHANNEL_ANNOUNCEMENT` | 256 | `0x0100` | Gossip announcement for channel existence. | -| `LIGHTNING_NODE_ANNOUNCEMENT` | 257 | `0x0101` | Gossip announcement for node metadata. | -| `LIGHTNING_CHANNEL_UPDATE` | 258 | `0x0102` | Gossip update for channel routing policy/state. | +| `P2P_INSTRUCTION` | 32 | `0x0020` | Peer instruction frame. | +| `LIGHTNING_INIT` | 8192 | `0x2000` | Lightning initialization (AMP; not BOLT-on-Lightning). | +| `LIGHTNING_ERROR` | 8193 | `0x2001` | Lightning protocol error. | +| `LIGHTNING_OPEN_CHANNEL` | 8194 | `0x2002` | Proposes opening a Lightning channel. | +| `LIGHTNING_ACCEPT_CHANNEL` | 8195 | `0x2003` | Accepts channel open parameters. | +| `LIGHTNING_FUNDING_CREATED` | 8196 | `0x2004` | Announces channel funding transaction creation. | +| `LIGHTNING_FUNDING_SIGNED` | 8197 | `0x2005` | Returns/acknowledges funding signatures. | +| `LIGHTNING_CHANNEL_READY` | 8198 | `0x2006` | Declares channel ready for routing/HTLC operations. | +| `LIGHTNING_SHUTDOWN` | 8199 | `0x2007` | Initiates cooperative channel shutdown. | +| `LIGHTNING_CLOSING_SIGNED` | 8200 | `0x2008` | Exchanges closing signatures. | +| `LIGHTNING_UPDATE_ADD_HTLC` | 8201 | `0x2009` | Adds an HTLC to commitment state. | +| `LIGHTNING_UPDATE_FULFILL_HTLC` | 8202 | `0x200a` | Fulfills HTLC with preimage/proof. | +| `LIGHTNING_UPDATE_FAIL_HTLC` | 8203 | `0x200b` | Fails/cancels HTLC. | +| `LIGHTNING_COMMITMENT_SIGNED` | 8204 | `0x200c` | Commitment signature exchange in channel updates. | +| `LIGHTNING_REVOKE_AND_ACK` | 8205 | `0x200d` | Revocation + acknowledgment in Lightning state machine. | +| `LIGHTNING_CHANNEL_ANNOUNCEMENT` | 8206 | `0x200e` | Gossip announcement for channel existence. | +| `LIGHTNING_NODE_ANNOUNCEMENT` | 8207 | `0x200f` | Gossip announcement for node metadata. | +| `LIGHTNING_CHANNEL_UPDATE` | 8208 | `0x2010` | Gossip update for channel routing policy/state. | +| `LIGHTNING_WARNING` | 8209 | `0x2011` | Lightning warning. | +| `LIGHTNING_PING` | 8210 | `0x2012` | Lightning keepalive (distinct from `P2P_PING`). | +| `LIGHTNING_PONG` | 8211 | `0x2013` | Lightning ping response (distinct from `P2P_PONG`). | ## 2) Known Generic Payload Types (Body `type` Values) @@ -138,7 +217,7 @@ These are routed from decoded JSON bodies (often received as `P2P_BASE_MESSAGE` ## 3) Application namespaces (contract flow) -**Full specification:** [docs/APPLICATION_NAMESPACES.md](docs/APPLICATION_NAMESPACES.md) +**Full specification:** [docs/APPLICATION_NAMESPACES.md](docs/APPLICATION_NAMESPACES.md) **Code catalog:** [`functions/applicationNamespaces.js`](functions/applicationNamespaces.js) Fabric uses a small, consistent pattern for multi-app mesh traffic (Hub, @@ -158,5 +237,108 @@ Wire aliases: `P2P_CONTRACT_*` encode to the same opcodes as `CONTRACT_*` ## 4) Aliases and Notes - `types/message.js` accepts both canonical wire names and legacy-friendly aliases (for example `JSONCall` -> `JSON_CALL`, `DocumentPublish` -> `DOCUMENT_PUBLISH`). -- Some constants intentionally share numeric codes (notably some `P2P_*` and `LIGHTNING_*` values); canonical decode ordering in `types/message.js` determines the preferred wire label. Rows marked “encoding alias” above are valid encode names but are not the first-match decode label. +- Lightning AMP types use `0x2000–0x2FFF` (`POLICY.md`). Fabric keeps the low numbers (`P2P_PING` `0x12`, `P2P_INSTRUCTION` `0x20`, …). Every opcode in `WIRE_TYPE_DECODE_ORDER` is unique; encode name round-trips to that decode label. - `HEARTBEAT` appears in policy/examples as a known logical type, but it is not currently registered in `constants.js` canonical wire decode map. + +## Mesh chat model (gossip-first) + +Fabric’s **primary mesh fabric** is the gossip / peering network +(`P2P_PEER_GOSSIP`, `P2P_PEERING_OFFER`, slot fill, NOISE sessions). Chat is a +**layered application** on that fabric — not a replacement for it. + +| Surface | Wire | Who can read | Role | +|---------|------|--------------|------| +| **Gossip / peering** | `P2P_PEER_GOSSIP`, offers | Direct peers + gossip budget | Topology, discovery, WebRTC ids | +| **Public shoutbox** | cleartext `P2P_CHAT_MESSAGE` flood | Every relay hop | Social / public presence (wave 0) | +| **Onion-sealed chat** | `P2P_FORWARD` + `FabricOnionChatSeal` body | Path tip (E2E) | Directed private delivery | +| **Sealed GroupChat** | `CONTRACT_MESSAGE` + participant/tip seal | Group members (v2 hub-blind) | **Wave 1** confidential contract messaging | +| **Cleartext GroupChat** | `CONTRACT_MESSAGE` without seal | Relays + members | Legacy / playnet until seals on | + +Classifier: [`functions/fabricChatKind.js`](functions/fabricChatKind.js) +(also re-exported from `fabricChatText`). Keep gossip/peering budgets first. Keep the global shoutbox intentionally public. Put confidential traffic on sealed GroupChat. + +## Remaining work + +Owner-gated. Protocol version stays `0x01`. + +1. **Hub catalog** — Drop INNER pending rows that are already outer opcodes. Do not register `JSONBlob` until core owns `15104`. +2. **Dual-carrier kill** — Originate inventory / file / chat only on first-class opcodes. Mesh chat = `0x68` only. +3. **Field schemas** — Still JSON for session offer/open/ack, inventory, file send, document publish/request, contract publish/proposal, instruction, state commitment. +4. **Protocol-v1 matrix** — Add `P2P_FORWARD`, `P2P_RELAY`, `P2P_PONG`, `P2P_INVENTORY_RESPONSE`, `P2P_SESSION_OPEN`, `P2P_DOCUMENT_PUBLISH`, `SIDECHAIN_STATE_PATCH`, `CONTRACT_PROPOSAL`. MuSig2 stays directed. + +## Fabric Message collections +Canonical **share / store / replay** unit for signed AMP frames (`Message.toBuffer()` +hex). Use this for contract journals, Discord / `GroupDataShare` packs, +`IdentityCrossSign` catch-up, and any other deterministic event stream that +must round-trip through peers. + +Application JSON (chat objects, Hub `messages/*.json`, Discord pack payloads) +is **not** the collection format. JSON is what a replay *handler* folds after +`Message.fromBuffer`. + +### Why + +Downstream apps already treat wire hex as identity: + +| App path | What is stored | +|----------|----------------| +| GoonCitizen `groupStatechain` journal | `fabricMessage: { hash, hex, type }` so peers can `GroupJournalBatch` and replay **bit-identical** frames | +| GoonCitizen `GroupDataShare` | `CONTRACT_MESSAGE` body; packs (`chat.catalog`, `chat.messages`, …) fold after ingest | +| ARC `contractMessageAccumulate` | Identity = AMP body hash; `ingestMessageBuffer` accepts mesh / queue / paste bytes | +| Hub `_appendFabricMessage` | Sequential `messages/*.json` — **activity log**, not a portable AMP collection | + +This helper is the shared collect → persist → restore → replay loop those +paths were each inventing. + +### Document shape + +```json +{ + "type": "FabricMessageCollection", + "v": 1, + "count": 2, + "root": "", + "messages": [ + { "hash": "<64-hex body hash>", "hex": "", "type": "P2P_CHAT_MESSAGE" }, + { "hash": "…", "hex": "…", "type": "CONTRACT_MESSAGE", "appType": "GroupDataShare", "contract": "…" } + ] +} +``` + +- **`hash`** — header field = double-SHA256(body). Frames whose header does not + match are dropped (`body-hash-mismatch`), same as Peer. +- **`root`** — SHA-256 of the **ordered** hashes. Arrival order is committed. + ARC fold may still sort by hash; a different `root` can still fold to the + same tip digest. +- **JSONL** — one `{ hash, hex, … }` (or raw hex) per line for append-only logs. + +### API + +```js +const col = require('@fabric/core/functions/fabricMessageCollection'); +const collection = col.createCollection(); +col.ingest(collection, message); // Message | Buffer | hex | { hex } +col.ingest(collection, journalRow.fabricMessage); +const doc = col.toJSON(collection); +const restored = col.fromJSON(doc); +col.replay(restored, ({ message, inner }) => { + // inner is { contract, type, object } for CONTRACT_MESSAGE +}); +``` + +CLI: `npm run messages -- collect stream.jsonl` · `replay` · `verify` +(`node functions/fabricMessageCollection.js`). + +Fixture-backed application story (publish + every `GroupChange` action + +GroupChat / proposal+vote / journal request, gossiped on a 3-node loopback mesh): +`tests/fabric.hub.mesh.integration.js` locks +`tests/fixtures/application-contract-chain.json`. Replay must match the +bound `collection.root` and folded `tip.stateDigest`. Regenerate with +`UPDATE_FIXTURE=1`. + +### Related + +- [`MESSAGE_BODY.md`](docs/MESSAGE_BODY.md) — AMP header / typed bodies +- [`APPLICATION_NAMESPACES.md`](docs/APPLICATION_NAMESPACES.md) — `CONTRACT_MESSAGE` inner types +- [`MESSAGES.md`](#mesh-chat-model-gossip-first) — shoutbox vs GroupChat +- `functions/contractMessageAccumulate.js` — ARC journal fold from the same bytes diff --git a/POLICY.md b/POLICY.md index bb5463555..500a33b32 100644 --- a/POLICY.md +++ b/POLICY.md @@ -78,10 +78,10 @@ Historical range buckets (approximate). Prefer exact opcodes in the tables below | Range | Category | Description | |-------|----------|-------------| -| `0x00-0x7F` | Core Network Protocol | Network management and peer communication (incl. mesh) | +| `0x00-0x7F` | Core Network Protocol | Fabric network management and peer communication (incl. mesh) | | `0x80-0xFF` | Application Layer | Some application types; others use higher codes | | `0x1000-0x1FFF` | (legacy sketch) | Not used for Fabric Bitcoin types in `constants.js` | -| `0x2000-0x2FFF` | (legacy sketch) | Lightning codes in constants use BOLT-style values — see Remaining work | +| `0x2000-0x2FFF` | Lightning AMP | Lightning types on the Fabric Message wire (not BOLT-on-lightningd) | | `0x8000-0xFFFF` | Experimental | Development and testing | ### Core Network Protocol Types (0x00-0x7F) @@ -98,6 +98,7 @@ Codes from [`constants.js`](constants.js). Full catalog: [`MESSAGES.md`](MESSAGE | `0x11` | 17 | `P2P_IDENT_RESPONSE` | Identity response | ❌ No | | `0x12` | 18 | `P2P_PING` | Network heartbeat / keepalive | ❌ No | | `0x13` | 19 | `P2P_PONG` | Ping response | ❌ No | +| `0x20` | 32 | `P2P_INSTRUCTION` | Peer instruction | ⚠️ Conditional | | `0x21` | 33 | `P2P_START_CHAIN` | Chain bootstrap announce | ⚠️ Conditional | | `0x29` | 41 | `P2P_STATE_REQUEST` | State synchronization request | ⚠️ Conditional | | `0x30` | 48 | `P2P_STATE_ROOT` | State root / snapshot anchor | ⚠️ Conditional | @@ -141,6 +142,12 @@ Several application types use codes **outside** this range (see [`constants.js`] | — | 1024 | `JSON_PATCH` † | JSON patch operation | ✅ Yes | | — | 16000 | `JSON_CALL` † | JSON function call | ✅ Yes | | — | 15103 | `GENERIC_MESSAGE` † | Hub/browser transitional carrier | ✅ Yes | +| `0x4220` | 16928 | `P2P_MUSIG_START` † | Directed BIP-327 session open | ❌ No | +| `0x4221` | 16929 | `P2P_MUSIG_ACCEPT` † | Co-signer pubnonce | ❌ No | +| `0x4222` | 16930 | `P2P_MUSIG_RECEIVE_COUNTER` † | Additional pubnonce (n>2) | ❌ No | +| `0x4223` | 16931 | `P2P_MUSIG_SEND_PROPOSAL` † | Coordinator aggnonce | ❌ No | +| `0x4224` | 16932 | `P2P_MUSIG_REPLY_TO_PROPOSAL` † | Partial signature | ❌ No | +| `0x4225` | 16933 | `P2P_MUSIG_ACCEPT_PROPOSAL` † | Aggregated BIP-340 signature | ❌ No | | `0x81`-`0x8F` (legacy draft) | … | CHAT / ACCEPT / REJECT / PAYMENT_* | Not all registered in `constants.js` — see Remaining work | ⚠️ Varies | ### Bitcoin Integration Types @@ -173,7 +180,10 @@ Several application types use codes **outside** this range (see [`constants.js`] | `0x200E` | 8206 | LIGHTNING_CHANNEL_ANNOUNCEMENT | Channel announcement | ✅ Yes | | `0x200F` | 8207 | LIGHTNING_NODE_ANNOUNCEMENT | Node announcement | ✅ Yes | | `0x2010` | 8208 | LIGHTNING_CHANNEL_UPDATE | Channel update | ✅ Yes | -| `0x2011-0x2FFF` | 8209-12287 | RESERVED | Reserved for Lightning protocol extensions | N/A | +| `0x2011` | 8209 | LIGHTNING_WARNING | Lightning warning | ❌ No | +| `0x2012` | 8210 | LIGHTNING_PING | Lightning keepalive | ❌ No | +| `0x2013` | 8211 | LIGHTNING_PONG | Lightning ping response | ❌ No | +| `0x2014-0x2FFF` | 8212-12287 | RESERVED | Reserved for Lightning protocol extensions | N/A | ### Experimental Types (0x8000-0xFFFF) @@ -427,27 +437,27 @@ Implementations SHOULD: ### Message Type Quick Reference -**Always Relay**: PING, PONG, PEER_ANNOUNCE, TRANSACTION, GENERIC, CHAT_MESSAGE, DOCUMENT_PUBLISH, JSON_CALL, JSON_PATCH, STATE_DELTA, STATE_SNAPSHOT, CONTRACT_*, PAYMENT_*, LOCK_MESSAGE, BITCOIN_*, LIGHTNING_*_ANNOUNCEMENT +**Always Relay**: PEER_ANNOUNCE, TRANSACTION, GENERIC, CHAT_MESSAGE, DOCUMENT_PUBLISH, JSON_CALL, JSON_PATCH, STATE_DELTA, STATE_SNAPSHOT, CONTRACT_*, PAYMENT_*, LOCK_MESSAGE, BITCOIN_*, LIGHTNING_*_ANNOUNCEMENT -**Never Relay**: IDENT_REQUEST, IDENT_RESPONSE, SESSION_START, SESSION_ACK, HEARTBEAT, LOG_MESSAGE, LIGHTNING_INIT, LIGHTNING_ERROR, LIGHTNING_*_CHANNEL, LIGHTNING_*_HTLC, LIGHTNING_COMMITMENT_SIGNED, LIGHTNING_REVOKE_AND_ACK +**Never Relay**: IDENT_REQUEST, IDENT_RESPONSE, SESSION_START, SESSION_ACK, HEARTBEAT, PING, PONG, LOG_MESSAGE, LIGHTNING_INIT, LIGHTNING_ERROR, LIGHTNING_*_CHANNEL, LIGHTNING_*_HTLC, LIGHTNING_COMMITMENT_SIGNED, LIGHTNING_REVOKE_AND_ACK **Conditional Relay**: STATE_REQUEST, STATE_RESPONSE, INVENTORY_REQUEST, INVENTORY_RESPONSE, DOCUMENT_REQUEST, DOCUMENT_RESPONSE ### Related Documents - `PROTOCOL.md` → `docs/MESSAGE_BODY.md`: Canonical wire specification -- `MESSAGES.md`: Opcode catalog +- `MESSAGES.md`: Opcode catalog and generated decode-order freeze - `SECURITY.md`: Amplification, peel/`P2P_RELAY`, scoring (prefer over relay matrix below when they conflict) - `docs/P2P_FORWARD.md`: Directed onion -Missing historical drafts (do not expect these files): `MESSAGING_PROTOCOL_COMPLETION.md`, `FABRIC_MESSAGE_TYPE_CONSOLIDATION.md`, `FABRIC_MESSAGE_RELAY_BEHAVIOR.md`. +Missing historical drafts (do not expect these files): `MESSAGING_PROTOCOL_COMPLETION.md`, `FABRIC_MESSAGE_RELAY_BEHAVIOR.md`. ### Remaining work Paused after header/preimage, core opcode accuracy, and mesh type rows. Still open: -1. **Relay Rules / Quick Reference** — Align Always/Local/Conditional lists with Peer (`SECURITY.md`); PING/PONG are not mesh flood; hop budgets exist -2. **Lightning table** — Codes in this doc use a `0x2000+` sketch; `constants.js` uses BOLT-style values (`0x10`, `0x20`, …) with Fabric name collisions +1. ~~**Relay Rules / Quick Reference**~~ — PING/PONG are never mesh-flooded (table + Quick Reference). Hop budgets remain in `SECURITY.md`. +2. ~~**Lightning table**~~ — `constants.js` AMP types use `0x2000+` (`LIGHTNING_INIT` `0x2000` … `LIGHTNING_PONG` `0x2013`). Fabric keeps the low numbers. Duplicate opcodes fail at `Message` module load. 3. **Application legacy rows** — `CONTRACT_ACCEPT` / `REJECT` / `PAYMENT_*` / dense `0x81+` draft codes not fully registered 4. **Type Ranges** preamble — Still describes old range buckets; Bitcoin types are not in `0x1000-0x1FFF` 5. **Citation pass** — `VISION.md` / `docs/README.md` still treat POLICY as oracle diff --git a/SECURITY.md b/SECURITY.md index 3eea6835f..6eb5f8d77 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,7 +13,7 @@ Fabric networks are intended for deployment where **peers, relays, hubs, and ope Bitcoin L1 finality and operator key hygiene remain outside this document’s guarantees. Suite apps SHOULD document the same adversarial assumption and keep fail-closed defaults for auth, allowlists, and spend paths. -**Basics coverage:** [`tests/adversarialEnvironment.basics.test.js`](tests/adversarialEnvironment.basics.test.js) (blinded-execution role minimum + phishing hub rejection via allowlist helpers when present). Broader Peer adversarial contracts live under [`tests/protocol-v1/`](tests/protocol-v1/README.md). +**Basics coverage:** [`tests/fabric.circuit.js`](tests/fabric.circuit.js) (blinded-execution role minimum + phishing hub rejection via allowlist helpers when present). Broader Peer adversarial contracts live in [`tests/fabric.peer.adversarial.js`](tests/fabric.peer.adversarial.js). ## Blinded execution (composition scaffold) [`functions/blindedExecutionCircuit.js`](functions/blindedExecutionCircuit.js) commits garbler publish → ContractProposal accept/reject → content-addressed circuit digests → optional hashlock Taproot. It is **not** Yao gate garbling / OT. Do not treat `Circuit#scramble` as cryptographic evaluation. Real GC remains a future backend behind the same digests ([docs/PROGRAM.md](docs/PROGRAM.md)). @@ -92,20 +92,20 @@ Exact wire duplicates remain a silent drop (no score change). Wire-hash dedup re `P2P_CHAT_MESSAGE` still emits locally, but mesh `relayFrom` is **per-origin rate-limited** (`CHAT_MAX_RELAYS_PER_ORIGIN_PER_MINUTE` / `settings.chat.maxRelaysPerOriginPerMinute`, default 30/min). ## Inbound frame size -Wire frames larger than `HEADER_SIZE + MAX_MESSAGE_SIZE` (override body via `settings.maxMessageSize`) are dropped **before** parse / body-hash / signature work. +Wire frames larger than `HEADER_SIZE + MAX_MESSAGE_SIZE` (override body via `settings.maxMessageSize`) **or shorter than `HEADER_SIZE`** are dropped **before** parse / body-hash / signature work. Unparseable buffers are dropped the same way (no score / ban). Truncated frames are not treated as body-hash mismatches — that would hard-ban a TCP peer for a partial NOISE chunk. ## Strict Protocol V1 (test contract) -Adversarial Peer coverage under the assumption that **NOISE payloads are raw, well-formed Fabric Messages** (not random bytes) lives in [`tests/protocol-v1/`](tests/protocol-v1/README.md): opcode × delivery matrix (`direct` / foreign `P2P_RELAY` / `P2P_FORWARD` peel), unknown-opcode policy (`UNKNOWN_MESSAGE` — must not alias to `P2P_BASE_MESSAGE`), multi-origin collusion budgets, and a typed live NOISE storm. Parser crash fuzz remains in `tests/fuzz/` and is **not** counted as semantic adversarial coverage. +Adversarial Peer coverage under the assumption that **NOISE payloads are raw, well-formed Fabric Messages** (not random bytes) lives in [`tests/fabric.peer.adversarial.js`](tests/fabric.peer.adversarial.js): unknown-opcode policy (`UNKNOWN_MESSAGE` — must not alias to `P2P_BASE_MESSAGE`), hop/budget bounds, and first-class opcode isolation from generic JSON carriers. Parser crash fuzz remains in `tests/fuzz/` and is **not** counted as semantic adversarial coverage. **Generic carrier:** `P2P_BASE_MESSAGE` / `GENERIC_MESSAGE` JSON bodies must not escalate first-class mesh/session opcodes (`P2P_PEERING_OFFER`, `P2P_PEER_GOSSIP`, `CONTRACT_MESSAGE`, …). Legacy inventory continues to use inner type `INVENTORY_REQUEST` (not the `P2P_` wire name). -**Phase B bodies:** registered field schemas (`Message.fromFields` / `tryParseMessageBody`) cover +**Typed bodies:** registered field schemas (`Message.fromFields` / `tryParseMessageBody`) cover `P2P_PING` / `P2P_PONG` / `P2P_FORWARD` / `P2P_PEER_GOSSIP` / `P2P_PEERING_OFFER` / `P2P_PEER_ANNOUNCE`. Peer typed handlers accept **JSON or field-layout forms**. Chat/alias remain -raw UTF-8. Coverage: `tests/protocol-v1/phase-b.typed-bodies.js`. +raw UTF-8. Coverage: `tests/fabric.peer.adversarial.js` plus `tests/fabric.message.js`. ## Application Resource Contract accumulate -`functions/contractMessageAccumulate.ingestMessageBuffer` folds signed `CONTRACT_MESSAGE` frames into a tip. **Signer mutations** (`GroupChange`, `GroupChangeProposal` / `GroupChangeVote`, federation invite (+ response), capability grants, withdrawal request/witness, journal request/batch/state) require the AMP author to be in **genesis.signers** (when tip members are empty) or the **current tip member set**, or to present a **verified** `OP_CONTRACT_SIGN` Token (`meta.capabilityToken`) issued by a current signer. **`GroupChat`** and other non-mutation body types (including custom genesis `primitives.messageTypes`) require author ∈ tip/genesis **reader ∪ signer** set (or verified `OP_CONTRACT_READ` / `OP_CONTRACT_SIGN`). **`ContractWithdrawalWitness`** must bind `signer` to the AMP author, carry BIP340 `signature` over `fabric:contract-withdrawal-witness:1:…`, and match the pending request’s tip fields. AMP signature alone is not sufficient. Body `type` must appear in genesis `primitives.messageTypes` when declared (**empty declared list = deny-all**); otherwise only known core `CONTRACT_BODY_TYPES` are accepted. **Withdrawals** must bind `stateDigest` + `bitcoinBlockHash` to the tip at request time (`functions/contractSpend`). See [`docs/ARC.md`](docs/ARC.md). +`functions/contractMessageAccumulate.ingestMessageBuffer` folds signed `CONTRACT_MESSAGE` frames into a tip. **Signer mutations** (`GroupChange`, `GroupChangeProposal` / `GroupChangeVote`, federation invite (+ response), capability grants, withdrawal request/witness, journal request/batch/state) require the AMP author to be in **genesis.signers** (when tip members are empty) or the **current tip member set**, or to present a **verified** `OP_CONTRACT_SIGN` Token (`meta.capabilityToken`) issued by a current signer. **`GroupChat`** and other non-mutation body types (including custom genesis `primitives.messageTypes`) require author ∈ tip/genesis **reader ∪ signer** set (or verified `OP_CONTRACT_READ` / `OP_CONTRACT_SIGN`). **`ContractWithdrawalWitness`** must bind `signer` to the AMP author, carry BIP340 `signature` over `fabric:contract-withdrawal-witness:1:…`, and match the pending request’s tip fields. AMP signature alone is not sufficient. Body `type` must appear in genesis `primitives.messageTypes` when declared (**empty declared list = deny-all**); otherwise only known core `CONTRACT_BODY_TYPES` are accepted. **Withdrawals** must bind `stateDigest` + `bitcoinBlockHash` to the tip at request time (`functions/contractSpend`). See [`docs/CONTRACTS.md`](docs/CONTRACTS.md). **Outstanding (seals / accumulate):** tip-bound and participant AES-GCM seals bind scheme (+ tip / contract / ephemeral) as AAD. Journal / re-fold caps drop compactable rows by hex-hash order (`maxJournalEntries`, hard cap 20000). Genesis `bitcoinAnchor.blockHash` is only folded when it is 64-hex (same gate as `meta.bitcoinBlockHash`). Explicit tip `signers: []` no longer widens `tipSpendKeys` / `resolveSpend` to participant `members`. @@ -117,7 +117,7 @@ raw UTF-8. Coverage: `tests/protocol-v1/phase-b.typed-bodies.js`. **Identity coin types:** Fabric protocol derivation uses **7777** on Bitcoin mainnet and **7778** otherwise ([IDENTITY.md](IDENTITY.md)). Do not mix Fabric coin-type keys with BIP44 coin-type-0 Bitcoin funds. Downstream Hub/Passport hard-codes of `7778` should pass `network` into `fabricIdentityDerivationPath` when promoting to mainnet. -**Still open (non-blocking for this PR pass):** eager `messageHex` on Peer hot paths; `contractId` → `contractIdentifier` rename; Hub/Passport callers still on hard-coded `7778` until they adopt `fabricIdentityDerivationPath`; `API.md` index sync via `make:api` for gossip / parse / `resolveSpend` option docs. Blinded-execution remains a scaffold (not Yao GC) even with signed decisions + hashlock+pubkey leaves — `decisionSigningMessage` v2 binds `at` (PR #183 Medium closed). GroupChangeProposal votes bind canonical `members` / `signers` (v2; PR #183 High closed). Beacon federation rounds stop collecting at `ready`; core `Beacon#submitFederationEpochSignature` idempotently finalizes already-`ready` rounds if epoch-chain persist fails ([docs/ARC.md](docs/ARC.md) §8 item 25). Peering self-dial suppress uses verified AMP signer (`verifiedPubkey`) only — advertised `obj.pubkey` is informational (FIFO-capped). OP_RETURN hallmarks ([docs/STATECHAIN.md](docs/STATECHAIN.md) / `functions/fabricHallmark`) are encode/verify in core; Hub opt-in publish+scan is separate — no P2P hallmark broadcast. Evaluator accepts for blinded-execution finalization exclude the garbler key. +**Still open (non-blocking for this PR pass):** eager `messageHex` on Peer hot paths; `contractId` → `contractIdentifier` rename; Hub/Passport callers still on hard-coded `7778` until they adopt `fabricIdentityDerivationPath`; `API.md` index sync via `make:api` for gossip / parse / `resolveSpend` option docs. Blinded-execution remains a scaffold (not Yao GC) even with signed decisions + hashlock+pubkey leaves — `decisionSigningMessage` v2 binds `at` (PR #183 Medium closed). GroupChangeProposal votes bind canonical `members` / `signers` (v2; PR #183 High closed). Beacon federation rounds stop collecting at `ready`; core `Beacon#submitFederationEpochSignature` idempotently finalizes already-`ready` rounds if epoch-chain persist fails ([docs/CONTRACTS.md](docs/CONTRACTS.md) §8 item 25). Peering self-dial suppress uses verified AMP signer (`verifiedPubkey`) only — advertised `obj.pubkey` is informational (FIFO-capped). OP_RETURN hallmarks ([docs/STATECHAIN.md](docs/STATECHAIN.md) / `functions/fabricHallmark`) are encode/verify in core; Hub opt-in publish+scan is separate — no P2P hallmark broadcast. Evaluator acceptance for blinded-execution finalization excludes the garbler key. ~~**Beacon/ARC publish authority fail-open**~~ — `Peer.collectContractAuthorityPubkeys` includes nested `members.signers` / `spendPolicy.validators` so Beacon genesis front-runs by non-validators are rejected. @@ -147,5 +147,10 @@ Paid CLI `/confirm` fails closed without Hub confirmation or successful local L1 3. Review **`npm audit`** results before release tags; record exceptions in the release notes if needed. 4. **Never** commit seeds, `stores/` production data, or RPC passwords (see [docs/PRODUCTION.md](docs/PRODUCTION.md)). +## MuSig2 (BIP-327) +`functions/musig2` is **n-of-n** Schnorr aggregation. It does not replace t-of-n Beacon / GroupChange quorums (those stay independent BIP-340 counts) and it is not FROST. New Taproot authority ladders with two or more keys use the MuSig2 aggregate as the internal key so all-n can spend the key-path; threshold and CSV failover remain script-path. Session secret nonces are zeroed after `sign` (including when `getSessionValues` fails), after a failed `aggregatePartials`, and when `Peer.stop()` clears stored sessions — reuse leaks keys. TTL expiry and capacity eviction already call `wipeSession()`. Invalid inbound partials stay retryable. Do not trust a caller-supplied aggregate; recompute `keyAgg` from the participant list. + +Interactive mesh signing uses the existing `P2P_MUSIG_*` opcodes (`0x4220`–`0x4225`) as **directed TCP** frames only — they are not mesh-flooded (`RELAY_AS_IS`) and must not arrive via peel, foreign `P2P_RELAY`, or a generic JSON carrier. The AMP author must be the contributing pubkey. Peers recompute `aggnonce` locally and reject a mismatched `SEND_PROPOSAL`. Enforced: 16 signers, 4KiB message. Defaults (overridable via `config.musig2`): **`autoAccept: false`** (inbound START does not auto-create a session or produce identity-key partials), 32 sessions (`maxSessions`), 10-minute TTL (`sessionTtlMs`). Set `musig2.autoAccept: true` only for operator-supervised cooperative signing. + ## Disclosure -Report security issues through the contact in [TODO.md](TODO.md) / project README as applicable. +Report vulnerabilities to **`security@fabric.pub`**. Do not file public GitHub issues for undisclosed flaws. See also [AUDIT.md](AUDIT.md). diff --git a/constants.js b/constants.js index 70ba67004..923f62a80 100644 --- a/constants.js +++ b/constants.js @@ -48,6 +48,23 @@ function fabricCoinTypeForNetwork (network) { return FABRIC_COIN_TYPE_TESTNET; } +/** + * Resolve Fabric identity coin type from a network name or explicit integer. + * Omit / empty → 7778. Mainnet names → 7777. Explicit integers are BIP32 child + * indices (not restricted to 7777/7778). + * @param {string|number} [networkOrCoinType] + * @returns {number} + */ +function resolveFabricIdentityCoinType (networkOrCoinType) { + if (typeof networkOrCoinType === 'number') { + return assertBip32ChildIndex(networkOrCoinType, 'coinType'); + } + if (networkOrCoinType != null && String(networkOrCoinType).trim() !== '') { + return fabricCoinTypeForNetwork(networkOrCoinType); + } + return FABRIC_COIN_TYPE_TESTNET; +} + /** * Fabric identity path: m/44'/{7777|7778}'/account'/0/index * @param {number} [account=0] @@ -57,15 +74,20 @@ function fabricCoinTypeForNetwork (network) { * @returns {string} */ function fabricIdentityDerivationPath (account = 0, index = 0, networkOrCoinType) { - const a = assertBip32ChildIndex(account, 'account'); const i = assertBip32ChildIndex(index, 'index'); - let coin = FABRIC_COIN_TYPE_TESTNET; - if (typeof networkOrCoinType === 'number') { - coin = assertBip32ChildIndex(networkOrCoinType, 'coinType'); - } else if (networkOrCoinType != null && String(networkOrCoinType).trim() !== '') { - coin = fabricCoinTypeForNetwork(networkOrCoinType); - } - return `m/44'/${coin}'/${a}'/0/${i}`; + return `${fabricIdentityAccountPath(account, networkOrCoinType)}/0/${i}`; +} + +/** + * Fabric identity account node: m/44'/{7777|7778}'/account' + * @param {number} [account=0] + * @param {string|number} [networkOrCoinType] Network name or explicit coin type. + * @returns {string} + */ +function fabricIdentityAccountPath (account = 0, networkOrCoinType) { + const a = assertBip32ChildIndex(account, 'account'); + const coin = resolveFabricIdentityCoinType(networkOrCoinType); + return `m/44'/${coin}'/${a}'`; } /** @@ -75,9 +97,7 @@ function fabricIdentityDerivationPath (account = 0, index = 0, networkOrCoinType * @returns {string} */ function bitcoinReceiveDerivationPath (account = 0, index = 0) { - const a = assertBip32ChildIndex(account, 'account'); - const i = assertBip32ChildIndex(index, 'index'); - return `m/44'/${BITCOIN_COIN_TYPE}'/${a}'/0/${i}`; + return bitcoinPurposeDerivationPath(44, account, 0, index); } /** @@ -87,9 +107,106 @@ function bitcoinReceiveDerivationPath (account = 0, index = 0) { * @returns {string} */ function bitcoinChangeDerivationPath (account = 0, index = 0) { + return bitcoinPurposeDerivationPath(44, account, 1, index); +} + +/** + * BIP-43 purpose path for Bitcoin funds: m/{purpose}'/0'/account'/{0|1}/index + * Default receive remains BIP-44 (`bitcoinReceiveDerivationPath`); these helpers + * name BIP-49 / BIP-84 / BIP-86 trees without changing that default. + * + * @param {number} purpose 44 (p2pkh), 49 (p2sh-p2wpkh), 84 (p2wpkh), or 86 (p2tr) + * @param {number} [account=0] + * @param {number} [change=0] 0 = receive, 1 = change + * @param {number} [index=0] + * @returns {string} + * @private + */ +function bitcoinPurposeDerivationPath (purpose, account = 0, change = 0, index = 0) { + const p = assertBip32ChildIndex(purpose, 'purpose'); + const a = assertBip32ChildIndex(account, 'account'); + const c = assertBip32ChildIndex(change, 'change'); + const i = assertBip32ChildIndex(index, 'index'); + if (c !== 0 && c !== 1) { + throw new RangeError('BIP32 change chain must be 0 (receive) or 1 (change)'); + } + return `m/${p}'/${BITCOIN_COIN_TYPE}'/${a}'/${c}/${i}`; +} + +/** BIP-49 wrapped SegWit receive: m/49'/0'/account'/0/index */ +function bitcoinBip49ReceiveDerivationPath (account = 0, index = 0) { + return bitcoinPurposeDerivationPath(49, account, 0, index); +} + +/** BIP-49 wrapped SegWit change: m/49'/0'/account'/1/index */ +function bitcoinBip49ChangeDerivationPath (account = 0, index = 0) { + return bitcoinPurposeDerivationPath(49, account, 1, index); +} + +/** BIP-84 native SegWit receive: m/84'/0'/account'/0/index */ +function bitcoinBip84ReceiveDerivationPath (account = 0, index = 0) { + return bitcoinPurposeDerivationPath(84, account, 0, index); +} + +/** BIP-84 native SegWit change: m/84'/0'/account'/1/index */ +function bitcoinBip84ChangeDerivationPath (account = 0, index = 0) { + return bitcoinPurposeDerivationPath(84, account, 1, index); +} + +/** BIP-86 Taproot receive: m/86'/0'/account'/0/index */ +function bitcoinBip86ReceiveDerivationPath (account = 0, index = 0) { + return bitcoinPurposeDerivationPath(86, account, 0, index); +} + +/** BIP-86 Taproot change: m/86'/0'/account'/1/index */ +function bitcoinBip86ChangeDerivationPath (account = 0, index = 0) { + return bitcoinPurposeDerivationPath(86, account, 1, index); +} + +/** + * BIP-48 multisig path: m/48'/0'/account'/script_type'/{0|1}/index + * script_type 1 = p2sh-p2wsh, 2 = p2wsh (recommended default). Path templates + * only — group/federation vaults do not switch to these by default. + * + * @param {number} scriptType + * @param {number} [account=0] + * @param {number} [change=0] + * @param {number} [index=0] + * @returns {string} + * @private + */ +function bitcoinBip48DerivationPath (scriptType, account = 0, change = 0, index = 0) { + const st = assertBip32ChildIndex(scriptType, 'scriptType'); + if (st !== 1 && st !== 2) { + throw new RangeError('BIP48 script_type must be 1 (p2sh-p2wsh) or 2 (p2wsh)'); + } const a = assertBip32ChildIndex(account, 'account'); + const c = assertBip32ChildIndex(change, 'change'); const i = assertBip32ChildIndex(index, 'index'); - return `m/44'/${BITCOIN_COIN_TYPE}'/${a}'/1/${i}`; + if (c !== 0 && c !== 1) { + throw new RangeError('BIP32 change chain must be 0 (receive) or 1 (change)'); + } + return `m/48'/${BITCOIN_COIN_TYPE}'/${a}'/${st}'/${c}/${i}`; +} + +/** BIP-48 nested SegWit multisig receive: m/48'/0'/account'/1'/0/index */ +function bitcoinBip48P2shP2wshReceiveDerivationPath (account = 0, index = 0) { + return bitcoinBip48DerivationPath(1, account, 0, index); +} + +/** BIP-48 nested SegWit multisig change: m/48'/0'/account'/1'/1/index */ +function bitcoinBip48P2shP2wshChangeDerivationPath (account = 0, index = 0) { + return bitcoinBip48DerivationPath(1, account, 1, index); +} + +/** BIP-48 native SegWit multisig receive: m/48'/0'/account'/2'/0/index */ +function bitcoinBip48P2wshReceiveDerivationPath (account = 0, index = 0) { + return bitcoinBip48DerivationPath(2, account, 0, index); +} + +/** BIP-48 native SegWit multisig change: m/48'/0'/account'/2'/1/index */ +function bitcoinBip48P2wshChangeDerivationPath (account = 0, index = 0) { + return bitcoinBip48DerivationPath(2, account, 1, index); } /** @@ -132,7 +249,8 @@ const MAX_CHANNEL_VALUE = 100000000; // Machine Constraints const MACHINE_MAX_MEMORY = MAX_MEMORY_ALLOC * MAX_MESSAGE_SIZE; -const MAX_CHAT_MESSAGE_LENGTH = 2048; +/** Matches Peer `P2P_CHAT_MAX_CHARS` so TUI send is not dropped on the mesh. */ +const MAX_CHAT_MESSAGE_LENGTH = 2000; // Playnet const FABRIC_PLAYNET_ADDRESS = ''; // unset until a published playnet P2TR deposit address is chosen @@ -153,7 +271,7 @@ const DOCUMENT_REQUEST_TYPE = 999; const JSON_CALL_TYPE = 16000; const PATCH_MESSAGE_TYPE = 1024; /** Contract negotiation: batched Fabric messages + chain Merkle root + JSON Patch (RFC 6902) — see docs/CONTRACT_PROPOSAL.md */ -const CONTRACT_PROPOSAL_TYPE = 138; // 0x8A (POLICY.md / FABRIC_MESSAGE_TYPE_CONSOLIDATION) +const CONTRACT_PROPOSAL_TYPE = 138; // 0x8A (POLICY.md / MESSAGES.md) // Opcodes const OP_CYCLE = '00'; @@ -194,6 +312,11 @@ const PEERING_OFFER_MAX_RELAYS_PER_ORIGIN_PER_MINUTE = 60; const PEERING_OFFER_MAX_PAYLOAD_CACHE = 50000; /** Max queued connection candidates from {@link P2P_PEERING_OFFER} (FIFO eviction). */ const PEER_MAX_CANDIDATES_QUEUE = 128; +/** + * Minimum delay before {@link Peer#_fillPeerSlots} redials the same candidate. + * Immediate requeue plus `connections:close` was a tight ECONNREFUSED loop. + */ +const PEER_CANDIDATE_RETRY_MS = 60000; /** Max wire-hash dedup entries in {@link Peer} (bounded memory). */ const PEER_MAX_WIRE_HASH_CACHE = 10000; /** @@ -226,13 +349,22 @@ const CHAT_MAX_RELAYS_PER_ORIGIN_PER_MINUTE = 30; const PEER_MAX_PENDING_SEALED_DELIVERIES = 32; /** Max private DocumentRequest reverse-route entries. */ const PEER_MAX_DOCUMENT_RELAY_ROUTES = 256; +/** Max concurrent BIP-327 `P2P_MUSIG_*` sessions on a Peer (FIFO eviction). */ +const PEER_MAX_MUSIG_SESSIONS = 32; +/** Idle TTL for a MuSig2 session before secret nonces are wiped. */ +const PEER_MUSIG_SESSION_TTL_MS = 10 * 60 * 1000; +/** + * Max RFC6902 patch batches retained on {@link Service#history} (FIFO). + * Unbounded history plus full-state commit snapshots OOMed Hub under reconnects. + */ +const SERVICE_COMMIT_HISTORY_MAX = 256; const P2P_GENERIC = 0x80; // 128 in decimal const P2P_IDENT_REQUEST = 0x01; // 1, or the identity const P2P_IDENT_RESPONSE = 0x11; const P2P_ROOT = 0x00000000; -const P2P_PING = 0x00000012; // same ID as Lightning (18) -const P2P_PONG = 0x00000013; // same ID as Lightning (19) -const P2P_INSTRUCTION = 0x00000020; // TODO: select w/ no overlap +const P2P_PING = 0x00000012; +const P2P_PONG = 0x00000013; +const P2P_INSTRUCTION = 0x00000020; const P2P_START_CHAIN = 0x00000021; const P2P_STATE_REQUEST = 0x00000029; // TODO: select w/ no overlap const P2P_STATE_ROOT = 0x00000030; // TODO: select w/ no overlap @@ -283,34 +415,28 @@ const LIGHTNING_TEST_HEADER = 'D0520C6E'; const LIGHTNING_PROTOCOL_H_INIT = 'Noise_XK_secp256k1_ChaChaPoly_SHA256'; const LIGHTNING_PROTOCOL_PROLOGUE = 'lightning'; -// Lightning BOLT Message Types (subset) -// Setup & Control -const LIGHTNING_WARNING = 0x00000001; // 1 -const LIGHTNING_INIT = 0x00000010; // 16 -const LIGHTNING_ERROR = 0x00000011; // 17 -const LIGHTNING_PING = 0x00000012; // 18 (alias of P2P_PING) -const LIGHTNING_PONG = 0x00000013; // 19 (alias of P2P_PONG) - -// Channel Management -const LIGHTNING_OPEN_CHANNEL = 0x00000020; // 32 -const LIGHTNING_ACCEPT_CHANNEL = 0x00000021; // 33 -const LIGHTNING_FUNDING_CREATED = 0x00000022; // 34 -const LIGHTNING_FUNDING_SIGNED = 0x00000023; // 35 -const LIGHTNING_CHANNEL_READY = 0x00000024; // 36 -const LIGHTNING_SHUTDOWN = 0x00000026; // 38 -const LIGHTNING_CLOSING_SIGNED = 0x00000027; // 39 - -// Payment Messages -const LIGHTNING_UPDATE_ADD_HTLC = 0x00000080; // 128 -const LIGHTNING_UPDATE_FULFILL_HTLC = 0x00000082; // 130 -const LIGHTNING_UPDATE_FAIL_HTLC = 0x00000083; // 131 -const LIGHTNING_COMMITMENT_SIGNED = 0x00000084; // 132 -const LIGHTNING_REVOKE_AND_ACK = 0x00000085; // 133 - -// Gossip Protocol -const LIGHTNING_CHANNEL_ANNOUNCEMENT = 0x00000100; // 256 -const LIGHTNING_NODE_ANNOUNCEMENT = 0x00000101; // 257 -const LIGHTNING_CHANNEL_UPDATE = 0x00000102; // 258 +// Lightning AMP types (0x2000–0x2FFF). Not BOLT-on-the-wire numbers — +// those live in lightningd. See POLICY.md Lightning Network Types. +const LIGHTNING_INIT = 0x2000; +const LIGHTNING_ERROR = 0x2001; +const LIGHTNING_OPEN_CHANNEL = 0x2002; +const LIGHTNING_ACCEPT_CHANNEL = 0x2003; +const LIGHTNING_FUNDING_CREATED = 0x2004; +const LIGHTNING_FUNDING_SIGNED = 0x2005; +const LIGHTNING_CHANNEL_READY = 0x2006; +const LIGHTNING_SHUTDOWN = 0x2007; +const LIGHTNING_CLOSING_SIGNED = 0x2008; +const LIGHTNING_UPDATE_ADD_HTLC = 0x2009; +const LIGHTNING_UPDATE_FULFILL_HTLC = 0x200A; +const LIGHTNING_UPDATE_FAIL_HTLC = 0x200B; +const LIGHTNING_COMMITMENT_SIGNED = 0x200C; +const LIGHTNING_REVOKE_AND_ACK = 0x200D; +const LIGHTNING_CHANNEL_ANNOUNCEMENT = 0x200E; +const LIGHTNING_NODE_ANNOUNCEMENT = 0x200F; +const LIGHTNING_CHANNEL_UPDATE = 0x2010; +const LIGHTNING_WARNING = 0x2011; +const LIGHTNING_PING = 0x2012; +const LIGHTNING_PONG = 0x2013; // Lightning BMM const LIGHTNING_BMM_HEADER = 'D0520C6E'; @@ -344,9 +470,21 @@ module.exports = { BITCOIN_COIN_TYPE, BITCOIN_KEY_DERIVATION_PATH, fabricCoinTypeForNetwork, + resolveFabricIdentityCoinType, fabricIdentityDerivationPath, + fabricIdentityAccountPath, bitcoinReceiveDerivationPath, bitcoinChangeDerivationPath, + bitcoinBip49ReceiveDerivationPath, + bitcoinBip49ChangeDerivationPath, + bitcoinBip84ReceiveDerivationPath, + bitcoinBip84ChangeDerivationPath, + bitcoinBip86ReceiveDerivationPath, + bitcoinBip86ChangeDerivationPath, + bitcoinBip48P2shP2wshReceiveDerivationPath, + bitcoinBip48P2shP2wshChangeDerivationPath, + bitcoinBip48P2wshReceiveDerivationPath, + bitcoinBip48P2wshChangeDerivationPath, assertBip32ChildIndex, FABRIC_USER_AGENT, FIXTURE_SEED, @@ -430,6 +568,7 @@ module.exports = { PEERING_OFFER_MAX_RELAYS_PER_ORIGIN_PER_MINUTE, PEERING_OFFER_MAX_PAYLOAD_CACHE, PEER_MAX_CANDIDATES_QUEUE, + PEER_CANDIDATE_RETRY_MS, PEER_MAX_WIRE_HASH_CACHE, PEER_MAX_LOGICAL_REGISTER_CACHE, PEER_SCORE_BODY_HASH_MISMATCH_PENALTY, @@ -447,6 +586,9 @@ module.exports = { CHAT_MAX_RELAYS_PER_ORIGIN_PER_MINUTE, PEER_MAX_PENDING_SEALED_DELIVERIES, PEER_MAX_DOCUMENT_RELAY_ROUTES, + PEER_MAX_MUSIG_SESSIONS, + PEER_MUSIG_SESSION_TTL_MS, + SERVICE_COMMIT_HISTORY_MAX, P2P_IDENT_REQUEST, P2P_IDENT_RESPONSE, P2P_CHAIN_SYNC_REQUEST, diff --git a/docs/APPLICATION_NAMESPACES.md b/docs/APPLICATION_NAMESPACES.md index 622cea87b..3399e8c7f 100644 --- a/docs/APPLICATION_NAMESPACES.md +++ b/docs/APPLICATION_NAMESPACES.md @@ -3,7 +3,7 @@ Canonical model for multi-app mesh traffic across **Hub**, light peers, and other `@fabric/core` consumers. -Formal ARC genesis / tip / spend: [`ARC.md`](ARC.md). +Formal ARC genesis / tip / spend: [`CONTRACTS.md`](CONTRACTS.md#application-resource-contracts-arc). Code catalog: [`functions/applicationNamespaces.js`](../functions/applicationNamespaces.js). Wire opcodes: [`MESSAGES.md`](../MESSAGES.md) §1 and §3. @@ -54,7 +54,15 @@ trees) belong in **app catalogs**, not this core list. **Multi-origin accumulate:** publisher and participants ingest the same signed AMP bytes from mesh, opaque hub queues, or copy-paste (`fabric:`) via [`functions/contractMessageAccumulate.js`](../functions/contractMessageAccumulate.js). -Fold is idempotent by message hash and arrival-order independent. **Delivery +Fold is idempotent by message hash and arrival-order independent. + +**Portable collections:** persist and replay those AMP hex frames as +[`FabricMessageCollection`](../MESSAGES.md#fabric-message-collections) +(`functions/fabricMessageCollection.js`) — JSONL or a JSON document whose +`messages[].hex` is `Message.toBuffer()`. Journal rows that already carry +`fabricMessage.hex` ingest directly. + +**Delivery synchronization filter:** sync-tracked body types (default: all except delivery ACKs and `GroupJournalRequest`) open a per-reader 2PC row in [`functions/contractMessageCommit.js`](../functions/contractMessageCommit.js) diff --git a/docs/ARC.md b/docs/ARC.md deleted file mode 100644 index ed1afb0ca..000000000 --- a/docs/ARC.md +++ /dev/null @@ -1,239 +0,0 @@ -# Application Resource Contracts (ARC) -**Status:** Draft — formalizes the Contract / ARC wire + state model for `@fabric/core` 0.1.x (`VERSION_NUMBER = 0x01`). -**Driven by:** security review themes on [FabricLabs/fabric#183](https://github.com/FabricLabs/fabric/pull/183) (authz, spend-policy fail-closed, path policy, Token grants). - -Related: [`CONTRACTS.md`](CONTRACTS.md), [`APPLICATION_NAMESPACES.md`](APPLICATION_NAMESPACES.md), [`DISTRIBUTED_EXECUTION.md`](DISTRIBUTED_EXECUTION.md), [`functions/applicationNamespaces.js`](../functions/applicationNamespaces.js). - ---- - -## 1. What an ARC is -An **Application Resource Contract (ARC)** is a published Fabric **Contract** whose -genesis declares: - -1. **Primitives** — which `CONTRACT_MESSAGE` body types (and optional Machine opcodes) are in-bounds -2. **Members** — who may read and who may sign (via Tip roster and/or `Token` grants) -3. **Spend policy** — how tip state maps to a Bitcoin P2TR (and related L2) spending surface -4. **Anchor** — a **Bitcoin block hash** (and preferably height) binding the publish / tip to L1 time - -Namespace id = `Actor(genesis).id` after `CONTRACT_PUBLISH` (same as today). - -An ARC is **not** a separate opcode family. Outer wire remains: - -| Outer | Role | -|-------|------| -| `CONTRACT_PUBLISH` (`0x5f`) | Genesis | -| `CONTRACT_MESSAGE` (`0x60`) | Namespaced events | -| `CONTRACT_PROPOSAL` (`0x8a`) | Batched proposal / patch / optional PSBT | - ---- - -## 2. Genesis object (`CONTRACT_PUBLISH` body) -Minimum fields for a 0.1 ARC genesis (extensible; unknown fields ignored by core): - -```json -{ - "@type": "FabricContract", - "name": "example-arc", - "interfaces": ["arc.core"], - "primitives": { - "messageTypes": [ - "GroupChat", - "GroupChange", - "ContractCapabilityGrant", - "ContractWithdrawalRequest", - "ContractWithdrawalWitness", - "MessageReceived", - "MessageReceipt" - ], - "opcodes": [] - }, - "members": { - "signers": ["", "..."], - "readers": ["*"], - "threshold": 1 - }, - "spendPolicy": { - "network": "regtest", - "publisher": "", - "validators": ["..."], - "threshold": 1, - "csvBlocks": 144 - }, - "sidechainPolicy": { - "allowedPathPrefixes": ["/registry", "/members"], - "maxOps": 32 - }, - "bitcoinAnchor": { - "blockHash": "<64-hex>", - "height": 0 - } -} -``` - -### Rules - -- **`primitives.messageTypes`**: ingest MUST reject `CONTRACT_MESSAGE` body `type` values not listed (apps may subclass catalogs only by publishing a new genesis or an authorized primitive-extend message — TBD; default fail-closed). -- **`primitives.deliverySync`**: optional synchronization filter for which body types open per-reader `MessageReceived` / `MessageReceipt` 2PC rows (`mode`: `all` | `none` | `include` | `exclude`). Delivery ACK types never open a new pending row. -- **`primitives.opcodes`**: Machine / Program opcode allow-list when the ARC exposes a program interface. Enforced by `Machine.setAllowedOpcodes` / `applyGenesisOpcodes` / `loadProgram({ genesis })` (fail closed when non-empty). -- **`members`**: initial tip roster. Later changes only via authorized `GroupChange` / grant messages (see §4). -- **`spendPolicy`**: input to `contractTaproot` / `Contract#toTaprootContract`. Fail closed on invalid threshold, CSV range, CLTV BIP65 boundary (see PR #183). -- **`bitcoinAnchor.blockHash`**: **required** for public-network ARCs; MUST be a known Bitcoin best-block hash at publish time (or last accepted tip when re-anchoring). - ---- - -## 3. Tip state -Each ARC maintains a tip: - -```text -tip = { - contractId, - clock, - stateDigest, // SHA256(canonical { version, clock, content }) - content, // members, messages, app bags, … - bitcoinBlockHash, // most recent L1 tip this state claims - bitcoinHeight? -} -``` - -**Standard:** every accepted state transition that advances `clock` SHOULD refresh -`bitcoinBlockHash` to the peer’s current Bitcoin tip (or Hub Beacon tip). Withdrawal -and L2 spend proofs MUST include the tip’s `stateDigest` **and** `bitcoinBlockHash`. - ---- - -## 4. Members, Tokens, and authz -| Role | Capability | How granted | -|------|------------|-------------| -| Reader | `OP_CONTRACT_READ` | Genesis `members.readers` or `ContractCapabilityGrant` Token | -| Signer | `OP_CONTRACT_SIGN` | Genesis `members.signers` / threshold set or grant Token | - -`functions/contractCapability.js` issues/verifies Tokens with `ctx.contractId`. - -### MUST (security — PR #183) -1. **Ingest authz:** `contractMessageAccumulate.ingestMessageBuffer` (and equivalents) MUST NOT fold `GroupChange` (or other member/spend mutations) on AMP signature alone. Author MUST be in the current signer set **or** present a verified `OP_CONTRACT_SIGN` Token for this `contractId`. -2. **Verify vs parse:** Token verify without `issuerKey` MUST NOT look like authorization (`allowUnverified` only, result labeled `verified: false`). -3. **Path policy:** RFC6902 / `patchesCanonical` paths MUST stay inside genesis `sidechainPolicy` (no fail-open `policy = null` for registry helpers). - ---- - -## 5. Primitives catalog (core) -Shared body `type` strings (`CONTRACT_BODY_TYPES` in `applicationNamespaces.js`): - -| Type | Mutates tip? | Authz | -|------|--------------|--------| -| `GroupChat` | append messages | reader or signer (policy) | -| `GroupChange` | members | **signer only** | -| `ContractCapabilityGrant` | grants | signer / issuer | -| `ContractWithdrawalRequest` | pending spend | signer | -| `ContractWithdrawalWitness` | witness set | signer | -| `GroupJournalRequest` / `Batch` / `GroupStateJournal` | sync | signer / federation | -| `MessageReceived` / `MessageReceipt` | sync filter (sidecar only) | participant (reader set) | - - -| `FederationContractInvite` (+ Response) | join flow | genesis / validators | - -Product apps MAY add types **only** if listed in that contract’s `primitives.messageTypes`. - ---- - -## 6. Tip → UTXO / spending contract -```text -resolveSpend({ genesis, tip }) = - buildContractTaproot( - spendPolicy from normalizeArcGenesis(genesis), - keys := tip.content.signers (else tip.content.members, else genesis signers/validators), - threshold := tip or genesis threshold - ) -→ { address, policy, leaves, network, stateDigest, bitcoinBlockHash } -``` - -Implementation: [`functions/contractSpend.js`](../functions/contractSpend.js) (`resolveSpend`, `buildWithdrawalRequest`, `validateWithdrawalRequest`, `prepareWithdrawalFromRequest`). `Contract#resolveSpend` delegates here. - -- **L1 withdrawal:** `ContractWithdrawalRequest` **MUST** bind `{ stateDigest, bitcoinBlockHash, destinationAddress, feeSats, … }` to the current tip; co-signers add `ContractWithdrawalWitness` with the same tip fields + `requestId`. Accumulate rejects stale bindings at ingest. -- **Program run binding:** When the tip seals `content.program` (`programHash` + `runCommitmentHex` from `Machine.runProgram` / `FabricProgramRun`), withdrawals MUST carry matching digests (`functions/contractProgramBind`). Genesis may require this via `interfaces: ['fabric.program']`, non-empty `primitives.opcodes`, or `spendPolicy.requireProgramRun`. Binding does **not** by itself change the P2TR address — Machine is the deterministic compute layer; Taproot remains the authority redeem surface. -- **Optional hashlock leaf:** `spendPolicy.hashlock` / `composeTaprootTree({ hashlock, extraLeaves })` adds a tapscript leaf (`OP_SHA256 EQUAL` or `… EQUALVERIFY CHECKSIG`). This **does** change the address. Commitment MAY be `runCommitmentHex` or `SHA256(preimage)`. See `docs/PROGRAM.md` and `prepareHashlockWithdrawalPsbt` / `finalizeHashlockPsbt`. -- **Composable trees:** `buildSpendLeaf` / `buildHashlockLeaf` / `buildScriptLeaf` + `composeTaprootTree` are the wallet-facing leaf API; Hub vault summaries expose `leaves[]` for UI selection. -- **L2 spending:** HTLC / channel offers SHOULD embed the same `contractId`, `stateDigest`, and `bitcoinBlockHash` so counterparties refuse stale tips. -- **Decay / CSV / CLTV:** encode and mature per BIP68/BIP65; never silently clamp k-of-n below declared threshold. - -`Contract#_handleBitcoinTransaction` remains a TODO: watch L1 for payments **to** `toAddress()` and fold balance into tip content under signer policy. - ---- - -## 7. Implementation map -| Concern | Module | -|---------|--------| -| Contract class | `types/contract.js` | -| Body type names | `functions/applicationNamespaces.js` | -| Tip → P2TR / withdrawals | `functions/contractSpend.js` | -| Machine/Program run bind | `functions/contractProgramBind.js` (`bindProgramRunToTip`, withdrawal digest checks) | -| Accumulate / fold | `functions/contractMessageAccumulate.js` (signer authz + primitives + withdrawal fold) | -| Token grants | `functions/contractCapability.js`, `types/token.js` | -| Tip Schnorr | `functions/contractStateSigning.js` | -| P2TR ladder | `functions/contractTaproot.js` | -| Sidechain doc | `functions/sidechainState.js`, `contractStatechains.js` | -| Hub tracked accept | Hub `functions/trackedApplicationContracts.js` (`normalizeArcGenesis` + `resolveSpend`) | - ---- - -## 8. Work remaining -1. ~~**Fix** `GroupChange` / member mutation authz in accumulate (PR #183 High)~~ -2. ~~**Enforce** `primitives.messageTypes` at ingest~~ — empty declared array is deny-all (not fail-open) -3. ~~**Thread** `bitcoinBlockHash` through withdrawal messages~~ — reject non-64-hex `meta.bitcoinBlockHash` before tip mutate -4. ~~**Wire** Token grants into member checks~~ -5. ~~**Document** Machine `opcodes[]` / Program bind when Program interface is attached~~ — see `docs/PROGRAM.md` + `contractProgramBind` -6. ~~**Complete** L1 payment observation on `Contract`~~ — `functions/contractPaymentObserve` + `Contract#_handleBitcoinTransaction` -7. ~~Align Hub tracked-contract accept path with this genesis shape~~ -8. ~~Call sites must pass `meta.genesis.signers` (or persist genesis)~~ — `ingestContractPublishBuffer` seeds genesis; later `GroupChange` reuses it -9. ~~**Enforce** GroupChat reader∪signer authz + BIP340 withdrawal witness / MessageReceipt~~ — `authorizeIngest` + `verifyWithdrawalWitnessSignature` / `markReceipt` (PR #183 review follow-up) -10. **RC deploy:** tag `@fabric/core`, bump Hub + application / relay deploy to the tag, verify AcceptTrackedApplicationContract attaches `spendAddress` + tip `bitcoinAnchor` -11. **Network promotion:** `beacon/NETWORK` binding + `FABRIC_BEACON_RESET_NETWORK=1` when flipping regtest → signet/mainnet (see `functions/beaconNetworkGuard.js`) -12. **Beacon as native ARC:** Hub auto-publishes `fabric-beacon` via `functions/beaconContractDefinition` on `startBeacon` (see Hub `docs/DISTRIBUTED_CONTRACT_EXECUTION.md`) -13. **Optional hashlock / composable trees:** `composeTaprootTree`, leaf builders, `prepareHashlockWithdrawalPsbt` — Hub vault PSBT uses full `policy` leaves; wallet UIs list `leaves[]` -14. ~~**ExecutionRun → FabricProgramRun / full Machine runner:**~~ Hub uses `fabric-execution` on `Machine`/`Program` (`executionProgramRunner`); digests via `executionRunBridge` -15. ~~**Enforce** genesis `primitives.opcodes` on `Machine.loadProgram` / `define`~~ — `functions/opcodeAllowList.js` + `Machine.setAllowedOpcodes` / `applyGenesisOpcodes` (fail closed when set; unrestricted legacy soft-coerce otherwise) -16. **API naming (Author Style):** public short ids such as `contractId` → `contractIdentifier` (and kin) where call sites allow — breaking; coordinate Hub / `@fabric/http` / apps (see root `AGENTS.md`) -17. ~~**Journal / re-fold growth:**~~ compactable types (`GroupChat`, `MessageReceived`, `MessageReceipt`) drop by hex-hash order above `maxJournalEntries` (genesis/meta; hard cap 20000); mutations are never evicted -18. ~~**Seal AAD:**~~ tip-bound `groupChatSeal` and participant / onion seals bind scheme (+ tip / contract / ephemeral) as AES-GCM AAD. Public API renames (16) remain. -19. ~~**Blinded-execution `at` bind (PR #183 follow-up):**~~ `decisionSigningMessage` v2 includes `at`; `recordProposalDecision` requires it. Same actor/proposal/decision/`at` is idempotent; a different `at` conflicts. -20. ~~**Peering self-suppress trust:**~~ offer/announce enqueue suppresses only from verified AMP `verifiedPubkey`, not remote-advertised `obj.pubkey` ([AUDIT.md](../AUDIT.md) §16). -21. **Eager `messageHex`:** Peer hot paths still materialize hex wire forms eagerly — laziness / cache invalidation is performance work. -22. **`API.md` regeneration:** run `npm run make:api` when next syncing field docs for gossip / `tryParseMessageBody` / `resolveSpend` opts. -23. ~~**Empty tip `signers` spend widen:**~~ `tipSpendKeys` treats explicit `signers: []` as authoritative so reader-only folds do not replace genesis Taproot validators with participant `members` (`resolveSpend` keeps genesis keys). -24. ~~**`createRound` omitted policy:**~~ defaults to `{}` (empty validators / threshold 1) instead of throwing. -25. ~~**Beacon federation `ready` finalization:**~~ `addSignature` stays closed at `ready`; core `Beacon#submitFederationEpochSignature` idempotently finalizes already-`ready` rounds if persist fails (does not reopen for new signatures). -26. ~~**GroupChangeProposal roster bind:**~~ vote string v2 includes canonical `members` / `signers`; fold still uses genesis/tip k-of-n (proposer `threshold` cannot lower the bar). - -### Dual P2TR surfaces → single authority ladder -| Surface | Role | -|---------|------| -| **Authority P2TR** (`taproot-authority-ladder-v1`) | One address: **t0-authority** = k-of-n validators/signers (immediate main-chain withdrawals); **t1-soft** unlocks after ~**144** blocks CSV (default softer rule = 1-of-publisher; optional `softMode: 'reduced'`) | -| Hub Federation vault | Same ladder via `buildFederationVaultFromPolicy` (default; legacy single-leaf opt-in only) | -| ARC `resolveSpend` / `spendAddress` | Same ladder when validators + threshold + network + csvBlocks + publisher match | -| **Beacon ARC** (`fabric-beacon`) | Same authority set as Federation validators; Actor id stable across networks; spend overlay at Accept / re-enrich | - -Do **not** maintain a second unrelated vault script for the same authority set. Genesis **MUST NOT** embed tip `bitcoinBlockHash` if Actor id stability across networks is required — Hub overlays network/tip at Accept time. - -**Canonical authority bag** (Beacon ARC genesis `spendPolicy`, Hub vault, `resolveSpend` return): - -```json -{ - "publisher": "", - "validators": ["..."], - "threshold": 1, - "csvBlocks": 144, - "softMode": "publisher" -} -``` - -`depositMaturityBlocks` is a deposit-UX alias of `csvBlocks` only. Tip overlays nest as `bitcoinAnchor: { blockHash, height }` (flat `bitcoinBlockHash` remains a summary alias). Spend tip keys prefer `content.signers` over a general `members` roster. Group folds keep **participants** in `members` and **authority** in `signers` / `proposedPolicy.validators` (application accept does not widen spend keys). - -Hub asserts vault `spendAddress` ≡ accepted `fabric-beacon` ARC after `startBeacon` (`authorityUnity` on vault / manifest). - ---- - -## 9. Non-goals (0.1) -- Full isolate/vm for arbitrary JS Programs (see `AUDIT.md`) -- Sphinx-level onion privacy inside ARC payloads -- Collapsing Beacon **epoch sealing** (`BEACON_EPOCH` / `beacon/CHAIN`) into application CONTRACT_MESSAGE traffic — the Beacon **service** stays the L1 clock; the Beacon **ARC** is the authority/spend/namespace surface under the same tracked-contract path -- Rewriting frozen application / Group Actor ids — Hub **normalizes** legacy `messageTypes` / `proposedPolicy` into ARC fields without republishing genesis diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 3d02029c9..9fedf31e9 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -5,7 +5,7 @@ binds parties, funds, state, or programs is a contract — not a separate “app layer. **Application Resource Contracts (ARC)** — genesis primitives, Token members, -tip→UTXO, and Bitcoin block-hash anchors — are specified in [`ARC.md`](ARC.md). +tip→UTXO, and Bitcoin block-hash anchors — are specified [below](#application-resource-contracts-arc). A contract is **not** classified by a single `kind` field. Instead, a contract **implements one or more interfaces** (capabilities peers and hubs can discover @@ -72,3 +72,243 @@ it does not cancel on-chain HTLCs, open sessions, or Hub-registry publishes. - Proposals — [CONTRACT_PROPOSAL.md](CONTRACT_PROPOSAL.md) - Terminal shell — [CLI.md](CLI.md) - Hub Bitcoin / value flows — Hub repo `CONTRACTS.md` + +## Application Resource Contracts (ARC) +**Status:** Draft — formalizes the Contract / ARC wire + state model for `@fabric/core` 0.1.x (`VERSION_NUMBER = 0x01`). +**Driven by:** security review themes on [FabricLabs/fabric#183](https://github.com/FabricLabs/fabric/pull/183) (authz, spend-policy fail-closed, path policy, Token grants). + +Related: [`CONTRACTS.md`](#fabric-contracts), [`APPLICATION_NAMESPACES.md`](APPLICATION_NAMESPACES.md), [`DISTRIBUTED_EXECUTION.md`](DISTRIBUTED_EXECUTION.md), [`functions/applicationNamespaces.js`](../functions/applicationNamespaces.js). + +--- + +### 1. What an ARC is +An **Application Resource Contract (ARC)** is a published Fabric **Contract** whose +genesis declares: + +1. **Primitives** — which `CONTRACT_MESSAGE` body types (and optional Machine opcodes) are in-bounds +2. **Members** — who may read and who may sign (via Tip roster and/or `Token` grants) +3. **Spend policy** — how tip state maps to a Bitcoin P2TR (and related L2) spending surface +4. **Anchor** — a **Bitcoin block hash** (and preferably height) binding the publish / tip to L1 time + +Namespace id = `Actor(genesis).id` after `CONTRACT_PUBLISH` (same as today). + +An ARC is **not** a separate opcode family. Outer wire remains: + +| Outer | Role | +|-------|------| +| `CONTRACT_PUBLISH` (`0x5f`) | Genesis | +| `CONTRACT_MESSAGE` (`0x60`) | Namespaced events | +| `CONTRACT_PROPOSAL` (`0x8a`) | Batched proposal / patch / optional PSBT | + +--- + +### 2. Genesis object (`CONTRACT_PUBLISH` body) +Minimum fields for a 0.1 ARC genesis (extensible; unknown fields ignored by core): + +```json +{ + "@type": "FabricContract", + "name": "example-arc", + "interfaces": ["arc.core"], + "primitives": { + "messageTypes": [ + "GroupChat", + "GroupChange", + "ContractCapabilityGrant", + "ContractWithdrawalRequest", + "ContractWithdrawalWitness", + "MessageReceived", + "MessageReceipt" + ], + "opcodes": [] + }, + "members": { + "signers": ["", "..."], + "readers": ["*"], + "threshold": 1 + }, + "spendPolicy": { + "network": "regtest", + "publisher": "", + "validators": ["..."], + "threshold": 1, + "csvBlocks": 144 + }, + "sidechainPolicy": { + "allowedPathPrefixes": ["/registry", "/members"], + "maxOps": 32 + }, + "bitcoinAnchor": { + "blockHash": "<64-hex>", + "height": 0 + } +} +``` + +#### Rules + +- **`primitives.messageTypes`**: ingest MUST reject `CONTRACT_MESSAGE` body `type` values not listed (apps may subclass catalogs only by publishing a new genesis or an authorized primitive-extend message — TBD; default fail-closed). +- **`primitives.deliverySync`**: optional synchronization filter for which body types open per-reader `MessageReceived` / `MessageReceipt` 2PC rows (`mode`: `all` | `none` | `include` | `exclude`). Delivery ACK types never open a new pending row. +- **`primitives.opcodes`**: Machine / Program opcode allow-list when the ARC exposes a program interface. Enforced by `Machine.setAllowedOpcodes` / `applyGenesisOpcodes` / `loadProgram({ genesis })` (fail closed when non-empty). +- **`members`**: initial tip roster. Later changes only via authorized `GroupChange` / grant messages (see §4). +- **`spendPolicy`**: input to `contractTaproot` / `Contract#toTaprootContract`. Fail closed on invalid threshold, CSV range, CLTV BIP65 boundary (see PR #183). +- **`bitcoinAnchor.blockHash`**: **required** for public-network ARCs; MUST be a known Bitcoin best-block hash at publish time (or last accepted tip when re-anchoring). + +--- + +### 3. Tip state +Each ARC maintains a tip: + +```text +tip = { + contractId, + clock, + stateDigest, // SHA256(canonical { version, clock, content }) + content, // members, messages, app bags, … + bitcoinBlockHash, // most recent L1 tip this state claims + bitcoinHeight? +} +``` + +**Standard:** every accepted state transition that advances `clock` SHOULD refresh +`bitcoinBlockHash` to the peer’s current Bitcoin tip (or Hub Beacon tip). Withdrawal +and L2 spend proofs MUST include the tip’s `stateDigest` **and** `bitcoinBlockHash`. + +--- + +### 4. Members, Tokens, and authz +| Role | Capability | How granted | +|------|------------|-------------| +| Reader | `OP_CONTRACT_READ` | Genesis `members.readers` or `ContractCapabilityGrant` Token | +| Signer | `OP_CONTRACT_SIGN` | Genesis `members.signers` / threshold set or grant Token | + +`functions/contractCapability.js` issues/verifies Tokens with `ctx.contractId`. + +#### MUST (security — PR #183) +1. **Ingest authz:** `contractMessageAccumulate.ingestMessageBuffer` (and equivalents) MUST NOT fold `GroupChange` (or other member/spend mutations) on AMP signature alone. Author MUST be in the current signer set **or** present a verified `OP_CONTRACT_SIGN` Token for this `contractId`. +2. **Verify vs parse:** Token verify without `issuerKey` MUST NOT look like authorization (`allowUnverified` only, result labeled `verified: false`). +3. **Path policy:** RFC6902 / `patchesCanonical` paths MUST stay inside genesis `sidechainPolicy` (no fail-open `policy = null` for registry helpers). + +--- + +### 5. Primitives catalog (core) +Shared body `type` strings (`CONTRACT_BODY_TYPES` in `applicationNamespaces.js`): + +| Type | Mutates tip? | Authz | +|------|--------------|--------| +| `GroupChat` | append messages | reader or signer (policy) | +| `GroupChange` | members | **signer only** | +| `ContractCapabilityGrant` | grants | signer / issuer | +| `ContractWithdrawalRequest` | pending spend | signer | +| `ContractWithdrawalWitness` | witness set | signer | +| `GroupJournalRequest` / `Batch` / `GroupStateJournal` | sync | signer / federation | +| `MessageReceived` / `MessageReceipt` | sync filter (sidecar only) | participant (reader set) | + + +| `FederationContractInvite` (+ Response) | join flow | genesis / validators | + +Product apps MAY add types **only** if listed in that contract’s `primitives.messageTypes`. + +--- + +### 6. Tip → UTXO / spending contract +```text +resolveSpend({ genesis, tip }) = + buildContractTaproot( + spendPolicy from normalizeArcGenesis(genesis), + keys := tip.content.signers (else tip.content.members, else genesis signers/validators), + threshold := tip or genesis threshold + ) +→ { address, policy, leaves, network, stateDigest, bitcoinBlockHash } +``` + +Implementation: [`functions/contractSpend.js`](../functions/contractSpend.js) (`resolveSpend`, `buildWithdrawalRequest`, `validateWithdrawalRequest`, `prepareWithdrawalFromRequest`). `Contract#resolveSpend` delegates here. + +- **L1 withdrawal:** `ContractWithdrawalRequest` **MUST** bind `{ stateDigest, bitcoinBlockHash, destinationAddress, feeSats, … }` to the current tip; co-signers add `ContractWithdrawalWitness` with the same tip fields + `requestId`. Accumulate rejects stale bindings at ingest. +- **Program run binding:** When the tip seals `content.program` (`programHash` + `runCommitmentHex` from `Machine.runProgram` / `FabricProgramRun`), withdrawals MUST carry matching digests (`functions/contractProgramBind`). Genesis may require this via `interfaces: ['fabric.program']`, non-empty `primitives.opcodes`, or `spendPolicy.requireProgramRun`. Binding does **not** by itself change the P2TR address — Machine is the deterministic compute layer; Taproot remains the authority redeem surface. +- **Optional hashlock leaf:** `spendPolicy.hashlock` / `composeTaprootTree({ hashlock, extraLeaves })` adds a tapscript leaf (`OP_SHA256 EQUAL` or `… EQUALVERIFY CHECKSIG`). This **does** change the address. Commitment MAY be `runCommitmentHex` or `SHA256(preimage)`. See `docs/PROGRAM.md` and `prepareHashlockWithdrawalPsbt` / `finalizeHashlockPsbt`. +- **Composable trees:** `buildSpendLeaf` / `buildHashlockLeaf` / `buildScriptLeaf` + `composeTaprootTree` are the wallet-facing leaf API; Hub vault summaries expose `leaves[]` for UI selection. +- **L2 spending:** HTLC / channel offers SHOULD embed the same `contractId`, `stateDigest`, and `bitcoinBlockHash` so counterparties refuse stale tips. +- **Decay / CSV / CLTV:** encode and mature per BIP68/BIP65; never silently clamp k-of-n below declared threshold. + +`Contract#_handleBitcoinTransaction` remains a TODO: watch L1 for payments **to** `toAddress()` and fold balance into tip content under signer policy. + +--- + +### 7. Implementation map +| Concern | Module | +|---------|--------| +| Contract class | `types/contract.js` | +| Body type names | `functions/applicationNamespaces.js` | +| Tip → P2TR / withdrawals | `functions/contractSpend.js` | +| Machine/Program run bind | `functions/contractProgramBind.js` (`bindProgramRunToTip`, withdrawal digest checks) | +| Accumulate / fold | `functions/contractMessageAccumulate.js` (signer authz + primitives + withdrawal fold) | +| Token grants | `functions/contractCapability.js`, `types/token.js` | +| Tip Schnorr | `functions/contractStateSigning.js` | +| P2TR ladder | `functions/contractTaproot.js` | +| Sidechain doc | `functions/sidechainState.js`, `contractStatechains.js` | +| Hub tracked accept | Hub `functions/trackedApplicationContracts.js` (`normalizeArcGenesis` + `resolveSpend`) | + +--- + +### 8. Work remaining +1. ~~**Fix** `GroupChange` / member mutation authz in accumulate (PR #183 High)~~ +2. ~~**Enforce** `primitives.messageTypes` at ingest~~ — empty declared array is deny-all (not fail-open) +3. ~~**Thread** `bitcoinBlockHash` through withdrawal messages~~ — reject non-64-hex `meta.bitcoinBlockHash` before tip mutate +4. ~~**Wire** Token grants into member checks~~ +5. ~~**Document** Machine `opcodes[]` / Program bind when Program interface is attached~~ — see `docs/PROGRAM.md` + `contractProgramBind` +6. ~~**Complete** L1 payment observation on `Contract`~~ — `functions/contractPaymentObserve` + `Contract#_handleBitcoinTransaction` +7. ~~Align Hub tracked-contract accept path with this genesis shape~~ +8. ~~Call sites must pass `meta.genesis.signers` (or persist genesis)~~ — `ingestContractPublishBuffer` seeds genesis; later `GroupChange` reuses it +9. ~~**Enforce** GroupChat reader∪signer authz + BIP340 withdrawal witness / MessageReceipt~~ — `authorizeIngest` + `verifyWithdrawalWitnessSignature` / `markReceipt` (PR #183 review follow-up) +10. **RC deploy:** tag `@fabric/core`, bump Hub + application / relay deploy to the tag, verify AcceptTrackedApplicationContract attaches `spendAddress` + tip `bitcoinAnchor` +11. **Network promotion:** `beacon/NETWORK` binding + `FABRIC_BEACON_RESET_NETWORK=1` when flipping regtest → signet/mainnet (see `functions/beaconNetworkGuard.js`) +12. **Beacon as native ARC:** Hub auto-publishes `fabric-beacon` via `functions/beaconContractDefinition` on `startBeacon` (see Hub `docs/DISTRIBUTED_CONTRACT_EXECUTION.md`) +13. **Optional hashlock / composable trees:** `composeTaprootTree`, leaf builders, `prepareHashlockWithdrawalPsbt` — Hub vault PSBT uses full `policy` leaves; wallet UIs list `leaves[]` +14. ~~**ExecutionRun → FabricProgramRun / full Machine runner:**~~ Hub uses `fabric-execution` on `Machine`/`Program` (`executionProgramRunner`); digests via `executionRunBridge` +15. ~~**Enforce** genesis `primitives.opcodes` on `Machine.loadProgram` / `define`~~ — `functions/opcodeAllowList.js` + `Machine.setAllowedOpcodes` / `applyGenesisOpcodes` (fail closed when set; unrestricted legacy soft-coerce otherwise) +16. **API naming (Author Style):** public short ids such as `contractId` → `contractIdentifier` (and kin) where call sites allow — breaking; coordinate Hub / `@fabric/http` / apps (see root `AGENTS.md`) +17. ~~**Journal / re-fold growth:**~~ compactable types (`GroupChat`, `MessageReceived`, `MessageReceipt`) drop by hex-hash order above `maxJournalEntries` (genesis/meta; hard cap 20000); mutations are never evicted +18. ~~**Seal AAD:**~~ tip-bound `groupChatSeal` and participant / onion seals bind scheme (+ tip / contract / ephemeral) as AES-GCM AAD. Public API renames (16) remain. +19. ~~**Blinded-execution `at` bind (PR #183 follow-up):**~~ `decisionSigningMessage` v2 includes `at`; `recordProposalDecision` requires it. Same actor/proposal/decision/`at` is idempotent; a different `at` conflicts. +20. ~~**Peering self-suppress trust:**~~ offer/announce enqueue suppresses only from verified AMP `verifiedPubkey`, not remote-advertised `obj.pubkey` ([AUDIT.md](../AUDIT.md) §16). +21. **Eager `messageHex`:** Peer hot paths still materialize hex wire forms eagerly — laziness / cache invalidation is performance work. +22. **`API.md` regeneration:** run `npm run make:api` when next syncing field docs for gossip / `tryParseMessageBody` / `resolveSpend` opts. +23. ~~**Empty tip `signers` spend widen:**~~ `tipSpendKeys` treats explicit `signers: []` as authoritative so reader-only folds do not replace genesis Taproot validators with participant `members` (`resolveSpend` keeps genesis keys). +24. ~~**`createRound` omitted policy:**~~ defaults to `{}` (empty validators / threshold 1) instead of throwing. +25. ~~**Beacon federation `ready` finalization:**~~ `addSignature` stays closed at `ready`; core `Beacon#submitFederationEpochSignature` idempotently finalizes already-`ready` rounds if persist fails (does not reopen for new signatures). +26. ~~**GroupChangeProposal roster bind:**~~ vote string v2 includes canonical `members` / `signers`; fold still uses genesis/tip k-of-n (proposer `threshold` cannot lower the bar). + +#### Dual P2TR surfaces → single authority ladder +| Surface | Role | +|---------|------| +| **Authority P2TR** (`taproot-authority-ladder-v1`) | One address: **t0-authority** = k-of-n validators/signers (immediate main-chain withdrawals); **t1-soft** unlocks after ~**144** blocks CSV (default softer rule = 1-of-publisher; optional `softMode: 'reduced'`) | +| Hub Federation vault | Same ladder via `buildFederationVaultFromPolicy` (default; legacy single-leaf opt-in only) | +| ARC `resolveSpend` / `spendAddress` | Same ladder when validators + threshold + network + csvBlocks + publisher match | +| **Beacon ARC** (`fabric-beacon`) | Same authority set as Federation validators; Actor id stable across networks; spend overlay at Accept / re-enrich | + +Do **not** maintain a second unrelated vault script for the same authority set. Genesis **MUST NOT** embed tip `bitcoinBlockHash` if Actor id stability across networks is required — Hub overlays network/tip at Accept time. + +**Canonical authority bag** (Beacon ARC genesis `spendPolicy`, Hub vault, `resolveSpend` return): + +```json +{ + "publisher": "", + "validators": ["..."], + "threshold": 1, + "csvBlocks": 144, + "softMode": "publisher" +} +``` + +`depositMaturityBlocks` is a deposit-UX alias of `csvBlocks` only. Tip overlays nest as `bitcoinAnchor: { blockHash, height }` (flat `bitcoinBlockHash` remains a summary alias). Spend tip keys prefer `content.signers` over a general `members` roster. Group folds keep **participants** in `members` and **authority** in `signers` / `proposedPolicy.validators` (application accept does not widen spend keys). + +Hub asserts vault `spendAddress` ≡ accepted `fabric-beacon` ARC after `startBeacon` (`authorityUnity` on vault / manifest). + +--- + +### 9. Non-goals (0.1) +- Full isolate/vm for arbitrary JS Programs (see `AUDIT.md`) +- Sphinx-level onion privacy inside ARC payloads +- Collapsing Beacon **epoch sealing** (`BEACON_EPOCH` / `beacon/CHAIN`) into application CONTRACT_MESSAGE traffic — the Beacon **service** stays the L1 clock; the Beacon **ARC** is the authority/spend/namespace surface under the same tracked-contract path +- Rewriting frozen application / Group Actor ids — Hub **normalizes** legacy `messageTypes` / `proposedPolicy` into ARC fields without republishing genesis diff --git a/docs/OUTSTANDING.md b/docs/OUTSTANDING.md index ec01abba1..10cdfdd9b 100644 --- a/docs/OUTSTANDING.md +++ b/docs/OUTSTANDING.md @@ -1,7 +1,7 @@ # Outstanding (security-first) Living queue for this repo. Detail and closed items live in [SECURITY.md](../SECURITY.md) and [AUDIT.md](../AUDIT.md). Suite production march: [PRODUCTION_MARCH.md](PRODUCTION_MARCH.md). -**Last reviewed:** 2026-08-14. Codacy `500000000` rewritten as `Number('500000000')`; genesis journal cap and publish-author fail-closed on existing files. +**Last reviewed:** 2026-08-19 ([Hub downstream scan](https://relay.goon.vc/downstream.agents.md) **23:31Z**). [#185](https://github.com/FabricLabs/fabric/pull/185) on `feature/rsi`. Live Hub **`c12df87`** ~73 h: crash loop broken; **slow heap climb** ~81 MiB → **~3.0 GiB @ 96%** / RSS **~8.6 GiB**. GitHub vs `master` file-count varies by wave — stay under review caps. ## Blockers before shared-host / non-experimental tag 1. **Third-party review** of `types/peer.js`, inventory HTLC, sealed exchange, `publishedDocumentEnvelope` ([AUDIT.md](../AUDIT.md) recommendations). @@ -9,21 +9,38 @@ Living queue for this repo. Detail and closed items live in [SECURITY.md](../SEC 3. Downstream **site-login / device-link redeem** is not a core bug; Hub/`@fabric/http` still treat QR `sessionId` as the capability. Tracked in those repos. ## Next slices (this repo) -- [ ] **IdentityCrossSign lift (local, not #183)** — `functions/identityCrossSign.js`, `fabricIdentitySchnorr.js`, `identityCrossSignVerify.js` + tests exist on disk. Held off [#183](https://github.com/FabricLabs/fabric/pull/183) (CodeRabbit ~150-file cap). Do **not** commit working-tree `package.json` / `PUBLIC_API.md` / `AGENTS.md` pointers until that follow-up PR (smoke would require missing leaves). Pin/link core before Hub/http/Passport/GoonCitizen re-exports CI. -- [ ] Type-tree keep/remove lock in [PRODUCTION_MARCH.md](PRODUCTION_MARCH.md) (`Scribe` / `Reader` / Global docs clutter). -- [ ] Eager `messageHex` laziness on Peer hot paths (performance, not a correctness claim). +- [ ] Production Hub **slow heap climb (P0)** ([scan 2026-08-19T23:31Z](https://relay.goon.vc/downstream.agents.md): restarts **539 +0**, FATAL **317 +0**, life **~73 h**; heap **~3035 MiB @ 95.6%**, RSS **~8.6 GiB**). Memory-bounding stopped the minute-scale loop; retainers still grow over days. Resume `Filesystem.publish` / STATE / sidechain triage with Hub. Do **not** raise `--max-old-space-size`. RSI peer write-after-close + oversized AMP frames are a separate P1 (RSS ~9.3 GiB, 0 FATAL). +- [ ] Keep documenting that `sensitive` ≠ encrypted ([PRIVACY.md](../PRIVACY.md), + [MESSAGE_BODY.md](MESSAGE_BODY.md)). Mesh chat model: + [MESSAGES.md](../MESSAGES.md#mesh-chat-model-gossip-first) + `functions/fabricChatKind.js`. Downstream privacy + queues: Hub/GoonCitizen `docs/OUTSTANDING.md` / GoonCitizen + `docs/PRIVACY_REMAINING.md`. +- [ ] Type-tree keep/remove lock in [PRODUCTION_MARCH.md](PRODUCTION_MARCH.md) (inventory remaining classes; `Scribe`/`Reader` deprecation notes are in). - [ ] Coordinated `contractId` → `contractIdentifier` rename. -- [ ] Hub / Passport callers still hard-coding BIP44 **7778** on mainnet should use `fabricIdentityDerivationPath(…, network)` (**7777** mainnet / **7778** otherwise). - [ ] Blinded-execution remains a **scaffold** (not Yao GC) even with signed `at`. -- [ ] Keep `.codacy.yml` Semgrep/Opengrep exclusions for `functions/fabricSetup.js` and `types/environment.js` unless a replacement SAST job covers those path-construction helpers (CodeRabbit asked to drop the excludes; Codacy still ignores `nosemgrep`). +- [ ] Keep `.codacy.yml` Semgrep/Opengrep exclusions for `functions/fabricSetup.js`, `functions/fabricHomeEnv.js`, `functions/fabricWalletIdentity.js`, `functions/fabricMessageCollection.js`, `types/environment.js`, and `types/filesystem.js` unless a replacement SAST job covers those path-construction helpers (CodeRabbit asked to drop the excludes; Codacy still ignores `nosemgrep`). The nine PR #185 criticals were all Filesystem `path.resolve` / `fs.*` on a contained name. ## Closed this pass (do not re-open) +- Identity coin-type dual path (7777 mainnet / 7778 otherwise) is closed at the helper layer: `resolveFabricIdentityCoinType`, `fabricIdentityAccountPath`, `fabricIdentityDerivationPath`. Hub and GoonCitizen no longer hard-code `m/44'/7778'/…`. See [IDENTITY.md](IDENTITY.md) (BIP44 / SLIP-44; 7777 remains Bitvote on SLIP-44 — integers unchanged). Do not silently re-derive existing Hub identities. +- Undersize AMP frames (`< HEADER_SIZE`) and unparseable buffers drop before parse/crypto (no score / ban). Oversized already did. Locked in `tests/fabric.peer.adversarial.js` with the other first-tier RC1 invariants (hop=0 does not poison gossip **or peering** cache; BASE_MESSAGE cannot inject gossip, chat, alias, or announce; budget is TCP-origin keyed; score-0 still disconnects; sealed wallet public object omits seed/xprv). +- JSON `type: 98` on a **P2P_PEERING_OFFER** AMP frame (or GenericMessage) still dispatches as a peering offer via `_handleGenericMessage` / `canonicalTypeName`. First-class **other** opcodes keep the AMP wire name (`INVENTORY_RESPONSE`, gossip, …) so JSON `type: 98` cannot smuggle a candidate. Locked in `tests/fabric.peer.adversarial.js` with beat/isolate/NOISE-handler and `_fillPeerSlots` retry-map coverage from [#185](https://github.com/FabricLabs/fabric/pull/185) Codecov + review threads. `resolveFabricIdentityCoinType` / `fabricIdentityAccountPath` are exported from `constants.js`. `Message.fromRaw` copies TypedArray/DataView bounds. Service ChatMessage relays are signed when `node.key` is present. `Key` classifies a legacy `xprv` or mnemonic in `seed` rather than PBKDF2-hashing the string. +- Fabric TUI shoutbox uses UTF-8 `P2P_CHAT_MESSAGE` + `peerAlias` (same mesh as Hub UI / GoonCitizen). Helper `functions/fabricChatText`. +- IdentityCrossSign / identity Schnorr / verify lifted into `functions/` after [#183](https://github.com/FabricLabs/fabric/pull/183) merge (was held for the CodeRabbit file cap). +- `signCrossSign` localPubkey is the Fabric signing pubkey (not Bech32 `id` / HD master). Canonical cross-sign pubkeys are compressed or x-only hex; unknown `kind` is rejected. `fabricIdentityIdFromPubkeyHex` requires compressed 66-hex (no truncated `Buffer.from(..., 'hex')`). `createdAt` is unsigned display metadata. JSDoc on the `.js` helpers covers arities / `ok` unions. +- `_fillPeerSlots` candidate retry cooldown + NOISE-after-TCP-connect (playnet `:7778` ECONNREFUSED / MaxListeners storm). `_connect` also skips in-flight `_outboundDialTargets`. Dial keys are canonical `host:port` so `pubkey@host:port` cannot duplicate `host:port`. Node URL IPv6 hostnames keep brackets — strip one pair before `createConnection` so `[::1]:port` does not become `[[::1]]:port`. `_candidateRetryAt` is pruned and capped to `maxCandidates`; `_disconnect` and inbound encrypt-end / banned-static paths tear down NOISE; outbound connect setup is try/caught. +- Wallet tmp files use `pid` + random suffix; Environment tests isolate `$HOME` inline (same helper in `tests/fabric.environment.js` / `tests/fabric.peer.adversarial.js`). +- `FABRIC_XPRV` / raw-hex `FABRIC_SEED` / `FABRIC_MNEMONIC` plus `~/.fabric/env` and `~/.fabric/hub-admin-token` (`functions/fabricHomeEnv`, `fabricKeyMaterial`, `fabricWalletIdentity`). `Key` `FROM_SEED` accepts 16–64 byte hex and sets `status = 'seeded'` (same as mnemonic). JSDoc on those helpers and `fabricChatText.chatActorIdOf` avoids TypeScript `?:` (`jsdoc2md`). `NODE_ENV=test` still prefers the fixture seed over a developer `FABRIC_XPRV` for Environment CLI/mocha. `loadIdentityFromWalletFile` uses `loadWallet({ fromFile: true })` so leftover `FABRIC_SEED` **and** `FABRIC_XPRV` cannot replace `wallet.json` (`tests/fabric.environment.js`). Sidechain path policy: allow-prefix `'/'` is a wildcard (`functions/sidechainState.js` `_pathMatchesPrefix`); never ship it. Codacy Semgrep/Opengrep excludes those home-env path helpers (same class as `types/environment.js`). +- Chaos fuzz prefers a still-connected peer, lands a dozen signed frames before hostile AMP, and fails the playnet storm on unexpected `hardErrors` (`writes.ok` remains invocation count — `_writeFabric` can skip a closed stream without throwing). +- `contract:message` `messageHex` is lazy (getter). `Reader` is `@deprecated` (fold into Peer/Message ingest). - Blinded-execution `at` bind; Beacon `ready` finalize; peering self-suppress via verified AMP signer; empty `signers: []` spend-key widen; Beacon ARC authority walk. - Gossip-network `P2P_PEERING_OFFER` candidate expect includes AMP `verifiedPubkey` (`tests/fabric.peer.gossip-network.js`). - Codacy `no-loss-of-precision` on `functions/contractTaproot.js` `CLTV_TIMESTAMP_THRESHOLD` — use `Number('500000000')` (decimal and `5e8` both trip the PR check). - `setWallet` restores in-memory state and unlinks a placeholder `wallet.json` after a failed first save; CLI `--password` stops at `--`, accepts `-secret`, and does not env-fallback when the flag is present. - Genesis `primitives.maxJournalEntries` wins over peer-local `meta.maxJournalEntries`; `CONTRACT_PUBLISH` rejects a non-64-hex author. - `printGeneratedWallet` never prints the master xprv (`FABRIC_DEBUG` included). Plaintext restore validates encryption password before `setWallet`. +- Fabric Message collections (`functions/fabricMessageCollection`, [MESSAGES.md](../MESSAGES.md#fabric-message-collections)) — collect / store / replay bit-identical AMP frames for journals, Discord packs, and catch-up. Not Hub `messages/*.json`. Relative CLI paths stay under cwd (`resolveCollectionFilePath`). +- Interactive MuSig2 defaults to `musig2.autoAccept: false` so a connected peer cannot use this node's identity key as a signing oracle. BIP-21 parse rejects unsupported `req-*`. Static server and `report-credits` lockfile reads are path-hardened. After `stat`, `realpath` must stay under the document root (symlink escape is 403). Example onion smoke verifies the decoded inner payload with the origin key. File-level JSDoc on examples + `scripts/report-credits.js` uses `@fileoverview`. +- `Filesystem.writeFile` / `readFile` / `delete` reject names that resolve outside the store root (`../`, absolute). Nested paths under the root still work. Final-component symlinks are refused on read/write (`O_NOFOLLOW`); `delete` unlinks the link without following. Intermediate directory symlinks that `realpath` outside the store are refused. Codacy Semgrep/Opengrep exclude `types/filesystem.js` for the same reason as other path-hardened helpers (nine PR-check criticals were `path.resolve` / `fs.*` on already-contained names). ## PRs -[#183](https://github.com/FabricLabs/fabric/pull/183) — mocha + codecov green at `4d7351ee3`; remaining GitHub check was Codacy (`500000000` literal). Cursor review on `4d7351ee3` reports **no remaining High/Medium/Critical**. Still deferred: IdentityCrossSign lift (file cap); `sealedBlob`/`identityLock` `.d.ts` stubs (new files); RFC6902 multi-op JSON bridge (http); eager `messageHex`; `withIsolatedHome` test helper; wallet tmp-name random suffix; `.codacy.yml` Semgrep excludes stay until a replacement SAST job exists. +[#185](https://github.com/FabricLabs/fabric/pull/185) — WIP ARC polish on `feature/rsi` after [#183](https://github.com/FabricLabs/fabric/pull/183) merge. HEAD **`0b8ce4b22`**. GitHub file count is vs **`master`** (**129**, under 150). Do not add playnet-chaos, protocol-v1, MQTT, or sidecar `functions/*.d.ts`. Filesystem final-component `O_NOFOLLOW` is in tree. Still deferred: unique Service commit ids; Blessed TUI chat markup; `isolatePeerContent` collections beyond the null/array map lock; RFC6902 multi-op JSON bridge (http); docstring-coverage gate; Filesystem intermediate-symlink TOCTOU (no Node `openat`). Dropping Codacy Semgrep excludes for home-env / collection / Filesystem path helpers is **wontfix**. Keep `report:install` lockfile wipe (`rm -f`; core has no git deps). Do not exclude all of `types/message.js` from Codacy. Do **not** coerce first-class opcodes through `canonicalTypeName(parsed.type)` (inventory `type: 98` must not become a peering offer). diff --git a/docs/P2P_FORWARD.md b/docs/P2P_FORWARD.md index c472a32f3..1ca655401 100644 --- a/docs/P2P_FORWARD.md +++ b/docs/P2P_FORWARD.md @@ -83,14 +83,13 @@ node examples/onion-forward.js ## Tests - `tests/functions.fabricOnion.js` -- `tests/peer.onion.forward.js` -- `tests/onionChatSeal.test.js` (seal/open, multi-hop relay ciphertext, mesh cleartext) +- `tests/peer.onion.forward.js` (onion hops, chat seal/open, multi-hop relay ciphertext, mesh cleartext) ## Downstream - Hub JSON-RPC **`SendOnion`** — TCP Peer on the hub agent; Bridge drops WS `P2P_FORWARD`. - `@fabric/http` does not terminate onions (see `docs/MESSAGE_SPEC.md`). ## Work remaining -- ~~**Seal AAD:**~~ tip + participant AES-GCM AAD bound in `groupChatSeal` / `onionChatSeal` (see [ARC.md](ARC.md) §8). +- ~~**Seal AAD:**~~ tip + participant AES-GCM AAD bound in `groupChatSeal` / `onionChatSeal` (see [CONTRACTS.md](CONTRACTS.md#application-resource-contracts-arc) §8). - **Hop encryption:** Sphinx/BOLT4-class routing metadata protection remains out of scope for 0.1 (accepted risk above). -- **Journal / re-fold growth** and **API renaming** remain tracked in [ARC.md](ARC.md) §8 / [AUDIT.md](../AUDIT.md). +- **Journal / re-fold growth** and **API renaming** remain tracked in [CONTRACTS.md](CONTRACTS.md#application-resource-contracts-arc) §8 / [AUDIT.md](../AUDIT.md). diff --git a/functions/beaconContractDefinition.js b/functions/beaconContractDefinition.js index ed611e396..0c1d90b8f 100644 --- a/functions/beaconContractDefinition.js +++ b/functions/beaconContractDefinition.js @@ -10,11 +10,12 @@ * redeploys across regtest → signet → mainnet with Accept overlays. * * @module functions/beaconContractDefinition - * @see docs/ARC.md + * @see docs/CONTRACTS.md */ const Actor = require('../types/actor'); const { pubkeyCompressed } = require('./groupChatSeal'); +const { sortPubkeysBip67 } = require('./bip67'); const { canonicalSpendPolicy } = require('./contractSpend'); const { DEFAULT_CSV_BLOCKS } = require('./contractTaproot'); @@ -54,8 +55,7 @@ function normalizeValidatorPubkeys (list) { seen.add(key); out.push(c.toLowerCase()); } - out.sort((a, b) => Buffer.from(a, 'hex').compare(Buffer.from(b, 'hex'))); - return out; + return sortPubkeysBip67(out).map((b) => b.toString('hex')); } /** diff --git a/functions/bip125.js b/functions/bip125.js new file mode 100644 index 000000000..e2ee7d53d --- /dev/null +++ b/functions/bip125.js @@ -0,0 +1,59 @@ +/** + * BIP-125 opt-in full replace-by-fee signaling via nSequence. + * + * A transaction opts in if **any** input has nSequence < 0xffffffff − 1. + * `0xfffffffe` is non-final (enables nLockTime / CLTV) but does **not** + * signal RBF. Wallets that want both typically use `0xfffffffd` (MAX − 2). + * + * @fileoverview BIP-125 RBF nSequence helpers. + * @module functions/bip125 + */ +'use strict'; + +/** nSequence / nLockTime maximum (2^32 − 1). */ +const SEQUENCE_MAX = (2 ** 32) - 1; +/** Final sequence (does not enable nLockTime; does not signal BIP-125 RBF). */ +const BIP125_SEQUENCE_FINAL = SEQUENCE_MAX; +/** + * Non-final for nLockTime / CLTV, does **not** opt in to BIP-125 RBF. + * Used by inventory HTLC refund spends. + */ +const BIP125_SEQUENCE_LOCKTIME_ONLY = SEQUENCE_MAX - 1; +/** Common wallet RBF sequence (MAX − 2). */ +const BIP125_SEQUENCE_RBF = SEQUENCE_MAX - 2; + +/** + * BIP-125: nSequence < 0xfffffffe opts this input into replace-by-fee. + * + * @param {number} nSequence + * @returns {boolean} + */ +function sequenceSignalsOptInRbf (nSequence) { + const n = Number(nSequence); + if (!Number.isInteger(n) || n < 0 || n > SEQUENCE_MAX) return false; + return n < BIP125_SEQUENCE_LOCKTIME_ONLY; +} + +/** + * True when any input's nSequence signals BIP-125 RBF. + * + * @param {Array<{sequence: (number|undefined), nSequence: (number|undefined)}>} inputs + * @returns {boolean} + */ +function transactionSignalsOptInRbf (inputs) { + if (!Array.isArray(inputs) || !inputs.length) return false; + for (const inp of inputs) { + const seq = inp && (inp.sequence != null ? inp.sequence : inp.nSequence); + if (sequenceSignalsOptInRbf(seq)) return true; + } + return false; +} + +module.exports = { + SEQUENCE_MAX, + BIP125_SEQUENCE_FINAL, + BIP125_SEQUENCE_LOCKTIME_ONLY, + BIP125_SEQUENCE_RBF, + sequenceSignalsOptInRbf, + transactionSignalsOptInRbf +}; diff --git a/functions/bip21.js b/functions/bip21.js new file mode 100644 index 000000000..91455ff2f --- /dev/null +++ b/functions/bip21.js @@ -0,0 +1,103 @@ +/** + * BIP-21 `bitcoin:` payment URIs (amount, label, message) plus opaque extra + * query keys (`pj=`, `lightning=`, …) so BIP-321 / BIP-78 callers can round-trip + * without this module claiming full BIP-321 grammar. + * + * @fileoverview BIP-21 bitcoin: URI encode/parse. + * @module functions/bip21 + */ +'use strict'; + +/** + * @param {*} amount + * @returns {string} + * @private + */ +function formatAmount (amount) { + if (typeof amount === 'number') { + if (!Number.isFinite(amount) || amount < 0) { + throw new RangeError('BIP21 amount must be a non-negative BTC value'); + } + const formatted = amount.toFixed(8); + if (Number(formatted) !== amount) { + throw new RangeError('BIP21 amount must be a non-negative BTC value'); + } + return formatted; + } + const s = String(amount).trim(); + if (!s || !/^\d+(\.\d+)?$/.test(s)) { + throw new RangeError('BIP21 amount must be a decimal BTC string'); + } + return s; +} + +/** + * Build a BIP-21 URI. + * + * @param {Object} opts + * @param {string} opts.address + * @param {number|string} [opts.amount] BTC (not satoshis) + * @param {string} [opts.label] + * @param {string} [opts.message] + * @param {Object} [opts.extras] extra query keys (`pj`, `lightning`, …) + * @returns {string} + */ +function encodeBitcoinUri (opts = {}) { + const address = String(opts.address || '').trim(); + if (!address) throw new Error('BIP21 URI requires an address'); + if (/[?#]/.test(address)) throw new Error('BIP21 address must not contain ? or #'); + const params = new URLSearchParams(); + if (opts.amount != null && opts.amount !== '') { + params.set('amount', formatAmount(opts.amount)); + } + if (opts.label) params.set('label', String(opts.label)); + if (opts.message) params.set('message', String(opts.message)); + const extras = opts.extras && typeof opts.extras === 'object' ? opts.extras : {}; + for (const key of Object.keys(extras)) { + if (key === 'amount' || key === 'label' || key === 'message') continue; + const val = extras[key]; + if (val == null || val === '') continue; + params.set(key, String(val)); + } + const query = params.toString(); + return query ? `bitcoin:${address}?${query}` : `bitcoin:${address}`; +} + +/** + * Parse a BIP-21 (or BIP-21-shaped BIP-321) URI. + * + * @param {string} uri + * @returns {Object} parsed URI (`address`, `amount`, `label`, `message`, `extras`) + */ +function parseBitcoinUri (uri) { + const raw = String(uri || '').trim(); + const m = /^bitcoin:(\/\/)?([^?#]*)(?:\?([^#]*))?(?:#.*)?$/i.exec(raw); + if (!m) throw new Error('not a bitcoin: URI'); + const address = decodeURIComponent(m[2] || '').trim(); + const extras = {}; + let amount = null; + let label = null; + let message = null; + if (m[3]) { + const params = new URLSearchParams(m[3]); + for (const [key, value] of params.entries()) { + const k = String(key); + if (k.toLowerCase().startsWith('req-')) { + throw new Error(`unsupported required BIP21 parameter: ${k}`); + } + if (k === 'amount') amount = value; + else if (k === 'label') label = value; + else if (k === 'message') message = value; + else extras[k] = value; + } + } + if (amount != null && (Number(amount) < 0 || !/^\d+(\.\d+)?$/.test(String(amount)))) { + throw new Error('BIP21 amount is invalid'); + } + return { address, amount, label, message, extras }; +} + +module.exports = { + encodeBitcoinUri, + parseBitcoinUri +}; diff --git a/functions/bip67.js b/functions/bip67.js new file mode 100644 index 000000000..f27de1b91 --- /dev/null +++ b/functions/bip67.js @@ -0,0 +1,57 @@ +/** + * BIP-67 lexicographic compressed-pubkey ordering for deterministic multisig + * redeem scripts (and the same byte order used by Fabric Taproot k-of-n leaves). + * + * @fileoverview BIP-67 pubkey sort. + * @module functions/bip67 + */ +'use strict'; + +/** + * @param {Buffer|Uint8Array|string} key + * @returns {Buffer} + * @private + */ +function asCompressedPubkey (key) { + let buf; + if (Buffer.isBuffer(key)) buf = key; + else if (key instanceof Uint8Array) buf = Buffer.from(key); + else if (typeof key === 'string') { + const hex = String(key).trim().toLowerCase().replace(/^0x/, ''); + if (!/^(02|03)[0-9a-f]{64}$/.test(hex)) { + throw new Error('BIP67 pubkey must be 33-byte compressed hex'); + } + buf = Buffer.from(hex, 'hex'); + } else { + throw new TypeError('BIP67 pubkey must be Buffer, Uint8Array, or hex string'); + } + if (buf.length !== 33 || (buf[0] !== 0x02 && buf[0] !== 0x03)) { + throw new Error('BIP67 pubkey must be a 33-byte compressed secp256k1 key'); + } + return buf; +} + +/** + * @param {Buffer|Uint8Array|string} a + * @param {Buffer|Uint8Array|string} b + * @returns {number} + */ +function comparePubkeysBip67 (a, b) { + return Buffer.compare(asCompressedPubkey(a), asCompressedPubkey(b)); +} + +/** + * Return a new array of compressed pubkey Buffers in BIP-67 order. + * + * @param {Array} keys + * @returns {Array} + */ +function sortPubkeysBip67 (keys) { + if (!Array.isArray(keys)) throw new TypeError('keys must be an array'); + return keys.map(asCompressedPubkey).sort(comparePubkeysBip67); +} + +module.exports = { + comparePubkeysBip67, + sortPubkeysBip67 +}; diff --git a/functions/bip69.js b/functions/bip69.js new file mode 100644 index 000000000..7a673d7c7 --- /dev/null +++ b/functions/bip69.js @@ -0,0 +1,241 @@ +/** + * BIP-69 lexicographical ordering of transaction inputs and outputs. + * + * Inputs: previous output hash in **display** (reversed-from-wire) byte order, + * then previous output index. Outputs: amount (uint64) then scriptPubKey bytes. + * + * Does not mutate the input arrays. Does not change signature hash types; + * callers still sign after sorting. + * + * Also hosts BIP-49 nested-SegWit payment helpers (`p2shP2wpkhPayment`) so + * that slice does not add a PR file. Path templates stay in `constants.js`; + * default funds path remains BIP-44 `m/44'/0'/0'/0/0`. + * + * @fileoverview BIP-69 vin/vout lexicographic sort + BIP-49 P2WPKH-in-P2SH. + * @module functions/bip69 + */ +'use strict'; + +const { payments, networks } = require('bitcoinjs-lib'); + +/** + * @param {Buffer|Uint8Array|string} value + * @param {string} [encoding] + * @returns {Buffer} + * @private + */ +function asBuffer (value, encoding) { + if (Buffer.isBuffer(value)) return value; + if (value instanceof Uint8Array) return Buffer.from(value); + if (typeof value === 'string') return Buffer.from(value, encoding || 'hex'); + throw new TypeError('expected Buffer, Uint8Array, or hex string'); +} + +/** + * 32-byte previous-output hash in BIP-69 comparison order (display txid bytes). + * + * - `txid` / `txId`: 64-char display hex (the form used in BIP-69 examples). + * - `hash` Buffer: bitcoinjs-lib wire/internal hash (little-endian on the wire); + * reversed to display order unless `hashIsDisplay` is true. + * + * @param {Object} input + * @returns {Buffer} + */ +function prevoutHashDisplay (input) { + if (!input || typeof input !== 'object') throw new TypeError('input is required'); + const txid = input.txid != null ? input.txid : input.txId; + if (txid != null && String(txid).length) { + const hex = String(txid).trim().toLowerCase().replace(/^0x/, ''); + if (!/^[0-9a-f]{64}$/.test(hex)) { + throw new Error('txid must be 32-byte hex (display order)'); + } + return Buffer.from(hex, 'hex'); + } + if (input.hash == null) throw new Error('input requires txid or hash'); + const hash = asBuffer(input.hash); + if (hash.length !== 32) throw new Error('prevout hash must be 32 bytes'); + if (input.hashIsDisplay) return Buffer.from(hash); + return Buffer.from(hash).reverse(); +} + +/** + * Previous output index (`vout` or bitcoinjs `index`). + * + * @param {Object} input + * @returns {number} + */ +/** Bitcoin prevout index is a 32-bit unsigned integer (Codacy no-loss-of-precision). */ +const UINT32_MAX = Number('4294967295'); + +function prevoutIndex (input) { + const v = input.vout != null ? input.vout : input.index; + const n = Number(v); + if (!Number.isInteger(n) || n < 0 || n > UINT32_MAX) { + throw new RangeError('prevout index must be a uint32'); + } + return n; +} + +/** + * @param {Object} a + * @param {Object} b + * @returns {number} + */ +function compareInputs (a, b) { + const ha = prevoutHashDisplay(a); + const hb = prevoutHashDisplay(b); + const cmp = Buffer.compare(ha, hb); + if (cmp !== 0) return cmp; + return prevoutIndex(a) - prevoutIndex(b); +} + +/** + * @param {Object} output + * @returns {number} + * @private + */ +function outputValue (output) { + const raw = output.value != null ? output.value : output.amount; + if (typeof raw === 'bigint') { + if (raw < 0n || raw > 0xffffffffffffffffn) { + throw new RangeError('output value must fit in uint64'); + } + return Number(raw); + } + const n = Number(raw); + if (!Number.isInteger(n) || n < 0 || n > Number.MAX_SAFE_INTEGER) { + throw new RangeError('output value must be a non-negative integer'); + } + return n; +} + +/** + * @param {Object} output + * @returns {Buffer} + * @private + */ +function outputScript (output) { + const s = output.script != null + ? output.script + : (output.scriptPubKey != null ? output.scriptPubKey : output.scriptpubkey); + if (s == null) throw new Error('output requires script / scriptPubKey'); + return asBuffer(s); +} + +/** + * @param {Object} a + * @param {Object} b + * @returns {number} + */ +function compareOutputs (a, b) { + const va = outputValue(a); + const vb = outputValue(b); + if (va !== vb) return va < vb ? -1 : 1; + return Buffer.compare(outputScript(a), outputScript(b)); +} + +/** + * @param {Array} inputs + * @returns {Array} + */ +function sortInputs (inputs) { + if (!Array.isArray(inputs)) throw new TypeError('inputs must be an array'); + const copy = inputs.slice(); + for (let i = 0; i < copy.length; i++) { + prevoutHashDisplay(copy[i]); + prevoutIndex(copy[i]); + } + return copy.sort(compareInputs); +} + +/** + * @param {Array} outputs + * @returns {Array} + */ +function sortOutputs (outputs) { + if (!Array.isArray(outputs)) throw new TypeError('outputs must be an array'); + const copy = outputs.slice(); + for (let i = 0; i < copy.length; i++) { + outputValue(copy[i]); + outputScript(copy[i]); + } + return copy.sort(compareOutputs); +} + +/** + * Sort a bitcoinjs-lib-like `{ ins, outs }` transaction in place on copies. + * + * @param {Object} tx + * @param {Array} [tx.ins] + * @param {Array} [tx.outs] + * @returns {{ ins: Array, outs: Array }} + */ +function sortTransaction (tx) { + const ins = sortInputs((tx && tx.ins) || []); + const outs = sortOutputs((tx && (tx.outs || tx.outputs)) || []); + return { ins, outs }; +} + +/** + * @param {Buffer|Uint8Array|string} key + * @returns {Buffer} + * @private + */ +function asCompressedPubkey (key) { + let buf; + if (Buffer.isBuffer(key)) buf = key; + else if (key instanceof Uint8Array) buf = Buffer.from(key); + else if (typeof key === 'string') { + const hex = String(key).trim().toLowerCase().replace(/^0x/, ''); + if (!/^(02|03)[0-9a-f]{64}$/.test(hex)) { + throw new Error('BIP49 pubkey must be 33-byte compressed hex'); + } + buf = Buffer.from(hex, 'hex'); + } else { + throw new TypeError('BIP49 pubkey must be Buffer, Uint8Array, or hex string'); + } + if (buf.length !== 33 || (buf[0] !== 0x02 && buf[0] !== 0x03)) { + throw new Error('BIP49 pubkey must be a 33-byte compressed secp256k1 key'); + } + return buf; +} + +/** + * bitcoinjs `payments.p2sh({ redeem: p2wpkh })` for a compressed pubkey. + * + * @param {Object} opts + * @param {Buffer|Uint8Array|string} opts.pubkey compressed secp256k1 public key + * @param {Object} [opts.network] bitcoinjs network (default mainnet) + * @returns {Object} bitcoinjs payment (`address`, `output`, `redeem`, …) + */ +function p2shP2wpkhPayment (opts = {}) { + const pubkey = asCompressedPubkey(opts.pubkey); + const network = opts.network || networks.bitcoin; + const redeem = payments.p2wpkh({ pubkey, network }); + return payments.p2sh({ redeem, network }); +} + +/** + * BIP-49 nested-SegWit address for a compressed pubkey. + * + * @param {Object} opts + * @param {Buffer|Uint8Array|string} opts.pubkey + * @param {Object} [opts.network] + * @returns {string} + */ +function p2shP2wpkhAddress (opts = {}) { + const pay = p2shP2wpkhPayment(opts); + if (!pay.address) throw new Error('BIP49 address derivation failed'); + return pay.address; +} + +module.exports = { + compareInputs, + compareOutputs, + prevoutHashDisplay, + sortInputs, + sortOutputs, + sortTransaction, + p2shP2wpkhPayment, + p2shP2wpkhAddress +}; diff --git a/functions/bip85.js b/functions/bip85.js new file mode 100644 index 000000000..a0ca5a70c --- /dev/null +++ b/functions/bip85.js @@ -0,0 +1,249 @@ +/** + * BIP-85 deterministic entropy from a BIP-32 root key. + * + * HMAC-SHA512(key = "bip-entropy-from-k", msg = derived private key k) yields + * 64 bytes of entropy. Application paths are fully hardened under purpose + * `m/83696968'`. + * + * @fileoverview BIP-85 entropy from BIP-32. + * @module functions/bip85 + */ +'use strict'; + +const { hmac } = require('@noble/hashes/hmac.js'); +const { sha512 } = require('@noble/hashes/sha2.js'); +const { secp256k1 } = require('@noble/curves/secp256k1.js'); +const { fromBase58, HDNode, DEFAULT_NETWORK } = require('./bip32'); +const { entropyToMnemonic } = require('./bip39'); +const { encodeCheck } = require('./base58'); +const { toUint8Strict } = require('./bytes'); + +/** BIP-85 purpose index (`"BIPS"`-era constant from the specification). */ +const BIP85_PURPOSE = Number('83696968'); +/** Maximum unhardened BIP-32 child index (2^31 − 1). */ +const HARDENED_INDEX_MAX = Number('2147483647'); +/** HMAC key required by BIP-85. */ +const BIP85_HMAC_KEY = Buffer.from('bip-entropy-from-k', 'utf8'); + +const APP_BIP39 = 39; +const APP_HDSEED_WIF = 2; +const APP_XPRV = 32; +const APP_HEX = 128169; + +const MNEMONIC_WORD_COUNTS = Object.freeze([12, 15, 18, 21, 24]); + +/** + * @param {HDNode|string} root + * @returns {HDNode} + * @private + */ +function asRoot (root) { + if (root && typeof root.derivePath === 'function') return root; + if (typeof root === 'string') return fromBase58(root); + throw new TypeError('BIP85 root must be an HDNode or xprv string'); +} + +/** + * @param {number} n + * @param {string} label + * @returns {number} + * @private + */ +function assertHardenedIndex (n, label) { + const v = Number(n); + if (!Number.isInteger(v) || v < 0 || v > HARDENED_INDEX_MAX) { + throw new RangeError(`BIP85 ${label} must be an integer in [0, 0x7fffffff]`); + } + return v; +} + +/** + * Fully-hardened BIP-85 path `m/83696968'/{app}'/{segments}'…`. + * + * @param {number} application + * @param {Array} segments remaining hardened indexes (including the + * application index when that is the last component) + * @returns {string} + */ +function bip85Path (application, segments) { + const app = assertHardenedIndex(application, 'application'); + const parts = [`m/${BIP85_PURPOSE}'`, `${app}'`]; + const rest = Array.isArray(segments) ? segments : [segments]; + for (let i = 0; i < rest.length; i++) { + parts.push(`${assertHardenedIndex(rest[i], 'path[' + i + ']')}'`); + } + return parts.join('/'); +} + +/** + * 64-byte BIP-85 entropy for an already-derived child private key. + * + * @param {Buffer|Uint8Array} privateKey32 + * @returns {Buffer} + */ +function entropyFromPrivateKey (privateKey32) { + const k = Buffer.from(toUint8Strict(privateKey32)); + if (k.length !== 32) throw new Error('BIP85 private key must be 32 bytes'); + return Buffer.from(hmac(sha512, toUint8Strict(BIP85_HMAC_KEY), toUint8Strict(k))); +} + +/** + * Derive 64 bytes of BIP-85 entropy from a BIP-32 root and hardened path. + * + * @param {HDNode|string} root + * @param {string} path absolute path beginning with `m/` + * @returns {{ path: string, derivedKeyHex: string, entropy: Buffer }} + */ +function deriveBip85Entropy (root, path) { + const p = String(path || ''); + if (!p.startsWith('m/')) throw new Error('BIP85 path must be absolute (m/…)'); + const nodeRoot = asRoot(root); + if (!nodeRoot.privateKey) throw new Error('BIP85 requires a private (xprv) root'); + const node = nodeRoot.derivePath(p); + const entropy = entropyFromPrivateKey(node.privateKey); + return { + path: p, + derivedKeyHex: Buffer.from(node.privateKey).toString('hex'), + entropy + }; +} + +/** + * Truncate trailing (least-significant) bytes of the 64-byte HMAC output. + * + * @param {Buffer} entropy + * @param {number} numBytes + * @returns {Buffer} + */ +function truncateEntropy (entropy, numBytes) { + const n = Number(numBytes); + if (!Number.isInteger(n) || n < 1 || n > 64) { + throw new RangeError('BIP85 entropy length must be 1–64 bytes'); + } + return Buffer.from(entropy.subarray(0, n)); +} + +/** + * BIP-39 mnemonic application (`39'`). + * + * @param {HDNode|string} root + * @param {Object} [opts] + * @param {number} [opts.language=0] BIP-85 language code (0 = English) + * @param {number} [opts.words=12] 12, 15, 18, 21, or 24 + * @param {number} [opts.index=0] + * @returns {{ path: string, entropy: Buffer, mnemonic: string }} + */ +function deriveBip85Mnemonic (root, opts = {}) { + const language = opts.language == null ? 0 : opts.language; + const words = opts.words == null ? 12 : opts.words; + const index = opts.index == null ? 0 : opts.index; + if (!MNEMONIC_WORD_COUNTS.includes(Number(words))) { + throw new RangeError('BIP85 mnemonic words must be 12, 15, 18, 21, or 24'); + } + const path = bip85Path(APP_BIP39, [language, words, index]); + const derived = deriveBip85Entropy(root, path); + const bits = Number(words) * 11 * 32 / 33; + const entropy = truncateEntropy(derived.entropy, bits / 8); + return { + path, + entropy, + mnemonic: entropyToMnemonic(entropy) + }; +} + +/** + * Raw hex entropy application (`128169'`), 16–64 bytes. + * + * @param {HDNode|string} root + * @param {Object} [opts] + * @param {number} [opts.numBytes=64] + * @param {number} [opts.index=0] + * @returns {{ path: string, entropy: Buffer }} + */ +function deriveBip85Hex (root, opts = {}) { + const numBytes = opts.numBytes == null ? 64 : opts.numBytes; + const index = opts.index == null ? 0 : opts.index; + if (!Number.isInteger(numBytes) || numBytes < 16 || numBytes > 64) { + throw new RangeError('BIP85 HEX numBytes must be 16–64'); + } + const path = bip85Path(APP_HEX, [numBytes, index]); + const derived = deriveBip85Entropy(root, path); + return { path, entropy: truncateEntropy(derived.entropy, numBytes) }; +} + +/** + * BIP-32 master xprv application (`32'`). Chain code is the first 32 HMAC + * bytes; private key is the second 32 (the reverse of BIP-32 HMAC layout). + * + * @param {HDNode|string} root + * @param {Object} [opts] + * @param {number} [opts.index=0] + * @returns {{ path: string, entropy: Buffer, xprv: string }} + */ +function deriveBip85Xprv (root, opts = {}) { + const index = opts.index == null ? 0 : opts.index; + const path = bip85Path(APP_XPRV, [index]); + const derived = deriveBip85Entropy(root, path); + const chainCode = derived.entropy.subarray(0, 32); + const privateKey = derived.entropy.subarray(32, 64); + let publicKey; + try { + publicKey = Buffer.from(secp256k1.getPublicKey(toUint8Strict(privateKey), true)); + } catch (_) { + throw new Error('BIP85 derived xprv key is invalid; try the next index'); + } + const node = new HDNode(DEFAULT_NETWORK, 0, 0, 0, Buffer.from(chainCode), Buffer.from(privateKey), publicKey); + return { + path, + entropy: derived.entropy, + xprv: node.toBase58() + }; +} + +/** + * Bitcoin Core hdseed WIF application (`2'`): first 32 HMAC bytes as a + * compressed mainnet WIF. + * + * @param {HDNode|string} root + * @param {Object} [opts] + * @param {number} [opts.index=0] + * @returns {{ path: string, entropy: Buffer, wif: string }} + */ +function deriveBip85Wif (root, opts = {}) { + const index = opts.index == null ? 0 : opts.index; + const path = bip85Path(APP_HDSEED_WIF, [index]); + const derived = deriveBip85Entropy(root, path); + const privateKey = truncateEntropy(derived.entropy, 32); + try { + secp256k1.getPublicKey(toUint8Strict(privateKey), true); + } catch (_) { + throw new Error('BIP85 derived WIF key is invalid; try the next index'); + } + const payload = Buffer.concat([ + Buffer.from([0x80]), + privateKey, + Buffer.from([0x01]) + ]); + return { + path, + entropy: privateKey, + wif: encodeCheck(payload) + }; +} + +module.exports = { + BIP85_PURPOSE, + BIP85_HMAC_KEY, + APP_BIP39, + APP_HDSEED_WIF, + APP_XPRV, + APP_HEX, + bip85Path, + entropyFromPrivateKey, + deriveBip85Entropy, + truncateEntropy, + deriveBip85Mnemonic, + deriveBip85Hex, + deriveBip85Xprv, + deriveBip85Wif +}; diff --git a/functions/cliPasswordArgv.d.ts b/functions/cliPasswordArgv.d.ts deleted file mode 100644 index c5dd7ee9a..000000000 --- a/functions/cliPasswordArgv.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare const cliPasswordArgv: { - readCliPasswordFromArgv: (argv: unknown, env?: unknown) => string; - [key: string]: unknown; -}; - -export = cliPasswordArgv; diff --git a/functions/contractMessageAccumulate.js b/functions/contractMessageAccumulate.js index a6bf9f777..49f780825 100644 --- a/functions/contractMessageAccumulate.js +++ b/functions/contractMessageAccumulate.js @@ -317,7 +317,7 @@ function normalizePubkeyList (list) { seen.add(x); out.push(x); } - // Preserve ARC public-reader wildcard (docs/ARC.md, beacon / blinded genesis). + // Preserve ARC public-reader wildcard (docs/CONTRACTS.md, beacon / blinded genesis). if (publicRead) out.push('*'); return out.sort(); } diff --git a/functions/contractPaymentObserve.js b/functions/contractPaymentObserve.js index 1bde2471a..a115a23a7 100644 --- a/functions/contractPaymentObserve.js +++ b/functions/contractPaymentObserve.js @@ -7,7 +7,7 @@ * so callers can fold balances into tip content under signer policy. * * @module functions/contractPaymentObserve - * @see docs/ARC.md + * @see docs/CONTRACTS.md */ const bitcoin = require('bitcoinjs-lib'); diff --git a/functions/contractProgramBind.js b/functions/contractProgramBind.js index 7182321ff..4d76208b2 100644 --- a/functions/contractProgramBind.js +++ b/functions/contractProgramBind.js @@ -11,7 +11,7 @@ * * @module functions/contractProgramBind * @see docs/PROGRAM.md - * @see docs/ARC.md + * @see docs/CONTRACTS.md */ const crypto = require('crypto'); diff --git a/functions/contractSpend.js b/functions/contractSpend.js index 746db27d5..b4514829b 100644 --- a/functions/contractSpend.js +++ b/functions/contractSpend.js @@ -4,7 +4,7 @@ * ARC tip → P2TR spend resolution and tip-bound withdrawal bodies. * * @module functions/contractSpend - * @see docs/ARC.md + * @see docs/CONTRACTS.md */ const crypto = require('crypto'); @@ -16,6 +16,7 @@ const { prepareDecayMigrationPsbt } = require('./contractTaproot'); const { pubkeyXOnly, pubkeyCompressed } = require('./groupChatSeal'); +const { sortPubkeysBip67 } = require('./bip67'); const { CONTRACT_BODY_TYPES } = require('./applicationNamespaces'); const { programMetaFromTip, @@ -61,9 +62,9 @@ function toCompressedPubkeys (list, genesisList = null) { seen.add(c); tip.push(c); } - tip.sort((a, b) => Buffer.from(a, 'hex').compare(Buffer.from(b, 'hex'))); + const sortedTip = sortPubkeysBip67(tip).map((b) => b.toString('hex')); - if (!genesisList) return tip; + if (!genesisList) return sortedTip; const genesis = []; const gSeen = new Set(); for (const raw of genesisList || []) { @@ -78,14 +79,14 @@ function toCompressedPubkeys (list, genesisList = null) { gSeen.add(c); genesis.push(c); } - genesis.sort((a, b) => Buffer.from(a, 'hex').compare(Buffer.from(b, 'hex'))); + const sortedGenesis = sortPubkeysBip67(genesis).map((b) => b.toString('hex')); - const tipX = tip.map((c) => pubkeyXOnly(c)).filter(Boolean).sort(); - const genX = genesis.map((c) => pubkeyXOnly(c)).filter(Boolean).sort(); + const tipX = sortedTip.map((c) => pubkeyXOnly(c)).filter(Boolean).sort(); + const genX = sortedGenesis.map((c) => pubkeyXOnly(c)).filter(Boolean).sort(); if (tipX.length && tipX.length === genX.length && tipX.every((x, i) => x === genX[i])) { - return genesis; + return sortedGenesis; } - return tip; + return sortedTip; } /** diff --git a/functions/contractTaproot.js b/functions/contractTaproot.js index 74fee26fe..4a09435e2 100644 --- a/functions/contractTaproot.js +++ b/functions/contractTaproot.js @@ -8,7 +8,7 @@ * Optional hashlock / arbitrary script leaves compose via {@link composeTaprootTree}. * * @see docs/DISTRIBUTED_EXECUTION.md - * @see docs/ARC.md + * @see docs/CONTRACTS.md * @see functions/contractTierWhen.js */ @@ -18,6 +18,9 @@ const bip341 = require('bitcoinjs-lib/src/payments/bip341'); const { payments, networks, script, Psbt } = bitcoin; const psbtutils = require('bitcoinjs-lib/src/psbt/psbtutils'); const { evaluateTierWhen } = require('./contractTierWhen'); +const { sortPubkeysBip67 } = require('./bip67'); +const { taprootPsbtInputFields } = require('./inventoryHtlc'); +const { aggregateXonly } = require('./musig2'); bitcoin.initEccLib(ecc); @@ -52,9 +55,9 @@ function parseCompressedPubkeysSorted (hexList) { } out.push(Buffer.from(s, 'hex')); } - out.sort((a, b) => a.compare(b)); + const sorted = sortPubkeysBip67(out); const uniq = []; - for (const b of out) { + for (const b of sorted) { if (!uniq.length || uniq[uniq.length - 1].compare(b) !== 0) uniq.push(b); } return uniq; @@ -186,6 +189,7 @@ function resolveKeyList (keysRef, keySets, publisher) { * @param {number} [opts.softThreshold] when softMode=reduced (default ceil(k/2)) * @param {object} [opts.hashlock] optional L1 hashlock leaf (`commitmentHex` / `runCommitmentHex`) * @param {object[]} [opts.extraLeaves] additional composable leaves (script / spend / hashlock) + * @param {string} [opts.internalKeyMode] `musig2` (default when n≥2) | `nums` (legacy script-path-only) * @returns {object} normalized spend policy */ function synthesizeDefaultLadder (opts = {}) { @@ -254,7 +258,11 @@ function synthesizeDefaultLadder (opts = {}) { softTier ], hashlock: opts.hashlock || null, - extraLeaves: Array.isArray(opts.extraLeaves) ? opts.extraLeaves : null + extraLeaves: Array.isArray(opts.extraLeaves) ? opts.extraLeaves : null, + // n>=2: MuSig2 of the full validator set as the Taproot internal key + // (cooperative n-of-n key-path). t-of-n and CSV soft-tier stay script-path. + // Pass internalKeyMode: 'nums' to keep the historical NUMS-only address. + internalKeyMode: opts.internalKeyMode || (sorted.length >= 2 ? 'musig2' : 'nums') }; return normalizeContractSpendPolicy(policyIn); } @@ -373,10 +381,66 @@ function normalizeContractSpendPolicy (raw = {}) { }, tiers, hashlock: raw.hashlock ? normalizeHashlockSpec(raw.hashlock) : null, - extraLeaves: normalizeExtraLeafSpecs(raw.extraLeaves) + extraLeaves: normalizeExtraLeafSpecs(raw.extraLeaves), + internalKeyMode: normalizeInternalKeyMode(raw.internalKeyMode) }; } +/** + * @param {string} [mode] + * @returns {string} `nums` | `musig2` + * @private + */ +function normalizeInternalKeyMode (mode) { + const m = String(mode || 'nums').toLowerCase(); + if (m === 'musig2' || m === 'auto') return 'musig2'; + return 'nums'; +} + +/** + * Keys used for a MuSig2 Taproot internal key (full authority set). + * + * @param {object} policy + * @returns {string[]|null} + * @private + */ +function musig2InternalKeySources (policy) { + if (!policy) return null; + if (policy.keySets && Array.isArray(policy.keySets.full) && policy.keySets.full.length) { + return policy.keySets.full; + } + if (policy.tiers && policy.tiers[0] && Array.isArray(policy.tiers[0].keys)) { + return policy.tiers[0].keys; + } + return null; +} + +/** + * @param {object} [opts] + * @param {object} [policy] + * @returns {Buffer} 32-byte x-only + * @private + */ +function resolveTaprootInternalPubkey (opts, policy) { + if (opts && opts.internalPubkeyHex) { + const hex = String(opts.internalPubkeyHex).trim().toLowerCase().replace(/^0x/i, ''); + if (!/^[0-9a-f]{64}$/.test(hex)) { + throw new Error('composeTaprootTree: internalPubkeyHex must be 32-byte x-only hex'); + } + return Buffer.from(hex, 'hex'); + } + const mode = normalizeInternalKeyMode( + (opts && opts.internalKeyMode) || (policy && policy.internalKeyMode) + ); + if (mode === 'musig2') { + const keys = musig2InternalKeySources(policy); + if (keys && keys.length >= 2) { + return aggregateXonly(keys); + } + } + return TAPROOT_INTERNAL_NUMS; +} + /** * Drop tiers whose until <= trigger lock; clear migrate leaf schedule for child. */ @@ -425,7 +489,8 @@ function policyAfterDecay (policy, atLock) { when: t.when })), hashlock: p.hashlock || null, - extraLeaves: p.extraLeaves || null + extraLeaves: p.extraLeaves || null, + internalKeyMode: p.internalKeyMode || 'nums' }); } @@ -834,7 +899,8 @@ function scriptTreeFromLeaves (leaves) { * @param {object[]} [opts.leaves] full leaf list (skips policy compile when set alone) * @param {object} [opts.hashlock] optional hashlock sugar * @param {object[]} [opts.extraLeaves] additional leaves - * @param {string} [opts.internalPubkeyHex] x-only; default NUMS + * @param {string} [opts.internalPubkeyHex] x-only; default NUMS or MuSig2 from policy + * @param {string} [opts.internalKeyMode] `nums` | `musig2` * @returns {object} */ function composeTaprootTree (opts = {}) { @@ -876,19 +942,7 @@ function composeTaprootTree (opts = {}) { seen.add(l.id); } - let internalPubkey = TAPROOT_INTERNAL_NUMS; - if (opts.internalPubkeyHex) { - const hex = String(opts.internalPubkeyHex).trim().toLowerCase().replace(/^0x/i, ''); - if (!/^[0-9a-f]{64}$/.test(hex)) { - throw new Error('composeTaprootTree: internalPubkeyHex must be 32-byte x-only hex'); - } - if (hex !== TAPROOT_INTERNAL_NUMS.toString('hex')) { - throw new Error( - 'composeTaprootTree: custom internal keys are not yet supported (NUMS script-path only)' - ); - } - internalPubkey = Buffer.from(hex, 'hex'); - } + const internalPubkey = resolveTaprootInternalPubkey(opts, policy); const scriptTree = scriptTreeFromLeaves(leaves); const btcNet = networkForFabricName(network); @@ -917,18 +971,21 @@ function composeTaprootTree (opts = {}) { }; } -function buildControlBlockForLeaf (leaves, leafScript) { +function buildControlBlockForLeaf (leaves, leafScript, internalPubkey) { + const internal = Buffer.isBuffer(internalPubkey) && internalPubkey.length === 32 + ? internalPubkey + : TAPROOT_INTERNAL_NUMS; 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); + const outputKey = bip341.tweakKey(internal, hashTree.hash); if (!outputKey) throw new Error('Taproot tweak failed.'); return Buffer.concat([ Buffer.from([leafVersion | outputKey.parity]), - TAPROOT_INTERNAL_NUMS, + internal, ...path ]); } @@ -1051,7 +1108,8 @@ function prepareLeafPsbt (opts = {}) { if (sequence == null || sequence === MAX_LOCKTIME) sequence = SEQUENCE_NONFINAL; } - const controlBlock = buildControlBlockForLeaf(leaves, ms); + const internalPubkey = resolveTaprootInternalPubkey(opts, opts.policy || null); + const controlBlock = buildControlBlockForLeaf(leaves, ms, internalPubkey); const outScript = Buffer.isBuffer(out.script) ? out.script : Buffer.from(out.script); const input = { hash: tx.getId(), @@ -1060,12 +1118,12 @@ function prepareLeafPsbt (opts = {}) { 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) - }] + ...taprootPsbtInputFields({ + tapInternalKey: internalPubkey, + script: ms, + controlBlock, + leafVersion: bip341.LEAF_VERSION_TAPSCRIPT + }) }; if (sequence != null) input.sequence = Number(sequence); @@ -1134,7 +1192,9 @@ function prepareTierWithdrawalPsbt (opts = {}) { leaves, destinationAddress: opts.destinationAddress, feeSats: opts.feeSats, - after: tier.after + after: tier.after, + internalPubkeyHex: built.internalPubkeyHex, + policy: built.policy }); return { ...result, @@ -1173,7 +1233,9 @@ function prepareDecayMigrationPsbt (opts = {}) { leaves, destinationAddress: childAddr, feeSats: opts.feeSats, - after: target.decayAt + after: target.decayAt, + internalPubkeyHex: built.internalPubkeyHex, + policy: built.policy }); return { ...result, @@ -1203,6 +1265,7 @@ function prepareHashlockWithdrawalPsbt (opts = {}) { let vaultAddress = opts.vaultAddress || opts.address; let leaves; let publicLeaves = null; + let internalPubkeyHex = opts.internalPubkeyHex || null; if (opts.policy) { const built = buildContractTaproot(opts.policy); @@ -1210,6 +1273,7 @@ function prepareHashlockWithdrawalPsbt (opts = {}) { vaultAddress = vaultAddress || built.address; leaves = compileLeaves(built.policy); publicLeaves = built.leaves; + internalPubkeyHex = internalPubkeyHex || built.internalPubkeyHex; } else if (Array.isArray(opts.leaves) && opts.leaves.length) { leaves = opts.leaves.map((l) => { if (l && Buffer.isBuffer(l.script)) return normalizeLeaf(l); @@ -1255,7 +1319,9 @@ function prepareHashlockWithdrawalPsbt (opts = {}) { destinationAddress: opts.destinationAddress, feeSats: opts.feeSats, after: leaf.after, - preimage32: preimage32 || undefined + preimage32: preimage32 || undefined, + internalPubkeyHex: internalPubkeyHex || undefined, + policy: opts.policy || undefined }); const witnessShape = leaf.witnessShape @@ -1393,7 +1459,7 @@ function buildFederationVaultFromPolicy (opts = {}) { softMode: softMode ? (String(softMode).toLowerCase() === 'reduced' ? 'reduced' : 'publisher') : 'publisher', - internalPubkeyHex: TAPROOT_INTERNAL_NUMS.toString('hex'), + internalPubkeyHex: built.internalPubkeyHex, depositMaturityBlocks: csvBlocks != null ? Number(csvBlocks) : DEFAULT_CSV_BLOCKS, csvBlocks: soft && soft.after && soft.after.blocks != null ? Number(soft.after.blocks) diff --git a/functions/fabricChatKind.js b/functions/fabricChatKind.js new file mode 100644 index 000000000..c1a295806 --- /dev/null +++ b/functions/fabricChatKind.js @@ -0,0 +1,125 @@ +'use strict'; + +/** + * Mesh chat surface classification — gossip network stays primary; chat is + * layered on top as either a public shoutbox or sealed confidential traffic + * (first wave for future contract messaging). + * + * | Surface | Wire | Confidentiality | + * |---------|------|-----------------| + * | `shoutbox` | cleartext `P2P_CHAT_MESSAGE` flood | Public to mesh relays | + * | `onion_seal` | `P2P_FORWARD` + sealed `P2P_CHAT_MESSAGE` body | E2E to path tip | + * | `group_seal` | `CONTRACT_MESSAGE` GroupChat with seal | Members (v2 hub-blind) | + * | `contract_cleartext` | GroupChat without seal | Relays can read | + * + * @module functions/fabricChatKind + * @see PRIVACY.md + * @see functions/onionChatSeal.js + * @see functions/groupChatSeal.js + */ + +const { isOnionChatSeal } = require('./onionChatSeal'); +const { + isSealedGroupChat, + isParticipantSealedGroupChat +} = require('./groupChatSeal'); + +/** @enum {string} */ +const CHAT_SURFACE = Object.freeze({ + SHOUTBOX: 'shoutbox', + ONION_SEAL: 'onion_seal', + GROUP_SEAL: 'group_seal', + CONTRACT_CLEARTEXT: 'contract_cleartext', + UNKNOWN: 'unknown' +}); + +function looksLikeGroupSeal (value) { + if (!value || typeof value !== 'object') return false; + if (isSealedGroupChat(value) || isParticipantSealedGroupChat(value)) return true; + if (value.seal && (isSealedGroupChat(value) || isParticipantSealedGroupChat(value.seal))) { + return true; + } + return false; +} + +/** + * Classify a UTF-8 chat body or GroupChat object for UI / policy. + * @param {string|object|null|undefined} body + * @param {Object} [opts] + * @param {string} [opts.appType] CONTRACT_MESSAGE type (e.g. GroupChat) + * @returns {string} one of {@link CHAT_SURFACE} + */ +function classifyChatSurface (body, opts = {}) { + const appType = opts.appType != null ? String(opts.appType) : null; + + if (body == null) return CHAT_SURFACE.UNKNOWN; + + if (typeof body === 'string') { + if (isOnionChatSeal(body)) return CHAT_SURFACE.ONION_SEAL; + if (appType === 'GroupChat') return CHAT_SURFACE.CONTRACT_CLEARTEXT; + return CHAT_SURFACE.SHOUTBOX; + } + + if (typeof body === 'object' && !Array.isArray(body)) { + if (body['@type'] === 'FabricOnionChatSeal' || isOnionChatSeal(JSON.stringify(body))) { + return CHAT_SURFACE.ONION_SEAL; + } + if (looksLikeGroupSeal(body) || looksLikeGroupSeal(body.object)) { + return CHAT_SURFACE.GROUP_SEAL; + } + if (appType === 'GroupChat' || body.type === 'GroupChat' || body['@type'] === 'GroupChat') { + return CHAT_SURFACE.CONTRACT_CLEARTEXT; + } + } + + return CHAT_SURFACE.UNKNOWN; +} + +/** + * True when the frame is public mesh flood (relays can read UTF-8). + * @param {string|object|null|undefined} body + * @param {Object} [opts] + * @returns {boolean} + */ +function isPublicMeshShoutbox (body, opts = {}) { + return classifyChatSurface(body, opts) === CHAT_SURFACE.SHOUTBOX; +} + +/** + * True when confidentiality is intended (onion seal or group participant/tip seal). + * @param {string|object|null|undefined} body + * @param {Object} [opts] + * @returns {boolean} + */ +function isConfidentialChatSurface (body, opts = {}) { + const kind = classifyChatSurface(body, opts); + return kind === CHAT_SURFACE.ONION_SEAL || kind === CHAT_SURFACE.GROUP_SEAL; +} + +/** + * Short operator-facing label for UI chips. + * @param {string} surface + * @returns {string} + */ +function chatSurfaceLabel (surface) { + switch (String(surface || '')) { + case CHAT_SURFACE.SHOUTBOX: + return 'Public mesh shoutbox'; + case CHAT_SURFACE.ONION_SEAL: + return 'Onion-sealed chat'; + case CHAT_SURFACE.GROUP_SEAL: + return 'Sealed group chat'; + case CHAT_SURFACE.CONTRACT_CLEARTEXT: + return 'Group chat (cleartext at relays)'; + default: + return 'Chat'; + } +} + +module.exports = { + CHAT_SURFACE, + classifyChatSurface, + isPublicMeshShoutbox, + isConfidentialChatSurface, + chatSurfaceLabel +}; diff --git a/functions/fabricChatText.js b/functions/fabricChatText.js new file mode 100644 index 000000000..cedfe6d10 --- /dev/null +++ b/functions/fabricChatText.js @@ -0,0 +1,111 @@ +'use strict'; + +/** + * Shared shoutbox text / author extraction for the Fabric TUI, Hub UI, and apps. + * + * Mesh wire (`P2P_CHAT_MESSAGE`): body = raw UTF-8 text; author = AMP signature. + * Peer emits `{ text, type: 'P2P_CHAT_MESSAGE' }` plus meta `{ signer }`. + * Hub SPA / WS may still cache `{ actor, object.content }` — that is the HTTP + * edge, not the P2P body. `@fabric/http/functions/fabricChatNormalize` maps + * both shapes into the Hub cache object. + */ + +/** + * @param {*} hex + * @returns {string|null} lowercase 64-hex x-only, or null + */ +function pubkeyXOnly (hex) { + const s = String(hex || '').toLowerCase().replace(/^0x/, ''); + if (/^0[23][0-9a-f]{64}$/.test(s)) return s.slice(2); + if (/^[0-9a-f]{64}$/.test(s)) return s; + return null; +} + +/** + * Extract displayable text from a chat payload or Peer chat event. + * @param {object|string|null|undefined} chat + * @returns {string} + */ +function chatTextOf (chat) { + if (chat == null) return ''; + if (typeof chat === 'string') return chat; + if (typeof chat !== 'object') return ''; + if (chat.text != null && String(chat.text).trim()) return String(chat.text); + const obj = chat.object && typeof chat.object === 'object' ? chat.object : {}; + if (obj.content != null && String(obj.content).trim()) return String(obj.content); + if (obj.body != null && String(obj.body).trim()) return String(obj.body); + if (chat.content != null && String(chat.content).trim()) return String(chat.content); + if (chat.body != null && String(chat.body).trim()) return String(chat.body); + return ''; +} + +/** + * Actor / author id (pubkey) from Hub UI shape or Peer meta/signer. + * Prefers x-only when the value is a parseable secp256k1 pubkey hex. + * @param {object|null|undefined} chat + * @param {Object} [opts] + * @param {string|null} [opts.defaultActorId] + * @param {string|null} [opts.signer] + * @returns {string|null} + */ +function chatActorIdOf (chat, opts = {}) { + let raw = null; + if (opts.signer) raw = String(opts.signer); + else if (chat && typeof chat === 'object') { + const actor = chat.actor && typeof chat.actor === 'object' ? chat.actor : {}; + raw = actor.id || actor.publicKey || actor.pubkey || null; + if (!raw) { + const obj = chat.object && typeof chat.object === 'object' ? chat.object : {}; + if (obj.author) raw = obj.author; + } + } + if (!raw && opts.defaultActorId) raw = opts.defaultActorId; + if (!raw) return null; + const s = String(raw); + const x = pubkeyXOnly(s); + return x || s; +} + +/** + * Strip C0 controls and Blessed markup braces from peer-supplied display fields. + * Blessed enables tags in log widgets; `{…}` and control chars can spoof TUI output. + * @param {*} value + * @returns {string} + */ +function sanitizeChatDisplayField (value) { + return String(value == null ? '' : value) + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '') + .replace(/[{}]/g, ''); +} + +/** + * Format a shoutbox line: nickname when known, else truncated pubkey. + * @param {string|null|undefined} actorId + * @param {string|null|undefined} alias + * @param {string} text + * @param {function} [truncate] + * @returns {string} + */ +function formatChatLogLine (actorId, alias, text, truncate) { + const nickRaw = alias && String(alias).trim() ? String(alias).trim() : ''; + const nick = sanitizeChatDisplayField(nickRaw); + let label = nick; + if (!label) { + const id = sanitizeChatDisplayField(actorId != null ? String(actorId) : ''); + const short = typeof truncate === 'function' + ? truncate(id, 10, '…', 5) + : (id.length > 10 ? id.slice(0, 5) + '…' + id.slice(-5) : id); + label = '@' + short; + } + return `[${label}]: ${sanitizeChatDisplayField(text)}`; +} + +module.exports = { + pubkeyXOnly, + chatTextOf, + chatActorIdOf, + sanitizeChatDisplayField, + formatChatLogLine, + // Re-export surface classifiers (gossip-first / encrypted-chat wave). + ...require('./fabricChatKind') +}; diff --git a/functions/fabricHomeEnv.js b/functions/fabricHomeEnv.js new file mode 100644 index 000000000..c14b0b920 --- /dev/null +++ b/functions/fabricHomeEnv.js @@ -0,0 +1,394 @@ +'use strict'; + +/** + * `~/.fabric/env` and `~/.fabric/hub-admin-token` — operator home environment. + * + * Dotenv fills **missing** `FABRIC_*` keys only (process env wins). The Hub + * admin token is a Schnorr-signed `OP_IDENTITY` Token for future RC1 + * `AcceptTrackedApplicationContract` / `--accept`. + * + * @fileoverview Fabric home env file + Hub admin token helpers. + * CLI: `node functions/fabricHomeEnv.js` (or `npm run home-env`) writes + * `~/.fabric/env` + `~/.fabric/hub-admin-token` without printing secrets. + * @module functions/fabricHomeEnv + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const Token = require('../types/token'); + +const WALLET_FILE_MODE = 0o600; +const HOME_ENV_FILENAME = 'env'; +const HUB_ADMIN_TOKEN_FILENAME = 'hub-admin-token'; + +/** Keys applied from `~/.fabric/env` (never overwrite a set process env value). */ +const HOME_ENV_KEYS = new Set([ + 'FABRIC_SEED', + 'FABRIC_MNEMONIC', + 'FABRIC_XPRV', + 'FABRIC_XPUB', + 'FABRIC_PUBKEY', + 'FABRIC_PASSWORD', + 'FABRIC_PASSPHRASE', + 'FABRIC_NETWORK', + 'FABRIC_LOCK_TIMEOUT_MINUTES', + 'FABRIC_HUB_ADMIN_TOKEN', + 'FABRIC_HUB_ADMIN_TOKEN_FILE', + 'FABRIC_ADMIN_TOKEN' +]); + +/** + * @param {Object} [opts] + * @param {string} [opts.home] + * @param {string} [opts.store] + * @returns {string} + */ +function fabricHomeDir (opts = {}) { + if (opts.store) return opts.store; + const home = opts.home || process.env.HOME || os.homedir(); + return path.join(home, '.fabric'); +} + +/** + * @param {Object} [opts] + * @returns {string} + */ +function fabricHomeEnvPath (opts = {}) { + return opts.envPath || path.join(fabricHomeDir(opts), HOME_ENV_FILENAME); +} + +/** + * @param {Object} [opts] + * @returns {string} + */ +function fabricHubAdminTokenPath (opts = {}) { + return opts.tokenPath || path.join(fabricHomeDir(opts), HUB_ADMIN_TOKEN_FILENAME); +} + +/** + * @param {string} filePath + * @param {string} content + */ +function writePrivateFile (filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, { encoding: 'utf8', mode: WALLET_FILE_MODE }); + try { + fs.chmodSync(filePath, WALLET_FILE_MODE); + } catch (_) { /* best-effort on platforms without chmod */ } +} + +/** + * @param {string} text + * @returns {Object} + */ +function parseDotEnvText (text) { + const out = {}; + if (text == null) return out; + for (const line of String(text).split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + let val = trimmed.slice(eq + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + out[key] = val; + } + return out; +} + +/** + * Load KEY=VALUE lines into `env` without overriding variables already present. + * + * @param {string} filePath + * @param {NodeJS.ProcessEnv} [env] + * @param {Object} [opts] + * @param {Set|string[]} [opts.allowKeys] When set, ignore other keys. + * @returns {number} Count of keys applied + */ +function loadDotEnvFile (filePath, env = process.env, opts = {}) { + let text; + try { + text = fs.readFileSync(filePath, 'utf8'); + } catch (_) { + return 0; + } + const allow = opts.allowKeys + ? (opts.allowKeys instanceof Set ? opts.allowKeys : new Set(opts.allowKeys)) + : null; + const parsed = parseDotEnvText(text); + let n = 0; + for (const [key, val] of Object.entries(parsed)) { + if (allow && !allow.has(key)) continue; + if (env[key] != null && env[key] !== '') continue; + env[key] = val; + n++; + } + return n; +} + +/** + * Fill missing `FABRIC_*` keys from `~/.fabric/env`. + * + * @param {NodeJS.ProcessEnv} [env] + * @param {Object} [opts] + * @returns {number} + */ +function loadFabricHomeEnv (env = process.env, opts = {}) { + return loadDotEnvFile(fabricHomeEnvPath(opts), env, { + allowKeys: opts.allowKeys || HOME_ENV_KEYS + }); +} + +/** + * Mint a Hub operator admin token (Schnorr `OP_IDENTITY` / `sub=admin`). + * Valid only for Hubs whose root key is `issuerKey`. + * + * @param {import('../types/key')} issuerKey + * @returns {string} + */ +function mintHubAdminToken (issuerKey) { + if (!issuerKey) throw new Error('issuerKey required'); + const token = new Token({ + capability: 'OP_IDENTITY', + issuer: issuerKey, + subject: 'admin' + }); + return token.toSignedString(); +} + +/** + * @param {string} token + * @param {Object} [opts] + * @returns {string} Path written + */ +function writeHubAdminToken (token, opts = {}) { + const file = fabricHubAdminTokenPath(opts); + const line = String(token || '').trim(); + if (!line) throw new Error('admin token is empty'); + writePrivateFile(file, line + '\n'); + return file; +} + +/** + * @param {string} filePath + * @returns {string} + */ +function readTokenFile (filePath) { + const p = String(filePath || '').trim(); + if (!p) return ''; + try { + if (!fs.existsSync(p)) return ''; + const line = fs.readFileSync(p, 'utf8').trim().split(/\r?\n/)[0]; + return String(line || '').trim(); + } catch (_) { + return ''; + } +} + +/** + * Resolve Hub admin token: env → `FABRIC_HUB_ADMIN_TOKEN_FILE` → `~/.fabric/hub-admin-token`. + * + * @param {NodeJS.ProcessEnv} [env] + * @param {Object} [opts] + * @param {string} [opts.tokenPath] + * @returns {{token: string, source: (string|null)}|{token: string, source: string, path: string}} + */ +function readHubAdminToken (env = process.env, opts = {}) { + const direct = String( + (env && (env.FABRIC_HUB_ADMIN_TOKEN || env.FABRIC_ADMIN_TOKEN)) || '' + ).trim(); + if (direct) return { token: direct, source: 'env' }; + + const file = String( + (env && env.FABRIC_HUB_ADMIN_TOKEN_FILE) || opts.tokenPath || '' + ).trim(); + if (file) { + const fromFile = readTokenFile(file); + if (fromFile) return { token: fromFile, source: 'adminTokenFile', path: file }; + } + + const homePath = fabricHubAdminTokenPath(opts); + const fromHome = readTokenFile(homePath); + if (fromHome) return { token: fromHome, source: 'home', path: homePath }; + + return { token: '', source: null }; +} + +/** + * Merge values into `~/.fabric/env` (0600). Existing keys are replaced when + * present in `values`; other keys are preserved. Does not log secrets. + * + * @param {Object} values + * @param {Object} [opts] + * @returns {string} Path written + */ +function upsertFabricHomeEnv (values, opts = {}) { + const file = fabricHomeEnvPath(opts); + let existing = {}; + try { + existing = parseDotEnvText(fs.readFileSync(file, 'utf8')); + } catch (_) { /* new file */ } + + const merged = Object.assign({}, existing); + for (const [key, val] of Object.entries(values || {})) { + if (!HOME_ENV_KEYS.has(key)) continue; + if (val == null || val === '') { + delete merged[key]; + } else { + merged[key] = String(val); + } + } + + const lines = [ + '# Fabric home environment — gitignored, mode 0600.', + '# Process env wins. Identity secrets prefer ~/.fabric/wallet.json when unset.', + '# Do not commit this file.' + ]; + for (const key of Array.from(HOME_ENV_KEYS)) { + if (merged[key] == null || merged[key] === '') continue; + lines.push(`${key}=${merged[key]}`); + } + writePrivateFile(file, lines.join('\n') + '\n'); + return file; +} + +module.exports = { + HOME_ENV_KEYS, + HOME_ENV_FILENAME, + HUB_ADMIN_TOKEN_FILENAME, + WALLET_FILE_MODE, + fabricHomeDir, + fabricHomeEnvPath, + fabricHubAdminTokenPath, + writePrivateFile, + parseDotEnvText, + loadDotEnvFile, + loadFabricHomeEnv, + mintHubAdminToken, + writeHubAdminToken, + readTokenFile, + readHubAdminToken, + upsertFabricHomeEnv, + runEnsureHomeEnvCli +}; + +function publicKeyPrefix (key) { + try { + const hex = key.public ? key.public.encodeCompressed('hex') : (key.pubkey || ''); + return hex ? String(hex).slice(0, 12) + '…' : null; + } catch (_) { + return null; + } +} + +/** + * Stamp `~/.fabric/env` + Hub admin token. Prints public paths only. + * + * @param {string[]} [argv] + * @returns {number} process exit code + */ +function runEnsureHomeEnvCli (argv = process.argv) { + const Key = require('../types/key'); + const { keySettingsFromEnv } = require('./fabricKeyMaterial'); + const { loadIdentityFromWalletFile } = require('./fabricWalletIdentity'); + + function keyFromSettings (settings) { + if (!settings) return null; + return new Key(settings); + } + + function identitySettingsFromWallet (row) { + if (!row || !row.xprv) return null; + return { xprv: row.xprv }; + } + + const walletOnly = argv.includes('--wallet-only'); + loadFabricHomeEnv(); + + let source = null; + let key = null; + + if (!walletOnly) { + const fromEnv = keySettingsFromEnv(process.env); + if (fromEnv) { + key = keyFromSettings(fromEnv); + source = fromEnv.xprv ? 'FABRIC_XPRV' : (fromEnv.seed ? 'FABRIC_SEED' : 'FABRIC_MNEMONIC'); + } + } + + if (!key || !key.xprv) { + const wallet = loadIdentityFromWalletFile({ + home: process.env.HOME || os.homedir(), + password: process.env.FABRIC_PASSWORD + }); + const settings = identitySettingsFromWallet(wallet); + if (settings) { + key = keyFromSettings(settings); + source = 'wallet.json'; + } + } + + const home = process.env.HOME || os.homedir(); + const store = path.join(home, '.fabric'); + const walletPath = path.join(store, 'wallet.json'); + const walletExists = fs.existsSync(walletPath); + let walletLocked = false; + if (walletExists && source !== 'wallet.json') { + try { + const doc = JSON.parse(fs.readFileSync(walletPath, 'utf8')); + walletLocked = !!(doc && doc.passwordProtected); + } catch (_) { /* ignore */ } + } + + if (!key || !key.xprv) { + console.error('[FABRIC:HOME-ENV] No private key. Set FABRIC_XPRV / FABRIC_SEED / FABRIC_MNEMONIC,'); + console.error(' or FABRIC_PASSWORD to unlock ~/.fabric/wallet.json.'); + return 1; + } + + try { + const token = mintHubAdminToken(key); + const tokenPath = writeHubAdminToken(token, { home, store }); + + const stamped = { + FABRIC_HUB_ADMIN_TOKEN: token, + FABRIC_HUB_ADMIN_TOKEN_FILE: tokenPath + }; + if (source === 'wallet.json') { + // Prefer the extended key only. Stamping seed/mnemonic beside xprv leaves + // unused secret material on disk (`keySettingsFromEnv` returns on XPRV first). + stamped.FABRIC_XPRV = key.xprv; + if (key.xpub) stamped.FABRIC_XPUB = key.xpub; + } + + const envPath = upsertFabricHomeEnv(stamped, { home, store }); + const check = readHubAdminToken(process.env, { home, store }); + + console.log('[FABRIC:HOME-ENV] home ', store); + console.log('[FABRIC:HOME-ENV] env ', envPath); + console.log('[FABRIC:HOME-ENV] admin token ', tokenPath); + console.log('[FABRIC:HOME-ENV] identity ', source, publicKeyPrefix(key) || ''); + console.log('[FABRIC:HOME-ENV] wallet.json ', walletExists ? (walletLocked && source !== 'wallet.json' ? 'present (sealed; FABRIC_PASSWORD to import)' : 'present') : 'missing'); + console.log('[FABRIC:HOME-ENV] token source', check.source || 'written'); + if (walletExists && walletLocked && source !== 'wallet.json') { + console.log('[FABRIC:HOME-ENV] Note: sealed wallet was not unlocked; token is from', source + '.'); + console.log(' Re-run with FABRIC_PASSWORD to stamp identity from wallet.json.'); + } + return 0; + } catch (err) { + const detail = err && err.message ? err.message : err; + console.error('[FABRIC:HOME-ENV] Could not write home env:', detail); + return 1; + } +} + +if (require.main === module) { + const code = runEnsureHomeEnvCli(process.argv); + if (code) process.exit(code); +} diff --git a/functions/fabricIdentitySchnorr.js b/functions/fabricIdentitySchnorr.js new file mode 100644 index 000000000..4dbe4821d --- /dev/null +++ b/functions/fabricIdentitySchnorr.js @@ -0,0 +1,236 @@ +'use strict'; + +/** + * @fileoverview Protocol identity Schnorr helpers (site-login, device-link, + * IdentityCrossSign). Signing uses {@link Identity#fabricKey} when an HD master + * is available. Verify: BIP340 over UTF-8; `identity.xpub` must neuter to + * `pubkeyHex`; `identity.id` must be Bech32 `id1…` of SHA-256d(compressed pubkey). + * @see IDENTITY.md + */ + +const Key = require('../types/key'); +const Identity = require('../types/identity'); +const Hash256 = require('../types/hash256'); +const Bech32 = require('../types/bech32'); +const { FABRIC_KEY_DERIVATION_PATH } = require('../constants'); + +/** + * Fabric Bech32 identity id from a compressed secp256k1 pubkey (IDENTITY.md). + * @param {string} pubkeyHex + * @returns {string} + */ +function fabricIdentityIdFromPubkeyHex (pubkeyHex) { + const raw = String(pubkeyHex || '').trim().replace(/^0x/i, ''); + if (!raw) throw new Error('Missing pubkey bytes.'); + if (!/^[0-9a-f]{66}$/i.test(raw)) { + throw new Error('Invalid compressed pubkey hex (expected 66 hex characters).'); + } + const input = Buffer.from(raw, 'hex'); + const pubkeyhash = Hash256.digest(input); + return new Bech32({ hrp: 'id', content: pubkeyhash }).toString(); +} + +/** + * @param {*} value + * @returns {boolean} + */ +function looksLikeFabricIdentity (value) { + if (!value || typeof value !== 'object') return false; + if (typeof value.id !== 'string' && typeof value.id !== 'number') return false; + if (value.fabricKey && typeof value.fabricKey.signSchnorr === 'function') return true; + return !!(value.key && typeof value.key.signSchnorr === 'function'); +} + +/** + * @param {*} value + * @returns {boolean} + */ +function looksLikeFabricKey (value) { + return !!(value && typeof value === 'object' && + typeof value.signSchnorr === 'function' && + (value.pubkey || value.public || value.xprv || value.xpub || value.master)); +} + +/** + * Resolve the protocol signing key + Fabric bech32 id from flexible inputs. + * @param {object} input Identity, HD Key / master, `{ mnemonic|xprv }`, or leaf + * `{ privateKeyHex|private, xpub? }` (Passport session material already at the Fabric path). + * @returns {{ fabricKey: object, identityId: string, identity: object }} + */ +function resolveFabricSigningIdentity (input) { + if (!input) throw new Error('Identity or Key required'); + + let identity = null; + let leafXpub = null; + if (looksLikeFabricIdentity(input)) { + identity = input; + } else if (looksLikeFabricKey(input)) { + identity = new Identity(input); + } else if (typeof input === 'object') { + if (input.mnemonic || input.xprv) { + identity = new Identity(new Key({ + mnemonic: input.mnemonic || undefined, + xprv: input.xprv || undefined, + passphrase: input.passphrase || undefined + })); + } else { + const priv = input.privateKeyHex || input.private || null; + if (priv) { + const hex = Buffer.isBuffer(priv) ? priv.toString('hex') : String(priv).replace(/^0x/i, ''); + const leaf = new Key({ private: hex }); + identity = new Identity(leaf); + leafXpub = typeof input.xpub === 'string' ? input.xpub : null; + } + } + } + + if (!identity) throw new Error('Identity or Key required'); + + let fabricKey = identity.fabricKey; + if (!fabricKey || typeof fabricKey.signSchnorr !== 'function') { + const master = identity.key || identity.master || (looksLikeFabricKey(input) ? input : null); + if (master && typeof master.derive === 'function') { + try { + const path = (identity.derivation) || FABRIC_KEY_DERIVATION_PATH; + fabricKey = master.derive(path); + } catch (_) { + fabricKey = master; + } + } else if (master && typeof master.signSchnorr === 'function') { + fabricKey = master; + } + } + if (!fabricKey || typeof fabricKey.signSchnorr !== 'function') { + throw new Error('Fabric signing key unavailable'); + } + + let xpub = fabricKey.xpub || leafXpub || null; + if (!xpub && typeof input === 'object' && typeof input.xpub === 'string') { + xpub = input.xpub; + } + if (!xpub) { + throw new Error('Fabric signing key missing xpub (pass identity.xpub for leaf keys)'); + } + if (!fabricKey.xpub) { + const candidate = String(xpub); + let neutered; + try { + neutered = new Key({ xpub: candidate }); + } catch (_) { + throw new Error('invalid xpub for signing key'); + } + const expected = String(fabricKey.pubkey || '').toLowerCase(); + const fromXpub = String(neutered.pubkey || '').toLowerCase(); + if (!expected || !fromXpub || expected !== fromXpub) { + throw new Error('xpub does not match signing pubkey'); + } + fabricKey.xpub = candidate; + } + + return { + fabricKey, + identityId: String(identity.id), + identity + }; +} + +/** + * @private + * @param {*} value + * @returns {boolean} + */ +function isResolvedSigningIdentity (value) { + return !!(value && typeof value === 'object' && + value.fabricKey && typeof value.fabricKey.signSchnorr === 'function' && + typeof value.identityId === 'string'); +} + +/** + * Build the JSON body Hub / LiveRelay expect for client-signed challenges. + * @param {object} input Identity, Key, Passport leaf, or already-resolved + * `{ fabricKey, identityId }` from {@link resolveFabricSigningIdentity} + * @param {string} message UTF-8 challenge string + * @returns {{ signature: string, pubkeyHex: string, identity: { id: string, xpub: string } }} + */ +function buildFabricIdentitySignedPayload (input, message) { + if (typeof message !== 'string' || !message) { + throw new Error('message required'); + } + const resolved = isResolvedSigningIdentity(input) + ? input + : resolveFabricSigningIdentity(input); + const { fabricKey, identityId } = resolved; + const signature = Buffer.from(fabricKey.signSchnorr(Buffer.from(message, 'utf8'))).toString('hex'); + const pubkeyHex = String(fabricKey.pubkey || ''); + if (!/^[a-f0-9]{66}$/i.test(pubkeyHex)) { + throw new Error('Could not derive Fabric public key'); + } + const xpub = String(fabricKey.xpub || ''); + if (!xpub) throw new Error('Fabric signing key missing xpub'); + return { + signature, + pubkeyHex, + identity: { id: identityId, xpub } + }; +} + +/** + * Verify Schnorr + identity.id matches the signing pubkey (IDENTITY.md). + * @param {string} message + * @param {string} signatureHex + * @param {string} pubkeyHex + * @param {{ id?: *, xpub: string }} identity + * @returns {{ ok: true, key: object, identityId: string }|{ ok: false, error: string }} + */ +function verifyIdentitySchnorr (message, signatureHex, pubkeyHex, identity) { + if (typeof message !== 'string' || !message) { + return { ok: false, error: 'Missing signed message' }; + } + if (typeof signatureHex !== 'string' || !/^[a-f0-9]{128}$/i.test(signatureHex)) { + return { ok: false, error: 'Missing or invalid signature' }; + } + if (typeof pubkeyHex !== 'string' || !/^[a-f0-9]{66}$/i.test(pubkeyHex)) { + return { ok: false, error: 'Missing or invalid pubkey' }; + } + if (!identity || typeof identity !== 'object' || typeof identity.xpub !== 'string' || !identity.xpub) { + return { ok: false, error: 'Missing identity xpub' }; + } + let key; + try { + key = new Key({ xpub: identity.xpub }); + } catch (_) { + return { ok: false, error: 'Invalid xpub' }; + } + const msgBuf = Buffer.from(message, 'utf8'); + let sigBuf; + try { + sigBuf = Buffer.from(signatureHex, 'hex'); + } catch (_) { + return { ok: false, error: 'Invalid signature encoding' }; + } + if (!key.verifySchnorr(msgBuf, sigBuf)) { + return { ok: false, error: 'Signature verification failed' }; + } + const compressedPub = String(key.pubkey || '').toLowerCase(); + if (compressedPub !== String(pubkeyHex).toLowerCase()) { + return { ok: false, error: 'Public key does not match xpub' }; + } + let expectedId; + try { + expectedId = fabricIdentityIdFromPubkeyHex(pubkeyHex); + } catch (_) { + return { ok: false, error: 'Could not derive identity from pubkey' }; + } + const claimedId = identity.id != null ? String(identity.id).trim() : ''; + if (!claimedId || String(expectedId) !== claimedId) { + return { ok: false, error: 'Identity id does not match xpub' }; + } + return { ok: true, key, identityId: claimedId }; +} + +module.exports = { + fabricIdentityIdFromPubkeyHex, + resolveFabricSigningIdentity, + buildFabricIdentitySignedPayload, + verifyIdentitySchnorr +}; diff --git a/functions/fabricKeyMaterial.js b/functions/fabricKeyMaterial.js new file mode 100644 index 000000000..11b7d537c --- /dev/null +++ b/functions/fabricKeyMaterial.js @@ -0,0 +1,107 @@ +'use strict'; + +/** + * Classify operator key material for Environment / Hub / application loaders. + * + * Latest RC1 expectation: + * - `FABRIC_XPRV` — BIP32 extended private key (preferred) + * - `FABRIC_SEED` — raw BIP32 seed **hex** (16–64 bytes; BIP39 PBKDF2 output is 64) + * - `FABRIC_MNEMONIC` — BIP39 word phrase + * + * `FABRIC_SEED` still accepts a mnemonic or an `xprv…` string for compatibility. + * + * @fileoverview Classify FABRIC_XPRV / FABRIC_SEED / FABRIC_MNEMONIC material. + * @module functions/fabricKeyMaterial + */ + +const bip39 = require('./bip39'); + +/** BIP32 `fromSeed` minimum length. */ +const MIN_SEED_BYTES = 16; +/** BIP32 `fromSeed` maximum length (BIP39 `mnemonicToSeed` is 64). */ +const MAX_SEED_BYTES = 64; + +/** + * Parse a raw BIP32 seed from hex (optional `0x` prefix) or a Buffer. + * + * @param {*} value + * @returns {Buffer|null} + */ +function parseRawSeedHex (value) { + if (value == null) return null; + if (Buffer.isBuffer(value)) { + if (value.length < MIN_SEED_BYTES || value.length > MAX_SEED_BYTES) return null; + return value; + } + if (value instanceof Uint8Array) { + if (value.byteLength < MIN_SEED_BYTES || value.byteLength > MAX_SEED_BYTES) return null; + return Buffer.from(value); + } + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed || /\s/.test(trimmed)) return null; + if (!/^(?:0x)?[0-9a-fA-F]+$/i.test(trimmed)) return null; + const hex = trimmed.slice(0, 2).toLowerCase() === '0x' ? trimmed.slice(2) : trimmed; + if (hex.length % 2 !== 0) return null; + const bytes = hex.length / 2; + if (bytes < MIN_SEED_BYTES || bytes > MAX_SEED_BYTES) return null; + return Buffer.from(hex, 'hex'); +} + +/** + * @param {*} value + * @returns {Object} `kind` plus at most one of `xprv`, `seed`, `mnemonic` + */ +function classifyFabricKeyMaterial (value) { + const trimmed = value == null ? '' : String(value).trim(); + if (!trimmed) return { kind: null }; + if (trimmed.startsWith('xprv') || trimmed.startsWith('tprv')) { + return { kind: 'xprv', xprv: trimmed }; + } + const hex = parseRawSeedHex(trimmed); + if (hex) return { kind: 'seedHex', seed: hex.toString('hex') }; + if (bip39.validateMnemonic(trimmed)) { + return { kind: 'mnemonic', mnemonic: trimmed }; + } + return { kind: 'unknown' }; +} + +/** + * Key constructor settings from process/env identity variables. + * Precedence: `FABRIC_XPRV` → `FABRIC_SEED` → `FABRIC_MNEMONIC`. + * + * @param {NodeJS.ProcessEnv} [env] + * @returns {Object|null} Key settings with `xprv`, `seed`, or `mnemonic` + */ +function keySettingsFromEnv (env = process.env) { + const xprv = String((env && env.FABRIC_XPRV) || '').trim(); + if (xprv.startsWith('xprv') || xprv.startsWith('tprv')) { + return { xprv }; + } + + const seedRaw = String((env && env.FABRIC_SEED) || '').trim(); + if (seedRaw) { + const classified = classifyFabricKeyMaterial(seedRaw); + if (classified.kind === 'xprv') return { xprv: classified.xprv }; + if (classified.kind === 'seedHex') return { seed: classified.seed }; + if (classified.kind === 'mnemonic') return { mnemonic: classified.mnemonic }; + } + + const mnemonic = String((env && env.FABRIC_MNEMONIC) || '').trim(); + if (mnemonic) { + const classified = classifyFabricKeyMaterial(mnemonic); + if (classified.kind === 'mnemonic') return { mnemonic: classified.mnemonic }; + if (classified.kind === 'xprv') return { xprv: classified.xprv }; + if (classified.kind === 'seedHex') return { seed: classified.seed }; + } + + return null; +} + +module.exports = { + MIN_SEED_BYTES, + MAX_SEED_BYTES, + parseRawSeedHex, + classifyFabricKeyMaterial, + keySettingsFromEnv +}; diff --git a/functions/fabricMessageCollection.js b/functions/fabricMessageCollection.js new file mode 100644 index 000000000..08ad500f1 --- /dev/null +++ b/functions/fabricMessageCollection.js @@ -0,0 +1,649 @@ +'use strict'; + +/** + * @fileoverview Ordered collection of bit-identical Fabric AMP Message frames. + * + * Canonical share / store unit for contract journals, Discord / GroupDataShare + * packs, IdentityCrossSign catch-up, and peer replay. Application JSON objects + * are **not** the format — `hex` of {@link Message#toBuffer} is. Hub + * `messages/*.json` (type + payload) is a different activity log. + * + * Identity of a frame is the AMP body hash (double-SHA256 of the body, the + * header `hash` field). Collection `root` is SHA-256 of the concatenated + * ordered hashes so replay order is committed. ARC fold + * (`contractMessageAccumulate`) may still sort by hash; those two commitments + * answer different questions. + * + * @module functions/fabricMessageCollection + * @see MESSAGES.md + * @see functions/contractMessageAccumulate.js + */ + +const fs = require('fs'); +const path = require('path'); + +const COLLECTION_PATH_MAX_LEN = 4096; + +/** + * Resolve an operator-supplied collection path. Absolute paths are normalized + * as-is; relative paths must stay under `process.cwd()`. + * @param {string} filePath + * @returns {string|null} + */ +function resolveCollectionFilePath (filePath) { + if (filePath == null || typeof filePath !== 'string') return null; + const trimmed = filePath.trim(); + if (!trimmed || trimmed.includes('\0') || trimmed.length > COLLECTION_PATH_MAX_LEN) return null; + let abs; + if (path.isAbsolute(trimmed)) { + abs = path.normalize(trimmed); + } else { + abs = path.resolve(process.cwd(), trimmed); + const root = path.resolve(process.cwd()); + const rel = path.relative(root, abs); + if (rel === '' || rel.startsWith('..' + path.sep) || rel === '..' || path.isAbsolute(rel)) { + return null; + } + } + if (!abs || abs.includes('\0') || !path.isAbsolute(abs)) return null; + return abs; +} + +const Message = require('../types/message'); +const Hash256 = require('../types/hash256'); +const { HEADER_SIZE, MAX_MESSAGE_SIZE } = require('../constants'); +const { + bufferFromPaste, + parseContractMessageBody, + messageHashHex, + authorXOnlyHex +} = require('./contractMessageAccumulate'); +/** @private */ +const COLLECTION_TYPE = 'FabricMessageCollection'; +/** @private */ +const SCHEMA_VERSION = 1; + +/** + * @returns {object} + */ +function createCollection () { + return { + type: COLLECTION_TYPE, + v: SCHEMA_VERSION, + messages: [], + seen: new Set() + }; +} + +/** + * Double-SHA256(body) must match the header `hash` field (Peer drop rule). + * @param {Message} msg + * @returns {boolean} + */ +function verifyBodyHash (msg) { + if (!msg || !msg.raw) return false; + const bodyBuf = msg.raw.data || Buffer.alloc(0); + const checksum = Hash256.doubleDigest(bodyBuf); + const expected = Buffer.isBuffer(msg.raw.hash) + ? msg.raw.hash.toString('hex') + : String(msg.raw.hash || ''); + return checksum === expected; +} + +/** + * @param {Buffer} buffer + * @returns {{ ok: true, message: Message, buffer: Buffer }|{ ok: false, error: string }} + */ +function parseFrame (buffer) { + if (!Buffer.isBuffer(buffer)) { + return { ok: false, error: 'not a buffer' }; + } + if (buffer.length < HEADER_SIZE) { + return { ok: false, error: 'undersize' }; + } + if (buffer.length > HEADER_SIZE + MAX_MESSAGE_SIZE) { + return { ok: false, error: 'oversize' }; + } + let message; + try { + message = Message.fromBuffer(buffer); + } catch (err) { + return { ok: false, error: (err && err.message) || 'parse failed' }; + } + if (!verifyBodyHash(message)) { + return { ok: false, error: 'body-hash-mismatch' }; + } + return { ok: true, message, buffer }; +} + +/** + * @param {Message} message + * @param {Object} [meta] + * @param {Buffer} [meta.buffer] + * @param {string} [meta.peer] + * @param {string} [meta.origin] + * @param {string} [meta.ts] + * @returns {object} + */ +function recordFromMessage (message, meta = {}) { + const buffer = Buffer.isBuffer(meta.buffer) ? meta.buffer : message.toBuffer(); + const hash = messageHashHex(message); + const type = message.type || message.wireType || null; + const author = authorXOnlyHex(message); + const record = { + hash, + hex: buffer.toString('hex'), + type + }; + if (author) record.author = author; + const inner = parseContractMessageBody(message); + if (inner) { + record.appType = inner.type; + record.contract = inner.contract; + } + if (meta.peer) record.peer = String(meta.peer); + if (meta.origin) record.origin = String(meta.origin); + if (meta.ts) record.ts = String(meta.ts); + return record; +} + +/** + * @param {*} input + * @returns {{ ok: true, buffer: Buffer }|{ ok: false, error: string }} + */ +function bufferFromInput (input) { + if (Buffer.isBuffer(input)) return { ok: true, buffer: input }; + if (input instanceof Uint8Array) return { ok: true, buffer: Buffer.from(input) }; + if (input && typeof input.toBuffer === 'function') { + try { + return { ok: true, buffer: input.toBuffer() }; + } catch (err) { + return { ok: false, error: (err && err.message) || 'toBuffer failed' }; + } + } + if (input && typeof input === 'object') { + const hex = input.hex || input.messageHex || input.bufferHex; + if (hex) { + try { + return { ok: true, buffer: bufferFromPaste(hex) }; + } catch (err) { + return { ok: false, error: (err && err.message) || 'invalid hex' }; + } + } + } + if (typeof input === 'string') { + try { + return { ok: true, buffer: bufferFromPaste(input) }; + } catch (err) { + return { ok: false, error: (err && err.message) || 'invalid hex' }; + } + } + return { ok: false, error: 'unsupported input' }; +} + +/** + * Append one AMP frame. Dedupes by body hash. Fail-closed on parse / body-hash. + * @param {object} collection + * @param {*} input Message, Buffer, hex, or `{ hex }` / journal `fabricMessage` + * @param {Object} [meta] + * @returns {{ accepted: boolean, duplicate: boolean, skipped: boolean, record: object|null, message: Message|null, error: string|null }} + */ +function ingest (collection, input, meta = {}) { + const empty = { + accepted: false, + duplicate: false, + skipped: true, + record: null, + message: null, + error: null + }; + if (!collection || !Array.isArray(collection.messages)) { + empty.error = 'invalid collection'; + return empty; + } + if (!collection.seen) collection.seen = new Set(collection.messages.map((m) => m && m.hash).filter(Boolean)); + + const decoded = bufferFromInput(input); + if (!decoded.ok) { + empty.error = decoded.error; + return empty; + } + + const parsed = parseFrame(decoded.buffer); + if (!parsed.ok) { + empty.error = parsed.error; + return empty; + } + + const record = recordFromMessage(parsed.message, Object.assign({}, meta, { + buffer: parsed.buffer + })); + if (input && typeof input === 'object' && input.hash && !input.toBuffer) { + const claimed = String(input.hash).toLowerCase().replace(/^0x/, ''); + if (/^[0-9a-f]{64}$/.test(claimed) && claimed !== record.hash) { + empty.error = 'hash-claim-mismatch'; + return empty; + } + } + + if (collection.seen.has(record.hash)) { + return { + accepted: false, + duplicate: true, + skipped: false, + record, + message: parsed.message, + error: null + }; + } + + collection.seen.add(record.hash); + collection.messages.push(record); + return { + accepted: true, + duplicate: false, + skipped: false, + record, + message: parsed.message, + error: null + }; +} + +/** + * @param {object} collection + * @param {Array<*>} list + * @param {Object} [meta] + * @returns {{ accepted: number, duplicate: number, skipped: number, errors: string[] }} + */ +function ingestMany (collection, list, meta = {}) { + const errors = []; + let accepted = 0; + let duplicate = 0; + let skipped = 0; + const rows = Array.isArray(list) ? list : []; + for (const item of rows) { + const result = ingest(collection, item, meta); + if (result.accepted) accepted += 1; + else if (result.duplicate) duplicate += 1; + else { + skipped += 1; + if (result.error) errors.push(result.error); + } + } + return { accepted, duplicate, skipped, errors }; +} + +/** + * Pull `{ hash, hex, type }` from GroupJournalBatch-style journal rows. + * @param {Array} entries + * @returns {object[]} + */ +function recordsFromJournalEntries (entries) { + const out = []; + const rows = Array.isArray(entries) ? entries : []; + for (const row of rows) { + if (!row || typeof row !== 'object') continue; + const fm = row.fabricMessage && typeof row.fabricMessage === 'object' + ? row.fabricMessage + : null; + if (fm && fm.hex) out.push(fm); + else if (row.hex) out.push(row); + } + return out; +} + +/** + * SHA-256 of concatenated ordered 32-byte hashes (order-sensitive). + * @param {object[]} messages + * @returns {string} + */ +function rootOf (messages) { + const rows = Array.isArray(messages) ? messages : []; + const parts = []; + for (const row of rows) { + const hash = row && row.hash != null ? String(row.hash).toLowerCase() : ''; + if (!/^[0-9a-f]{64}$/.test(hash)) continue; + parts.push(Buffer.from(hash, 'hex')); + } + return Hash256.digest(parts.length ? Buffer.concat(parts) : Buffer.alloc(0)); +} + +/** + * Public JSON document (no `seen` set). + * @param {object} collection + * @returns {object} + */ +function toJSON (collection) { + const messages = (collection && Array.isArray(collection.messages) ? collection.messages : []) + .map((row) => publicRecord(row)); + return { + type: COLLECTION_TYPE, + v: SCHEMA_VERSION, + count: messages.length, + root: rootOf(messages), + messages + }; +} + +/** + * @param {object} row + * @returns {object} + * @private + */ +function publicRecord (row) { + if (!row || typeof row !== 'object') return { hash: null, hex: '' }; + const out = { + hash: row.hash != null ? String(row.hash) : null, + hex: row.hex != null ? String(row.hex) : '' + }; + if (row.type) out.type = String(row.type); + if (row.author) out.author = String(row.author); + if (row.appType) out.appType = String(row.appType); + if (row.contract) out.contract = String(row.contract); + if (row.peer) out.peer = String(row.peer); + if (row.origin) out.origin = String(row.origin); + if (row.ts) out.ts = String(row.ts); + return out; +} + +/** + * Re-parse every `hex` so restore is fail-closed. + * @param {object} doc + * @returns {object} + */ +function fromJSON (doc) { + const collection = createCollection(); + if (!doc || typeof doc !== 'object') return collection; + const list = Array.isArray(doc.messages) ? doc.messages : []; + ingestMany(collection, list, { origin: 'restore' }); + return collection; +} + +/** + * One JSON record or raw hex per line. `#` comments ignored. + * @param {object} collection + * @returns {string} + */ +function toJSONL (collection) { + const messages = collection && Array.isArray(collection.messages) ? collection.messages : []; + return messages.map((row) => JSON.stringify(publicRecord(row))).join('\n') + (messages.length ? '\n' : ''); +} + +/** + * @param {string} text + * @param {Object} [meta] + * @returns {object} + */ +function fromJSONL (text, meta = {}) { + const collection = createCollection(); + ingestText(collection, text, meta); + return collection; +} + +/** + * Ingest a JSON collection, JSONL, a single record, or hex blob. + * @param {object} collection + * @param {string} text + * @param {Object} [meta] + * @returns {{ accepted: number, duplicate: number, skipped: number, errors: string[] }} + */ +function ingestText (collection, text, meta = {}) { + const raw = text == null ? '' : String(text); + const trimmed = raw.trim(); + if (!trimmed) return { accepted: 0, duplicate: 0, skipped: 0, errors: [] }; + + if (trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed); + if (parsed && parsed.type === COLLECTION_TYPE && Array.isArray(parsed.messages)) { + return ingestMany(collection, parsed.messages, meta); + } + if (parsed && (parsed.hex || parsed.messageHex)) { + const one = ingest(collection, parsed, meta); + return summarizeOne(one); + } + } catch (_) { + // fall through to JSONL / hex + } + } + + if (trimmed.includes('\n')) { + const stats = { accepted: 0, duplicate: 0, skipped: 0, errors: [] }; + const lines = raw.split(/\r?\n/); + for (const line of lines) { + const s = line.trim(); + if (!s || s.startsWith('#')) continue; + const chunk = ingestText(collection, s, meta); + stats.accepted += chunk.accepted; + stats.duplicate += chunk.duplicate; + stats.skipped += chunk.skipped; + for (const err of chunk.errors) stats.errors.push(err); + } + return stats; + } + + const one = ingest(collection, trimmed, meta); + return summarizeOne(one); +} + +/** + * @param {{ accepted: boolean, duplicate: boolean, skipped: boolean, error: string|null }} result + * @returns {{ accepted: number, duplicate: number, skipped: number, errors: string[] }} + * @private + */ +function summarizeOne (result) { + return { + accepted: result.accepted ? 1 : 0, + duplicate: result.duplicate ? 1 : 0, + skipped: result.skipped ? 1 : 0, + errors: result.error ? [result.error] : [] + }; +} + +/** + * Replay frames in collection order. Handler sees a live {@link Message}. + * @param {object} collection + * @param {Function} handler + * @returns {{ applied: number, skipped: number, errors: Array<{ index: number, hash: string|null, error: string }> }} + */ +function replay (collection, handler) { + const applied = { applied: 0, skipped: 0, errors: [] }; + const rows = collection && Array.isArray(collection.messages) ? collection.messages : []; + if (typeof handler !== 'function') { + applied.errors.push({ index: -1, hash: null, error: 'handler required' }); + return applied; + } + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const decoded = bufferFromInput(row); + if (!decoded.ok) { + applied.skipped += 1; + applied.errors.push({ index: i, hash: row && row.hash ? String(row.hash) : null, error: decoded.error }); + continue; + } + const parsed = parseFrame(decoded.buffer); + if (!parsed.ok) { + applied.skipped += 1; + applied.errors.push({ index: i, hash: row && row.hash ? String(row.hash) : null, error: parsed.error }); + continue; + } + try { + handler({ + message: parsed.message, + record: row, + index: i, + buffer: parsed.buffer, + inner: parseContractMessageBody(parsed.message) + }); + applied.applied += 1; + } catch (err) { + applied.skipped += 1; + applied.errors.push({ + index: i, + hash: row && row.hash ? String(row.hash) : null, + error: (err && err.message) || 'handler failed' + }); + } + } + return applied; +} + +/** + * Deterministic fold: `folder(state, ctx)` returns next state. + * @param {object} collection + * @param {Function} folder + * @param {*} initialState + * @returns {{ applied: number, skipped: number, errors: Array, state: * }} + */ +function replayFold (collection, folder, initialState) { + let state = initialState; + const result = replay(collection, (ctx) => { + state = folder(state, ctx); + }); + return { + applied: result.applied, + skipped: result.skipped, + errors: result.errors, + state + }; +} + +/** + * @param {string} filePath + * @param {object} collection + */ +function writeFile (filePath, collection) { + const dest = resolveCollectionFilePath(filePath); + if (!dest) throw new Error('invalid collection path'); + const ext = path.extname(dest).toLowerCase(); + const body = ext === '.jsonl' ? toJSONL(collection) : JSON.stringify(toJSON(collection), null, 2) + '\n'; + fs.writeFileSync(dest, body, 'utf8'); + return dest; +} + +/** + * @param {string} filePath + * @returns {object} + */ +function readFile (filePath) { + const src = resolveCollectionFilePath(filePath); + if (!src) throw new Error('invalid collection path'); + const text = fs.readFileSync(src, 'utf8'); + const collection = createCollection(); + ingestText(collection, text, { origin: src }); + return collection; +} + +/** + * CLI: collect | replay | verify. Reads files or stdin (`-`). + * @param {string[]} argv + * @param {{ stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WritableStream, stderr?: NodeJS.WritableStream }} [io] + * @returns {number} process exit code + */ +function runCli (argv = [], io = {}) { + const out = io.stdout || process.stdout; + const err = io.stderr || process.stderr; + const action = String(argv[0] || 'help').toLowerCase(); + const files = argv.slice(1); + + if (action === 'help' || action === '-h' || action === '--help') { + out.write([ + 'Usage: node functions/fabricMessageCollection.js [file ...]', + '', + ' collect Ingest AMP hex / JSON / JSONL into a FabricMessageCollection.', + ' replay Parse each stored frame and print hash, type, appType.', + ' verify Same as replay; exit 1 if any frame fails body-hash / parse.', + '', + 'Canonical bytes are Message.toBuffer() hex. JSON application objects are not.', + '' + ].join('\n')); + return 0; + } + + const collection = createCollection(); + const inputs = files.length ? files : ['-']; + for (const file of inputs) { + let text; + try { + if (file === '-') { + text = fs.readFileSync(0, 'utf8'); + } else { + const src = resolveCollectionFilePath(file); + if (!src) { + err.write('read failed: ' + file + ': invalid collection path\n'); + return 2; + } + text = fs.readFileSync(src, 'utf8'); + } + } catch (exception) { + err.write('read failed: ' + file + ': ' + ((exception && exception.message) || 'unknown error') + '\n'); + return 2; + } + ingestText(collection, text, { origin: file }); + } + + if (action === 'collect') { + out.write(JSON.stringify(toJSON(collection), null, 2) + '\n'); + return 0; + } + + if (action === 'replay' || action === 'verify') { + const rows = []; + const result = replay(collection, (ctx) => { + rows.push({ + index: ctx.index, + hash: ctx.record && ctx.record.hash, + type: ctx.message.type, + appType: ctx.inner && ctx.inner.type ? ctx.inner.type : null, + contract: ctx.inner && ctx.inner.contract ? ctx.inner.contract : null + }); + }); + out.write(JSON.stringify({ + type: COLLECTION_TYPE, + count: collection.messages.length, + root: rootOf(collection.messages), + applied: result.applied, + skipped: result.skipped, + errors: result.errors, + messages: rows + }, null, 2) + '\n'); + if (action === 'verify' && (result.skipped || result.errors.length)) { + err.write('verify failed\n'); + return 1; + } + return 0; + } + + err.write('unknown action: ' + action + '\n'); + return 2; +} + +module.exports = { + COLLECTION_TYPE, + SCHEMA_VERSION, + createCollection, + verifyBodyHash, + parseFrame, + recordFromMessage, + bufferFromInput, + ingest, + ingestMany, + ingestText, + recordsFromJournalEntries, + rootOf, + toJSON, + fromJSON, + toJSONL, + fromJSONL, + replay, + replayFold, + writeFile, + readFile, + runCli, + resolveCollectionFilePath +}; + +if (require.main === module) { + const code = runCli(process.argv.slice(2)); + if (code) process.exit(code); +} diff --git a/functions/fabricSetup.js b/functions/fabricSetup.js index 81614d476..2a6d2c524 100644 --- a/functions/fabricSetup.js +++ b/functions/fabricSetup.js @@ -160,7 +160,7 @@ function walletFromXprv (xprv) { /** * @param {Environment} environment * @param {string} [walletFile] - * @returns {{ ok: boolean, error?: string }} + * @returns {{ok: boolean}|{ok: boolean, path: string}|{ok: boolean, error: string}} */ function applyWalletFileOption (environment, walletFile) { if (!isSet(walletFile)) return { ok: true }; @@ -339,7 +339,7 @@ function formatSnapshot (snapshot) { * Seed / xprv for an explicit reveal. Callers must not log this by default. * * @param {Environment} environment - * @returns {{ ok: boolean, seed?: string, xprv?: string, error?: string }} + * @returns {{ok: boolean, seed: *, xprv: *}|{ok: boolean, error: string}} */ function revealSecrets (environment) { if (environment && environment.walletLocked) { @@ -382,7 +382,7 @@ function writePrivateFile (filePath, content) { * @param {string} [opts.password] Encryption password (required unless `encrypt` is false). * @param {boolean} [opts.encrypt=true] * @param {boolean} [opts.force] - * @returns {{ ok: boolean, seed?: string, walletId?: string, snapshot?: object, error?: string }} + * @returns {{ok: boolean, seed: string, walletId: *, snapshot: object}|{ok: boolean, error: string}|{ok: boolean, error: string, snapshot: object}} */ function generateWallet (environment, opts = {}) { const force = !!opts.force; @@ -427,7 +427,7 @@ function generateWallet (environment, opts = {}) { /** * @param {Environment} environment * @param {string} [destPath] - * @returns {{ ok: boolean, path?: string, error?: string }} + * @returns {{ok: boolean, path: string}|{ok: boolean, error: string}} */ function backupWallet (environment, destPath) { const dest = destPath @@ -474,7 +474,7 @@ function walletFromBackupObject (parsed, passphrase = '') { * @param {string} [opts.passphrase] BIP39 passphrase for plaintext backups that store a seed. * @param {string} [opts.password] Unlock a sealed backup, or encrypt a plaintext restore. * @param {boolean} [opts.encrypt=true] - * @returns {{ ok: boolean, walletId?: string, snapshot?: object, error?: string }} + * @returns {{ok: boolean, walletId: *, snapshot: object}|{ok: boolean, error: string}} */ function restoreWalletFromFile (environment, sourcePath, opts = {}) { const force = !!opts.force; @@ -550,7 +550,7 @@ function restoreWalletFromFile (environment, sourcePath, opts = {}) { * @param {string} [opts.passphrase] BIP39 passphrase. * @param {string} [opts.password] Encryption password (required unless `encrypt` is false). * @param {boolean} [opts.encrypt=true] - * @returns {{ ok: boolean, walletId?: string, snapshot?: object, error?: string }} + * @returns {{ok: boolean, walletId: *, snapshot: object}|{ok: boolean, error: string}} */ function restoreWalletFromSeed (environment, phrase, opts = {}) { const force = !!opts.force; diff --git a/functions/fabricWalletIdentity.js b/functions/fabricWalletIdentity.js new file mode 100644 index 000000000..6e2f3671b --- /dev/null +++ b/functions/fabricWalletIdentity.js @@ -0,0 +1,63 @@ +'use strict'; + +/** + * Load operator identity from `~/.fabric/wallet.json` (Environment). + * + * Sealed wallets need `FABRIC_PASSWORD`. Public object never contains seed/xprv. + * Always reads the wallet file (`loadWallet({ fromFile: true })`) so a leftover + * `FABRIC_SEED` / `NODE_ENV=test` fixture cannot replace the file identity. + * + * @fileoverview Wallet-file identity fallback for suite loaders. + * @module functions/fabricWalletIdentity + */ + +const os = require('os'); +const path = require('path'); + +const Environment = require('../types/environment'); +const { classifyFabricKeyMaterial } = require('./fabricKeyMaterial'); + +/** + * @param {Object} [opts] + * @param {string} [opts.home] + * @param {string} [opts.walletPath] + * @param {string} [opts.store] + * @param {string} [opts.password] + * @returns {{ mnemonic: string|null, seed: string|null, xprv: string, xpub: string }|null} + */ +function loadIdentityFromWalletFile (opts = {}) { + const home = opts.home || process.env.HOME || os.homedir(); + const store = opts.store || path.join(home, '.fabric'); + const walletPath = opts.walletPath || path.join(store, 'wallet.json'); + const environment = new Environment({ + home, + path: walletPath, + store + }); + try { + environment.loadWallet({ + password: opts.password || environment.readVariable('FABRIC_PASSWORD'), + fromFile: true + }); + } catch (_) { + return null; + } + if (!environment.wallet || !environment.wallet.key || !environment.wallet.key.xprv) { + return null; + } + const key = environment.wallet.key; + const seedRaw = key.seed ? String(key.seed) : ''; + const classified = classifyFabricKeyMaterial(seedRaw); + return { + mnemonic: classified.kind === 'mnemonic' + ? classified.mnemonic + : (key.mnemonic ? String(key.mnemonic) : null), + seed: classified.kind === 'seedHex' ? classified.seed : (classified.kind === 'mnemonic' ? null : (seedRaw || null)), + xprv: key.xprv, + xpub: key.xpub || null + }; +} + +module.exports = { + loadIdentityFromWalletFile +}; diff --git a/functions/identityCrossSign.js b/functions/identityCrossSign.js new file mode 100644 index 000000000..0e8fdbd32 --- /dev/null +++ b/functions/identityCrossSign.js @@ -0,0 +1,138 @@ +'use strict'; + +/** + * @fileoverview Canonical IdentityCrossSign / IdentityCrossSignRevoke strings. + * Browser-safe (no Node built-ins). Site-login / device-link HTTP stays in + * `@fabric/http`; this module is the gossiped BIP340 message after countersign. + */ + +const CROSS_SIGN_PREFIX = 'fabric:identity-cross-sign:1'; +const REVOKE_PREFIX = 'fabric:identity-cross-sign-revoke:1'; +const SIGN_TYPE = 'IdentityCrossSign'; +const REVOKE_TYPE = 'IdentityCrossSignRevoke'; + +function _normHex (value) { + return String(value || '').trim().toLowerCase().replace(/^0x/, ''); +} + +function _normNonce (nonce) { + const n = _normHex(nonce); + if (!/^[a-f0-9]{64}$/.test(n)) return null; + return n; +} + +/** Compressed 33-byte (02/03 + 32) or x-only 32-byte hex. Rejects `:` so fields cannot collide. */ +function _normPubkey (value) { + const s = _normHex(value); + if (/^0[23][a-f0-9]{64}$/.test(s)) return s; + if (/^[a-f0-9]{64}$/.test(s)) return s; + return null; +} + +/** + * Canonical BIP340 message: A attests that B is the same actor. + * @param {string} nonce 64-hex from the device-link session + * @param {string} localPubkey signing device pubkey + * @param {string} peerPubkey linked device pubkey + * @returns {string|null} + */ +function buildCrossSignMessage (nonce, localPubkey, peerPubkey) { + const n = _normNonce(nonce); + const local = _normPubkey(localPubkey); + const peer = _normPubkey(peerPubkey); + if (!n || !local || !peer) return null; + return `${CROSS_SIGN_PREFIX}:${n}:${local}:${peer}`; +} + +/** + * Canonical BIP340 message: either side splits the A↔B edge. + * @param {string} nonce + * @param {string} localPubkey + * @param {string} peerPubkey + * @returns {string|null} + */ +function buildRevokeMessage (nonce, localPubkey, peerPubkey) { + const n = _normNonce(nonce); + const local = _normPubkey(localPubkey); + const peer = _normPubkey(peerPubkey); + if (!n || !local || !peer) return null; + return `${REVOKE_PREFIX}:${n}:${local}:${peer}`; +} + +function parsePrefixed (msg, prefix) { + const s = String(msg || ''); + const head = prefix + ':'; + if (!s.startsWith(head)) return null; + const rest = s.slice(head.length); + const nonce = rest.slice(0, 64); + if (!/^[a-f0-9]{64}$/i.test(nonce) || rest[64] !== ':') return null; + const after = rest.slice(65); + const i = after.indexOf(':'); + if (i <= 0) return null; + const localPubkey = _normPubkey(after.slice(0, i)); + const peerPubkey = _normPubkey(after.slice(i + 1)); + if (!localPubkey || !peerPubkey) return null; + return { nonce: nonce.toLowerCase(), localPubkey, peerPubkey }; +} + +function parseCrossSignMessage (msg) { + return parsePrefixed(msg, CROSS_SIGN_PREFIX); +} + +function parseRevokeMessage (msg) { + return parsePrefixed(msg, REVOKE_PREFIX); +} + +/** + * @param {string} type + * @param {object} [fields] + * @returns {object} + */ +function _typedObject (type, fields = {}) { + return { + type, + '@type': type, + localPubkey: _normPubkey(fields.localPubkey) || '', + peerPubkey: _normPubkey(fields.peerPubkey) || '', + nonce: _normNonce(fields.nonce) || '', + createdAt: fields.createdAt || new Date().toISOString(), + signature: fields.signature || null, + pubkeyHex: fields.pubkeyHex || null, + identity: fields.identity || null + }; +} + +/** + * @param {object} [fields] + * @returns {object} + */ +function buildCrossSignObject (fields = {}) { + return _typedObject(SIGN_TYPE, fields); +} + +/** + * @param {object} [fields] + * @returns {object} + */ +function buildRevokeObject (fields = {}) { + return _typedObject(REVOKE_TYPE, fields); +} + +function isCrossSignType (type) { + const t = String(type || ''); + return t === SIGN_TYPE || t === REVOKE_TYPE; +} + +module.exports = { + CROSS_SIGN_PREFIX, + REVOKE_PREFIX, + SIGN_TYPE, + REVOKE_TYPE, + buildCrossSignMessage, + buildRevokeMessage, + parseCrossSignMessage, + parseRevokeMessage, + buildCrossSignObject, + buildRevokeObject, + isCrossSignType +}; diff --git a/functions/identityCrossSignVerify.js b/functions/identityCrossSignVerify.js new file mode 100644 index 000000000..eebaad459 --- /dev/null +++ b/functions/identityCrossSignVerify.js @@ -0,0 +1,125 @@ +'use strict'; + +/** + * @fileoverview Sign / verify IdentityCrossSign bodies with protocol identity + * Schnorr (`functions/fabricIdentitySchnorr`). AMP / envelope author is transport + * only — the inner BIP340 over the canonical string is the identity proof. + */ + +const { + buildFabricIdentitySignedPayload, + resolveFabricSigningIdentity, + verifyIdentitySchnorr +} = require('./fabricIdentitySchnorr'); +const { + SIGN_TYPE, + REVOKE_TYPE, + buildCrossSignMessage, + buildRevokeMessage, + buildCrossSignObject, + buildRevokeObject +} = require('./identityCrossSign'); + +function pubkeyXOnly (hex) { + const s = String(hex || '').toLowerCase().replace(/^0x/, ''); + if (/^0[23][0-9a-f]{64}$/.test(s)) return s.slice(2); + if (/^[0-9a-f]{64}$/.test(s)) return s; + return null; +} + +function pubkeysMatch (a, b) { + const xa = pubkeyXOnly(a); + const xb = pubkeyXOnly(b); + return !!(xa && xb && xa === xb); +} + +function _messageFor (kind, rec) { + if (kind === REVOKE_TYPE) { + return buildRevokeMessage(rec.nonce, rec.localPubkey, rec.peerPubkey); + } + return buildCrossSignMessage(rec.nonce, rec.localPubkey, rec.peerPubkey); +} + +/** + * @param {object} identity unlocked Fabric Identity, HD Key, or Passport + * `{ privateKeyHex, xpub }` leaf. Canonical `localPubkey` is the Fabric + * signing key (`fabricKey.pubkey`), never Bech32 `identity.id` or the + * HD master pubkey. + * @param {object} fields { peerPubkey, nonce, createdAt? } + * `createdAt` is display metadata only — it is not in the canonical BIP340 + * string and must not be treated as authenticated. + * @param {string} [kind] + * @returns {object} + */ +function signCrossSign (identity, fields, kind = SIGN_TYPE) { + if (!fields || typeof fields !== 'object') { + throw new Error('cross-sign fields required'); + } + if (kind !== SIGN_TYPE && kind !== REVOKE_TYPE) { + throw new Error('unknown cross-sign type'); + } + const resolved = resolveFabricSigningIdentity(identity); + const localPubkey = String(resolved.fabricKey.pubkey || ''); + const rec = { + localPubkey, + peerPubkey: fields.peerPubkey, + nonce: fields.nonce, + createdAt: fields.createdAt + }; + const message = _messageFor(kind, rec); + if (!message) throw new Error('invalid cross-sign fields'); + const signed = buildFabricIdentitySignedPayload(resolved, message); + const base = kind === REVOKE_TYPE + ? buildRevokeObject(Object.assign({}, rec, signed)) + : buildCrossSignObject(Object.assign({}, rec, signed)); + return base; +} + +/** + * @param {object} object gossip / HTTP body + * @param {string} [_signerPubkey] AMP / envelope author when present (ignored) + * @returns {{ ok: true, kind: string, record: object }|{ ok: false, error: string }} + * `record.createdAt` is copied from the body when present; it is not covered + * by the Schnorr signature. + */ +function verifyCrossSignObject (object, _signerPubkey) { + if (!object || typeof object !== 'object') { + return { ok: false, error: 'cross-sign object required' }; + } + const kind = object.type || object['@type']; + if (kind !== SIGN_TYPE && kind !== REVOKE_TYPE) { + return { ok: false, error: 'unknown cross-sign type' }; + } + const localPubkey = object.localPubkey || object.pubkeyHex; + const peerPubkey = object.peerPubkey; + const nonce = object.nonce; + const message = _messageFor(kind, { localPubkey, peerPubkey, nonce }); + if (!message) return { ok: false, error: 'invalid cross-sign fields' }; + const checked = verifyIdentitySchnorr( + message, + object.signature, + object.pubkeyHex || localPubkey, + object.identity + ); + if (!checked.ok) return { ok: false, error: checked.error || 'signature failed' }; + if (!pubkeysMatch(checked.key && checked.key.pubkey, localPubkey) && + !pubkeysMatch(object.pubkeyHex, localPubkey)) { + return { ok: false, error: 'pubkey does not match localPubkey' }; + } + return { + ok: true, + kind, + record: { + localPubkey, + peerPubkey, + nonce, + createdAt: object.createdAt || null + } + }; +} + +module.exports = { + signCrossSign, + verifyCrossSignObject, + pubkeysMatch +}; diff --git a/functions/identityLock.d.ts b/functions/identityLock.d.ts deleted file mode 100644 index 76968e543..000000000 --- a/functions/identityLock.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -declare const identityLock: { - DEFAULT_LOCK_TIMEOUT_MINUTES: number; - IdentityLock: new (...args: unknown[]) => unknown; - clampLockTimeoutMinutes: (minutes: unknown) => number; - lockTimeoutMinutesToMs: (minutes: unknown) => number; - [key: string]: unknown; -}; - -export = identityLock; diff --git a/functions/inventoryHtlc.js b/functions/inventoryHtlc.js index 06b3a9505..6c14eb348 100644 --- a/functions/inventoryHtlc.js +++ b/functions/inventoryHtlc.js @@ -24,6 +24,8 @@ const ecc = require('../types/ecc'); const bip341 = require('bitcoinjs-lib/src/payments/bip341'); const psbtutils = require('bitcoinjs-lib/src/psbt/psbtutils'); const { payments, networks, script, Psbt } = bitcoin; +const { encodeBitcoinUri } = require('./bip21'); +const { BIP125_SEQUENCE_LOCKTIME_ONLY } = require('./bip125'); bitcoin.initEccLib(ecc); @@ -65,6 +67,74 @@ function asBuffer (value, encoding = 'hex') { return null; } +/** BIP-341 leaf version for tapscript (0xc0). */ +const LEAF_VERSION_TAPSCRIPT = 0xc0; + +/** + * One BIP-371 `PSBT_IN_TAP_LEAF_SCRIPT` entry (script + control block + leaf version). + * + * @param {Object} opts + * @param {Buffer|Uint8Array|string} opts.script + * @param {Buffer|Uint8Array|string} opts.controlBlock + * @param {number} [opts.leafVersion] + * @returns {{ leafVersion: number, script: Buffer, controlBlock: Buffer }} + */ +function tapLeafScriptEntry (opts = {}) { + const script = asBuffer(opts.script); + const controlBlock = asBuffer(opts.controlBlock); + if (!script || !script.length) { + throw new Error('BIP371 tapLeafScript requires script bytes'); + } + if (!controlBlock || controlBlock.length < 33 || + controlBlock.length > 33 + (32 * 128) || + ((controlBlock.length - 33) % 32) !== 0) { + throw new Error('BIP371 controlBlock length must be 33 + 32*m bytes'); + } + const leafVersion = opts.leafVersion != null + ? Number(opts.leafVersion) + : LEAF_VERSION_TAPSCRIPT; + if (!Number.isInteger(leafVersion) || leafVersion < 0 || leafVersion > 255) { + throw new Error('BIP371 leafVersion must be an 8-bit integer'); + } + if ((leafVersion & 1) !== 0) { + throw new Error('BIP371 leafVersion must be even (BIP-341 leaf version + parity bit)'); + } + if ((controlBlock[0] & 0xfe) !== leafVersion) { + throw new Error('BIP371 leafVersion must match the control block'); + } + return { leafVersion, script, controlBlock }; +} + +/** + * Taproot PSBT input fields for a script-path spend (BIP-371 bags). + * + * @param {Object} opts + * @param {Buffer|Uint8Array|string} opts.tapInternalKey 32-byte x-only internal key + * @param {Buffer|Uint8Array|string} opts.script + * @param {Buffer|Uint8Array|string} opts.controlBlock + * @param {number} [opts.leafVersion] + * @param {Buffer|Uint8Array|string} [opts.tapMerkleRoot] + * @returns {Object} + */ +function taprootPsbtInputFields (opts = {}) { + const tapInternalKey = asBuffer(opts.tapInternalKey); + if (!tapInternalKey || tapInternalKey.length !== 32) { + throw new Error('BIP371 tapInternalKey must be 32 bytes (x-only)'); + } + const fields = { + tapInternalKey, + tapLeafScript: [tapLeafScriptEntry(opts)] + }; + if (opts.tapMerkleRoot != null && opts.tapMerkleRoot !== '') { + const tapMerkleRoot = asBuffer(opts.tapMerkleRoot); + if (!tapMerkleRoot || tapMerkleRoot.length !== 32) { + throw new Error('BIP371 tapMerkleRoot must be 32 bytes'); + } + fields.tapMerkleRoot = tapMerkleRoot; + } + return fields; +} + function toXOnly (pubkey33) { const key = asBuffer(pubkey33); if (!key || key.length !== 33) return null; @@ -135,12 +205,12 @@ function hash256 (buf) { } /** - * BIP21-style URI and display amounts so wallets can pre-fill send-to Taproot (bc1p / bcrt1 / tb1). + * BIP21 bitcoin: URI and display amounts so wallets can pre-fill send-to Taproot (bc1p / bcrt1 / tb1). * @param {Object} opts * @param {string} opts.paymentAddress - Bech32m P2TR address * @param {number} opts.amountSats - Invoice amount in satoshis * @param {string} [opts.label] - Optional label (shortened for URI) - * @returns {{ amountBtc: string, bitcoinUri: string, label?: string }} + * @returns {{ amountBtc: string, bitcoinUri: string, label: (string|undefined) }} */ function buildHtlcFundingHints ({ paymentAddress, amountSats, label = '' }) { const addr = String(paymentAddress || '').trim(); @@ -149,11 +219,12 @@ function buildHtlcFundingHints ({ paymentAddress, amountSats, label = '' }) { return { amountBtc: '', bitcoinUri: '', label: label || undefined }; } const amountBtc = (sats / 100000000).toFixed(8); - const params = new URLSearchParams(); - params.set('amount', amountBtc); const lab = String(label || '').trim().slice(0, 120); - if (lab) params.set('label', lab); - const bitcoinUri = `bitcoin:${addr}?${params.toString()}`; + const bitcoinUri = encodeBitcoinUri({ + address: addr, + amount: amountBtc, + label: lab || undefined + }); return { amountBtc, bitcoinUri, ...(lab ? { label: lab } : {}) }; } @@ -244,12 +315,12 @@ function prepareInventoryHtlcSellerClaimPsbt (opts = {}) { script: out.script, value: BigInt(inputSats) }, - tapInternalKey: TAPROOT_INTERNAL_NUMS, - tapLeafScript: [{ - leafVersion: bip341.LEAF_VERSION_TAPSCRIPT, + ...taprootPsbtInputFields({ + tapInternalKey: TAPROOT_INTERNAL_NUMS, script: claimBuf, - controlBlock - }] + controlBlock, + leafVersion: bip341.LEAF_VERSION_TAPSCRIPT + }) }); psbt.addOutput({ address: String(destinationAddress || '').trim(), @@ -335,17 +406,17 @@ function prepareInventoryHtlcBuyerRefundPsbt (opts = {}) { psbt.addInput({ hash: tx.getId(), index: vout, - sequence: 0xfffffffe, + sequence: BIP125_SEQUENCE_LOCKTIME_ONLY, witnessUtxo: { script: out.script, value: BigInt(inputSats) }, - tapInternalKey: TAPROOT_INTERNAL_NUMS, - tapLeafScript: [{ - leafVersion: bip341.LEAF_VERSION_TAPSCRIPT, + ...taprootPsbtInputFields({ + tapInternalKey: TAPROOT_INTERNAL_NUMS, script: refundBuf, - controlBlock - }] + controlBlock, + leafVersion: bip341.LEAF_VERSION_TAPSCRIPT + }) }); psbt.addOutput({ address: String(destinationAddress || '').trim(), @@ -594,6 +665,9 @@ function validateInventoryHtlcOffer (opts = {}) { } module.exports = { + LEAF_VERSION_TAPSCRIPT, + tapLeafScriptEntry, + taprootPsbtInputFields, buildInventoryHtlcP2tr, buildHtlcFundingHints, buildDocumentOfferEscrow, diff --git a/functions/musig2.js b/functions/musig2.js new file mode 100644 index 000000000..d93714617 --- /dev/null +++ b/functions/musig2.js @@ -0,0 +1,851 @@ +/** + * BIP-327 MuSig2 (n-of-n Schnorr). Official vectors: + * https://github.com/bitcoin/bips/tree/master/bip-0327/vectors + * + * MuSig2 is **n-of-n only**. t-of-n Bitcoin spends stay Taproot script-path + * (`OP_CHECKSIGADD`). Off-chain Beacon / GroupChange stay independent BIP-340 + * signature counts. Interactive `P2P_MUSIG_*` mesh sessions live in + * {@link module:functions/musig2Session} and are dispatched by {@link Peer}. + * + * @fileoverview BIP-327 MuSig2. + * @module functions/musig2 + */ +'use strict'; + +const crypto = require('crypto'); +const { secp256k1, schnorr: schnorrModule } = require('@noble/curves/secp256k1.js'); +const taggedHash = require('./taggedHash'); +const { toUint8Flexible } = require('./bytes'); + +const SecpPoint = secp256k1.ProjectivePoint || secp256k1.Point; +const N = (secp256k1.CURVE && secp256k1.CURVE.n) || + (secp256k1.Point && secp256k1.Point.Fn && secp256k1.Point.Fn.ORDER); +const nobleSchnorr = schnorrModule && typeof schnorrModule.verify === 'function' + ? schnorrModule + : (schnorrModule && schnorrModule.schnorr) || schnorrModule; + +/** + * @param {Buffer|Uint8Array} bytes + * @returns {object} + * @private + */ +function fromBytes (bytes) { + if (!SecpPoint) throw new Error('Unsupported noble secp256k1 Point API'); + const u8 = toUint8Flexible(bytes, 65); + if (typeof SecpPoint.fromBytes === 'function') return SecpPoint.fromBytes(u8); + if (typeof SecpPoint.fromHex === 'function') { + return SecpPoint.fromHex(Buffer.from(u8).toString('hex')); + } + throw new Error('Unsupported noble secp256k1 Point API'); +} + +/** + * @param {object} P + * @param {boolean} [compressed] + * @returns {Uint8Array} + * @private + */ +function toRawBytesLocal (P, compressed = true) { + if (!P) throw new Error('Missing point'); + if (typeof P.toRawBytes === 'function') return P.toRawBytes(compressed); + if (typeof P.toBytes === 'function') { + if (compressed) return P.toBytes(); + const affine = typeof P.toAffine === 'function' ? P.toAffine() : null; + if (!affine || typeof affine.x !== 'bigint' || typeof affine.y !== 'bigint') { + throw new Error('Point.toAffine() unavailable for uncompressed encoding'); + } + const x = Buffer.from(affine.x.toString(16).padStart(64, '0'), 'hex'); + const y = Buffer.from(affine.y.toString(16).padStart(64, '0'), 'hex'); + return Buffer.concat([Buffer.from([0x04]), x, y]); + } + throw new Error('Unsupported point serialization'); +} + +/** + * @param {object} P + * @returns {number} + * @private + */ +function getYParity (P) { + const y = (P && typeof P.y === 'bigint') + ? P.y + : (P && typeof P.toAffine === 'function' ? P.toAffine().y : null); + if (typeof y !== 'bigint') throw new Error('Unable to determine point y-parity'); + return (y & 1n) ? 1 : 0; +} + +const ZERO33 = Buffer.alloc(33); + +class InvalidContributionError extends Error { + /** + * @param {number|null} signer + * @param {string} contrib pubkey | pubnonce | aggnonce | psig | aggothernonce + */ + constructor (signer, contrib) { + super(`Invalid MuSig2 contribution (${contrib})` + + (signer == null ? '' : ` from signer ${signer}`)); + this.name = 'InvalidContributionError'; + this.signer = signer; + this.contrib = contrib; + } +} + +class ValueError extends Error { + constructor (message) { + super(message); + this.name = 'ValueError'; + } +} + +/** + * @param {number} threshold + * @param {number} n + * @returns {boolean} + */ +function musig2IsNOfN (threshold, n) { + const t = Number(threshold); + const count = Number(n); + return Number.isInteger(t) && Number.isInteger(count) && count >= 2 && t === count; +} + +/** + * @param {Buffer|Uint8Array|string} input + * @returns {Buffer} + * @private + */ +function asBuf (input) { + if (Buffer.isBuffer(input)) return input; + if (input instanceof Uint8Array) return Buffer.from(input); + if (typeof input === 'string') { + const hex = String(input).trim().replace(/^0x/i, ''); + if (hex.length % 2) throw new TypeError('odd hex length'); + if (!/^[0-9a-f]*$/i.test(hex)) throw new TypeError('expected hex'); + return Buffer.from(hex, 'hex'); + } + throw new TypeError('expected Buffer, Uint8Array, or hex'); +} + +/** + * @param {bigint} x + * @returns {Buffer} + * @private + */ +function bytesFromInt (x) { + let hex = x.toString(16); + if (hex.length % 2) hex = '0' + hex; + return Buffer.from(hex.padStart(64, '0'), 'hex'); +} + +/** + * @param {Buffer} b + * @returns {bigint} + * @private + */ +function intFromBytes (b) { + const hex = Buffer.from(b).toString('hex'); + if (!hex) return 0n; + return BigInt('0x' + hex); +} + +/** + * @param {Buffer} a + * @param {Buffer} b + * @returns {Buffer} + * @private + */ +function bytesXor (a, b) { + const out = Buffer.alloc(a.length); + for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i]; + return out; +} + +/** + * @param {*} P + * @returns {boolean} + * @private + */ +function isInfinite (P) { + if (!P) return true; + if (SecpPoint && SecpPoint.ZERO && typeof P.equals === 'function') { + return P.equals(SecpPoint.ZERO); + } + return false; +} + +/** + * @param {*} P + * @returns {boolean} + * @private + */ +function hasEvenY (P) { + return getYParity(P) === 0; +} + +/** + * @param {*} P + * @returns {Buffer} 32-byte x + * @private + */ +function xbytes (P) { + const c = Buffer.from(toRawBytesLocal(P, true)); + return c.subarray(1, 33); +} + +/** + * @param {*} P + * @returns {Buffer} 33-byte compressed + * @private + */ +function cbytes (P) { + return Buffer.from(toRawBytesLocal(P, true)); +} + +/** + * @param {*|null} P + * @returns {Buffer} + * @private + */ +function cbytesExt (P) { + if (isInfinite(P)) return Buffer.from(ZERO33); + return cbytes(P); +} + +/** + * @param {*|null} P + * @returns {*|null} + * @private + */ +function pointNegate (P) { + if (isInfinite(P)) return P; + if (typeof P.negate === 'function') return P.negate(); + return P.multiply(N - 1n); +} + +/** + * @param {*|null} P + * @param {bigint} k + * @returns {*|null} + * @private + */ +function pointMul (P, k) { + if (isInfinite(P)) return null; + let s = k % N; + if (s < 0n) s += N; + if (s === 0n) return null; + return P.multiply(s); +} + +/** + * @param {*|null} P1 + * @param {*|null} P2 + * @returns {*|null} + * @private + */ +function pointAdd (P1, P2) { + if (isInfinite(P1)) return isInfinite(P2) ? null : P2; + if (isInfinite(P2)) return P1; + const R = P1.add(P2); + return isInfinite(R) ? null : R; +} + +/** + * @param {Buffer} x 33-byte compressed + * @returns {*} + * @private + */ +function cpoint (x) { + const buf = asBuf(x); + if (buf.length !== 33) { + throw new ValueError('x is not a valid compressed point.'); + } + if (buf[0] !== 0x02 && buf[0] !== 0x03) { + throw new ValueError('x is not a valid compressed point.'); + } + try { + return fromBytes(buf); + } catch { + throw new ValueError('x is not a valid compressed point.'); + } +} + +/** + * @param {Buffer} x + * @returns {*|null} + * @private + */ +function cpointExt (x) { + const buf = asBuf(x); + if (buf.equals(ZERO33)) return null; + return cpoint(buf); +} + +/** + * @param {Buffer} seckey + * @returns {Buffer} + */ +function individualPk (seckey) { + const d0 = intFromBytes(asBuf(seckey)); + if (!(d0 >= 1n && d0 <= N - 1n)) { + throw new ValueError('The secret key must be an integer in the range 1..n-1.'); + } + const P = SecpPoint.BASE.multiply(d0); + return cbytes(P); +} + +/** + * Lexicographic sort of compressed encodings (BIP-327 KeySort). Does not + * require valid curve points — the official vectors include an invalid key. + * + * @param {Array} pubkeys + * @returns {Array} + */ +function keySort (pubkeys) { + if (!Array.isArray(pubkeys)) throw new TypeError('pubkeys must be an array'); + return pubkeys.map((pk) => Buffer.from(asBuf(pk))).sort(Buffer.compare); +} + +/** + * @param {Array} pubkeys + * @returns {Buffer} + * @private + */ +function hashKeys (pubkeys) { + return taggedHash('KeyAgg list', Buffer.concat(pubkeys.map((pk) => asBuf(pk)))); +} + +/** + * @param {Array} pubkeys + * @returns {Buffer} + * @private + */ +function getSecondKey (pubkeys) { + const first = asBuf(pubkeys[0]); + for (let j = 1; j < pubkeys.length; j++) { + const pk = asBuf(pubkeys[j]); + if (!pk.equals(first)) return pk; + } + return Buffer.alloc(33); +} + +/** + * @param {Array} pubkeys + * @param {Buffer} pk_ + * @param {Buffer} pk2 + * @returns {bigint} + * @private + */ +function keyAggCoeffInternal (pubkeys, pk_, pk2) { + const L = hashKeys(pubkeys); + const pk = asBuf(pk_); + if (pk.equals(asBuf(pk2))) return 1n; + return intFromBytes(taggedHash('KeyAgg coefficient', Buffer.concat([L, pk]))) % N; +} + +/** + * @param {Array} pubkeys + * @param {Buffer} pk_ + * @returns {bigint} + */ +function keyAggCoeff (pubkeys, pk_) { + return keyAggCoeffInternal(pubkeys, pk_, getSecondKey(pubkeys)); +} + +/** + * @param {Array} pubkeys + * @returns {{ Q: object, gacc: bigint, tacc: bigint }} + */ +function keyAgg (pubkeys) { + if (!Array.isArray(pubkeys) || !pubkeys.length) { + throw new TypeError('keyAgg requires a non-empty pubkey list'); + } + const keys = pubkeys.map((pk) => Buffer.from(asBuf(pk))); + const pk2 = getSecondKey(keys); + let Q = null; + for (let i = 0; i < keys.length; i++) { + let P; + try { + P = cpoint(keys[i]); + } catch (err) { + if (err instanceof ValueError) throw new InvalidContributionError(i, 'pubkey'); + throw err; + } + const a = keyAggCoeffInternal(keys, keys[i], pk2); + Q = pointAdd(Q, pointMul(P, a)); + } + if (isInfinite(Q)) { + throw new Error('keyAgg: aggregate is infinity'); + } + return { Q, gacc: 1n, tacc: 0n }; +} + +/** + * @param {{ Q: object, gacc: bigint, tacc: bigint }} keyaggCtx + * @returns {Buffer} 32-byte x-only + */ +function getXonlyPk (keyaggCtx) { + return xbytes(keyaggCtx.Q); +} + +/** + * @param {{ Q: object, gacc: bigint, tacc: bigint }} keyaggCtx + * @param {Buffer|string} tweak + * @param {boolean} isXonly + * @returns {{ Q: object, gacc: bigint, tacc: bigint }} + */ +function applyTweak (keyaggCtx, tweak, isXonly) { + const tBuf = asBuf(tweak); + if (tBuf.length !== 32) { + throw new ValueError('The tweak must be a 32-byte array.'); + } + const { Q, gacc, tacc } = keyaggCtx; + const g = (isXonly && !hasEvenY(Q)) ? (N - 1n) : 1n; + const t = intFromBytes(tBuf); + if (t >= N) throw new ValueError('The tweak must be less than n.'); + const Gt = t === 0n ? null : SecpPoint.BASE.multiply(t); + const Q_ = pointAdd(pointMul(Q, g), Gt); + if (isInfinite(Q_)) { + throw new ValueError('The result of tweaking cannot be infinity.'); + } + return { + Q: Q_, + gacc: (g * gacc) % N, + tacc: (t + g * tacc) % N + }; +} + +/** + * @param {Array} pubkeys + * @param {Array} tweaks + * @param {Array} isXonly + * @returns {{ Q: object, gacc: bigint, tacc: bigint }} + */ +function keyAggAndTweak (pubkeys, tweaks, isXonly) { + const tw = tweaks || []; + const xo = isXonly || []; + if (tw.length !== xo.length) { + throw new ValueError('The `tweaks` and `is_xonly` arrays must have the same length.'); + } + let ctx = keyAgg(pubkeys); + for (let i = 0; i < tw.length; i++) { + ctx = applyTweak(ctx, tw[i], !!xo[i]); + } + return ctx; +} + +/** + * @param {Buffer} rand + * @param {Buffer} pk + * @param {Buffer} aggpk + * @param {number} i + * @param {Buffer} msgPrefixed + * @param {Buffer} extraIn + * @returns {bigint} + * @private + */ +function nonceHash (rand, pk, aggpk, i, msgPrefixed, extraIn) { + const buf = Buffer.concat([ + rand, + Buffer.from([pk.length]), + pk, + Buffer.from([aggpk.length]), + aggpk, + msgPrefixed, + Buffer.from([ + (extraIn.length >>> 24) & 0xff, + (extraIn.length >>> 16) & 0xff, + (extraIn.length >>> 8) & 0xff, + extraIn.length & 0xff + ]), + extraIn, + Buffer.from([i]) + ]); + return intFromBytes(taggedHash('MuSig/nonce', buf)); +} + +/** + * @param {Buffer} rand_ + * @param {Buffer|null} sk + * @param {Buffer} pk + * @param {Buffer|null} aggpk + * @param {Buffer|null} msg + * @param {Buffer|null} extraIn + * @returns {{ secnonce: Buffer, pubnonce: Buffer }} + */ +function nonceGenInternal (rand_, sk, pk, aggpk, msg, extraIn) { + const pkBuf = asBuf(pk); + let rand = asBuf(rand_); + if (sk != null) { + rand = bytesXor(asBuf(sk), taggedHash('MuSig/aux', rand)); + } + const agg = aggpk == null ? Buffer.alloc(0) : asBuf(aggpk); + let msgPrefixed; + if (msg == null) { + msgPrefixed = Buffer.from([0x00]); + } else { + const m = asBuf(msg); + const len = Buffer.alloc(8); + len.writeBigUInt64BE(BigInt(m.length)); + msgPrefixed = Buffer.concat([Buffer.from([0x01]), len, m]); + } + const extra = extraIn == null ? Buffer.alloc(0) : asBuf(extraIn); + const k1 = nonceHash(rand, pkBuf, agg, 0, msgPrefixed, extra) % N; + const k2 = nonceHash(rand, pkBuf, agg, 1, msgPrefixed, extra) % N; + if (k1 === 0n || k2 === 0n) throw new Error('nonceGen: zero nonce scalar'); + const Rs1 = SecpPoint.BASE.multiply(k1); + const Rs2 = SecpPoint.BASE.multiply(k2); + return { + secnonce: Buffer.concat([bytesFromInt(k1), bytesFromInt(k2), pkBuf]), + pubnonce: Buffer.concat([cbytes(Rs1), cbytes(Rs2)]) + }; +} + +/** + * @param {Buffer|null} sk + * @param {Buffer} pk + * @param {Buffer|null} aggpk + * @param {Buffer|null} msg + * @param {Buffer|null} extraIn + * @returns {{ secnonce: Buffer, pubnonce: Buffer }} + */ +function nonceGen (sk, pk, aggpk, msg, extraIn) { + if (sk != null && asBuf(sk).length !== 32) { + throw new ValueError('The optional byte array sk must have length 32.'); + } + if (aggpk != null && asBuf(aggpk).length !== 32) { + throw new ValueError('The optional byte array aggpk must have length 32.'); + } + return nonceGenInternal(crypto.randomBytes(32), sk, pk, aggpk, msg, extraIn); +} + +/** + * @param {Array} pubnonces + * @returns {Buffer} 66-byte aggnonce + */ +function nonceAgg (pubnonces) { + if (!Array.isArray(pubnonces) || !pubnonces.length) { + throw new TypeError('nonceAgg requires pubnonces'); + } + const parts = []; + for (let j = 0; j < 2; j++) { + let Rj = null; + for (let i = 0; i < pubnonces.length; i++) { + const pn = asBuf(pubnonces[i]); + let Rij; + try { + Rij = cpoint(pn.subarray(j * 33, (j + 1) * 33)); + } catch (err) { + if (err instanceof ValueError) throw new InvalidContributionError(i, 'pubnonce'); + throw err; + } + Rj = pointAdd(Rj, Rij); + } + parts.push(cbytesExt(Rj)); + } + return Buffer.concat(parts); +} + +/** + * @param {Buffer} aggnonce + * @param {Array} pubkeys + * @param {Array} tweaks + * @param {Array} isXonly + * @param {Buffer} msg + * @returns {object} + */ +function sessionContext (aggnonce, pubkeys, tweaks, isXonly, msg) { + return { + aggnonce: asBuf(aggnonce), + pubkeys: pubkeys.map((pk) => Buffer.from(asBuf(pk))), + tweaks: (tweaks || []).map((t) => Buffer.from(asBuf(t))), + isXonly: (isXonly || []).map((v) => !!v), + msg: asBuf(msg) + }; +} + +/** + * @param {object} sessionCtx + * @returns {{ Q: object, gacc: bigint, tacc: bigint, b: bigint, R: object, e: bigint }} + */ +function getSessionValues (sessionCtx) { + const { aggnonce, pubkeys, tweaks, isXonly, msg } = sessionCtx; + const { Q, gacc, tacc } = keyAggAndTweak(pubkeys, tweaks, isXonly); + const b = intFromBytes(taggedHash( + 'MuSig/noncecoef', + Buffer.concat([aggnonce, xbytes(Q), msg]) + )) % N; + let R1; + let R2; + try { + R1 = cpointExt(aggnonce.subarray(0, 33)); + R2 = cpointExt(aggnonce.subarray(33, 66)); + } catch (err) { + if (err instanceof ValueError) throw new InvalidContributionError(null, 'aggnonce'); + throw err; + } + let R_ = pointAdd(R1, pointMul(R2, b)); + const R = isInfinite(R_) ? SecpPoint.BASE : R_; + const e = intFromBytes(taggedHash( + 'BIP0340/challenge', + Buffer.concat([xbytes(R), xbytes(Q), msg]) + )) % N; + return { Q, gacc, tacc, b, R, e }; +} + +/** + * @param {object} sessionCtx + * @param {*} P + * @returns {bigint} + * @private + */ +function getSessionKeyAggCoeff (sessionCtx, P) { + const pk = cbytes(P); + const found = sessionCtx.pubkeys.some((p) => asBuf(p).equals(pk)); + if (!found) { + throw new ValueError('The signer\'s pubkey must be included in the list of pubkeys.'); + } + return keyAggCoeff(sessionCtx.pubkeys, pk); +} + +/** + * Zero the secret nonce after use (BIP-327). Mutates `secnonce` in place. + * + * @param {Buffer} secnonce 97-byte (k1 || k2 || pk) + * @param {Buffer} sk + * @param {object} sessionCtx + * @returns {Buffer} 32-byte partial signature + */ +function sign (secnonce, sk, sessionCtx) { + const sn = asBuf(secnonce); + const k1_ = intFromBytes(sn.subarray(0, 32)); + const k2_ = intFromBytes(sn.subarray(32, 64)); + const pkPart = Buffer.from(sn.subarray(64, 97)); + // BIP-327: never reuse a secret nonce, including after a later session-ctx throw. + if (Buffer.isBuffer(secnonce)) secnonce.fill(0); + else sn.fill(0); + const { Q, gacc, b, R, e } = getSessionValues(sessionCtx); + if (!(k1_ > 0n && k1_ < N)) { + throw new ValueError('first secnonce value is out of range.'); + } + if (!(k2_ > 0n && k2_ < N)) { + throw new ValueError('second secnonce value is out of range.'); + } + const k1 = hasEvenY(R) ? k1_ : (N - k1_); + const k2 = hasEvenY(R) ? k2_ : (N - k2_); + const d_ = intFromBytes(asBuf(sk)); + if (!(d_ > 0n && d_ < N)) { + throw new ValueError('secret key value is out of range.'); + } + const P = SecpPoint.BASE.multiply(d_); + const pk = cbytes(P); + if (!pk.equals(pkPart)) { + throw new ValueError('Public key does not match nonce_gen argument'); + } + const a = getSessionKeyAggCoeff(sessionCtx, P); + const g = hasEvenY(Q) ? 1n : (N - 1n); + const d = (g * gacc * d_) % N; + const s = (k1 + b * k2 + e * a * d) % N; + const psig = bytesFromInt(s); + const Rs1 = SecpPoint.BASE.multiply(k1_); + const Rs2 = SecpPoint.BASE.multiply(k2_); + const pubnonce = Buffer.concat([cbytes(Rs1), cbytes(Rs2)]); + if (!partialSigVerifyInternal(psig, pubnonce, pk, sessionCtx)) { + throw new Error('sign: partial signature failed self-check'); + } + return psig; +} + +/** + * @param {Buffer} psig + * @param {Buffer} pubnonce + * @param {Buffer} pk + * @param {object} sessionCtx + * @returns {boolean} + */ +function partialSigVerifyInternal (psig, pubnonce, pk, sessionCtx) { + const { Q, gacc, b, R, e } = getSessionValues(sessionCtx); + const s = intFromBytes(asBuf(psig)); + if (!(s > 0n && s < N)) return false; + const pn = asBuf(pubnonce); + const Rs1 = cpoint(pn.subarray(0, 33)); + const Rs2 = cpoint(pn.subarray(33, 66)); + const ReS_ = pointAdd(Rs1, pointMul(Rs2, b)); + const ReS = hasEvenY(R) ? ReS_ : pointNegate(ReS_); + const P = cpoint(pk); + const a = getSessionKeyAggCoeff(sessionCtx, P); + const g = hasEvenY(Q) ? 1n : (N - 1n); + const g_ = (g * gacc) % N; + const lhs = SecpPoint.BASE.multiply(s); + const rhs = pointAdd(ReS, pointMul(P, (e * a * g_) % N)); + if (isInfinite(rhs)) return false; + return lhs.equals(rhs); +} + +/** + * @param {Buffer} psig + * @param {Array} pubnonces + * @param {Array} pubkeys + * @param {Array} tweaks + * @param {Array} isXonly + * @param {Buffer} msg + * @param {number} i + * @returns {boolean} + */ +function partialSigVerify (psig, pubnonces, pubkeys, tweaks, isXonly, msg, i) { + if (pubnonces.length !== pubkeys.length) { + throw new ValueError('The `pubnonces` and `pubkeys` arrays must have the same length.'); + } + const aggnonce = nonceAgg(pubnonces); + const sessionCtx = sessionContext(aggnonce, pubkeys, tweaks, isXonly, msg); + return partialSigVerifyInternal(psig, pubnonces[i], pubkeys[i], sessionCtx); +} + +/** + * @param {Array} psigs + * @param {object} sessionCtx + * @returns {Buffer} 64-byte BIP-340 signature + */ +function partialSigAgg (psigs, sessionCtx) { + const { Q, tacc, R, e } = getSessionValues(sessionCtx); + let s = 0n; + for (let i = 0; i < psigs.length; i++) { + const si = intFromBytes(asBuf(psigs[i])); + if (si >= N) throw new InvalidContributionError(i, 'psig'); + s = (s + si) % N; + } + const g = hasEvenY(Q) ? 1n : (N - 1n); + s = (s + e * g * tacc) % N; + return Buffer.concat([xbytes(R), bytesFromInt(s)]); +} + +/** + * @param {Buffer} sk_ + * @param {Buffer} aggothernonce + * @param {Buffer} aggpk + * @param {Buffer} msg + * @param {number} i + * @returns {bigint} + * @private + */ +function detNonceHash (sk_, aggothernonce, aggpk, msg, i) { + const len = Buffer.alloc(8); + len.writeBigUInt64BE(BigInt(msg.length)); + const buf = Buffer.concat([ + sk_, + aggothernonce, + aggpk, + len, + msg, + Buffer.from([i]) + ]); + return intFromBytes(taggedHash('MuSig/deterministic/nonce', buf)); +} + +/** + * @param {Buffer} sk + * @param {Buffer} aggothernonce + * @param {Array} pubkeys + * @param {Array} tweaks + * @param {Array} isXonly + * @param {Buffer} msg + * @param {Buffer|null} rand + * @returns {{ pubnonce: Buffer, psig: Buffer }} + */ +function deterministicSign (sk, aggothernonce, pubkeys, tweaks, isXonly, msg, rand) { + const skBuf = asBuf(sk); + const sk_ = rand != null + ? bytesXor(skBuf, taggedHash('MuSig/aux', asBuf(rand))) + : skBuf; + const aggpk = getXonlyPk(keyAggAndTweak(pubkeys, tweaks, isXonly)); + const m = asBuf(msg); + const other = asBuf(aggothernonce); + const k1 = detNonceHash(sk_, other, aggpk, m, 0) % N; + const k2 = detNonceHash(sk_, other, aggpk, m, 1) % N; + if (k1 === 0n || k2 === 0n) throw new Error('deterministicSign: zero nonce'); + const Rs1 = SecpPoint.BASE.multiply(k1); + const Rs2 = SecpPoint.BASE.multiply(k2); + const pubnonce = Buffer.concat([cbytes(Rs1), cbytes(Rs2)]); + const secnonce = Buffer.concat([bytesFromInt(k1), bytesFromInt(k2), individualPk(skBuf)]); + let aggnonce; + try { + aggnonce = nonceAgg([pubnonce, other]); + } catch (err) { + if (err instanceof InvalidContributionError) { + throw new InvalidContributionError(null, 'aggothernonce'); + } + throw err; + } + const sessionCtx = sessionContext(aggnonce, pubkeys, tweaks, isXonly, m); + const psig = sign(secnonce, skBuf, sessionCtx); + return { pubnonce, psig }; +} + +/** + * Convenience: x-only MuSig2 aggregate of compressed pubkeys (no tweaks). + * + * @param {Array} pubkeys + * @returns {Buffer} 32-byte x-only + */ +function aggregateXonly (pubkeys, opts = {}) { + const keys = opts.sort === false ? pubkeys : keySort(pubkeys); + return getXonlyPk(keyAgg(keys)); +} + +/** + * BIP-340 verify of an aggregated signature against keys we compute (never + * trust a caller-supplied aggregate). `message` is hashed with SHA-256 unless + * it is already 32 bytes. + * + * @param {Buffer|string} message + * @param {Buffer|string} signature 64-byte Schnorr + * @param {Array} pubkeys compressed participants + * @param {Buffer|string} [claimedAggregate] compressed or x-only; must match + * @returns {boolean} + */ +function verifyAggregatedSchnorr (message, signature, pubkeys, claimedAggregate) { + if (!Array.isArray(pubkeys) || pubkeys.length < 1) return false; + const xonly = aggregateXonly(pubkeys); + if (claimedAggregate != null) { + const claimed = asBuf(claimedAggregate); + let claimedX; + if (claimed.length === 32) claimedX = claimed; + else if (claimed.length === 33) claimedX = claimed.subarray(1, 33); + else return false; + if (!claimedX.equals(xonly)) return false; + } + let msg = asBuf(message); + if (msg.length !== 32) { + msg = crypto.createHash('sha256').update(msg).digest(); + } + const sig = asBuf(signature); + if (sig.length !== 64) return false; + try { + return nobleSchnorr.verify(sig, msg, xonly); + } catch { + return false; + } +} + +module.exports = { + InvalidContributionError, + ValueError, + musig2IsNOfN, + individualPk, + keySort, + keyAgg, + getXonlyPk, + applyTweak, + keyAggAndTweak, + keyAggCoeff, + nonceGen, + nonceGenInternal, + nonceAgg, + sessionContext, + getSessionValues, + sign, + partialSigVerify, + partialSigVerifyInternal, + partialSigAgg, + deterministicSign, + aggregateXonly, + verifyAggregatedSchnorr +}; diff --git a/functions/musig2Session.js b/functions/musig2Session.js new file mode 100644 index 000000000..0a786ece3 --- /dev/null +++ b/functions/musig2Session.js @@ -0,0 +1,570 @@ +/** + * Interactive BIP-327 MuSig2 session machine for `P2P_MUSIG_*` opcodes. + * + * Directed n-of-n only. Callers recompute KeyAgg / aggnonce locally and bind + * each contribution to the AMP signer. Secret nonces never leave this module + * except on the session object held by {@link Peer}; {@link module:functions/musig2.sign} + * zeros `secnonce` after use. + * + * @fileoverview BIP-327 MuSig2 P2P session state machine. + * @module functions/musig2Session + */ +'use strict'; + +const crypto = require('crypto'); +const { + individualPk, + keySort, + keyAgg, + getXonlyPk, + nonceGen, + nonceAgg, + sessionContext, + sign, + partialSigVerify, + partialSigAgg, + verifyAggregatedSchnorr +} = require('./musig2'); + +const MUSIG_MAX_SIGNERS = 16; +const MUSIG_MAX_MSG_BYTES = 4096; +const MUSIG_PUBNONCE_BYTES = 66; +const MUSIG_PSIG_BYTES = 32; +const MUSIG_SIG_BYTES = 64; +const MUSIG_PK_BYTES = 33; + +/** + * @param {Buffer|Uint8Array|string} input + * @returns {Buffer} + * @private + */ +function asBuf (input) { + if (Buffer.isBuffer(input)) return input; + if (input instanceof Uint8Array) return Buffer.from(input); + if (typeof input === 'string') { + const hex = String(input).trim().replace(/^0x/i, ''); + if (hex.length % 2) throw new TypeError('odd hex length'); + if (!/^[0-9a-f]*$/i.test(hex)) throw new TypeError('expected hex'); + return Buffer.from(hex, 'hex'); + } + throw new TypeError('expected Buffer, Uint8Array, or hex'); +} + +/** + * @param {*} input + * @param {number} len + * @returns {Buffer|null} + * @private + */ +function exactBuf (input, len) { + try { + const b = asBuf(input); + return b.length === len ? b : null; + } catch { + return null; + } +} + +/** + * Application message → 32-byte MuSig2 challenge (SHA-256 unless already 32). + * + * @param {Buffer|string} msg + * @returns {Buffer} + */ +function challengeMessage (msg) { + let raw; + if (Buffer.isBuffer(msg) || msg instanceof Uint8Array) { + raw = Buffer.from(msg); + } else if (typeof msg === 'string') { + const trimmed = msg.trim().replace(/^0x/i, ''); + if (/^[0-9a-f]*$/i.test(trimmed) && trimmed.length % 2 === 0 && trimmed.length > 0) { + raw = Buffer.from(trimmed, 'hex'); + } else { + raw = Buffer.from(msg, 'utf8'); + } + } else { + throw new TypeError('msg required'); + } + if (raw.length === 32) return Buffer.from(raw); + return crypto.createHash('sha256').update(raw).digest(); +} + +/** + * @param {Buffer|string} pk compressed or x-only + * @returns {string} 64-char x-only hex + */ +function xOnlyHex (pk) { + const b = asBuf(pk); + if (b.length === 32) return b.toString('hex'); + if (b.length === 33) return b.subarray(1, 33).toString('hex'); + throw new TypeError('pubkey must be 32 or 33 bytes'); +} + +/** + * @param {Array} pubkeys + * @returns {Buffer} concatenated compressed keys + */ +function packPubkeys (pubkeys) { + const keys = normalizePubkeys(pubkeys); + return Buffer.concat(keys); +} + +/** + * @param {Buffer|string} packed + * @returns {Buffer[]} + */ +function unpackPubkeys (packed) { + const buf = asBuf(packed); + if (!buf.length || buf.length % MUSIG_PK_BYTES !== 0) { + throw new TypeError('packed pubkeys must be n×33 bytes'); + } + const out = []; + for (let i = 0; i < buf.length; i += MUSIG_PK_BYTES) { + out.push(Buffer.from(buf.subarray(i, i + MUSIG_PK_BYTES))); + } + return out; +} + +/** + * @param {Array|Buffer|string} pubkeys + * @returns {Buffer[]} + * @private + */ +function normalizePubkeys (pubkeys) { + let list; + if (Buffer.isBuffer(pubkeys) || typeof pubkeys === 'string' || pubkeys instanceof Uint8Array) { + list = unpackPubkeys(pubkeys); + } else if (Array.isArray(pubkeys)) { + list = pubkeys.map((pk) => { + const b = asBuf(pk); + if (b.length !== MUSIG_PK_BYTES) throw new TypeError('each pubkey must be 33 bytes'); + return Buffer.from(b); + }); + } else { + throw new TypeError('pubkeys required'); + } + if (list.length < 2 || list.length > MUSIG_MAX_SIGNERS) { + throw new TypeError(`MuSig2 needs 2–${MUSIG_MAX_SIGNERS} signers`); + } + return keySort(list); +} + +/** + * @param {Array} pubkeys + * @param {string} signerXOnly + * @returns {number} + */ +function signerIndex (pubkeys, signerXOnly) { + const want = String(signerXOnly || '').toLowerCase(); + for (let i = 0; i < pubkeys.length; i++) { + if (xOnlyHex(pubkeys[i]) === want) return i; + } + return -1; +} + +/** + * @param {object} body START/ACCEPT/… JSON or field map + * @returns {object|null} + */ +function parseMusigBody (body) { + if (!body || typeof body !== 'object' || Array.isArray(body)) return null; + const sessionId = exactBuf(body.sessionId, 32); + if (!sessionId) return null; + const out = { sessionId }; + if (body.msg != null) { + try { + if (Buffer.isBuffer(body.msg) || body.msg instanceof Uint8Array) { + out.msg = Buffer.from(body.msg); + } else if (typeof body.msg === 'string') { + const trimmed = body.msg.trim().replace(/^0x/i, ''); + if (/^[0-9a-f]*$/i.test(trimmed) && trimmed.length % 2 === 0 && trimmed.length >= 2) { + out.msg = Buffer.from(trimmed, 'hex'); + } else { + out.msg = Buffer.from(body.msg, 'utf8'); + } + } + } catch { + return null; + } + } + if (body.pubkeys != null) { + try { + out.pubkeys = Array.isArray(body.pubkeys) + ? normalizePubkeys(body.pubkeys) + : unpackPubkeys(body.pubkeys); + } catch { + return null; + } + } + if (body.pubnonce != null) { + out.pubnonce = exactBuf(body.pubnonce, MUSIG_PUBNONCE_BYTES); + if (!out.pubnonce) return null; + } + if (body.aggnonce != null) { + out.aggnonce = exactBuf(body.aggnonce, MUSIG_PUBNONCE_BYTES); + if (!out.aggnonce) return null; + } + if (body.psig != null) { + out.psig = exactBuf(body.psig, MUSIG_PSIG_BYTES); + if (!out.psig) return null; + } + if (body.signature != null) { + out.signature = exactBuf(body.signature, MUSIG_SIG_BYTES); + if (!out.signature) return null; + } + if (body.purpose != null) out.purpose = String(body.purpose); + return out; +} + +/** + * @param {Buffer} sk + * @returns {Buffer} + * @private + */ +function localPk (sk) { + return individualPk(sk); +} + +/** + * @param {object} opts + * @param {Buffer|string} [opts.sessionId] + * @param {Buffer|string} opts.msg + * @param {Array|Buffer} opts.pubkeys + * @param {Buffer} opts.sk + * @param {string} [opts.purpose] + * @returns {object} + */ +function createLocalSession (opts = {}) { + const sk = asBuf(opts.sk); + if (sk.length !== 32) throw new TypeError('sk must be 32 bytes'); + const pubkeys = normalizePubkeys(opts.pubkeys); + const pk = localPk(sk); + const localIndex = signerIndex(pubkeys, xOnlyHex(pk)); + if (localIndex < 0) throw new Error('local key is not a session participant'); + let rawMsg; + if (Buffer.isBuffer(opts.msg) || opts.msg instanceof Uint8Array) { + rawMsg = Buffer.from(opts.msg); + } else if (typeof opts.msg === 'string') { + rawMsg = Buffer.from(opts.msg, 'utf8'); + } else { + throw new TypeError('msg required'); + } + if (rawMsg.length > MUSIG_MAX_MSG_BYTES) throw new TypeError('msg too large'); + const sessionId = opts.sessionId ? exactBuf(opts.sessionId, 32) : crypto.randomBytes(32); + if (!sessionId) throw new TypeError('sessionId must be 32 bytes'); + const challenge = challengeMessage(rawMsg); + const aggpk = getXonlyPk(keyAgg(pubkeys)); + const { secnonce, pubnonce } = nonceGen(sk, pk, aggpk, challenge, sessionId); + const n = pubkeys.length; + const session = { + sessionId, + rawMsg, + challenge, + purpose: opts.purpose != null ? String(opts.purpose) : '', + pubkeys, + localIndex, + initiator: opts.initiator !== false, + initiatorIndex: opts.initiator !== false + ? localIndex + : (Number.isInteger(opts.initiatorIndex) ? opts.initiatorIndex : -1), + pubnonces: new Array(n).fill(null), + psigs: new Array(n).fill(null), + secnonce, + ownPubnonce: Buffer.from(pubnonce), + aggnonce: null, + signature: null, + createdAt: Date.now() + }; + session.pubnonces[localIndex] = Buffer.from(pubnonce); + return session; +} + +/** + * Co-signer intake of `P2P_MUSIG_START`. + * + * @param {object} body parsed START fields + * @param {object} opts + * @param {Buffer} opts.sk + * @param {string} opts.signerXOnly AMP author of START + * @returns {{ ok: true, session: object }|{ ok: false, reason: string }} + */ +function createFromStart (body, opts = {}) { + const parsed = parseMusigBody(body); + if (!parsed || !parsed.sessionId || !parsed.msg || !parsed.pubkeys || !parsed.pubnonce) { + return { ok: false, reason: 'invalid-start' }; + } + if (parsed.msg.length > MUSIG_MAX_MSG_BYTES) return { ok: false, reason: 'msg-too-large' }; + let pubkeys; + try { + pubkeys = normalizePubkeys(parsed.pubkeys); + } catch (err) { + return { ok: false, reason: err.message || 'bad-pubkeys' }; + } + const signerXOnly = String(opts.signerXOnly || '').toLowerCase(); + const authorIndex = signerIndex(pubkeys, signerXOnly); + if (authorIndex < 0) return { ok: false, reason: 'signer-not-participant' }; + let sk; + try { + sk = asBuf(opts.sk); + } catch { + return { ok: false, reason: 'missing-sk' }; + } + if (sk.length !== 32) return { ok: false, reason: 'missing-sk' }; + const localIndex = signerIndex(pubkeys, xOnlyHex(localPk(sk))); + if (localIndex < 0) return { ok: false, reason: 'not-participant' }; + if (localIndex === authorIndex) return { ok: false, reason: 'self-start' }; + let session; + try { + session = createLocalSession({ + sessionId: parsed.sessionId, + msg: parsed.msg, + pubkeys, + sk, + purpose: parsed.purpose || '', + initiator: false + }); + } catch (err) { + return { ok: false, reason: err.message || 'start-failed' }; + } + session.rawMsg = Buffer.from(parsed.msg); + session.challenge = challengeMessage(parsed.msg); + session.initiatorIndex = authorIndex; + const rec = recordPubnonce(session, signerXOnly, parsed.pubnonce); + if (!rec.ok) { + wipeSession(session); + return rec; + } + return { ok: true, session }; +} + +/** + * @param {object} session + * @param {string} signerXOnly + * @param {Buffer|string} pubnonce + * @returns {{ ok: boolean, reason?: string }} + */ +function recordPubnonce (session, signerXOnly, pubnonce) { + if (!session) return { ok: false, reason: 'no-session' }; + const i = signerIndex(session.pubkeys, signerXOnly); + if (i < 0) return { ok: false, reason: 'signer-not-participant' }; + const pn = exactBuf(pubnonce, MUSIG_PUBNONCE_BYTES); + if (!pn) return { ok: false, reason: 'bad-pubnonce' }; + const prev = session.pubnonces[i]; + if (prev && !prev.equals(pn)) return { ok: false, reason: 'pubnonce-mismatch' }; + session.pubnonces[i] = Buffer.from(pn); + return { ok: true }; +} + +/** + * @param {object} session + * @returns {boolean} + */ +function allPubnoncesPresent (session) { + return !!(session && session.pubnonces && session.pubnonces.every((p) => Buffer.isBuffer(p))); +} + +/** + * @param {object} session + * @returns {Buffer|null} + */ +function computeAggNonce (session) { + if (!allPubnoncesPresent(session)) return null; + return nonceAgg(session.pubnonces); +} + +/** + * Recompute aggnonce; reject a claimed value that does not match. + * + * @param {object} session + * @param {Buffer|string} claimed + * @returns {{ ok: boolean, reason?: string, aggnonce?: Buffer }} + */ +function setAggNonce (session, claimed) { + const local = computeAggNonce(session); + if (!local) return { ok: false, reason: 'incomplete-nonces' }; + const remote = exactBuf(claimed, MUSIG_PUBNONCE_BYTES); + if (!remote) return { ok: false, reason: 'bad-aggnonce' }; + if (!local.equals(remote)) return { ok: false, reason: 'aggnonce-mismatch' }; + session.aggnonce = Buffer.from(local); + return { ok: true, aggnonce: session.aggnonce }; +} + +/** + * @param {object} session + * @returns {object} + * @private + */ +function sessionCtxFor (session) { + return sessionContext(session.aggnonce, session.pubkeys, [], [], session.challenge); +} + +/** + * Produce a partial signature. Zeros `session.secnonce`. + * + * @param {object} session + * @param {Buffer} sk + * @returns {{ ok: true, psig: Buffer }|{ ok: false, reason: string }} + */ +function createPartial (session, sk) { + if (!session || !session.aggnonce) return { ok: false, reason: 'no-aggnonce' }; + if (!session.secnonce || !Buffer.isBuffer(session.secnonce)) { + return { ok: false, reason: 'no-secnonce' }; + } + try { + const psig = sign(session.secnonce, asBuf(sk), sessionCtxFor(session)); + session.psigs[session.localIndex] = Buffer.from(psig); + wipeSession(session); + return { ok: true, psig }; + } catch (err) { + wipeSession(session); + return { ok: false, reason: err.message || 'sign-failed' }; + } +} + +/** + * @param {object} session + * @param {string} signerXOnly + * @param {Buffer|string} psig + * @returns {{ ok: boolean, reason?: string }} + */ +function recordPartial (session, signerXOnly, psig) { + if (!session || !session.aggnonce) return { ok: false, reason: 'no-aggnonce' }; + const i = signerIndex(session.pubkeys, signerXOnly); + if (i < 0) return { ok: false, reason: 'signer-not-participant' }; + const s = exactBuf(psig, MUSIG_PSIG_BYTES); + if (!s) return { ok: false, reason: 'bad-psig' }; + const prev = session.psigs[i]; + if (prev && !prev.equals(s)) return { ok: false, reason: 'psig-mismatch' }; + if (!session.pubnonces[i]) return { ok: false, reason: 'missing-pubnonce' }; + let ok = false; + try { + ok = partialSigVerify( + s, session.pubnonces, session.pubkeys, [], [], session.challenge, i); + } catch { + ok = false; + } + if (!ok) return { ok: false, reason: 'psig-invalid' }; + session.psigs[i] = Buffer.from(s); + return { ok: true }; +} + +/** + * @param {object} session + * @returns {boolean} + */ +function allPartialsPresent (session) { + return !!(session && session.psigs && session.psigs.every((p) => Buffer.isBuffer(p))); +} + +/** + * @param {object} session + * @returns {{ ok: true, signature: Buffer }|{ ok: false, reason: string }} + */ +function aggregatePartials (session) { + if (!allPartialsPresent(session)) return { ok: false, reason: 'incomplete-psigs' }; + try { + const signature = partialSigAgg(session.psigs, sessionCtxFor(session)); + if (!verifyAggregatedSchnorr(session.challenge, signature, session.pubkeys)) { + wipeSession(session); + return { ok: false, reason: 'agg-verify-failed' }; + } + session.signature = Buffer.from(signature); + wipeSession(session); + return { ok: true, signature: session.signature }; + } catch (err) { + wipeSession(session); + return { ok: false, reason: err.message || 'agg-failed' }; + } +} + +/** + * @param {object} session + * @param {Buffer|string} signature + * @returns {{ ok: boolean, reason?: string }} + */ +function verifyFinalSignature (session, signature) { + if (!session || !session.pubkeys || !session.challenge) { + return { ok: false, reason: 'no-session' }; + } + const sig = exactBuf(signature, MUSIG_SIG_BYTES); + if (!sig) return { ok: false, reason: 'bad-signature' }; + if (!verifyAggregatedSchnorr(session.challenge, sig, session.pubkeys)) { + return { ok: false, reason: 'sig-invalid' }; + } + session.signature = Buffer.from(sig); + wipeSession(session); + return { ok: true }; +} + +/** + * Zero secret nonce material. Safe to call more than once. + * + * @param {object} session + */ +function wipeSession (session) { + if (!session) return; + if (Buffer.isBuffer(session.secnonce)) session.secnonce.fill(0); + session.secnonce = null; +} + +/** + * Public view (no secnonce). + * + * @param {object} session + * @returns {object|null} + */ +function sessionPublicView (session) { + if (!session) return null; + return { + sessionId: Buffer.from(session.sessionId).toString('hex'), + purpose: session.purpose || '', + n: session.pubkeys.length, + initiator: !!session.initiator, + pubnonceCount: session.pubnonces.filter(Boolean).length, + psigCount: session.psigs.filter(Boolean).length, + hasAggNonce: Buffer.isBuffer(session.aggnonce), + signature: session.signature ? Buffer.from(session.signature).toString('hex') : null + }; +} + +/** + * START fields for {@link Message.fromFields}. + * + * @param {object} session + * @returns {object} + */ +function startFields (session) { + return { + sessionId: session.sessionId, + msg: session.rawMsg, + pubkeys: packPubkeys(session.pubkeys), + pubnonce: session.ownPubnonce, + purpose: session.purpose || '' + }; +} + +module.exports = { + MUSIG_MAX_SIGNERS, + MUSIG_MAX_MSG_BYTES, + MUSIG_PUBNONCE_BYTES, + packPubkeys, + unpackPubkeys, + challengeMessage, + xOnlyHex, + signerIndex, + parseMusigBody, + createLocalSession, + createFromStart, + recordPubnonce, + allPubnoncesPresent, + computeAggNonce, + setAggNonce, + createPartial, + recordPartial, + allPartialsPresent, + aggregatePartials, + verifyFinalSignature, + wipeSession, + sessionPublicView, + startFields +}; diff --git a/functions/opcodeAllowList.js b/functions/opcodeAllowList.js index 092c449f2..251774012 100644 --- a/functions/opcodeAllowList.js +++ b/functions/opcodeAllowList.js @@ -5,7 +5,7 @@ * * @module functions/opcodeAllowList * @see docs/PROGRAM.md - * @see docs/ARC.md + * @see docs/CONTRACTS.md */ /** diff --git a/functions/sealedBlob.d.ts b/functions/sealedBlob.d.ts deleted file mode 100644 index 8dac90872..000000000 --- a/functions/sealedBlob.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -declare const sealedBlob: { - SEAL_SCHEME: string; - sealJson: (...args: unknown[]) => unknown; - openSealedJson: (...args: unknown[]) => unknown; - isPasswordProtectedDocument: (...args: unknown[]) => boolean; - [key: string]: unknown; -}; - -export = sealedBlob; diff --git a/package-lock.json b/package-lock.json index 1e8d3d51b..0dce2cbce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -570,9 +570,9 @@ "license": "MIT" }, "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2463,9 +2463,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", - "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz", + "integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index d9cfb9479..2242b9b45 100644 --- a/package.json +++ b/package.json @@ -64,10 +64,13 @@ "build": "npm run make:all", "chat": "node scripts/fabric.js shell", "ci": "npm run smoke && npm run lint:types && npm run lint:pkg && npm test && npm run test:benchmark", - "smoke": "node -e \"require('./types/fabric'); require('./functions/beaconFederationSigning'); require('./functions/beaconNetworkGuard'); require('./functions/fabricProgramManifest'); require('./types/program'); require('./types/machine'); require('./functions/sidechainState'); require('./functions/documentRegistrySidechain'); require('./types/beacon'); require('./types/chain'); require('./types/message'); require('./functions/messageBodyCodec'); require('./types/peer'); require('./types/hash256'); require('./functions/bytes'); require('./functions/fabricCanonicalJson'); require('./functions/wireJson'); require('./functions/fabricNativeAccel'); require('./functions/lightningBolt12'); require('./functions/bolt12Semantics'); require('./functions/fabricPaymentBech32'); require('./functions/publishedDocumentEnvelope'); require('./functions/fabricOnion'); require('./functions/onionChatSeal'); require('./functions/applicationNamespaces'); require('./functions/contractStatechains'); require('./functions/fabricHallmark'); require('./functions/contractSidechainLocal'); require('./functions/contractSpend'); require('./functions/contractProgramBind'); require('./functions/opcodeAllowList'); require('./functions/cliDocumentExchange'); require('./functions/fabricSetup'); require('./functions/sealedBlob'); require('./functions/identityLock'); require('./functions/cliPasswordArgv'); require('./functions/inventoryHtlc'); require('./functions/groupChangeGovernance'); require('./functions/contractMessageAccumulate'); require('./functions/blindedExecutionCircuit'); require('./functions/loadLocalSettings').loadLocalSettings(); console.log('smoke ok');\"", - "lint:pkg": "eslint scripts/fabric.js scripts/cli.js scripts/gen-quality-reports.js scripts/check-book-links.js scripts/coverage-baseline.js scripts/clean-jsdoc-html.js scripts/classtree.js scripts/report-noble-hashes.js scripts/fix-noble-hashes-compat.js scripts/install-native.js scripts/benchmark.js functions/bytes.js functions/fabricCanonicalJson.js functions/wireJson.js functions/fabricNativeAccel.js functions/base58.js functions/bech32.js functions/bolt12Semantics.js functions/fabricPaymentBech32.js functions/publishedDocumentEnvelope.js functions/lightningBolt12.js functions/sipa/bech32.js functions/sipa/segwit_addr.js functions/bip39.js functions/bip32.js functions/fabricSetup.js functions/sealedBlob.js functions/identityLock.js functions/cliPasswordArgv.js", + "smoke": "node -e \"require('./types/fabric'); require('./functions/beaconFederationSigning'); require('./functions/beaconNetworkGuard'); require('./functions/fabricProgramManifest'); require('./types/program'); require('./types/machine'); require('./functions/sidechainState'); require('./functions/documentRegistrySidechain'); require('./types/beacon'); require('./types/chain'); require('./types/message'); require('./functions/messageBodyCodec'); require('./types/peer'); require('./types/hash256'); require('./functions/bytes'); require('./functions/fabricCanonicalJson'); require('./functions/wireJson'); require('./functions/fabricNativeAccel'); require('./functions/lightningBolt12'); require('./functions/bolt12Semantics'); require('./functions/fabricPaymentBech32'); require('./functions/publishedDocumentEnvelope'); require('./functions/fabricOnion'); require('./functions/onionChatSeal'); require('./functions/applicationNamespaces'); require('./functions/contractStatechains'); require('./functions/fabricHallmark'); require('./functions/contractSidechainLocal'); require('./functions/contractSpend'); require('./functions/contractProgramBind'); require('./functions/opcodeAllowList'); require('./functions/cliDocumentExchange'); require('./functions/fabricSetup'); require('./functions/sealedBlob'); require('./functions/identityLock'); require('./functions/identityCrossSign'); require('./functions/fabricIdentitySchnorr'); require('./functions/identityCrossSignVerify'); require('./functions/fabricChatText'); require('./functions/fabricChatKind'); require('./functions/cliPasswordArgv'); require('./functions/fabricKeyMaterial'); require('./functions/fabricHomeEnv'); require('./functions/fabricWalletIdentity'); require('./functions/inventoryHtlc'); require('./functions/bip21'); require('./functions/bip67'); require('./functions/bip69'); require('./functions/bip85'); require('./functions/bip125'); require('./functions/musig2'); require('./functions/musig2Session'); require('./functions/groupChangeGovernance'); require('./functions/contractMessageAccumulate'); require('./functions/fabricMessageCollection'); require('./functions/blindedExecutionCircuit'); require('./functions/loadLocalSettings').loadLocalSettings(); console.log('smoke ok');\"", + "lint:pkg": "eslint scripts/fabric.js scripts/cli.js scripts/gen-quality-reports.js scripts/check-book-links.js scripts/coverage-baseline.js scripts/clean-jsdoc-html.js scripts/classtree.js scripts/report-noble-hashes.js scripts/fix-noble-hashes-compat.js scripts/install-native.js scripts/benchmark.js functions/fabricMessageCollection.js scripts/report-credits.js functions/bytes.js functions/fabricCanonicalJson.js functions/wireJson.js functions/fabricNativeAccel.js functions/base58.js functions/bech32.js functions/bolt12Semantics.js functions/fabricPaymentBech32.js functions/publishedDocumentEnvelope.js functions/lightningBolt12.js functions/sipa/bech32.js functions/sipa/segwit_addr.js functions/bip39.js functions/bip32.js functions/bip21.js functions/bip67.js functions/bip69.js functions/bip85.js functions/bip125.js functions/musig2.js functions/musig2Session.js functions/fabricSetup.js functions/sealedBlob.js functions/identityLock.js functions/identityCrossSign.js functions/fabricIdentitySchnorr.js functions/identityCrossSignVerify.js functions/fabricChatText.js functions/fabricChatKind.js functions/cliPasswordArgv.js functions/fabricKeyMaterial.js functions/fabricHomeEnv.js functions/fabricWalletIdentity.js", + "messages": "node functions/fabricMessageCollection.js", + "docs:message-types": "node types/message.js", "exchange": "node scripts/fabric.js exchange", "fabric:start": "node scripts/fabric.js start", + "home-env": "node functions/fabricHomeEnv.js", "clean:all": "rm -rf node_modules package-lock.json", "clean": "rm -rf node_modules", "cli": "npm run chat", @@ -84,25 +87,30 @@ "lint:types:fix": "eslint types/**/*.js --fix", "lint": "eslint .", "local": "npm run build && node scripts/static-server.js ./assets -p 8000", - "make:all": "npm run make:service && npm run make:app && npm run make:lib && npm run make:dev # && npm run make:binary", - "make:api": "NODE_OPTIONS=\"--require ./scripts/bigint-json.js\" jsdoc2md --configure jsdoc.json --files $(node scripts/list-jsdoc-type-files.js) schemata/*.js services/*.js settings/deprecations.js README.md > API.md", + "make:all": "npm run make:dev", + "make:api": "NODE_OPTIONS=\"--require ./scripts/bigint-json.js\" jsdoc2md --configure jsdoc.json --files $(node scripts/list-jsdoc-type-files.js) schemata/*.js services/*.js settings/deprecations.js README.md > API.md.tmp && mv API.md.tmp API.md", "make:app": "### ERROR: Not yet implemented. Debug: `examples/app.js` to `assets/app.js`", "make:binary": "pkg -c package.json -o assets/binaries/fabric scripts/fabric.js", "make:book": "mkdir -p _book && npx --yes honkit@6.2.2 build", "make:coverage": "npm run coverage && c8 report --reporter html", "make:dev": "npm run make:examples && npm run make:docs && npm run make:book && cp -rf docs _book/ && cp -rf examples _book/ && js-beautify --indent-size 2 -r _book/**.html", "make:docs": "npm run make:api && node scripts/clean-jsdoc-html.js && node_modules/.bin/jsdoc -c jsdoc.json services/**.js $(node scripts/list-jsdoc-type-files.js --recursive) settings/deprecations.js DEVELOPERS.md -d docs/ && js-beautify --indent-size 2 -r docs/**.html", - "make:examples": "docco --output assets examples/*.js && ./node_modules/.bin/js-beautify --indent-size 2 -r assets/examples/*.html", + "make:examples": "docco --output assets examples/*.js && ./node_modules/.bin/js-beautify --indent-size 2 -r assets/examples/*.html && cp -f assets/examples/*.html examples/", "make:lib": "### ERROR: Not yet implemented. Debug: `scripts/fabric.js` to `assets/fabric.min.js`", "make:nix": "node2nix --lock package-lock.json --nodejs-16 --node-env .nix/node-env.nix --output .nix/node-packages.nix", "make:service": "### ERROR: Not yet implemented. Debug: ` examples/service.js` to `assets/service.js`", "make:viewer": "### ERROR: Not yet implemented. Debug: `components/circuit-viewer.js` to `assets/viewer.min.js`", "playnet": "node scripts/playnet.js", - "test:playnet-publish": "NODE_ENV=test mocha --require ./tests/mocha-env.js tests/playnet.contract.publish.integration.js --exit", - "test:playnet-publish-live": "FABRIC_PLAYNET_PUBLISH=1 NODE_ENV=test mocha --require ./tests/mocha-env.js tests/playnet.contract.publish.integration.js --exit --timeout 60000", + "playnet:chaos": "node scripts/playnet-chaos-neighbors.js", + "playnet:chaos:local": "node scripts/playnet-chaos-neighbors.js --local", + "playnet:chaos:production": "FABRIC_PLAYNET_CHAOS=1 node scripts/playnet-chaos-neighbors.js --production", + "test:playnet-publish": "NODE_ENV=test mocha --require ./tests/mocha-env.js tests/fabric.hub.mesh.integration.js --grep 'playnet contract publish' --exit", + "test:playnet-publish-live": "FABRIC_PLAYNET_PUBLISH=1 NODE_ENV=test mocha --require ./tests/mocha-env.js tests/fabric.hub.mesh.integration.js --grep 'playnet contract publish live' --exit --timeout 60000", + "test:playnet-chaos-unit": "NODE_ENV=test mocha --require ./tests/mocha-env.js tests/fuzz/playnet.chaosNeighbors.unit.js --exit", + "test:playnet-chaos-live": "FABRIC_PLAYNET_CHAOS=1 NODE_ENV=test mocha --require ./tests/mocha-env.js tests/fuzz/p2p.chaosPeers.fuzz.js --grep 'playnet neighbor chaos' --exit --timeout 120000", "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 && mkdir -p reports && echo \"$ npm i\" > reports/install.log && npm i >> 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", @@ -135,8 +143,10 @@ "test:network": "node test_three_peer_network.js", "test:security": "node test_network_security.js", "example:basic": "node examples/fabric-basic-usage.js", - "example:chat": "node examples/fabric-chat-app.js", + "example:chat": "node examples/message.js --chat", "example:demo": "node examples/fabric-demo.js", + "example:onion": "node examples/onion-forward.js", + "example:smoke": "node examples/index.js --smoke", "example:agents:smoke": "node examples/agents.js --smoke", "example:agents:distributor": "node examples/agents.js --distributor-only", "example:agents:bitcoin": "node examples/agents.js --bitcoin-only", @@ -223,8 +233,13 @@ "functions/**", "!functions/base58.js", "!functions/bech32.js", + "!functions/bip21.js", "!functions/bip32.js", "!functions/bip39.js", + "!functions/bip67.js", + "!functions/bip69.js", + "!functions/bip85.js", + "!functions/bip125.js", "!functions/bytes.js", "!functions/cliDocumentExchange.js", "!functions/fabricSetup.js", diff --git a/scripts/bip-compliance-report.js b/scripts/bip-compliance-report.js index ca5e21aed..a54830dfc 100644 --- a/scripts/bip-compliance-report.js +++ b/scripts/bip-compliance-report.js @@ -58,9 +58,9 @@ const EVALUATION_SUITE = [ layer: 'Applications', adoption: 'universal', why: 'Default payment deep-link / QR format across wallets.', - patterns: ['bitcoin:', 'BIP21', 'bip21', 'bitcoinUri'], - expected: { core: 'strong', hub: 'strong', passport: 'absent', application: 'partial' }, - notes: 'Core inventory HTLC funding hints emit bitcoin: URIs with amount=. Passport uses fabric: deep links, not bitcoin:.' + patterns: ['functions/bip21', 'encodeBitcoinUri', 'parseBitcoinUri', 'bitcoin:', 'BIP21', 'bip21', 'bitcoinUri'], + expected: { core: 'full', hub: 'strong', passport: 'absent', application: 'partial' }, + notes: 'Core functions/bip21.js encode/parse; Hub Payjoin, invoices, crowdfund, and desktop bitcoin: links use the same helper. Inventory HTLC funding hints emit bitcoin: URIs with amount=. Passport uses fabric: deep links, not bitcoin:.' }, { bip: 32, @@ -93,7 +93,7 @@ const EVALUATION_SUITE = [ why: 'Purpose level (44/49/84/86) disambiguates account trees.', patterns: ['BIP43', "m/44'", "m/84'", "m/86'"], expected: { core: 'strong', hub: 'strong', passport: 'strong', application: 'partial' }, - notes: 'Fabric uses purpose 44 for identity (coin 7777 mainnet / 7778 other) and Bitcoin funds; Passport also uses 84.' + notes: 'Fabric uses purpose 44 for identity (coin 7777 mainnet / 7778 other) and Bitcoin funds; Core also names BIP-49/84/86 fund path templates. Passport receive UI is BIP84.' }, { bip: 44, @@ -102,7 +102,7 @@ const EVALUATION_SUITE = [ layer: 'Applications', adoption: 'universal', why: 'Default multi-account path template; still dominant for legacy P2PKH + many apps.', - patterns: ['BIP44', 'BITCOIN_KEY_DERIVATION_PATH', "m/44'/0'", "m/44'/7777'", "m/44'/7778'", 'bitcoinReceiveDerivationPath', 'fabricCoinTypeForNetwork'], + patterns: ['BIP44', 'BITCOIN_KEY_DERIVATION_PATH', "m/44'/0'", "m/44'/7777'", "m/44'/7778'", 'bitcoinReceiveDerivationPath', 'fabricCoinTypeForNetwork', 'resolveFabricIdentityCoinType', 'fabricIdentityDerivationPath', 'fabricIdentityAccountPath'], expected: { core: 'full', hub: 'strong', passport: 'absent', application: 'strong' }, notes: 'Core constants encode BIP44 Bitcoin funds + Fabric coin-type 7777 (mainnet) / 7778 (other) identity. Passport receive UI is BIP84, not BIP44 path templates.' }, @@ -113,8 +113,9 @@ const EVALUATION_SUITE = [ layer: 'Applications', adoption: 'common', why: 'Wrapped SegWit path — still seen in older wallets / migrations.', - patterns: ["m/49'", 'BIP49', 'p2sh-p2wpkh'], - expected: { core: 'absent', hub: 'absent', passport: 'absent', application: 'absent' } + patterns: ["m/49'", 'BIP49', 'p2sh-p2wpkh', 'p2shP2wpkh', 'bitcoinBip49ReceiveDerivationPath', 'functions/bip69'], + expected: { core: 'strong', hub: 'absent', passport: 'absent', application: 'absent' }, + notes: 'Named m/49\' path templates plus p2sh-p2wpkh payment helper (BIP-49 testnet vector). Default funds path remains BIP-44.' }, { bip: 65, @@ -134,9 +135,9 @@ const EVALUATION_SUITE = [ layer: 'Applications', adoption: 'common', why: 'Lexicographic pubkey sort for deterministic multisig scripts.', - patterns: ['BIP67', 'lexicographically sorted', 'sorted validator', 'sorted keys', 'sortPub'], - expected: { core: 'partial', hub: 'partial', passport: 'n/a', application: 'strong' }, - notes: 'application group wallets sort keys; Hub federation vaults sort validators. Not a named BIP67 module.' + patterns: ['BIP67', 'functions/bip67', 'sortPubkeysBip67', 'lexicographically sorted', 'sorted validator', 'sorted keys', 'sortPub'], + expected: { core: 'strong', hub: 'partial', passport: 'n/a', application: 'strong' }, + notes: 'Named sortPubkeysBip67; contractTaproot k-of-n leaves, ARC spend keys, and Beacon validators use it.' }, { bip: 68, @@ -177,9 +178,9 @@ const EVALUATION_SUITE = [ layer: 'Applications', adoption: 'universal', why: 'Default receive path for modern bc1q wallets.', - patterns: ['BIP84', "m/84'", 'p2wpkh', 'native SegWit'], - expected: { core: 'partial', hub: 'partial', passport: 'strong', application: 'partial' }, - notes: 'Passport derives BIP84 receive from xpub; Core still defaults Bitcoin funds to BIP44 paths while emitting p2wpkh addresses.' + patterns: ['BIP84', "m/84'", 'p2wpkh', 'native SegWit', 'bitcoinBip84ReceiveDerivationPath'], + expected: { core: 'strong', hub: 'partial', passport: 'strong', application: 'partial' }, + notes: 'Core names BIP-84 path templates; default Bitcoin funds path remains BIP-44. Passport derives BIP84 receive from xpub.' }, { bip: 86, @@ -188,9 +189,9 @@ const EVALUATION_SUITE = [ layer: 'Applications', adoption: 'common', why: 'Standard Taproot single-key receive path (bc1p).', - patterns: ['BIP86', "m/86'", 'p2tr', 'Key Derivation for Single'], - expected: { core: 'partial', hub: 'partial', passport: 'absent', application: 'absent' }, - notes: 'P2TR address generation exists in core/hub; dedicated BIP86 path template is not the default funds path. GoonCitizen has no BIP86 surface.' + patterns: ['BIP86', "m/86'", 'p2tr', 'bitcoinBip86ReceiveDerivationPath', 'Key Derivation for Single'], + expected: { core: 'strong', hub: 'partial', passport: 'absent', application: 'absent' }, + notes: 'Named m/86\' receive/change templates; default funds path is still BIP-44. P2TR address generation exists separately. GoonCitizen has no BIP86 surface.' }, { bip: 112, @@ -209,8 +210,9 @@ const EVALUATION_SUITE = [ layer: 'Applications', adoption: 'common', why: 'Wallet fee-bumping interoperability via sequence signaling.', - patterns: ['BIP125', 'RBF', 'replace-by-fee', 'replaceByFee'], - expected: { core: 'absent', hub: 'dependent', passport: 'absent', application: 'absent' } + patterns: ['BIP125', 'functions/bip125', 'sequenceSignalsOptInRbf', 'BIP125_SEQUENCE_RBF', 'RBF', 'replace-by-fee', 'replaceByFee'], + expected: { core: 'partial', hub: 'dependent', passport: 'absent', application: 'absent' }, + notes: 'Named nSequence helpers. Inventory HTLC refunds use locktime-only (MAX-1), which does not signal RBF. Spends are not RBF by default.' }, { bip: 141, @@ -262,8 +264,8 @@ const EVALUATION_SUITE = [ adoption: 'specialized', why: 'Interactive Schnorr multi-sig; relevant to Fabric federation / group signing narrative.', patterns: ['MuSig2', 'musig2', 'BIP327', 'MuSig'], - expected: { core: 'absent', hub: 'partial', passport: 'absent', application: 'partial' }, - notes: 'No first-party MuSig2 module; federation vaults use tapscript k-of-n. Revisit if MuSig2 aggregates become product-critical.' + expected: { core: 'full', hub: 'partial', passport: 'absent', application: 'partial' }, + notes: 'First-party BIP-327 in functions/musig2 (official vectors). Taproot n≥2 vaults use the MuSig2 aggregate as the internal key (cooperative n-of-n key-path); t-of-n stays CHECKSIGADD script-path. Beacon/GroupChange remain count-k BIP-340. P2P_MUSIG_* is dispatched on directed TCP (musig2Session + Peer).' }, { bip: 340, @@ -325,8 +327,9 @@ const EVALUATION_SUITE = [ layer: 'Applications', adoption: 'common', why: 'PSBT taproot leaf/control-block fields for hardware + collaborative signing.', - patterns: ['BIP371', 'taproot.*PSBT', 'tapLeafScript', 'tapMerkleRoot'], - expected: { core: 'partial', hub: 'partial', passport: 'absent', application: 'absent' } + patterns: ['BIP371', 'taprootPsbtInputFields', 'taproot.*PSBT', 'tapLeafScript', 'tapMerkleRoot', 'LEAF_VERSION_TAPSCRIPT'], + expected: { core: 'strong', hub: 'partial', passport: 'absent', application: 'absent' }, + notes: 'Named taproot PSBT input bags (internal key + tapLeafScript); inventory HTLC and contract vault leaf spends use them.' }, { bip: 380, @@ -378,9 +381,9 @@ const EVALUATION_SUITE = [ layer: 'Applications', adoption: 'common', why: 'Deterministic vin/vout ordering used by privacy-preserving and collaborative tx builders.', - patterns: ['BIP69', 'BIP-69', 'lexicographical indexing', 'bip69'], - expected: { core: 'absent', hub: 'absent', passport: 'absent', application: 'absent' }, - notes: 'Fabric sorts keys / JSON canonically elsewhere; not BIP69 tx input/output ordering.' + patterns: ['BIP69', 'BIP-69', 'functions/bip69', 'lexicographical indexing', 'bip69', 'sortInputs'], + expected: { core: 'full', hub: 'strong', passport: 'absent', application: 'absent' }, + notes: 'Core sortInputs/sortOutputs with official BIP-69 examples. Hub unsigned PSBTs (Bitcoin _buildPSBT, crowdfund payout, original Payjoin) sort before the first signature. Payjoin ACP does not reorder signed slots.' }, { bip: 85, @@ -389,8 +392,9 @@ const EVALUATION_SUITE = [ layer: 'Applications', adoption: 'common', why: 'Derive app-specific entropy (extra mnemonics, Lightning seeds) from one BIP32 root.', - patterns: ['BIP85', 'BIP-85', 'deterministic entropy', "m/83696968'"], - expected: { core: 'absent', hub: 'absent', passport: 'absent', application: 'absent' } + patterns: ['BIP85', 'BIP-85', 'functions/bip85', 'deterministic entropy', "m/83696968'", 'deriveBip85Mnemonic'], + expected: { core: 'full', hub: 'absent', passport: 'absent', application: 'absent' }, + notes: 'First-party HMAC entropy + BIP-39/WIF/XPRV/HEX applications against published BIP-85 vectors.' }, { bip: 321, @@ -400,8 +404,8 @@ const EVALUATION_SUITE = [ adoption: 'common', why: 'BIP21 successor with richer query grammar; wallets increasingly advertise BIP321.', patterns: ['BIP321', 'BIP-321', 'bitcoin:', 'lightning=', 'pj='], - expected: { core: 'partial', hub: 'partial', passport: 'absent', application: 'partial' }, - notes: 'Stack emits classic bitcoin: URIs (BIP21). Full BIP321 query grammar is not claimed.' + expected: { core: 'partial', hub: 'strong', passport: 'absent', application: 'partial' }, + notes: 'Stack emits classic bitcoin: URIs (BIP21) with opaque extras (`pj=`, `lightning=`). Full BIP321 query grammar is not claimed. Hub Payjoin / invoices / crowdfund / desktop bitcoin: links go through core encode/parse.' }, { bip: 322, @@ -421,9 +425,9 @@ const EVALUATION_SUITE = [ layer: 'Applications', adoption: 'specialized', why: 'Standard purpose-48 paths for multisig script types; relevant to group / federation wallets.', - patterns: ['BIP48', 'BIP-48', "m/48'", 'multi-script hierarchy'], - expected: { core: 'absent', hub: 'absent', passport: 'absent', application: 'absent' }, - notes: 'Group/federation vaults sort keys and build scripts, but do not advertise BIP48 path trees.' + patterns: ['BIP48', 'BIP-48', "m/48'", 'bitcoinBip48P2wshReceiveDerivationPath', 'multi-script hierarchy'], + expected: { core: 'partial', hub: 'absent', passport: 'absent', application: 'absent' }, + notes: 'Named m/48\' p2sh-p2wsh / p2wsh path templates only. Group/federation vaults still sort keys without advertising BIP48 trees.' }, { bip: 382, diff --git a/scripts/report-credits.js b/scripts/report-credits.js new file mode 100644 index 000000000..727a78467 --- /dev/null +++ b/scripts/report-credits.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node +'use strict'; + +/** + * @fileoverview Emit license-checker-compatible JSON from package-lock.json + node_modules. + * Replaces the deprecated license-checker devDependency. + */ + +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const lockPath = path.join(root, 'package-lock.json'); + +const LICENSE_FILES = ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'LICENCE', 'LICENCE.md', 'LICENCE.txt', 'COPYING']; + +function repoUrl (pkg) { + if (!pkg || !pkg.repository) return undefined; + const r = pkg.repository; + if (typeof r === 'string') return r; + if (r.url) { + return String(r.url) + .replace(/^git\+/, '') + .replace(/^git:\/\//, 'https://') + .replace(/\.git$/, ''); + } + return undefined; +} + +function formatLicense (pkg) { + if (pkg.license) return String(pkg.license); + if (Array.isArray(pkg.licenses)) { + return pkg.licenses.map((l) => (typeof l === 'string' ? l : l.type)).filter(Boolean).join(' AND '); + } + return 'UNKNOWN'; +} + +function findLicenseFile (dir) { + for (const name of LICENSE_FILES) { + const p = path.join(dir, name); + if (fs.existsSync(p)) return p; + } + return undefined; +} + +/** + * Lockfile `packages` keys must stay under this repo's `node_modules`. + * @param {string} pkgPath + * @returns {boolean} + */ +function isLockNodeModulesPath (pkgPath) { + const n = String(pkgPath || '').replace(/\\/g, '/'); + if (n === 'node_modules' || n.startsWith('node_modules/')) { + return n.split('/').indexOf('..') < 0; + } + return false; +} + +/** + * @param {string} repoRoot + * @param {string} pkgPath lockfile package key + * @returns {string|null} resolved directory, or null when outside `node_modules` + */ +function resolveLockPackageDir (repoRoot, pkgPath) { + if (!isLockNodeModulesPath(pkgPath)) return null; + const nmRoot = path.resolve(repoRoot, 'node_modules'); + const nmPath = path.resolve(repoRoot, pkgPath); + const rel = path.relative(nmRoot, nmPath); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null; + return nmPath; +} + +function main () { + const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + const packages = lock.packages || {}; + const out = {}; + + for (const [pkgPath, meta] of Object.entries(packages)) { + if (!pkgPath || pkgPath === '' || !meta.version) continue; + const nmPath = resolveLockPackageDir(root, pkgPath); + if (!nmPath || !fs.existsSync(nmPath)) continue; + + const pkgJsonPath = path.join(nmPath, 'package.json'); + let pkg = {}; + try { + pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + } catch { + continue; + } + + const key = `${pkg.name}@${meta.version}`; + if (out[key]) continue; + + const entry = { + licenses: formatLicense(pkg), + path: nmPath + }; + const repository = repoUrl(pkg); + if (repository) entry.repository = repository; + if (pkg.author) { + if (typeof pkg.author === 'string') { + entry.publisher = pkg.author; + } else { + if (pkg.author.name) entry.publisher = pkg.author.name; + if (pkg.author.email) entry.email = pkg.author.email; + if (pkg.author.url) entry.url = pkg.author.url; + } + } + const licenseFile = findLicenseFile(nmPath); + if (licenseFile) entry.licenseFile = licenseFile; + out[key] = entry; + } + + process.stdout.write(JSON.stringify(out, null, 2) + '\n'); +} + +module.exports = { + isLockNodeModulesPath, + resolveLockPackageDir, + main +}; + +if (require.main === module) { + main(); +} diff --git a/scripts/static-server.js b/scripts/static-server.js new file mode 100644 index 000000000..948c9bf70 --- /dev/null +++ b/scripts/static-server.js @@ -0,0 +1,195 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Minimal static file server (replaces deprecated http-server devDependency). + * Usage: node scripts/static-server.js [root] [-p port] + */ + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const MIME = { + '.html': 'text/html; charset=utf-8', + '.htm': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.map': 'application/json', + '.md': 'text/markdown; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.wasm': 'application/wasm', + '.xml': 'application/xml' +}; + +function parseArgs (argv) { + let root = '.'; + let port = 8080; + const positional = []; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === '-p' || a === '--port') { + port = Number(argv[++i]); + } else if (a.startsWith('-p') && a.length > 2) { + port = Number(a.slice(2)); + } else if (a === '-h' || a === '--help') { + console.log('Usage: node scripts/static-server.js [root] [-p port]'); + process.exit(0); + } else { + positional.push(a); + } + } + if (positional.length) root = positional[0]; + return { root: path.resolve(root), port }; +} + +function pathContained (rootPath, candidatePath) { + const rootPrefix = rootPath.endsWith(path.sep) ? rootPath : rootPath + path.sep; + return candidatePath === rootPath || candidatePath.startsWith(rootPrefix); +} + +function safeJoin (root, reqPath) { + let decoded; + try { + decoded = decodeURIComponent(String(reqPath || '/').split('?')[0]); + } catch { + return null; + } + const rel = decoded.replace(/^\/+/, '') || 'index.html'; + const resolved = path.normalize(path.join(root, rel)); + const rootResolved = path.resolve(root); + if (!pathContained(rootResolved, resolved)) return null; + return resolved; +} + +/** + * Lexical `safeJoin` cannot see a symlink whose target is outside the + * document root. After `stat` confirms a file, compare real paths. + * @param {string} root + * @param {string} candidate + * @returns {boolean} + */ +function isRealpathInsideRoot (root, candidate) { + let rootReal; + let fileReal; + try { + rootReal = fs.realpathSync(root); + fileReal = fs.realpathSync(candidate); + } catch { + return false; + } + return pathContained(rootReal, fileReal); +} + +function contentType (filePath) { + return MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream'; +} + +function serveFile (res, filePath) { + const stream = fs.createReadStream(filePath); + res.writeHead(200, { 'Content-Type': contentType(filePath), 'Cache-Control': 'no-cache' }); + stream.pipe(res); + stream.on('error', () => { + if (res.headersSent) { + res.destroy(); + return; + } + res.writeHead(500); + res.end('Internal Server Error'); + }); +} + +function handler (root) { + return (req, res) => { + let filePath = safeJoin(root, req.url || '/'); + if (!filePath) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + fs.stat(filePath, (err, stat) => { + if (!err && stat.isDirectory()) { + filePath = path.join(filePath, 'index.html'); + } + fs.stat(filePath, (err2, stat2) => { + if (err2 || !stat2.isFile()) { + res.writeHead(404); + res.end('Not Found'); + return; + } + if (!isRealpathInsideRoot(root, filePath)) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + serveFile(res, filePath); + }); + }); + }; +} + +function listenHost () { + const host = String(process.env.FABRIC_STATIC_HOST || '127.0.0.1').trim() || '127.0.0.1'; + return host; +} + +function listen (port, root) { + const host = listenHost(); + const server = http.createServer(handler(root)); + server.listen(port, host, () => { + const addr = server.address(); + const actual = typeof addr === 'object' && addr ? addr.port : port; + const base = host === '0.0.0.0' || host === '::' + ? `http://127.0.0.1:${actual}` + : `http://${host}:${actual}`; + console.log('Serving', root); + console.log('Available on:', base, `(bind ${host})`); + console.log('Hit CTRL-C to stop'); + }); + return server; +} + +module.exports = { + parseArgs, + safeJoin, + isRealpathInsideRoot, + contentType, + serveFile, + handler, + listenHost, + listen +}; + +if (require.main === module) { + const { root, port } = parseArgs(process.argv); + if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) { + console.error('Not a directory:', root); + process.exit(1); + } + + if (port === 0) { + const host = listenHost(); + const server = http.createServer(handler(root)); + server.listen(0, host, () => { + const addr = server.address(); + const actual = typeof addr === 'object' && addr ? addr.port : 0; + const base = host === '0.0.0.0' || host === '::' + ? `http://127.0.0.1:${actual}` + : `http://${host}:${actual}`; + console.log('Serving', root); + console.log('Available on:', base, `(bind ${host})`); + }); + } else { + listen(port, root); + } +} diff --git a/services/bitcoin.js b/services/bitcoin.js index e15a13fd8..4affe62d7 100644 --- a/services/bitcoin.js +++ b/services/bitcoin.js @@ -25,6 +25,7 @@ const ecc = require('../types/ecc'); const bip65 = require('bip65'); const bip68 = require('bip68'); const bitcoin = require('bitcoinjs-lib'); +const { sortInputs, sortOutputs } = require('../functions/bip69'); // Initialize bitcoinjs-lib with the ECC library bitcoin.initEccLib(ecc); @@ -55,6 +56,30 @@ const SATS_PER_BTC = 10 ** 8; // Internal: max |sats - round(sats)| allowed for BTC -> sats conversion // (reject fractional satoshis while tolerating float noise). const SAT_ADJ_EPS = 1 / (10 ** 6); + +/** + * PSBT output values are integer satoshis. Reject float BTC leftovers. + * @param {bigint|number|string} value + * @returns {bigint} + */ +function toIntegerSats (value) { + if (typeof value === 'bigint') { + if (value < 0n) throw new Error('Output value must be a non-negative integer satoshi count'); + return value; + } + if (typeof value !== 'number' && typeof value !== 'string') { + throw new Error('Output value must be a non-negative integer satoshi count'); + } + if (typeof value === 'string' && value.trim() === '') { + throw new Error('Output value must be a non-negative integer satoshi count'); + } + const n = typeof value === 'number' ? value : Number(value); + if (!Number.isInteger(n) || !Number.isSafeInteger(n) || n < 0) { + throw new Error('Output value must be a non-negative integer satoshi count'); + } + return BigInt(n); +} + // Internal: reject absurd cookie path lengths before filesystem reads. const BITCOIN_COOKIE_PATH_MAX_LEN = 4096; @@ -2366,6 +2391,7 @@ class Bitcoin extends Service { /** * Create a Partially-Signed Bitcoin Transaction (PSBT). + * Vin/vout are BIP-69 sorted before they are added (unsigned construction only). * @param {Object} options Parameters for the PSBT. * @returns {PSBT} Instance of the PSBT. */ @@ -2396,37 +2422,50 @@ class Bitcoin extends Service { // TODO: add change output - // Create the PSBT - const psbt = new bitcoin.Psbt({ network }); - - for (let i = 0; i < options.inputs.length; i++) { - const input = options.inputs[i]; - const data = { - hash: input.txid, - index: input.vout, - sequence: -1 >>> 0 - }; - - psbt.addInput(data); - } - + const sortedInputs = sortInputs(options.inputs.map((input) => Object.assign({}, input))); + const preparedOutputs = []; for (let i = 0; i < options.outputs.length; i++) { const output = options.outputs[i]; + let sats; + try { + sats = toIntegerSats(output.value); + } catch (e) { + throw new Error(`Invalid output value for ${output.address}: ${e.message}`); + } try { const script = bitcoin.address.toOutputScript(output.address, network); - const data = { + preparedOutputs.push({ + address: output.address, script, - value: typeof output.value === 'bigint' ? output.value : BigInt(Math.round(Number(output.value))) - }; - - psbt.addOutput(data); + value: sats + }); } catch (e) { if (this.settings.debug) { - if (this.settings.debug) this.emit('debug', `[FABRIC:BITCOIN] Failed to add output: ${e.message}`); + this.emit('debug', `[FABRIC:BITCOIN] Failed to add output: ${e.message}`); } throw new Error(`Invalid address ${output.address}: ${e.message}`); } } + const sortedOutputs = sortOutputs(preparedOutputs); + + const psbt = new bitcoin.Psbt({ network }); + + for (let i = 0; i < sortedInputs.length; i++) { + const input = sortedInputs[i]; + psbt.addInput({ + hash: input.txid, + index: input.vout, + sequence: -1 >>> 0 + }); + } + + for (let i = 0; i < sortedOutputs.length; i++) { + const output = sortedOutputs[i]; + psbt.addOutput({ + script: output.script, + value: output.value + }); + } return psbt; } @@ -2743,6 +2782,13 @@ class Bitcoin extends Service { this.emit('log', `[FABRIC:BITCOIN] addnode ${cmd} ${addr}`); } catch (e) { const msg = e && e.message ? e.message : String(e); + if (cmd === 'add' && (/-23\b/.test(msg) || /already added/i.test(msg))) { + done.push(addr); + if (this.settings && this.settings.debug) { + this.emit('log', `[FABRIC:BITCOIN] addnode ${addr} already present`); + } + continue; + } this.emit('warning', `[FABRIC:BITCOIN] addnode failed ${addr}: ${msg}`); } } diff --git a/settings/local.example.js b/settings/local.example.js index 1700ee141..dd6c810fe 100644 --- a/settings/local.example.js +++ b/settings/local.example.js @@ -1,5 +1,5 @@ /** - * Example local settings for a Fabric node. + * @fileoverview Example local settings for a Fabric node. * Copy to `settings/local.js` (gitignored) for operator overrides, or rely on * environment variables alone — the example is the packaged fallback when * `settings/local.js` is absent. @@ -10,6 +10,7 @@ module.exports = { name: process.env.NAME, namespace: process.env.NAMESPACE, seed: process.env.FABRIC_SEED, + mnemonic: process.env.FABRIC_MNEMONIC, xprv: process.env.FABRIC_XPRV, xpub: process.env.FABRIC_XPUB, /** Fabric Peer TCP/NOISE listen port. */ diff --git a/tests/adversarialEnvironment.basics.test.js b/tests/adversarialEnvironment.basics.test.js deleted file mode 100644 index fa6a59df2..000000000 --- a/tests/adversarialEnvironment.basics.test.js +++ /dev/null @@ -1,39 +0,0 @@ -'use strict'; - -/** - * Basics tied to SECURITY.md § Adversarial environment. - * Not a substitute for tests/protocol-v1 adversarial Peer coverage. - */ - -const assert = require('assert'); -const Key = require('../types/key'); -const { - composeGarblerPublish, - finalizeBlindedExecution -} = require('../functions/blindedExecutionCircuit'); - -describe('adversarialEnvironment.basics (@fabric/core)', function () { - it('refuses a single-party blinded-execution publish (needs evaluator)', function () { - const garbler = new Key(); - assert.throws(() => composeGarblerPublish({ - network: 'regtest', - garbler: garbler.pubkey, - parties: [], - state: { v: 1 }, - program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } - }), /at least one evaluator/); - }); - - it('refuses finalize without an accept under adversarial coordination', function () { - const a = new Key(); - const b = new Key(); - const session = composeGarblerPublish({ - network: 'regtest', - garbler: a.pubkey, - parties: [b.pubkey], - state: {}, - program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } - }); - assert.throws(() => finalizeBlindedExecution({ session }), /at least one evaluator accept/); - }); -}); diff --git a/tests/applicationNamespaces.unit.js b/tests/applicationNamespaces.unit.js index 15034f5d6..ad00f1e49 100644 --- a/tests/applicationNamespaces.unit.js +++ b/tests/applicationNamespaces.unit.js @@ -2,6 +2,7 @@ const assert = require('assert'); const constants = require('../constants'); +const { HEADER_SIZE, MAX_MESSAGE_SIZE } = constants; const { OUTER, OUTER_OPCODES, @@ -56,3 +57,1184 @@ describe('applicationNamespaces', function () { assert.strictEqual(ACTIVITY_TYPES.GameStateSnapshot, undefined); }); }); + +const Key = require('../types/key'); +const Message = require('../types/message'); +const { + createMemoryStore, + ingestMessageBuffer, + ingestContractPublishBuffer, + foldContractState, + stateDigest, + bufferFromPaste, + tipFromDoc, + loadDoc, + normalizeGenesis, + resolveMaxJournalEntries +} = require('../functions/contractMessageAccumulate'); +const { pubkeyXOnly } = require('../functions/groupChatSeal'); +const { + signProposalVote +} = require('../functions/groupChangeGovernance'); +const { + OP_CONTRACT_SIGN, + issueContractCapability +} = require('../functions/contractCapability'); +const { + createPending, + markReceived, + markReceipt, + receiptSigningMessage, + phaseFlags, + allPhasesComplete, + aggregatePhaseFlags, + pendingForRelay +} = require('../functions/contractMessageCommit'); + +function signContractMessage (key, contractId, type, object) { + const body = JSON.stringify({ + contract: contractId, + type, + object + }); + return Message.fromVector(['CONTRACT_MESSAGE', body]).signWithKey(key); +} + +describe('@fabric/core contractMessageAccumulate', function () { + const contractId = 'c'.repeat(64); + + it('idempotent multi-origin ingest of the same AMP bytes', function () { + const author = new Key(); + const genesis = { signers: [author.pubkey] }; + const msg = signContractMessage(author, contractId, 'GroupChat', { + body: 'hello', + author: author.pubkey, + ts: '2026-01-01T00:00:00.000Z' + }); + const buf = msg.toBuffer(); + const paste = `fabric:${buf.toString('hex')}`; + + const store = createMemoryStore(); + const a = ingestMessageBuffer(store, contractId, buf, { origin: 'mesh', genesis }); + assert.strictEqual(a.accepted, true); + const b = ingestMessageBuffer(store, contractId, buf, { origin: 'queue', genesis }); + assert.strictEqual(b.duplicate, true); + const c = ingestMessageBuffer(store, contractId, paste, { origin: 'paste', genesis }); + assert.strictEqual(c.duplicate, true); + assert.strictEqual(a.tip.stateDigest, b.tip.stateDigest); + assert.strictEqual(a.tip.stateDigest, c.tip.stateDigest); + assert.strictEqual(a.tip.clock, 1); + assert.strictEqual(loadDoc(store, contractId).entries.length, 1); + }); + + it('arrival-order independence across peers', function () { + const aKey = new Key(); + const bKey = new Key(); + // Both authors must be authorized for GroupChat in any arrival order. + const genesis = { signers: [aKey.pubkey, bKey.pubkey] }; + const m1 = signContractMessage(aKey, contractId, 'GroupChange', { + action: 'members.set', + members: [aKey.pubkey, bKey.pubkey] + }); + const m2 = signContractMessage(aKey, contractId, 'GroupChat', { body: 'one', author: aKey.pubkey }); + const m3 = signContractMessage(bKey, contractId, 'GroupChat', { body: 'two', author: bKey.pubkey }); + const bufs = [m1.toBuffer(), m2.toBuffer(), m3.toBuffer()]; + + const storeA = createMemoryStore(); + for (const buf of bufs) ingestMessageBuffer(storeA, contractId, buf, { origin: 'mesh', genesis }); + + const storeB = createMemoryStore(); + for (const buf of [bufs[2], bufs[0], bufs[1]]) { + ingestMessageBuffer(storeB, contractId, buf, { origin: 'mesh', genesis }); + } + + const storeC = createMemoryStore(); + ingestMessageBuffer(storeC, contractId, `fabric:${bufs[1].toString('hex')}`, { origin: 'paste', genesis }); + ingestMessageBuffer(storeC, contractId, bufs[0], { origin: 'queue', genesis }); + ingestMessageBuffer(storeC, contractId, bufs[2], { origin: 'mesh', genesis }); + + const tipA = tipFromDoc(loadDoc(storeA, contractId)); + const tipB = tipFromDoc(loadDoc(storeB, contractId)); + const tipC = tipFromDoc(loadDoc(storeC, contractId)); + assert.strictEqual(tipA.stateDigest, tipB.stateDigest); + assert.strictEqual(tipA.stateDigest, tipC.stateDigest); + assert.strictEqual(tipA.clock, 3); + assert.deepStrictEqual(tipA.content.members.slice().sort(), tipB.content.members.slice().sort()); + }); + + it('publisher local accumulate matches paste-only participant', function () { + const publisher = new Key(); + const peer = new Key(); + const genesis = { signers: [publisher.pubkey, peer.pubkey] }; + const out = signContractMessage(publisher, contractId, 'GroupChat', { + body: 'from publisher', + author: publisher.pubkey + }); + const reply = signContractMessage(peer, contractId, 'GroupChat', { + body: 'reply', + author: peer.pubkey + }); + + const pubStore = createMemoryStore(); + ingestMessageBuffer(pubStore, contractId, out.toBuffer(), { origin: 'local', genesis }); + ingestMessageBuffer(pubStore, contractId, reply.toBuffer(), { origin: 'mesh', genesis }); + + const partStore = createMemoryStore(); + ingestMessageBuffer(partStore, contractId, `fabric:${out.toBuffer().toString('hex')}`, { origin: 'paste', genesis }); + ingestMessageBuffer(partStore, contractId, reply.toBuffer(), { origin: 'mesh', genesis }); + + assert.strictEqual( + tipFromDoc(loadDoc(pubStore, contractId)).stateDigest, + tipFromDoc(loadDoc(partStore, contractId)).stateDigest + ); + }); + + it('rejects wrong contract, bad signature, and leaves tip unchanged', function () { + const author = new Key(); + const attacker = new Key(); + const store = createMemoryStore(); + const genesis = { signers: [author.pubkey] }; + const good = signContractMessage(author, contractId, 'GroupChat', { body: 'ok' }); + ingestMessageBuffer(store, contractId, good.toBuffer(), { origin: 'mesh', genesis }); + const tip0 = tipFromDoc(loadDoc(store, contractId)).stateDigest; + + const wrongContract = signContractMessage(author, 'd'.repeat(64), 'GroupChat', { body: 'nope' }); + const badContract = ingestMessageBuffer(store, contractId, wrongContract.toBuffer(), { genesis }); + assert.strictEqual(badContract.accepted, false); + assert.match(badContract.error, /mismatch/i); + + const forged = Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ + contract: contractId, + type: 'GroupChat', + object: { body: 'evil' } + })]).signWithKey(attacker); + // Corrupt the BIP340 signature field (AMP trailer), not the JSON body. + const signed = forged.toBuffer(); + const badBuf = Buffer.from(signed); + // Signature is the last 64 bytes of the Fabric Message frame. + assert.ok(badBuf.length > 64); + badBuf[badBuf.length - 1] ^= 0xff; + const badSig = ingestMessageBuffer(store, contractId, badBuf, { genesis }); + assert.strictEqual(badSig.accepted, false); + assert.match(badSig.error, /invalid signature/i); + + assert.strictEqual(tipFromDoc(loadDoc(store, contractId)).stateDigest, tip0); + }); + + it('rejects frames larger than Peer MAX_MESSAGE_SIZE before parse', function () { + const store = createMemoryStore(); + const huge = Buffer.alloc(HEADER_SIZE + MAX_MESSAGE_SIZE + 1, 0xab); + const res = ingestMessageBuffer(store, contractId, huge, { + origin: 'paste', + genesis: { signers: ['02' + '11'.repeat(32)] } + }); + assert.strictEqual(res.accepted, false); + assert.match(res.error, /MAX_MESSAGE_SIZE/); + }); + + it('rejects GroupChat from non-reader/non-signer', function () { + const owner = new Key(); + const outsider = new Key(); + const store = createMemoryStore(); + const genesis = { signers: [owner.pubkey], readers: [owner.pubkey] }; + const bad = ingestMessageBuffer( + store, + contractId, + signContractMessage(outsider, contractId, 'GroupChat', { body: 'spam' }).toBuffer(), + { origin: 'mesh', genesis } + ); + assert.strictEqual(bad.accepted, false); + assert.match(bad.error, /reader\/signer/i); + assert.strictEqual(loadDoc(store, contractId).entries.length, 0); + }); + + it('rejects GroupChange from non-signer (F1)', function () { + const owner = new Key(); + const attacker = new Key(); + const store = createMemoryStore(); + const genesis = { signers: [owner.pubkey] }; + + const takeover = signContractMessage(attacker, contractId, 'GroupChange', { + action: 'members.set', + members: [attacker.pubkey] + }); + const bad = ingestMessageBuffer(store, contractId, takeover.toBuffer(), { + origin: 'mesh', + genesis + }); + assert.strictEqual(bad.accepted, false); + assert.match(bad.error, /not authorized/i); + assert.strictEqual(loadDoc(store, contractId).entries.length, 0); + + const ok = ingestMessageBuffer(store, contractId, signContractMessage(owner, contractId, 'GroupChange', { + action: 'members.set', + members: [owner.pubkey] + }).toBuffer(), { origin: 'mesh', genesis }); + assert.strictEqual(ok.accepted, true); + assert.deepStrictEqual(ok.tip.content.members, [pubkeyXOnly(owner.pubkey)]); + }); + + it('rejects governance/invite/journal-request spam from non-signers (tip integrity)', function () { + const owner = new Key(); + const outsider = new Key(); + const store = createMemoryStore(); + const genesis = { signers: [owner.pubkey] }; + const tipBefore = () => { + const doc = loadDoc(store, contractId); + return tipFromDoc(doc); + }; + + for (const type of [ + 'GroupChangeProposal', + 'GroupChangeVote', + 'FederationContractInvite', + 'FederationContractInviteResponse', + 'GroupJournalRequest' + ]) { + const bad = ingestMessageBuffer( + store, + contractId, + signContractMessage(outsider, contractId, type, { note: 'spam' }).toBuffer(), + { origin: 'mesh', genesis } + ); + assert.strictEqual(bad.accepted, false, type); + assert.match(bad.error, /not authorized|fail closed|genesis/i, type); + } + assert.strictEqual(loadDoc(store, contractId).entries.length, 0); + assert.strictEqual(tipBefore().clock, 0); + }); + + it('rejects GroupChange with no genesis signers (fail closed)', function () { + const anyone = new Key(); + const store = createMemoryStore(); + const res = ingestMessageBuffer(store, contractId, signContractMessage(anyone, contractId, 'GroupChange', { + action: 'member.add', + member: anyone.pubkey + }).toBuffer(), { origin: 'mesh' }); + assert.strictEqual(res.accepted, false); + assert.match(res.error, /genesis|fail closed/i); + }); + + it('accepts GroupChange via verified OP_CONTRACT_SIGN token', function () { + const owner = new Key(); + const delegate = new Key(); + const store = createMemoryStore(); + const genesis = { signers: [owner.pubkey] }; + const tok = issueContractCapability({ + issuerKey: owner, + subject: delegate.pubkey, + contractId, + capability: OP_CONTRACT_SIGN + }); + const change = signContractMessage(delegate, contractId, 'GroupChange', { + action: 'member.add', + member: delegate.pubkey + }); + const without = ingestMessageBuffer(store, contractId, change.toBuffer(), { + origin: 'mesh', + genesis + }); + assert.strictEqual(without.accepted, false); + + const withTok = ingestMessageBuffer(store, contractId, change.toBuffer(), { + origin: 'mesh', + genesis, + capabilityToken: tok + }); + assert.strictEqual(withTok.accepted, true); + assert.ok(withTok.tip.content.members.includes(pubkeyXOnly(delegate.pubkey))); + assert.ok(withTok.tip.content.signers.includes(pubkeyXOnly(delegate.pubkey))); + }); + + it('reader-role GroupChange widens members but not signers', function () { + const owner = new Key(); + const reader = new Key(); + const store = createMemoryStore(); + const genesis = { signers: [owner.pubkey] }; + const change = signContractMessage(owner, contractId, 'GroupChange', { + action: 'member.add', + member: reader.pubkey, + role: 'reader' + }); + const res = ingestMessageBuffer(store, contractId, change.toBuffer(), { + origin: 'mesh', + genesis + }); + assert.strictEqual(res.accepted, true); + assert.deepStrictEqual(res.tip.content.signers, []); + assert.deepStrictEqual(res.tip.content.members, [pubkeyXOnly(reader.pubkey)]); + + // Owner already a signer via member.add (commutes with reader add under + // hash-ordered fold). Avoid members.set here — a later-sorted set would + // clear the reader and flake this assertion. + const store2 = createMemoryStore(); + const seed = ingestMessageBuffer(store2, contractId, signContractMessage(owner, contractId, 'GroupChange', { + action: 'member.add', + member: owner.pubkey, + role: 'signer' + }).toBuffer(), { origin: 'mesh', genesis }); + assert.strictEqual(seed.accepted, true); + const withReader = ingestMessageBuffer(store2, contractId, change.toBuffer(), { + origin: 'mesh', + genesis + }); + assert.strictEqual(withReader.accepted, true); + assert.deepStrictEqual(withReader.tip.content.signers, [pubkeyXOnly(owner.pubkey)]); + assert.ok(withReader.tip.content.members.includes(pubkeyXOnly(owner.pubkey))); + assert.ok(withReader.tip.content.members.includes(pubkeyXOnly(reader.pubkey))); + assert.ok(!withReader.tip.content.signers.includes(pubkeyXOnly(reader.pubkey))); + }); + + it('rejects unknown body types and enforces genesis primitives', function () { + const author = new Key(); + const store = createMemoryStore(); + const unknown = ingestMessageBuffer(store, contractId, signContractMessage(author, contractId, 'EvilCustom', { + body: 'nope' + }).toBuffer(), { origin: 'mesh' }); + assert.strictEqual(unknown.accepted, false); + assert.match(unknown.error, /not allowed/i); + + const chat = signContractMessage(author, contractId, 'GroupChat', { body: 'hi' }); + const blocked = ingestMessageBuffer(store, contractId, chat.toBuffer(), { + origin: 'mesh', + genesis: { + signers: [author.pubkey], + primitives: { messageTypes: ['GroupChange'] } + } + }); + assert.strictEqual(blocked.accepted, false); + assert.match(blocked.error, /not allowed/i); + + const emptyAllow = ingestMessageBuffer(store, contractId, chat.toBuffer(), { + origin: 'mesh', + genesis: { + signers: [author.pubkey], + primitives: { messageTypes: [] } + } + }); + assert.strictEqual(emptyAllow.accepted, false); + assert.match(emptyAllow.error, /not allowed/i); + }); + + it('custom genesis messageTypes still require reader/signer membership', function () { + const owner = new Key(); + const outsider = new Key(); + const store = createMemoryStore(); + const genesis = { + signers: [owner.pubkey], + primitives: { messageTypes: ['AppNote'] } + }; + const spam = ingestMessageBuffer( + store, + contractId, + signContractMessage(outsider, contractId, 'AppNote', { body: 'spam' }).toBuffer(), + { origin: 'mesh', genesis } + ); + assert.strictEqual(spam.accepted, false); + assert.match(spam.error, /reader\/signer|fail closed/i); + + const ok = ingestMessageBuffer( + store, + contractId, + signContractMessage(owner, contractId, 'AppNote', { body: 'ok' }).toBuffer(), + { origin: 'mesh', genesis } + ); + assert.strictEqual(ok.accepted, true, ok.error); + }); + + it('threads bitcoinBlockHash onto tip', function () { + const author = new Key(); + const store = createMemoryStore(); + const hash = 'ab'.repeat(32); + const res = ingestMessageBuffer(store, contractId, signContractMessage(author, contractId, 'GroupChat', { + body: 'anchored' + }).toBuffer(), { + origin: 'mesh', + genesis: { signers: [author.pubkey] }, + bitcoinBlockHash: hash, + bitcoinHeight: 100 + }); + assert.strictEqual(res.accepted, true); + assert.strictEqual(res.tip.bitcoinBlockHash, hash); + assert.strictEqual(res.tip.bitcoinHeight, 100); + + const bad = ingestMessageBuffer(store, contractId, signContractMessage(author, contractId, 'GroupChat', { + body: 'bad-hash' + }).toBuffer(), { + origin: 'mesh', + genesis: { signers: [author.pubkey] }, + bitcoinBlockHash: 'not-a-hash' + }); + assert.strictEqual(bad.accepted, false); + assert.match(bad.error, /bitcoinBlockHash/i); + }); + + it('bufferFromPaste accepts fabric: prefix', function () { + const buf = Buffer.from('abcd', 'hex'); + assert.ok(bufferFromPaste(`fabric:${buf.toString('hex')}`).equals(buf)); + }); + + it('foldContractState sorts by hash', function () { + const entries = [ + { hash: 'bb', type: 'GroupChat', object: { body: 'b' } }, + { hash: 'aa', type: 'GroupChat', object: { body: 'a' } } + ]; + const state = foldContractState(entries); + assert.strictEqual(state.content.messages[0].body, 'a'); + assert.strictEqual(state.clock, 2); + assert.ok(stateDigest(state)); + }); + + it('MessageReceipt stores + relays without advancing tip digest; updates 2PC sidecar', function () { + const a = new Key(); + const b = new Key(); + const genesis = { signers: [a.pubkey], readers: [a.pubkey, b.pubkey] }; + const chat = signContractMessage(a, contractId, 'GroupChat', { + body: 'o7', + author: a.pubkey, + ts: '2026-01-01T00:00:00.000Z' + }); + const store = createMemoryStore(); + const chatRes = ingestMessageBuffer(store, contractId, chat.toBuffer(), { + origin: 'local', + genesis + }); + assert.strictEqual(chatRes.accepted, true); + const tipBefore = chatRes.tip.stateDigest; + const clockBefore = chatRes.tip.clock; + assert.ok(store.get('contractmessagecommits', chatRes.entry.hash), 'sync filter opens pending 2PC'); + + const wireHash = chatRes.entry.hash; + const receiptSig = Buffer.from(b.signSchnorr(receiptSigningMessage(wireHash))).toString('hex'); + const receipt = signContractMessage(b, contractId, 'MessageReceipt', { + messageId: wireHash, + receiptAt: '2026-01-01T00:00:01.000Z', + receiptSig + }); + const rx = ingestMessageBuffer(store, contractId, receipt.toBuffer(), { + origin: 'mesh', + genesis + }); + assert.strictEqual(rx.accepted, true, rx.error); + assert.strictEqual(rx.entry.type, 'MessageReceipt'); + assert.ok(rx.entry.hex); + assert.strictEqual(rx.tip.stateDigest, tipBefore); + assert.strictEqual(rx.tip.clock, clockBefore); + assert.strictEqual(loadDoc(store, contractId).entries.length, 2); + assert.strictEqual( + store.get('contractmessagecommits', rx.entry.hash), + null, + 'ACK frames do not open a new pending row' + ); + + const sidecar = store.get('contractmessagecommits', wireHash); + assert.ok(sidecar); + assert.strictEqual(phaseFlags(sidecar, b.pubkey).receipt, true); + assert.ok(Array.isArray(sidecar.ackMessages)); + assert.strictEqual(sidecar.ackMessages[0].type, 'MessageReceipt'); + }); + + it('delivery sync filter tracks GroupChange and respects deliverySync.none', function () { + const { + isSyncTrackedType, + ensureDeliveryPending + } = require('../functions/contractMessageAccumulate'); + assert.strictEqual(isSyncTrackedType('GroupChange'), true); + assert.strictEqual(isSyncTrackedType('GroupChat'), true); + assert.strictEqual(isSyncTrackedType('MessageReceipt'), false); + assert.strictEqual(isSyncTrackedType('GroupJournalRequest'), false); + + const a = new Key(); + const genesis = { + signers: [a.pubkey], + readers: [a.pubkey], + primitives: { deliverySync: { mode: 'none' } } + }; + const change = signContractMessage(a, contractId, 'GroupChange', { + action: 'members.set', + members: [a.pubkey] + }); + const store = createMemoryStore(); + const res = ingestMessageBuffer(store, contractId, change.toBuffer(), { + origin: 'local', + genesis + }); + assert.strictEqual(res.accepted, true); + assert.strictEqual( + store.get('contractmessagecommits', res.entry.hash), + null, + 'deliverySync.none skips pending 2PC' + ); + assert.strictEqual(isSyncTrackedType('GroupChange', loadDoc(store, contractId), { genesis }), false); + + // include mode tracks only listed types + const includeDoc = { + genesis: normalizeGenesis({ + signers: [a.pubkey], + primitives: { deliverySync: { mode: 'include', types: ['GroupShare'] } } + }) + }; + assert.strictEqual(isSyncTrackedType('GroupChat', includeDoc), false); + assert.strictEqual(isSyncTrackedType('GroupShare', includeDoc), true); + assert.ok(ensureDeliveryPending); + }); + + it('threshold-1 GroupChangeProposal from a signer adopts member.add', function () { + const owner = new Key(); + const recruit = new Key(); + const store = createMemoryStore(); + const genesis = { signers: [owner.pubkey], threshold: 1 }; + const proposal = { + id: 'gprop-t1', + contractId, + action: 'member.add', + member: recruit.pubkey, + role: 'signer', + proposedBy: pubkeyXOnly(owner.pubkey), + createdAt: '2026-01-01T00:00:00.000Z', + threshold: 1 + }; + const res = ingestMessageBuffer( + store, + contractId, + signContractMessage(owner, contractId, 'GroupChangeProposal', proposal).toBuffer(), + { origin: 'mesh', genesis } + ); + assert.strictEqual(res.accepted, true); + const adopted = (res.tip.content.proposals || []).find((p) => p.id === 'gprop-t1'); + assert.ok(adopted); + assert.strictEqual(adopted.status, 'adopted'); + assert.ok(res.tip.content.members.includes(pubkeyXOnly(recruit.pubkey))); + assert.ok(res.tip.content.members.includes(pubkeyXOnly(owner.pubkey))); + assert.ok(res.tip.content.signers.includes(pubkeyXOnly(recruit.pubkey))); + }); + + it('threshold-2 proposal stays pending until a BIP340 vote', function () { + const a = new Key(); + const b = new Key(); + const recruit = new Key(); + const store = createMemoryStore(); + const genesis = { signers: [a.pubkey, b.pubkey], threshold: 2 }; + const proposal = { + id: 'gprop-t2', + contractId, + action: 'member.add', + member: recruit.pubkey, + role: 'signer', + proposedBy: pubkeyXOnly(a.pubkey), + createdAt: '2026-01-01T00:00:00.000Z', + threshold: 2 + }; + const first = ingestMessageBuffer( + store, + contractId, + signContractMessage(a, contractId, 'GroupChangeProposal', proposal).toBuffer(), + { origin: 'mesh', genesis } + ); + assert.strictEqual(first.accepted, true); + assert.strictEqual(first.tip.content.proposals[0].status, 'pending'); + assert.ok(!first.tip.content.members.includes(pubkeyXOnly(recruit.pubkey))); + + const signed = signProposalVote(b, proposal); + const vote = ingestMessageBuffer( + store, + contractId, + signContractMessage(b, contractId, 'GroupChangeVote', { + proposalId: proposal.id, + voter: pubkeyXOnly(b.pubkey), + signature: signed.signature + }).toBuffer(), + { origin: 'mesh', genesis } + ); + assert.strictEqual(vote.accepted, true); + assert.strictEqual(vote.tip.content.proposals[0].status, 'adopted'); + assert.ok(vote.tip.content.members.includes(pubkeyXOnly(recruit.pubkey))); + }); + + it('rejects GroupChangeVote with voter/AMP mismatch or bad signature', function () { + const a = new Key(); + const b = new Key(); + const store = createMemoryStore(); + const genesis = { signers: [a.pubkey, b.pubkey], threshold: 2 }; + const proposal = { + id: 'gprop-bad', + contractId, + action: 'member.add', + member: new Key().pubkey, + proposedBy: pubkeyXOnly(a.pubkey), + createdAt: '2026-01-01T00:00:00.000Z', + threshold: 2 + }; + ingestMessageBuffer( + store, + contractId, + signContractMessage(a, contractId, 'GroupChangeProposal', proposal).toBuffer(), + { origin: 'mesh', genesis } + ); + const mismatch = ingestMessageBuffer( + store, + contractId, + signContractMessage(b, contractId, 'GroupChangeVote', { + proposalId: proposal.id, + voter: pubkeyXOnly(a.pubkey), + signature: 'ab'.repeat(64) + }).toBuffer(), + { origin: 'mesh', genesis } + ); + assert.strictEqual(mismatch.accepted, false); + assert.match(mismatch.error, /voter must match/i); + + const badSig = ingestMessageBuffer( + store, + contractId, + signContractMessage(b, contractId, 'GroupChangeVote', { + proposalId: proposal.id, + voter: pubkeyXOnly(b.pubkey), + signature: 'ab'.repeat(64) + }).toBuffer(), + { origin: 'mesh', genesis } + ); + assert.strictEqual(badSig.accepted, false); + assert.match(badSig.error, /invalid vote/i); + }); + + it('persists genesis from CONTRACT_PUBLISH so GroupChange needs no meta.genesis', function () { + const owner = new Key(); + const store = createMemoryStore(); + const definition = { + name: 'ArcGenesisSeed', + members: { signers: [owner.pubkey], threshold: 1 }, + primitives: { messageTypes: ['GroupChange', 'GroupChat'] } + }; + const pub = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(definition)]).signWithKey(owner); + const seeded = ingestContractPublishBuffer(store, pub.toBuffer()); + assert.strictEqual(seeded.accepted, true); + assert.ok(seeded.contractId); + const change = ingestMessageBuffer( + store, + seeded.contractId, + signContractMessage(owner, seeded.contractId, 'GroupChange', { + action: 'member.add', + member: owner.pubkey + }).toBuffer(), + { origin: 'mesh' } + ); + assert.strictEqual(change.accepted, true, change.error); + assert.ok(change.tip.content.members.includes(pubkeyXOnly(owner.pubkey))); + }); + + it('does not let meta.genesis replace the signed CONTRACT_PUBLISH body', function () { + const owner = new Key(); + const attacker = new Key(); + const store = createMemoryStore(); + const definition = { + name: 'SignedGenesisAuthoritative', + members: { signers: [owner.pubkey], threshold: 1 } + }; + const pub = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(definition)]).signWithKey(owner); + const seeded = ingestContractPublishBuffer(store, pub.toBuffer(), { + genesis: { signers: [attacker.pubkey], threshold: 1 } + }); + assert.strictEqual(seeded.accepted, true, seeded.error); + const doc = loadDoc(store, seeded.contractId); + const expected = pubkeyXOnly(owner.pubkey); + const unexpected = pubkeyXOnly(attacker.pubkey); + assert.ok(doc.genesis.signers.includes(expected)); + assert.ok(!doc.genesis.signers.includes(unexpected)); + }); + + it('does not let meta.signers replace the signed CONTRACT_PUBLISH body', function () { + const owner = new Key(); + const attacker = new Key(); + const store = createMemoryStore(); + const definition = { + name: 'SignedGenesisSignersAuthoritative', + members: { signers: [owner.pubkey], threshold: 1 } + }; + const pub = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(definition)]).signWithKey(owner); + const seeded = ingestContractPublishBuffer(store, pub.toBuffer(), { + signers: [attacker.pubkey], + threshold: 1 + }); + assert.strictEqual(seeded.accepted, true, seeded.error); + const doc = loadDoc(store, seeded.contractId); + const expected = pubkeyXOnly(owner.pubkey); + const unexpected = pubkeyXOnly(attacker.pubkey); + assert.ok(doc.genesis.signers.includes(expected)); + assert.ok(!doc.genesis.signers.includes(unexpected)); + }); + + it('second GroupChange reuses stored genesis without meta.signers', function () { + const owner = new Key(); + const other = new Key(); + const store = createMemoryStore(); + const genesis = { signers: [owner.pubkey] }; + const first = ingestMessageBuffer( + store, + contractId, + signContractMessage(owner, contractId, 'GroupChange', { + action: 'member.add', + member: owner.pubkey + }).toBuffer(), + { origin: 'mesh', genesis } + ); + assert.strictEqual(first.accepted, true); + const second = ingestMessageBuffer( + store, + contractId, + signContractMessage(owner, contractId, 'GroupChange', { + action: 'member.add', + member: other.pubkey + }).toBuffer(), + { origin: 'mesh' } + ); + assert.strictEqual(second.accepted, true, second.error); + assert.ok(second.tip.content.members.includes(pubkeyXOnly(other.pubkey))); + }); + + it('journal cap drops GroupChat by hash but keeps GroupChange', function () { + const owner = new Key(); + const store = createMemoryStore(); + const genesis = { signers: [owner.pubkey] }; + const change = ingestMessageBuffer( + store, + contractId, + signContractMessage(owner, contractId, 'GroupChange', { + action: 'members.set', + members: [owner.pubkey] + }).toBuffer(), + { origin: 'mesh', genesis, maxJournalEntries: 3 } + ); + assert.strictEqual(change.accepted, true); + for (let i = 0; i < 5; i++) { + ingestMessageBuffer( + store, + contractId, + signContractMessage(owner, contractId, 'GroupChat', { + body: `m${i}`, + author: owner.pubkey, + ts: `2026-01-0${i + 1}T00:00:00.000Z` + }).toBuffer(), + { origin: 'mesh', maxJournalEntries: 3 } + ); + } + const doc = loadDoc(store, contractId); + assert.ok(doc.entries.length <= 3); + assert.ok(doc.entries.some((e) => e.type === 'GroupChange')); + assert.ok(doc.content.members.includes(pubkeyXOnly(owner.pubkey))); + }); + + it('prefers genesis journal cap over peer-local meta.maxJournalEntries', function () { + const genesis = { + primitives: { maxJournalEntries: 10 } + }; + assert.strictEqual( + resolveMaxJournalEntries({ genesis, maxJournalEntries: 8 }, { maxJournalEntries: 3 }), + 10 + ); + assert.strictEqual( + resolveMaxJournalEntries({ maxJournalEntries: 8 }, { maxJournalEntries: 3 }), + 8 + ); + assert.strictEqual( + resolveMaxJournalEntries({}, { maxJournalEntries: 3 }), + 3 + ); + }); +}); + +describe('@fabric/core contractMessageCommit', function () { + it('tracks per-reader received and receipt phases', function () { + const a = new Key(); + const b = new Key(); + const record = createPending({ + id: 'm1', + contractId: 'c'.repeat(64), + readers: [a.pubkey, b.pubkey] + }); + assert.deepStrictEqual(phaseFlags(record, a.pubkey), { received: false, receipt: false }); + markReceived(record, a.pubkey); + assert.strictEqual(phaseFlags(record, a.pubkey).received, true); + assert.strictEqual(aggregatePhaseFlags(record).received, false); + markReceived(record, b.pubkey); + assert.strictEqual(aggregatePhaseFlags(record).received, true); + assert.strictEqual(allPhasesComplete(record), false); + const msg = receiptSigningMessage(record.id); + const sigA = Buffer.from(a.signSchnorr(msg)).toString('hex'); + const sigB = Buffer.from(b.signSchnorr(msg)).toString('hex'); + markReceipt(record, a.pubkey, null, sigA); + markReceipt(record, b.pubkey, null, sigB); + assert.strictEqual(allPhasesComplete(record), true); + assert.deepStrictEqual(aggregatePhaseFlags(record), { received: true, receipt: true }); + assert.strictEqual(pendingForRelay([record]).length, 0); + }); + + it('rejects mark for non-reader', function () { + const a = new Key(); + const outsider = new Key(); + const record = createPending({ + id: 'm2', + contractId: 'c'.repeat(64), + readers: [a.pubkey] + }); + assert.throws(() => markReceived(record, outsider.pubkey), /reader set/); + }); + + it('rejects markReceipt without a valid BIP340 receiptSig', function () { + const a = new Key(); + const record = createPending({ + id: 'm3', + contractId: 'c'.repeat(64), + readers: [a.pubkey] + }); + markReceived(record, a.pubkey); + assert.throws(() => markReceipt(record, a.pubkey), /receiptSig required/); + assert.throws(() => markReceipt(record, a.pubkey, null, 'ab'.repeat(32)), /invalid receiptSig/); + const wrong = Buffer.from(a.signSchnorr(receiptSigningMessage('other'))).toString('hex'); + assert.throws(() => markReceipt(record, a.pubkey, null, wrong), /invalid receiptSig/); + }); + + it('markReceived initializes peers map when missing (legacy records)', function () { + const a = new Key(); + const x = require('../functions/groupChatSeal').pubkeyXOnly(a.pubkey); + const record = { + id: 'm4', + contractId: 'c'.repeat(64), + readers: [x], + peers: null + }; + markReceived(record, a.pubkey); + assert.ok(record.peers && record.peers[x] && record.peers[x].receivedAt); + assert.throws(() => markReceived(record, new Key().pubkey), /reader set|invalid reader/); + }); + + it('preserves public reader wildcard and fail-closes empty delivery-ack authz', function () { + const { + normalizePubkeyList, + authorAllowedByReaders, + authorizeIngest + } = require('../functions/contractMessageAccumulate'); + const cid = 'c'.repeat(64); + const readers = normalizePubkeyList(['*', '02' + 'ab'.repeat(32)]); + assert.ok(readers.includes('*')); + assert.strictEqual(authorAllowedByReaders(['*'], 'cd'.repeat(32)), true); + assert.strictEqual(authorAllowedByReaders([], 'cd'.repeat(32)), false); + + const outsider = new Key(); + const doc = { + id: cid, + genesis: { signers: [], readers: [] }, + content: { members: [], signers: [] }, + entries: [] + }; + const denied = authorizeIngest(doc, 'MessageReceived', pubkeyXOnly(outsider.pubkey), {}, { + messageId: 'ab'.repeat(32) + }); + assert.strictEqual(denied.ok, false); + assert.match(denied.error, /fail closed/i); + + const publicDoc = { + id: cid, + genesis: { signers: [], readers: ['*'] }, + content: { members: [], signers: [] }, + entries: [] + }; + const allowed = authorizeIngest(publicDoc, 'MessageReceived', pubkeyXOnly(outsider.pubkey), {}, { + messageId: 'ab'.repeat(32) + }); + assert.strictEqual(allowed.ok, true); + }); +}); + +const { + enqueueMessageBuffer, + listQueuedMessages, + pendingForDelivery, + markDelivered, + entryHexList, + COLLECTION, + DEFAULT_MAX_ENTRIES, + ABSOLUTE_MAX_ENTRIES, + clampMaxEntries +} = require('../functions/contractMessageQueue'); + +describe('@fabric/core contractMessageQueue', function () { + const contractId = 'd'.repeat(64); + + it('enqueues opaque bytes idempotently by message hash', function () { + const author = new Key(); + const msg = signContractMessage(author, contractId, 'GroupChat', { + body: 'queued', + author: author.pubkey + }); + const buf = msg.toBuffer(); + const store = createMemoryStore(); + const a = enqueueMessageBuffer(store, contractId, buf, { origin: 'mesh' }); + assert.strictEqual(a.accepted, true); + assert.ok(a.entry && a.entry.hex); + assert.strictEqual(a.entry.type, 'GroupChat'); + const b = enqueueMessageBuffer(store, contractId, buf, { origin: 'mesh' }); + assert.strictEqual(b.duplicate, true); + assert.strictEqual(listQueuedMessages(store, contractId).length, 1); + assert.strictEqual(store.get(COLLECTION, contractId).entries.length, 1); + }); + + it('tracks per-peer delivery without altering hex', function () { + const author = new Key(); + const msg = signContractMessage(author, contractId, 'GroupChat', { body: 'later' }); + const store = createMemoryStore(); + const enq = enqueueMessageBuffer(store, contractId, msg.toBuffer()); + const peer = 'peer-a'; + assert.strictEqual(pendingForDelivery(store, contractId, peer).length, 1); + const marked = markDelivered(store, contractId, enq.entry.hash, peer); + assert.strictEqual(marked.ok, true); + assert.strictEqual(pendingForDelivery(store, contractId, peer).length, 0); + assert.strictEqual(listQueuedMessages(store, contractId, { includeDelivered: true }).length, 1); + assert.strictEqual( + listQueuedMessages(store, contractId, { includeDelivered: true })[0].hex, + enq.entry.hex + ); + }); + + it('queue hex folds via accumulate with origin queue', function () { + const author = new Key(); + const genesis = { signers: [author.pubkey] }; + const msg = signContractMessage(author, contractId, 'GroupChat', { + body: 'from-queue', + author: author.pubkey, + ts: '2026-01-02T00:00:00.000Z' + }); + const qStore = createMemoryStore(); + enqueueMessageBuffer(qStore, contractId, msg.toBuffer(), { origin: 'mesh' }); + const hexes = entryHexList(listQueuedMessages(qStore, contractId)); + assert.strictEqual(hexes.length, 1); + + const accStore = createMemoryStore(); + const result = ingestMessageBuffer(accStore, contractId, hexes[0], { origin: 'queue', genesis }); + assert.strictEqual(result.accepted, true); + assert.strictEqual(result.tip.clock, 1); + }); + + it('rejects unsigned frames at enqueue', function () { + const store = createMemoryStore(); + const unsigned = Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ + contract: contractId, + type: 'GroupChat', + object: { body: 'no-sig' } + })]); + const bad = enqueueMessageBuffer(store, contractId, unsigned.toBuffer()); + assert.strictEqual(bad.accepted, false); + assert.match(bad.error, /invalid signature/i); + }); + + it('clamps maxEntries away from Infinity', function () { + // Non-finite values fall back to the default (not ABSOLUTE_MAX). + assert.strictEqual(clampMaxEntries(Number.POSITIVE_INFINITY), DEFAULT_MAX_ENTRIES); + assert.strictEqual(clampMaxEntries(1e999), DEFAULT_MAX_ENTRIES); + assert.strictEqual(clampMaxEntries(0), DEFAULT_MAX_ENTRIES); + assert.strictEqual(clampMaxEntries(12), 12); + assert.strictEqual(clampMaxEntries(ABSOLUTE_MAX_ENTRIES + 50), ABSOLUTE_MAX_ENTRIES); + }); + + it('trims to maxEntries preferring delivered rows', function () { + const author = new Key(); + const store = createMemoryStore(); + const hashes = []; + for (let i = 0; i < 5; i++) { + const msg = signContractMessage(author, contractId, 'GroupChat', { + body: `m${i}`, + ts: `2026-01-0${i + 1}T00:00:00.000Z` + }); + const r = enqueueMessageBuffer(store, contractId, msg.toBuffer(), { maxEntries: 3 }); + assert.strictEqual(r.accepted, true); + hashes.push(r.entry.hash); + if (i < 2) markDelivered(store, contractId, r.entry.hash, 'early-peer'); + } + const rows = listQueuedMessages(store, contractId, { includeDelivered: true }); + assert.ok(rows.length <= 3); + assert.ok(rows.length >= 1); + assert.ok(DEFAULT_MAX_ENTRIES >= 3); + }); +}); + +const Machine = require('../types/machine'); +const Program = require('../types/program'); +const { + resolveSpend, + buildWithdrawalRequest, + validateWithdrawalRequest, + bindProgramRunToTip, + programMetaFromTip, + requiresProgramBind +} = require('../functions/contractSpend'); +const { + assertMachineRunMatches, + validateProgramRunBinding +} = require('../functions/contractProgramBind'); + +describe('contractProgramBind / Machine ↔ redeemable withdrawals', function () { + const blockHash = 'ab'.repeat(32); + + async function runOpcodeProgram () { + const prog = Program.from({ + language: 'fabric-opcodes', + steps: ['OP_TRUE'] + }); + prog.compile(); + const machine = new Machine({ deterministic: true }); + machine.define('OP_TRUE', () => true); + const run = await machine.runProgram(prog); + assert.strictEqual(run.ok, true); + assert.ok(run.runCommitmentHex); + const match = assertMachineRunMatches({ program: prog, run }); + assert.strictEqual(match.ok, true); + assert.strictEqual(match.runCommitmentHex, run.runCommitmentHex); + return { prog, run, match }; + } + + it('assertMachineRunMatches prefers run.trace when stack was pre-seeded', async function () { + const prog = Program.from({ + language: 'fabric-opcodes', + steps: ['OP_TRUE'] + }); + prog.compile(); + const machine = new Machine({ deterministic: true }); + machine.define('OP_TRUE', () => true); + machine.stack.push({ preseed: true }); + const run = await machine.runProgram(prog); + assert.strictEqual(run.ok, true); + assert.ok(run.stack.length > run.trace.length); + const match = assertMachineRunMatches({ program: prog, run }); + assert.strictEqual(match.ok, true, match.error); + assert.strictEqual(match.runCommitmentHex, run.runCommitmentHex); + }); + + it('Machine runCommitmentHex is stable and binds into tip without changing P2TR', async function () { + const a = new Key(); + const { prog, run, match } = await runOpcodeProgram(); + const again = await (async () => { + const m = new Machine({ deterministic: true }); + m.define('OP_TRUE', () => true); + return m.runProgram(prog); + })(); + assert.strictEqual(again.runCommitmentHex, run.runCommitmentHex); + + const tip0 = { + contractId: 'c'.repeat(64), + clock: 1, + stateDigest: '11'.repeat(32), + bitcoinBlockHash: blockHash, + content: { + signers: [a.pubkey], + members: [a.pubkey], + threshold: 1 + } + }; + const tip1 = bindProgramRunToTip(tip0, { + programHash: match.programHash, + runCommitmentHex: match.runCommitmentHex, + programId: prog.programId, + clock: tip0.clock + }); + assert.ok(tip1.content.program); + assert.strictEqual(programMetaFromTip(tip1).programHash, match.programHash); + + const spend0 = resolveSpend({ + genesis: { + members: { signers: [a.pubkey], threshold: 1 }, + spendPolicy: { + validators: [a.pubkey], + threshold: 1, + publisher: a.pubkey, + csvBlocks: 144 + } + }, + tip: tip0, + overrides: { network: 'regtest' } + }); + const spend1 = resolveSpend({ + genesis: { + members: { signers: [a.pubkey], threshold: 1 }, + spendPolicy: { + validators: [a.pubkey], + threshold: 1, + publisher: a.pubkey, + csvBlocks: 144 + } + }, + tip: tip1, + overrides: { network: 'regtest' } + }); + assert.strictEqual(spend1.address, spend0.address); + }); + + it('withdrawal requires matching program digests when tip seals a run', async function () { + const a = new Key(); + const { match } = await runOpcodeProgram(); + let tip = { + contractId: 'd'.repeat(64), + clock: 2, + stateDigest: '22'.repeat(32), + bitcoinBlockHash: blockHash, + content: { signers: [a.pubkey], threshold: 1 } + }; + tip = bindProgramRunToTip(tip, { + programHash: match.programHash, + runCommitmentHex: match.runCommitmentHex + }); + + const missing = validateWithdrawalRequest({ + stateDigest: tip.stateDigest, + bitcoinBlockHash: tip.bitcoinBlockHash, + destinationAddress: 'bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3l9y0' + }, tip); + assert.strictEqual(missing.ok, false); + assert.match(missing.error, /programHash|runCommitment/i); + + const stale = validateWithdrawalRequest({ + stateDigest: tip.stateDigest, + bitcoinBlockHash: tip.bitcoinBlockHash, + destinationAddress: 'bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3l9y0', + programHash: match.programHash, + runCommitmentHex: 'ff'.repeat(32) + }, tip); + assert.strictEqual(stale.ok, false); + assert.match(stale.error, /runCommitmentHex/); + + const okReq = buildWithdrawalRequest({ + tip, + contractId: tip.contractId, + destinationAddress: 'bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3l9y0', + feeSats: 500 + }); + assert.strictEqual(okReq.programHash, match.programHash); + assert.strictEqual(okReq.runCommitmentHex, match.runCommitmentHex); + assert.strictEqual(validateWithdrawalRequest(okReq, tip).ok, true); + }); + + it('genesis with fabric.program interface requires tip-sealed run', function () { + assert.strictEqual(requiresProgramBind({ + interfaces: ['arc.core', 'fabric.program'], + primitives: { opcodes: [] } + }), true); + assert.strictEqual(requiresProgramBind({ + primitives: { opcodes: ['OP_TRUE'] } + }), true); + assert.strictEqual(requiresProgramBind({ + spendPolicy: { requireProgramRun: true } + }), true); + assert.strictEqual(requiresProgramBind({ name: 'plain' }), false); + + const check = validateProgramRunBinding({ + object: { + stateDigest: '11'.repeat(32), + bitcoinBlockHash: blockHash, + destinationAddress: 'bcrt1q' + }, + tip: { stateDigest: '11'.repeat(32), bitcoinBlockHash: blockHash, content: {} }, + genesis: { interfaces: ['fabric.program'] } + }); + assert.strictEqual(check.ok, false); + assert.match(check.error, /tip\.content\.program/); + }); +}); diff --git a/tests/beaconContractDefinition.test.js b/tests/beaconContractDefinition.test.js deleted file mode 100644 index e63c57dc9..000000000 --- a/tests/beaconContractDefinition.test.js +++ /dev/null @@ -1,88 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const Key = require('../types/key'); -const { - BEACON_CONTRACT_NAME, - BEACON_MESSAGE_TYPES, - beaconContractDefinition, - beaconContractId, - beaconRedeploySteps, - normalizeValidatorPubkeys -} = require('../functions/beaconContractDefinition'); -const { normalizeArcGenesis, resolveSpend } = require('../functions/contractSpend'); -const { buildFederationVaultFromPolicy, DEFAULT_CSV_BLOCKS } = require('../functions/contractTaproot'); - -describe('beaconContractDefinition', function () { - it('builds network-agnostic ARC genesis with stable Actor id', function () { - const a = new Key({ private: '1111111111111111111111111111111111111111111111111111111111111111' }); - const b = new Key({ private: '2222222222222222222222222222222222222222222222222222222222222222' }); - const def1 = beaconContractDefinition({ - validators: [b.pubkey, a.pubkey], - threshold: 1, - publisher: a.pubkey - }); - const def2 = beaconContractDefinition({ - validators: [a.pubkey, b.pubkey], - threshold: 1, - publisher: a.pubkey - }); - assert.strictEqual(def1.name, BEACON_CONTRACT_NAME); - assert.ok(!Object.prototype.hasOwnProperty.call(def1.spendPolicy, 'network')); - assert.ok(!Object.prototype.hasOwnProperty.call(def1, 'bitcoinAnchor')); - assert.deepStrictEqual(def1.primitives.messageTypes, BEACON_MESSAGE_TYPES.slice()); - assert.strictEqual(beaconContractId(def1), beaconContractId(def2)); - assert.strictEqual(def1.spendPolicy.csvBlocks, DEFAULT_CSV_BLOCKS); - }); - - it('normalizeValidatorPubkeys sorts and dedupes', function () { - const a = new Key({ private: '3333333333333333333333333333333333333333333333333333333333333333' }); - const list = normalizeValidatorPubkeys([a.pubkey, a.pubkey, String(a.pubkey).toUpperCase()]); - assert.strictEqual(list.length, 1); - }); - - it('normalizeValidatorPubkeys drops invalid x-only points', function () { - const a = new Key({ private: '6666666666666666666666666666666666666666666666666666666666666666' }); - const invalidXOnly = '00'.repeat(32); // not a lift-able curve x - const list = normalizeValidatorPubkeys([invalidXOnly, a.pubkey, 'not-a-key']); - assert.deepStrictEqual(list, [String(a.pubkey).toLowerCase()]); - }); - - it('spendAddress matches federation vault on each network overlay', function () { - const a = new Key({ private: '4444444444444444444444444444444444444444444444444444444444444444' }); - const b = new Key({ private: '5555555555555555555555555555555555555555555555555555555555555555' }); - const def = beaconContractDefinition({ - validators: [a.pubkey, b.pubkey], - threshold: 2, - publisher: a.pubkey, - csvBlocks: 144 - }); - const arc = normalizeArcGenesis(def); - for (const network of ['regtest', 'signet', 'mainnet']) { - const spend = resolveSpend({ - genesis: arc, - tip: { - content: { members: arc.members.signers.map((p) => String(p).replace(/^0[23]/i, '')) }, - stateDigest: 'aa'.repeat(32), - bitcoinBlockHash: 'bb'.repeat(32) - }, - overrides: { network } - }); - const vault = buildFederationVaultFromPolicy({ - validatorPubkeysHex: arc.spendPolicy.validators, - threshold: 2, - networkName: network, - publisher: a.pubkey, - csvBlocks: 144 - }); - assert.strictEqual(spend.address, vault.address, network); - } - }); - - it('exposes redeploy steps', function () { - const steps = beaconRedeploySteps(); - assert.ok(Array.isArray(steps)); - assert.ok(steps.length >= 4); - assert.ok(steps.some((s) => /FABRIC_BEACON_RESET_NETWORK/.test(s))); - }); -}); diff --git a/tests/beaconNetworkGuard.test.js b/tests/beaconNetworkGuard.test.js deleted file mode 100644 index cae772247..000000000 --- a/tests/beaconNetworkGuard.test.js +++ /dev/null @@ -1,102 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const { - BEACON_NETWORK_PATH, - BEACON_CHAIN_PATH, - SIDECHAIN_STATE_PATH, - normalizeBitcoinNetwork, - readNetworkBinding, - writeNetworkBinding, - assertBeaconNetworkCompatible, - resetBeaconNetworkStores, - ensureBeaconNetworkBinding, - hasBeaconOrSidechainData -} = require('../functions/beaconNetworkGuard'); - -function memoryFs (seed = {}) { - const data = Object.assign(Object.create(null), seed); - return { - _data: data, - readFile (p) { - return Object.prototype.hasOwnProperty.call(data, p) ? data[p] : null; - }, - writeFile (p, v) { - data[p] = typeof v === 'string' ? v : JSON.stringify(v); - }, - async publish (p, v) { - data[p] = typeof v === 'string' ? v : JSON.stringify(v); - } - }; -} - -describe('beaconNetworkGuard', function () { - it('normalizes bitcoin / testnet3 labels', function () { - assert.strictEqual(normalizeBitcoinNetwork('Bitcoin'), 'mainnet'); - assert.strictEqual(normalizeBitcoinNetwork('testnet3'), 'testnet'); - assert.strictEqual(normalizeBitcoinNetwork('signet'), 'signet'); - }); - - it('allows fresh stores and binds network', async function () { - const fs = memoryFs(); - const ensured = await ensureBeaconNetworkBinding(fs, 'regtest'); - assert.strictEqual(ensured.ok, true); - assert.strictEqual(ensured.binding.network, 'regtest'); - assert.ok(readNetworkBinding(fs)); - assert.strictEqual(readNetworkBinding(fs).network, 'regtest'); - }); - - it('rejects mismatch without reset', async function () { - const fs = memoryFs(); - await writeNetworkBinding(fs, 'regtest'); - fs.writeFile(BEACON_CHAIN_PATH, JSON.stringify({ messages: [{ id: '1' }] })); - const check = assertBeaconNetworkCompatible(fs, 'signet'); - assert.strictEqual(check.ok, false); - assert.strictEqual(check.code, 'NETWORK_MISMATCH'); - const blocked = await ensureBeaconNetworkBinding(fs, 'signet'); - assert.strictEqual(blocked.ok, false); - }); - - it('resets and rebinds when FABRIC_BEACON_RESET_NETWORK=1', async function () { - const fs = memoryFs(); - await writeNetworkBinding(fs, 'regtest'); - fs.writeFile(BEACON_CHAIN_PATH, JSON.stringify({ messages: [{ id: 'old' }] })); - const prev = process.env.FABRIC_BEACON_RESET_NETWORK; - process.env.FABRIC_BEACON_RESET_NETWORK = '1'; - try { - const ensured = await ensureBeaconNetworkBinding(fs, 'signet'); - assert.strictEqual(ensured.ok, true); - assert.strictEqual(ensured.binding.network, 'signet'); - assert.ok(ensured.reset); - assert.ok(ensured.reset.cleared.includes(BEACON_CHAIN_PATH)); - const chain = JSON.parse(fs.readFile(BEACON_CHAIN_PATH)); - assert.deepStrictEqual(chain.messages, []); - } finally { - if (prev == null) delete process.env.FABRIC_BEACON_RESET_NETWORK; - else process.env.FABRIC_BEACON_RESET_NETWORK = prev; - } - }); - - it('flags unbound legacy stores with data', function () { - const fs = memoryFs(); - fs.writeFile(BEACON_CHAIN_PATH, JSON.stringify({ messages: [{ id: 'legacy' }] })); - assert.strictEqual(hasBeaconOrSidechainData(fs), true); - const check = assertBeaconNetworkCompatible(fs, 'mainnet'); - assert.strictEqual(check.ok, false); - assert.strictEqual(check.code, 'NETWORK_UNBOUND'); - }); - - it('does not treat empty sidechain content (clock 0) as used data', function () { - const fs = memoryFs(); - fs.writeFile(SIDECHAIN_STATE_PATH, JSON.stringify({ content: {}, clock: 0 })); - assert.strictEqual(hasBeaconOrSidechainData(fs), false); - }); - - it('resetBeaconNetworkStores clears listed paths', async function () { - const fs = memoryFs(); - fs.writeFile(BEACON_CHAIN_PATH, JSON.stringify({ messages: [1] })); - fs.writeFile(BEACON_NETWORK_PATH, '{"network":"regtest"}'); - const { cleared } = await resetBeaconNetworkStores(fs); - assert.ok(cleared.length >= 1); - }); -}); diff --git a/tests/bitcoin/block.js b/tests/bitcoin/block.js index 857faab43..82dfe1297 100644 --- a/tests/bitcoin/block.js +++ b/tests/bitcoin/block.js @@ -15,7 +15,7 @@ describe('@fabric/core/types/bitcoin', function () { assert.strictEqual(block.data.network, 'regtest'); }); - it('builds a consensus block from transactions', function () { + it('toBitcoinBlock wraps state.transactions through the injected consensus.Block', function () { class FakeConsensusBlock { constructor (opts = {}) { this.opts = opts; diff --git a/tests/bitcoin/service.deep.js b/tests/bitcoin/service.deep.js index 9db25591b..c74e50c21 100644 --- a/tests/bitcoin/service.deep.js +++ b/tests/bitcoin/service.deep.js @@ -193,6 +193,65 @@ describe('@fabric/core/services/bitcoin (deep coverage)', function () { assert.ok(psbt && typeof psbt.toBase64 === 'function'); }); + it('_buildPSBT orders vin/vout with BIP-69', async function () { + const addr = 'bcrt1pr4wctwfz0uznz86ash62jret9gq8ysg82zlzl9kdmuvq066pjcmsa0plmz'; + const btc = new Bitcoin({ network: 'regtest', mode: 'rpc' }); + btc._makeRPCRequest = async function (method) { + if (method === 'gettxout') return { value: 1.0, scriptPubKey: { hex: '00' } }; + throw new Error(method); + }; + const highTxid = 'ff'.repeat(32); + const lowTxid = 'aa'.repeat(32); + const psbt = await btc._buildPSBT({ + inputs: [ + { txid: highTxid, vout: 1 }, + { txid: lowTxid, vout: 0 } + ], + outputs: [ + { address: addr, value: 9000 }, + { address: addr, value: 1000 } + ] + }); + const id0 = Buffer.from(psbt.txInputs[0].hash).reverse().toString('hex'); + const id1 = Buffer.from(psbt.txInputs[1].hash).reverse().toString('hex'); + assert.strictEqual(id0, lowTxid); + assert.strictEqual(psbt.txInputs[0].index, 0); + assert.strictEqual(id1, highTxid); + assert.strictEqual(psbt.txInputs[1].index, 1); + const v0 = psbt.txOutputs[0].value; + const v1 = psbt.txOutputs[1].value; + assert.strictEqual(Number(v0), 1000); + assert.strictEqual(Number(v1), 9000); + }); + + it('throws on fractional satoshi output values', async function () { + const addr = 'bcrt1pr4wctwfz0uznz86ash62jret9gq8ysg82zlzl9kdmuvq066pjcmsa0plmz'; + const btc = new Bitcoin({ network: 'regtest', mode: 'rpc' }); + btc._makeRPCRequest = async () => null; + await assert.rejects( + () => btc._buildPSBT({ + inputs: [], + outputs: [{ address: addr, value: 1.5 }] + }), + /integer satoshi/ + ); + }); + + it('throws on null, false, or blank output values instead of coercing to zero', async function () { + const addr = 'bcrt1pr4wctwfz0uznz86ash62jret9gq8ysg82zlzl9kdmuvq066pjcmsa0plmz'; + const btc = new Bitcoin({ network: 'regtest', mode: 'rpc' }); + btc._makeRPCRequest = async () => null; + for (const value of [null, false, '']) { + await assert.rejects( + () => btc._buildPSBT({ + inputs: [], + outputs: [{ address: addr, value }] + }), + /integer satoshi/ + ); + } + }); + it('throws on invalid output address', async function () { const btc = new Bitcoin({ network: 'regtest', mode: 'rpc', debug: true }); btc._makeRPCRequest = async () => null; @@ -744,8 +803,17 @@ describe('@fabric/core/services/bitcoin (deep coverage)', function () { for (const [ev, h] of Object.entries(btc._errorHandlers)) { process.on(ev, h); } + const before = {}; + for (const ev of Object.keys(btc._errorHandlers)) { + before[ev] = process.listenerCount(ev); + } + const handlers = { ...btc._errorHandlers }; await btc.cleanup(); - assert.ok(true); + for (const [ev, h] of Object.entries(handlers)) { + assert.ok(!process.listeners(ev).includes(h), `${ev} handler must be removed`); + assert.ok(process.listenerCount(ev) <= before[ev]); + assert.strictEqual(btc._errorHandlers[ev], null); + } }); }); diff --git a/tests/bitcoin/service.js b/tests/bitcoin/service.js index f4f36be12..4351c20fa 100644 --- a/tests/bitcoin/service.js +++ b/tests/bitcoin/service.js @@ -577,12 +577,19 @@ describe('@fabric/core/services/bitcoin', function () { it('tolerates malformed BitcoinBlockHash payload content', async function () { const btc = new Bitcoin({ network: 'regtest', mode: 'rpc' }); - // Should be ignored by internal try/catch. + const errors = []; + let committed = 0; + let blocks = 0; + btc.on('error', (msg) => errors.push(String(msg))); + btc.on('block', () => { blocks++; }); + btc.commit = () => { committed++; }; await btc._handleZMQMessage({ type: 'BitcoinBlockHash', data: Buffer.from('not-json', 'utf8') }); - assert.ok(true); + assert.strictEqual(errors.length, 0, 'malformed block-hash JSON must not emit error'); + assert.strictEqual(committed, 0, 'malformed payload must not commit tip state'); + assert.strictEqual(blocks, 0, 'malformed payload must not emit block'); }); it('swallows internal RPC failures while processing known ZMQ topics', async function () { diff --git a/tests/blindedExecutionCircuit.test.js b/tests/blindedExecutionCircuit.test.js deleted file mode 100644 index 83a051c15..000000000 --- a/tests/blindedExecutionCircuit.test.js +++ /dev/null @@ -1,394 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const bitcoin = require('bitcoinjs-lib'); -const Key = require('../types/key'); -const Message = require('../types/message'); -const Machine = require('../types/machine'); -const Program = require('../types/program'); -const { - buildContractProposalPayload -} = require('../functions/contractProposal'); -const { - composeGarblerPublish, - recordProposalDecision, - finalizeBlindedExecution, - bindBlindedExecutionToHashlock, - decisionSigningMessage, - HASHLOCK_LEAF_ID, - DECISION_SIGNING_VERSION -} = require('../functions/blindedExecutionCircuit'); -const { toAddress, synthesizeDefaultLadder } = require('../functions/contractTaproot'); - -describe('functions/blindedExecutionCircuit', function () { - const keys = []; - before(function () { - for (let i = 0; i < 3; i++) keys.push(new Key()); - }); - function pk (i) { return keys[i].pubkey; } - function signDecision (keyIndex, session, proposalPayload, decision, at) { - const merkle = proposalPayload.chain && proposalPayload.chain.merkleRoot - ? String(proposalPayload.chain.merkleRoot).toLowerCase() - : ''; - const msg = decisionSigningMessage({ - sessionId: session.circuitId, - proposalMerkleRoot: merkle, - decision, - at - }); - return Buffer.from(keys[keyIndex].signSchnorr(msg)).toString('hex'); - } - - it('composeGarblerPublish requires an evaluator party and is digest-stable', function () { - assert.throws(() => composeGarblerPublish({ - network: 'regtest', - parties: [], - state: { v: 1 } - }), /at least one evaluator/); - - const a = composeGarblerPublish({ - name: 'hashlock-demo', - network: 'regtest', - garbler: pk(0), - parties: [pk(1)], - state: { balance: 1 }, - program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } - }); - const b = composeGarblerPublish({ - name: 'hashlock-demo', - network: 'regtest', - garbler: pk(0), - parties: [pk(1)], - state: { balance: 1 }, - program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } - }); - - assert.strictEqual(a.version, 1); - assert.ok(a.circuitId && /^[0-9a-f]{64}$/.test(a.circuitId)); - assert.strictEqual(a.programHash, b.programHash); - assert.strictEqual(a.stateDigest, b.stateDigest); - assert.strictEqual(a.circuitCommitment, b.circuitCommitment); - assert.strictEqual(a.roles.garbler, pk(0).toLowerCase()); - assert.deepStrictEqual(a.roles.evaluators, [pk(1).toLowerCase()]); - assert.strictEqual(a.definition['@type'], 'FabricContract'); - assert.ok(a.definition.interfaces.includes('fabric.blinded-execution')); - assert.strictEqual(a.definition.blindedExecution.circuitCommitment, a.circuitCommitment); - assert.strictEqual(a.finalized, false); - assert.strictEqual(a.coordinationRoot, null); - }); - - it('composeGarblerPublish excludes garbler from evaluator set', function () { - assert.throws(() => composeGarblerPublish({ - network: 'regtest', - garbler: pk(0), - parties: [pk(0)], - state: { v: 1 } - }), /distinct from garbler/); - - const session = composeGarblerPublish({ - network: 'regtest', - garbler: pk(0), - parties: [pk(0), pk(1)], - state: { v: 1 }, - program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } - }); - assert.deepStrictEqual(session.roles.evaluators, [pk(1).toLowerCase()]); - assert.deepStrictEqual(session.parties, [pk(1).toLowerCase()]); - assert.ok(!session.parties.includes(pk(0).toLowerCase())); - }); - - it('binds at into decisionSigningMessage v2 (relay cannot swap timestamp)', function () { - assert.strictEqual(DECISION_SIGNING_VERSION, 2); - const msg = decisionSigningMessage({ - sessionId: 'aa'.repeat(32), - proposalMerkleRoot: 'bb'.repeat(32), - decision: 'accept', - at: 42 - }); - assert.strictEqual( - msg, - `fabric:blinded-execution-decision:2:${'aa'.repeat(32)}:${'bb'.repeat(32)}:accept:42` - ); - assert.throws(() => decisionSigningMessage({ - sessionId: 'aa'.repeat(32), - proposalMerkleRoot: 'bb'.repeat(32), - decision: 'accept' - }), /at must be a non-negative safe integer/); - - const session = composeGarblerPublish({ - network: 'regtest', - garbler: pk(0), - parties: [pk(1)], - state: { v: 1 }, - program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } - }); - const proposal = buildContractProposalPayload({ - contractId: session.circuitId, - messages: [Message.fromVector([ - 'P2P_BASE_MESSAGE', - JSON.stringify({ type: 'StateHint', object: { go: true } }) - ])], - statePatch: [{ op: 'add', path: '/ok', value: true }] - }); - assert.throws(() => recordProposalDecision({ - session, - proposalPayload: proposal, - decision: 'accept', - actorPubkey: pk(1), - signature: signDecision(1, session, proposal, 'accept', 7) - }), /at required/); - }); - - it('recordProposalDecision reject then accept; finalize needs an evaluator accept', function () { - let session = composeGarblerPublish({ - name: 'coord-demo', - network: 'regtest', - garbler: pk(0), - parties: [pk(1), pk(2)], - state: { open: true }, - program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } - }); - - const outsider = new Key(); - const rejectInner = Message.fromVector([ - 'P2P_BASE_MESSAGE', - JSON.stringify({ type: 'StateHint', object: { reject: true } }) - ]); - const rejectProposal = buildContractProposalPayload({ - contractId: session.circuitId, - messages: [rejectInner], - statePatch: [{ op: 'replace', path: '/open', value: false }] - }); - - assert.throws(() => recordProposalDecision({ - session, - proposalPayload: rejectProposal, - decision: 'accept', - actorPubkey: outsider.pubkey, - signature: 'aa'.repeat(64), - at: 999 - }), /not a session party/); - - assert.throws(() => recordProposalDecision({ - session, - proposalPayload: rejectProposal, - decision: 'reject', - actorPubkey: pk(1), - at: 1000 - }), /signature required/); - - session = recordProposalDecision({ - session, - proposalPayload: rejectProposal, - decision: 'reject', - actorPubkey: pk(1), - signature: signDecision(1, session, rejectProposal, 'reject', 1000), - at: 1000 - }); - assert.strictEqual(session.decisions.length, 1); - assert.ok(session.coordinationRoot); - const afterReject = session.coordinationRoot; - - // Same actor/proposal/decision/`at` is idempotent. - const replayed = recordProposalDecision({ - session, - proposalPayload: rejectProposal, - decision: 'reject', - actorPubkey: pk(1), - signature: signDecision(1, session, rejectProposal, 'reject', 1000), - at: 1000 - }); - assert.strictEqual(replayed.decisions.length, 1); - assert.strictEqual(replayed.coordinationRoot, afterReject); - assert.strictEqual(replayed.decisions[0].at, 1000); - - // A relay cannot swap `at` under a valid signature (v2 binds timestamp). - assert.throws(() => recordProposalDecision({ - session, - proposalPayload: rejectProposal, - decision: 'reject', - actorPubkey: pk(1), - signature: signDecision(1, session, rejectProposal, 'reject', 1000), - at: 9999 - }), /invalid decision signature/); - - // A later different `at` with a fresh signature is a conflict, not a second row. - assert.throws(() => recordProposalDecision({ - session, - proposalPayload: rejectProposal, - decision: 'reject', - actorPubkey: pk(1), - signature: signDecision(1, session, rejectProposal, 'reject', 9999), - at: 9999 - }), /conflicting at/); - - assert.throws(() => finalizeBlindedExecution({ session }), /evaluator accept/); - - // Garbler-only accept is not enough — need an evaluator. - const garblerAcceptProposal = buildContractProposalPayload({ - contractId: session.circuitId, - messages: [Message.fromVector([ - 'P2P_BASE_MESSAGE', - JSON.stringify({ type: 'StateHint', object: { accept: true } }) - ])], - statePatch: [{ op: 'add', path: '/garblerOk', value: true }] - }); - session = recordProposalDecision({ - session, - proposalPayload: garblerAcceptProposal, - decision: 'accept', - actorPubkey: pk(0), - signature: signDecision(0, session, garblerAcceptProposal, 'accept', 1500), - at: 1500 - }); - assert.throws(() => finalizeBlindedExecution({ session }), /evaluator accept/); - - const acceptInner = Message.fromVector([ - 'P2P_BASE_MESSAGE', - JSON.stringify({ type: 'StateHint', object: { accept: true } }) - ]); - const acceptProposal = buildContractProposalPayload({ - contractId: session.circuitId, - messages: [acceptInner], - statePatch: [{ op: 'add', path: '/settled', value: true }] - }); - - assert.throws(() => recordProposalDecision({ - session, - proposalPayload: acceptProposal, - decision: 'accept', - actorPubkey: pk(2), - signature: signDecision(1, session, acceptProposal, 'accept', 2000), - at: 2000 - }), /invalid decision signature/); - - session = recordProposalDecision({ - session, - proposalPayload: acceptProposal, - decision: 'accept', - actorPubkey: pk(2), - signature: signDecision(2, session, acceptProposal, 'accept', 2000), - at: 2000 - }); - assert.strictEqual(session.decisions.length, 3); - assert.notStrictEqual(session.coordinationRoot, afterReject); - - const baseCommitment = session.circuitCommitment; - const finalized = finalizeBlindedExecution({ session }); - assert.strictEqual(finalized.finalized, true); - assert.notStrictEqual(finalized.circuitCommitment, baseCommitment); - assert.ok(/^[0-9a-f]{64}$/.test(finalized.hashlockCommitmentHex)); - assert.ok(/^[0-9a-f]{64}$/.test(finalized.hashlockPreimageHex)); - }); - - it('binds finalized session to hashlock+pubkey Taproot (public preimage alone insufficient)', async function () { - const bitcoin = require('bitcoinjs-lib'); - const ecc = require('../types/ecc'); - bitcoin.initEccLib(ecc); - const { Psbt } = bitcoin; - - let session = composeGarblerPublish({ - name: 'hashlock-bind', - network: 'regtest', - garbler: pk(0), - parties: [pk(1)], - state: { phase: 'init' }, - program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } - }); - - const acceptInner = Message.fromVector([ - 'P2P_BASE_MESSAGE', - JSON.stringify({ type: 'StateHint', object: { go: true } }) - ]); - const acceptProposal = buildContractProposalPayload({ - contractId: session.circuitId, - messages: [acceptInner], - statePatch: [{ op: 'replace', path: '/phase', value: 'accepted' }] - }); - session = recordProposalDecision({ - session, - proposalPayload: acceptProposal, - decision: 'accept', - actorPubkey: pk(1), - signature: signDecision(1, session, acceptProposal, 'accept', 3000), - at: 3000 - }); - - const prog = Program.from({ language: 'fabric-opcodes', steps: ['OP_TRUE'] }); - prog.compile(); - const machine = new Machine(); - machine.define('OP_TRUE', () => true); - const run = await machine.runProgram(prog); - assert.strictEqual(run.ok, true); - assert.ok(run.runCommitmentHex); - - session = finalizeBlindedExecution({ - session, - runCommitmentHex: run.runCommitmentHex - }); - - const bound = bindBlindedExecutionToHashlock({ - session, - validators: [pk(0), pk(1)], - publisher: pk(0), - network: 'regtest', - threshold: 1 - }); - - assert.notStrictEqual(bound.address, bound.ladderOnlyAddress); - const hl = bound.tree.leaves.find((l) => l.kind === 'hashlock' && l.id === HASHLOCK_LEAF_ID); - assert.ok(hl); - assert.strictEqual(hl.commitmentHex, session.hashlockCommitmentHex); - assert.strictEqual(String(hl.pubkeyHex).toLowerCase(), pk(0).toLowerCase()); - assert.strictEqual(hl.witnessShape, 'preimage+sig'); - - const ladderOnly = synthesizeDefaultLadder({ - validators: [pk(0), pk(1)], - threshold: 1, - publisher: pk(0), - network: 'regtest' - }); - assert.strictEqual(bound.ladderOnlyAddress, toAddress(ladderOnly)); - - const value = 100000n; - const fund = new bitcoin.Transaction(); - fund.version = 2; - fund.addInput(Buffer.alloc(32), 0); - fund.addOutput(bound.tree.output, value); - - const destination = bitcoin.payments.p2wpkh({ - pubkey: Buffer.from(pk(2), 'hex'), - network: bitcoin.networks.regtest - }).address; - - const prepared = bound.prepareWithdrawal({ - fundedTxHex: fund.toHex(), - destinationAddress: destination, - feeSats: 1000 - }); - assert.ok(prepared.psbtBase64); - assert.strictEqual(prepared.action, 'hashlock'); - assert.strictEqual(prepared.witnessShape, 'preimage+sig'); - - const priv = Buffer.isBuffer(keys[0].private) - ? keys[0].private - : Buffer.from(String(keys[0].private), 'hex'); - const publicKey = Buffer.from(ecc.pointFromScalar(priv, true)); - const signer = { - publicKey, - sign (hash) { return Buffer.from(ecc.sign(hash, priv)); }, - signSchnorr (hash) { return Buffer.from(ecc.signSchnorr(hash, priv)); } - }; - const psbt = Psbt.fromBase64(prepared.psbtBase64); - psbt.signInput(0, signer); - - const fin = bound.finalizeWithdrawal({ - psbtBase64: psbt.toBase64(), - witnessShape: 'preimage+sig' - }); - assert.ok(fin.txHex); - assert.ok(fin.txid); - const spent = bitcoin.Transaction.fromHex(fin.txHex); - assert.strictEqual(spent.ins[0].witness.length, 4); - }); -}); diff --git a/tests/cli.nonrender.js b/tests/cli.nonrender.js index 9aa993f57..b0272b865 100644 --- a/tests/cli.nonrender.js +++ b/tests/cli.nonrender.js @@ -85,5 +85,254 @@ describe('@fabric/core/types/cli (non-render guards)', function () { cli._handleFormSubmit({ input: '/help' }); assert.deepStrictEqual(cli.history, ['/unlock ***', '/wallet unlock ***', '/help']); }); + + it('sends mesh shoutbox as UTF-8 P2P_CHAT_MESSAGE (not ChatMessage JSON)', function () { + const Key = require('../types/key'); + const { messageDataToString } = require('../functions/wireJson'); + const sent = []; + const lines = []; + const cli = Object.create(CLI.prototype); + cli.history = []; + cli.key = new Key(); + cli.node = { + id: 'aa'.repeat(32), + relayFrom (_from, message) { sent.push(message); } + }; + cli._peerAliasByPubkey = {}; + cli._processInput = () => false; + cli._appendMessage = (line) => { lines.push(line); }; + cli.setPane = () => {}; + cli._sendToAllServices = () => {}; + cli.elements = { form: { reset () {} } }; + cli.screen = { render () {} }; + + cli._handleFormSubmit({ input: 'héllo 🌍' }); + assert.strictEqual(sent.length, 1); + assert.strictEqual(sent[0].type, 'P2P_CHAT_MESSAGE'); + const body = messageDataToString(sent[0].raw ? sent[0].raw.data : sent[0].data); + assert.strictEqual(body, 'héllo 🌍'); + assert.ok(lines.some((l) => /héllo/.test(l))); + assert.ok(!body.startsWith('{')); + }); + + it('renders Peer { text } + signer and P2P_PEER_ALIAS nicknames', async function () { + const lines = []; + const cli = Object.create(CLI.prototype); + cli.node = { id: 'local' }; + cli._peerAliasByPubkey = {}; + cli._appendMessage = (line) => { lines.push(line); }; + + const compressed = '02' + 'aa'.repeat(32); + await cli._handlePeerAlias({ alias: 'neorion', signer: compressed }); + await cli._handlePeerChat( + { text: 'from hub', type: 'P2P_CHAT_MESSAGE' }, + { signer: compressed } + ); + assert.deepStrictEqual(lines, ['[neorion]: from hub']); + }); }); + +const { readCliPasswordFromArgv } = require('../functions/cliPasswordArgv'); + +describe('@fabric/core/functions/cliPasswordArgv', function () { + it('reads --password=VALUE and --password VALUE', function () { + assert.strictEqual(readCliPasswordFromArgv(['node', 'fabric', '--password=secret=ok'], {}), 'secret=ok'); + assert.strictEqual( + readCliPasswordFromArgv(['node', 'fabric', '--password', 'separate'], {}), + 'separate' + ); + }); + + it('does not consume a following long option as the password', function () { + assert.strictEqual( + readCliPasswordFromArgv(['--password', '--force'], {}), + '' + ); + }); + + it('accepts a separate value that begins with a single dash', function () { + assert.strictEqual( + readCliPasswordFromArgv(['--password', '-secret'], {}), + '-secret' + ); + }); + + it('treats explicit --password= as empty (no env fallback)', function () { + assert.strictEqual( + readCliPasswordFromArgv(['--password='], { FABRIC_PASSWORD: 'from-env' }), + '' + ); + }); + + it('stops option scanning at a standalone -- terminator', function () { + assert.strictEqual( + readCliPasswordFromArgv( + ['node', 'fabric', '--', '--password=positional'], + { FABRIC_PASSWORD: 'from-env' } + ), + 'from-env' + ); + }); + + it('does not fall back to env when --password is present without a value', function () { + assert.strictEqual( + readCliPasswordFromArgv(['--password'], { FABRIC_PASSWORD: 'from-env' }), + '' + ); + }); + + it('falls back to FABRIC_PASSWORD only (not generic PASSWORD)', function () { + assert.strictEqual( + readCliPasswordFromArgv([], { FABRIC_PASSWORD: 'from-env', PASSWORD: 'generic' }), + 'from-env' + ); + assert.strictEqual( + readCliPasswordFromArgv([], { PASSWORD: 'generic' }), + '' + ); + }); +}); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { Writable } = require('stream'); +const http = require('http'); +const { safeJoin, isRealpathInsideRoot, serveFile, handler } = require('../scripts/static-server'); + +class FakeResponse extends Writable { + constructor () { + super(); + this.headersSent = false; + this.writeHeadCount = 0; + this.destroyed = false; + } + + writeHead () { + this.writeHeadCount++; + this.headersSent = true; + } + + _write (chunk, encoding, callback) { + callback(); + } + + destroy (err) { + this.destroyed = true; + return super.destroy(err); + } +} + +describe('scripts/static-server', function () { + it('rejects malformed percent-encoding and path escape', function () { + const root = path.resolve(os.tmpdir(), 'fabric-static-root'); + assert.strictEqual(safeJoin(root, '/%E0%A4%A'), null); + assert.strictEqual(safeJoin(root, '/../etc/passwd'), null); + }); + + it('does not call writeHead after headers are sent', function (done) { + const res = new FakeResponse(); + serveFile(res, path.join(os.tmpdir(), 'fabric-static-missing-' + Date.now())); + setTimeout(() => { + assert.ok(res.writeHeadCount >= 1); + assert.strictEqual(res.writeHeadCount, 1); + assert.strictEqual(res.destroyed, true); + done(); + }, 80); + }); + + it('handler returns 403 for a malformed URI', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-static-h-')); + const req = { url: '/%E0%A4%A' }; + const res = { statusCode: 0, writeHead (code) { this.statusCode = code; }, end () {} }; + handler(dir)(req, res); + fs.rmSync(dir, { recursive: true, force: true }); + assert.strictEqual(res.statusCode, 403); + }); + + it('rejects a symlink whose real path is outside the document root', function (done) { + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-static-out-')); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-static-in-')); + const secret = path.join(outside, 'secret.txt'); + const leak = path.join(root, 'leak.txt'); + fs.writeFileSync(secret, 'nope'); + fs.symlinkSync(secret, leak); + assert.strictEqual(isRealpathInsideRoot(root, leak), false); + const server = http.createServer(handler(root)); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + http.get(`http://127.0.0.1:${port}/leak.txt`, (res) => { + const code = res.statusCode; + res.resume(); + server.close(() => { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + try { + assert.strictEqual(code, 403); + done(); + } catch (err) { + done(err); + } + }); + }).on('error', (err) => { + server.close(); + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + done(err); + }); + }); + }); + + it('serves a symlink that stays inside the document root', function (done) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-static-ok-')); + fs.writeFileSync(path.join(root, 'index.html'), '

ok

'); + fs.symlinkSync(path.join(root, 'index.html'), path.join(root, 'alias.html')); + assert.strictEqual(isRealpathInsideRoot(root, path.join(root, 'alias.html')), true); + const server = http.createServer(handler(root)); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + http.get(`http://127.0.0.1:${port}/alias.html`, (res) => { + const code = res.statusCode; + res.resume(); + server.close(() => { + fs.rmSync(root, { recursive: true, force: true }); + try { + assert.strictEqual(code, 200); + done(); + } catch (err) { + done(err); + } + }); + }).on('error', (err) => { + server.close(); + fs.rmSync(root, { recursive: true, force: true }); + done(err); + }); + }); + }); +}); + +const { + isLockNodeModulesPath, + resolveLockPackageDir +} = require('../scripts/report-credits'); + +describe('scripts/report-credits lockfile paths', function () { + const root = path.resolve('/tmp/fabric-credits-root'); + + it('accepts only node_modules lockfile keys', function () { + assert.strictEqual(isLockNodeModulesPath('node_modules/foo'), true); + assert.strictEqual(isLockNodeModulesPath('node_modules/foo/node_modules/bar'), true); + assert.strictEqual(isLockNodeModulesPath(''), false); + assert.strictEqual(isLockNodeModulesPath('../etc'), false); + assert.strictEqual(isLockNodeModulesPath('node_modules/../secret'), false); + }); + + it('resolves packages inside node_modules only', function () { + const inside = resolveLockPackageDir(root, 'node_modules/foo'); + assert.ok(inside.endsWith(path.join('node_modules', 'foo'))); + assert.strictEqual(resolveLockPackageDir(root, 'stores/hub'), null); + assert.strictEqual(resolveLockPackageDir(root, 'node_modules/../../etc'), null); + }); +}); diff --git a/tests/contractMessageAccumulate.test.js b/tests/contractMessageAccumulate.test.js deleted file mode 100644 index 38b15b927..000000000 --- a/tests/contractMessageAccumulate.test.js +++ /dev/null @@ -1,891 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const Key = require('../types/key'); -const Message = require('../types/message'); -const { - createMemoryStore, - ingestMessageBuffer, - ingestContractPublishBuffer, - foldContractState, - stateDigest, - bufferFromPaste, - tipFromDoc, - loadDoc, - normalizeGenesis, - resolveMaxJournalEntries -} = require('../functions/contractMessageAccumulate'); -const { pubkeyXOnly } = require('../functions/groupChatSeal'); -const { - signProposalVote -} = require('../functions/groupChangeGovernance'); -const { - OP_CONTRACT_SIGN, - issueContractCapability -} = require('../functions/contractCapability'); -const { - createPending, - markReceived, - markReceipt, - receiptSigningMessage, - phaseFlags, - allPhasesComplete, - aggregatePhaseFlags, - pendingForRelay -} = require('../functions/contractMessageCommit'); - -function signContractMessage (key, contractId, type, object) { - const body = JSON.stringify({ - contract: contractId, - type, - object - }); - return Message.fromVector(['CONTRACT_MESSAGE', body]).signWithKey(key); -} - -describe('@fabric/core contractMessageAccumulate', function () { - const contractId = 'c'.repeat(64); - - it('idempotent multi-origin ingest of the same AMP bytes', function () { - const author = new Key(); - const genesis = { signers: [author.pubkey] }; - const msg = signContractMessage(author, contractId, 'GroupChat', { - body: 'hello', - author: author.pubkey, - ts: '2026-01-01T00:00:00.000Z' - }); - const buf = msg.toBuffer(); - const paste = `fabric:${buf.toString('hex')}`; - - const store = createMemoryStore(); - const a = ingestMessageBuffer(store, contractId, buf, { origin: 'mesh', genesis }); - assert.strictEqual(a.accepted, true); - const b = ingestMessageBuffer(store, contractId, buf, { origin: 'queue', genesis }); - assert.strictEqual(b.duplicate, true); - const c = ingestMessageBuffer(store, contractId, paste, { origin: 'paste', genesis }); - assert.strictEqual(c.duplicate, true); - assert.strictEqual(a.tip.stateDigest, b.tip.stateDigest); - assert.strictEqual(a.tip.stateDigest, c.tip.stateDigest); - assert.strictEqual(a.tip.clock, 1); - assert.strictEqual(loadDoc(store, contractId).entries.length, 1); - }); - - it('arrival-order independence across peers', function () { - const aKey = new Key(); - const bKey = new Key(); - // Both authors must be authorized for GroupChat in any arrival order. - const genesis = { signers: [aKey.pubkey, bKey.pubkey] }; - const m1 = signContractMessage(aKey, contractId, 'GroupChange', { - action: 'members.set', - members: [aKey.pubkey, bKey.pubkey] - }); - const m2 = signContractMessage(aKey, contractId, 'GroupChat', { body: 'one', author: aKey.pubkey }); - const m3 = signContractMessage(bKey, contractId, 'GroupChat', { body: 'two', author: bKey.pubkey }); - const bufs = [m1.toBuffer(), m2.toBuffer(), m3.toBuffer()]; - - const storeA = createMemoryStore(); - for (const buf of bufs) ingestMessageBuffer(storeA, contractId, buf, { origin: 'mesh', genesis }); - - const storeB = createMemoryStore(); - for (const buf of [bufs[2], bufs[0], bufs[1]]) { - ingestMessageBuffer(storeB, contractId, buf, { origin: 'mesh', genesis }); - } - - const storeC = createMemoryStore(); - ingestMessageBuffer(storeC, contractId, `fabric:${bufs[1].toString('hex')}`, { origin: 'paste', genesis }); - ingestMessageBuffer(storeC, contractId, bufs[0], { origin: 'queue', genesis }); - ingestMessageBuffer(storeC, contractId, bufs[2], { origin: 'mesh', genesis }); - - const tipA = tipFromDoc(loadDoc(storeA, contractId)); - const tipB = tipFromDoc(loadDoc(storeB, contractId)); - const tipC = tipFromDoc(loadDoc(storeC, contractId)); - assert.strictEqual(tipA.stateDigest, tipB.stateDigest); - assert.strictEqual(tipA.stateDigest, tipC.stateDigest); - assert.strictEqual(tipA.clock, 3); - assert.deepStrictEqual(tipA.content.members.slice().sort(), tipB.content.members.slice().sort()); - }); - - it('publisher local accumulate matches paste-only participant', function () { - const publisher = new Key(); - const peer = new Key(); - const genesis = { signers: [publisher.pubkey, peer.pubkey] }; - const out = signContractMessage(publisher, contractId, 'GroupChat', { - body: 'from publisher', - author: publisher.pubkey - }); - const reply = signContractMessage(peer, contractId, 'GroupChat', { - body: 'reply', - author: peer.pubkey - }); - - const pubStore = createMemoryStore(); - ingestMessageBuffer(pubStore, contractId, out.toBuffer(), { origin: 'local', genesis }); - ingestMessageBuffer(pubStore, contractId, reply.toBuffer(), { origin: 'mesh', genesis }); - - const partStore = createMemoryStore(); - ingestMessageBuffer(partStore, contractId, `fabric:${out.toBuffer().toString('hex')}`, { origin: 'paste', genesis }); - ingestMessageBuffer(partStore, contractId, reply.toBuffer(), { origin: 'mesh', genesis }); - - assert.strictEqual( - tipFromDoc(loadDoc(pubStore, contractId)).stateDigest, - tipFromDoc(loadDoc(partStore, contractId)).stateDigest - ); - }); - - it('rejects wrong contract, bad signature, and leaves tip unchanged', function () { - const author = new Key(); - const attacker = new Key(); - const store = createMemoryStore(); - const genesis = { signers: [author.pubkey] }; - const good = signContractMessage(author, contractId, 'GroupChat', { body: 'ok' }); - ingestMessageBuffer(store, contractId, good.toBuffer(), { origin: 'mesh', genesis }); - const tip0 = tipFromDoc(loadDoc(store, contractId)).stateDigest; - - const wrongContract = signContractMessage(author, 'd'.repeat(64), 'GroupChat', { body: 'nope' }); - const badContract = ingestMessageBuffer(store, contractId, wrongContract.toBuffer(), { genesis }); - assert.strictEqual(badContract.accepted, false); - assert.match(badContract.error, /mismatch/i); - - const forged = Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ - contract: contractId, - type: 'GroupChat', - object: { body: 'evil' } - })]).signWithKey(attacker); - // Corrupt the BIP340 signature field (AMP trailer), not the JSON body. - const signed = forged.toBuffer(); - const badBuf = Buffer.from(signed); - // Signature is the last 64 bytes of the Fabric Message frame. - assert.ok(badBuf.length > 64); - badBuf[badBuf.length - 1] ^= 0xff; - const badSig = ingestMessageBuffer(store, contractId, badBuf, { genesis }); - assert.strictEqual(badSig.accepted, false); - assert.match(badSig.error, /invalid signature/i); - - assert.strictEqual(tipFromDoc(loadDoc(store, contractId)).stateDigest, tip0); - }); - - it('rejects frames larger than Peer MAX_MESSAGE_SIZE before parse', function () { - const { HEADER_SIZE, MAX_MESSAGE_SIZE } = require('../constants'); - const store = createMemoryStore(); - const huge = Buffer.alloc(HEADER_SIZE + MAX_MESSAGE_SIZE + 1, 0xab); - const res = ingestMessageBuffer(store, contractId, huge, { - origin: 'paste', - genesis: { signers: ['02' + '11'.repeat(32)] } - }); - assert.strictEqual(res.accepted, false); - assert.match(res.error, /MAX_MESSAGE_SIZE/); - }); - - it('rejects GroupChat from non-reader/non-signer', function () { - const owner = new Key(); - const outsider = new Key(); - const store = createMemoryStore(); - const genesis = { signers: [owner.pubkey], readers: [owner.pubkey] }; - const bad = ingestMessageBuffer( - store, - contractId, - signContractMessage(outsider, contractId, 'GroupChat', { body: 'spam' }).toBuffer(), - { origin: 'mesh', genesis } - ); - assert.strictEqual(bad.accepted, false); - assert.match(bad.error, /reader\/signer/i); - assert.strictEqual(loadDoc(store, contractId).entries.length, 0); - }); - - it('rejects GroupChange from non-signer (F1)', function () { - const owner = new Key(); - const attacker = new Key(); - const store = createMemoryStore(); - const genesis = { signers: [owner.pubkey] }; - - const takeover = signContractMessage(attacker, contractId, 'GroupChange', { - action: 'members.set', - members: [attacker.pubkey] - }); - const bad = ingestMessageBuffer(store, contractId, takeover.toBuffer(), { - origin: 'mesh', - genesis - }); - assert.strictEqual(bad.accepted, false); - assert.match(bad.error, /not authorized/i); - assert.strictEqual(loadDoc(store, contractId).entries.length, 0); - - const ok = ingestMessageBuffer(store, contractId, signContractMessage(owner, contractId, 'GroupChange', { - action: 'members.set', - members: [owner.pubkey] - }).toBuffer(), { origin: 'mesh', genesis }); - assert.strictEqual(ok.accepted, true); - assert.deepStrictEqual(ok.tip.content.members, [pubkeyXOnly(owner.pubkey)]); - }); - - it('rejects governance/invite/journal-request spam from non-signers (tip integrity)', function () { - const owner = new Key(); - const outsider = new Key(); - const store = createMemoryStore(); - const genesis = { signers: [owner.pubkey] }; - const tipBefore = () => { - const doc = loadDoc(store, contractId); - return tipFromDoc(doc); - }; - - for (const type of [ - 'GroupChangeProposal', - 'GroupChangeVote', - 'FederationContractInvite', - 'FederationContractInviteResponse', - 'GroupJournalRequest' - ]) { - const bad = ingestMessageBuffer( - store, - contractId, - signContractMessage(outsider, contractId, type, { note: 'spam' }).toBuffer(), - { origin: 'mesh', genesis } - ); - assert.strictEqual(bad.accepted, false, type); - assert.match(bad.error, /not authorized|fail closed|genesis/i, type); - } - assert.strictEqual(loadDoc(store, contractId).entries.length, 0); - assert.strictEqual(tipBefore().clock, 0); - }); - - it('rejects GroupChange with no genesis signers (fail closed)', function () { - const anyone = new Key(); - const store = createMemoryStore(); - const res = ingestMessageBuffer(store, contractId, signContractMessage(anyone, contractId, 'GroupChange', { - action: 'member.add', - member: anyone.pubkey - }).toBuffer(), { origin: 'mesh' }); - assert.strictEqual(res.accepted, false); - assert.match(res.error, /genesis|fail closed/i); - }); - - it('accepts GroupChange via verified OP_CONTRACT_SIGN token', function () { - const owner = new Key(); - const delegate = new Key(); - const store = createMemoryStore(); - const genesis = { signers: [owner.pubkey] }; - const tok = issueContractCapability({ - issuerKey: owner, - subject: delegate.pubkey, - contractId, - capability: OP_CONTRACT_SIGN - }); - const change = signContractMessage(delegate, contractId, 'GroupChange', { - action: 'member.add', - member: delegate.pubkey - }); - const without = ingestMessageBuffer(store, contractId, change.toBuffer(), { - origin: 'mesh', - genesis - }); - assert.strictEqual(without.accepted, false); - - const withTok = ingestMessageBuffer(store, contractId, change.toBuffer(), { - origin: 'mesh', - genesis, - capabilityToken: tok - }); - assert.strictEqual(withTok.accepted, true); - assert.ok(withTok.tip.content.members.includes(pubkeyXOnly(delegate.pubkey))); - assert.ok(withTok.tip.content.signers.includes(pubkeyXOnly(delegate.pubkey))); - }); - - it('reader-role GroupChange widens members but not signers', function () { - const owner = new Key(); - const reader = new Key(); - const store = createMemoryStore(); - const genesis = { signers: [owner.pubkey] }; - const change = signContractMessage(owner, contractId, 'GroupChange', { - action: 'member.add', - member: reader.pubkey, - role: 'reader' - }); - const res = ingestMessageBuffer(store, contractId, change.toBuffer(), { - origin: 'mesh', - genesis - }); - assert.strictEqual(res.accepted, true); - assert.deepStrictEqual(res.tip.content.signers, []); - assert.deepStrictEqual(res.tip.content.members, [pubkeyXOnly(reader.pubkey)]); - - // Owner already a signer via member.add (commutes with reader add under - // hash-ordered fold). Avoid members.set here — a later-sorted set would - // clear the reader and flake this assertion. - const store2 = createMemoryStore(); - const seed = ingestMessageBuffer(store2, contractId, signContractMessage(owner, contractId, 'GroupChange', { - action: 'member.add', - member: owner.pubkey, - role: 'signer' - }).toBuffer(), { origin: 'mesh', genesis }); - assert.strictEqual(seed.accepted, true); - const withReader = ingestMessageBuffer(store2, contractId, change.toBuffer(), { - origin: 'mesh', - genesis - }); - assert.strictEqual(withReader.accepted, true); - assert.deepStrictEqual(withReader.tip.content.signers, [pubkeyXOnly(owner.pubkey)]); - assert.ok(withReader.tip.content.members.includes(pubkeyXOnly(owner.pubkey))); - assert.ok(withReader.tip.content.members.includes(pubkeyXOnly(reader.pubkey))); - assert.ok(!withReader.tip.content.signers.includes(pubkeyXOnly(reader.pubkey))); - }); - - it('rejects unknown body types and enforces genesis primitives', function () { - const author = new Key(); - const store = createMemoryStore(); - const unknown = ingestMessageBuffer(store, contractId, signContractMessage(author, contractId, 'EvilCustom', { - body: 'nope' - }).toBuffer(), { origin: 'mesh' }); - assert.strictEqual(unknown.accepted, false); - assert.match(unknown.error, /not allowed/i); - - const chat = signContractMessage(author, contractId, 'GroupChat', { body: 'hi' }); - const blocked = ingestMessageBuffer(store, contractId, chat.toBuffer(), { - origin: 'mesh', - genesis: { - signers: [author.pubkey], - primitives: { messageTypes: ['GroupChange'] } - } - }); - assert.strictEqual(blocked.accepted, false); - assert.match(blocked.error, /not allowed/i); - - const emptyAllow = ingestMessageBuffer(store, contractId, chat.toBuffer(), { - origin: 'mesh', - genesis: { - signers: [author.pubkey], - primitives: { messageTypes: [] } - } - }); - assert.strictEqual(emptyAllow.accepted, false); - assert.match(emptyAllow.error, /not allowed/i); - }); - - it('custom genesis messageTypes still require reader/signer membership', function () { - const owner = new Key(); - const outsider = new Key(); - const store = createMemoryStore(); - const genesis = { - signers: [owner.pubkey], - primitives: { messageTypes: ['AppNote'] } - }; - const spam = ingestMessageBuffer( - store, - contractId, - signContractMessage(outsider, contractId, 'AppNote', { body: 'spam' }).toBuffer(), - { origin: 'mesh', genesis } - ); - assert.strictEqual(spam.accepted, false); - assert.match(spam.error, /reader\/signer|fail closed/i); - - const ok = ingestMessageBuffer( - store, - contractId, - signContractMessage(owner, contractId, 'AppNote', { body: 'ok' }).toBuffer(), - { origin: 'mesh', genesis } - ); - assert.strictEqual(ok.accepted, true, ok.error); - }); - - it('threads bitcoinBlockHash onto tip', function () { - const author = new Key(); - const store = createMemoryStore(); - const hash = 'ab'.repeat(32); - const res = ingestMessageBuffer(store, contractId, signContractMessage(author, contractId, 'GroupChat', { - body: 'anchored' - }).toBuffer(), { - origin: 'mesh', - genesis: { signers: [author.pubkey] }, - bitcoinBlockHash: hash, - bitcoinHeight: 100 - }); - assert.strictEqual(res.accepted, true); - assert.strictEqual(res.tip.bitcoinBlockHash, hash); - assert.strictEqual(res.tip.bitcoinHeight, 100); - - const bad = ingestMessageBuffer(store, contractId, signContractMessage(author, contractId, 'GroupChat', { - body: 'bad-hash' - }).toBuffer(), { - origin: 'mesh', - genesis: { signers: [author.pubkey] }, - bitcoinBlockHash: 'not-a-hash' - }); - assert.strictEqual(bad.accepted, false); - assert.match(bad.error, /bitcoinBlockHash/i); - }); - - it('bufferFromPaste accepts fabric: prefix', function () { - const buf = Buffer.from('abcd', 'hex'); - assert.ok(bufferFromPaste(`fabric:${buf.toString('hex')}`).equals(buf)); - }); - - it('foldContractState sorts by hash', function () { - const entries = [ - { hash: 'bb', type: 'GroupChat', object: { body: 'b' } }, - { hash: 'aa', type: 'GroupChat', object: { body: 'a' } } - ]; - const state = foldContractState(entries); - assert.strictEqual(state.content.messages[0].body, 'a'); - assert.strictEqual(state.clock, 2); - assert.ok(stateDigest(state)); - }); - - it('MessageReceipt stores + relays without advancing tip digest; updates 2PC sidecar', function () { - const a = new Key(); - const b = new Key(); - const genesis = { signers: [a.pubkey], readers: [a.pubkey, b.pubkey] }; - const chat = signContractMessage(a, contractId, 'GroupChat', { - body: 'o7', - author: a.pubkey, - ts: '2026-01-01T00:00:00.000Z' - }); - const store = createMemoryStore(); - const chatRes = ingestMessageBuffer(store, contractId, chat.toBuffer(), { - origin: 'local', - genesis - }); - assert.strictEqual(chatRes.accepted, true); - const tipBefore = chatRes.tip.stateDigest; - const clockBefore = chatRes.tip.clock; - assert.ok(store.get('contractmessagecommits', chatRes.entry.hash), 'sync filter opens pending 2PC'); - - const wireHash = chatRes.entry.hash; - const receiptSig = Buffer.from(b.signSchnorr(receiptSigningMessage(wireHash))).toString('hex'); - const receipt = signContractMessage(b, contractId, 'MessageReceipt', { - messageId: wireHash, - receiptAt: '2026-01-01T00:00:01.000Z', - receiptSig - }); - const rx = ingestMessageBuffer(store, contractId, receipt.toBuffer(), { - origin: 'mesh', - genesis - }); - assert.strictEqual(rx.accepted, true, rx.error); - assert.strictEqual(rx.entry.type, 'MessageReceipt'); - assert.ok(rx.entry.hex); - assert.strictEqual(rx.tip.stateDigest, tipBefore); - assert.strictEqual(rx.tip.clock, clockBefore); - assert.strictEqual(loadDoc(store, contractId).entries.length, 2); - assert.strictEqual( - store.get('contractmessagecommits', rx.entry.hash), - null, - 'ACK frames do not open a new pending row' - ); - - const sidecar = store.get('contractmessagecommits', wireHash); - assert.ok(sidecar); - assert.strictEqual(phaseFlags(sidecar, b.pubkey).receipt, true); - assert.ok(Array.isArray(sidecar.ackMessages)); - assert.strictEqual(sidecar.ackMessages[0].type, 'MessageReceipt'); - }); - - it('delivery sync filter tracks GroupChange and respects deliverySync.none', function () { - const { - isSyncTrackedType, - ensureDeliveryPending - } = require('../functions/contractMessageAccumulate'); - assert.strictEqual(isSyncTrackedType('GroupChange'), true); - assert.strictEqual(isSyncTrackedType('GroupChat'), true); - assert.strictEqual(isSyncTrackedType('MessageReceipt'), false); - assert.strictEqual(isSyncTrackedType('GroupJournalRequest'), false); - - const a = new Key(); - const genesis = { - signers: [a.pubkey], - readers: [a.pubkey], - primitives: { deliverySync: { mode: 'none' } } - }; - const change = signContractMessage(a, contractId, 'GroupChange', { - action: 'members.set', - members: [a.pubkey] - }); - const store = createMemoryStore(); - const res = ingestMessageBuffer(store, contractId, change.toBuffer(), { - origin: 'local', - genesis - }); - assert.strictEqual(res.accepted, true); - assert.strictEqual( - store.get('contractmessagecommits', res.entry.hash), - null, - 'deliverySync.none skips pending 2PC' - ); - assert.strictEqual(isSyncTrackedType('GroupChange', loadDoc(store, contractId), { genesis }), false); - - // include mode tracks only listed types - const includeDoc = { - genesis: normalizeGenesis({ - signers: [a.pubkey], - primitives: { deliverySync: { mode: 'include', types: ['GroupShare'] } } - }) - }; - assert.strictEqual(isSyncTrackedType('GroupChat', includeDoc), false); - assert.strictEqual(isSyncTrackedType('GroupShare', includeDoc), true); - assert.ok(ensureDeliveryPending); - }); - - it('threshold-1 GroupChangeProposal from a signer adopts member.add', function () { - const owner = new Key(); - const recruit = new Key(); - const store = createMemoryStore(); - const genesis = { signers: [owner.pubkey], threshold: 1 }; - const proposal = { - id: 'gprop-t1', - contractId, - action: 'member.add', - member: recruit.pubkey, - role: 'signer', - proposedBy: pubkeyXOnly(owner.pubkey), - createdAt: '2026-01-01T00:00:00.000Z', - threshold: 1 - }; - const res = ingestMessageBuffer( - store, - contractId, - signContractMessage(owner, contractId, 'GroupChangeProposal', proposal).toBuffer(), - { origin: 'mesh', genesis } - ); - assert.strictEqual(res.accepted, true); - const adopted = (res.tip.content.proposals || []).find((p) => p.id === 'gprop-t1'); - assert.ok(adopted); - assert.strictEqual(adopted.status, 'adopted'); - assert.ok(res.tip.content.members.includes(pubkeyXOnly(recruit.pubkey))); - assert.ok(res.tip.content.members.includes(pubkeyXOnly(owner.pubkey))); - assert.ok(res.tip.content.signers.includes(pubkeyXOnly(recruit.pubkey))); - }); - - it('threshold-2 proposal stays pending until a BIP340 vote', function () { - const a = new Key(); - const b = new Key(); - const recruit = new Key(); - const store = createMemoryStore(); - const genesis = { signers: [a.pubkey, b.pubkey], threshold: 2 }; - const proposal = { - id: 'gprop-t2', - contractId, - action: 'member.add', - member: recruit.pubkey, - role: 'signer', - proposedBy: pubkeyXOnly(a.pubkey), - createdAt: '2026-01-01T00:00:00.000Z', - threshold: 2 - }; - const first = ingestMessageBuffer( - store, - contractId, - signContractMessage(a, contractId, 'GroupChangeProposal', proposal).toBuffer(), - { origin: 'mesh', genesis } - ); - assert.strictEqual(first.accepted, true); - assert.strictEqual(first.tip.content.proposals[0].status, 'pending'); - assert.ok(!first.tip.content.members.includes(pubkeyXOnly(recruit.pubkey))); - - const signed = signProposalVote(b, proposal); - const vote = ingestMessageBuffer( - store, - contractId, - signContractMessage(b, contractId, 'GroupChangeVote', { - proposalId: proposal.id, - voter: pubkeyXOnly(b.pubkey), - signature: signed.signature - }).toBuffer(), - { origin: 'mesh', genesis } - ); - assert.strictEqual(vote.accepted, true); - assert.strictEqual(vote.tip.content.proposals[0].status, 'adopted'); - assert.ok(vote.tip.content.members.includes(pubkeyXOnly(recruit.pubkey))); - }); - - it('rejects GroupChangeVote with voter/AMP mismatch or bad signature', function () { - const a = new Key(); - const b = new Key(); - const store = createMemoryStore(); - const genesis = { signers: [a.pubkey, b.pubkey], threshold: 2 }; - const proposal = { - id: 'gprop-bad', - contractId, - action: 'member.add', - member: new Key().pubkey, - proposedBy: pubkeyXOnly(a.pubkey), - createdAt: '2026-01-01T00:00:00.000Z', - threshold: 2 - }; - ingestMessageBuffer( - store, - contractId, - signContractMessage(a, contractId, 'GroupChangeProposal', proposal).toBuffer(), - { origin: 'mesh', genesis } - ); - const mismatch = ingestMessageBuffer( - store, - contractId, - signContractMessage(b, contractId, 'GroupChangeVote', { - proposalId: proposal.id, - voter: pubkeyXOnly(a.pubkey), - signature: 'ab'.repeat(64) - }).toBuffer(), - { origin: 'mesh', genesis } - ); - assert.strictEqual(mismatch.accepted, false); - assert.match(mismatch.error, /voter must match/i); - - const badSig = ingestMessageBuffer( - store, - contractId, - signContractMessage(b, contractId, 'GroupChangeVote', { - proposalId: proposal.id, - voter: pubkeyXOnly(b.pubkey), - signature: 'ab'.repeat(64) - }).toBuffer(), - { origin: 'mesh', genesis } - ); - assert.strictEqual(badSig.accepted, false); - assert.match(badSig.error, /invalid vote/i); - }); - - it('persists genesis from CONTRACT_PUBLISH so GroupChange needs no meta.genesis', function () { - const owner = new Key(); - const store = createMemoryStore(); - const definition = { - name: 'ArcGenesisSeed', - members: { signers: [owner.pubkey], threshold: 1 }, - primitives: { messageTypes: ['GroupChange', 'GroupChat'] } - }; - const pub = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(definition)]).signWithKey(owner); - const seeded = ingestContractPublishBuffer(store, pub.toBuffer()); - assert.strictEqual(seeded.accepted, true); - assert.ok(seeded.contractId); - const change = ingestMessageBuffer( - store, - seeded.contractId, - signContractMessage(owner, seeded.contractId, 'GroupChange', { - action: 'member.add', - member: owner.pubkey - }).toBuffer(), - { origin: 'mesh' } - ); - assert.strictEqual(change.accepted, true, change.error); - assert.ok(change.tip.content.members.includes(pubkeyXOnly(owner.pubkey))); - }); - - it('does not let meta.genesis replace the signed CONTRACT_PUBLISH body', function () { - const owner = new Key(); - const attacker = new Key(); - const store = createMemoryStore(); - const definition = { - name: 'SignedGenesisAuthoritative', - members: { signers: [owner.pubkey], threshold: 1 } - }; - const pub = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(definition)]).signWithKey(owner); - const seeded = ingestContractPublishBuffer(store, pub.toBuffer(), { - genesis: { signers: [attacker.pubkey], threshold: 1 } - }); - assert.strictEqual(seeded.accepted, true, seeded.error); - const doc = loadDoc(store, seeded.contractId); - const expected = pubkeyXOnly(owner.pubkey); - const unexpected = pubkeyXOnly(attacker.pubkey); - assert.ok(doc.genesis.signers.includes(expected)); - assert.ok(!doc.genesis.signers.includes(unexpected)); - }); - - it('does not let meta.signers replace the signed CONTRACT_PUBLISH body', function () { - const owner = new Key(); - const attacker = new Key(); - const store = createMemoryStore(); - const definition = { - name: 'SignedGenesisSignersAuthoritative', - members: { signers: [owner.pubkey], threshold: 1 } - }; - const pub = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(definition)]).signWithKey(owner); - const seeded = ingestContractPublishBuffer(store, pub.toBuffer(), { - signers: [attacker.pubkey], - threshold: 1 - }); - assert.strictEqual(seeded.accepted, true, seeded.error); - const doc = loadDoc(store, seeded.contractId); - const expected = pubkeyXOnly(owner.pubkey); - const unexpected = pubkeyXOnly(attacker.pubkey); - assert.ok(doc.genesis.signers.includes(expected)); - assert.ok(!doc.genesis.signers.includes(unexpected)); - }); - - it('second GroupChange reuses stored genesis without meta.signers', function () { - const owner = new Key(); - const other = new Key(); - const store = createMemoryStore(); - const genesis = { signers: [owner.pubkey] }; - const first = ingestMessageBuffer( - store, - contractId, - signContractMessage(owner, contractId, 'GroupChange', { - action: 'member.add', - member: owner.pubkey - }).toBuffer(), - { origin: 'mesh', genesis } - ); - assert.strictEqual(first.accepted, true); - const second = ingestMessageBuffer( - store, - contractId, - signContractMessage(owner, contractId, 'GroupChange', { - action: 'member.add', - member: other.pubkey - }).toBuffer(), - { origin: 'mesh' } - ); - assert.strictEqual(second.accepted, true, second.error); - assert.ok(second.tip.content.members.includes(pubkeyXOnly(other.pubkey))); - }); - - it('journal cap drops GroupChat by hash but keeps GroupChange', function () { - const owner = new Key(); - const store = createMemoryStore(); - const genesis = { signers: [owner.pubkey] }; - const change = ingestMessageBuffer( - store, - contractId, - signContractMessage(owner, contractId, 'GroupChange', { - action: 'members.set', - members: [owner.pubkey] - }).toBuffer(), - { origin: 'mesh', genesis, maxJournalEntries: 3 } - ); - assert.strictEqual(change.accepted, true); - for (let i = 0; i < 5; i++) { - ingestMessageBuffer( - store, - contractId, - signContractMessage(owner, contractId, 'GroupChat', { - body: `m${i}`, - author: owner.pubkey, - ts: `2026-01-0${i + 1}T00:00:00.000Z` - }).toBuffer(), - { origin: 'mesh', maxJournalEntries: 3 } - ); - } - const doc = loadDoc(store, contractId); - assert.ok(doc.entries.length <= 3); - assert.ok(doc.entries.some((e) => e.type === 'GroupChange')); - assert.ok(doc.content.members.includes(pubkeyXOnly(owner.pubkey))); - }); - - it('prefers genesis journal cap over peer-local meta.maxJournalEntries', function () { - const genesis = { - primitives: { maxJournalEntries: 10 } - }; - assert.strictEqual( - resolveMaxJournalEntries({ genesis, maxJournalEntries: 8 }, { maxJournalEntries: 3 }), - 10 - ); - assert.strictEqual( - resolveMaxJournalEntries({ maxJournalEntries: 8 }, { maxJournalEntries: 3 }), - 8 - ); - assert.strictEqual( - resolveMaxJournalEntries({}, { maxJournalEntries: 3 }), - 3 - ); - }); -}); - -describe('@fabric/core contractMessageCommit', function () { - it('tracks per-reader received and receipt phases', function () { - const a = new Key(); - const b = new Key(); - const record = createPending({ - id: 'm1', - contractId: 'c'.repeat(64), - readers: [a.pubkey, b.pubkey] - }); - assert.deepStrictEqual(phaseFlags(record, a.pubkey), { received: false, receipt: false }); - markReceived(record, a.pubkey); - assert.strictEqual(phaseFlags(record, a.pubkey).received, true); - assert.strictEqual(aggregatePhaseFlags(record).received, false); - markReceived(record, b.pubkey); - assert.strictEqual(aggregatePhaseFlags(record).received, true); - assert.strictEqual(allPhasesComplete(record), false); - const msg = receiptSigningMessage(record.id); - const sigA = Buffer.from(a.signSchnorr(msg)).toString('hex'); - const sigB = Buffer.from(b.signSchnorr(msg)).toString('hex'); - markReceipt(record, a.pubkey, null, sigA); - markReceipt(record, b.pubkey, null, sigB); - assert.strictEqual(allPhasesComplete(record), true); - assert.deepStrictEqual(aggregatePhaseFlags(record), { received: true, receipt: true }); - assert.strictEqual(pendingForRelay([record]).length, 0); - }); - - it('rejects mark for non-reader', function () { - const a = new Key(); - const outsider = new Key(); - const record = createPending({ - id: 'm2', - contractId: 'c'.repeat(64), - readers: [a.pubkey] - }); - assert.throws(() => markReceived(record, outsider.pubkey), /reader set/); - }); - - it('rejects markReceipt without a valid BIP340 receiptSig', function () { - const a = new Key(); - const record = createPending({ - id: 'm3', - contractId: 'c'.repeat(64), - readers: [a.pubkey] - }); - markReceived(record, a.pubkey); - assert.throws(() => markReceipt(record, a.pubkey), /receiptSig required/); - assert.throws(() => markReceipt(record, a.pubkey, null, 'ab'.repeat(32)), /invalid receiptSig/); - const wrong = Buffer.from(a.signSchnorr(receiptSigningMessage('other'))).toString('hex'); - assert.throws(() => markReceipt(record, a.pubkey, null, wrong), /invalid receiptSig/); - }); - - it('markReceived initializes peers map when missing (legacy records)', function () { - const a = new Key(); - const x = require('../functions/groupChatSeal').pubkeyXOnly(a.pubkey); - const record = { - id: 'm4', - contractId: 'c'.repeat(64), - readers: [x], - peers: null - }; - markReceived(record, a.pubkey); - assert.ok(record.peers && record.peers[x] && record.peers[x].receivedAt); - assert.throws(() => markReceived(record, new Key().pubkey), /reader set|invalid reader/); - }); - - it('preserves public reader wildcard and fail-closes empty delivery-ack authz', function () { - const { - normalizePubkeyList, - authorAllowedByReaders, - authorizeIngest - } = require('../functions/contractMessageAccumulate'); - const cid = 'c'.repeat(64); - const readers = normalizePubkeyList(['*', '02' + 'ab'.repeat(32)]); - assert.ok(readers.includes('*')); - assert.strictEqual(authorAllowedByReaders(['*'], 'cd'.repeat(32)), true); - assert.strictEqual(authorAllowedByReaders([], 'cd'.repeat(32)), false); - - const outsider = new Key(); - const doc = { - id: cid, - genesis: { signers: [], readers: [] }, - content: { members: [], signers: [] }, - entries: [] - }; - const denied = authorizeIngest(doc, 'MessageReceived', pubkeyXOnly(outsider.pubkey), {}, { - messageId: 'ab'.repeat(32) - }); - assert.strictEqual(denied.ok, false); - assert.match(denied.error, /fail closed/i); - - const publicDoc = { - id: cid, - genesis: { signers: [], readers: ['*'] }, - content: { members: [], signers: [] }, - entries: [] - }; - const allowed = authorizeIngest(publicDoc, 'MessageReceived', pubkeyXOnly(outsider.pubkey), {}, { - messageId: 'ab'.repeat(32) - }); - assert.strictEqual(allowed.ok, true); - }); -}); diff --git a/tests/contractMessageQueue.test.js b/tests/contractMessageQueue.test.js deleted file mode 100644 index 92217557f..000000000 --- a/tests/contractMessageQueue.test.js +++ /dev/null @@ -1,126 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const Key = require('../types/key'); -const Message = require('../types/message'); -const { - createMemoryStore, - enqueueMessageBuffer, - listQueuedMessages, - pendingForDelivery, - markDelivered, - entryHexList, - COLLECTION, - DEFAULT_MAX_ENTRIES, - ABSOLUTE_MAX_ENTRIES, - clampMaxEntries -} = require('../functions/contractMessageQueue'); -const { ingestMessageBuffer } = require('../functions/contractMessageAccumulate'); - -function signContractMessage (key, contractId, type, object) { - const body = JSON.stringify({ - contract: contractId, - type, - object - }); - return Message.fromVector(['CONTRACT_MESSAGE', body]).signWithKey(key); -} - -describe('@fabric/core contractMessageQueue', function () { - const contractId = 'd'.repeat(64); - - it('enqueues opaque bytes idempotently by message hash', function () { - const author = new Key(); - const msg = signContractMessage(author, contractId, 'GroupChat', { - body: 'queued', - author: author.pubkey - }); - const buf = msg.toBuffer(); - const store = createMemoryStore(); - const a = enqueueMessageBuffer(store, contractId, buf, { origin: 'mesh' }); - assert.strictEqual(a.accepted, true); - assert.ok(a.entry && a.entry.hex); - assert.strictEqual(a.entry.type, 'GroupChat'); - const b = enqueueMessageBuffer(store, contractId, buf, { origin: 'mesh' }); - assert.strictEqual(b.duplicate, true); - assert.strictEqual(listQueuedMessages(store, contractId).length, 1); - assert.strictEqual(store.get(COLLECTION, contractId).entries.length, 1); - }); - - it('tracks per-peer delivery without altering hex', function () { - const author = new Key(); - const msg = signContractMessage(author, contractId, 'GroupChat', { body: 'later' }); - const store = createMemoryStore(); - const enq = enqueueMessageBuffer(store, contractId, msg.toBuffer()); - const peer = 'peer-a'; - assert.strictEqual(pendingForDelivery(store, contractId, peer).length, 1); - const marked = markDelivered(store, contractId, enq.entry.hash, peer); - assert.strictEqual(marked.ok, true); - assert.strictEqual(pendingForDelivery(store, contractId, peer).length, 0); - assert.strictEqual(listQueuedMessages(store, contractId, { includeDelivered: true }).length, 1); - assert.strictEqual( - listQueuedMessages(store, contractId, { includeDelivered: true })[0].hex, - enq.entry.hex - ); - }); - - it('queue hex folds via accumulate with origin queue', function () { - const author = new Key(); - const genesis = { signers: [author.pubkey] }; - const msg = signContractMessage(author, contractId, 'GroupChat', { - body: 'from-queue', - author: author.pubkey, - ts: '2026-01-02T00:00:00.000Z' - }); - const qStore = createMemoryStore(); - enqueueMessageBuffer(qStore, contractId, msg.toBuffer(), { origin: 'mesh' }); - const hexes = entryHexList(listQueuedMessages(qStore, contractId)); - assert.strictEqual(hexes.length, 1); - - const accStore = createMemoryStore(); - const result = ingestMessageBuffer(accStore, contractId, hexes[0], { origin: 'queue', genesis }); - assert.strictEqual(result.accepted, true); - assert.strictEqual(result.tip.clock, 1); - }); - - it('rejects unsigned frames at enqueue', function () { - const store = createMemoryStore(); - const unsigned = Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ - contract: contractId, - type: 'GroupChat', - object: { body: 'no-sig' } - })]); - const bad = enqueueMessageBuffer(store, contractId, unsigned.toBuffer()); - assert.strictEqual(bad.accepted, false); - assert.match(bad.error, /invalid signature/i); - }); - - it('clamps maxEntries away from Infinity', function () { - // Non-finite values fall back to the default (not ABSOLUTE_MAX). - assert.strictEqual(clampMaxEntries(Number.POSITIVE_INFINITY), DEFAULT_MAX_ENTRIES); - assert.strictEqual(clampMaxEntries(1e999), DEFAULT_MAX_ENTRIES); - assert.strictEqual(clampMaxEntries(0), DEFAULT_MAX_ENTRIES); - assert.strictEqual(clampMaxEntries(12), 12); - assert.strictEqual(clampMaxEntries(ABSOLUTE_MAX_ENTRIES + 50), ABSOLUTE_MAX_ENTRIES); - }); - - it('trims to maxEntries preferring delivered rows', function () { - const author = new Key(); - const store = createMemoryStore(); - const hashes = []; - for (let i = 0; i < 5; i++) { - const msg = signContractMessage(author, contractId, 'GroupChat', { - body: `m${i}`, - ts: `2026-01-0${i + 1}T00:00:00.000Z` - }); - const r = enqueueMessageBuffer(store, contractId, msg.toBuffer(), { maxEntries: 3 }); - assert.strictEqual(r.accepted, true); - hashes.push(r.entry.hash); - if (i < 2) markDelivered(store, contractId, r.entry.hash, 'early-peer'); - } - const rows = listQueuedMessages(store, contractId, { includeDelivered: true }); - assert.ok(rows.length <= 3); - assert.ok(rows.length >= 1); - assert.ok(DEFAULT_MAX_ENTRIES >= 3); - }); -}); diff --git a/tests/contractProgramBind.test.js b/tests/contractProgramBind.test.js deleted file mode 100644 index 21a272c09..000000000 --- a/tests/contractProgramBind.test.js +++ /dev/null @@ -1,185 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const Key = require('../types/key'); -const Machine = require('../types/machine'); -const Program = require('../types/program'); -const { - resolveSpend, - buildWithdrawalRequest, - validateWithdrawalRequest, - bindProgramRunToTip, - programMetaFromTip, - requiresProgramBind -} = require('../functions/contractSpend'); -const { - assertMachineRunMatches, - validateProgramRunBinding -} = require('../functions/contractProgramBind'); - -describe('contractProgramBind / Machine ↔ redeemable withdrawals', function () { - const blockHash = 'ab'.repeat(32); - - async function runOpcodeProgram () { - const prog = Program.from({ - language: 'fabric-opcodes', - steps: ['OP_TRUE'] - }); - prog.compile(); - const machine = new Machine({ deterministic: true }); - machine.define('OP_TRUE', () => true); - const run = await machine.runProgram(prog); - assert.strictEqual(run.ok, true); - assert.ok(run.runCommitmentHex); - const match = assertMachineRunMatches({ program: prog, run }); - assert.strictEqual(match.ok, true); - assert.strictEqual(match.runCommitmentHex, run.runCommitmentHex); - return { prog, run, match }; - } - - it('assertMachineRunMatches prefers run.trace when stack was pre-seeded', async function () { - const prog = Program.from({ - language: 'fabric-opcodes', - steps: ['OP_TRUE'] - }); - prog.compile(); - const machine = new Machine({ deterministic: true }); - machine.define('OP_TRUE', () => true); - machine.stack.push({ preseed: true }); - const run = await machine.runProgram(prog); - assert.strictEqual(run.ok, true); - assert.ok(run.stack.length > run.trace.length); - const match = assertMachineRunMatches({ program: prog, run }); - assert.strictEqual(match.ok, true, match.error); - assert.strictEqual(match.runCommitmentHex, run.runCommitmentHex); - }); - - it('Machine runCommitmentHex is stable and binds into tip without changing P2TR', async function () { - const a = new Key(); - const { prog, run, match } = await runOpcodeProgram(); - const again = await (async () => { - const m = new Machine({ deterministic: true }); - m.define('OP_TRUE', () => true); - return m.runProgram(prog); - })(); - assert.strictEqual(again.runCommitmentHex, run.runCommitmentHex); - - const tip0 = { - contractId: 'c'.repeat(64), - clock: 1, - stateDigest: '11'.repeat(32), - bitcoinBlockHash: blockHash, - content: { - signers: [a.pubkey], - members: [a.pubkey], - threshold: 1 - } - }; - const tip1 = bindProgramRunToTip(tip0, { - programHash: match.programHash, - runCommitmentHex: match.runCommitmentHex, - programId: prog.programId, - clock: tip0.clock - }); - assert.ok(tip1.content.program); - assert.strictEqual(programMetaFromTip(tip1).programHash, match.programHash); - - const spend0 = resolveSpend({ - genesis: { - members: { signers: [a.pubkey], threshold: 1 }, - spendPolicy: { - validators: [a.pubkey], - threshold: 1, - publisher: a.pubkey, - csvBlocks: 144 - } - }, - tip: tip0, - overrides: { network: 'regtest' } - }); - const spend1 = resolveSpend({ - genesis: { - members: { signers: [a.pubkey], threshold: 1 }, - spendPolicy: { - validators: [a.pubkey], - threshold: 1, - publisher: a.pubkey, - csvBlocks: 144 - } - }, - tip: tip1, - overrides: { network: 'regtest' } - }); - assert.strictEqual(spend1.address, spend0.address); - }); - - it('withdrawal requires matching program digests when tip seals a run', async function () { - const a = new Key(); - const { match } = await runOpcodeProgram(); - let tip = { - contractId: 'd'.repeat(64), - clock: 2, - stateDigest: '22'.repeat(32), - bitcoinBlockHash: blockHash, - content: { signers: [a.pubkey], threshold: 1 } - }; - tip = bindProgramRunToTip(tip, { - programHash: match.programHash, - runCommitmentHex: match.runCommitmentHex - }); - - const missing = validateWithdrawalRequest({ - stateDigest: tip.stateDigest, - bitcoinBlockHash: tip.bitcoinBlockHash, - destinationAddress: 'bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3l9y0' - }, tip); - assert.strictEqual(missing.ok, false); - assert.match(missing.error, /programHash|runCommitment/i); - - const stale = validateWithdrawalRequest({ - stateDigest: tip.stateDigest, - bitcoinBlockHash: tip.bitcoinBlockHash, - destinationAddress: 'bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3l9y0', - programHash: match.programHash, - runCommitmentHex: 'ff'.repeat(32) - }, tip); - assert.strictEqual(stale.ok, false); - assert.match(stale.error, /runCommitmentHex/); - - const okReq = buildWithdrawalRequest({ - tip, - contractId: tip.contractId, - destinationAddress: 'bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3l9y0', - feeSats: 500 - }); - assert.strictEqual(okReq.programHash, match.programHash); - assert.strictEqual(okReq.runCommitmentHex, match.runCommitmentHex); - assert.strictEqual(validateWithdrawalRequest(okReq, tip).ok, true); - }); - - it('genesis with fabric.program interface requires tip-sealed run', function () { - assert.strictEqual(requiresProgramBind({ - interfaces: ['arc.core', 'fabric.program'], - primitives: { opcodes: [] } - }), true); - assert.strictEqual(requiresProgramBind({ - primitives: { opcodes: ['OP_TRUE'] } - }), true); - assert.strictEqual(requiresProgramBind({ - spendPolicy: { requireProgramRun: true } - }), true); - assert.strictEqual(requiresProgramBind({ name: 'plain' }), false); - - const check = validateProgramRunBinding({ - object: { - stateDigest: '11'.repeat(32), - bitcoinBlockHash: blockHash, - destinationAddress: 'bcrt1q' - }, - tip: { stateDigest: '11'.repeat(32), bitcoinBlockHash: blockHash, content: {} }, - genesis: { interfaces: ['fabric.program'] } - }); - assert.strictEqual(check.ok, false); - assert.match(check.error, /tip\.content\.program/); - }); -}); diff --git a/tests/contractSpend.test.js b/tests/contractSpend.test.js deleted file mode 100644 index 2909f731d..000000000 --- a/tests/contractSpend.test.js +++ /dev/null @@ -1,444 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const Key = require('../types/key'); -const Message = require('../types/message'); -const Contract = require('../types/contract'); -const { - normalizeArcGenesis, - resolveSpend, - buildWithdrawalRequest, - buildWithdrawalWitness, - validateWithdrawalRequest, - prepareWithdrawalFromRequest, - verifyWithdrawalWitnessSignature -} = require('../functions/contractSpend'); -const { pubkeyXOnly } = require('../functions/groupChatSeal'); -const { - createMemoryStore, - ingestMessageBuffer, - tipFromDoc, - loadDoc -} = require('../functions/contractMessageAccumulate'); - -describe('contractSpend / ARC resolveSpend', function () { - it('normalizeArcGenesis maps proposedPolicy + messageTypes', function () { - const a = new Key(); - const arc = normalizeArcGenesis({ - name: 'DemoGroup', - messageTypes: ['GroupChat', 'GroupChange'], - proposedPolicy: { validators: [a.pubkey], threshold: 1 }, - creator: a.pubkey, - parentContractId: 'parent'.padEnd(64, '0'), - softMode: 'reduced', - depositMaturityBlocks: 144 - }); - assert.strictEqual(arc.name, 'DemoGroup'); - assert.deepStrictEqual(arc.primitives.messageTypes, ['GroupChat', 'GroupChange']); - assert.strictEqual(arc.members.signers.length, 1); - assert.strictEqual(arc.spendPolicy.validators.length, 1); - assert.strictEqual(arc.spendPolicy.threshold, 1); - assert.strictEqual(arc.spendPolicy.softMode, 'reduced'); - assert.strictEqual(arc.spendPolicy.csvBlocks, 144); - assert.ok(!Object.prototype.hasOwnProperty.call(arc.spendPolicy, 'depositMaturityBlocks')); - assert.strictEqual(arc.extension.parentContract, 'parent'.padEnd(64, '0')); - assert.strictEqual(arc.extension.parentContractId, arc.extension.parentContract); - }); - - it('resolveSpend prefers tip.content.signers over members roster', function () { - const a = new Key(); - const b = new Key(); - const { - tipSpendKeys - } = require('../functions/contractSpend'); - const tip = { - content: { - signers: [a.pubkey], - members: [a.pubkey.slice(2), b.pubkey.slice(2)], - threshold: 1 - } - }; - assert.deepStrictEqual(tipSpendKeys(tip), [a.pubkey]); - const spend = resolveSpend({ - genesis: { - members: { signers: [a.pubkey], threshold: 1 }, - spendPolicy: { - validators: [a.pubkey], - threshold: 1, - publisher: a.pubkey, - csvBlocks: 144, - softMode: 'publisher' - } - }, - tip, - overrides: { network: 'regtest' } - }); - assert.strictEqual(spend.validators.length, 1); - assert.ok(spend.bitcoinAnchor === null || spend.bitcoinAnchor); - assert.strictEqual(spend.softMode, 'publisher'); - assert.strictEqual(spend.spendAddress, spend.address); - assert.strictEqual(spend.depositMaturityBlocks, spend.csvBlocks); - assert.ok(spend.spendPolicy); - assert.strictEqual(spend.spendPolicy.softMode, 'publisher'); - }); - - it('explicit empty tip.content.signers does not widen spend keys to members', function () { - const a = new Key(); - const reader = new Key(); - const { - tipSpendKeys - } = require('../functions/contractSpend'); - const tip = { - content: { - signers: [], - members: [a.pubkey.slice(2), reader.pubkey.slice(2)], - threshold: 1 - } - }; - assert.deepStrictEqual(tipSpendKeys(tip), []); - const spend = resolveSpend({ - genesis: { - members: { signers: [a.pubkey], threshold: 1 }, - spendPolicy: { - validators: [a.pubkey], - threshold: 1, - publisher: a.pubkey, - csvBlocks: 144, - softMode: 'publisher' - } - }, - tip, - overrides: { network: 'regtest' } - }); - assert.strictEqual(spend.validators.length, 1); - assert.strictEqual(spend.validators[0], require('../functions/groupChatSeal').pubkeyCompressed(a.pubkey) || a.pubkey); - }); - - it('resolveSpend is stable for tip members', function () { - const a = new Key(); - const b = new Key(); - const genesis = { - spendPolicy: { network: 'regtest', validators: [a.pubkey], threshold: 1, csvBlocks: 144 }, - members: { signers: [a.pubkey], threshold: 1 }, - bitcoinAnchor: { blockHash: 'ab'.repeat(32), height: 10 } - }; - const tip = { - contractId: 'c'.repeat(64), - clock: 2, - stateDigest: 'cd'.repeat(32), - bitcoinBlockHash: 'ab'.repeat(32), - bitcoinHeight: 10, - content: { members: [a.pubkey.slice(2), b.pubkey.slice(2)], threshold: 2 } - }; - const spend = resolveSpend({ genesis, tip }); - assert.ok(spend.address); - assert.match(spend.address, /^bcrt1p/); - assert.strictEqual(spend.threshold, 2); - assert.strictEqual(spend.validators.length, 2); - assert.strictEqual(spend.bitcoinBlockHash, 'ab'.repeat(32)); - - const again = resolveSpend({ genesis, tip }); - assert.strictEqual(again.address, spend.address); - }); - - it('resolveSpend address matches federation vault ladder', function () { - const a = new Key(); - const b = new Key(); - const vals = [a.pubkey, b.pubkey]; - const { - buildFederationVaultFromPolicy - } = require('../functions/contractTaproot'); - const vault = buildFederationVaultFromPolicy({ - validatorPubkeysHex: vals, - threshold: 2, - networkName: 'regtest', - publisher: a.pubkey, - csvBlocks: 144 - }); - const spend = resolveSpend({ - genesis: { - members: { signers: vals, threshold: 2 }, - spendPolicy: { - validators: vals, - threshold: 2, - publisher: a.pubkey, - csvBlocks: 144, - network: 'regtest' - } - }, - tip: { content: { members: vals.map((p) => p.slice(2)) } }, - overrides: { network: 'regtest' } - }); - assert.strictEqual(spend.address, vault.address); - assert.strictEqual(spend.scheme, 'taproot-authority-ladder-v1'); - assert.strictEqual(spend.csvBlocks, 144); - assert.ok(spend.softTier); - }); - - it('resolveSpend fails closed without explicit network', function () { - const a = new Key(); - const genesis = { - members: { signers: [a.pubkey], threshold: 1 }, - spendPolicy: { validators: [a.pubkey], threshold: 1 } - }; - assert.throws(() => resolveSpend({ - genesis, - tip: { content: { members: [a.pubkey.slice(2)] } } - }), /network required/i); - const spend = resolveSpend({ - genesis, - tip: { content: { members: [a.pubkey.slice(2)] } }, - overrides: { network: 'signet' } - }); - assert.match(spend.address, /^tb1p/); - }); - - it('normalizeArcGenesis does not default spendPolicy.network to regtest', function () { - const a = new Key(); - const arc = normalizeArcGenesis({ - proposedPolicy: { validators: [a.pubkey], threshold: 1 } - }); - assert.strictEqual(arc.spendPolicy.network, undefined); - }); - - it('Contract#resolveSpend uses settings as genesis', function () { - const k = new Key(); - const c = new Contract({ - validators: [k.pubkey], - threshold: 1, - network: 'regtest', - csvBlocks: 144, - bitcoinAnchor: { blockHash: '11'.repeat(32), height: 1 } - }); - const spend = c.resolveSpend({ - tip: { - content: { members: [k.pubkey.slice(2)] }, - stateDigest: '22'.repeat(32), - bitcoinBlockHash: '11'.repeat(32) - }, - overrides: { network: 'regtest' } - }); - assert.ok(spend.address); - assert.strictEqual(spend.address, c.toAddress('regtest')); - }); - - it('withdrawal request binds tip digest + block hash', function () { - const k = new Key(); - const tip = { - contractId: 'c'.repeat(64), - clock: 1, - stateDigest: 'aa'.repeat(32), - bitcoinBlockHash: 'bb'.repeat(32), - bitcoinHeight: 42, - content: { members: [] } - }; - const req = buildWithdrawalRequest({ - tip, - destinationAddress: 'bcrt1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', - feeSats: 500 - }); - assert.strictEqual(req.type, 'ContractWithdrawalRequest'); - assert.ok(req.requestId); - assert.strictEqual(req.stateDigest, tip.stateDigest); - assert.strictEqual(req.bitcoinBlockHash, tip.bitcoinBlockHash); - assert.strictEqual(validateWithdrawalRequest(req, tip).ok, true); - assert.strictEqual(validateWithdrawalRequest(req, Object.assign({}, tip, { - stateDigest: 'cc'.repeat(32) - })).ok, false); - - const diverted = Object.assign({}, req, { - destinationAddress: 'bcrt1q00000000000000000000000000000000000000', - // Keep original requestId — must fail commitment bind. - requestId: req.requestId - }); - const divertedCheck = validateWithdrawalRequest(diverted, tip); - assert.strictEqual(divertedCheck.ok, false); - assert.match(String(divertedCheck.error || ''), /requestId does not match/); - - const missingId = Object.assign({}, req); - delete missingId.requestId; - assert.strictEqual(validateWithdrawalRequest(missingId, tip).ok, false); - - const wit = buildWithdrawalWitness({ - request: req, - signer: pubkeyXOnly(k.pubkey), - signerKey: k - }); - assert.strictEqual(wit.requestId, req.requestId); - assert.strictEqual(wit.bitcoinBlockHash, tip.bitcoinBlockHash); - assert.ok(/^[0-9a-f]{128}$/.test(wit.signature)); - assert.strictEqual(verifyWithdrawalWitnessSignature(wit).ok, true); - }); - - it('ingest rejects withdrawal with stale tip binding', function () { - const owner = new Key(); - const store = createMemoryStore(); - const contractId = 'e'.repeat(64); - const blockHash = 'ff'.repeat(32); - const genesis = { - signers: [owner.pubkey], - bitcoinAnchor: { blockHash, height: 7 }, - primitives: { - messageTypes: ['GroupChange', 'ContractWithdrawalRequest', 'GroupChat'] - } - }; - const setMembers = Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ - contract: contractId, - type: 'GroupChange', - object: { action: 'members.set', members: [owner.pubkey] } - })]).signWithKey(owner); - const okSet = ingestMessageBuffer(store, contractId, setMembers.toBuffer(), { - origin: 'local', - genesis, - bitcoinBlockHash: blockHash, - bitcoinHeight: 7 - }); - assert.strictEqual(okSet.accepted, true); - - const tip = tipFromDoc(loadDoc(store, contractId)); - const stale = buildWithdrawalRequest({ - tip: Object.assign({}, tip, { stateDigest: '00'.repeat(32) }), - destinationAddress: 'bcrt1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', - feeSats: 250 - }); - // Force stale digest onto a signed message - const bad = Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ - contract: contractId, - type: 'ContractWithdrawalRequest', - object: stale - })]).signWithKey(owner); - const rejected = ingestMessageBuffer(store, contractId, bad.toBuffer(), { - origin: 'mesh', - genesis, - bitcoinBlockHash: blockHash - }); - assert.strictEqual(rejected.accepted, false); - assert.match(rejected.error, /stateDigest|stale/i); - - const goodReq = buildWithdrawalRequest({ - tip, - destinationAddress: 'bcrt1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', - feeSats: 250 - }); - const good = Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ - contract: contractId, - type: 'ContractWithdrawalRequest', - object: goodReq - })]).signWithKey(owner); - const accepted = ingestMessageBuffer(store, contractId, good.toBuffer(), { - origin: 'mesh', - genesis, - bitcoinBlockHash: blockHash - }); - assert.strictEqual(accepted.accepted, true); - assert.ok(accepted.tip.content.withdrawals.some((w) => w.requestId === goodReq.requestId)); - }); - - it('prepareWithdrawalFromRequest refuses tip mismatch', function () { - const a = new Key(); - const genesis = { - spendPolicy: { network: 'regtest', validators: [a.pubkey], threshold: 1, csvBlocks: 144 }, - members: { signers: [a.pubkey], threshold: 1 } - }; - const tip = { - contractId: 'd'.repeat(64), - stateDigest: '11'.repeat(32), - bitcoinBlockHash: '22'.repeat(32), - content: { members: [a.pubkey.slice(2)] } - }; - const req = buildWithdrawalRequest({ - tip, - destinationAddress: 'bcrt1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', - feeSats: 100 - }); - assert.throws(() => prepareWithdrawalFromRequest({ - request: req, - tip: Object.assign({}, tip, { bitcoinBlockHash: '33'.repeat(32) }), - genesis, - fundedTxHex: '02000000000100' - }), /bitcoinBlockHash|match/i); - }); - - it('ingest rejects forged ContractWithdrawalWitness (unbound signer / bad sig)', function () { - const owner = new Key(); - const coSigner = new Key(); - const store = createMemoryStore(); - const contractId = 'f'.repeat(64); - const blockHash = 'aa'.repeat(32); - const genesis = { - signers: [owner.pubkey, coSigner.pubkey], - bitcoinAnchor: { blockHash, height: 3 }, - primitives: { - messageTypes: [ - 'GroupChange', - 'ContractWithdrawalRequest', - 'ContractWithdrawalWitness' - ] - } - }; - assert.strictEqual(ingestMessageBuffer(store, contractId, Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ - contract: contractId, - type: 'GroupChange', - object: { action: 'members.set', members: [owner.pubkey, coSigner.pubkey] } - })]).signWithKey(owner).toBuffer(), { - origin: 'local', - genesis, - bitcoinBlockHash: blockHash, - bitcoinHeight: 3 - }).accepted, true); - - const tip = tipFromDoc(loadDoc(store, contractId)); - const req = buildWithdrawalRequest({ - tip, - destinationAddress: 'bcrt1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', - feeSats: 100 - }); - assert.strictEqual(ingestMessageBuffer(store, contractId, Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ - contract: contractId, - type: 'ContractWithdrawalRequest', - object: req - })]).signWithKey(owner).toBuffer(), { - origin: 'mesh', - genesis, - bitcoinBlockHash: blockHash - }).accepted, true); - - // Malicious co-signer claims honest owner's key with garbage signature. - const forged = buildWithdrawalWitness({ - request: req, - signer: pubkeyXOnly(owner.pubkey), - signature: 'ab'.repeat(64) - }); - const bad = ingestMessageBuffer(store, contractId, Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ - contract: contractId, - type: 'ContractWithdrawalWitness', - object: forged - })]).signWithKey(coSigner).toBuffer(), { - origin: 'mesh', - genesis, - bitcoinBlockHash: blockHash - }); - assert.strictEqual(bad.accepted, false); - assert.match(bad.error, /signer must match|invalid witness/i); - - const honest = buildWithdrawalWitness({ - request: req, - signer: pubkeyXOnly(coSigner.pubkey), - signerKey: coSigner - }); - const ok = ingestMessageBuffer(store, contractId, Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ - contract: contractId, - type: 'ContractWithdrawalWitness', - object: honest - })]).signWithKey(coSigner).toBuffer(), { - origin: 'mesh', - genesis, - bitcoinBlockHash: blockHash - }); - assert.strictEqual(ok.accepted, true, ok.error); - const row = ok.tip.content.withdrawals.find((w) => w.requestId === req.requestId); - assert.ok(row); - assert.strictEqual(row.witnesses.length, 1); - assert.strictEqual(row.witnesses[0].signer, pubkeyXOnly(coSigner.pubkey)); - }); -}); diff --git a/tests/contractTaproot.compose.test.js b/tests/contractTaproot.compose.test.js deleted file mode 100644 index 9d06d0c3b..000000000 --- a/tests/contractTaproot.compose.test.js +++ /dev/null @@ -1,132 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const crypto = require('crypto'); -const bitcoin = require('bitcoinjs-lib'); -const Key = require('../types/key'); -const { - synthesizeDefaultLadder, - buildContractTaproot, - composeTaprootTree, - buildHashlockLeaf, - buildSpendLeaf, - buildScriptLeaf, - compileLeaves, - prepareHashlockWithdrawalPsbt, - finalizeHashlockPsbt, - toAddress -} = require('../functions/contractTaproot'); - -describe('contractTaproot composable trees + hashlock', function () { - const keys = []; - before(function () { - for (let i = 0; i < 4; i++) keys.push(new Key()); - }); - function pk (i) { return keys[i].pubkey; } - - it('optional hashlock leaf changes address vs ladder-only', function () { - const commitmentHex = crypto.createHash('sha256').update('run').digest('hex'); - const ladder = synthesizeDefaultLadder({ - validators: [pk(0), pk(1)], - threshold: 1, - publisher: pk(0), - network: 'regtest', - csvBlocks: 144 - }); - const withLock = synthesizeDefaultLadder({ - validators: [pk(0), pk(1)], - threshold: 1, - publisher: pk(0), - network: 'regtest', - csvBlocks: 144, - hashlock: { commitmentHex, id: 'hashlock-run' } - }); - const a = toAddress(ladder); - const b = toAddress(withLock); - assert.notStrictEqual(a, b); - const built = buildContractTaproot(withLock); - assert.ok(built.leaves.some((l) => l.kind === 'hashlock' && l.id === 'hashlock-run')); - assert.strictEqual(built.leaves.find((l) => l.kind === 'hashlock').commitmentHex, commitmentHex); - }); - - it('composeTaprootTree builds arbitrary spend + script + hashlock trees', function () { - const preimage = crypto.randomBytes(32); - const commitmentHex = crypto.createHash('sha256').update(preimage).digest('hex'); - const spend = buildSpendLeaf({ - id: 'alice', - keys: [pk(0)], - threshold: 1 - }); - const hashlock = buildHashlockLeaf({ commitmentHex, id: 'hl' }); - const custom = buildScriptLeaf({ - id: 'true', - asm: 'OP_TRUE' - }); - const tree = composeTaprootTree({ - network: 'regtest', - leaves: [spend, hashlock, custom] - }); - assert.ok(tree.address.startsWith('bcrt1')); - assert.strictEqual(tree.leaves.length, 3); - assert.ok(tree.merkleRootHex); - assert.strictEqual(tree.scheme, 'taproot-composed-v1'); - }); - - it('pure hashlock path: prepare + finalize with preimage only', function () { - const preimage = crypto.randomBytes(32); - const commitmentHex = crypto.createHash('sha256').update(preimage).digest('hex'); - const policy = synthesizeDefaultLadder({ - validators: [pk(0)], - threshold: 1, - publisher: pk(0), - network: 'regtest', - hashlock: { commitmentHex } - }); - const built = buildContractTaproot(policy); - const value = 100000n; - const fund = new bitcoin.Transaction(); - fund.version = 2; - fund.addInput(Buffer.alloc(32), 0); - fund.addOutput(built.output, value); - - const prepared = prepareHashlockWithdrawalPsbt({ - policy, - fundedTxHex: fund.toHex(), - vaultAddress: built.address, - destinationAddress: bitcoin.payments.p2wpkh({ - pubkey: Buffer.from(pk(1), 'hex'), - network: bitcoin.networks.regtest - }).address, - feeSats: 1000, - preimage32: preimage - }); - assert.ok(prepared.psbtBase64); - assert.strictEqual(prepared.action, 'hashlock'); - assert.strictEqual(prepared.witnessShape, 'preimage'); - - const fin = finalizeHashlockPsbt({ - psbtBase64: prepared.psbtBase64, - preimage32: preimage, - witnessShape: 'preimage' - }); - assert.ok(fin.txHex); - assert.ok(fin.txid); - const spent = bitcoin.Transaction.fromHex(fin.txHex); - assert.strictEqual(spent.ins[0].witness.length, 3); - }); - - it('compileLeaves appends extraLeaves after authority tiers', function () { - const policy = synthesizeDefaultLadder({ - validators: [pk(0), pk(1)], - threshold: 2, - publisher: pk(0), - network: 'regtest', - extraLeaves: [ - { kind: 'script', id: 'op-true', asm: 'OP_TRUE' } - ] - }); - const leaves = compileLeaves(policy); - assert.ok(leaves.some((l) => l.kind === 'spend' && l.id === 't0-authority')); - assert.ok(leaves.some((l) => l.kind === 'script' && l.id === 'op-true')); - }); -}); diff --git a/tests/contractTaproot.unit.js b/tests/contractTaproot.unit.js index 8d9cef0dc..a32091c94 100644 --- a/tests/contractTaproot.unit.js +++ b/tests/contractTaproot.unit.js @@ -18,7 +18,15 @@ const { networkForFabricName, buildFederationVaultFromPolicy, prepareTierWithdrawalPsbt, - parseCompressedPubkeysSorted + parseCompressedPubkeysSorted, + TAPROOT_INTERNAL_NUMS, + composeTaprootTree, + buildHashlockLeaf, + buildSpendLeaf, + buildScriptLeaf, + compileLeaves, + prepareHashlockWithdrawalPsbt, + finalizeHashlockPsbt } = require('../functions/contractTaproot'); describe('contractTaproot', function () { @@ -50,6 +58,39 @@ describe('contractTaproot', function () { assert.ok(a.startsWith('bcrt1')); }); + it('uses MuSig2 of the full validator set as the Taproot internal key when n>=2', function () { + const { aggregateXonly } = require('../functions/musig2'); + const vals = [pk(0), pk(1), pk(2)]; + const policy = synthesizeDefaultLadder({ + validators: vals, + threshold: 2, + publisher: pk(0), + network: 'regtest', + csvBlocks: 144 + }); + assert.strictEqual(policy.internalKeyMode, 'musig2'); + const built = buildContractTaproot(policy); + const expected = aggregateXonly(vals).toString('hex'); + assert.strictEqual(built.internalPubkeyHex, expected); + const spend = built.leafScripts.find((l) => l.kind === 'spend'); + const cb = require('../functions/contractTaproot').buildControlBlockForLeaf( + built.leafScripts, + spend.script, + Buffer.from(built.internalPubkeyHex, 'hex') + ); + assert.strictEqual(cb.subarray(1, 33).toString('hex'), expected); + const nums = synthesizeDefaultLadder({ + validators: vals, + threshold: 2, + publisher: pk(0), + network: 'regtest', + csvBlocks: 144, + internalKeyMode: 'nums' + }); + assert.notStrictEqual(toAddress(nums), built.address); + assert.strictEqual(buildContractTaproot(nums).internalPubkeyHex, TAPROOT_INTERNAL_NUMS.toString('hex')); + }); + it('builds multi-tier failover with until; expired tiers leave active set', function () { const policy = normalizeContractSpendPolicy({ network: 'regtest', @@ -540,3 +581,556 @@ describe('contractTaproot', function () { assert.strictEqual(prep.sequence >>> 31, 0); }); }); + +const crypto = require('crypto'); + +describe('contractTaproot composable trees + hashlock', function () { + const keys = []; + before(function () { + for (let i = 0; i < 4; i++) keys.push(new Key()); + }); + function pk (i) { return keys[i].pubkey; } + + it('optional hashlock leaf changes address vs ladder-only', function () { + const commitmentHex = crypto.createHash('sha256').update('run').digest('hex'); + const ladder = synthesizeDefaultLadder({ + validators: [pk(0), pk(1)], + threshold: 1, + publisher: pk(0), + network: 'regtest', + csvBlocks: 144 + }); + const withLock = synthesizeDefaultLadder({ + validators: [pk(0), pk(1)], + threshold: 1, + publisher: pk(0), + network: 'regtest', + csvBlocks: 144, + hashlock: { commitmentHex, id: 'hashlock-run' } + }); + const a = toAddress(ladder); + const b = toAddress(withLock); + assert.notStrictEqual(a, b); + const built = buildContractTaproot(withLock); + assert.ok(built.leaves.some((l) => l.kind === 'hashlock' && l.id === 'hashlock-run')); + assert.strictEqual(built.leaves.find((l) => l.kind === 'hashlock').commitmentHex, commitmentHex); + }); + + it('composeTaprootTree builds arbitrary spend + script + hashlock trees', function () { + const preimage = crypto.randomBytes(32); + const commitmentHex = crypto.createHash('sha256').update(preimage).digest('hex'); + const spend = buildSpendLeaf({ + id: 'alice', + keys: [pk(0)], + threshold: 1 + }); + const hashlock = buildHashlockLeaf({ commitmentHex, id: 'hl' }); + const custom = buildScriptLeaf({ + id: 'true', + asm: 'OP_TRUE' + }); + const tree = composeTaprootTree({ + network: 'regtest', + leaves: [spend, hashlock, custom] + }); + assert.ok(tree.address.startsWith('bcrt1')); + assert.strictEqual(tree.leaves.length, 3); + assert.ok(tree.merkleRootHex); + assert.strictEqual(tree.scheme, 'taproot-composed-v1'); + }); + + it('pure hashlock path: prepare + finalize with preimage only', function () { + const preimage = crypto.randomBytes(32); + const commitmentHex = crypto.createHash('sha256').update(preimage).digest('hex'); + const policy = synthesizeDefaultLadder({ + validators: [pk(0)], + threshold: 1, + publisher: pk(0), + network: 'regtest', + hashlock: { commitmentHex } + }); + const built = buildContractTaproot(policy); + const value = 100000n; + const fund = new bitcoin.Transaction(); + fund.version = 2; + fund.addInput(Buffer.alloc(32), 0); + fund.addOutput(built.output, value); + + const prepared = prepareHashlockWithdrawalPsbt({ + policy, + fundedTxHex: fund.toHex(), + vaultAddress: built.address, + destinationAddress: bitcoin.payments.p2wpkh({ + pubkey: Buffer.from(pk(1), 'hex'), + network: bitcoin.networks.regtest + }).address, + feeSats: 1000, + preimage32: preimage + }); + assert.ok(prepared.psbtBase64); + assert.strictEqual(prepared.action, 'hashlock'); + assert.strictEqual(prepared.witnessShape, 'preimage'); + + const fin = finalizeHashlockPsbt({ + psbtBase64: prepared.psbtBase64, + preimage32: preimage, + witnessShape: 'preimage' + }); + assert.ok(fin.txHex); + assert.ok(fin.txid); + const spent = bitcoin.Transaction.fromHex(fin.txHex); + assert.strictEqual(spent.ins[0].witness.length, 3); + }); + + it('compileLeaves appends extraLeaves after authority tiers', function () { + const policy = synthesizeDefaultLadder({ + validators: [pk(0), pk(1)], + threshold: 2, + publisher: pk(0), + network: 'regtest', + extraLeaves: [ + { kind: 'script', id: 'op-true', asm: 'OP_TRUE' } + ] + }); + const leaves = compileLeaves(policy); + assert.ok(leaves.some((l) => l.kind === 'spend' && l.id === 't0-authority')); + assert.ok(leaves.some((l) => l.kind === 'script' && l.id === 'op-true')); + }); +}); + +const Message = require('../types/message'); +const { + normalizeArcGenesis, + resolveSpend, + buildWithdrawalRequest, + buildWithdrawalWitness, + validateWithdrawalRequest, + prepareWithdrawalFromRequest, + verifyWithdrawalWitnessSignature +} = require('../functions/contractSpend'); +const { pubkeyXOnly } = require('../functions/groupChatSeal'); +const { + createMemoryStore, + ingestMessageBuffer, + tipFromDoc, + loadDoc +} = require('../functions/contractMessageAccumulate'); + +describe('contractSpend / ARC resolveSpend', function () { + it('normalizeArcGenesis maps proposedPolicy + messageTypes', function () { + const a = new Key(); + const arc = normalizeArcGenesis({ + name: 'DemoGroup', + messageTypes: ['GroupChat', 'GroupChange'], + proposedPolicy: { validators: [a.pubkey], threshold: 1 }, + creator: a.pubkey, + parentContractId: 'parent'.padEnd(64, '0'), + softMode: 'reduced', + depositMaturityBlocks: 144 + }); + assert.strictEqual(arc.name, 'DemoGroup'); + assert.deepStrictEqual(arc.primitives.messageTypes, ['GroupChat', 'GroupChange']); + assert.strictEqual(arc.members.signers.length, 1); + assert.strictEqual(arc.spendPolicy.validators.length, 1); + assert.strictEqual(arc.spendPolicy.threshold, 1); + assert.strictEqual(arc.spendPolicy.softMode, 'reduced'); + assert.strictEqual(arc.spendPolicy.csvBlocks, 144); + assert.ok(!Object.prototype.hasOwnProperty.call(arc.spendPolicy, 'depositMaturityBlocks')); + assert.strictEqual(arc.extension.parentContract, 'parent'.padEnd(64, '0')); + assert.strictEqual(arc.extension.parentContractId, arc.extension.parentContract); + }); + + it('resolveSpend prefers tip.content.signers over members roster', function () { + const a = new Key(); + const b = new Key(); + const { + tipSpendKeys + } = require('../functions/contractSpend'); + const tip = { + content: { + signers: [a.pubkey], + members: [a.pubkey.slice(2), b.pubkey.slice(2)], + threshold: 1 + } + }; + assert.deepStrictEqual(tipSpendKeys(tip), [a.pubkey]); + const spend = resolveSpend({ + genesis: { + members: { signers: [a.pubkey], threshold: 1 }, + spendPolicy: { + validators: [a.pubkey], + threshold: 1, + publisher: a.pubkey, + csvBlocks: 144, + softMode: 'publisher' + } + }, + tip, + overrides: { network: 'regtest' } + }); + assert.strictEqual(spend.validators.length, 1); + assert.ok(spend.bitcoinAnchor === null || spend.bitcoinAnchor); + assert.strictEqual(spend.softMode, 'publisher'); + assert.strictEqual(spend.spendAddress, spend.address); + assert.strictEqual(spend.depositMaturityBlocks, spend.csvBlocks); + assert.ok(spend.spendPolicy); + assert.strictEqual(spend.spendPolicy.softMode, 'publisher'); + }); + + it('explicit empty tip.content.signers does not widen spend keys to members', function () { + const a = new Key(); + const reader = new Key(); + const { + tipSpendKeys + } = require('../functions/contractSpend'); + const tip = { + content: { + signers: [], + members: [a.pubkey.slice(2), reader.pubkey.slice(2)], + threshold: 1 + } + }; + assert.deepStrictEqual(tipSpendKeys(tip), []); + const spend = resolveSpend({ + genesis: { + members: { signers: [a.pubkey], threshold: 1 }, + spendPolicy: { + validators: [a.pubkey], + threshold: 1, + publisher: a.pubkey, + csvBlocks: 144, + softMode: 'publisher' + } + }, + tip, + overrides: { network: 'regtest' } + }); + assert.strictEqual(spend.validators.length, 1); + assert.strictEqual(spend.validators[0], require('../functions/groupChatSeal').pubkeyCompressed(a.pubkey) || a.pubkey); + }); + + it('resolveSpend is stable for tip members', function () { + const a = new Key(); + const b = new Key(); + const genesis = { + spendPolicy: { network: 'regtest', validators: [a.pubkey], threshold: 1, csvBlocks: 144 }, + members: { signers: [a.pubkey], threshold: 1 }, + bitcoinAnchor: { blockHash: 'ab'.repeat(32), height: 10 } + }; + const tip = { + contractId: 'c'.repeat(64), + clock: 2, + stateDigest: 'cd'.repeat(32), + bitcoinBlockHash: 'ab'.repeat(32), + bitcoinHeight: 10, + content: { members: [a.pubkey.slice(2), b.pubkey.slice(2)], threshold: 2 } + }; + const spend = resolveSpend({ genesis, tip }); + assert.ok(spend.address); + assert.match(spend.address, /^bcrt1p/); + assert.strictEqual(spend.threshold, 2); + assert.strictEqual(spend.validators.length, 2); + assert.strictEqual(spend.bitcoinBlockHash, 'ab'.repeat(32)); + + const again = resolveSpend({ genesis, tip }); + assert.strictEqual(again.address, spend.address); + }); + + it('resolveSpend address matches federation vault ladder', function () { + const a = new Key(); + const b = new Key(); + const vals = [a.pubkey, b.pubkey]; + const vault = buildFederationVaultFromPolicy({ + validatorPubkeysHex: vals, + threshold: 2, + networkName: 'regtest', + publisher: a.pubkey, + csvBlocks: 144 + }); + const spend = resolveSpend({ + genesis: { + members: { signers: vals, threshold: 2 }, + spendPolicy: { + validators: vals, + threshold: 2, + publisher: a.pubkey, + csvBlocks: 144, + network: 'regtest' + } + }, + tip: { content: { members: vals.map((p) => p.slice(2)) } }, + overrides: { network: 'regtest' } + }); + assert.strictEqual(spend.address, vault.address); + assert.strictEqual(spend.scheme, 'taproot-authority-ladder-v1'); + assert.strictEqual(spend.csvBlocks, 144); + assert.ok(spend.softTier); + }); + + it('resolveSpend fails closed without explicit network', function () { + const a = new Key(); + const genesis = { + members: { signers: [a.pubkey], threshold: 1 }, + spendPolicy: { validators: [a.pubkey], threshold: 1 } + }; + assert.throws(() => resolveSpend({ + genesis, + tip: { content: { members: [a.pubkey.slice(2)] } } + }), /network required/i); + const spend = resolveSpend({ + genesis, + tip: { content: { members: [a.pubkey.slice(2)] } }, + overrides: { network: 'signet' } + }); + assert.match(spend.address, /^tb1p/); + }); + + it('normalizeArcGenesis does not default spendPolicy.network to regtest', function () { + const a = new Key(); + const arc = normalizeArcGenesis({ + proposedPolicy: { validators: [a.pubkey], threshold: 1 } + }); + assert.strictEqual(arc.spendPolicy.network, undefined); + }); + + it('Contract#resolveSpend uses settings as genesis', function () { + const k = new Key(); + const c = new Contract({ + validators: [k.pubkey], + threshold: 1, + network: 'regtest', + csvBlocks: 144, + bitcoinAnchor: { blockHash: '11'.repeat(32), height: 1 } + }); + const spend = c.resolveSpend({ + tip: { + content: { members: [k.pubkey.slice(2)] }, + stateDigest: '22'.repeat(32), + bitcoinBlockHash: '11'.repeat(32) + }, + overrides: { network: 'regtest' } + }); + assert.ok(spend.address); + assert.strictEqual(spend.address, c.toAddress('regtest')); + }); + + it('withdrawal request binds tip digest + block hash', function () { + const k = new Key(); + const tip = { + contractId: 'c'.repeat(64), + clock: 1, + stateDigest: 'aa'.repeat(32), + bitcoinBlockHash: 'bb'.repeat(32), + bitcoinHeight: 42, + content: { members: [] } + }; + const req = buildWithdrawalRequest({ + tip, + destinationAddress: 'bcrt1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + feeSats: 500 + }); + assert.strictEqual(req.type, 'ContractWithdrawalRequest'); + assert.ok(req.requestId); + assert.strictEqual(req.stateDigest, tip.stateDigest); + assert.strictEqual(req.bitcoinBlockHash, tip.bitcoinBlockHash); + assert.strictEqual(validateWithdrawalRequest(req, tip).ok, true); + assert.strictEqual(validateWithdrawalRequest(req, Object.assign({}, tip, { + stateDigest: 'cc'.repeat(32) + })).ok, false); + + const diverted = Object.assign({}, req, { + destinationAddress: 'bcrt1q00000000000000000000000000000000000000', + // Keep original requestId — must fail commitment bind. + requestId: req.requestId + }); + const divertedCheck = validateWithdrawalRequest(diverted, tip); + assert.strictEqual(divertedCheck.ok, false); + assert.match(String(divertedCheck.error || ''), /requestId does not match/); + + const missingId = Object.assign({}, req); + delete missingId.requestId; + assert.strictEqual(validateWithdrawalRequest(missingId, tip).ok, false); + + const wit = buildWithdrawalWitness({ + request: req, + signer: pubkeyXOnly(k.pubkey), + signerKey: k + }); + assert.strictEqual(wit.requestId, req.requestId); + assert.strictEqual(wit.bitcoinBlockHash, tip.bitcoinBlockHash); + assert.ok(/^[0-9a-f]{128}$/.test(wit.signature)); + assert.strictEqual(verifyWithdrawalWitnessSignature(wit).ok, true); + }); + + it('ingest rejects withdrawal with stale tip binding', function () { + const owner = new Key(); + const store = createMemoryStore(); + const contractId = 'e'.repeat(64); + const blockHash = 'ff'.repeat(32); + const genesis = { + signers: [owner.pubkey], + bitcoinAnchor: { blockHash, height: 7 }, + primitives: { + messageTypes: ['GroupChange', 'ContractWithdrawalRequest', 'GroupChat'] + } + }; + const setMembers = Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ + contract: contractId, + type: 'GroupChange', + object: { action: 'members.set', members: [owner.pubkey] } + })]).signWithKey(owner); + const okSet = ingestMessageBuffer(store, contractId, setMembers.toBuffer(), { + origin: 'local', + genesis, + bitcoinBlockHash: blockHash, + bitcoinHeight: 7 + }); + assert.strictEqual(okSet.accepted, true); + + const tip = tipFromDoc(loadDoc(store, contractId)); + const stale = buildWithdrawalRequest({ + tip: Object.assign({}, tip, { stateDigest: '00'.repeat(32) }), + destinationAddress: 'bcrt1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + feeSats: 250 + }); + // Force stale digest onto a signed message + const bad = Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ + contract: contractId, + type: 'ContractWithdrawalRequest', + object: stale + })]).signWithKey(owner); + const rejected = ingestMessageBuffer(store, contractId, bad.toBuffer(), { + origin: 'mesh', + genesis, + bitcoinBlockHash: blockHash + }); + assert.strictEqual(rejected.accepted, false); + assert.match(rejected.error, /stateDigest|stale/i); + + const goodReq = buildWithdrawalRequest({ + tip, + destinationAddress: 'bcrt1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + feeSats: 250 + }); + const good = Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ + contract: contractId, + type: 'ContractWithdrawalRequest', + object: goodReq + })]).signWithKey(owner); + const accepted = ingestMessageBuffer(store, contractId, good.toBuffer(), { + origin: 'mesh', + genesis, + bitcoinBlockHash: blockHash + }); + assert.strictEqual(accepted.accepted, true); + assert.ok(accepted.tip.content.withdrawals.some((w) => w.requestId === goodReq.requestId)); + }); + + it('prepareWithdrawalFromRequest refuses tip mismatch', function () { + const a = new Key(); + const genesis = { + spendPolicy: { network: 'regtest', validators: [a.pubkey], threshold: 1, csvBlocks: 144 }, + members: { signers: [a.pubkey], threshold: 1 } + }; + const tip = { + contractId: 'd'.repeat(64), + stateDigest: '11'.repeat(32), + bitcoinBlockHash: '22'.repeat(32), + content: { members: [a.pubkey.slice(2)] } + }; + const req = buildWithdrawalRequest({ + tip, + destinationAddress: 'bcrt1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + feeSats: 100 + }); + assert.throws(() => prepareWithdrawalFromRequest({ + request: req, + tip: Object.assign({}, tip, { bitcoinBlockHash: '33'.repeat(32) }), + genesis, + fundedTxHex: '02000000000100' + }), /bitcoinBlockHash|match/i); + }); + + it('ingest rejects forged ContractWithdrawalWitness (unbound signer / bad sig)', function () { + const owner = new Key(); + const coSigner = new Key(); + const store = createMemoryStore(); + const contractId = 'f'.repeat(64); + const blockHash = 'aa'.repeat(32); + const genesis = { + signers: [owner.pubkey, coSigner.pubkey], + bitcoinAnchor: { blockHash, height: 3 }, + primitives: { + messageTypes: [ + 'GroupChange', + 'ContractWithdrawalRequest', + 'ContractWithdrawalWitness' + ] + } + }; + assert.strictEqual(ingestMessageBuffer(store, contractId, Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ + contract: contractId, + type: 'GroupChange', + object: { action: 'members.set', members: [owner.pubkey, coSigner.pubkey] } + })]).signWithKey(owner).toBuffer(), { + origin: 'local', + genesis, + bitcoinBlockHash: blockHash, + bitcoinHeight: 3 + }).accepted, true); + + const tip = tipFromDoc(loadDoc(store, contractId)); + const req = buildWithdrawalRequest({ + tip, + destinationAddress: 'bcrt1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + feeSats: 100 + }); + assert.strictEqual(ingestMessageBuffer(store, contractId, Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ + contract: contractId, + type: 'ContractWithdrawalRequest', + object: req + })]).signWithKey(owner).toBuffer(), { + origin: 'mesh', + genesis, + bitcoinBlockHash: blockHash + }).accepted, true); + + // Malicious co-signer claims honest owner's key with garbage signature. + const forged = buildWithdrawalWitness({ + request: req, + signer: pubkeyXOnly(owner.pubkey), + signature: 'ab'.repeat(64) + }); + const bad = ingestMessageBuffer(store, contractId, Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ + contract: contractId, + type: 'ContractWithdrawalWitness', + object: forged + })]).signWithKey(coSigner).toBuffer(), { + origin: 'mesh', + genesis, + bitcoinBlockHash: blockHash + }); + assert.strictEqual(bad.accepted, false); + assert.match(bad.error, /signer must match|invalid witness/i); + + const honest = buildWithdrawalWitness({ + request: req, + signer: pubkeyXOnly(coSigner.pubkey), + signerKey: coSigner + }); + const ok = ingestMessageBuffer(store, contractId, Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ + contract: contractId, + type: 'ContractWithdrawalWitness', + object: honest + })]).signWithKey(coSigner).toBuffer(), { + origin: 'mesh', + genesis, + bitcoinBlockHash: blockHash + }); + assert.strictEqual(ok.accepted, true, ok.error); + const row = ok.tip.content.withdrawals.find((w) => w.requestId === req.requestId); + assert.ok(row); + assert.strictEqual(row.witnesses.length, 1); + assert.strictEqual(row.witnesses[0].signer, pubkeyXOnly(coSigner.pubkey)); + }); +}); diff --git a/tests/contracts/federation.unit.js b/tests/contracts/federation.unit.js index 9ea46639d..ca8df8d71 100644 --- a/tests/contracts/federation.unit.js +++ b/tests/contracts/federation.unit.js @@ -2,17 +2,270 @@ const assert = require('assert'); const settings = require('../../fixtures'); -const Federation = require('../../contracts/federation'); +const OP_ADVANCE_BLOCK = require('../../contracts/federation'); describe('@fabric/core/contracts/federation', function () { - describe('Federation', function () { - it('exists', function () { - assert.ok(Federation); + describe('OP_ADVANCE_BLOCK', function () { + it('exports the block-advance opcode', function () { + assert.strictEqual(typeof OP_ADVANCE_BLOCK, 'function'); }); - it('can execute', function () { - const federation = Federation(settings); - assert.ok(federation); + it('wraps input as an Actor', function () { + const out = OP_ADVANCE_BLOCK(settings); + assert.ok(out); + assert.ok(out.input); + assert.ok(out.input.id); + assert.match(String(out.input.id), /^[0-9a-f]{64}$/); }); }); }); + +const Key = require('../../types/key'); +const { + signingStringForGroupChangeProposal, + signProposalVote, + verifyProposalVote, + applyGroupChangeAction, + foldProposals +} = require('../../functions/groupChangeGovernance'); +const { pubkeyXOnly } = require('../../functions/groupChatSeal'); + +describe('@fabric/core groupChangeGovernance', function () { + it('signs and verifies a proposal vote (canonical application string)', function () { + const a = new Key(); + const proposal = { + id: 'gprop-test', + contractId: 'ab'.repeat(32), + groupId: 'group-1', + action: 'member.add', + member: new Key().pubkey, + role: 'signer', + patch: null, + proposedBy: pubkeyXOnly(a.pubkey), + createdAt: '2026-01-01T00:00:00.000Z' + }; + const signed = signProposalVote(a, proposal); + assert.ok(signed.signature); + assert.strictEqual(signed.message, signingStringForGroupChangeProposal(proposal)); + assert.strictEqual(verifyProposalVote(proposal, a.pubkey, signed.signature), true); + assert.strictEqual(verifyProposalVote(proposal, a.pubkey, 'ab'.repeat(64)), false); + assert.strictEqual(verifyProposalVote(proposal, new Key().pubkey, signed.signature), false); + }); + + it('applyGroupChangeAction member.add / remove / set', function () { + const a = pubkeyXOnly(new Key().pubkey); + const b = pubkeyXOnly(new Key().pubkey); + const ctx = { members: new Set(), signers: new Set(), readerOnlyAdds: false, threshold: null }; + applyGroupChangeAction(ctx, { action: 'member.add', member: a, role: 'signer' }); + applyGroupChangeAction(ctx, { action: 'member.add', member: b, role: 'reader' }); + assert.ok(ctx.members.has(a) && ctx.members.has(b)); + assert.ok(ctx.signers.has(a) && !ctx.signers.has(b)); + applyGroupChangeAction(ctx, { action: 'member.remove', member: b }); + assert.ok(!ctx.members.has(b)); + applyGroupChangeAction(ctx, { action: 'members.set', members: [a, b] }); + assert.ok(ctx.signers.has(b)); + }); + + it('foldProposals adopts at threshold using genesis signers', function () { + const owner = new Key(); + const recruit = new Key(); + const ox = pubkeyXOnly(owner.pubkey); + const rx = pubkeyXOnly(recruit.pubkey); + const ctx = { members: new Set(), signers: new Set(), readerOnlyAdds: false, threshold: 1 }; + const entries = [{ + hash: 'aa', + type: 'GroupChangeProposal', + author: ox, + object: { + id: 'p1', + action: 'member.add', + member: rx, + role: 'signer', + proposedBy: ox, + threshold: 1 + } + }]; + const out = foldProposals(entries, ctx, 1, [ox]); + assert.strictEqual(out[0].status, 'adopted'); + assert.ok(ctx.members.has(ox) && ctx.members.has(rx)); + }); + + it('foldProposals adopts member.remove and update threshold (Federation actions)', function () { + const owner = new Key(); + const extra = new Key(); + const ox = pubkeyXOnly(owner.pubkey); + const ex = pubkeyXOnly(extra.pubkey); + const ctx = { members: new Set([ox, ex]), signers: new Set([ox, ex]), readerOnlyAdds: false, threshold: 2 }; + const remove = { + hash: '11', + type: 'GroupChangeProposal', + author: ox, + object: { + id: 'p-remove', + action: 'member.remove', + member: ex, + proposedBy: ox, + threshold: 1 + } + }; + const removed = foldProposals([remove], ctx, 1, [ox, ex]); + assert.strictEqual(removed[0].status, 'adopted'); + assert.ok(!ctx.members.has(ex)); + + const update = { + hash: '22', + type: 'GroupChangeProposal', + author: ox, + object: { + id: 'p-update', + action: 'update', + patch: { threshold: 1, pinnedChannels: ['discord:ops'] }, + proposedBy: ox, + threshold: 1 + } + }; + const updated = foldProposals([update], ctx, 1, [ox]); + assert.strictEqual(updated[0].status, 'adopted'); + assert.strictEqual(ctx.threshold, 1); + }); + + it('foldProposals ignores a proposer-chosen threshold below genesis k-of-n', function () { + const owner = new Key(); + const co = new Key(); + const ox = pubkeyXOnly(owner.pubkey); + const cx = pubkeyXOnly(co.pubkey); + const ctx = { members: new Set([ox, cx]), signers: new Set([ox, cx]), readerOnlyAdds: false, threshold: 2 }; + const entries = [{ + hash: 'cc', + type: 'GroupChangeProposal', + author: ox, + object: { + id: 'p-bypass', + action: 'members.set', + members: [ox], + proposedBy: ox, + threshold: 1 + } + }]; + const out = foldProposals(entries, ctx, 2, [ox, cx]); + assert.strictEqual(out[0].status, 'pending'); + assert.ok(ctx.members.has(cx)); + assert.ok(ctx.signers.has(cx)); + }); + + it('signing string v2 binds members/signers (order-insensitive)', function () { + const a = pubkeyXOnly(new Key().pubkey); + const b = pubkeyXOnly(new Key().pubkey); + const first = [a, b].sort(); + const second = [b, a]; + const left = JSON.parse(signingStringForGroupChangeProposal({ + id: 'p-set', + action: 'members.set', + members: first + })); + const right = JSON.parse(signingStringForGroupChangeProposal({ + id: 'p-set', + action: 'members.set', + members: second + })); + assert.strictEqual(left.v, 2); + assert.deepStrictEqual(left.members, right.members); + assert.strictEqual(left.signers, null); + }); + + it('foldProposals rejects votes when members.set roster is swapped after signing', function () { + const owner = new Key(); + const co = new Key(); + const attacker = new Key(); + const ox = pubkeyXOnly(owner.pubkey); + const cx = pubkeyXOnly(co.pubkey); + const ax = pubkeyXOnly(attacker.pubkey); + const ctx = { members: new Set([ox, cx]), signers: new Set([ox, cx]), readerOnlyAdds: false, threshold: 2 }; + const honest = { + id: 'p-roster', + action: 'members.set', + members: [ox, cx], + proposedBy: ox + }; + const vote = signProposalVote(co, honest); + const entries = [{ + hash: 'dd', + type: 'GroupChangeProposal', + author: ox, + object: { + id: 'p-roster', + action: 'members.set', + members: [ax], + proposedBy: ox + } + }, { + hash: 'ee', + type: 'GroupChangeVote', + author: cx, + object: { + proposalId: 'p-roster', + voter: cx, + signature: vote.signature + } + }]; + const out = foldProposals(entries, ctx, 2, [ox, cx]); + assert.strictEqual(out[0].status, 'pending'); + assert.ok(ctx.members.has(ox) && ctx.members.has(cx)); + assert.strictEqual(ctx.members.has(ax), false); + }); + + it('foldProposals rejects votes when signers.set roster is swapped after signing', function () { + const owner = new Key(); + const co = new Key(); + const attacker = new Key(); + const ox = pubkeyXOnly(owner.pubkey); + const cx = pubkeyXOnly(co.pubkey); + const ax = pubkeyXOnly(attacker.pubkey); + const ctx = { members: new Set([ox, cx]), signers: new Set([ox, cx]), readerOnlyAdds: false, threshold: 2 }; + const honest = { + id: 'p-signers', + action: 'signers.set', + signers: [ox, cx], + proposedBy: ox + }; + const vote = signProposalVote(co, honest); + const entries = [{ + hash: 'ff', + type: 'GroupChangeProposal', + author: ox, + object: { + id: 'p-signers', + action: 'signers.set', + signers: [ax], + proposedBy: ox + } + }, { + hash: '00', + type: 'GroupChangeVote', + author: cx, + object: { + proposalId: 'p-signers', + voter: cx, + signature: vote.signature + } + }]; + const out = foldProposals(entries, ctx, 2, [ox, cx]); + assert.strictEqual(out[0].status, 'pending'); + assert.ok(ctx.signers.has(ox) && ctx.signers.has(cx)); + assert.strictEqual(ctx.signers.has(ax), false); + }); + + it('signing string v2 unique-sorts duplicate members and signers', function () { + const a = pubkeyXOnly(new Key().pubkey); + const b = pubkeyXOnly(new Key().pubkey); + const parsed = JSON.parse(signingStringForGroupChangeProposal({ + id: 'p-dup', + action: 'members.set', + members: [b, a, a], + signers: [b, b, a] + })); + assert.strictEqual(parsed.v, 2); + assert.deepStrictEqual(parsed.members, [a, b].sort()); + assert.deepStrictEqual(parsed.signers, [a, b].sort()); + }); +}); diff --git a/tests/downstream.application.interop.test.js b/tests/downstream.application.interop.test.js deleted file mode 100644 index 17f97c56d..000000000 --- a/tests/downstream.application.interop.test.js +++ /dev/null @@ -1,419 +0,0 @@ -'use strict'; - -/** - * Application ARC / Federation wire shapes against `@fabric/core`. - * - * Covers Hub tracked CONTRACT_PUBLISH → Accept spend overlay, opaque queue, - * Federation group genesis (`proposedPolicy` + top-level `messageTypes`), - * GroupChangeProposal votes, and Peer `contract:message` `messageHex` - * accumulate — without importing any application product repo. - */ - -const assert = require('assert'); -const Actor = require('../types/actor'); -const Key = require('../types/key'); -const Message = require('../types/message'); -const Peer = require('../types/peer'); -const { pubkeyXOnly } = require('../functions/groupChatSeal'); -const { - signingStringForGroupChangeProposal, - signProposalVote, - verifyProposalVote -} = require('../functions/groupChangeGovernance'); -const { - createMemoryStore, - ingestMessageBuffer, - ingestContractPublishBuffer, - normalizeGenesis, - isSyncTrackedType, - loadDoc -} = require('../functions/contractMessageAccumulate'); -const { - enqueueMessageBuffer, - entryHexList, - listQueuedMessages -} = require('../functions/contractMessageQueue'); -const { - createPending, - markReceived, - phaseFlags -} = require('../functions/contractMessageCommit'); -const { - normalizeArcGenesis, - resolveSpend -} = require('../functions/contractSpend'); - -/** Representative Federation group messageTypes allow-list (application catalogs may extend). */ -const FEDERATION_GROUP_MESSAGE_TYPES = Object.freeze([ - 'GroupChat', - 'GroupChange', - 'GroupChangeProposal', - 'GroupChangeVote', - 'GroupShare', - 'GroupActivityTree', - 'FleetShare', - 'FederationContractInvite', - 'FederationContractInviteResponse', - 'GroupJournalRequest', - 'GroupJournalBatch', - 'GroupStateJournal', - 'ContractCapabilityGrant', - 'ContractWithdrawalRequest', - 'ContractWithdrawalWitness' -]); - -function federationGroupDefinition (creatorKey, extra = {}) { - const creator = String(creatorKey.pubkey).toLowerCase(); - return { - name: 'FederationGroup', - version: 6, - parentContract: 'aa'.repeat(32), - groupId: extra.groupId || 'grp-wing-1', - creator, - createdAt: extra.createdAt || '2026-08-13T00:00:00.000Z', - proposedPolicy: { - validators: extra.validators || [creator], - threshold: extra.threshold != null ? extra.threshold : 1 - }, - meta: { - name: extra.name || 'Wing', - visibility: extra.visibility || 'public', - slug: extra.slug || 'wing', - parentId: null - }, - messageTypes: FEDERATION_GROUP_MESSAGE_TYPES.slice(), - state: { members: {}, chat: {}, changes: {}, shares: {}, meta: {} } - }; -} - -function hubPlaynetDefinition (ownerKey) { - const ownerPub = String(ownerKey.pubkey).toLowerCase(); - return { - name: 'DemoGroup', - version: 5, - messageTypes: ['GroupChat', 'GroupChange', 'GroupChangeProposal'], - parties: [ownerPub], - validators: [ownerPub], - proposedPolicy: { validators: [ownerPub], threshold: 1 }, - creator: ownerPub, - state: { network: 'regtest' } - }; -} - -function signContractMessage (key, contractId, type, object) { - return Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ - contract: contractId, - type, - object - })]).signWithKey(key); -} - -/** - * Canonical proposal signing string used by application vote helpers. - * Kept inline so core CI does not import application repos. - */ -function applicationCanonicalPubkeyList (list) { - if (!Array.isArray(list)) return null; - const seen = new Set(); - const out = []; - for (const item of list) { - const x = pubkeyXOnly(item); - if (!x || seen.has(x)) continue; - seen.add(x); - out.push(x); - } - out.sort(); - return out; -} - -function applicationProposalSigningString (proposal) { - const p = proposal && typeof proposal === 'object' ? proposal : {}; - return JSON.stringify({ - type: 'GroupChangeProposal', - v: 2, - id: String(p.id || ''), - contractId: String(p.contractId || '').toLowerCase(), - groupId: String(p.groupId || ''), - action: String(p.action || ''), - member: p.member != null ? String(p.member).toLowerCase() : null, - role: p.role != null ? String(p.role) : null, - members: applicationCanonicalPubkeyList(p.members), - signers: applicationCanonicalPubkeyList(p.signers), - patch: p.patch && typeof p.patch === 'object' ? p.patch : null, - proposedBy: String(p.proposedBy || '').toLowerCase(), - createdAt: String(p.createdAt || '') - }); -} - -describe('application ARC interop (Hub + Federation groups)', function () { - it('proposal signing string is byte-identical to application vote helpers', function () { - const a = new Key(); - const recruit = new Key(); - const proposal = { - id: 'gprop-wing', - contractId: 'ab'.repeat(32), - groupId: 'grp-wing-1', - action: 'member.add', - member: recruit.pubkey, - role: 'signer', - patch: null, - proposedBy: String(a.pubkey).toLowerCase(), - createdAt: '2026-08-13T00:00:00.000Z' - }; - assert.strictEqual( - signingStringForGroupChangeProposal(proposal), - applicationProposalSigningString(proposal) - ); - const signed = signProposalVote(a, proposal); - assert.strictEqual(verifyProposalVote(proposal, a.pubkey, signed.signature), true); - }); - - it('normalizeGenesis reads proposedPolicy + top-level messageTypes', function () { - const owner = new Key(); - const def = federationGroupDefinition(owner, { threshold: 2 }); - const genesis = normalizeGenesis(def); - assert.ok(genesis.signers.includes(pubkeyXOnly(owner.pubkey))); - assert.strictEqual(genesis.threshold, 2); - assert.ok(genesis.primitives.messageTypes.includes('GroupChangeProposal')); - assert.ok(genesis.primitives.messageTypes.includes('FederationContractInvite')); - }); - - it('FederationGroup CONTRACT_PUBLISH seeds accumulate so GroupChat needs no meta.genesis', function () { - const owner = new Key(); - const store = createMemoryStore(); - const def = federationGroupDefinition(owner); - const pub = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(owner); - const seeded = ingestContractPublishBuffer(store, pub.toBuffer()); - assert.strictEqual(seeded.accepted, true, seeded.error); - assert.ok(seeded.contractId); - assert.ok(seeded.genesis.signers.includes(pubkeyXOnly(owner.pubkey))); - - const chat = ingestMessageBuffer( - store, - seeded.contractId, - signContractMessage(owner, seeded.contractId, 'GroupChat', { - body: 'o7', - author: owner.pubkey, - ts: '2026-08-13T00:00:01.000Z' - }).toBuffer(), - { origin: 'mesh' } - ); - assert.strictEqual(chat.accepted, true, chat.error); - assert.strictEqual(isSyncTrackedType('GroupChat', loadDoc(store, seeded.contractId)), true); - }); - - it('threshold-2 proposal stays pending until a BIP340 vote (application vote wire)', function () { - const a = new Key(); - const b = new Key(); - const recruit = new Key(); - const store = createMemoryStore(); - const def = federationGroupDefinition(a, { - validators: [a.pubkey, b.pubkey], - threshold: 2 - }); - const seeded = ingestContractPublishBuffer( - store, - Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(a).toBuffer() - ); - assert.strictEqual(seeded.accepted, true, seeded.error); - const contractId = seeded.contractId; - const proposal = { - id: 'gprop-k2', - contractId, - groupId: def.groupId, - action: 'member.add', - member: recruit.pubkey, - role: 'signer', - patch: null, - proposedBy: pubkeyXOnly(a.pubkey), - createdAt: def.createdAt, - threshold: 2 - }; - const proposed = ingestMessageBuffer( - store, - contractId, - signContractMessage(a, contractId, 'GroupChangeProposal', proposal).toBuffer(), - { origin: 'mesh' } - ); - assert.strictEqual(proposed.accepted, true, proposed.error); - assert.strictEqual(proposed.tip.content.proposals[0].status, 'pending'); - assert.ok(!proposed.tip.content.members.includes(pubkeyXOnly(recruit.pubkey))); - - const vote = signProposalVote(b, proposal); - const adopted = ingestMessageBuffer( - store, - contractId, - signContractMessage(b, contractId, 'GroupChangeVote', { - type: 'GroupChangeVote', - proposalId: proposal.id, - contractId, - groupId: def.groupId, - voter: String(b.pubkey).toLowerCase(), - signature: vote.signature, - votedAt: '2026-08-13T00:00:02.000Z' - }).toBuffer(), - { origin: 'mesh' } - ); - assert.strictEqual(adopted.accepted, true, adopted.error); - assert.strictEqual(adopted.tip.content.proposals[0].status, 'adopted'); - assert.ok(adopted.tip.content.members.includes(pubkeyXOnly(recruit.pubkey))); - }); - - it('GroupChange update patch (pinnedChannels + threshold) is accepted from a signer', function () { - const owner = new Key(); - const store = createMemoryStore(); - const def = federationGroupDefinition(owner); - const seeded = ingestContractPublishBuffer( - store, - Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(owner).toBuffer() - ); - const change = ingestMessageBuffer( - store, - seeded.contractId, - signContractMessage(owner, seeded.contractId, 'GroupChange', { - action: 'update', - patch: { - pinnedChannels: ['discord:1', 'group:' + seeded.contractId], - threshold: 1 - } - }).toBuffer(), - { origin: 'mesh' } - ); - assert.strictEqual(change.accepted, true, change.error); - assert.strictEqual(change.tip.content.threshold, 1); - }); - - it('outsider FederationContractInvite is rejected on a Federation group', function () { - const owner = new Key(); - const outsider = new Key(); - const store = createMemoryStore(); - const def = federationGroupDefinition(owner); - const seeded = ingestContractPublishBuffer( - store, - Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(owner).toBuffer() - ); - const denied = ingestMessageBuffer( - store, - seeded.contractId, - signContractMessage(outsider, seeded.contractId, 'FederationContractInvite', { - inviteePubkey: outsider.pubkey, - groupId: def.groupId - }).toBuffer(), - { origin: 'mesh' } - ); - assert.strictEqual(denied.accepted, false); - }); - - it('Hub playnet DemoGroup publish → Accept overlay yields a spendAddress', function () { - const owner = new Key(); - const definition = hubPlaynetDefinition(owner); - const contractId = new Actor(definition).id; - const arc = normalizeArcGenesis(definition); - assert.ok(arc.members.signers.length); - assert.deepStrictEqual(arc.primitives.messageTypes.slice(0, 3), [ - 'GroupChat', 'GroupChange', 'GroupChangeProposal' - ]); - const tipHash = 'ab'.repeat(32); - const spend = resolveSpend({ - genesis: arc, - tip: { - contractId, - content: { - signers: arc.members.signers.slice(), - members: arc.members.signers.slice(), - threshold: arc.members.threshold - }, - stateDigest: 'cd'.repeat(32), - bitcoinBlockHash: tipHash, - bitcoinHeight: 101 - }, - contractId, - overrides: { network: 'regtest' } - }); - assert.ok(spend.spendAddress || spend.address); - const genesis = normalizeGenesis(definition); - assert.ok(genesis.signers.includes(pubkeyXOnly(owner.pubkey))); - assert.ok(genesis.primitives.messageTypes.includes('GroupChangeProposal')); - }); - - it('Hub queue then participant fold (origin queue) matches mesh ingest', function () { - const owner = new Key(); - const store = createMemoryStore(); - const qStore = createMemoryStore(); - const def = hubPlaynetDefinition(owner); - const seeded = ingestContractPublishBuffer( - store, - Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(owner).toBuffer() - ); - const msg = signContractMessage(owner, seeded.contractId, 'GroupChat', { - body: 'queued-from-hub', - author: owner.pubkey, - ts: '2026-08-13T00:00:03.000Z' - }); - const enq = enqueueMessageBuffer(qStore, seeded.contractId, msg.toBuffer(), { origin: 'mesh' }); - assert.strictEqual(enq.accepted, true, enq.error); - const hexes = entryHexList(listQueuedMessages(qStore, seeded.contractId)); - const folded = ingestMessageBuffer(store, seeded.contractId, hexes[0], { origin: 'queue' }); - assert.strictEqual(folded.accepted, true, folded.error); - assert.strictEqual(folded.tip.clock, 1); - }); - - it('Peer contract:message messageHex folds into accumulate (app relay path)', function () { - const peer = new Peer({ listen: false, peersDb: null, networking: false }); - const owner = new Key(); - const origin = '127.0.0.1:3041'; - peer.connections[origin] = { _writeFabric () {}, destroy () {} }; - peer.peers[origin] = { publicKey: owner.pubkey }; - - const def = federationGroupDefinition(owner); - const publish = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(owner); - peer._handleFabricMessage(publish.toBuffer(), { name: origin }, null); - const contractId = new Actor(def).id; - assert.ok(peer._signerMayPatchContract(contractId, owner.pubkey)); - - const events = []; - peer.on('contract:message', (ev) => events.push(ev)); - const chat = signContractMessage(owner, contractId, 'GroupChat', { - body: 'from-mesh', - author: owner.pubkey, - ts: '2026-08-13T00:00:04.000Z' - }); - peer._handleFabricMessage(chat.toBuffer(), { name: origin }, null); - assert.strictEqual(events.length, 1); - assert.ok(events[0].wireMessage); - assert.match(String(events[0].messageHex || ''), /^[0-9a-f]+$/i); - assert.ok(events[0].genesis); - assert.strictEqual(events[0].genesis.name, 'FederationGroup'); - - const store = createMemoryStore(); - ingestContractPublishBuffer(store, publish.toBuffer()); - const result = ingestMessageBuffer(store, contractId, events[0].messageHex, { origin: 'mesh' }); - assert.strictEqual(result.accepted, true, result.error); - - const record = createPending({ - id: result.entry.hash, - contractId, - readers: [owner.pubkey] - }); - markReceived(record, owner.pubkey); - assert.strictEqual(phaseFlags(record, owner.pubkey).received, true); - }); - - it('attacker cannot front-run a FederationGroup publish (proposedPolicy authorities)', function () { - const peer = new Peer({ listen: false, peersDb: null, networking: false }); - const owner = new Key(); - const attacker = new Key(); - const def = federationGroupDefinition(owner); - const hijack = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(attacker); - peer._handleFabricMessage(hijack.toBuffer(), { name: '127.0.0.1:18445' }, null); - const contractId = new Actor(def).id; - assert.strictEqual(peer._signerMayPatchContract(contractId, attacker.pubkey), false); - assert.ok(!peer.contracts[contractId]); - - const legit = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(owner); - peer._handleFabricMessage(legit.toBuffer(), { name: '127.0.0.1:18444' }, null); - assert.ok(peer.contracts[contractId]); - assert.ok(peer._signerMayPatchContract(contractId, owner.pubkey)); - assert.strictEqual(peer._signerMayPatchContract(contractId, attacker.pubkey), false); - }); -}); diff --git a/tests/executionProgramRunner.test.js b/tests/executionProgramRunner.test.js deleted file mode 100644 index 986013d9b..000000000 --- a/tests/executionProgramRunner.test.js +++ /dev/null @@ -1,100 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const Machine = require('../types/machine'); -const Program = require('../types/program'); -const { - runExecutionProgram, - executionProgramFromHub -} = require('../functions/executionProgramRunner'); - -describe('executionProgramRunner / Machine fabric-execution', function () { - it('runs Push/Dup/Pop/ADD on Machine stack', function () { - const r = runExecutionProgram({ - version: 1, - steps: [ - { op: 'Push', value: 2 }, - { op: 'PUSH', value: 3 }, - { op: 'ADD' }, - { op: 'Dup' } - ] - }); - assert.strictEqual(r.ok, true, r.error); - assert.strictEqual(r.stack[0], 5); - assert.strictEqual(r.stack[1], 5); - assert.ok(r.programHash); - assert.ok(r.runCommitmentHex); - assert.ok(r.program); - }); - - it('content-addresses via Program.programHash', function () { - const program = { version: 1, steps: [{ op: 'Push', value: 1 }] }; - const a = executionProgramFromHub(program).programHash; - const b = executionProgramFromHub(program).programHash; - assert.strictEqual(a, b); - assert.strictEqual(runExecutionProgram(program).programHash, a); - }); - - it('Machine.runProgram delegates fabric-execution', async function () { - const machine = new Machine({ deterministic: true }); - const out = await machine.runProgram({ - language: 'fabric-execution', - source: { - version: 1, - steps: [{ op: 'Push', value: { hi: true } }] - } - }); - assert.strictEqual(out.ok, true, out.error); - assert.deepStrictEqual(out.tip, { hi: true }); - assert.ok(out.runCommitmentHex); - }); - - it('rejects Ping FabricOpcode when resolver marks it', function () { - const r = runExecutionProgram({ - steps: [{ op: 'FabricOpcode', fabricType: 'Ping' }] - }, { - resolveFabricEntry () { - throw new Error('Ping is a transport keepalive, not an Execution program opcode'); - } - }); - assert.strictEqual(r.ok, false); - assert.match(r.error, /Ping/); - }); - - it('enforces Machine / genesis opcode allow-list on FabricOpcode steps', function () { - const program = { - version: 1, - steps: [{ op: 'FabricOpcode', fabricType: 'ChatMessage' }] - }; - const denied = runExecutionProgram(program, { - allowedOpcodes: ['P2P_CHAT_MESSAGE'], - resolveFabricEntry () { - return { name: 'ChatMessage', opcodeDec: 0x67 }; - } - }); - assert.strictEqual(denied.ok, false); - assert.match(denied.error, /allow-list/i); - - const allowed = runExecutionProgram(program, { - allowedOpcodes: ['ChatMessage'], - resolveFabricEntry () { - return { name: 'ChatMessage', opcodeDec: 0x67 }; - } - }); - assert.strictEqual(allowed.ok, true, allowed.error); - - const fromGenesis = runExecutionProgram(program, { - genesis: { primitives: { opcodes: ['OtherOnly'] } }, - resolveFabricEntry () { - return { name: 'ChatMessage', opcodeDec: 0x67 }; - } - }); - assert.strictEqual(fromGenesis.ok, false); - assert.match(fromGenesis.error, /allow-list/i); - }); - - it('Program.compile fabric-execution requires steps', function () { - const bad = Program.from({ language: 'fabric-execution', source: {} }).compile(); - assert.strictEqual(bad.ok, false); - }); -}); \ No newline at end of file diff --git a/tests/executionRun.paymentObserve.test.js b/tests/executionRun.paymentObserve.test.js deleted file mode 100644 index a4160aa94..000000000 --- a/tests/executionRun.paymentObserve.test.js +++ /dev/null @@ -1,193 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const crypto = require('crypto'); -const bitcoin = require('bitcoinjs-lib'); -const Key = require('../types/key'); -const Contract = require('../types/contract'); -const { - observePaymentToAddress, - applyPaymentObservationToTip -} = require('../functions/contractPaymentObserve'); -const { - buildExecutionRunOutput, - computeExecutionRunCommitmentHex, - computeFabricProgramRunFromExecution -} = require('../functions/executionRunBridge'); - -describe('executionRunBridge / FabricProgramRun forward digest', function () { - it('keeps legacy ExecutionRun digest stable', function () { - const result = { - ok: true, - stepsExecuted: 2, - trace: [{ pc: 0, op: 'Push' }], - stack: [1] - }; - const a = computeExecutionRunCommitmentHex('cid-1', result); - const b = computeExecutionRunCommitmentHex('cid-1', result); - assert.strictEqual(a, b); - assert.strictEqual(a.length, 64); - }); - - it('buildExecutionRunOutput prefers FabricProgramRun when programHash set', function () { - const programHash = crypto.createHash('sha256').update('prog').digest('hex'); - const result = { ok: true, stepsExecuted: 1, trace: [], stack: ['x'] }; - const out = buildExecutionRunOutput({ - contractId: 'cid-forward', - programHash, - result - }); - assert.strictEqual(out.programHash, programHash); - assert.ok(out.executionRunCommitmentHex); - assert.ok(out.fabricProgramRunCommitmentHex); - assert.notStrictEqual(out.executionRunCommitmentHex, out.fabricProgramRunCommitmentHex); - assert.strictEqual(out.runCommitmentHex, out.fabricProgramRunCommitmentHex); - assert.strictEqual(out.fabricProgramRun.kind, 'FabricProgramRun'); - assert.strictEqual(out.fabricProgramRun.runCommitmentHex, out.fabricProgramRunCommitmentHex); - assert.strictEqual( - computeFabricProgramRunFromExecution({ programHash, contractId: 'cid-forward', result }), - out.fabricProgramRunCommitmentHex - ); - }); - - it('falls back to ExecutionRun digest without programHash', function () { - const result = { ok: false, error: 'boom', trace: [], stack: [] }; - const out = buildExecutionRunOutput({ contractId: 'cid-legacy', result }); - assert.strictEqual(out.programHash, null); - assert.strictEqual(out.runCommitmentHex, out.executionRunCommitmentHex); - assert.strictEqual(out.fabricProgramRun, null); - }); -}); - -describe('contractPaymentObserve / Contract L1 payment', function () { - const keys = []; - before(function () { - for (let i = 0; i < 3; i++) keys.push(new Key()); - }); - - function fundTx (outputScript, valueSats) { - const fund = new bitcoin.Transaction(); - fund.version = 2; - fund.addInput(Buffer.alloc(32), 0); - fund.addOutput(outputScript, BigInt(valueSats)); - return fund; - } - - it('observes successful payment to contract address', function () { - const contract = new Contract({ - network: 'regtest', - validators: [keys[0].pubkey, keys[1].pubkey], - threshold: 1, - publisher: keys[0].pubkey - }); - const address = contract.toAddress('regtest'); - const built = contract.toTaprootContract({ network: 'regtest' }); - const tx = fundTx(built.output, 50000); - const obs = observePaymentToAddress({ - tx: tx.toHex(), - address, - network: 'regtest', - amountSats: 40000 - }); - assert.strictEqual(obs.ok, true); - assert.strictEqual(obs.code, 'PAID'); - assert.strictEqual(obs.matchedSats, 50000); - assert.ok(obs.txid); - }); - - it('fails when tx does not pay contract address', function () { - const other = bitcoin.payments.p2wpkh({ - pubkey: Buffer.from(keys[2].pubkey, 'hex'), - network: bitcoin.networks.regtest - }); - const tx = fundTx(other.output, 10000); - const contract = new Contract({ - network: 'regtest', - validators: [keys[0].pubkey], - threshold: 1, - publisher: keys[0].pubkey - }); - const obs = observePaymentToAddress({ - tx: tx.toHex(), - address: contract.toAddress('regtest'), - network: 'regtest' - }); - assert.strictEqual(obs.ok, false); - assert.strictEqual(obs.code, 'NO_MATCH'); - }); - - it('fails UNDERPAID when matched sats below amountSats', function () { - const contract = new Contract({ - network: 'regtest', - validators: [keys[0].pubkey], - threshold: 1, - publisher: keys[0].pubkey - }); - const built = contract.toTaprootContract({ network: 'regtest' }); - const tx = fundTx(built.output, 1000); - const obs = observePaymentToAddress({ - tx, - address: contract.toAddress('regtest'), - network: 'regtest', - amountSats: 5000 - }); - assert.strictEqual(obs.ok, false); - assert.strictEqual(obs.code, 'UNDERPAID'); - assert.strictEqual(obs.matchedSats, 1000); - }); - - it('fails on missing / invalid tx', function () { - assert.strictEqual(observePaymentToAddress({ address: 'bcrt1qxx' }).code, 'MISSING_TX'); - assert.strictEqual( - observePaymentToAddress({ tx: 'zz', address: 'bcrt1qxx' }).code, - 'INVALID_TX' - ); - }); - - it('Contract#_handleBitcoinTransaction applies balances on success', function () { - const contract = new Contract({ - network: 'regtest', - validators: [keys[0].pubkey], - threshold: 1, - publisher: keys[0].pubkey, - state: { balances: {}, payments: [] } - }); - const built = contract.toTaprootContract({ network: 'regtest' }); - const tx = fundTx(built.output, 25000); - const obs = contract._handleBitcoinTransaction(tx.toHex(), { - network: 'regtest', - amountSats: 10000 - }); - assert.strictEqual(obs.ok, true); - assert.strictEqual(obs.applied, true); - const addr = contract.toAddress('regtest'); - assert.strictEqual(contract._state.content.balances[addr].sats, 25000); - assert.strictEqual(contract._state.content.payments.length, 1); - }); - - it('Contract#_handleBitcoinTransaction does not apply on failure', function () { - const contract = new Contract({ - network: 'regtest', - validators: [keys[0].pubkey], - threshold: 1, - publisher: keys[0].pubkey, - state: { balances: {}, payments: [] } - }); - const other = bitcoin.payments.p2wpkh({ - pubkey: Buffer.from(keys[1].pubkey, 'hex'), - network: bitcoin.networks.regtest - }); - const tx = fundTx(other.output, 9000); - const obs = contract._handleBitcoinTransaction(tx.toHex(), { network: 'regtest' }); - assert.strictEqual(obs.ok, false); - assert.strictEqual(obs.code, 'NO_MATCH'); - assert.deepStrictEqual(contract._state.content.payments || [], []); - }); - - it('applyPaymentObservationToTip rejects failed observations', function () { - assert.throws( - () => applyPaymentObservationToTip({}, { ok: false, code: 'NO_MATCH' }), - /successful/ - ); - }); -}); diff --git a/tests/fabric.application.js b/tests/fabric.application.js index 54a3b54c0..8fcb00e09 100644 --- a/tests/fabric.application.js +++ b/tests/fabric.application.js @@ -26,16 +26,18 @@ describe('@fabric/core/types/service (FabricShell)', function () { it('allow a Resource to be defined', async function () { const app = new FabricShell(); - app.define('Example', { + await app.define('Example', { components: { list: 'fabric-example-list', view: 'fabric-example-view' } }); + assert.ok(app.resources.Example); + assert.strictEqual(app.resources.Example.components.list, 'fabric-example-list'); + await app.start(); await app.stop(); - assert.ok(app); }); it('should create an application smoothly', async function () { @@ -45,18 +47,20 @@ describe('@fabric/core/types/service (FabricShell)', function () { assert.ok(app); }); - it('should defer to an oracle authority', async function () { + it('defer warns and consume()s an empty resource map onto the attached element', async function () { const app = new FabricShell(); - const oracle = { id: 'test-oracle' }; - app.attach({}); + const element = {}; + const warnings = []; + app.on('warning', (line) => warnings.push(String(line))); + app.attach(element); await app.start(); - await app.defer(oracle); - + const returned = await app.defer({ id: 'test-oracle' }); await app.stop(); - assert.ok(oracle); - assert.ok(app); + assert.strictEqual(returned, app); + assert.ok(warnings.some((line) => line.includes('deferring authority'))); + assert.deepStrictEqual(element.resources, {}); }); it('render writes attached element markup', function () { @@ -122,6 +126,26 @@ describe('@fabric/core/types/service (FabricShell)', function () { assert.strictEqual(relayed[0].id, 'node-1'); }); + it('registerService signs ChatMessage relays when node.key is present', async function () { + const Key = require('../types/key'); + const app = new FabricShell(); + app.settings.services = ['relay']; + const relayed = []; + const key = new Key(); + app.node = { + id: 'node-1', + key, + relayFrom: function (id, msg) { + relayed.push({ id, msg }); + } + }; + class RelayService extends require('../types/service') {} + const service = app._registerService('relay', RelayService); + service.emit('message', { '@type': 'ChatMessage', body: 'signed-hello' }); + assert.strictEqual(relayed.length, 1); + assert.ok(relayed[0].msg.verifyWithKey(key)); + }); + it('registerService forwards warning/error and exercises default message branch', function () { const app = new FabricShell(); app.settings.services = ['relay']; @@ -174,3 +198,420 @@ describe('@fabric/core/types/service (FabricShell)', function () { }); }); }); + +/** + * Application ARC / Federation wire shapes against `@fabric/core`. + * + * Covers Hub tracked CONTRACT_PUBLISH → Accept spend overlay, opaque queue, + * Federation group genesis (`proposedPolicy` + top-level `messageTypes`), + * GroupChangeProposal votes, and Peer `contract:message` `messageHex` + * accumulate — without importing any application product repo. + */ + +const Actor = require('../types/actor'); +const Key = require('../types/key'); +const Message = require('../types/message'); +const Peer = require('../types/peer'); +const { pubkeyXOnly } = require('../functions/groupChatSeal'); +const { + signingStringForGroupChangeProposal, + signProposalVote, + verifyProposalVote +} = require('../functions/groupChangeGovernance'); +const { + createMemoryStore, + ingestMessageBuffer, + ingestContractPublishBuffer, + normalizeGenesis, + isSyncTrackedType, + loadDoc +} = require('../functions/contractMessageAccumulate'); +const { + enqueueMessageBuffer, + entryHexList, + listQueuedMessages +} = require('../functions/contractMessageQueue'); +const { + createPending, + markReceived, + phaseFlags +} = require('../functions/contractMessageCommit'); +const { + normalizeArcGenesis, + resolveSpend +} = require('../functions/contractSpend'); + +/** Representative Federation group messageTypes allow-list (application catalogs may extend). */ +const FEDERATION_GROUP_MESSAGE_TYPES = Object.freeze([ + 'GroupChat', + 'GroupChange', + 'GroupChangeProposal', + 'GroupChangeVote', + 'GroupShare', + 'GroupActivityTree', + 'FleetShare', + 'FederationContractInvite', + 'FederationContractInviteResponse', + 'GroupJournalRequest', + 'GroupJournalBatch', + 'GroupStateJournal', + 'ContractCapabilityGrant', + 'ContractWithdrawalRequest', + 'ContractWithdrawalWitness' +]); + +function federationGroupDefinition (creatorKey, extra = {}) { + const creator = String(creatorKey.pubkey).toLowerCase(); + return { + name: 'FederationGroup', + version: 6, + parentContract: 'aa'.repeat(32), + groupId: extra.groupId || 'grp-wing-1', + creator, + createdAt: extra.createdAt || '2026-08-13T00:00:00.000Z', + proposedPolicy: { + validators: extra.validators || [creator], + threshold: extra.threshold != null ? extra.threshold : 1 + }, + meta: { + name: extra.name || 'Wing', + visibility: extra.visibility || 'public', + slug: extra.slug || 'wing', + parentId: null + }, + messageTypes: FEDERATION_GROUP_MESSAGE_TYPES.slice(), + state: { members: {}, chat: {}, changes: {}, shares: {}, meta: {} } + }; +} + +function hubPlaynetDefinition (ownerKey) { + const ownerPub = String(ownerKey.pubkey).toLowerCase(); + return { + name: 'DemoGroup', + version: 5, + messageTypes: ['GroupChat', 'GroupChange', 'GroupChangeProposal'], + parties: [ownerPub], + validators: [ownerPub], + proposedPolicy: { validators: [ownerPub], threshold: 1 }, + creator: ownerPub, + state: { network: 'regtest' } + }; +} + +function signContractMessage (key, contractId, type, object) { + return Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ + contract: contractId, + type, + object + })]).signWithKey(key); +} + +/** + * Canonical proposal signing string used by application vote helpers. + * Kept inline so core CI does not import application repos. + */ +function applicationCanonicalPubkeyList (list) { + if (!Array.isArray(list)) return null; + const seen = new Set(); + const out = []; + for (const item of list) { + const x = pubkeyXOnly(item); + if (!x || seen.has(x)) continue; + seen.add(x); + out.push(x); + } + out.sort(); + return out; +} + +function applicationProposalSigningString (proposal) { + const p = proposal && typeof proposal === 'object' ? proposal : {}; + return JSON.stringify({ + type: 'GroupChangeProposal', + v: 2, + id: String(p.id || ''), + contractId: String(p.contractId || '').toLowerCase(), + groupId: String(p.groupId || ''), + action: String(p.action || ''), + member: p.member != null ? String(p.member).toLowerCase() : null, + role: p.role != null ? String(p.role) : null, + members: applicationCanonicalPubkeyList(p.members), + signers: applicationCanonicalPubkeyList(p.signers), + patch: p.patch && typeof p.patch === 'object' ? p.patch : null, + proposedBy: String(p.proposedBy || '').toLowerCase(), + createdAt: String(p.createdAt || '') + }); +} + +describe('application ARC interop (Hub + Federation groups)', function () { + it('proposal signing string is byte-identical to application vote helpers', function () { + const a = new Key(); + const recruit = new Key(); + const proposal = { + id: 'gprop-wing', + contractId: 'ab'.repeat(32), + groupId: 'grp-wing-1', + action: 'member.add', + member: recruit.pubkey, + role: 'signer', + patch: null, + proposedBy: String(a.pubkey).toLowerCase(), + createdAt: '2026-08-13T00:00:00.000Z' + }; + assert.strictEqual( + signingStringForGroupChangeProposal(proposal), + applicationProposalSigningString(proposal) + ); + const signed = signProposalVote(a, proposal); + assert.strictEqual(verifyProposalVote(proposal, a.pubkey, signed.signature), true); + }); + + it('normalizeGenesis reads proposedPolicy + top-level messageTypes', function () { + const owner = new Key(); + const def = federationGroupDefinition(owner, { threshold: 2 }); + const genesis = normalizeGenesis(def); + assert.ok(genesis.signers.includes(pubkeyXOnly(owner.pubkey))); + assert.strictEqual(genesis.threshold, 2); + assert.ok(genesis.primitives.messageTypes.includes('GroupChangeProposal')); + assert.ok(genesis.primitives.messageTypes.includes('FederationContractInvite')); + }); + + it('FederationGroup CONTRACT_PUBLISH seeds accumulate so GroupChat needs no meta.genesis', function () { + const owner = new Key(); + const store = createMemoryStore(); + const def = federationGroupDefinition(owner); + const pub = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(owner); + const seeded = ingestContractPublishBuffer(store, pub.toBuffer()); + assert.strictEqual(seeded.accepted, true, seeded.error); + assert.ok(seeded.contractId); + assert.ok(seeded.genesis.signers.includes(pubkeyXOnly(owner.pubkey))); + + const chat = ingestMessageBuffer( + store, + seeded.contractId, + signContractMessage(owner, seeded.contractId, 'GroupChat', { + body: 'o7', + author: owner.pubkey, + ts: '2026-08-13T00:00:01.000Z' + }).toBuffer(), + { origin: 'mesh' } + ); + assert.strictEqual(chat.accepted, true, chat.error); + assert.strictEqual(isSyncTrackedType('GroupChat', loadDoc(store, seeded.contractId)), true); + }); + + it('threshold-2 proposal stays pending until a BIP340 vote (application vote wire)', function () { + const a = new Key(); + const b = new Key(); + const recruit = new Key(); + const store = createMemoryStore(); + const def = federationGroupDefinition(a, { + validators: [a.pubkey, b.pubkey], + threshold: 2 + }); + const seeded = ingestContractPublishBuffer( + store, + Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(a).toBuffer() + ); + assert.strictEqual(seeded.accepted, true, seeded.error); + const contractId = seeded.contractId; + const proposal = { + id: 'gprop-k2', + contractId, + groupId: def.groupId, + action: 'member.add', + member: recruit.pubkey, + role: 'signer', + patch: null, + proposedBy: pubkeyXOnly(a.pubkey), + createdAt: def.createdAt, + threshold: 2 + }; + const proposed = ingestMessageBuffer( + store, + contractId, + signContractMessage(a, contractId, 'GroupChangeProposal', proposal).toBuffer(), + { origin: 'mesh' } + ); + assert.strictEqual(proposed.accepted, true, proposed.error); + assert.strictEqual(proposed.tip.content.proposals[0].status, 'pending'); + assert.ok(!proposed.tip.content.members.includes(pubkeyXOnly(recruit.pubkey))); + + const vote = signProposalVote(b, proposal); + const adopted = ingestMessageBuffer( + store, + contractId, + signContractMessage(b, contractId, 'GroupChangeVote', { + type: 'GroupChangeVote', + proposalId: proposal.id, + contractId, + groupId: def.groupId, + voter: String(b.pubkey).toLowerCase(), + signature: vote.signature, + votedAt: '2026-08-13T00:00:02.000Z' + }).toBuffer(), + { origin: 'mesh' } + ); + assert.strictEqual(adopted.accepted, true, adopted.error); + assert.strictEqual(adopted.tip.content.proposals[0].status, 'adopted'); + assert.ok(adopted.tip.content.members.includes(pubkeyXOnly(recruit.pubkey))); + }); + + it('GroupChange update patch (pinnedChannels + threshold) is accepted from a signer', function () { + const owner = new Key(); + const store = createMemoryStore(); + const def = federationGroupDefinition(owner); + const seeded = ingestContractPublishBuffer( + store, + Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(owner).toBuffer() + ); + const change = ingestMessageBuffer( + store, + seeded.contractId, + signContractMessage(owner, seeded.contractId, 'GroupChange', { + action: 'update', + patch: { + pinnedChannels: ['discord:1', 'group:' + seeded.contractId], + threshold: 1 + } + }).toBuffer(), + { origin: 'mesh' } + ); + assert.strictEqual(change.accepted, true, change.error); + assert.strictEqual(change.tip.content.threshold, 1); + }); + + it('outsider FederationContractInvite is rejected on a Federation group', function () { + const owner = new Key(); + const outsider = new Key(); + const store = createMemoryStore(); + const def = federationGroupDefinition(owner); + const seeded = ingestContractPublishBuffer( + store, + Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(owner).toBuffer() + ); + const denied = ingestMessageBuffer( + store, + seeded.contractId, + signContractMessage(outsider, seeded.contractId, 'FederationContractInvite', { + inviteePubkey: outsider.pubkey, + groupId: def.groupId + }).toBuffer(), + { origin: 'mesh' } + ); + assert.strictEqual(denied.accepted, false); + }); + + it('Hub playnet DemoGroup publish → Accept overlay yields a spendAddress', function () { + const owner = new Key(); + const definition = hubPlaynetDefinition(owner); + const contractId = new Actor(definition).id; + const arc = normalizeArcGenesis(definition); + assert.ok(arc.members.signers.length); + assert.deepStrictEqual(arc.primitives.messageTypes.slice(0, 3), [ + 'GroupChat', 'GroupChange', 'GroupChangeProposal' + ]); + const tipHash = 'ab'.repeat(32); + const spend = resolveSpend({ + genesis: arc, + tip: { + contractId, + content: { + signers: arc.members.signers.slice(), + members: arc.members.signers.slice(), + threshold: arc.members.threshold + }, + stateDigest: 'cd'.repeat(32), + bitcoinBlockHash: tipHash, + bitcoinHeight: 101 + }, + contractId, + overrides: { network: 'regtest' } + }); + assert.ok(spend.spendAddress || spend.address); + const genesis = normalizeGenesis(definition); + assert.ok(genesis.signers.includes(pubkeyXOnly(owner.pubkey))); + assert.ok(genesis.primitives.messageTypes.includes('GroupChangeProposal')); + }); + + it('Hub queue then participant fold (origin queue) matches mesh ingest', function () { + const owner = new Key(); + const store = createMemoryStore(); + const qStore = createMemoryStore(); + const def = hubPlaynetDefinition(owner); + const seeded = ingestContractPublishBuffer( + store, + Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(owner).toBuffer() + ); + const msg = signContractMessage(owner, seeded.contractId, 'GroupChat', { + body: 'queued-from-hub', + author: owner.pubkey, + ts: '2026-08-13T00:00:03.000Z' + }); + const enq = enqueueMessageBuffer(qStore, seeded.contractId, msg.toBuffer(), { origin: 'mesh' }); + assert.strictEqual(enq.accepted, true, enq.error); + const hexes = entryHexList(listQueuedMessages(qStore, seeded.contractId)); + const folded = ingestMessageBuffer(store, seeded.contractId, hexes[0], { origin: 'queue' }); + assert.strictEqual(folded.accepted, true, folded.error); + assert.strictEqual(folded.tip.clock, 1); + }); + + it('Peer contract:message messageHex folds into accumulate (app relay path)', function () { + const peer = new Peer({ listen: false, peersDb: null, networking: false }); + const owner = new Key(); + const origin = '127.0.0.1:3041'; + peer.connections[origin] = { _writeFabric () {}, destroy () {} }; + peer.peers[origin] = { publicKey: owner.pubkey }; + + const def = federationGroupDefinition(owner); + const publish = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(owner); + peer._handleFabricMessage(publish.toBuffer(), { name: origin }, null); + const contractId = new Actor(def).id; + assert.ok(peer._signerMayPatchContract(contractId, owner.pubkey)); + + const events = []; + peer.on('contract:message', (ev) => events.push(ev)); + const chat = signContractMessage(owner, contractId, 'GroupChat', { + body: 'from-mesh', + author: owner.pubkey, + ts: '2026-08-13T00:00:04.000Z' + }); + peer._handleFabricMessage(chat.toBuffer(), { name: origin }, null); + assert.strictEqual(events.length, 1); + assert.ok(events[0].wireMessage); + assert.match(String(events[0].messageHex || ''), /^[0-9a-f]+$/i); + assert.ok(events[0].genesis); + assert.strictEqual(events[0].genesis.name, 'FederationGroup'); + + const store = createMemoryStore(); + ingestContractPublishBuffer(store, publish.toBuffer()); + const result = ingestMessageBuffer(store, contractId, events[0].messageHex, { origin: 'mesh' }); + assert.strictEqual(result.accepted, true, result.error); + + const record = createPending({ + id: result.entry.hash, + contractId, + readers: [owner.pubkey] + }); + markReceived(record, owner.pubkey); + assert.strictEqual(phaseFlags(record, owner.pubkey).received, true); + }); + + it('attacker cannot front-run a FederationGroup publish (proposedPolicy authorities)', function () { + const peer = new Peer({ listen: false, peersDb: null, networking: false }); + const owner = new Key(); + const attacker = new Key(); + const def = federationGroupDefinition(owner); + const hijack = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(attacker); + peer._handleFabricMessage(hijack.toBuffer(), { name: '127.0.0.1:18445' }, null); + const contractId = new Actor(def).id; + assert.strictEqual(peer._signerMayPatchContract(contractId, attacker.pubkey), false); + assert.ok(!peer.contracts[contractId]); + + const legit = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(def)]).signWithKey(owner); + peer._handleFabricMessage(legit.toBuffer(), { name: '127.0.0.1:18444' }, null); + assert.ok(peer.contracts[contractId]); + assert.ok(peer._signerMayPatchContract(contractId, owner.pubkey)); + assert.strictEqual(peer._signerMayPatchContract(contractId, attacker.pubkey), false); + }); +}); diff --git a/tests/fabric.circuit.js b/tests/fabric.circuit.js index f4bbacad8..ffab33556 100644 --- a/tests/fabric.circuit.js +++ b/tests/fabric.circuit.js @@ -13,48 +13,33 @@ describe('@fabric/core/types/circuit', function () { describe('Bristol Format', function () { it('can convert from Bristol Format', function () { - const circuit = new Circuit({ - state: { - graph: { - nodes: [], - edges: [] - } - }, - gates: [], - wires: [] + const circuit = new Circuit(); + Object.defineProperty(circuit, 'dot', { + configurable: true, + get () { + return '2 2\n1 1 2 XOR\n'; + } }); - - const bristolFormat = '2 2\n1 1 2 XOR\n'; - circuit._state.content.graph = { - nodes: [], - edges: [] - }; - circuit.gates = []; - circuit.wires = []; - - // Parse the Bristol Format and set up the circuit - const lines = bristolFormat.split('\n'); - const [numGates, numWires] = lines[0].split(' ').map(Number); - - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) continue; - - const parts = line.split(' '); - if (parts.length < 3) continue; - - circuit.gates.push({ - type: parts[parts.length - 1], - inputs: parts.slice(0, -1).map(Number) - }); - } - - assert.ok(circuit.gates); + circuit.fromBristolFormat(); assert.strictEqual(circuit.gates.length, 1); assert.strictEqual(circuit.gates[0].type, 'XOR'); assert.deepStrictEqual(circuit.gates[0].inputs, [1, 1, 2]); }); + it('fromBristolFormat skips blank and undersize lines', function () { + const circuit = new Circuit(); + Object.defineProperty(circuit, 'dot', { + configurable: true, + get () { + return '2 4\n\n1 1\n1 1 2 XOR\n \n3 3 4 AND\n'; + } + }); + circuit.fromBristolFormat(); + assert.strictEqual(circuit.gates.length, 2); + assert.strictEqual(circuit.gates[0].type, 'XOR'); + assert.strictEqual(circuit.gates[1].type, 'AND'); + }); + it('can convert to Bristol Format', function () { const circuit = new Circuit(); circuit.gates = [{ @@ -70,50 +55,14 @@ describe('@fabric/core/types/circuit', function () { describe('Bristol Fashion', function () { it('can convert from Bristol Fashion', function () { - const circuit = new Circuit({ - state: { - graph: { - nodes: [], - edges: [] - } - }, - gates: [], - wires: [] + const circuit = new Circuit(); + Object.defineProperty(circuit, 'dot', { + configurable: true, + get () { + return '2 2 1 1\n2 1 1 1 2 XOR\n'; + } }); - - const bristolFashion = '2 2 1 1\n2 1 1 1 2 XOR\n'; - circuit._state.content.graph = { - nodes: [], - edges: [] - }; - circuit.gates = []; - circuit.wires = []; - - // Parse the Bristol Fashion and set up the circuit - const lines = bristolFashion.split('\n'); - const [numGates, numWires, numInputWires, numOutputWires] = lines[0].split(' ').map(Number); - circuit.inputWires = numInputWires; - circuit.outputWires = numOutputWires; - - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) continue; - - const parts = line.split(' '); - if (parts.length < 4) continue; - - const numInputs = parseInt(parts[0]); - const numOutputs = parseInt(parts[1]); - circuit.gates.push({ - type: parts[parts.length - 1], - numInputs: numInputs, - numOutputs: numOutputs, - inputs: parts.slice(2, 2 + numInputs).map(Number), - outputs: parts.slice(2 + numInputs, 2 + numInputs + numOutputs).map(Number) - }); - } - - assert.ok(circuit.gates); + circuit.fromBristolFashion(); assert.strictEqual(circuit.gates.length, 1); assert.strictEqual(circuit.gates[0].type, 'XOR'); assert.strictEqual(circuit.gates[0].numInputs, 2); @@ -270,4 +219,421 @@ describe('@fabric/core/types/circuit', function () { assert.strictEqual(c.methods.m, fn); }); }); -}); \ No newline at end of file +}); +const bitcoin = require('bitcoinjs-lib'); +const Key = require('../types/key'); +const Message = require('../types/message'); +const Machine = require('../types/machine'); +const Program = require('../types/program'); +const { + buildContractProposalPayload +} = require('../functions/contractProposal'); +const { + composeGarblerPublish, + recordProposalDecision, + finalizeBlindedExecution, + bindBlindedExecutionToHashlock, + decisionSigningMessage, + HASHLOCK_LEAF_ID, + DECISION_SIGNING_VERSION +} = require('../functions/blindedExecutionCircuit'); +const { toAddress, synthesizeDefaultLadder } = require('../functions/contractTaproot'); + +describe('functions/blindedExecutionCircuit', function () { + const keys = []; + before(function () { + for (let i = 0; i < 3; i++) keys.push(new Key()); + }); + function pk (i) { return keys[i].pubkey; } + function signDecision (keyIndex, session, proposalPayload, decision, at) { + const merkle = proposalPayload.chain && proposalPayload.chain.merkleRoot + ? String(proposalPayload.chain.merkleRoot).toLowerCase() + : ''; + const msg = decisionSigningMessage({ + sessionId: session.circuitId, + proposalMerkleRoot: merkle, + decision, + at + }); + return Buffer.from(keys[keyIndex].signSchnorr(msg)).toString('hex'); + } + + it('composeGarblerPublish requires an evaluator party and is digest-stable', function () { + assert.throws(() => composeGarblerPublish({ + network: 'regtest', + parties: [], + state: { v: 1 } + }), /at least one evaluator/); + + const a = composeGarblerPublish({ + name: 'hashlock-demo', + network: 'regtest', + garbler: pk(0), + parties: [pk(1)], + state: { balance: 1 }, + program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } + }); + const b = composeGarblerPublish({ + name: 'hashlock-demo', + network: 'regtest', + garbler: pk(0), + parties: [pk(1)], + state: { balance: 1 }, + program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } + }); + + assert.strictEqual(a.version, 1); + assert.ok(a.circuitId && /^[0-9a-f]{64}$/.test(a.circuitId)); + assert.strictEqual(a.programHash, b.programHash); + assert.strictEqual(a.stateDigest, b.stateDigest); + assert.strictEqual(a.circuitCommitment, b.circuitCommitment); + assert.strictEqual(a.roles.garbler, pk(0).toLowerCase()); + assert.deepStrictEqual(a.roles.evaluators, [pk(1).toLowerCase()]); + assert.strictEqual(a.definition['@type'], 'FabricContract'); + assert.ok(a.definition.interfaces.includes('fabric.blinded-execution')); + assert.strictEqual(a.definition.blindedExecution.circuitCommitment, a.circuitCommitment); + assert.strictEqual(a.finalized, false); + assert.strictEqual(a.coordinationRoot, null); + }); + + it('composeGarblerPublish excludes garbler from evaluator set', function () { + assert.throws(() => composeGarblerPublish({ + network: 'regtest', + garbler: pk(0), + parties: [pk(0)], + state: { v: 1 } + }), /distinct from garbler/); + + const session = composeGarblerPublish({ + network: 'regtest', + garbler: pk(0), + parties: [pk(0), pk(1)], + state: { v: 1 }, + program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } + }); + assert.deepStrictEqual(session.roles.evaluators, [pk(1).toLowerCase()]); + assert.deepStrictEqual(session.parties, [pk(1).toLowerCase()]); + assert.ok(!session.parties.includes(pk(0).toLowerCase())); + }); + + it('binds at into decisionSigningMessage v2 (relay cannot swap timestamp)', function () { + assert.strictEqual(DECISION_SIGNING_VERSION, 2); + const msg = decisionSigningMessage({ + sessionId: 'aa'.repeat(32), + proposalMerkleRoot: 'bb'.repeat(32), + decision: 'accept', + at: 42 + }); + assert.strictEqual( + msg, + `fabric:blinded-execution-decision:2:${'aa'.repeat(32)}:${'bb'.repeat(32)}:accept:42` + ); + assert.throws(() => decisionSigningMessage({ + sessionId: 'aa'.repeat(32), + proposalMerkleRoot: 'bb'.repeat(32), + decision: 'accept' + }), /at must be a non-negative safe integer/); + + const session = composeGarblerPublish({ + network: 'regtest', + garbler: pk(0), + parties: [pk(1)], + state: { v: 1 }, + program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } + }); + const proposal = buildContractProposalPayload({ + contractId: session.circuitId, + messages: [Message.fromVector([ + 'P2P_BASE_MESSAGE', + JSON.stringify({ type: 'StateHint', object: { go: true } }) + ])], + statePatch: [{ op: 'add', path: '/ok', value: true }] + }); + assert.throws(() => recordProposalDecision({ + session, + proposalPayload: proposal, + decision: 'accept', + actorPubkey: pk(1), + signature: signDecision(1, session, proposal, 'accept', 7) + }), /at required/); + }); + + it('recordProposalDecision reject then accept; finalize needs an evaluator accept', function () { + let session = composeGarblerPublish({ + name: 'coord-demo', + network: 'regtest', + garbler: pk(0), + parties: [pk(1), pk(2)], + state: { open: true }, + program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } + }); + + const outsider = new Key(); + const rejectInner = Message.fromVector([ + 'P2P_BASE_MESSAGE', + JSON.stringify({ type: 'StateHint', object: { reject: true } }) + ]); + const rejectProposal = buildContractProposalPayload({ + contractId: session.circuitId, + messages: [rejectInner], + statePatch: [{ op: 'replace', path: '/open', value: false }] + }); + + assert.throws(() => recordProposalDecision({ + session, + proposalPayload: rejectProposal, + decision: 'accept', + actorPubkey: outsider.pubkey, + signature: 'aa'.repeat(64), + at: 999 + }), /not a session party/); + + assert.throws(() => recordProposalDecision({ + session, + proposalPayload: rejectProposal, + decision: 'reject', + actorPubkey: pk(1), + at: 1000 + }), /signature required/); + + session = recordProposalDecision({ + session, + proposalPayload: rejectProposal, + decision: 'reject', + actorPubkey: pk(1), + signature: signDecision(1, session, rejectProposal, 'reject', 1000), + at: 1000 + }); + assert.strictEqual(session.decisions.length, 1); + assert.ok(session.coordinationRoot); + const afterReject = session.coordinationRoot; + + // Same actor/proposal/decision/`at` is idempotent. + const replayed = recordProposalDecision({ + session, + proposalPayload: rejectProposal, + decision: 'reject', + actorPubkey: pk(1), + signature: signDecision(1, session, rejectProposal, 'reject', 1000), + at: 1000 + }); + assert.strictEqual(replayed.decisions.length, 1); + assert.strictEqual(replayed.coordinationRoot, afterReject); + assert.strictEqual(replayed.decisions[0].at, 1000); + + // A relay cannot swap `at` under a valid signature (v2 binds timestamp). + assert.throws(() => recordProposalDecision({ + session, + proposalPayload: rejectProposal, + decision: 'reject', + actorPubkey: pk(1), + signature: signDecision(1, session, rejectProposal, 'reject', 1000), + at: 9999 + }), /invalid decision signature/); + + // A later different `at` with a fresh signature is a conflict, not a second row. + assert.throws(() => recordProposalDecision({ + session, + proposalPayload: rejectProposal, + decision: 'reject', + actorPubkey: pk(1), + signature: signDecision(1, session, rejectProposal, 'reject', 9999), + at: 9999 + }), /conflicting at/); + + assert.throws(() => finalizeBlindedExecution({ session }), /evaluator accept/); + + // Garbler-only accept is not enough — need an evaluator. + const garblerAcceptProposal = buildContractProposalPayload({ + contractId: session.circuitId, + messages: [Message.fromVector([ + 'P2P_BASE_MESSAGE', + JSON.stringify({ type: 'StateHint', object: { accept: true } }) + ])], + statePatch: [{ op: 'add', path: '/garblerOk', value: true }] + }); + session = recordProposalDecision({ + session, + proposalPayload: garblerAcceptProposal, + decision: 'accept', + actorPubkey: pk(0), + signature: signDecision(0, session, garblerAcceptProposal, 'accept', 1500), + at: 1500 + }); + assert.throws(() => finalizeBlindedExecution({ session }), /evaluator accept/); + + const acceptInner = Message.fromVector([ + 'P2P_BASE_MESSAGE', + JSON.stringify({ type: 'StateHint', object: { accept: true } }) + ]); + const acceptProposal = buildContractProposalPayload({ + contractId: session.circuitId, + messages: [acceptInner], + statePatch: [{ op: 'add', path: '/settled', value: true }] + }); + + assert.throws(() => recordProposalDecision({ + session, + proposalPayload: acceptProposal, + decision: 'accept', + actorPubkey: pk(2), + signature: signDecision(1, session, acceptProposal, 'accept', 2000), + at: 2000 + }), /invalid decision signature/); + + session = recordProposalDecision({ + session, + proposalPayload: acceptProposal, + decision: 'accept', + actorPubkey: pk(2), + signature: signDecision(2, session, acceptProposal, 'accept', 2000), + at: 2000 + }); + assert.strictEqual(session.decisions.length, 3); + assert.notStrictEqual(session.coordinationRoot, afterReject); + + const baseCommitment = session.circuitCommitment; + const finalized = finalizeBlindedExecution({ session }); + assert.strictEqual(finalized.finalized, true); + assert.notStrictEqual(finalized.circuitCommitment, baseCommitment); + assert.ok(/^[0-9a-f]{64}$/.test(finalized.hashlockCommitmentHex)); + assert.ok(/^[0-9a-f]{64}$/.test(finalized.hashlockPreimageHex)); + }); + + it('binds finalized session to hashlock+pubkey Taproot (public preimage alone insufficient)', async function () { + const bitcoin = require('bitcoinjs-lib'); + const ecc = require('../types/ecc'); + bitcoin.initEccLib(ecc); + const { Psbt } = bitcoin; + + let session = composeGarblerPublish({ + name: 'hashlock-bind', + network: 'regtest', + garbler: pk(0), + parties: [pk(1)], + state: { phase: 'init' }, + program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } + }); + + const acceptInner = Message.fromVector([ + 'P2P_BASE_MESSAGE', + JSON.stringify({ type: 'StateHint', object: { go: true } }) + ]); + const acceptProposal = buildContractProposalPayload({ + contractId: session.circuitId, + messages: [acceptInner], + statePatch: [{ op: 'replace', path: '/phase', value: 'accepted' }] + }); + session = recordProposalDecision({ + session, + proposalPayload: acceptProposal, + decision: 'accept', + actorPubkey: pk(1), + signature: signDecision(1, session, acceptProposal, 'accept', 3000), + at: 3000 + }); + + const prog = Program.from({ language: 'fabric-opcodes', steps: ['OP_TRUE'] }); + prog.compile(); + const machine = new Machine(); + machine.define('OP_TRUE', () => true); + const run = await machine.runProgram(prog); + assert.strictEqual(run.ok, true); + assert.ok(run.runCommitmentHex); + + session = finalizeBlindedExecution({ + session, + runCommitmentHex: run.runCommitmentHex + }); + + const bound = bindBlindedExecutionToHashlock({ + session, + validators: [pk(0), pk(1)], + publisher: pk(0), + network: 'regtest', + threshold: 1 + }); + + assert.notStrictEqual(bound.address, bound.ladderOnlyAddress); + const hl = bound.tree.leaves.find((l) => l.kind === 'hashlock' && l.id === HASHLOCK_LEAF_ID); + assert.ok(hl); + assert.strictEqual(hl.commitmentHex, session.hashlockCommitmentHex); + assert.strictEqual(String(hl.pubkeyHex).toLowerCase(), pk(0).toLowerCase()); + assert.strictEqual(hl.witnessShape, 'preimage+sig'); + + const ladderOnly = synthesizeDefaultLadder({ + validators: [pk(0), pk(1)], + threshold: 1, + publisher: pk(0), + network: 'regtest' + }); + assert.strictEqual(bound.ladderOnlyAddress, toAddress(ladderOnly)); + + const value = 100000n; + const fund = new bitcoin.Transaction(); + fund.version = 2; + fund.addInput(Buffer.alloc(32), 0); + fund.addOutput(bound.tree.output, value); + + const destination = bitcoin.payments.p2wpkh({ + pubkey: Buffer.from(pk(2), 'hex'), + network: bitcoin.networks.regtest + }).address; + + const prepared = bound.prepareWithdrawal({ + fundedTxHex: fund.toHex(), + destinationAddress: destination, + feeSats: 1000 + }); + assert.ok(prepared.psbtBase64); + assert.strictEqual(prepared.action, 'hashlock'); + assert.strictEqual(prepared.witnessShape, 'preimage+sig'); + + const priv = Buffer.isBuffer(keys[0].private) + ? keys[0].private + : Buffer.from(String(keys[0].private), 'hex'); + const publicKey = Buffer.from(ecc.pointFromScalar(priv, true)); + const signer = { + publicKey, + sign (hash) { return Buffer.from(ecc.sign(hash, priv)); }, + signSchnorr (hash) { return Buffer.from(ecc.signSchnorr(hash, priv)); } + }; + const psbt = Psbt.fromBase64(prepared.psbtBase64); + psbt.signInput(0, signer); + + const fin = bound.finalizeWithdrawal({ + psbtBase64: psbt.toBase64(), + witnessShape: 'preimage+sig' + }); + assert.ok(fin.txHex); + assert.ok(fin.txid); + const spent = bitcoin.Transaction.fromHex(fin.txHex); + assert.strictEqual(spent.ins[0].witness.length, 4); + }); +}); + +describe('adversarial blinded-execution (@fabric/core)', function () { + it('refuses a single-party blinded-execution publish (needs evaluator)', function () { + const garbler = new Key(); + assert.throws(() => composeGarblerPublish({ + network: 'regtest', + garbler: garbler.pubkey, + parties: [], + state: { v: 1 }, + program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } + }), /at least one evaluator/); + }); + + it('refuses finalize without an accept under adversarial coordination', function () { + const a = new Key(); + const b = new Key(); + const session = composeGarblerPublish({ + network: 'regtest', + garbler: a.pubkey, + parties: [b.pubkey], + state: {}, + program: { language: 'fabric-opcodes', steps: ['OP_TRUE'] } + }); + assert.throws(() => finalizeBlindedExecution({ session }), /at least one evaluator accept/); + }); +}); diff --git a/tests/fabric.collection.js b/tests/fabric.collection.js index 31615d49e..9e0bfb655 100644 --- a/tests/fabric.collection.js +++ b/tests/fabric.collection.js @@ -54,6 +54,11 @@ describe('@fabric/core/types/collection', function () { assert.equal(set.render(), JSON.stringify(expectedIDs)); }); + it('getLatest is undefined on an empty collection', function () { + const set = new Fabric.Collection(); + assert.strictEqual(set.getLatest(), undefined); + }); + it('can import with commit', async () => { let set = new Fabric.Collection(); let res = await set.import(samples.list[0]); @@ -206,8 +211,9 @@ describe('@fabric/core/types/collection', function () { ]); const latest = set.getLatest(); - // Collection stores keyed objects, so getLatest currently returns undefined. - assert.strictEqual(latest, undefined); + assert.ok(latest); + assert.strictEqual(latest.id, 'x2'); + assert.strictEqual(latest.name, 'y'); const queryResult = await set.query(`${set.path}/x1/name`); assert.strictEqual(queryResult, null); @@ -427,3 +433,302 @@ describe('@fabric/core/types/collection', function () { }); }); }); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const Key = require('../types/key'); +const Message = require('../types/message'); +const { chatTextOf } = require('../functions/fabricChatText'); +const { + COLLECTION_TYPE, + createCollection, + ingest, + ingestMany, + ingestText, + recordsFromJournalEntries, + toJSON, + fromJSON, + toJSONL, + fromJSONL, + replay, + replayFold, + writeFile, + readFile, + runCli, + parseFrame, + verifyBodyHash, + resolveCollectionFilePath +} = require('../functions/fabricMessageCollection'); +const { + createMemoryStore, + ingestMessageBuffer, + loadDoc, + parseContractMessageBody +} = require('../functions/contractMessageAccumulate'); +const { + SIGN_TYPE, + buildCrossSignObject +} = require('../functions/identityCrossSign'); + +function signContract (key, contractId, type, object) { + const body = JSON.stringify({ + contract: contractId, + type, + object + }); + return Message.fromVector(['CONTRACT_MESSAGE', body]).signWithKey(key); +} + +function signChat (key, text) { + return Message.fromVector(['P2P_CHAT_MESSAGE', text]).signWithKey(key); +} + +describe('@fabric/core/functions/fabricMessageCollection', function () { + const contractId = 'a'.repeat(64); + + it('round-trips AMP hex with a stable body hash', function () { + const key = new Key(); + const msg = signChat(key, 'o7 from the shoutbox'); + const buf = msg.toBuffer(); + const collection = createCollection(); + const one = ingest(collection, buf, { origin: 'mesh', peer: 'hub.fabric.pub:7777' }); + assert.strictEqual(one.accepted, true); + assert.strictEqual(one.record.hash.length, 64); + assert.strictEqual(one.record.type, 'P2P_CHAT_MESSAGE'); + assert.strictEqual(one.record.hex, buf.toString('hex')); + + const restored = fromJSON(toJSON(collection)); + assert.strictEqual(restored.messages.length, 1); + assert.strictEqual(restored.messages[0].hash, one.record.hash); + assert.strictEqual(restored.messages[0].hex, one.record.hex); + + const jsonl = fromJSONL(toJSONL(collection)); + assert.strictEqual(jsonl.messages[0].hash, one.record.hash); + }); + + it('dedupes by body hash and skips a flipped header hash', function () { + const key = new Key(); + const msg = signChat(key, 'same bytes twice'); + const buf = msg.toBuffer(); + const collection = createCollection(); + assert.strictEqual(ingest(collection, buf).accepted, true); + assert.strictEqual(ingest(collection, `fabric:${buf.toString('hex')}`).duplicate, true); + assert.strictEqual(collection.messages.length, 1); + + const flipped = Buffer.from(buf); + flipped[80] ^= 0xff; + const bad = ingest(collection, flipped); + assert.strictEqual(bad.skipped, true); + assert.strictEqual(bad.error, 'body-hash-mismatch'); + assert.strictEqual(collection.messages.length, 1); + + const tiny = ingest(collection, Buffer.alloc(10)); + assert.strictEqual(tiny.error, 'undersize'); + }); + + it('replays shoutbox UTF-8 and CONTRACT_MESSAGE inner types in order', function () { + const key = new Key(); + const chat = signChat(key, 'public mesh line'); + const pack = signContract(key, contractId, 'GroupDataShare', { + type: 'GroupDataShare', + groupId: 'grp-ops', + packs: [{ + pack: 'chat.catalog', + platform: 'discord', + payload: { platform: 'discord', guilds: [{ id: 'g1', name: 'Fleet Ops' }] } + }] + }); + const cross = signContract(key, contractId, SIGN_TYPE, buildCrossSignObject({ + localPubkey: key.pubkey, + peerPubkey: '02' + 'bb'.repeat(32), + nonce: 'ab'.repeat(32) + })); + + const collection = createCollection(); + ingestMany(collection, [chat, pack, cross], { origin: 'peer' }); + assert.strictEqual(collection.messages.length, 3); + assert.strictEqual(collection.messages[1].appType, 'GroupDataShare'); + assert.strictEqual(collection.messages[2].appType, SIGN_TYPE); + + const texts = []; + const types = []; + const result = replay(collection, (ctx) => { + types.push(ctx.inner && ctx.inner.type ? ctx.inner.type : ctx.message.type); + if (ctx.message.type === 'P2P_CHAT_MESSAGE') { + texts.push(chatTextOf(ctx.message.body)); + } + }); + assert.strictEqual(result.applied, 3); + assert.deepStrictEqual(texts, ['public mesh line']); + assert.deepStrictEqual(types, ['P2P_CHAT_MESSAGE', 'GroupDataShare', SIGN_TYPE]); + }); + + it('replays a GroupJournalBatch-style fabricMessage.hex list', function () { + const key = new Key(); + const change = signContract(key, contractId, 'GroupChange', { + action: 'member.add', + member: '02' + 'cc'.repeat(32) + }); + const stored = ingest(createCollection(), change); + const entries = [{ + id: 'gchg-1', + type: 'GroupChange', + clock: 1, + fabricMessage: { + hash: stored.record.hash, + hex: stored.record.hex, + type: 'GroupChange' + } + }]; + const claimed = recordsFromJournalEntries(entries); + assert.strictEqual(claimed.length, 1); + const honest = createCollection(); + const ok = ingest(honest, claimed[0]); + assert.strictEqual(ok.accepted, true); + assert.strictEqual(ok.record.appType, 'GroupChange'); + assert.strictEqual(ok.record.hash, stored.record.hash); + + const lying = createCollection(); + const mismatch = ingest(lying, { hash: 'deadbeef'.repeat(8).slice(0, 64), hex: stored.record.hex }); + assert.strictEqual(mismatch.error, 'hash-claim-mismatch'); + }); + + it('collection root is order-sensitive; ARC tip fold is not', function () { + const aKey = new Key(); + const bKey = new Key(); + const genesis = { signers: [aKey.pubkey, bKey.pubkey] }; + const m1 = signContract(aKey, contractId, 'GroupChange', { + action: 'members.set', + members: [aKey.pubkey, bKey.pubkey] + }); + const m2 = signContract(bKey, contractId, 'GroupChat', { + body: 'second', + author: bKey.pubkey, + ts: '2026-01-01T00:00:00.000Z' + }); + + const ab = createCollection(); + ingest(ab, m1); + ingest(ab, m2); + const ba = createCollection(); + ingest(ba, m2); + ingest(ba, m1); + assert.notStrictEqual(toJSON(ab).root, toJSON(ba).root); + assert.strictEqual(toJSON(ab).count, 2); + + function foldIntoStore (collection) { + const store = createMemoryStore(); + replay(collection, (ctx) => { + ingestMessageBuffer(store, contractId, ctx.buffer, { origin: 'replay', genesis }); + }); + return loadDoc(store, contractId); + } + + const docAB = foldIntoStore(ab); + const docBA = foldIntoStore(ba); + assert.strictEqual(docAB.entries.length, 2); + assert.strictEqual(docBA.entries.length, 2); + const hashesAB = docAB.entries.map((e) => e.hash).sort(); + const hashesBA = docBA.entries.map((e) => e.hash).sort(); + assert.deepStrictEqual(hashesAB, hashesBA); + }); + + it('save and restore a JSON file then fold GroupChat deterministically', function () { + const key = new Key(); + const genesis = { signers: [key.pubkey] }; + const msg = signContract(key, contractId, 'GroupChat', { + body: 'restore me', + author: key.pubkey, + ts: '2026-08-15T00:00:00.000Z' + }); + const collection = createCollection(); + ingest(collection, msg, { origin: 'local' }); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-msgcol-')); + const file = path.join(dir, 'stream.json'); + writeFile(file, collection); + const restored = readFile(file); + + const store = createMemoryStore(); + const folded = replayFold(restored, (state, ctx) => { + const result = ingestMessageBuffer(state.store, contractId, ctx.buffer, { + origin: 'replay', + genesis + }); + state.accepted += result.accepted ? 1 : 0; + return state; + }, { store, accepted: 0 }); + + assert.strictEqual(folded.applied, 1); + assert.strictEqual(folded.state.accepted, 1); + const inner = parseContractMessageBody(Message.fromBuffer(Buffer.from(restored.messages[0].hex, 'hex'))); + assert.strictEqual(inner.type, 'GroupChat'); + assert.strictEqual(inner.object.body, 'restore me'); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('ingestText accepts a collection document and CLI collect/replay', function () { + const key = new Key(); + const msg = signChat(key, 'cli line'); + const seed = createCollection(); + ingest(seed, msg); + const collection = createCollection(); + const stats = ingestText(collection, JSON.stringify(toJSON(seed))); + assert.strictEqual(stats.accepted, 1); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-msgcli-')); + const file = path.join(dir, 'seed.json'); + fs.writeFileSync(file, JSON.stringify(toJSON(seed))); + const chunks = []; + const code = runCli(['collect', file], { + stdout: { write (s) { chunks.push(s); return true; } }, + stderr: { write () { return true; } } + }); + assert.strictEqual(code, 0); + const collected = JSON.parse(chunks.join('')); + assert.strictEqual(collected.type, COLLECTION_TYPE); + assert.strictEqual(collected.count, 1); + + const replayOut = []; + const replayCode = runCli(['verify', file], { + stdout: { write (s) { replayOut.push(s); return true; } }, + stderr: { write () { return true; } } + }); + assert.strictEqual(replayCode, 0); + const verified = JSON.parse(replayOut.join('')); + assert.strictEqual(verified.applied, 1); + + const help = []; + const helpCode = runCli(['help'], { + stdout: { write (s) { help.push(s); return true; } }, + stderr: { write () { return true; } } + }); + assert.strictEqual(helpCode, 0); + assert.ok(help.join('').includes('collect')); + + const missingErr = []; + const missingCode = runCli(['collect', path.join(dir, 'no-such.json')], { + stdout: { write () { return true; } }, + stderr: { write (s) { missingErr.push(s); return true; } } + }); + assert.strictEqual(missingCode, 2); + assert.ok(missingErr.join('').includes('read failed')); + + const parsed = parseFrame(msg.toBuffer()); + assert.strictEqual(parsed.ok, true); + assert.strictEqual(verifyBodyHash(parsed.message), true); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('rejects relative collection paths that escape cwd', function () { + assert.strictEqual(resolveCollectionFilePath(''), null); + assert.strictEqual(resolveCollectionFilePath('..\0secret.json'), null); + assert.strictEqual(resolveCollectionFilePath('../etc/passwd'), null); + const abs = path.join(os.tmpdir(), 'fabric-msgcol-ok.json'); + assert.strictEqual(resolveCollectionFilePath(abs), path.normalize(abs)); + const collection = createCollection(); + assert.throws(() => writeFile('../etc/passwd', collection), /invalid collection path/); + }); +}); diff --git a/tests/fabric.environment.js b/tests/fabric.environment.js index 018797805..9c70a6224 100644 --- a/tests/fabric.environment.js +++ b/tests/fabric.environment.js @@ -16,6 +16,62 @@ const path = require('path'); // Fabric Types const Environment = require('../types/environment'); const Wallet = require('../types/wallet'); +const Key = require('../types/key'); + +const IDENTITY_ENV_KEYS = [ + 'NODE_ENV', + 'FABRIC_SEED', + 'FABRIC_MNEMONIC', + 'FABRIC_XPRV', + 'FABRIC_XPUB', + 'FABRIC_PASSWORD', + 'FABRIC_HUB_ADMIN_TOKEN', + 'FABRIC_HUB_ADMIN_TOKEN_FILE' +]; + +function withIsolatedHome (prefix = 'fabric-env-') { + const prev = {}; + for (const key of IDENTITY_ENV_KEYS) prev[key] = process.env[key]; + prev.HOME = process.env.HOME; + const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const store = path.join(home, '.fabric'); + const walletPath = path.join(store, 'wallet.json'); + process.env.HOME = home; + return { + home, + store, + walletPath, + clearIdentityEnv () { + delete process.env.FABRIC_SEED; + delete process.env.FABRIC_MNEMONIC; + delete process.env.FABRIC_XPRV; + delete process.env.FABRIC_XPUB; + delete process.env.FABRIC_PASSWORD; + delete process.env.FABRIC_HUB_ADMIN_TOKEN; + delete process.env.FABRIC_HUB_ADMIN_TOKEN_FILE; + }, + leftoverWalletTemporaryFiles () { + try { + const base = path.basename(walletPath); + return fs.readdirSync(store).filter((name) => { + return name.startsWith(base + '.') && name.endsWith('.tmp'); + }); + } catch (error) { + if (error && error.code === 'ENOENT') return []; + throw error; + } + }, + restore () { + for (const key of IDENTITY_ENV_KEYS) { + if (prev[key] === undefined) delete process.env[key]; + else process.env[key] = prev[key]; + } + if (prev.HOME === undefined) delete process.env.HOME; + else process.env.HOME = prev.HOME; + fs.rmSync(home, { recursive: true, force: true }); + } + }; +} describe('@fabric/core/types/environment', function () { describe('Environment', function () { @@ -24,16 +80,30 @@ describe('@fabric/core/types/environment', function () { }); it('can start and stop smoothly', async function () { - const environment = new Environment(); - await environment.start(); - await environment.stop(); - assert.ok(environment); + const iso = withIsolatedHome('fabric-env-start-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const environment = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store, + lockTimeoutMinutes: 0 + }); + await environment.start(); + await environment.stop(); + assert.ok(environment); + assert.strictEqual(environment.wallet, false); + } finally { + iso.restore(); + } }); it('wipes in-memory wallet keys on stop', function () { const prev = { NODE_ENV: process.env.NODE_ENV, FABRIC_SEED: process.env.FABRIC_SEED, + FABRIC_MNEMONIC: process.env.FABRIC_MNEMONIC, FABRIC_XPRV: process.env.FABRIC_XPRV, FABRIC_XPUB: process.env.FABRIC_XPUB, FABRIC_PASSWORD: process.env.FABRIC_PASSWORD @@ -43,6 +113,7 @@ describe('@fabric/core/types/environment', function () { const walletPath = path.join(store, 'wallet.json'); try { delete process.env.FABRIC_SEED; + delete process.env.FABRIC_MNEMONIC; delete process.env.FABRIC_XPRV; delete process.env.FABRIC_XPUB; delete process.env.FABRIC_PASSWORD; @@ -71,6 +142,7 @@ describe('@fabric/core/types/environment', function () { const prev = { NODE_ENV: process.env.NODE_ENV, FABRIC_SEED: process.env.FABRIC_SEED, + FABRIC_MNEMONIC: process.env.FABRIC_MNEMONIC, FABRIC_XPRV: process.env.FABRIC_XPRV, FABRIC_XPUB: process.env.FABRIC_XPUB, cwd: process.cwd() @@ -78,6 +150,7 @@ describe('@fabric/core/types/environment', function () { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-cwd-seed-')); try { delete process.env.FABRIC_SEED; + delete process.env.FABRIC_MNEMONIC; delete process.env.FABRIC_XPRV; delete process.env.FABRIC_XPUB; process.env.NODE_ENV = 'production'; @@ -102,7 +175,7 @@ describe('@fabric/core/types/environment', function () { assert.strictEqual(String(allowed.seed).trim(), FIXTURE_SEED); } finally { process.chdir(prev.cwd); - for (const k of ['NODE_ENV', 'FABRIC_SEED', 'FABRIC_XPRV', 'FABRIC_XPUB']) { + for (const k of ['NODE_ENV', 'FABRIC_SEED', 'FABRIC_MNEMONIC', 'FABRIC_XPRV', 'FABRIC_XPUB']) { if (prev[k] === undefined) delete process.env[k]; else process.env[k] = prev[k]; } @@ -114,6 +187,7 @@ describe('@fabric/core/types/environment', function () { const prev = { NODE_ENV: process.env.NODE_ENV, FABRIC_SEED: process.env.FABRIC_SEED, + FABRIC_MNEMONIC: process.env.FABRIC_MNEMONIC, FABRIC_XPRV: process.env.FABRIC_XPRV, FABRIC_XPUB: process.env.FABRIC_XPUB }; @@ -122,6 +196,7 @@ describe('@fabric/core/types/environment', function () { const walletPath = path.join(store, 'wallet.json'); try { delete process.env.FABRIC_SEED; + delete process.env.FABRIC_MNEMONIC; delete process.env.FABRIC_XPRV; delete process.env.FABRIC_XPUB; process.env.NODE_ENV = 'production'; @@ -137,16 +212,40 @@ describe('@fabric/core/types/environment', function () { } }); + it('NODE_ENV=test fixture seed wins over a developer FABRIC_XPRV', function () { + const { home, store, walletPath, restore, clearIdentityEnv } = withIsolatedHome('fabric-env-fixture-xprv-'); + try { + clearIdentityEnv(); + process.env.NODE_ENV = 'test'; + const foreign = new Key(); + process.env.FABRIC_XPRV = foreign.xprv; + const environment = new Environment({ home, path: walletPath, store }); + environment.start(); + assert.ok(environment.wallet, 'fixture seed should still load in test'); + const fixtureWallet = new Wallet({ key: { seed: FIXTURE_SEED } }); + assert.strictEqual( + environment.wallet.key && environment.wallet.key.pubkey, + fixtureWallet.key.pubkey, + 'mocha fixture identity must not be replaced by FABRIC_XPRV' + ); + environment.stop(); + } finally { + restore(); + } + }); + it('does not emit fatal error events when wallet file is empty or invalid JSON', function () { const prev = { NODE_ENV: process.env.NODE_ENV, FABRIC_SEED: process.env.FABRIC_SEED, + FABRIC_MNEMONIC: process.env.FABRIC_MNEMONIC, FABRIC_XPRV: process.env.FABRIC_XPRV, FABRIC_XPUB: process.env.FABRIC_XPUB }; try { delete process.env.FABRIC_SEED; + delete process.env.FABRIC_MNEMONIC; delete process.env.FABRIC_XPRV; delete process.env.FABRIC_XPUB; process.env.NODE_ENV = 'production'; @@ -176,21 +275,61 @@ describe('@fabric/core/types/environment', function () { }); it('can instantiate from a seed', async function () { - const environment = new Environment({ seed: FIXTURE_SEED }); - await environment.start(); - await environment.stop(); - assert.ok(environment); - // assert.strictEqual(environment.xprv, FIXTURE_XPRV); - // assert.strictEqual(environment.xpub, FIXTURE_XPUB); + const iso = withIsolatedHome('fabric-env-seed-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const environment = new Environment({ + seed: FIXTURE_SEED, + home: iso.home, + path: iso.walletPath, + store: iso.store + }); + await environment.start(); + assert.ok(environment.wallet); + assert.ok(environment.xprv); + assert.match(String(environment.xprv), /^xprv/); + await environment.stop(); + } finally { + iso.restore(); + } }); it('can instantiate from an xpub', async function () { - const environment = new Environment({ xpub: FIXTURE_XPUB }); - await environment.start(); - await environment.stop(); - assert.ok(environment); - // assert.strictEqual(environment.xprv, undefined); - // assert.strictEqual(environment.xpub, FIXTURE_XPUB); + const iso = withIsolatedHome('fabric-env-xpub-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const environment = new Environment({ + xpub: FIXTURE_XPUB, + home: iso.home, + path: iso.walletPath, + store: iso.store + }); + await environment.start(); + assert.ok(environment.xpub); + await environment.stop(); + } finally { + iso.restore(); + } + }); + + it('classifies an xprv in settings.seed as the wallet xprv', async function () { + const iso = withIsolatedHome('fabric-env-seed-xprv-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const environment = new Environment({ + seed: FIXTURE_XPRV, + home: iso.home, + path: iso.walletPath, + store: iso.store + }); + environment.loadWallet(); + assert.strictEqual(environment.wallet.key.xprv, FIXTURE_XPRV); + } finally { + iso.restore(); + } }); it('can instantiate from an xprv', async function () { @@ -222,61 +361,163 @@ describe('@fabric/core/types/environment', function () { }); it('can save a valid wallet', async function () { - const environment = new Environment({ - path: `./stores/test-wallet.json` - }); - - const wallet = new Wallet({ - type: 'FabricWallet', - format: 'aes-256-cbc', - version: 1 - }); - - environment.setWallet(wallet, false, { encrypt: false }); - assert.ok(environment); - environment.destroyWallet(); + const iso = withIsolatedHome('fabric-env-save-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const environment = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store, + lockTimeoutMinutes: 0 + }); + environment.start(); + const wallet = new Wallet({ key: { seed: FIXTURE_SEED } }); + environment.setWallet(wallet, false, { password: 'test-pass-ok!' }); + assert.ok(fs.existsSync(iso.walletPath)); + const doc = JSON.parse(fs.readFileSync(iso.walletPath, 'utf8')); + assert.strictEqual(doc.passwordProtected, true); + environment.destroyWallet(); + assert.strictEqual(fs.existsSync(iso.walletPath), false); + } finally { + iso.restore(); + } }); it('can check for store', async function () { - const environment = new Environment(); - const exists = environment.storeExists(); - assert.ok(environment); - assert.equal(typeof exists, 'boolean'); + const iso = withIsolatedHome('fabric-env-store-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const environment = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store + }); + assert.strictEqual(environment.storeExists(), false); + environment.makeStore(); + assert.strictEqual(environment.storeExists(), true); + } finally { + iso.restore(); + } }); it('can check for wallet', async function () { - const environment = new Environment(); - const exists = environment.walletExists(); - - assert.ok(environment); - assert.equal(typeof exists, 'boolean'); + const iso = withIsolatedHome('fabric-env-exists-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const environment = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store, + lockTimeoutMinutes: 0 + }); + assert.strictEqual(environment.walletExists(), false); + environment.start(); + environment.setWallet(new Wallet({ key: { seed: FIXTURE_SEED } }), false, { password: 'test-pass-ok!' }); + assert.strictEqual(environment.walletExists(), true); + } finally { + iso.restore(); + } }); it('can touch the wallet', async function () { - if (process.env.FABRIC_ALLOW_WALLET_TOUCH !== '1') { - // Avoid failures in environments without permission to write to $HOME - return; + const iso = withIsolatedHome('fabric-env-touch-ok-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const environment = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store + }); + assert.strictEqual(environment.touchWallet(), true); + assert.ok(fs.existsSync(iso.walletPath)); + assert.strictEqual(fs.statSync(iso.walletPath).size, 0); + } finally { + iso.restore(); } - - const environment = new Environment(); - environment.touchWallet(); - assert.ok(environment); }); it('can load the wallet', async function () { - const environment = new Environment(); - environment.loadWallet(); - assert.ok(environment); + const iso = withIsolatedHome('fabric-env-load-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const environment = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store, + lockTimeoutMinutes: 0 + }); + environment.start(); + environment.setWallet(new Wallet({ key: { seed: FIXTURE_SEED } }), false, { password: 'test-pass-ok!' }); + environment.lockWallet(); + environment.loadWallet({ fromFile: true, password: 'test-pass-ok!' }); + assert.ok(environment.wallet && environment.wallet.key); + } finally { + iso.restore(); + } + }); + + it('loadWallet({ fromFile: true }) ignores leftover FABRIC_SEED and FABRIC_XPRV (PR #185)', function () { + const iso = withIsolatedHome('fabric-env-fromfile-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + fs.mkdirSync(iso.store, { recursive: true }); + const writer = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store, + lockTimeoutMinutes: 0 + }); + writer.start(); + const wallet = new Wallet({ key: { seed: FIXTURE_SEED } }); + writer.setWallet(wallet, false, { encrypt: false }); + const fileXprv = writer.wallet.key.xprv; + writer.stop(); + + const leftover = new Key({ mnemonic: 'legal winner thank year wave sausage worth useful legal winner thank yellow' }); + process.env.FABRIC_SEED = leftover.seed; + process.env.FABRIC_XPRV = leftover.xprv; + const reader = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store, + lockTimeoutMinutes: 0 + }); + reader.start(); + reader.loadWallet({ fromFile: true }); + assert.ok(reader.wallet && reader.wallet.key); + assert.strictEqual(reader.wallet.key.xprv, fileXprv); + assert.notStrictEqual(reader.wallet.key.xprv, leftover.xprv); + reader.stop(); + } finally { + iso.restore(); + } }); it('can read the wallet', async function () { - const environment = new Environment(); - if (!environment.walletExists()) { - // Wallet file only exists after `fabric setup`; skip in CI and fresh envs - return; + const iso = withIsolatedHome('fabric-env-read-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const environment = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store, + lockTimeoutMinutes: 0 + }); + environment.start(); + environment.setWallet(new Wallet({ key: { seed: FIXTURE_SEED } }), false, { password: 'test-pass-ok!' }); + const raw = environment.readWallet(); + assert.ok(typeof raw === 'string' && raw.includes('seal')); + assert.ok(!raw.includes(FIXTURE_SEED)); + } finally { + iso.restore(); } - environment.readWallet(); - assert.ok(environment); }); it('can read contracts', async function () { @@ -289,6 +530,7 @@ describe('@fabric/core/types/environment', function () { const prev = { NODE_ENV: process.env.NODE_ENV, FABRIC_SEED: process.env.FABRIC_SEED, + FABRIC_MNEMONIC: process.env.FABRIC_MNEMONIC, FABRIC_XPRV: process.env.FABRIC_XPRV, FABRIC_XPUB: process.env.FABRIC_XPUB, FABRIC_PASSWORD: process.env.FABRIC_PASSWORD @@ -299,6 +541,7 @@ describe('@fabric/core/types/environment', function () { const password = 'test-pass-ok!'; try { delete process.env.FABRIC_SEED; + delete process.env.FABRIC_MNEMONIC; delete process.env.FABRIC_XPRV; delete process.env.FABRIC_XPUB; delete process.env.FABRIC_PASSWORD; @@ -345,6 +588,7 @@ describe('@fabric/core/types/environment', function () { const prev = { NODE_ENV: process.env.NODE_ENV, FABRIC_SEED: process.env.FABRIC_SEED, + FABRIC_MNEMONIC: process.env.FABRIC_MNEMONIC, FABRIC_XPRV: process.env.FABRIC_XPRV, FABRIC_XPUB: process.env.FABRIC_XPUB, FABRIC_PASSWORD: process.env.FABRIC_PASSWORD @@ -354,6 +598,7 @@ describe('@fabric/core/types/environment', function () { const walletPath = path.join(store, 'wallet.json'); try { delete process.env.FABRIC_SEED; + delete process.env.FABRIC_MNEMONIC; delete process.env.FABRIC_XPRV; delete process.env.FABRIC_XPUB; delete process.env.FABRIC_PASSWORD; @@ -378,78 +623,56 @@ describe('@fabric/core/types/environment', function () { }); it('touchWallet does not truncate an existing sealed wallet when utimes fails', function () { - const prev = { - NODE_ENV: process.env.NODE_ENV, - FABRIC_SEED: process.env.FABRIC_SEED, - FABRIC_XPRV: process.env.FABRIC_XPRV, - FABRIC_XPUB: process.env.FABRIC_XPUB, - FABRIC_PASSWORD: process.env.FABRIC_PASSWORD - }; - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-env-touch-')); - const store = path.join(home, '.fabric'); - const walletPath = path.join(store, 'wallet.json'); + const isolated = withIsolatedHome('fabric-env-touch-'); const origUtimes = fs.utimesSync; try { - delete process.env.FABRIC_SEED; - delete process.env.FABRIC_XPRV; - delete process.env.FABRIC_XPUB; - delete process.env.FABRIC_PASSWORD; + isolated.clearIdentityEnv(); process.env.NODE_ENV = 'production'; - const environment = new Environment({ home, path: walletPath, store, lockTimeoutMinutes: 0 }); + const environment = new Environment({ + home: isolated.home, + path: isolated.walletPath, + store: isolated.store, + lockTimeoutMinutes: 0 + }); environment.start(); environment.setWallet(new Wallet({ key: { seed: FIXTURE_SEED } }), true, { password: 'test-pass-ok!' }); - const before = fs.readFileSync(walletPath, 'utf8'); + const before = fs.readFileSync(isolated.walletPath, 'utf8'); assert.ok(before.includes('seal')); fs.utimesSync = () => { throw new Error('EPERM'); }; environment.touchWallet(); - assert.strictEqual(fs.readFileSync(walletPath, 'utf8'), before); - assert.ok(!fs.existsSync(`${walletPath}.${process.pid}.tmp`)); + assert.strictEqual(fs.readFileSync(isolated.walletPath, 'utf8'), before); + assert.deepStrictEqual(isolated.leftoverWalletTemporaryFiles(), []); } finally { fs.utimesSync = origUtimes; - for (const k of Object.keys(prev)) { - if (prev[k] === undefined) delete process.env[k]; - else process.env[k] = prev[k]; - } - fs.rmSync(home, { recursive: true, force: true }); + isolated.restore(); } }); it('cleans up the tmp wallet file when rename fails and keeps the existing seal', function () { - const prev = { - NODE_ENV: process.env.NODE_ENV, - FABRIC_SEED: process.env.FABRIC_SEED, - FABRIC_XPRV: process.env.FABRIC_XPRV, - FABRIC_XPUB: process.env.FABRIC_XPUB, - FABRIC_PASSWORD: process.env.FABRIC_PASSWORD - }; - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-env-rename-')); - const store = path.join(home, '.fabric'); - const walletPath = path.join(store, 'wallet.json'); + const isolated = withIsolatedHome('fabric-env-rename-'); const origRename = fs.renameSync; try { - delete process.env.FABRIC_SEED; - delete process.env.FABRIC_XPRV; - delete process.env.FABRIC_XPUB; - delete process.env.FABRIC_PASSWORD; + isolated.clearIdentityEnv(); process.env.NODE_ENV = 'production'; - const environment = new Environment({ home, path: walletPath, store, lockTimeoutMinutes: 0 }); + const environment = new Environment({ + home: isolated.home, + path: isolated.walletPath, + store: isolated.store, + lockTimeoutMinutes: 0 + }); environment.start(); environment.setWallet(new Wallet({ key: { seed: FIXTURE_SEED } }), true, { password: 'test-pass-ok!' }); - const before = fs.readFileSync(walletPath, 'utf8'); + const before = fs.readFileSync(isolated.walletPath, 'utf8'); fs.renameSync = () => { throw new Error('EXDEV'); }; assert.throws( () => environment.setWallet(new Wallet({ key: { seed: FIXTURE_SEED } }), true, { password: 'test-pass-ok!' }), /EXDEV/ ); - assert.ok(!fs.existsSync(`${walletPath}.${process.pid}.tmp`)); - assert.strictEqual(fs.readFileSync(walletPath, 'utf8'), before); + assert.deepStrictEqual(isolated.leftoverWalletTemporaryFiles(), []); + assert.strictEqual(fs.readFileSync(isolated.walletPath, 'utf8'), before); } finally { fs.renameSync = origRename; - for (const k of Object.keys(prev)) { - if (prev[k] === undefined) delete process.env[k]; - else process.env[k] = prev[k]; - } - fs.rmSync(home, { recursive: true, force: true }); + isolated.restore(); } }); @@ -458,6 +681,7 @@ describe('@fabric/core/types/environment', function () { const prev = { NODE_ENV: process.env.NODE_ENV, FABRIC_SEED: process.env.FABRIC_SEED, + FABRIC_MNEMONIC: process.env.FABRIC_MNEMONIC, FABRIC_XPRV: process.env.FABRIC_XPRV, FABRIC_XPUB: process.env.FABRIC_XPUB, FABRIC_PASSWORD: process.env.FABRIC_PASSWORD @@ -467,6 +691,7 @@ describe('@fabric/core/types/environment', function () { const walletPath = path.join(store, 'wallet.json'); try { delete process.env.FABRIC_SEED; + delete process.env.FABRIC_MNEMONIC; delete process.env.FABRIC_XPRV; delete process.env.FABRIC_XPUB; delete process.env.FABRIC_PASSWORD; @@ -499,6 +724,7 @@ describe('@fabric/core/types/environment', function () { const prev = { NODE_ENV: process.env.NODE_ENV, FABRIC_SEED: process.env.FABRIC_SEED, + FABRIC_MNEMONIC: process.env.FABRIC_MNEMONIC, FABRIC_XPRV: process.env.FABRIC_XPRV, FABRIC_XPUB: process.env.FABRIC_XPUB }; @@ -507,6 +733,7 @@ describe('@fabric/core/types/environment', function () { const walletPath = path.join(store, 'wallet.json'); try { delete process.env.FABRIC_SEED; + delete process.env.FABRIC_MNEMONIC; delete process.env.FABRIC_XPRV; delete process.env.FABRIC_XPUB; process.env.NODE_ENV = 'production'; @@ -534,6 +761,7 @@ describe('@fabric/core/types/environment', function () { const prev = { NODE_ENV: process.env.NODE_ENV, FABRIC_SEED: process.env.FABRIC_SEED, + FABRIC_MNEMONIC: process.env.FABRIC_MNEMONIC, FABRIC_XPRV: process.env.FABRIC_XPRV, FABRIC_XPUB: process.env.FABRIC_XPUB }; @@ -542,6 +770,7 @@ describe('@fabric/core/types/environment', function () { const walletPath = path.join(store, 'wallet.json'); try { delete process.env.FABRIC_SEED; + delete process.env.FABRIC_MNEMONIC; delete process.env.FABRIC_XPRV; delete process.env.FABRIC_XPUB; process.env.NODE_ENV = 'production'; @@ -571,10 +800,51 @@ describe('@fabric/core/types/environment', function () { } }); + it('wallet writes survive chmodSync failures on the temporary file', function () { + const prev = { + NODE_ENV: process.env.NODE_ENV, + FABRIC_SEED: process.env.FABRIC_SEED, + FABRIC_MNEMONIC: process.env.FABRIC_MNEMONIC, + FABRIC_XPRV: process.env.FABRIC_XPRV, + FABRIC_XPUB: process.env.FABRIC_XPUB + }; + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-env-chmod-')); + const store = path.join(home, '.fabric'); + const walletPath = path.join(store, 'wallet.json'); + const originalChmod = fs.chmodSync; + try { + delete process.env.FABRIC_SEED; + delete process.env.FABRIC_MNEMONIC; + delete process.env.FABRIC_XPRV; + delete process.env.FABRIC_XPUB; + process.env.NODE_ENV = 'production'; + const environment = new Environment({ home, path: walletPath, store, lockTimeoutMinutes: 0 }); + environment.start(); + fs.chmodSync = function (target) { + if (String(target).includes('.tmp')) { + const err = new Error('EPERM'); + err.code = 'EPERM'; + throw err; + } + return originalChmod.apply(fs, arguments); + }; + environment.setWallet(new Wallet({ key: { seed: FIXTURE_SEED } }), false, { password: 'test-pass-ok!' }); + assert.strictEqual(fs.existsSync(walletPath), true); + } finally { + fs.chmodSync = originalChmod; + for (const k of Object.keys(prev)) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; + } + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it('does not auto-unlock from a generic PASSWORD environment variable', function () { const prev = { NODE_ENV: process.env.NODE_ENV, FABRIC_SEED: process.env.FABRIC_SEED, + FABRIC_MNEMONIC: process.env.FABRIC_MNEMONIC, FABRIC_XPRV: process.env.FABRIC_XPRV, FABRIC_XPUB: process.env.FABRIC_XPUB, FABRIC_PASSWORD: process.env.FABRIC_PASSWORD, @@ -586,6 +856,7 @@ describe('@fabric/core/types/environment', function () { const password = 'test-pass-ok!'; try { delete process.env.FABRIC_SEED; + delete process.env.FABRIC_MNEMONIC; delete process.env.FABRIC_XPRV; delete process.env.FABRIC_XPUB; delete process.env.FABRIC_PASSWORD; @@ -608,6 +879,125 @@ describe('@fabric/core/types/environment', function () { } }); + it('auto-unlocks a sealed wallet from FABRIC_PASSWORD', function () { + const iso = withIsolatedHome('fabric-env-fabpass-'); + const password = 'test-pass-ok!'; + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const environment = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store, + lockTimeoutMinutes: 0 + }); + environment.start(); + environment.setWallet(new Wallet({ key: { seed: FIXTURE_SEED } }), true, { password }); + environment.lockWallet(); + process.env.FABRIC_PASSWORD = password; + const reloaded = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store, + lockTimeoutMinutes: 0 + }); + reloaded.start(); + assert.ok(reloaded.wallet && reloaded.wallet.key); + assert.strictEqual(reloaded.walletLocked, false); + reloaded.lockWallet(); + } finally { + iso.restore(); + } + }); + + it('encryptWallet seals a loaded plaintext file', function () { + const iso = withIsolatedHome('fabric-env-encrypt-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const environment = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store, + lockTimeoutMinutes: 0 + }); + environment.start(); + environment.setWallet(new Wallet({ key: { seed: FIXTURE_SEED } }), true, { encrypt: false }); + assert.strictEqual(JSON.parse(fs.readFileSync(iso.walletPath, 'utf8')).passwordProtected, false); + environment.encryptWallet('test-pass-ok!'); + const doc = JSON.parse(fs.readFileSync(iso.walletPath, 'utf8')); + assert.strictEqual(doc.passwordProtected, true); + assert.ok(doc.seal && doc.seal.ciphertext); + assert.ok(!JSON.stringify(doc.object).includes('xprv')); + environment.lockWallet(); + environment.unlockWallet('test-pass-ok!'); + assert.ok(environment.wallet && environment.wallet.key); + } finally { + iso.restore(); + } + }); + + it('rejects plaintext unlock and a wrong sealed password', function () { + const iso = withIsolatedHome('fabric-env-badunlock-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const environment = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store, + lockTimeoutMinutes: 0 + }); + environment.start(); + environment.setWallet(new Wallet({ key: { seed: FIXTURE_SEED } }), true, { encrypt: false }); + assert.throws(() => environment.unlockWallet('test-pass-ok!'), /not password-protected/); + environment.encryptWallet('test-pass-ok!'); + environment.lockWallet(); + assert.throws( + () => environment.unlockWallet('wrong-pass-ok!'), + (err) => err && err.code === 'FABRIC_SEAL_DECRYPT' + ); + assert.strictEqual(environment.wallet, false); + assert.strictEqual(environment.walletLocked, true); + } finally { + iso.restore(); + } + }); + + it('idle-locks an unlocked sealed wallet', function (done) { + const iso = withIsolatedHome('fabric-env-idle-'); + let restored = false; + const finish = (err) => { + if (restored) return; + restored = true; + iso.restore(); + done(err); + }; + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const environment = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store, + lockTimeoutMs: 25 + }); + environment.start(); + environment.setWallet(new Wallet({ key: { seed: FIXTURE_SEED } }), true, { password: 'test-pass-ok!' }); + environment.lockSession.once('lock', () => { + try { + assert.strictEqual(environment.wallet, false); + assert.strictEqual(environment.walletLocked, true); + finish(); + } catch (exception) { + finish(exception); + } + }); + } catch (exception) { + finish(exception); + } + }); + describe('bitcoin.conf helpers', function () { it('_parseConfigValue unwraps quotes and coerces primitives', function () { const e = new Environment(); @@ -780,3 +1170,806 @@ describe('@fabric/core/types/environment', function () { }); }); }); + +const bip39 = require('../functions/bip39'); +const { + parseRawSeedHex, + classifyFabricKeyMaterial, + keySettingsFromEnv, + MIN_SEED_BYTES, + MAX_SEED_BYTES +} = require('../functions/fabricKeyMaterial'); + +const SAMPLE_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + +describe('@fabric/core/functions/fabricKeyMaterial', function () { + it('parses raw BIP32 seed hex (optional 0x) within 16–64 bytes', function () { + const hex = 'aa'.repeat(32); + const a = parseRawSeedHex(hex); + const b = parseRawSeedHex('0x' + hex); + assert.ok(Buffer.isBuffer(a)); + assert.strictEqual(a.length, 32); + assert.deepStrictEqual(a, b); + assert.strictEqual(parseRawSeedHex('aa'.repeat(MIN_SEED_BYTES - 1)), null); + assert.strictEqual(parseRawSeedHex('aa'.repeat(MAX_SEED_BYTES + 1)), null); + assert.strictEqual(parseRawSeedHex('not-hex'), null); + }); + + it('classifies xprv, seed hex, and mnemonic', function () { + assert.strictEqual(classifyFabricKeyMaterial('xprv1example').kind, 'xprv'); + assert.strictEqual(classifyFabricKeyMaterial('aa'.repeat(32)).kind, 'seedHex'); + assert.strictEqual(classifyFabricKeyMaterial(SAMPLE_MNEMONIC).kind, 'mnemonic'); + assert.strictEqual(classifyFabricKeyMaterial('???').kind, 'unknown'); + }); + + it('keySettingsFromEnv prefers FABRIC_XPRV then FABRIC_SEED then FABRIC_MNEMONIC', function () { + const seedHex = bip39.mnemonicToSeedSync(SAMPLE_MNEMONIC).toString('hex'); + assert.deepStrictEqual(keySettingsFromEnv({ + FABRIC_XPRV: 'xprv1example', + FABRIC_SEED: seedHex, + FABRIC_MNEMONIC: SAMPLE_MNEMONIC + }), { xprv: 'xprv1example' }); + assert.deepStrictEqual(keySettingsFromEnv({ + FABRIC_SEED: seedHex, + FABRIC_MNEMONIC: SAMPLE_MNEMONIC + }), { seed: seedHex }); + assert.deepStrictEqual(keySettingsFromEnv({ + FABRIC_MNEMONIC: SAMPLE_MNEMONIC + }), { mnemonic: SAMPLE_MNEMONIC }); + assert.strictEqual(keySettingsFromEnv({}), null); + }); +}); + +const { + parseDotEnvText, + loadDotEnvFile, + loadFabricHomeEnv, + mintHubAdminToken, + writeHubAdminToken, + readHubAdminToken, + upsertFabricHomeEnv, + HOME_ENV_KEYS +} = require('../functions/fabricHomeEnv'); + +describe('@fabric/core/functions/fabricHomeEnv', function () { + it('parses KEY=VALUE lines and ignores comments', function () { + const parsed = parseDotEnvText([ + '# comment', + 'FABRIC_XPRV=xprv1example', + "FABRIC_SEED='aabbcc'", + 'NOT A LINE', + 'FABRIC_MNEMONIC="one two three"' + ].join('\n')); + assert.strictEqual(parsed.FABRIC_XPRV, 'xprv1example'); + assert.strictEqual(parsed.FABRIC_SEED, 'aabbcc'); + assert.strictEqual(parsed.FABRIC_MNEMONIC, 'one two three'); + }); + + it('fills missing env keys from ~/.fabric/env without overriding process env', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-home-env-')); + try { + upsertFabricHomeEnv({ + FABRIC_XPRV: 'xprv-from-file', + FABRIC_SEED: 'aa'.repeat(32) + }, { store: dir }); + const env = { FABRIC_XPRV: 'xprv-already-set' }; + const n = loadFabricHomeEnv(env, { store: dir }); + assert.ok(n >= 1); + assert.strictEqual(env.FABRIC_XPRV, 'xprv-already-set'); + assert.strictEqual(env.FABRIC_SEED, 'aa'.repeat(32)); + assert.ok(HOME_ENV_KEYS.has('FABRIC_XPRV')); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('readHubAdminToken prefers env over a token file', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-admin-token-')); + try { + const file = path.join(dir, 'hub-admin-token'); + fs.writeFileSync(file, 'file-token\n', { encoding: 'utf8', mode: 0o600 }); + const fromEnv = readHubAdminToken({ FABRIC_HUB_ADMIN_TOKEN: 'env-token' }, { store: dir }); + assert.strictEqual(fromEnv.token, 'env-token'); + assert.strictEqual(fromEnv.source, 'env'); + const fromFile = readHubAdminToken({}, { tokenPath: file, store: dir }); + assert.strictEqual(fromFile.token, 'file-token'); + assert.strictEqual(fromFile.source, 'adminTokenFile'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('loadDotEnvFile ignores keys outside the allow list', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-dotenv-allow-')); + try { + const file = path.join(dir, 'env'); + fs.writeFileSync(file, 'FABRIC_XPRV=ok\nSECRET_OTHER=nope\n'); + const env = {}; + loadDotEnvFile(file, env, { allowKeys: HOME_ENV_KEYS }); + assert.strictEqual(env.FABRIC_XPRV, 'ok'); + assert.strictEqual(env.SECRET_OTHER, undefined); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +const { + sealJson, + openSealedJson, + isSealedEnvelope, + isPasswordProtectedDocument, + SEAL_SCHEME, + PASSWORD_MIN_LENGTH +} = require('../functions/sealedBlob'); + +describe('@fabric/core/functions/sealedBlob', function () { + const password = 'test-pass-ok!'; + const secret = { seed: 'abandon abandon', xprv: 'xprv-test', note: 'keep me' }; + + it('round-trips JSON through AES-256-GCM', function () { + const envelope = sealJson(secret, password); + assert.strictEqual(envelope.type, 'FabricSealedBlob'); + assert.strictEqual(envelope.encryption, SEAL_SCHEME); + assert.ok(envelope.ciphertext); + assert.ok(envelope.iv); + assert.ok(envelope.kdf && envelope.kdf.salt); + assert.strictEqual(envelope.kdf.iterations, 210000); + const opened = openSealedJson(envelope, password); + assert.deepStrictEqual(opened, secret); + }); + + it('opens a nested seal field (wallet document shape)', function () { + const envelope = sealJson(secret, password); + const document = { + type: 'FabricWallet', + format: SEAL_SCHEME, + passwordProtected: true, + object: { id: 'public', xpub: 'xpub1' }, + seal: envelope + }; + assert.strictEqual(isPasswordProtectedDocument(document), true); + assert.deepStrictEqual(openSealedJson(document, password), secret); + }); + + it('opens a Hub backup v2-shaped envelope (ciphertext + kdf + iv)', function () { + const core = sealJson(secret, password); + const hubShaped = { + format: SEAL_SCHEME, + kdf: core.kdf, + iv: core.iv, + ciphertext: core.ciphertext + }; + assert.strictEqual(isSealedEnvelope(hubShaped), true); + assert.deepStrictEqual(openSealedJson(hubShaped, password), secret); + }); + + it('rejects a short password and a wrong password', function () { + assert.throws(() => sealJson(secret, 'short'), (err) => err.code === 'FABRIC_PASSWORD_POLICY'); + const envelope = sealJson(secret, password); + assert.throws(() => openSealedJson(envelope, 'wrong-pass-ok!'), (err) => err.code === 'FABRIC_SEAL_DECRYPT'); + }); + + it('does not treat plaintext wallets as sealed', function () { + assert.strictEqual(PASSWORD_MIN_LENGTH, 8); + assert.strictEqual(isPasswordProtectedDocument({ + type: 'FabricWallet', + format: 'plaintext', + object: { xprv: 'xprv1', seed: 'words' } + }), false); + assert.strictEqual(isSealedEnvelope({ object: { xprv: 'xprv1' } }), false); + }); +}); + +const Token = require('../types/token'); +const { loadIdentityFromWalletFile } = require('../functions/fabricWalletIdentity'); + +describe('@fabric/core operator env (seed hex + home admin token)', function () { + it('classifies xprv, raw seed hex, and mnemonic', function () { + const key = new Key({ mnemonic: FIXTURE_SEED }); + const hex = bip39.mnemonicToSeedSync(FIXTURE_SEED).toString('hex'); + assert.strictEqual(classifyFabricKeyMaterial(key.xprv).kind, 'xprv'); + assert.strictEqual(classifyFabricKeyMaterial(hex).kind, 'seedHex'); + assert.strictEqual(classifyFabricKeyMaterial(FIXTURE_SEED).kind, 'mnemonic'); + assert.ok(parseRawSeedHex(hex)); + assert.strictEqual(parseRawSeedHex(FIXTURE_SEED), null); + }); + + it('keySettingsFromEnv prefers XPRV then SEED hex then MNEMONIC', function () { + const key = new Key({ mnemonic: FIXTURE_SEED }); + const hex = bip39.mnemonicToSeedSync(FIXTURE_SEED).toString('hex'); + assert.deepStrictEqual(keySettingsFromEnv({ FABRIC_XPRV: key.xprv, FABRIC_SEED: hex }), { xprv: key.xprv }); + assert.deepStrictEqual(keySettingsFromEnv({ FABRIC_SEED: hex }), { seed: hex }); + assert.deepStrictEqual(keySettingsFromEnv({ FABRIC_MNEMONIC: FIXTURE_SEED }), { mnemonic: FIXTURE_SEED }); + assert.strictEqual(keySettingsFromEnv({}), null); + }); + + it('loads identity from a plaintext wallet.json when env is empty', function () { + const iso = withIsolatedHome('fabric-wallet-id-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const environment = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store + }); + environment.setWallet(new Wallet({ key: { seed: FIXTURE_SEED } }), true, { encrypt: false }); + const loaded = loadIdentityFromWalletFile({ + home: iso.home, + store: iso.store, + walletPath: iso.walletPath + }); + assert.ok(loaded); + assert.ok(loaded.xprv); + const expected = new Key({ mnemonic: FIXTURE_SEED }); + assert.strictEqual(loaded.xprv, expected.xprv); + } finally { + iso.restore(); + } + }); + + it('loads identity from a sealed wallet.json with FABRIC_PASSWORD', function () { + const iso = withIsolatedHome('fabric-wallet-seal-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'production'; + const password = 'correct-horse-battery'; + const environment = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store + }); + environment.setWallet(new Wallet({ key: { seed: FIXTURE_SEED } }), true, { password, encrypt: true }); + assert.strictEqual(loadIdentityFromWalletFile({ + home: iso.home, + store: iso.store, + walletPath: iso.walletPath + }), null); + const loaded = loadIdentityFromWalletFile({ + home: iso.home, + store: iso.store, + walletPath: iso.walletPath, + password + }); + assert.ok(loaded); + assert.strictEqual(loaded.xprv, new Key({ mnemonic: FIXTURE_SEED }).xprv); + } finally { + iso.restore(); + } + }); + + it('loads wallet.json even when FABRIC_SEED is invalid nonempty text', function () { + const iso = withIsolatedHome('fabric-wallet-fromfile-'); + try { + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'test'; + process.env.FABRIC_SEED = 'not-a-seed-or-mnemonic'; + const environment = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store + }); + environment.setWallet(new Wallet({ key: { seed: FIXTURE_SEED } }), true, { encrypt: false }); + const loaded = loadIdentityFromWalletFile({ + home: iso.home, + store: iso.store, + walletPath: iso.walletPath + }); + assert.ok(loaded); + assert.strictEqual(loaded.xprv, new Key({ mnemonic: FIXTURE_SEED }).xprv); + } finally { + iso.restore(); + } + }); + + it('mints a Hub admin token and reads it from ~/.fabric/hub-admin-token', function () { + const iso = withIsolatedHome('fabric-home-token-'); + try { + iso.clearIdentityEnv(); + const key = new Key({ mnemonic: FIXTURE_SEED }); + const token = mintHubAdminToken(key); + assert.ok(Token.verifySigned(token, key)); + const tokenPath = writeHubAdminToken(token, { home: iso.home, store: iso.store }); + const envPath = upsertFabricHomeEnv({ + FABRIC_HUB_ADMIN_TOKEN_FILE: tokenPath + }, { home: iso.home, store: iso.store }); + assert.ok(fs.existsSync(envPath)); + const env = {}; + loadFabricHomeEnv(env, { home: iso.home, store: iso.store }); + const got = readHubAdminToken(env, { home: iso.home, store: iso.store }); + assert.strictEqual(got.token, token); + assert.ok(got.source === 'adminTokenFile' || got.source === 'home'); + } finally { + iso.restore(); + } + }); +}); + +const SetupTui = require('../types/setup'); +const fabricSetup = require('../functions/fabricSetup'); + +const TEST_PASSWORD = 'test-pass-ok!'; + +const liveIsolatedEnvs = []; + +function isolatedEnv () { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-setup-')); + const store = path.join(home, '.fabric'); + const walletPath = path.join(store, 'wallet.json'); + const environment = new Environment({ home, path: walletPath, store, lockTimeoutMinutes: 0 }); + environment.start(); + const handle = { home, store, walletPath, environment }; + liveIsolatedEnvs.push(handle); + return handle; +} + +function withEnv (overrides, fn) { + const keys = Object.keys(overrides); + const prev = {}; + for (const k of keys) { + prev[k] = process.env[k]; + if (overrides[k] === undefined) delete process.env[k]; + else process.env[k] = overrides[k]; + } + try { + return fn(); + } finally { + for (const k of keys) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; + } + } +} + +describe('@fabric/core/functions/fabricSetup', function () { + this.timeout(20000); + + let prevNodeEnv; + let prevSeed; + let prevMnemonic; + let prevXprv; + let prevXpub; + + beforeEach(function () { + prevNodeEnv = process.env.NODE_ENV; + prevSeed = process.env.FABRIC_SEED; + prevMnemonic = process.env.FABRIC_MNEMONIC; + prevXprv = process.env.FABRIC_XPRV; + prevXpub = process.env.FABRIC_XPUB; + process.env.NODE_ENV = 'production'; + delete process.env.FABRIC_SEED; + delete process.env.FABRIC_MNEMONIC; + delete process.env.FABRIC_XPRV; + delete process.env.FABRIC_XPUB; + }); + + afterEach(function () { + if (prevNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = prevNodeEnv; + if (prevSeed === undefined) delete process.env.FABRIC_SEED; + else process.env.FABRIC_SEED = prevSeed; + if (prevMnemonic === undefined) delete process.env.FABRIC_MNEMONIC; + else process.env.FABRIC_MNEMONIC = prevMnemonic; + if (prevXprv === undefined) delete process.env.FABRIC_XPRV; + else process.env.FABRIC_XPRV = prevXprv; + if (prevXpub === undefined) delete process.env.FABRIC_XPUB; + else process.env.FABRIC_XPUB = prevXpub; + while (liveIsolatedEnvs.length) { + const { home, environment } = liveIsolatedEnvs.pop(); + try { environment.stop(); } catch (_) { /* already stopped */ } + try { fs.rmSync(home, { recursive: true, force: true }); } catch (_) { /* already removed */ } + } + }); + + describe('resolveSetupPath', function () { + it('rejects empty, null-byte, and oversize paths', function () { + assert.strictEqual(fabricSetup.resolveSetupPath(''), null); + assert.strictEqual(fabricSetup.resolveSetupPath('foo\0bar'), null); + assert.strictEqual(fabricSetup.resolveSetupPath('x'.repeat(fabricSetup.SETUP_PATH_MAX_LEN + 1)), null); + }); + + it('resolves relative paths against cwd', function () { + const cwd = os.tmpdir(); + const abs = fabricSetup.resolveSetupPath('wallet-backup.json', { cwd }); + assert.strictEqual(abs, path.normalize(path.join(cwd, 'wallet-backup.json'))); + }); + + it('accepts absolute paths', function () { + const abs = path.join(os.tmpdir(), 'fabric-usb', 'wallet.json'); + assert.strictEqual(fabricSetup.resolveSetupPath(abs), path.normalize(abs)); + }); + }); + + describe('shouldLaunchTui', function () { + const tty = { isTTY: true }; + + it('is true on a TTY without one-shot flags', function () { + withEnv({ CI: undefined, FABRIC_SETUP_TUI: undefined }, () => { + assert.strictEqual(fabricSetup.shouldLaunchTui({}, { stdout: tty }), true); + }); + }); + + it('is false without a TTY', function () { + withEnv({ CI: undefined, FABRIC_SETUP_TUI: undefined }, () => { + assert.strictEqual(fabricSetup.shouldLaunchTui({}, { stdout: { isTTY: false } }), false); + assert.strictEqual(fabricSetup.shouldLaunchTui({}, { stdout: {} }), false); + }); + }); + + it('is false for json, force, backup, restore, --no-tui, and CI', function () { + withEnv({ CI: undefined, FABRIC_SETUP_TUI: undefined }, () => { + assert.strictEqual(fabricSetup.shouldLaunchTui({ json: true }, { stdout: tty }), false); + assert.strictEqual(fabricSetup.shouldLaunchTui({ force: true }, { stdout: tty }), false); + assert.strictEqual(fabricSetup.shouldLaunchTui({ backup: true }, { stdout: tty }), false); + assert.strictEqual(fabricSetup.shouldLaunchTui({ backup: '/tmp/w.json' }, { stdout: tty }), false); + assert.strictEqual(fabricSetup.shouldLaunchTui({ unlock: true }, { stdout: tty }), false); + assert.strictEqual(fabricSetup.shouldLaunchTui({ lock: true }, { stdout: tty }), false); + assert.strictEqual(fabricSetup.shouldLaunchTui({ timeout: '15' }, { stdout: tty }), false); + assert.strictEqual(fabricSetup.shouldLaunchTui({ noTui: true }, { stdout: tty }), false); + assert.strictEqual(fabricSetup.shouldLaunchTui({ tui: false }, { stdout: tty }), false); + }); + withEnv({ CI: '1', FABRIC_SETUP_TUI: undefined }, () => { + assert.strictEqual(fabricSetup.shouldLaunchTui({}, { stdout: tty }), false); + }); + withEnv({ CI: undefined, FABRIC_SETUP_TUI: '0' }, () => { + assert.strictEqual(fabricSetup.shouldLaunchTui({}, { stdout: tty }), false); + }); + }); + }); + + describe('snapshot / generate / backup / restore', function () { + it('snapshots a missing wallet without secrets', function () { + const { home, environment } = isolatedEnv(); + try { + const snapshot = fabricSetup.snapshotEnvironment(environment); + assert.strictEqual(snapshot.status, 'missing'); + assert.strictEqual(snapshot.walletLoaded, false); + assert.strictEqual(snapshot.identity, null); + const text = fabricSetup.formatSnapshot(snapshot); + assert.ok(text.includes('missing')); + assert.ok(!/xprv[A-Za-z0-9]{20,}/i.test(text), 'snapshot text must not contain an xprv payload'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('generates, snapshots public fields, and refuses a second generate without force', function () { + const { home, environment } = isolatedEnv(); + try { + const created = fabricSetup.generateWallet(environment, { passphrase: '', password: TEST_PASSWORD }); + assert.strictEqual(created.ok, true); + assert.ok(created.seed && created.seed.split(' ').length >= 12); + assert.ok(environment.walletExists()); + + const snapshot = fabricSetup.snapshotEnvironment(environment); + assert.strictEqual(snapshot.status, 'unlocked'); + assert.strictEqual(snapshot.passwordProtected, true); + assert.ok(snapshot.identity && snapshot.identity.startsWith('id')); + assert.ok(snapshot.pubkey); + assert.ok(snapshot.xpub && snapshot.xpub.startsWith('xpub')); + const text = fabricSetup.formatSnapshot(snapshot); + assert.ok(!text.includes(created.seed)); + assert.ok(!/xprv[A-Za-z0-9]/i.test(text)); + + const blocked = fabricSetup.generateWallet(environment); + assert.strictEqual(blocked.ok, false); + assert.ok(/already configured/i.test(blocked.error)); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('backs up and restores a wallet file', function () { + const { home, store, environment } = isolatedEnv(); + try { + const created = fabricSetup.generateWallet(environment, { password: TEST_PASSWORD }); + assert.strictEqual(created.ok, true); + const identity = fabricSetup.snapshotEnvironment(environment).identity; + const backupPath = path.join(store, 'backups', 'roundtrip.json'); + const backed = fabricSetup.backupWallet(environment, backupPath); + assert.strictEqual(backed.ok, true); + assert.ok(fs.existsSync(backupPath)); + + environment.destroyWallet(); + environment.wallet = false; + environment.walletLocked = false; + environment.walletPublic = null; + + const restored = fabricSetup.restoreWalletFromFile(environment, backupPath, { password: TEST_PASSWORD }); + // file is gone so restore should succeed without force + assert.strictEqual(restored.ok, true); + assert.strictEqual(fabricSetup.snapshotEnvironment(environment).identity, identity); + + environment.destroyWallet(); + environment.wallet = false; + environment.walletLocked = false; + environment.walletPublic = null; + const badPassword = fabricSetup.restoreWalletFromFile(environment, backupPath, { + force: true, + password: 'wrong-password-xx' + }); + assert.strictEqual(badPassword.ok, false); + assert.ok(badPassword.error); + + const again = fabricSetup.restoreWalletFromFile(environment, backupPath); + assert.strictEqual(again.ok, false); + + const forced = fabricSetup.restoreWalletFromFile(environment, backupPath, { force: true, password: TEST_PASSWORD }); + assert.strictEqual(forced.ok, true); + assert.strictEqual(fabricSetup.snapshotEnvironment(environment).identity, identity); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('restores from a seed phrase', function () { + const { home, environment } = isolatedEnv(); + try { + const created = fabricSetup.generateWallet(environment, { password: TEST_PASSWORD }); + const phrase = created.seed; + const identity = fabricSetup.snapshotEnvironment(environment).identity; + + environment.destroyWallet(); + environment.wallet = false; + environment.walletLocked = false; + environment.walletPublic = null; + + const restored = fabricSetup.restoreWalletFromSeed(environment, phrase, { password: TEST_PASSWORD }); + assert.strictEqual(restored.ok, true); + assert.strictEqual(fabricSetup.snapshotEnvironment(environment).identity, identity); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('rejects plaintext restore when the encryption password is missing', function () { + const { home, store, environment } = isolatedEnv(); + try { + const created = fabricSetup.generateWallet(environment, { encrypt: false }); + assert.strictEqual(created.ok, true); + const backupPath = path.join(store, 'backups', 'plaintext.json'); + const backed = fabricSetup.backupWallet(environment, backupPath); + assert.strictEqual(backed.ok, true); + + environment.destroyWallet(); + environment.wallet = false; + environment.walletLocked = false; + environment.walletPublic = null; + + const missing = fabricSetup.restoreWalletFromFile(environment, backupPath); + assert.strictEqual(missing.ok, false); + assert.match(String(missing.error), /at least/i); + assert.strictEqual(environment.walletExists(), false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('does not print the master xprv even when FABRIC_DEBUG is set', function () { + const warnings = []; + const logs = []; + const origWarn = console.warn; + const origLog = console.log; + console.warn = (...args) => warnings.push(args.join(' ')); + console.log = (...args) => logs.push(args.join(' ')); + try { + withEnv({ FABRIC_DEBUG: '1' }, () => { + fabricSetup.printGeneratedWallet('abandon ability able', { + walletId: 'id1', + xpub: 'xpubPUBLIC', + xprv: 'xprvTESTSECRET' + }); + }); + } finally { + console.warn = origWarn; + console.log = origLog; + } + const text = warnings.concat(logs).join('\n'); + assert.ok(!/xprvTESTSECRET/.test(text)); + assert.ok(/abandon ability able/.test(text)); + }); + + it('rejects an invalid seed phrase', function () { + const { home, environment } = isolatedEnv(); + try { + const result = fabricSetup.restoreWalletFromSeed(environment, 'not a mnemonic at all'); + assert.strictEqual(result.ok, false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('reveals seed only on demand', function () { + const { home, environment } = isolatedEnv(); + try { + assert.strictEqual(fabricSetup.revealSecrets(environment).ok, false); + const created = fabricSetup.generateWallet(environment, { password: TEST_PASSWORD }); + const secrets = fabricSetup.revealSecrets(environment); + assert.strictEqual(secrets.ok, true); + assert.strictEqual(secrets.seed, created.seed); + assert.ok(secrets.xprv && secrets.xprv.startsWith('xprv')); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('refuses generate without an encryption password', function () { + const { home, environment } = isolatedEnv(); + try { + const result = fabricSetup.generateWallet(environment); + assert.strictEqual(result.ok, false); + assert.ok(/password/i.test(result.error)); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('locks, unlocks, and persists idle timeout', function () { + const { home, environment } = isolatedEnv(); + try { + const created = fabricSetup.generateWallet(environment, { password: TEST_PASSWORD }); + assert.strictEqual(created.ok, true); + assert.strictEqual(fabricSetup.snapshotEnvironment(environment).status, 'unlocked'); + + environment.lockWallet(); + const locked = fabricSetup.snapshotEnvironment(environment); + assert.strictEqual(locked.status, 'locked'); + assert.strictEqual(locked.passwordProtected, true); + assert.ok(locked.xpub && locked.xpub.startsWith('xpub')); + assert.strictEqual(fabricSetup.revealSecrets(environment).ok, false); + + environment.unlockWallet(TEST_PASSWORD); + assert.strictEqual(fabricSetup.snapshotEnvironment(environment).status, 'unlocked'); + assert.strictEqual(fabricSetup.revealSecrets(environment).seed, created.seed); + + environment.setLockTimeoutMinutes(15); + const snap = fabricSetup.snapshotEnvironment(environment); + assert.strictEqual(snap.lockTimeoutMinutes, 15); + const doc = JSON.parse(fs.readFileSync(environment.WALLET_FILE, 'utf8')); + assert.strictEqual(doc.lockTimeoutMinutes, 15); + assert.ok(doc.seal && doc.seal.ciphertext); + assert.ok(!JSON.stringify(doc.object).includes('xprv')); + } finally { + environment.setLockTimeoutMinutes(0); + fs.rmSync(home, { recursive: true, force: true }); + } + }); + }); + + describe('runSetupCli', function () { + it('prints JSON snapshot for --json', async function () { + const { home, environment } = isolatedEnv(); + const logs = []; + const orig = console.log; + console.log = function () { logs.push(Array.from(arguments).join(' ')); }; + try { + const result = await fabricSetup.runSetupCli(environment, { json: true }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.snapshot.status, 'missing'); + const parsed = JSON.parse(logs.join('\n')); + assert.strictEqual(parsed.status, 'missing'); + assert.ok(!parsed.seed); + assert.ok(!parsed.xprv); + } finally { + console.log = orig; + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('writes a backup via --backup', async function () { + const { home, store, environment } = isolatedEnv(); + const origLog = console.log; + console.log = function () {}; + try { + const created = fabricSetup.generateWallet(environment, { password: TEST_PASSWORD }); + assert.strictEqual(created.ok, true); + const dest = path.join(store, 'cli-backup.json'); + const result = await fabricSetup.runSetupCli(environment, { backup: dest }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.path, dest); + assert.ok(fs.existsSync(dest)); + } finally { + console.log = origLog; + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('rejects a non-numeric --timeout', async function () { + const { home, environment } = isolatedEnv(); + const origErr = console.error; + console.error = function () {}; + try { + const result = await fabricSetup.runSetupCli(environment, { timeout: 'abc' }); + assert.strictEqual(result.ok, false); + assert.match(result.error, /integer/i); + } finally { + console.error = origErr; + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('accepts integer --timeout including 0', async function () { + const { home, environment } = isolatedEnv(); + const origLog = console.log; + console.log = function () {}; + try { + fabricSetup.generateWallet(environment, { password: TEST_PASSWORD }); + const zero = await fabricSetup.runSetupCli(environment, { timeout: '0' }); + assert.strictEqual(zero.ok, true); + assert.strictEqual(environment.lockSession.timeoutMinutes, 0); + const fifteen = await fabricSetup.runSetupCli(environment, { timeout: 15 }); + assert.strictEqual(fifteen.ok, true); + assert.strictEqual(environment.lockSession.timeoutMinutes, 15); + } finally { + console.log = origLog; + fs.rmSync(home, { recursive: true, force: true }); + } + }); + }); + + describe('applyWalletFileOption', function () { + it('fails closed on an invalid path', function () { + const { home, environment } = isolatedEnv(); + try { + const result = fabricSetup.applyWalletFileOption(environment, 'bad\0path.json'); + assert.strictEqual(result.ok, false); + assert.match(result.error, /Invalid --wallet path/i); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + }); +}); + +describe('@fabric/core/types/setup', function () { + this.timeout(20000); + + it('returns a public snapshot when render is false', async function () { + const prev = { + NODE_ENV: process.env.NODE_ENV, + FABRIC_SEED: process.env.FABRIC_SEED, + FABRIC_MNEMONIC: process.env.FABRIC_MNEMONIC, + FABRIC_XPRV: process.env.FABRIC_XPRV, + FABRIC_XPUB: process.env.FABRIC_XPUB + }; + process.env.NODE_ENV = 'production'; + delete process.env.FABRIC_SEED; + delete process.env.FABRIC_MNEMONIC; + delete process.env.FABRIC_XPRV; + delete process.env.FABRIC_XPUB; + const { home, environment } = isolatedEnv(); + try { + fabricSetup.generateWallet(environment, { password: TEST_PASSWORD }); + const ui = new SetupTui(environment, { render: false }); + const snapshot = await ui.start(); + assert.strictEqual(snapshot.status, 'unlocked'); + assert.ok(snapshot.identity && snapshot.identity.startsWith('id')); + assert.ok(!snapshot.seed); + } finally { + for (const k of Object.keys(prev)) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; + } + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('compares encryption passwords in constant time', function () { + assert.ok(fabricSetup.passwordsMatch('same-secret', 'same-secret')); + assert.ok(!fabricSetup.passwordsMatch('same-secret', 'same-secreT')); + assert.ok(!fabricSetup.passwordsMatch('short', 'longer-than-short')); + assert.ok(!fabricSetup.passwordsMatch(null, 'x')); + assert.ok(fabricSetup.passwordsMatch(null, null)); + }); + + it('fails closed on an invalid --wallet path before opening the TUI', async function () { + const { home, environment } = isolatedEnv(); + try { + const ui = new SetupTui(environment, { render: false, walletFile: 'bad\0path.json' }); + await assert.rejects(() => ui.start(), /Invalid --wallet path/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/fabric.filesystem.js b/tests/fabric.filesystem.js index 43f5c7e32..b1023599d 100644 --- a/tests/fabric.filesystem.js +++ b/tests/fabric.filesystem.js @@ -119,6 +119,81 @@ describe('@fabric/core/types/filesystem', function () { assert.strictEqual(actorJson.object.name, 'Satoshi Nakamoto'); }); + it('publish does not retain document bodies in memory', async function () { + await filesystem.start(); + for (let i = 0; i < 20; i++) { + await filesystem.publish('blob-' + i + '.txt', 'payload-' + i); + } + assert.deepStrictEqual(Object.keys(filesystem.documents), []); + assert.strictEqual(fs.existsSync(path.join(testDir, 'blob-19.txt')), true); + assert.ok(filesystem.files.includes('blob-19.txt')); + }); + + it('writeFile/readFile refuse a final-component symlink (O_NOFOLLOW)', async function () { + await filesystem.start(); + const outside = path.join(os.tmpdir(), 'fabric-fs-nofollow-' + process.pid + '.txt'); + const linkName = 'symlink-escape.txt'; + const linkPath = path.join(testDir, linkName); + fs.writeFileSync(outside, 'outside-target'); + try { + fs.symlinkSync(outside, linkPath); + assert.strictEqual(filesystem.readFile(linkName), null); + assert.strictEqual(filesystem.writeFile(linkName, 'overwrite'), false); + assert.strictEqual(fs.readFileSync(outside, 'utf8'), 'outside-target'); + assert.strictEqual(filesystem.delete(linkName), true); + assert.strictEqual(fs.existsSync(linkPath), false); + assert.strictEqual(fs.readFileSync(outside, 'utf8'), 'outside-target'); + } finally { + if (fs.existsSync(linkPath)) fs.unlinkSync(linkPath); + if (fs.existsSync(outside)) fs.unlinkSync(outside); + } + }); + + it('writeFile/readFile/delete refuse paths that escape the store root', async function () { + await filesystem.start(); + const probe = path.resolve(testDir, '..', 'filesystem-escape-probe.txt'); + try { + if (fs.existsSync(probe)) fs.unlinkSync(probe); + assert.strictEqual(filesystem.writeFile('../filesystem-escape-probe.txt', 'escaped'), false); + assert.strictEqual(fs.existsSync(probe), false); + assert.strictEqual(filesystem.readFile('../filesystem-escape-probe.txt'), null); + fs.writeFileSync(probe, 'outside'); + assert.strictEqual(filesystem.delete('../filesystem-escape-probe.txt'), false); + assert.strictEqual(fs.readFileSync(probe, 'utf8'), 'outside'); + await assert.rejects( + () => filesystem.publish('../filesystem-escape-probe.txt', 'escaped'), + /Could not publish/ + ); + } finally { + if (fs.existsSync(probe)) fs.unlinkSync(probe); + } + }); + + it('tracks the store-relative name when publish is given an absolute path', async function () { + await filesystem.start(); + const abs = path.join(path.resolve(testDir), 'abs-note.txt'); + await filesystem.publish(abs, 'payload'); + assert.ok(filesystem.files.includes('abs-note.txt')); + assert.ok(!filesystem.files.some((name) => name === 'Users' || name === 'home')); + assert.strictEqual(fs.readFileSync(path.join(testDir, 'abs-note.txt'), 'utf8'), 'payload'); + }); + + it('publish fails closed when writeFile returns false', async function () { + await filesystem.start(); + const originalWrite = filesystem.writeFile; + filesystem.writeFile = function () { return false; }; + try { + await assert.rejects( + () => filesystem.publish('missing.txt', 'payload'), + /Could not publish missing\.txt/ + ); + assert.ok(!filesystem.files.includes('missing.txt')); + assert.strictEqual(fs.existsSync(path.join(testDir, 'missing.txt')), false); + } finally { + filesystem.writeFile = originalWrite; + } + }); + it('can delete from a local filesystem', async function () { const actor = new Actor({ name: 'Satoshi Nakamoto' }); const filesystem = new Filesystem({ diff --git a/tests/fabric.hub.mesh.integration.js b/tests/fabric.hub.mesh.integration.js index 0636093a7..d4d353338 100644 --- a/tests/fabric.hub.mesh.integration.js +++ b/tests/fabric.hub.mesh.integration.js @@ -236,3 +236,685 @@ describe('@fabric/core Hub mesh integration', function () { await bob.stop(); }); }); + +/** + * Publish a generic application contract, run common ARC operations, gossip + * the AMP chain across a local Peer mesh, and lock the message collection + * plus folded tip to tests/fixtures/application-contract-chain.json. + * + * UPDATE_FIXTURE=1 npm test -- --grep 'generic application contract chain' + */ + +const fs = require('fs'); +const path = require('path'); + +const { + createCollection, + ingest, + toJSON, + fromJSON, + replay +} = require('../functions/fabricMessageCollection'); +const { NetworkHarness } = require('./simulator/lib/network'); + +const FIXTURE_PATH = path.join(__dirname, 'fixtures', 'application-contract-chain.json'); + +function loadFixture () { + return JSON.parse(fs.readFileSync(FIXTURE_PATH, 'utf8')); +} + +function attachCollector (peer) { + const collection = createCollection(); + const orig = peer._handleFabricMessage.bind(peer); + peer._handleFabricMessage = function (buffer, origin, socket) { + ingest(collection, buffer, { + origin: origin && origin.name ? String(origin.name) : 'peer' + }); + return orig(buffer, origin, socket); + }; + return collection; +} + +function appHashes (collection) { + return (collection.messages || []) + .filter((row) => row.type === 'CONTRACT_PUBLISH' || row.type === 'CONTRACT_MESSAGE') + .map((row) => row.hash) + .sort(); +} + +describe('generic application contract chain', function () { + this.timeout(30000); + + it('builds a stable AMP chain that matches the committed fixture', function () { + const chain = buildGenericApplicationChain(); + if (process.env.UPDATE_FIXTURE === '1' || process.env.UPDATE_FIXTURE === 'true') { + fs.writeFileSync(FIXTURE_PATH, JSON.stringify(serializeFixture(chain), null, 2) + '\n'); + } + const fixture = loadFixture(); + assert.strictEqual(fixture.type, 'FabricApplicationContractChainFixture'); + assert.strictEqual(chain.contractId, fixture.contractId); + assert.strictEqual(chain.document.root, fixture.collection.root); + assert.strictEqual(chain.document.count, fixture.collection.count); + assert.deepStrictEqual( + chain.document.messages.map((m) => m.hash), + fixture.collection.messages.map((m) => m.hash) + ); + assert.deepStrictEqual(chain.tip, fixture.tip); + assert.deepStrictEqual( + [...GOVERNANCE_ACTIONS].sort(), + chain.groupChangeActions, + 'chain must include every GroupChange action' + ); + assert.deepStrictEqual(fixture.groupChangeActions, chain.groupChangeActions); + const changeLabels = fixture.labels.filter((row) => row.appType === 'GroupChange'); + const changeActions = [...new Set(changeLabels.map((row) => row.action))].sort(); + assert.deepStrictEqual(changeActions, [...GOVERNANCE_ACTIONS].sort()); + assert.ok(changeLabels.some((row) => /member-add-dave/.test(row.label))); + assert.ok(changeLabels.some((row) => /eve-reader/.test(row.label))); + assert.ok(changeLabels.some((row) => /member-remove-dave/.test(row.label))); + assert.ok(changeLabels.some((row) => /members-set/.test(row.label))); + assert.ok(changeLabels.some((row) => /signers-set/.test(row.label))); + assert.ok(changeLabels.some((row) => row.label === 'change-update')); + assert.strictEqual(fixture.tip.proposalStatus, 'adopted'); + assert.ok(fixture.tip.members.length >= 2); + assert.ok(fixture.tip.signers.length >= 2); + assert.deepStrictEqual(fixture.tip.chatBodies, ['hello application', 'carol is in']); + }); + + it('replays the fixture collection to the bound tip digest', function () { + const fixture = loadFixture(); + const replayed = replayCollectionDocument(fixture.collection); + assert.strictEqual(replayed.skipped, 0, JSON.stringify(replayed.errors)); + assert.strictEqual(replayed.applied, fixture.collection.count); + assert.strictEqual(replayed.contractId, fixture.contractId); + assert.deepStrictEqual(replayed.tip, fixture.tip); + assert.strictEqual(replayed.tip.stateDigest, fixture.tip.stateDigest); + }); + + it('gossips the fixture chain across a local Peer mesh and replays to the same tip', async function () { + const fixture = loadFixture(); + const network = new NetworkHarness({ + peerCount: 3, + topology: 'star', + connectTimeoutMs: 20000 + }); + try { + await network.start(); + const collectors = network.peers.map(attachCollector); + const hub = network.peers[0]; + for (const row of fixture.collection.messages) { + hub._handleFabricMessage(Buffer.from(row.hex, 'hex'), { name: 'local-inject' }); + } + + const expected = fixture.collection.messages.map((m) => m.hash).sort(); + await waitUntil(() => { + return collectors.every((col) => { + const got = appHashes(col); + return got.length === expected.length && got.every((h, i) => h === expected[i]); + }); + }, 20000); + + for (const peer of network.peers) { + assert.ok( + peer.contracts && peer.contracts[fixture.contractId], + 'each node should register the published contract' + ); + } + + for (const col of collectors) { + const subset = createCollection(); + for (const row of col.messages) { + if (row.type === 'CONTRACT_PUBLISH' || row.type === 'CONTRACT_MESSAGE') { + ingest(subset, row.hex); + } + } + const replayed = replayCollectionDocument(toJSON(subset)); + assert.strictEqual(replayed.tip.stateDigest, fixture.tip.stateDigest); + assert.deepStrictEqual(replayed.tip.members, fixture.tip.members); + assert.deepStrictEqual(replayed.tip.chatBodies, fixture.tip.chatBodies); + assert.strictEqual(replayed.tip.proposalStatus, 'adopted'); + } + } finally { + await network.stop(); + } + }); +}); + +const Actor = require('../types/actor'); +const Key = require('../types/key'); +const { pubkeyXOnly } = require('../functions/groupChatSeal'); +const { + GOVERNANCE_ACTIONS, + signProposalVote +} = require('../functions/groupChangeGovernance'); +const { + createMemoryStore, + ingestContractPublishBuffer, + ingestMessageBuffer, + loadDoc, + tipFromDoc +} = require('../functions/contractMessageAccumulate'); + +const CREATED_AT = '2026-08-16T00:00:00.000Z'; + +/** BIP39 test vectors + extra 32-byte seeds — not live secrets. */ +const KEY_MATERIAL = Object.freeze({ + alice: { mnemonic: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' }, + bob: { mnemonic: 'legal winner thank year wave sausage worth useful legal winner thank yellow' }, + carol: { mnemonic: 'letter advice cage absurd amount doctor acoustic avoid letter advice cage above' }, + dave: { seedHex: '11'.repeat(32) }, + eve: { seedHex: '22'.repeat(32) } +}); + +/** @deprecated use KEY_MATERIAL */ +const MNEMONICS = Object.freeze({ + alice: KEY_MATERIAL.alice.mnemonic, + bob: KEY_MATERIAL.bob.mnemonic, + carol: KEY_MATERIAL.carol.mnemonic +}); + +const MESSAGE_TYPES = Object.freeze([ + 'GroupChat', + 'GroupChange', + 'GroupChangeProposal', + 'GroupChangeVote', + 'FederationContractInvite', + 'FederationContractInviteResponse', + 'GroupJournalRequest', + 'GroupJournalBatch' +]); + +function keyFromMaterial (spec) { + if (spec && spec.mnemonic) return new Key({ mnemonic: spec.mnemonic }); + if (spec && spec.seedHex) return new Key({ seed: Buffer.from(String(spec.seedHex), 'hex') }); + throw new Error('keyFromMaterial: mnemonic or seedHex required'); +} + +function keysFromMaterial (material = KEY_MATERIAL) { + const out = {}; + for (const name of Object.keys(material)) { + out[name] = keyFromMaterial(material[name]); + } + return out; +} + +function keysFromMnemonics (mnemonics = MNEMONICS) { + const material = Object.assign({}, KEY_MATERIAL); + if (mnemonics && mnemonics.alice) material.alice = { mnemonic: mnemonics.alice }; + if (mnemonics && mnemonics.bob) material.bob = { mnemonic: mnemonics.bob }; + if (mnemonics && mnemonics.carol) material.carol = { mnemonic: mnemonics.carol }; + return keysFromMaterial(material); +} + +function pub (key) { + return String(key.pubkey || '').toLowerCase(); +} + +function applicationDefinition (keys) { + const alice = pub(keys.alice); + const bob = pub(keys.bob); + return { + name: 'GenericApplication', + version: 1, + createdAt: CREATED_AT, + creator: alice, + groupId: 'app-generic-1', + parties: [alice, bob], + validators: [alice, bob], + proposedPolicy: { + validators: [alice, bob], + threshold: 2 + }, + messageTypes: MESSAGE_TYPES.slice(), + state: { label: 'fixture-generic-app' } + }; +} + +function signPublish (key, definition) { + return Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(definition)]).signWithKey(key); +} + +function signContractMessage (key, contractId, type, object) { + return Message.fromVector(['CONTRACT_MESSAGE', JSON.stringify({ + contract: contractId, + type, + object + })]).signWithKey(key); +} + +function changeActionOf (message) { + if (!message || message.type !== 'CONTRACT_MESSAGE') return null; + try { + const body = JSON.parse(message.body || message.data || '{}'); + if (body.type !== 'GroupChange' && body.type !== 'GroupChangeProposal') return null; + const obj = body.object && typeof body.object === 'object' ? body.object : {}; + return obj.action != null ? String(obj.action) : null; + } catch (_) { + return null; + } +} + +/** + * Ordered signed frames for the generic application story. + * @param {object} [opts] + * @param {object} [opts.mnemonics] + * @param {object} [opts.material] + * @returns {object} + */ +function buildGenericApplicationChain (opts = {}) { + const mnemonics = opts.mnemonics || MNEMONICS; + const material = opts.material || KEY_MATERIAL; + const keys = keysFromMaterial(material); + const definition = applicationDefinition(keys); + const contractId = new Actor(definition).id; + const alice = pub(keys.alice); + const bob = pub(keys.bob); + const carol = pub(keys.carol); + const dave = pub(keys.dave); + const eve = pub(keys.eve); + + const proposal = { + id: 'gprop-fixture-1', + contractId, + groupId: definition.groupId, + action: 'member.add', + member: carol, + role: 'signer', + patch: null, + proposedBy: pubkeyXOnly(alice) || alice, + createdAt: CREATED_AT, + threshold: 2 + }; + const vote = signProposalVote(keys.bob, proposal); + + const frames = [ + { + label: 'publish', + message: signPublish(keys.alice, definition) + }, + { + label: 'chat-alice', + message: signContractMessage(keys.alice, contractId, 'GroupChat', { + body: 'hello application', + author: alice, + ts: '2026-08-16T00:00:01.000Z' + }) + }, + { + label: 'change-seed-roster', + message: signContractMessage(keys.alice, contractId, 'GroupChange', { + action: 'members.set', + members: [alice, bob] + }) + }, + { + label: 'change-member-add-dave', + message: signContractMessage(keys.alice, contractId, 'GroupChange', { + action: 'member.add', + member: dave, + role: 'signer' + }) + }, + { + label: 'change-member-add-eve-reader', + message: signContractMessage(keys.alice, contractId, 'GroupChange', { + action: 'member.add', + member: eve, + role: 'reader' + }) + }, + { + label: 'change-member-remove-dave', + message: signContractMessage(keys.alice, contractId, 'GroupChange', { + action: 'member.remove', + member: dave + }) + }, + { + label: 'change-members-set', + message: signContractMessage(keys.alice, contractId, 'GroupChange', { + action: 'members.set', + members: [alice, bob, carol, eve] + }) + }, + { + label: 'change-signers-set', + message: signContractMessage(keys.alice, contractId, 'GroupChange', { + action: 'signers.set', + signers: [alice, bob] + }) + }, + { + label: 'change-update', + message: signContractMessage(keys.alice, contractId, 'GroupChange', { + action: 'update', + patch: { + pinnedChannels: ['group:app-generic-1'], + threshold: 2 + } + }) + }, + { + label: 'proposal-add-carol', + message: signContractMessage(keys.alice, contractId, 'GroupChangeProposal', proposal) + }, + { + label: 'vote-bob', + message: signContractMessage(keys.bob, contractId, 'GroupChangeVote', { + type: 'GroupChangeVote', + proposalId: proposal.id, + contractId, + groupId: definition.groupId, + voter: bob, + signature: vote.signature, + votedAt: '2026-08-16T00:00:02.000Z' + }) + }, + { + label: 'chat-bob', + message: signContractMessage(keys.bob, contractId, 'GroupChat', { + body: 'carol is in', + author: bob, + ts: '2026-08-16T00:00:03.000Z' + }) + }, + { + label: 'journal-request', + message: signContractMessage(keys.alice, contractId, 'GroupJournalRequest', { + fromClock: 1, + contractId + }) + } + ]; + + const collection = createCollection(); + for (const row of frames) { + const result = ingest(collection, row.message, { origin: row.label }); + if (!result.accepted) { + throw new Error(`ingest ${row.label}: ${result.error || 'rejected'}`); + } + row.hash = result.record.hash; + row.type = result.record.type; + row.appType = result.record.appType || null; + row.hex = result.record.hex; + row.action = changeActionOf(row.message); + } + + const folded = foldFrames(frames.map((row) => row.message)); + if (folded.contractId !== contractId) { + throw new Error(`fold contractId mismatch: ${folded.contractId} != ${contractId}`); + } + + return { + mnemonics, + material, + keys, + definition, + contractId, + frames, + collection, + document: toJSON(collection), + tip: snapshotTip(folded.tip), + groupChangeActions: groupChangeActionsOf(frames) + }; +} + +function groupChangeActionsOf (frames) { + const seen = new Set(); + for (const row of frames || []) { + if (row && row.appType === 'GroupChange' && row.action) seen.add(row.action); + } + return [...seen].sort(); +} + +function foldFrames (messages) { + const store = createMemoryStore(); + let contractId = null; + for (const message of messages) { + const buf = message.toBuffer(); + if (message.type === 'CONTRACT_PUBLISH' || message.type === 'P2P_CONTRACT_PUBLISH') { + const seeded = ingestContractPublishBuffer(store, buf); + if (!seeded.accepted && !seeded.duplicate) { + throw new Error(`publish fold: ${seeded.error || 'rejected'}`); + } + contractId = seeded.contractId; + continue; + } + const result = ingestMessageBuffer(store, contractId, buf, { origin: 'mesh' }); + if (!result.accepted && !result.duplicate) { + throw new Error(`message fold: ${result.error || 'rejected'}`); + } + } + const doc = loadDoc(store, contractId); + return { + store, + contractId, + tip: tipFromDoc(doc), + doc + }; +} + +/** + * Replay a FabricMessageCollection document into a fresh accumulate store. + * @param {object} document + * @returns {object} + */ +function replayCollectionDocument (document) { + const restored = fromJSON(document); + const store = createMemoryStore(); + let contractId = null; + const result = replay(restored, (ctx) => { + const buf = ctx.buffer; + if (ctx.message.type === 'CONTRACT_PUBLISH' || ctx.message.type === 'P2P_CONTRACT_PUBLISH') { + const seeded = ingestContractPublishBuffer(store, buf); + if (!seeded.accepted && !seeded.duplicate) { + throw new Error(`replay publish: ${seeded.error || 'rejected'}`); + } + contractId = seeded.contractId; + return; + } + const ingested = ingestMessageBuffer(store, contractId, buf, { origin: 'replay' }); + if (!ingested.accepted && !ingested.duplicate) { + throw new Error(`replay ${ctx.record && ctx.record.appType}: ${ingested.error || 'rejected'}`); + } + }); + const doc = contractId ? loadDoc(store, contractId) : null; + return { + applied: result.applied, + skipped: result.skipped, + errors: result.errors, + contractId, + tip: doc ? snapshotTip(tipFromDoc(doc)) : null + }; +} + +function snapshotTip (tip) { + if (!tip) return null; + const content = tip.content && typeof tip.content === 'object' ? tip.content : {}; + return { + contractId: tip.contractId, + clock: tip.clock, + stateDigest: tip.stateDigest, + members: Array.isArray(content.members) ? content.members.slice() : [], + signers: Array.isArray(content.signers) ? content.signers.slice() : [], + threshold: content.threshold != null ? content.threshold : null, + chatBodies: Array.isArray(content.messages) + ? content.messages.map((m) => m && m.body) + : [], + proposalStatus: Array.isArray(content.proposals) && content.proposals[0] + ? content.proposals[0].status + : null + }; +} + +function serializeFixture (chain) { + return { + type: 'FabricApplicationContractChainFixture', + v: 1, + createdAt: CREATED_AT, + contractId: chain.contractId, + keyMaterial: chain.material || KEY_MATERIAL, + mnemonics: chain.mnemonics, + definition: chain.definition, + collection: chain.document, + tip: chain.tip, + groupChangeActions: chain.groupChangeActions, + labels: chain.frames.map((row) => ({ + label: row.label, + hash: row.hash, + type: row.type, + appType: row.appType, + action: row.action || null + })) + }; +} + + +/** + * Playnet-oriented CONTRACT_PUBLISH → register → re-publish integration. + * + * Always runs offline (in-process Peer). Optional live dial when + * FABRIC_PLAYNET_PUBLISH=1 (connects FABRIC_PLAYNET_PEERS, default + * 127.0.0.1:7777) — skipped unless that env is set. Live mode also requires + * an explicit FABRIC_MNEMONIC (no public fallback). + * + * npm test -- --grep 'playnet contract publish' + * FABRIC_PLAYNET_PUBLISH=1 FABRIC_MNEMONIC='…' npm test -- --grep 'playnet contract publish live' + */ + + +describe('playnet contract publish + re-publish', function () { + it('publishes, registers Actor id, and re-publishes without allow-list hijack', function () { + const peer = new Peer({ listen: false, peersDb: null, networking: false }); + const owner = new Key(); + const attacker = new Key(); + const ownerPub = String(owner.pubkey).toLowerCase(); + const attackerPub = String(attacker.pubkey).toLowerCase(); + + const definition = { + name: 'PlaynetDemoContract', + version: 1, + messageTypes: ['GroupChat', 'GroupChange'], + parties: [ownerPub], + validators: [ownerPub], + threshold: 1, + state: { network: 'regtest', label: 'playnet-leader' } + }; + const contractId = new Actor(definition).id; + assert.match(contractId, /^[0-9a-f]{64}$/); + + let publishes = 0; + peer.on('contract:publish', () => { publishes++; }); + + const wire = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(definition)]).signWithKey(owner); + peer._handleFabricMessage(wire.toBuffer(), { name: '127.0.0.1:18444' }); + + assert.ok(peer.contracts[contractId] || Object.keys(peer.contracts || {}).length >= 1); + const id = peer.contracts[contractId] ? contractId : Object.keys(peer.contracts)[0]; + assert.ok(peer._signerMayPatchContract(id, ownerPub)); + assert.strictEqual(peer._signerMayPatchContract(id, attackerPub), false); + + // Re-publish identical body (owner) — first-writer-wins / stable registration. + const again = peer._registerContract(definition, ownerPub); + assert.strictEqual(again, true); + assert.ok(peer._signerMayPatchContract(id, ownerPub)); + + // Attacker re-signs identical body — must not gain patch rights. + assert.strictEqual(peer._registerContract(definition, attackerPub), true); + assert.strictEqual( + peer._signerMayPatchContract(id, attackerPub), + false, + 're-publish must not grant patch rights to a new wire signer' + ); + + assert.ok(publishes >= 1, 'CONTRACT_PUBLISH ingest must emit contract:publish'); + }); + + it('broadcast writes CONTRACT_PUBLISH frames to connected peers', function () { + const peer = new Peer({ listen: false, peersDb: null, networking: false }); + const writes = []; + peer.connections['127.0.0.1:7777'] = { + _writeFabric (buf) { writes.push(Buffer.isBuffer(buf) ? Buffer.from(buf) : Buffer.from(buf)); } + }; + const owner = new Key(); + const definition = { + name: 'PlaynetWriteAssert', + version: 1, + parties: [String(owner.pubkey).toLowerCase()], + state: { network: 'regtest' } + }; + const msg = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(definition)]).signWithKey(owner); + const sent = peer.broadcast(msg.toBuffer()); + assert.ok(sent > 0, `expected CONTRACT_PUBLISH fan-out > 0, got ${sent}`); + assert.strictEqual(writes.length, 1); + const parsed = Message.fromBuffer(writes[0]); + assert.strictEqual(parsed.type, 'CONTRACT_PUBLISH'); + }); + + it('deterministic Actor id is stable across JSON clone (re-publish body)', function () { + const owner = new Key(); + const ownerPub = String(owner.pubkey).toLowerCase(); + const definition = { + name: 'PlaynetDemoContract', + version: 1, + messageTypes: ['GroupChat'], + parties: [ownerPub], + state: {} + }; + const a = new Actor(definition).id; + const b = new Actor(JSON.parse(JSON.stringify(definition))).id; + assert.strictEqual(a, b); + }); +}); + +describe('playnet contract publish live', function () { + const runLive = process.env.FABRIC_PLAYNET_PUBLISH === '1' || process.env.FABRIC_PLAYNET_PUBLISH === 'true'; + const mnemonic = String(process.env.FABRIC_MNEMONIC || '').trim(); + + (runLive && mnemonic ? it : it.skip)('dials configured peers and emits CONTRACT_PUBLISH', async function () { + this.timeout(60000); + const peers = String(process.env.FABRIC_PLAYNET_PEERS || '127.0.0.1:7777') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + const peer = new Peer({ + listen: false, + networking: true, + peers, + key: { mnemonic }, + peersDb: null + }); + peer.on('error', () => {}); + try { + await peer.start(); + + const deadline = Date.now() + Number(process.env.FABRIC_PLAYNET_WAIT_MS || 20000); + while (Date.now() < deadline && Object.keys(peer.connections || {}).length === 0) { + await new Promise((r) => setTimeout(r, 250)); + } + const conns = Object.keys(peer.connections || {}); + assert.ok(conns.length > 0, `expected at least one connection to ${peers.join(',')}`); + + const ownerPub = String(peer.key.pubkey).toLowerCase(); + const definition = { + name: 'PlaynetLivePublish', + version: 1, + parties: [ownerPub], + state: { network: process.env.FABRIC_FLUSH_NETWORK || 'regtest' } + }; + const msg = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(definition)]).signWithKey(peer.key); + const sent1 = peer.broadcast(msg.toBuffer()); + assert.ok(sent1 > 0, `expected CONTRACT_PUBLISH fan-out > 0, got ${sent1}`); + + // Brief observe, then re-publish once. + await new Promise((r) => setTimeout(r, 1000)); + const msg2 = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(definition)]).signWithKey(peer.key); + const sent2 = peer.broadcast(msg2.toBuffer()); + assert.ok(sent2 > 0, `expected second CONTRACT_PUBLISH fan-out > 0, got ${sent2}`); + await new Promise((r) => setTimeout(r, 1000)); + } finally { + await peer.stop(); + } + }); +}); diff --git a/tests/fabric.identity.js b/tests/fabric.identity.js index f67a0ad99..b7e73d2c3 100644 --- a/tests/fabric.identity.js +++ b/tests/fabric.identity.js @@ -5,7 +5,7 @@ const assert = require('assert'); const bip39 = require('../functions/bip39'); const BIP32 = require('../functions/bip32').default; const ecc = require('../types/ecc'); -const { FABRIC_KEY_DERIVATION_PATH, bitcoinReceiveDerivationPath, fabricIdentityDerivationPath, fabricCoinTypeForNetwork, FABRIC_COIN_TYPE_MAINNET, FABRIC_COIN_TYPE_TESTNET } = require('../constants'); +const { FABRIC_KEY_DERIVATION_PATH, bitcoinReceiveDerivationPath, fabricIdentityDerivationPath, fabricIdentityAccountPath, fabricCoinTypeForNetwork, resolveFabricIdentityCoinType, FABRIC_COIN_TYPE_MAINNET, FABRIC_COIN_TYPE_TESTNET } = require('../constants'); let nobleSecp256k1 = null; try { nobleSecp256k1 = require('@noble/curves/secp256k1.js'); @@ -68,8 +68,18 @@ describe('@fabric/core/types/identity', function () { assert.strictEqual(fabricCoinTypeForNetwork('testnet'), FABRIC_COIN_TYPE_TESTNET); assert.strictEqual(fabricCoinTypeForNetwork('signet'), FABRIC_COIN_TYPE_TESTNET); assert.strictEqual(fabricCoinTypeForNetwork('mainnet'), FABRIC_COIN_TYPE_MAINNET); + assert.strictEqual(resolveFabricIdentityCoinType('regtest'), FABRIC_COIN_TYPE_TESTNET); + assert.strictEqual(resolveFabricIdentityCoinType('mainnet'), FABRIC_COIN_TYPE_MAINNET); + assert.strictEqual(resolveFabricIdentityCoinType(7777), FABRIC_COIN_TYPE_MAINNET); + assert.strictEqual(resolveFabricIdentityCoinType(), FABRIC_COIN_TYPE_TESTNET); assert.strictEqual(fabricIdentityDerivationPath(0, 0), FABRIC_KEY_DERIVATION_PATH); assert.strictEqual(fabricIdentityDerivationPath(0, 0, 'mainnet'), "m/44'/7777'/0'/0/0"); + assert.strictEqual(fabricIdentityAccountPath(0), "m/44'/7778'/0'"); + assert.strictEqual(fabricIdentityAccountPath(0, 'mainnet'), "m/44'/7777'/0'"); + assert.strictEqual( + fabricIdentityDerivationPath(2, 3, 'regtest'), + fabricIdentityAccountPath(2, 'regtest') + '/0/3' + ); const reg = new Identity({ seed: SAMPLE.seed, network: 'regtest' }); const main = new Identity({ seed: SAMPLE.seed, network: 'mainnet' }); @@ -109,15 +119,324 @@ describe('@fabric/core/types/identity', function () { const root = new BIP32(ecc).fromSeed(seed); const fabricNode = root.derivePath(FABRIC_KEY_DERIVATION_PATH); const msgHash = crypto.createHash('sha256').update(Buffer.from(message)).digest(); - const expectedSig = nobleSchnorr.sign(msgHash, fabricNode.privateKey); + const expectedSig = nobleSchnorr.sign(msgHash, fabricNode.privateKey, Buffer.alloc(32)); const xOnlyPubkey = Buffer.from( secp256k1.getPublicKey(fabricNode.privateKey, true) ).slice(1); // drop prefix for x-only const expectedVerified = nobleSchnorr.verify(expectedSig, msgHash, xOnlyPubkey); + const actualBytes = Buffer.isBuffer(actualSignature) + ? actualSignature + : Buffer.from(actualSignature); assert.ok(actualSignature); assert.equal(actualVerified, true); assert.equal(actualVerified, expectedVerified); + assert.ok(actualBytes.equals(Buffer.from(expectedSig)), + 'Identity.sign must match noble BIP340 with zero auxRand'); }); }); }); + +const { + IdentityLock, + clampLockTimeoutMinutes, + lockTimeoutMinutesToMs, + DEFAULT_LOCK_TIMEOUT_MINUTES, + MAX_LOCK_TIMEOUT_MINUTES +} = require('../functions/identityLock'); + +function fakeClock () { + let now = 1_000_000; + const timers = new Map(); + let nextId = 1; + return { + now: () => now, + advance (ms) { + now += ms; + for (const [id, t] of [...timers.entries()]) { + if (t.fireAt <= now) { + timers.delete(id); + t.fn(); + } + } + }, + setTimeout (fn, ms) { + const id = nextId++; + timers.set(id, { fn, fireAt: now + ms }); + return id; + }, + clearTimeout (id) { + timers.delete(id); + } + }; +} + +describe('@fabric/core/functions/identityLock', function () { + it('clamps timeout minutes (0 disables)', function () { + assert.strictEqual(clampLockTimeoutMinutes(0), 0); + assert.strictEqual(clampLockTimeoutMinutes(-3), DEFAULT_LOCK_TIMEOUT_MINUTES); + assert.strictEqual(clampLockTimeoutMinutes(99999), MAX_LOCK_TIMEOUT_MINUTES); + assert.strictEqual(lockTimeoutMinutesToMs(0), 0); + assert.strictEqual(lockTimeoutMinutesToMs(1), 60 * 1000); + }); + + it('holds a payload only while unlocked', function () { + const lock = new IdentityLock({ timeoutMinutes: 0 }); + assert.strictEqual(lock.locked, true); + lock.unlock({ xprv: 'secret' }); + assert.strictEqual(lock.locked, false); + assert.deepStrictEqual(lock.peek(), { xprv: 'secret' }); + lock.lock(); + assert.strictEqual(lock.locked, true); + assert.strictEqual(lock.peek(), null); + }); + + it('auto-locks after idle timeout and resets on touch', function () { + const clock = fakeClock(); + const lock = new IdentityLock({ + timeoutMs: 50, + now: clock.now, + setTimeout: clock.setTimeout, + clearTimeout: clock.clearTimeout + }); + const events = []; + lock.on('lock', () => events.push('lock')); + lock.on('unlock', () => events.push('unlock')); + lock.on('touch', () => events.push('touch')); + + lock.unlock({ ok: true }); + clock.advance(40); + assert.strictEqual(lock.locked, false); + lock.touch(); + clock.advance(40); + assert.strictEqual(lock.locked, false, 'touch should reset the idle timer'); + clock.advance(20); + assert.strictEqual(lock.locked, true); + assert.deepStrictEqual(events, ['unlock', 'touch', 'lock']); + }); + + it('setTimeoutMinutes(0) disables auto-lock', function () { + const clock = fakeClock(); + const lock = new IdentityLock({ + timeoutMs: 10, + now: clock.now, + setTimeout: clock.setTimeout, + clearTimeout: clock.clearTimeout + }); + lock.unlock({ ok: true }); + lock.setTimeoutMinutes(0); + clock.advance(1000); + assert.strictEqual(lock.locked, false); + assert.strictEqual(lock.timeoutMinutes, 0); + }); + + it('unref()s the idle timer so Node can exit', function () { + const lock = new IdentityLock({ timeoutMinutes: 30 }); + lock.unlock({ ok: true }); + assert.ok(lock._timer); + assert.strictEqual(typeof lock._timer.unref, 'function'); + assert.strictEqual(lock._timer.hasRef(), false); + lock.lock(); + }); +}); + +const Key = require('../types/key'); +const { + CROSS_SIGN_PREFIX, + REVOKE_PREFIX, + SIGN_TYPE, + REVOKE_TYPE, + buildCrossSignMessage, + buildRevokeMessage, + parseCrossSignMessage, + parseRevokeMessage, + buildCrossSignObject, + buildRevokeObject, + isCrossSignType +} = require('../functions/identityCrossSign'); +const { + fabricIdentityIdFromPubkeyHex, + buildFabricIdentitySignedPayload, + verifyIdentitySchnorr +} = require('../functions/fabricIdentitySchnorr'); +const { + signCrossSign, + verifyCrossSignObject +} = require('../functions/identityCrossSignVerify'); + +function nonce () { + return crypto.randomBytes(32).toString('hex'); +} + +describe('@fabric/core/functions/identityCrossSign', function () { + it('round-trips the canonical cross-sign string', function () { + const n = 'ab'.repeat(32); + const a = '02' + 'aa'.repeat(32); + const b = '03' + 'bb'.repeat(32); + const msg = buildCrossSignMessage(n, a, b); + assert.ok(msg.startsWith(CROSS_SIGN_PREFIX)); + const parsed = parseCrossSignMessage(msg); + assert.strictEqual(parsed.nonce, n); + assert.strictEqual(parsed.localPubkey, a.toLowerCase()); + assert.strictEqual(parsed.peerPubkey, b.toLowerCase()); + }); + + it('round-trips revoke messages and rejects a short nonce', function () { + const n = 'cd'.repeat(32); + const a = 'aa'.repeat(32); + const b = 'bb'.repeat(32); + const msg = buildRevokeMessage(n, a, b); + assert.ok(msg.startsWith(REVOKE_PREFIX)); + const parsed = parseRevokeMessage(msg); + assert.strictEqual(parsed.nonce, n); + assert.strictEqual(parsed.localPubkey, a); + assert.strictEqual(parsed.peerPubkey, b); + assert.strictEqual(buildCrossSignMessage('short', a, b), null); + assert.strictEqual(buildRevokeMessage(n, '', b), null); + assert.strictEqual(buildCrossSignMessage(n, 'aa', b), null); + assert.strictEqual(buildCrossSignMessage(n, a + ':' + b, b), null); + const with0x = buildRevokeMessage('0x' + n, '0x' + a, b); + assert.strictEqual(with0x, msg); + assert.strictEqual(isCrossSignType(SIGN_TYPE), true); + assert.strictEqual(isCrossSignType(REVOKE_TYPE), true); + assert.strictEqual(isCrossSignType('ChatMessage'), false); + }); + + it('object builders drop an invalid nonce instead of keeping the raw string', function () { + const a = '02' + 'aa'.repeat(32); + const b = '03' + 'bb'.repeat(32); + const sign = buildCrossSignObject({ localPubkey: a, peerPubkey: b, nonce: 'not-64-hex' }); + const revoke = buildRevokeObject({ localPubkey: a, peerPubkey: b, nonce: 'bad:colon' }); + assert.strictEqual(sign.nonce, ''); + assert.strictEqual(revoke.nonce, ''); + assert.strictEqual(sign.type, SIGN_TYPE); + assert.strictEqual(revoke.type, REVOKE_TYPE); + assert.strictEqual(buildCrossSignMessage(sign.nonce, a, b), null); + }); +}); + +describe('@fabric/core/functions/fabricIdentitySchnorr', function () { + it('signs and verifies with Identity.fabricKey', function () { + const ident = new Identity(new Key()); + const message = 'fabric:hub-login:1:test'; + const payload = buildFabricIdentitySignedPayload(ident, message); + assert.strictEqual(payload.pubkeyHex, ident.fabricKey.pubkey); + assert.strictEqual(payload.identity.id, ident.id); + const verified = verifyIdentitySchnorr( + message, + payload.signature, + payload.pubkeyHex, + payload.identity + ); + assert.strictEqual(verified.ok, true); + assert.strictEqual(verified.identityId, ident.id); + assert.strictEqual(fabricIdentityIdFromPubkeyHex(payload.pubkeyHex), ident.id); + }); + + it('signs Passport-style leaf private + fabric-path xpub', function () { + const ident = new Identity(new Key()); + const fabric = ident.fabricKey; + const priv = Buffer.isBuffer(fabric.private) + ? fabric.private.toString('hex') + : String(fabric.private); + const message = 'fabric:device-link:id-probe'; + const payload = buildFabricIdentitySignedPayload({ + privateKeyHex: priv, + xpub: fabric.xpub + }, message); + assert.strictEqual(payload.pubkeyHex, fabric.pubkey); + const verified = verifyIdentitySchnorr( + message, + payload.signature, + payload.pubkeyHex, + payload.identity + ); + assert.strictEqual(verified.ok, true); + }); + + it('rejects a tampered signature', function () { + const ident = new Identity(new Key()); + const message = 'fabric:hub-login:1:test'; + const payload = buildFabricIdentitySignedPayload(ident, message); + const flipped = payload.signature.replace(/[0-9a-f]$/, (c) => (c === '0' ? '1' : '0')); + const verified = verifyIdentitySchnorr( + message, + flipped, + payload.pubkeyHex, + payload.identity + ); + assert.strictEqual(verified.ok, false); + }); + + it('rejects truncated or non-hex pubkey bytes', function () { + assert.throws(() => fabricIdentityIdFromPubkeyHex('02aa' + 'zz'), /invalid pubkey|66 hex/i); + assert.throws(() => fabricIdentityIdFromPubkeyHex('02aa'), /66 hex/i); + assert.throws(() => fabricIdentityIdFromPubkeyHex('0'), /invalid pubkey|66 hex/i); + assert.throws(() => fabricIdentityIdFromPubkeyHex(''), /Missing pubkey/i); + }); +}); + +describe('@fabric/core/functions/identityCrossSignVerify', function () { + it('signs and verifies with Fabric Identity.fabricKey', function () { + const ident = new Identity(new Key()); + const peer = new Identity(new Key()); + const obj = signCrossSign(ident, { peerPubkey: peer.pubkey, nonce: nonce() }); + assert.strictEqual(obj.type, SIGN_TYPE); + const v = verifyCrossSignObject(obj, ident.fabricKey.pubkey); + assert.strictEqual(v.ok, true); + assert.strictEqual(v.kind, SIGN_TYPE); + }); + + it('rejects a tampered peer pubkey', function () { + const ident = new Identity(new Key()); + const peer = new Identity(new Key()); + const obj = signCrossSign(ident, { peerPubkey: peer.pubkey, nonce: nonce() }); + obj.peerPubkey = new Identity(new Key()).pubkey; + const v = verifyCrossSignObject(obj); + assert.strictEqual(v.ok, false); + }); + + it('signs revoke bodies', function () { + const ident = new Identity(new Key()); + const peer = new Identity(new Key()); + const obj = signCrossSign(ident, { peerPubkey: peer.pubkey, nonce: nonce() }, REVOKE_TYPE); + assert.strictEqual(obj.type, REVOKE_TYPE); + const v = verifyCrossSignObject(obj); + assert.strictEqual(v.ok, true); + assert.strictEqual(v.kind, REVOKE_TYPE); + }); + + it('signs a raw HD Key with fabricKey pubkey, not the master', function () { + const master = new Key(); + const ident = new Identity(master); + const peer = new Identity(new Key()); + const obj = signCrossSign(master, { peerPubkey: peer.pubkey, nonce: nonce() }); + assert.strictEqual(obj.localPubkey.toLowerCase(), ident.fabricKey.pubkey.toLowerCase()); + assert.notStrictEqual(obj.localPubkey.toLowerCase(), String(master.pubkey).toLowerCase()); + const v = verifyCrossSignObject(obj); + assert.strictEqual(v.ok, true); + }); + + it('signs Passport-style leaf private + fabric-path xpub', function () { + const ident = new Identity(new Key()); + const fabric = ident.fabricKey; + const priv = Buffer.isBuffer(fabric.private) + ? fabric.private.toString('hex') + : String(fabric.private); + const peer = new Identity(new Key()); + const obj = signCrossSign({ + privateKeyHex: priv, + xpub: fabric.xpub + }, { peerPubkey: peer.pubkey, nonce: nonce() }); + assert.strictEqual(obj.localPubkey.toLowerCase(), fabric.pubkey.toLowerCase()); + const v = verifyCrossSignObject(obj); + assert.strictEqual(v.ok, true); + }); + + it('rejects an unknown kind and missing fields', function () { + const ident = new Identity(new Key()); + const peer = new Identity(new Key()); + assert.throws(() => signCrossSign(ident, { peerPubkey: peer.pubkey, nonce: nonce() }, 'ChatMessage'), /unknown cross-sign type/i); + assert.throws(() => signCrossSign(ident, null, SIGN_TYPE), /fields required/i); + }); +}); diff --git a/tests/fabric.key.js b/tests/fabric.key.js index 74edcec14..4f393e8c7 100644 --- a/tests/fabric.key.js +++ b/tests/fabric.key.js @@ -4,13 +4,14 @@ const Key = require('../types/key'); const assert = require('assert'); const { networks: bitcoinNetworks } = require('bitcoinjs-lib'); const networks = Object.assign({ mainnet: bitcoinNetworks.bitcoin }, bitcoinNetworks); -const { secp256k1 } = require('@noble/curves/secp256k1.js'); +const { secp256k1, schnorr: nobleSchnorr } = require('@noble/curves/secp256k1.js'); const message = require('../assets/message'); const playnet = require('../settings/playnet'); const SAMPLE = { - seed: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + seed: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + private: '1111111111111111111111111111111111111111111111111111111111111111' }; describe('@fabric/core/types/key', function () { @@ -39,6 +40,30 @@ describe('@fabric/core/types/key', function () { assert.equal(key.public.encodeCompressed('hex'), '0223cffd5e94da3c8915c6b868f06d15183c1aeffad8ddf58fcb35a428e3158e71'); }); + it('can load from a raw BIP32 seed hex (FABRIC_SEED)', function () { + const bip39 = require('../functions/bip39'); + const fromMnemonic = new Key({ mnemonic: SAMPLE.seed }); + const hex = bip39.mnemonicToSeedSync(SAMPLE.seed).toString('hex'); + const fromHex = new Key({ seed: hex }); + assert.equal(fromHex.xprv, fromMnemonic.xprv); + assert.equal(fromHex.seed, hex); + assert.equal(fromHex.status, 'seeded'); + assert.equal(fromHex.public.encodeCompressed('hex'), fromMnemonic.public.encodeCompressed('hex')); + }); + + it('rejects a short byte seed instead of treating it as a mnemonic', function () { + assert.throws(() => new Key({ seed: Buffer.alloc(8) }), /Seed must be 16–64 bytes/); + assert.throws(() => new Key({ seed: new Uint8Array(8) }), /Seed must be 16–64 bytes/); + }); + + it('classifies an xprv passed as seed (legacy FABRIC_SEED)', function () { + const { FIXTURE_XPRV } = require('../constants'); + const fromXprv = new Key({ xprv: FIXTURE_XPRV }); + const fromSeed = new Key({ seed: FIXTURE_XPRV }); + assert.equal(fromSeed.xprv, fromXprv.xprv); + assert.equal(fromSeed.xprv, FIXTURE_XPRV); + }); + it('can load from a WIF', function () { const origin = new Key(); const wif = origin.toWIF(); @@ -145,6 +170,14 @@ describe('@fabric/core/types/key', function () { assert.equal(target.address, 'bc1q6t2wjeuavrd08fd8pu5ktf2sced5wf5chnd5qc'); }); + it('can generate BIP-49 p2sh-p2wpkh addresses without changing the default path', function () { + const key = new Key({ seed: playnet.key.seed }); + const target = key.deriveAddress(0, 0, 'p2sh-p2wpkh'); + assert.match(target.address, /^3[1-9A-HJ-NP-Za-km-z]{25,34}$/); + const alias = key.deriveAddress(0, 0, 'p2shp2wpkh'); + assert.strictEqual(alias.address, target.address); + }); + it('can derive valid child keys', function () { const key = new Key({ seed: playnet.key.seed }); const derivedKey = key.derive(); @@ -197,82 +230,64 @@ describe('@fabric/core/types/key', function () { }); it('rejects Schnorr signatures for different messages', function () { - const key = new Key({ private: '1111111111111111111111111111111111111111111111111111111111111111' }); + const key = new Key({ private: SAMPLE.private }); const message1 = 'test message 1'; const message2 = 'test message 2'; const signature1 = key.signSchnorr(message1); const signature2 = key.signSchnorr(message2); - assert.ok(signature1 !== signature2); - it('can create a new key from a seed', function () { - const key = new Key({ - seed: SAMPLE.seed - }); - assert.ok(key); - }); + assert.ok(!signature1.equals(signature2), 'distinct messages must not share signature bytes'); + assert.strictEqual(key.verifySchnorr(message1, signature1), true); + assert.strictEqual(key.verifySchnorr(message2, signature1), false); + assert.strictEqual(key.verifySchnorr(message1, signature2), false); + }); - it('can create a new key from a private key', function () { - const key = new Key({ - private: SAMPLE.private - }); - assert.ok(key); + it('can create a new key from a seed', function () { + const key = new Key({ + seed: SAMPLE.seed }); + assert.ok(key); + assert.ok(key.public); + }); - it('provides the correct public key for a known private key', function () { - const key = new Key({ - private: SAMPLE.private - }); - const actualPubkey = key.pubkey; - const expectedPubkey = Buffer.from( - secp256k1.getPublicKey(Buffer.from(SAMPLE.private, 'hex'), true) - ).toString('hex'); - - assert.equal(actualPubkey, expectedPubkey); + it('can create a new key from a private key', function () { + const key = new Key({ + private: SAMPLE.private }); + assert.ok(key); + }); - it('can sign and verify messages using Schnorr signatures', function () { - const key = new Key({ - private: SAMPLE.private - }); - const message = 'Hello, Fabric!'; - - // Sign the message - const signature = key.signSchnorr(message); - assert.ok(signature); - assert.ok(Buffer.isBuffer(signature)); - - // Verify the signature - const verified = key.verifySchnorr(message, signature); - assert.equal(verified, true); - - // Verify with a different message should fail - const wrongMessage = 'Hello, World!'; - const wrongVerified = key.verifySchnorr(wrongMessage, signature); - assert.equal(wrongVerified, false); + it('provides the correct public key for a known private key', function () { + const key = new Key({ + private: SAMPLE.private }); + const actualPubkey = key.pubkey; + const expectedPubkey = Buffer.from( + secp256k1.getPublicKey(Buffer.from(SAMPLE.private, 'hex'), true) + ).toString('hex'); - it('can verify Schnorr signatures from other keys', function () { - // Create two different keys - const key1 = new Key({ - private: SAMPLE.private - }); - const key2 = new Key({ - seed: SAMPLE.seed - }); - - const message = 'Hello, Fabric!'; - - // Sign with key1 - const signature = key1.signSchnorr(message); - assert.ok(signature); + assert.equal(actualPubkey, expectedPubkey); + }); - // Verify with key1 should succeed - const verified1 = key1.verifySchnorr(message, signature); - assert.equal(verified1, true); + it('Schnorr signatures match noble BIP340 with zero auxRand', function () { + const key = new Key({ private: SAMPLE.private }); + const message = 'Hello, Fabric!'; + const signature = key.signSchnorr(message); + const messageHash = require('crypto').createHash('sha256').update(Buffer.from(message)).digest(); + const expected = Buffer.from( + nobleSchnorr.sign(messageHash, Buffer.from(SAMPLE.private, 'hex'), Buffer.alloc(32)) + ); + assert.ok(signature.equals(expected)); + assert.strictEqual(key.verifySchnorr(message, signature), true); + assert.strictEqual(key.verifySchnorr('Hello, World!', signature), false); + }); - // Verify with key2 should fail - const verified2 = key2.verifySchnorr(message, signature); - assert.equal(verified2, false); - }); + it('rejects a Schnorr signature from a different key', function () { + const key1 = new Key({ private: SAMPLE.private }); + const key2 = new Key({ seed: SAMPLE.seed }); + const message = 'Hello, Fabric!'; + const signature = key1.signSchnorr(message); + assert.strictEqual(key1.verifySchnorr(message, signature), true); + assert.strictEqual(key2.verifySchnorr(message, signature), false); }); it('throws when signing without private key using Schnorr', function () { @@ -319,3 +334,535 @@ describe('@fabric/core/types/key', function () { }); }); }); + +const fs = require('fs'); +const path = require('path'); +const { + InvalidContributionError, + ValueError, + musig2IsNOfN, + individualPk, + keySort, + keyAgg, + getXonlyPk, + keyAggAndTweak, + nonceGenInternal, + nonceAgg, + sessionContext, + sign, + partialSigVerify, + partialSigVerifyInternal, + partialSigAgg, + deterministicSign, + aggregateXonly, + verifyAggregatedSchnorr +} = require('../functions/musig2'); + +const VEC = JSON.parse(fs.readFileSync(path.join(__dirname, 'vectors', 'bip327.json'), 'utf8')); + +function load (name) { + return VEC[name]; +} + +function hx (hex) { + return Buffer.from(String(hex), 'hex'); +} + +function pick (arr, indices) { + return indices.map((i) => arr[i]); +} + +function expectError (fn, spec) { + let err; + try { + fn(); + } catch (e) { + err = e; + } + assert.ok(err, 'expected exception: ' + JSON.stringify(spec)); + if (spec.type === 'invalid_contribution') { + assert.ok(err instanceof InvalidContributionError, err.message); + if (Object.prototype.hasOwnProperty.call(spec, 'signer')) { + assert.strictEqual(err.signer, spec.signer); + } + if (spec.contrib) assert.strictEqual(err.contrib, spec.contrib); + } else if (spec.type === 'value') { + assert.ok(err instanceof ValueError, err.name + ': ' + err.message); + assert.strictEqual(err.message, spec.message); + } else { + throw new Error('unknown error type ' + spec.type); + } +} + +describe('@fabric/core/functions/musig2 BIP-327', function () { + it('treats MuSig2 as n-of-n only', function () { + assert.strictEqual(musig2IsNOfN(2, 2), true); + assert.strictEqual(musig2IsNOfN(2, 3), false); + assert.strictEqual(musig2IsNOfN(1, 1), false); + }); + + it('matches key_sort_vectors.json', function () { + const data = load('key_sort_vectors.json'); + const sorted = keySort(data.pubkeys.map(hx)); + assert.deepStrictEqual( + sorted.map((b) => b.toString('hex').toUpperCase()), + data.sorted_pubkeys.map((s) => s.toUpperCase()) + ); + }); + + it('matches key_agg_vectors.json', function () { + const data = load('key_agg_vectors.json'); + const X = data.pubkeys.map(hx); + const T = data.tweaks.map(hx); + for (const tc of data.valid_test_cases) { + const pubkeys = pick(X, tc.key_indices); + const got = getXonlyPk(keyAgg(pubkeys)); + assert.strictEqual(got.toString('hex').toUpperCase(), tc.expected.toUpperCase()); + } + for (const tc of data.error_test_cases) { + expectError(() => { + keyAggAndTweak(pick(X, tc.key_indices), pick(T, tc.tweak_indices), tc.is_xonly); + }, tc.error); + } + }); + + it('matches nonce_gen_vectors.json', function () { + const data = load('nonce_gen_vectors.json'); + for (const tc of data.test_cases) { + const maybe = (key) => (tc[key] == null ? null : hx(tc[key])); + const { secnonce, pubnonce } = nonceGenInternal( + hx(tc.rand_), + maybe('sk'), + hx(tc.pk), + maybe('aggpk'), + maybe('msg'), + maybe('extra_in') + ); + assert.strictEqual(secnonce.toString('hex').toUpperCase(), tc.expected_secnonce.toUpperCase()); + assert.strictEqual(pubnonce.toString('hex').toUpperCase(), tc.expected_pubnonce.toUpperCase()); + } + }); + + it('matches nonce_agg_vectors.json', function () { + const data = load('nonce_agg_vectors.json'); + const pnonce = data.pnonces.map(hx); + for (const tc of data.valid_test_cases) { + const got = nonceAgg(pick(pnonce, tc.pnonce_indices)); + assert.strictEqual(got.toString('hex').toUpperCase(), tc.expected.toUpperCase()); + } + for (const tc of data.error_test_cases) { + expectError(() => nonceAgg(pick(pnonce, tc.pnonce_indices)), tc.error); + } + }); + + it('matches sign_verify_vectors.json', function () { + const data = load('sign_verify_vectors.json'); + const sk = hx(data.sk); + const X = data.pubkeys.map(hx); + assert.deepStrictEqual(individualPk(sk), X[0]); + const secnonces = data.secnonces.map(hx); + const pnonce = data.pnonces.map(hx); + const aggnonces = data.aggnonces.map(hx); + const msgs = data.msgs.map(hx); + + for (const tc of data.valid_test_cases) { + const pubkeys = pick(X, tc.key_indices); + const pubnonces = pick(pnonce, tc.nonce_indices); + const aggnonce = aggnonces[tc.aggnonce_index]; + assert.deepStrictEqual(nonceAgg(pubnonces), aggnonce); + const msg = msgs[tc.msg_index]; + const sessionCtx = sessionContext(aggnonce, pubkeys, [], [], msg); + const secnonceTmp = Buffer.from(secnonces[0]); + const psig = sign(secnonceTmp, sk, sessionCtx); + assert.strictEqual(psig.toString('hex').toUpperCase(), tc.expected.toUpperCase()); + assert.ok(partialSigVerify(psig, pubnonces, pubkeys, [], [], msg, tc.signer_index)); + assert.ok(secnonceTmp.subarray(0, 64).equals(Buffer.alloc(64))); + } + + for (const tc of data.sign_error_test_cases) { + const pubkeys = pick(X, tc.key_indices); + const aggnonce = aggnonces[tc.aggnonce_index]; + const msg = msgs[tc.msg_index]; + const sessionCtx = sessionContext(aggnonce, pubkeys, [], [], msg); + const secnonce = Buffer.from(secnonces[tc.secnonce_index]); + expectError(() => sign(secnonce, sk, sessionCtx), tc.error); + } + + for (const tc of data.verify_fail_test_cases) { + const pubkeys = pick(X, tc.key_indices); + const pubnonces = pick(pnonce, tc.nonce_indices); + const msg = msgs[tc.msg_index]; + assert.strictEqual( + partialSigVerify(hx(tc.sig), pubnonces, pubkeys, [], [], msg, tc.signer_index), + false + ); + } + + for (const tc of data.verify_error_test_cases) { + const pubkeys = pick(X, tc.key_indices); + const pubnonces = pick(pnonce, tc.nonce_indices); + const msg = msgs[tc.msg_index]; + expectError(() => { + partialSigVerify(hx(tc.sig), pubnonces, pubkeys, [], [], msg, tc.signer_index); + }, tc.error); + } + }); + + it('matches tweak_vectors.json', function () { + const data = load('tweak_vectors.json'); + const sk = hx(data.sk); + const X = data.pubkeys.map(hx); + const pnonce = data.pnonces.map(hx); + const aggnonce = hx(data.aggnonce); + const tweaks = data.tweaks.map(hx); + const msg = hx(data.msg); + const secnonce = hx(data.secnonce); + + for (const tc of data.valid_test_cases) { + const pubkeys = pick(X, tc.key_indices); + const pubnonces = pick(pnonce, tc.nonce_indices); + const tw = pick(tweaks, tc.tweak_indices); + const sessionCtx = sessionContext(aggnonce, pubkeys, tw, tc.is_xonly, msg); + const secnonceTmp = Buffer.from(secnonce); + const psig = sign(secnonceTmp, sk, sessionCtx); + assert.strictEqual(psig.toString('hex').toUpperCase(), tc.expected.toUpperCase()); + assert.ok(partialSigVerify(psig, pubnonces, pubkeys, tw, tc.is_xonly, msg, tc.signer_index)); + } + + for (const tc of data.error_test_cases) { + const pubkeys = pick(X, tc.key_indices); + const tw = pick(tweaks, tc.tweak_indices); + const sessionCtx = sessionContext(aggnonce, pubkeys, tw, tc.is_xonly, msg); + expectError(() => sign(Buffer.from(secnonce), sk, sessionCtx), tc.error); + } + }); + + it('matches det_sign_vectors.json', function () { + const data = load('det_sign_vectors.json'); + const sk = hx(data.sk); + const X = data.pubkeys.map(hx); + const msgs = data.msgs.map(hx); + for (const tc of data.valid_test_cases) { + const pubkeys = pick(X, tc.key_indices); + const tweaks = (tc.tweaks || []).map(hx); + const msg = msgs[tc.msg_index]; + const rand = tc.rand != null ? hx(tc.rand) : null; + const { pubnonce, psig } = deterministicSign( + sk, + hx(tc.aggothernonce), + pubkeys, + tweaks, + tc.is_xonly, + msg, + rand + ); + assert.strictEqual(pubnonce.toString('hex').toUpperCase(), tc.expected[0].toUpperCase()); + assert.strictEqual(psig.toString('hex').toUpperCase(), tc.expected[1].toUpperCase()); + const aggnonce = nonceAgg([hx(tc.aggothernonce), pubnonce]); + const sessionCtx = sessionContext(aggnonce, pubkeys, tweaks, tc.is_xonly, msg); + assert.ok(partialSigVerifyInternal(psig, pubnonce, pubkeys[tc.signer_index], sessionCtx)); + } + for (const tc of data.error_test_cases) { + const pubkeys = pick(X, tc.key_indices); + const tweaks = (tc.tweaks || []).map(hx); + const msg = msgs[tc.msg_index]; + const rand = tc.rand != null ? hx(tc.rand) : null; + expectError(() => { + deterministicSign(sk, hx(tc.aggothernonce), pubkeys, tweaks, tc.is_xonly, msg, rand); + }, tc.error); + } + }); + + it('matches sig_agg_vectors.json', function () { + const data = load('sig_agg_vectors.json'); + const X = data.pubkeys.map(hx); + const pnonce = data.pnonces.map(hx); + const tweaks = data.tweaks.map(hx); + const psigs = data.psigs.map(hx); + const msg = hx(data.msg); + for (const tc of data.valid_test_cases) { + const pubkeys = pick(X, tc.key_indices); + const pubnonces = pick(pnonce, tc.nonce_indices); + const tw = pick(tweaks, tc.tweak_indices || []); + const aggnonce = hx(tc.aggnonce); + assert.deepStrictEqual(nonceAgg(pubnonces), aggnonce); + const sessionCtx = sessionContext(aggnonce, pubkeys, tw, tc.is_xonly || [], msg); + const sig = partialSigAgg(pick(psigs, tc.psig_indices), sessionCtx); + assert.strictEqual(sig.toString('hex').toUpperCase(), tc.expected.toUpperCase()); + } + for (const tc of data.error_test_cases || []) { + const pubkeys = pick(X, tc.key_indices); + const tw = pick(tweaks, tc.tweak_indices || []); + const aggnonce = hx(tc.aggnonce); + const sessionCtx = sessionContext(aggnonce, pubkeys, tw, tc.is_xonly || [], msg); + expectError(() => partialSigAgg(pick(psigs, tc.psig_indices), sessionCtx), tc.error); + } + }); + + it('rejects a caller-supplied aggregate that does not match keyAgg', function () { + const data = load('key_agg_vectors.json'); + const keys = pick(data.pubkeys.map(hx), data.valid_test_cases[0].key_indices); + const xonly = aggregateXonly(keys); + assert.ok(!verifyAggregatedSchnorr( + Buffer.alloc(32), + Buffer.alloc(64), + keys, + Buffer.concat([Buffer.from([0x02]), Buffer.alloc(32, 1)]) + )); + assert.ok(xonly.length === 32); + }); + + it('rejects non-hex session message strings instead of truncating', function () { + assert.throws( + () => sessionContext(Buffer.alloc(66), [Buffer.alloc(33)], [], [], 'zzzzzz'), + /expected hex/ + ); + }); + + it('zeros secnonce before getSessionValues so a later throw cannot reuse it', function () { + const sk = skOf(new Key()); + const pk = individualPk(sk); + const { secnonce } = nonceGenInternal( + Buffer.alloc(32, 7), + sk, + pk, + null, + Buffer.alloc(32), + null + ); + assert.ok(secnonce.some((byte) => byte !== 0)); + const ctx = { + aggnonce: Buffer.alloc(66, 2), + pubkeys: [], + tweaks: [], + isXonly: [], + msg: Buffer.alloc(32) + }; + assert.throws(() => sign(secnonce, sk, ctx)); + assert.ok(secnonce.every((byte) => byte === 0)); + }); +}); + +const Message = require('../types/message'); +const { + packPubkeys, + unpackPubkeys, + createLocalSession, + createFromStart, + recordPubnonce, + allPubnoncesPresent, + computeAggNonce, + setAggNonce, + createPartial, + recordPartial, + allPartialsPresent, + aggregatePartials, + verifyFinalSignature, + wipeSession, + xOnlyHex, + startFields +} = require('../functions/musig2Session'); + +function skOf (key) { + return Buffer.isBuffer(key.private) ? key.private : Buffer.from(String(key.private), 'hex'); +} + +function runRound (keys, msg) { + const sks = keys.map(skOf); + const pks = sks.map((sk) => individualPk(sk)); + const initiator = createLocalSession({ + msg, + pubkeys: pks, + sk: sks[0], + purpose: 'test' + }); + const sessions = [initiator]; + const author = xOnlyHex(pks[0]); + for (let i = 1; i < keys.length; i++) { + const made = createFromStart(startFields(initiator), { + sk: sks[i], + signerXOnly: author + }); + assert.strictEqual(made.ok, true, made.reason); + sessions.push(made.session); + } + for (let i = 0; i < sessions.length; i++) { + for (let j = 0; j < sessions.length; j++) { + if (i === j) continue; + const rec = recordPubnonce( + sessions[i], + xOnlyHex(pks[j]), + sessions[j].ownPubnonce + ); + assert.strictEqual(rec.ok, true, rec.reason); + } + assert.strictEqual(allPubnoncesPresent(sessions[i]), true); + } + const aggnonce = computeAggNonce(initiator); + assert.ok(aggnonce); + for (const session of sessions) { + const set = setAggNonce(session, aggnonce); + assert.strictEqual(set.ok, true, set.reason); + } + const psigs = []; + for (let i = 0; i < sessions.length; i++) { + const signed = createPartial(sessions[i], sks[i]); + assert.strictEqual(signed.ok, true, signed.reason); + psigs.push(signed.psig); + } + for (let i = 0; i < sessions.length; i++) { + for (let j = 0; j < sessions.length; j++) { + if (i === j) continue; + const rec = recordPartial(sessions[i], xOnlyHex(pks[j]), psigs[j]); + assert.strictEqual(rec.ok, true, rec.reason); + } + assert.strictEqual(allPartialsPresent(sessions[i]), true); + } + const agg = aggregatePartials(initiator); + assert.strictEqual(agg.ok, true, agg.reason); + for (let i = 1; i < sessions.length; i++) { + const v = verifyFinalSignature(sessions[i], agg.signature); + assert.strictEqual(v.ok, true, v.reason); + } + assert.strictEqual( + verifyAggregatedSchnorr(initiator.challenge, agg.signature, initiator.pubkeys), + true + ); + return { initiator, signature: agg.signature, pubkeys: initiator.pubkeys }; +} + +describe('@fabric/core/functions/musig2Session', function () { + it('packs and unpacks compressed pubkeys', function () { + const a = individualPk(skOf(new Key())); + const b = individualPk(skOf(new Key())); + const packed = packPubkeys([b, a]); + const unpacked = unpackPubkeys(packed); + assert.strictEqual(unpacked.length, 2); + assert.strictEqual(packed.length, 66); + const hex = (pk) => Buffer.from(pk).toString('hex'); + const got = unpacked.map(hex).sort(); + const want = [hex(a), hex(b)].sort(); + assert.deepStrictEqual(got, want); + }); + + it('completes a 2-of-2 session', function () { + runRound([new Key(), new Key()], Buffer.from('hello musig2')); + }); + + it('completes a 3-of-3 session', function () { + runRound([new Key(), new Key(), new Key()], Buffer.from('three party')); + }); + + it('rejects a START whose AMP signer is not a participant', function () { + const a = new Key(); + const b = new Key(); + const rogue = new Key(); + const session = createLocalSession({ + msg: Buffer.from('x'), + pubkeys: [individualPk(skOf(a)), individualPk(skOf(b))], + sk: skOf(a) + }); + const made = createFromStart(startFields(session), { + sk: skOf(b), + signerXOnly: xOnlyHex(individualPk(skOf(rogue))) + }); + assert.strictEqual(made.ok, false); + assert.strictEqual(made.reason, 'signer-not-participant'); + }); + + it('rejects a claimed aggnonce that does not recompute', function () { + const a = new Key(); + const b = new Key(); + const session = createLocalSession({ + msg: Buffer.from('x'), + pubkeys: [individualPk(skOf(a)), individualPk(skOf(b))], + sk: skOf(a) + }); + const other = createFromStart(startFields(session), { + sk: skOf(b), + signerXOnly: xOnlyHex(individualPk(skOf(a))) + }); + assert.ok(other.ok); + recordPubnonce(session, xOnlyHex(individualPk(skOf(b))), other.session.ownPubnonce); + const fake = Buffer.alloc(66, 2); + fake[0] = 0x02; + fake[33] = 0x02; + const set = setAggNonce(session, fake); + assert.strictEqual(set.ok, false); + assert.strictEqual(set.reason, 'aggnonce-mismatch'); + }); + + it('encodes P2P_MUSIG_START as a first-class AMP type', function () { + const a = new Key(); + const b = new Key(); + const session = createLocalSession({ + msg: Buffer.from('wire'), + pubkeys: [individualPk(skOf(a)), individualPk(skOf(b))], + sk: skOf(a) + }); + const msg = Message.fromFields('P2P_MUSIG_START', startFields(session)); + assert.strictEqual(msg.type, 'P2P_MUSIG_START'); + const decoded = Message.fromBuffer(msg.toBuffer()); + assert.strictEqual(decoded.type, 'P2P_MUSIG_START'); + const fields = decoded.toFields(); + assert.ok(fields); + assert.ok(Buffer.isBuffer(fields.sessionId)); + assert.strictEqual(fields.sessionId.equals(session.sessionId), true); + }); + + it('verifyFinalSignature returns ok:false without a session', function () { + assert.deepStrictEqual( + verifyFinalSignature(null, Buffer.alloc(64)), + { ok: false, reason: 'no-session' } + ); + }); + + it('does not wipe secnonce while aggregatePartials waits for more partials', function () { + const a = new Key(); + const b = new Key(); + const session = createLocalSession({ + msg: Buffer.from('incomplete'), + pubkeys: [individualPk(skOf(a)), individualPk(skOf(b))], + sk: skOf(a) + }); + assert.ok(Buffer.isBuffer(session.secnonce)); + const agg = aggregatePartials(session); + assert.strictEqual(agg.ok, false); + assert.strictEqual(agg.reason, 'incomplete-psigs'); + assert.ok(Buffer.isBuffer(session.secnonce)); + wipeSession(session); + assert.strictEqual(session.secnonce, null); + }); + + it('wipes session.secnonce when createPartial cannot complete sign', function () { + const a = new Key(); + const b = new Key(); + const session = createLocalSession({ + msg: Buffer.from('partial-fail'), + pubkeys: [individualPk(skOf(a)), individualPk(skOf(b))], + sk: skOf(a) + }); + const held = session.secnonce; + session.aggnonce = Buffer.alloc(5); + const signed = createPartial(session, skOf(a)); + assert.strictEqual(signed.ok, false); + assert.strictEqual(session.secnonce, null); + assert.ok(held.every((byte) => byte === 0)); + }); + + it('rejects an unparsed START body instead of falling back', function () { + const a = new Key(); + const b = new Key(); + const made = createFromStart({ + sessionId: 'not-32-bytes', + msg: 'x', + pubkeys: 'zzzz', + pubnonce: '00' + }, { + sk: skOf(b), + signerXOnly: xOnlyHex(individualPk(skOf(a))) + }); + assert.strictEqual(made.ok, false); + assert.strictEqual(made.reason, 'invalid-start'); + }); +}); diff --git a/tests/fabric.listenInterface.js b/tests/fabric.listenInterface.js deleted file mode 100644 index df87d0a3b..000000000 --- a/tests/fabric.listenInterface.js +++ /dev/null @@ -1,25 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { - resolveFabricPeerInterface -} = require('../functions/fabricListenInterface'); - -test('resolveFabricPeerInterface defaults to 0.0.0.0', () => { - assert.equal(resolveFabricPeerInterface({ env: {} }), '0.0.0.0'); -}); - -test('resolveFabricPeerInterface prefers FABRIC_INTERFACE', () => { - assert.equal(resolveFabricPeerInterface({ - env: { FABRIC_INTERFACE: '65.21.231.149', FABRIC_PEER_INTERFACE: '1.2.3.4' }, - interface: '9.9.9.9' - }), '65.21.231.149'); -}); - -test('resolveFabricPeerInterface falls back to settings.interface', () => { - assert.equal(resolveFabricPeerInterface({ - env: {}, - interface: '65.21.231.149' - }), '65.21.231.149'); -}); diff --git a/tests/fabric.machine.js b/tests/fabric.machine.js index 92159d25b..2df377e4d 100644 --- a/tests/fabric.machine.js +++ b/tests/fabric.machine.js @@ -58,24 +58,36 @@ describe('@fabric/core/types/machine', function () { it('can correctly sum two values', async function prove () { const machine = new Machine(false); - machine.define('SUM_TWO', function () { return 2; }); - machine.script.push('SUM_TWO'); + machine.define('PUSH_2', function () { return 2; }); + machine.define('PUSH_3', function () { return 3; }); + machine.define('SUM_TWO', function () { + const b = machine.stack.pop(); + const a = machine.stack.pop(); + return a + b; + }); + machine.script.push('PUSH_2', 'PUSH_3', 'SUM_TWO'); await machine.start(); await machine.compute(); await machine.stop(); - assert.ok(machine.stack.length >= 1); - assert.strictEqual(machine.stack[machine.stack.length - 1], 2); + assert.strictEqual(machine.stack[machine.stack.length - 1], 5); }); it('can correctly sum three values', async function prove () { const machine = new Machine(false); - machine.define('SUM_THREE', function () { return 4; }); - machine.script.push('SUM_THREE'); + machine.define('PUSH_1', function () { return 1; }); + machine.define('PUSH_2', function () { return 2; }); + machine.define('PUSH_3', function () { return 3; }); + machine.define('SUM_THREE', function () { + const c = machine.stack.pop(); + const b = machine.stack.pop(); + const a = machine.stack.pop(); + return a + b + c; + }); + machine.script.push('PUSH_1', 'PUSH_2', 'PUSH_3', 'SUM_THREE'); await machine.start(); await machine.compute(); await machine.stop(); - assert.ok(machine.stack.length >= 1); - assert.strictEqual(machine.stack[machine.stack.length - 1], 4); + assert.strictEqual(machine.stack[machine.stack.length - 1], 6); }); it('compileOpcodeContract accepts newline-delimited opcode bodies', function () { @@ -90,3 +102,324 @@ describe('@fabric/core/types/machine', function () { }); }); }); + +const { + normalizeOpcodeAllowList, + opcodesFromGenesis, + assertStepsAllowed +} = require('../functions/opcodeAllowList'); + +describe('opcodeAllowList', function () { + it('normalizes and dedupes', function () { + assert.deepStrictEqual( + normalizeOpcodeAllowList([' OP_TRUE ', 'OP_TRUE', '', 'OP_ADD']), + ['OP_TRUE', 'OP_ADD'] + ); + }); + + it('opcodesFromGenesis prefers primitives.opcodes', function () { + assert.deepStrictEqual( + opcodesFromGenesis({ + primitives: { opcodes: ['OP_A'] }, + opcodes: ['OP_B'] + }), + ['OP_A'] + ); + assert.deepStrictEqual(opcodesFromGenesis({ opcodes: ['OP_B'] }), ['OP_B']); + assert.deepStrictEqual(opcodesFromGenesis({}), []); + }); + + it('assertStepsAllowed fails closed', function () { + assert.strictEqual(assertStepsAllowed(['OP_TRUE'], ['OP_TRUE']).ok, true); + const bad = assertStepsAllowed(['OP_TRUE'], ['OP_TRUE', 'OP_EVIL']); + assert.strictEqual(bad.ok, false); + assert.deepStrictEqual(bad.unknown, ['OP_EVIL']); + }); +}); + +const Program = require('../types/program'); +const { + runExecutionProgram, + executionProgramFromHub +} = require('../functions/executionProgramRunner'); + +describe('executionProgramRunner / Machine fabric-execution', function () { + it('runs Push/Dup/Pop/ADD on Machine stack', function () { + const r = runExecutionProgram({ + version: 1, + steps: [ + { op: 'Push', value: 2 }, + { op: 'PUSH', value: 3 }, + { op: 'ADD' }, + { op: 'Dup' } + ] + }); + assert.strictEqual(r.ok, true, r.error); + assert.strictEqual(r.stack[0], 5); + assert.strictEqual(r.stack[1], 5); + assert.ok(r.programHash); + assert.ok(r.runCommitmentHex); + assert.ok(r.program); + }); + + it('content-addresses via Program.programHash', function () { + const program = { version: 1, steps: [{ op: 'Push', value: 1 }] }; + const a = executionProgramFromHub(program).programHash; + const b = executionProgramFromHub(program).programHash; + assert.strictEqual(a, b); + assert.strictEqual(runExecutionProgram(program).programHash, a); + }); + + it('Machine.runProgram delegates fabric-execution', async function () { + const machine = new Machine({ deterministic: true }); + const out = await machine.runProgram({ + language: 'fabric-execution', + source: { + version: 1, + steps: [{ op: 'Push', value: { hi: true } }] + } + }); + assert.strictEqual(out.ok, true, out.error); + assert.deepStrictEqual(out.tip, { hi: true }); + assert.ok(out.runCommitmentHex); + }); + + it('rejects Ping FabricOpcode when resolver marks it', function () { + const r = runExecutionProgram({ + steps: [{ op: 'FabricOpcode', fabricType: 'Ping' }] + }, { + resolveFabricEntry () { + throw new Error('Ping is a transport keepalive, not an Execution program opcode'); + } + }); + assert.strictEqual(r.ok, false); + assert.match(r.error, /Ping/); + }); + + it('enforces Machine / genesis opcode allow-list on FabricOpcode steps', function () { + const program = { + version: 1, + steps: [{ op: 'FabricOpcode', fabricType: 'ChatMessage' }] + }; + const denied = runExecutionProgram(program, { + allowedOpcodes: ['P2P_CHAT_MESSAGE'], + resolveFabricEntry () { + return { name: 'ChatMessage', opcodeDec: 0x67 }; + } + }); + assert.strictEqual(denied.ok, false); + assert.match(denied.error, /allow-list/i); + + const allowed = runExecutionProgram(program, { + allowedOpcodes: ['ChatMessage'], + resolveFabricEntry () { + return { name: 'ChatMessage', opcodeDec: 0x67 }; + } + }); + assert.strictEqual(allowed.ok, true, allowed.error); + + const fromGenesis = runExecutionProgram(program, { + genesis: { primitives: { opcodes: ['OtherOnly'] } }, + resolveFabricEntry () { + return { name: 'ChatMessage', opcodeDec: 0x67 }; + } + }); + assert.strictEqual(fromGenesis.ok, false); + assert.match(fromGenesis.error, /allow-list/i); + }); + + it('Program.compile fabric-execution requires steps', function () { + const bad = Program.from({ language: 'fabric-execution', source: {} }).compile(); + assert.strictEqual(bad.ok, false); + }); +}); +const crypto = require('crypto'); +const bitcoin = require('bitcoinjs-lib'); +const Key = require('../types/key'); +const Contract = require('../types/contract'); +const { + observePaymentToAddress, + applyPaymentObservationToTip +} = require('../functions/contractPaymentObserve'); +const { + buildExecutionRunOutput, + computeExecutionRunCommitmentHex, + computeFabricProgramRunFromExecution +} = require('../functions/executionRunBridge'); + +describe('executionRunBridge / FabricProgramRun forward digest', function () { + it('keeps legacy ExecutionRun digest stable', function () { + const result = { + ok: true, + stepsExecuted: 2, + trace: [{ pc: 0, op: 'Push' }], + stack: [1] + }; + const a = computeExecutionRunCommitmentHex('cid-1', result); + const b = computeExecutionRunCommitmentHex('cid-1', result); + assert.strictEqual(a, b); + assert.strictEqual(a.length, 64); + }); + + it('buildExecutionRunOutput prefers FabricProgramRun when programHash set', function () { + const programHash = crypto.createHash('sha256').update('prog').digest('hex'); + const result = { ok: true, stepsExecuted: 1, trace: [], stack: ['x'] }; + const out = buildExecutionRunOutput({ + contractId: 'cid-forward', + programHash, + result + }); + assert.strictEqual(out.programHash, programHash); + assert.ok(out.executionRunCommitmentHex); + assert.ok(out.fabricProgramRunCommitmentHex); + assert.notStrictEqual(out.executionRunCommitmentHex, out.fabricProgramRunCommitmentHex); + assert.strictEqual(out.runCommitmentHex, out.fabricProgramRunCommitmentHex); + assert.strictEqual(out.fabricProgramRun.kind, 'FabricProgramRun'); + assert.strictEqual(out.fabricProgramRun.runCommitmentHex, out.fabricProgramRunCommitmentHex); + assert.strictEqual( + computeFabricProgramRunFromExecution({ programHash, contractId: 'cid-forward', result }), + out.fabricProgramRunCommitmentHex + ); + }); + + it('falls back to ExecutionRun digest without programHash', function () { + const result = { ok: false, error: 'boom', trace: [], stack: [] }; + const out = buildExecutionRunOutput({ contractId: 'cid-legacy', result }); + assert.strictEqual(out.programHash, null); + assert.strictEqual(out.runCommitmentHex, out.executionRunCommitmentHex); + assert.strictEqual(out.fabricProgramRun, null); + }); +}); + +describe('contractPaymentObserve / Contract L1 payment', function () { + const keys = []; + before(function () { + for (let i = 0; i < 3; i++) keys.push(new Key()); + }); + + function fundTx (outputScript, valueSats) { + const fund = new bitcoin.Transaction(); + fund.version = 2; + fund.addInput(Buffer.alloc(32), 0); + fund.addOutput(outputScript, BigInt(valueSats)); + return fund; + } + + it('observes successful payment to contract address', function () { + const contract = new Contract({ + network: 'regtest', + validators: [keys[0].pubkey, keys[1].pubkey], + threshold: 1, + publisher: keys[0].pubkey + }); + const address = contract.toAddress('regtest'); + const built = contract.toTaprootContract({ network: 'regtest' }); + const tx = fundTx(built.output, 50000); + const obs = observePaymentToAddress({ + tx: tx.toHex(), + address, + network: 'regtest', + amountSats: 40000 + }); + assert.strictEqual(obs.ok, true); + assert.strictEqual(obs.code, 'PAID'); + assert.strictEqual(obs.matchedSats, 50000); + assert.ok(obs.txid); + }); + + it('fails when tx does not pay contract address', function () { + const other = bitcoin.payments.p2wpkh({ + pubkey: Buffer.from(keys[2].pubkey, 'hex'), + network: bitcoin.networks.regtest + }); + const tx = fundTx(other.output, 10000); + const contract = new Contract({ + network: 'regtest', + validators: [keys[0].pubkey], + threshold: 1, + publisher: keys[0].pubkey + }); + const obs = observePaymentToAddress({ + tx: tx.toHex(), + address: contract.toAddress('regtest'), + network: 'regtest' + }); + assert.strictEqual(obs.ok, false); + assert.strictEqual(obs.code, 'NO_MATCH'); + }); + + it('fails UNDERPAID when matched sats below amountSats', function () { + const contract = new Contract({ + network: 'regtest', + validators: [keys[0].pubkey], + threshold: 1, + publisher: keys[0].pubkey + }); + const built = contract.toTaprootContract({ network: 'regtest' }); + const tx = fundTx(built.output, 1000); + const obs = observePaymentToAddress({ + tx, + address: contract.toAddress('regtest'), + network: 'regtest', + amountSats: 5000 + }); + assert.strictEqual(obs.ok, false); + assert.strictEqual(obs.code, 'UNDERPAID'); + assert.strictEqual(obs.matchedSats, 1000); + }); + + it('fails on missing / invalid tx', function () { + assert.strictEqual(observePaymentToAddress({ address: 'bcrt1qxx' }).code, 'MISSING_TX'); + assert.strictEqual( + observePaymentToAddress({ tx: 'zz', address: 'bcrt1qxx' }).code, + 'INVALID_TX' + ); + }); + + it('Contract#_handleBitcoinTransaction applies balances on success', function () { + const contract = new Contract({ + network: 'regtest', + validators: [keys[0].pubkey], + threshold: 1, + publisher: keys[0].pubkey, + state: { balances: {}, payments: [] } + }); + const built = contract.toTaprootContract({ network: 'regtest' }); + const tx = fundTx(built.output, 25000); + const obs = contract._handleBitcoinTransaction(tx.toHex(), { + network: 'regtest', + amountSats: 10000 + }); + assert.strictEqual(obs.ok, true); + assert.strictEqual(obs.applied, true); + const addr = contract.toAddress('regtest'); + assert.strictEqual(contract._state.content.balances[addr].sats, 25000); + assert.strictEqual(contract._state.content.payments.length, 1); + }); + + it('Contract#_handleBitcoinTransaction does not apply on failure', function () { + const contract = new Contract({ + network: 'regtest', + validators: [keys[0].pubkey], + threshold: 1, + publisher: keys[0].pubkey, + state: { balances: {}, payments: [] } + }); + const other = bitcoin.payments.p2wpkh({ + pubkey: Buffer.from(keys[1].pubkey, 'hex'), + network: bitcoin.networks.regtest + }); + const tx = fundTx(other.output, 9000); + const obs = contract._handleBitcoinTransaction(tx.toHex(), { network: 'regtest' }); + assert.strictEqual(obs.ok, false); + assert.strictEqual(obs.code, 'NO_MATCH'); + assert.deepStrictEqual(contract._state.content.payments || [], []); + }); + + it('applyPaymentObservationToTip rejects failed observations', function () { + assert.throws( + () => applyPaymentObservationToTip({}, { ok: false, code: 'NO_MATCH' }), + /successful/ + ); + }); +}); diff --git a/tests/fabric.message.js b/tests/fabric.message.js index 6256d364d..4f716ecf4 100644 --- a/tests/fabric.message.js +++ b/tests/fabric.message.js @@ -18,13 +18,32 @@ const { P2P_STATE_CHANGE, P2P_TRANSACTION, P2P_CALL, - P2P_MESSAGE_RECEIPT + P2P_MESSAGE_RECEIPT, + P2P_GENERIC, + P2P_START_CHAIN, + P2P_SESSION_ACK, + LIGHTNING_WARNING, + LIGHTNING_INIT, + LIGHTNING_ERROR, + LIGHTNING_PING, + LIGHTNING_PONG, + LIGHTNING_OPEN_CHANNEL, + LIGHTNING_ACCEPT_CHANNEL, + LIGHTNING_UPDATE_ADD_HTLC } = require('../constants'); const Message = require('../types/message'); const Key = require('../types/key'); const Hash256 = require('../types/hash256'); const assert = require('assert'); +const fs = require('fs'); +const { + OUTPUT_PATH, + CATALOG_BEGIN, + CATALOG_END, + collectCatalog, + catalogBlock +} = Message.wireCatalog; // Create a key with a private key for signing const key = new Key({ @@ -178,6 +197,30 @@ describe('@fabric/core/types/message', function () { assert.strictEqual(body['@actor'], 'deadbeef'); }); + it('fromRaw copies a Uint8Array view without sibling ArrayBuffer bytes', function () { + const message = Message.fromVector(['Call', JSON.stringify(example.data)]); + message.signWithKey(key); + const raw = message.toBuffer(); + const pad = 24; + const padded = Buffer.concat([Buffer.alloc(pad, 0xaa), raw, Buffer.alloc(pad, 0xbb)]); + const view = new Uint8Array(padded.buffer, padded.byteOffset + pad, raw.length); + const restored = Message.fromRaw(view); + assert.ok(restored); + assert.strictEqual(restored.type, message.type); + assert.ok(restored.verifyWithKey(key)); + const hashOnWire = raw.subarray(80, 112).toString('hex'); + const hashAfter = Buffer.isBuffer(restored.raw.hash) + ? restored.raw.hash.toString('hex') + : restored.raw.hash; + assert.strictEqual(hashAfter, hashOnWire); + assert.ok(Buffer.from(restored.toBuffer()).equals(raw)); + + const dv = new DataView(padded.buffer, padded.byteOffset + pad, raw.length); + const fromView = Message.fromRaw(dv); + assert.strictEqual(fromView.type, message.type); + assert.ok(fromView.verifyWithKey(key)); + }); + it('fromBuffer keeps header hash bytes from wire (body integrity field)', function () { const message = Message.fromVector(['Call', JSON.stringify(example.data)]); message.signWithKey(key); @@ -385,3 +428,93 @@ describe('@fabric/core/types/message', function () { }); }); }); + +describe('@fabric/core AMP opcode catalog', function () { + it('every WIRE_TYPE_DECODE_ORDER opcode is unique', function () { + const seen = Object.create(null); + for (const pair of Message.WIRE_TYPE_DECODE_ORDER) { + const code = pair[0]; + const name = pair[1]; + if (typeof code !== 'number' || !Number.isFinite(code)) continue; + assert.strictEqual(seen[code], undefined, `duplicate opcode ${code}: ${seen[code]} and ${name}`); + seen[code] = name; + assert.strictEqual(Message.canonicalTypeName(code), name); + } + }); + + it('Fabric low numbers are not Lightning AMP opcodes', function () { + const fabric = [ + [P2P_IDENT_REQUEST, 'P2P_IDENT_REQUEST'], + [P2P_IDENT_RESPONSE, 'P2P_IDENT_RESPONSE'], + [P2P_PING, 'P2P_PING'], + [P2P_PONG, 'P2P_PONG'], + [P2P_INSTRUCTION, 'P2P_INSTRUCTION'], + [P2P_START_CHAIN, 'P2P_START_CHAIN'], + [P2P_GENERIC, 'P2P_GENERIC'], + [P2P_STATE_COMMITTMENT, 'P2P_STATE_COMMITTMENT'], + [P2P_SESSION_ACK, 'P2P_SESSION_ACK'] + ]; + for (const row of fabric) { + assert.strictEqual(Message.canonicalTypeName(row[0]), row[1]); + assert.ok(row[0] < 0x2000 || row[0] === P2P_SESSION_ACK); + } + assert.strictEqual(P2P_SESSION_ACK, 0x4200); + }); + + it('Lightning AMP types occupy 0x2000–0x2013 and round-trip by name', function () { + const rows = [ + ['LightningInit', LIGHTNING_INIT, 'LIGHTNING_INIT'], + ['LightningError', LIGHTNING_ERROR, 'LIGHTNING_ERROR'], + ['OpenChannel', LIGHTNING_OPEN_CHANNEL, 'LIGHTNING_OPEN_CHANNEL'], + ['AcceptChannel', LIGHTNING_ACCEPT_CHANNEL, 'LIGHTNING_ACCEPT_CHANNEL'], + ['UpdateAddHTLC', LIGHTNING_UPDATE_ADD_HTLC, 'LIGHTNING_UPDATE_ADD_HTLC'], + ['LightningWarning', LIGHTNING_WARNING, 'LIGHTNING_WARNING'], + ['LightningPing', LIGHTNING_PING, 'LIGHTNING_PING'], + ['LightningPong', LIGHTNING_PONG, 'LIGHTNING_PONG'] + ]; + for (const row of rows) { + assert.ok(row[1] >= 0x2000 && row[1] <= 0x2013, row[0]); + const msg = Message.fromVector([row[0], 'x']); + assert.strictEqual(msg.type, row[2]); + assert.strictEqual(Message.fromBuffer(msg.toBuffer()).type, row[2]); + assert.strictEqual(Message.canonicalTypeName(row[1]), row[2]); + } + }); + + it('PeerInstruction round-trips as P2P_INSTRUCTION, not OpenChannel', function () { + const msg = Message.fromVector(['PeerInstruction', 'x']); + assert.strictEqual(msg.type, 'P2P_INSTRUCTION'); + assert.strictEqual(Message.fromBuffer(msg.toBuffer()).type, 'P2P_INSTRUCTION'); + assert.notStrictEqual(P2P_INSTRUCTION, LIGHTNING_OPEN_CHANNEL); + const open = Message.fromVector(['OpenChannel', 'x']); + assert.strictEqual(open.type, 'LIGHTNING_OPEN_CHANNEL'); + }); +}); + +describe('MESSAGES.md wire catalog', function () { + it('matches live WIRE_TYPE_DECODE_ORDER', function () { + const rows = collectCatalog(); + assert.strictEqual(rows.length, Message.WIRE_TYPE_DECODE_ORDER.length); + for (let i = 0; i < rows.length; i++) { + const pair = Message.WIRE_TYPE_DECODE_ORDER[i]; + assert.strictEqual(rows[i].opcode, pair[0], rows[i].name); + assert.strictEqual(rows[i].name, pair[1]); + } + const seen = Object.create(null); + for (const row of rows) { + assert.strictEqual(seen[row.opcode], undefined, `duplicate opcode ${row.opcode}`); + seen[row.opcode] = row.name; + } + const ping = rows.find((row) => row.name === 'P2P_PING'); + assert.ok(ping && ping.aliases.indexOf('Ping') !== -1); + const instruction = rows.find((row) => row.name === 'P2P_INSTRUCTION'); + assert.ok(instruction && instruction.aliases.indexOf('PeerInstruction') !== -1); + const publish = rows.find((row) => row.name === 'CONTRACT_PUBLISH'); + assert.ok(publish && publish.aliases.indexOf('P2P_CONTRACT_PUBLISH') !== -1); + const actual = fs.readFileSync(OUTPUT_PATH, 'utf8'); + const start = actual.indexOf(CATALOG_BEGIN); + const stop = actual.indexOf(CATALOG_END); + assert.ok(start >= 0 && stop > start, 'MESSAGES.md catalog markers'); + assert.strictEqual(actual.slice(start, stop + CATALOG_END.length), catalogBlock(rows)); + }); +}); diff --git a/tests/fabric.oracle.js b/tests/fabric.oracle.js index c906b4413..67dbc4b59 100644 --- a/tests/fabric.oracle.js +++ b/tests/fabric.oracle.js @@ -12,25 +12,29 @@ describe('@fabric/core/types/oracle', function () { }); it('can use _SET', async function () { - let oracle = new Oracle(); + const oracle = new Oracle({ persistent: false }); await oracle.start(); - await oracle._SET('sample', message['@data']); + const written = await oracle._SET('sample', message['@data']); await oracle.stop(); assert.ok(oracle); + assert.strictEqual(written, message['@data']); }); it('can store a string value', async function () { - let oracle = new Oracle(); + const oracle = new Oracle({ persistent: false }); + const payload = message['@data']; await oracle.start(); - let set = await oracle._SET('sample', message['@data']); - let get = await oracle._GET('sample'); + const written = await oracle._SET('sample', payload); + const got = await oracle._GET('sample'); + const missing = await oracle._GET('does-not-exist'); await oracle.stop(); - assert.ok(oracle); - assert.ok(get); + assert.strictEqual(written, payload); + assert.strictEqual(got, payload); + assert.strictEqual(missing, null); }); }); }); diff --git a/tests/fabric.peer.adversarial.js b/tests/fabric.peer.adversarial.js index 28d69cdf9..843bc7ffd 100644 --- a/tests/fabric.peer.adversarial.js +++ b/tests/fabric.peer.adversarial.js @@ -15,7 +15,15 @@ const { PEER_SCORE_BODY_HASH_MISMATCH_PENALTY, HEADER_SIZE, MAX_MESSAGE_SIZE, - CHAT_MAX_RELAYS_PER_ORIGIN_PER_MINUTE + CHAT_MAX_RELAYS_PER_ORIGIN_PER_MINUTE, + FABRIC_COIN_TYPE_MAINNET, + FABRIC_COIN_TYPE_TESTNET, + FIXTURE_SEED, + bitcoinReceiveDerivationPath, + fabricCoinTypeForNetwork, + fabricIdentityAccountPath, + fabricIdentityDerivationPath, + resolveFabricIdentityCoinType } = require('../constants'); const { DocumentBlobTransferBook } = require('../functions/documentBlobManifest'); const inventoryHtlc = require('../functions/inventoryHtlc'); @@ -127,22 +135,28 @@ describe('@fabric/core Peer adversarial hardening', function () { 150 - PEER_SCORE_BODY_HASH_MISMATCH_PENALTY ); - // Dial refused - let connected = false; + // Dial refused before createConnection + const scoreAfterBan = peer._state.peers['ban-peer'].score; peer._outboundDialTargets = new Set(); - const origCreate = require('net').createConnection; - // _connect returns early before createConnection when banned + const warnings = []; + peer.on('warning', (msg) => warnings.push(String(msg))); peer._connect(origin); - assert.strictEqual(peer.connections[origin] == null || peer.connections[origin].destroyed === true || destroyed, true); + assert.ok(warnings.some((m) => /Refusing dial to banned peer/.test(m))); + assert.strictEqual(peer._outboundDialTargets.size, 0); - // Inbound message from banned peer dropped + // Inbound valid frame from banned origin is dropped (no cache / no score change) const ping = Message.fromVector(['P2P_PING', JSON.stringify({ ok: 1 })]).signWithKey(peer.key); - let handled = false; - peer.on('debug', () => { handled = true; }); - peer._handleFabricMessage(ping.toBuffer(), { name: origin }, null); - void origCreate; - void connected; - void handled; + const good = ping.toBuffer(); + const hash = crypto.createHash('sha256').update(good).digest('hex'); + let inboundDestroyed = 0; + peer.connections[origin] = { + destroy () { inboundDestroyed++; } + }; + peer._handleFabricMessage(good, { name: origin }, null); + assert.ok(warnings.some((m) => /Dropping message from banned peer/.test(m))); + assert.strictEqual(inboundDestroyed, 1); + assert.strictEqual(peer._state.peers['ban-peer'].score, scoreAfterBan); + assert.strictEqual(peer.messages[hash], undefined); assert.ok(peer._isPeerBanned(origin)); }); @@ -455,3 +469,623 @@ describe('@fabric/core Peer adversarial hardening', function () { ); }); }); + +// RC1 first-tier contract — the four production-readiness "green" domains. +// These invariants are what a score of 100 means for a shared hostile mesh: +// wire integrity, gossip/peering bounds, peer scoring/bans, identity/wallets. +// Existing suites cover most of this; this file locks the remaining edges. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const Hash256 = require('../types/hash256'); +const Identity = require('../types/identity'); +const Wallet = require('../types/wallet'); +const { fabricIdentityIdFromPubkeyHex } = require('../functions/fabricIdentitySchnorr'); +const { snapshotEnvironment } = require('../functions/fabricSetup'); +const { offlinePeerSettings } = require('./helpers/peer'); + +const IDENTITY_ENV_KEYS = [ + 'NODE_ENV', + 'FABRIC_SEED', + 'FABRIC_MNEMONIC', + 'FABRIC_XPRV', + 'FABRIC_XPUB', + 'FABRIC_PASSWORD', + 'FABRIC_HUB_ADMIN_TOKEN', + 'FABRIC_HUB_ADMIN_TOKEN_FILE' +]; + +function withIsolatedHome (prefix = 'fabric-env-') { + const prev = {}; + for (const key of IDENTITY_ENV_KEYS) prev[key] = process.env[key]; + prev.HOME = process.env.HOME; + const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const store = path.join(home, '.fabric'); + const walletPath = path.join(store, 'wallet.json'); + process.env.HOME = home; + return { + home, + store, + walletPath, + clearIdentityEnv () { + delete process.env.FABRIC_SEED; + delete process.env.FABRIC_MNEMONIC; + delete process.env.FABRIC_XPRV; + delete process.env.FABRIC_XPUB; + delete process.env.FABRIC_PASSWORD; + delete process.env.FABRIC_HUB_ADMIN_TOKEN; + delete process.env.FABRIC_HUB_ADMIN_TOKEN_FILE; + }, + leftoverWalletTemporaryFiles () { + try { + const base = path.basename(walletPath); + return fs.readdirSync(store).filter((name) => { + return name.startsWith(base + '.') && name.endsWith('.tmp'); + }); + } catch (error) { + if (error && error.code === 'ENOENT') return []; + throw error; + } + }, + restore () { + for (const key of IDENTITY_ENV_KEYS) { + if (prev[key] === undefined) delete process.env[key]; + else process.env[key] = prev[key]; + } + if (prev.HOME === undefined) delete process.env.HOME; + else process.env.HOME = prev.HOME; + fs.rmSync(home, { recursive: true, force: true }); + } + }; +} +const Environment = require('../types/environment'); + +const SAMPLE_SEED = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + +describe('@fabric/core RC1 first-tier contract', function () { + function offlinePeer (overrides = {}) { + return new Peer(offlinePeerSettings(overrides)); + } + + function wireConn (writes) { + return { + _writeFabric (buf) { writes.push(Buffer.from(buf)); }, + destroy () { this.destroyed = true; }, + destroyed: false + }; + } + + function seedScore (peer, originName, peerId, score, pubkeyHex) { + peer._addressToId[originName] = peerId; + peer._state.peers = peer._state.peers || {}; + peer._state.peers[peerId] = { + id: peerId, + address: originName, + score, + publicKey: pubkeyHex || 'ab'.repeat(32) + }; + let destroyed = false; + peer.connections[originName] = { + destroy () { destroyed = true; }, + get destroyed () { return destroyed; } + }; + return () => destroyed; + } + + function signPing (key, nonce) { + return Message.fromVector(['P2P_PING', JSON.stringify({ t: nonce })]).signWithKey(key); + } + + describe('wire integrity (body hash + BIP-340)', function () { + it('drops undersize frames before parse (no score, ban, or wire-hash cache)', function () { + const peer = offlinePeer(); + const origin = '127.0.0.1:rc1-short'; + const wasDestroyed = seedScore(peer, origin, 'peer-short', 180); + const warnings = []; + peer.on('warning', (w) => warnings.push(String(w))); + + for (const len of [0, 1, HEADER_SIZE - 1]) { + const buf = Buffer.alloc(len, 0x11); + const hash = crypto.createHash('sha256').update(buf).digest('hex'); + peer._handleFabricMessage(buf, { name: origin }, null); + assert.strictEqual(peer.messages[hash], undefined, `undersize ${len} must not fill cache`); + } + + assert.strictEqual(peer._state.peers['peer-short'].score, 180); + assert.strictEqual(wasDestroyed(), false); + assert.strictEqual(peer._isPeerBanned(origin), false); + assert.ok(warnings.some((w) => /undersize frame/i.test(w))); + }); + + it('drops unparseable non-buffer input without punishing the origin', function () { + const peer = offlinePeer(); + const origin = '127.0.0.1:rc1-unparsed'; + const wasDestroyed = seedScore(peer, origin, 'peer-unparsed', 160); + peer._handleFabricMessage({ not: 'a-buffer' }, { name: origin }, null); + assert.strictEqual(peer._state.peers['peer-unparsed'].score, 160); + assert.strictEqual(wasDestroyed(), false); + assert.strictEqual(peer._isPeerBanned(origin), false); + }); + + it('header author mutation fails BIP-340 and does not fill the wire-hash cache', function () { + const peer = offlinePeer(); + const origin = '127.0.0.1:rc1-author'; + const wasDestroyed = seedScore(peer, origin, 'peer-author', 200); + const key = new Key(); + const buf = Buffer.from(signPing(key, 'author-flip').toBuffer()); + buf[40] ^= 0xff; + const hash = crypto.createHash('sha256').update(buf).digest('hex'); + + peer._handleFabricMessage(buf, { name: origin }, null); + assert.strictEqual(peer.messages[hash], undefined); + assert.ok(peer._state.peers['peer-author'].score < 200); + assert.strictEqual(wasDestroyed(), true); + }); + + it('verified frames FIFO-evict so an older wire hash can be claimed again', function () { + const peer = offlinePeer({ + gossip: { maxWireHashCache: 2 } + }); + const origin = '127.0.0.1:rc1-fifo'; + const key = new Key(); + peer.connections[origin] = wireConn([]); + peer.peers[origin] = { publicKey: key.pubkey }; + + const frames = [1, 2, 3].map((n) => signPing(key, `fifo-${n}`).toBuffer()); + const hashes = frames.map((buf) => crypto.createHash('sha256').update(buf).digest('hex')); + + for (const buf of frames) { + peer._handleFabricMessage(buf, { name: origin }, null); + } + + assert.strictEqual(peer.messages[hashes[0]], undefined, 'oldest verified hash must evict'); + assert.strictEqual(peer.messages[hashes[1]], true); + assert.strictEqual(peer.messages[hashes[2]], true); + + peer._handleFabricMessage(frames[0], { name: origin }, null); + assert.strictEqual(peer.messages[hashes[0]], true, 'evicted hash must be claimable again'); + }); + + it('public AMP hash is double-SHA256(body) and preimage stays zero', function () { + const key = new Key(); + const msg = signPing(key, 'preimage-zero'); + const buf = msg.toBuffer(); + const body = buf.subarray(HEADER_SIZE); + const hashOnWire = buf.subarray(80, 112).toString('hex'); + const preimage = buf.subarray(112, 144); + assert.strictEqual(Hash256.doubleDigest(body), hashOnWire); + assert.ok(preimage.equals(Buffer.alloc(32))); + assert.ok(msg.verifyWithKey(key)); + }); + }); + + describe('gossip / peering DoS bounds', function () { + it('hop=0 gossip does not poison the payload cache for a later valid hop', function () { + const peer = offlinePeer({ + gossip: { maxRelaysPerOriginPerMinute: 8 } + }); + const origin = '127.0.0.1:rc1-g0'; + const sink = '127.0.0.1:rc1-g1'; + const sinkWrites = []; + const author = new Key(); + peer.connections[origin] = wireConn([]); + peer.connections[sink] = wireConn(sinkWrites); + peer.peers[origin] = { publicKey: author.pubkey }; + + const body = { host: '198.51.100.77', port: 7777, tag: 'hop0-then-5' }; + const dead = Message.fromVector( + ['P2P_PEER_GOSSIP', JSON.stringify(Object.assign({}, body, { gossipHop: 0 }))] + ).signWithKey(author); + peer._handleFabricMessage(dead.toBuffer(), { name: origin }, null); + assert.strictEqual(sinkWrites.length, 0); + + const live = Message.fromVector( + ['P2P_PEER_GOSSIP', JSON.stringify(Object.assign({}, body, { gossipHop: 5 }))] + ).signWithKey(author); + peer._handleFabricMessage(live.toBuffer(), { name: origin }, null); + assert.strictEqual(sinkWrites.length, 1, 'valid hop after hop=0 must still relay'); + }); + + it('hop=0 peering offer does not poison the payload cache for a later valid hop', function () { + const peer = offlinePeer({ + constraints: { peers: { max: 32 } }, + peering: { maxRelaysPerOriginPerMinute: 8, maxCandidates: 8 } + }); + const origin = '127.0.0.1:rc1-p0'; + const sink = '127.0.0.1:rc1-p1'; + const sinkWrites = []; + const author = new Key(); + peer.connections[origin] = wireConn([]); + peer.connections[sink] = wireConn(sinkWrites); + peer.peers[origin] = { publicKey: author.pubkey }; + + const body = { transport: 'fabric', host: '203.0.113.77', port: 7777, tag: 'phop0-then-5' }; + const dead = Message.fromVector( + ['P2P_PEERING_OFFER', JSON.stringify(Object.assign({}, body, { peeringHop: 0 }))] + ).signWithKey(author); + peer._handleFabricMessage(dead.toBuffer(), { name: origin }, null); + assert.strictEqual(sinkWrites.length, 0); + assert.strictEqual(peer.candidates.length, 0); + + const live = Message.fromVector( + ['P2P_PEERING_OFFER', JSON.stringify(Object.assign({}, body, { peeringHop: 5 }))] + ).signWithKey(author); + peer._handleFabricMessage(live.toBuffer(), { name: origin }, null); + assert.strictEqual(sinkWrites.length, 1, 'valid hop after peeringHop=0 must still relay'); + assert.ok(peer.candidates.some((c) => c.host === '203.0.113.77' && Number(c.port) === 7777)); + }); + + it('P2P_BASE_MESSAGE cannot inject P2P_PEER_GOSSIP mesh relay', function () { + const peer = offlinePeer({ + gossip: { maxRelaysPerOriginPerMinute: 8 } + }); + const origin = '127.0.0.1:rc1-base-g'; + const sink = '127.0.0.1:rc1-base-s'; + const sinkWrites = []; + const author = new Key(); + peer.connections[origin] = wireConn([]); + peer.connections[sink] = wireConn(sinkWrites); + peer.peers[origin] = { publicKey: author.pubkey }; + let seen = 0; + peer.on('peeringGossip', () => { seen++; }); + + const wire = Message.fromVector(['P2P_BASE_MESSAGE', JSON.stringify({ + type: 'P2P_PEER_GOSSIP', + object: { host: '203.0.113.8', port: 7777, gossipHop: 5 } + })]).signWithKey(author); + peer._handleFabricMessage(wire.toBuffer(), { name: origin }, null); + + assert.strictEqual(seen, 0); + assert.strictEqual(sinkWrites.length, 0); + assert.strictEqual(peer._gossipRelayByOrigin.has(origin), false); + }); + + it('P2P_BASE_MESSAGE cannot inject chat, alias, or announce side effects', function () { + const peer = offlinePeer({ + constraints: { peers: { max: 32 } }, + peering: { maxCandidates: 8 }, + chat: { maxRelaysPerOriginPerMinute: 8 } + }); + const origin = '127.0.0.1:rc1-base-x'; + const sink = '127.0.0.1:rc1-base-y'; + const sinkWrites = []; + const author = new Key(); + peer.connections[origin] = wireConn([]); + peer.connections[sink] = wireConn(sinkWrites); + peer.peers[origin] = { publicKey: author.pubkey }; + peer.connections[origin]._alias = 'honest'; + let chats = 0; + let announces = 0; + peer.on('chat', () => { chats++; }); + peer.on('peerAnnounce', () => { announces++; }); + + const smuggles = [ + { type: 'P2P_CHAT_MESSAGE', object: { content: 'injected' } }, + { type: 'P2P_PEER_ALIAS', object: { alias: 'evil' } }, + { type: 'P2P_PEER_ANNOUNCE', object: { host: '203.0.113.50', port: 7777 } } + ]; + for (const body of smuggles) { + const wire = Message.fromVector(['P2P_BASE_MESSAGE', JSON.stringify(body)]).signWithKey(author); + peer._handleFabricMessage(wire.toBuffer(), { name: origin }, null); + } + + assert.strictEqual(chats, 0); + assert.strictEqual(announces, 0); + assert.strictEqual(peer.connections[origin]._alias, 'honest'); + assert.strictEqual(peer.candidates.length, 0); + assert.ok(!peer._candidateKeys || !peer._candidateKeys.has('203.0.113.50:7777')); + assert.strictEqual(sinkWrites.length, 0); + }); + + it('gossip relay budget is keyed by TCP origin, not advertised body pubkey', function () { + const peer = offlinePeer({ + gossip: { maxRelaysPerOriginPerMinute: 1 } + }); + const originA = '127.0.0.1:rc1-ba'; + const originB = '127.0.0.1:rc1-bb'; + const sink = '127.0.0.1:rc1-bs'; + const sinkWrites = []; + const a = new Key(); + const b = new Key(); + peer.connections[originA] = wireConn([]); + peer.connections[originB] = wireConn([]); + peer.connections[sink] = wireConn(sinkWrites); + peer.peers[originA] = { publicKey: a.pubkey }; + peer.peers[originB] = { publicKey: b.pubkey }; + + const bodyForB = { host: '198.51.100.9', port: 1, gossipHop: 5, pubkey: b.pubkey, tag: 'steal-b' }; + peer._handleFabricMessage( + Message.fromVector(['P2P_PEER_GOSSIP', JSON.stringify(bodyForB)]).signWithKey(a).toBuffer(), + { name: originA }, + null + ); + peer._handleFabricMessage( + Message.fromVector(['P2P_PEER_GOSSIP', JSON.stringify({ + host: '198.51.100.10', + port: 2, + gossipHop: 5, + pubkey: b.pubkey, + tag: 'steal-b-2' + })]).signWithKey(a).toBuffer(), + { name: originA }, + null + ); + assert.strictEqual(sinkWrites.length, 1, 'origin A is budget-capped at 1'); + + peer._handleFabricMessage( + Message.fromVector(['P2P_PEER_GOSSIP', JSON.stringify({ + host: '198.51.100.11', + port: 3, + gossipHop: 5, + tag: 'honest-b' + })]).signWithKey(b).toBuffer(), + { name: originB }, + null + ); + assert.strictEqual(sinkWrites.length, 2, 'origin B keeps an independent budget'); + }); + }); + + describe('peer scoring / bans', function () { + it('hard offense at score 0 still disconnects and bans', function () { + const peer = offlinePeer({ peerScore: { banTtlMs: PEER_BAN_TTL_MS } }); + const origin = '127.0.0.1:rc1-floor'; + const wasDestroyed = seedScore(peer, origin, 'peer-floor', 0); + const msg = signPing(peer.key, 'floor'); + const buf = Buffer.from(msg.toBuffer()); + if (buf.length > HEADER_SIZE) buf[HEADER_SIZE] ^= 0xff; + + peer._handleFabricMessage(buf, { name: origin }, null); + assert.strictEqual(peer._state.peers['peer-floor'].score, 0); + assert.strictEqual(wasDestroyed(), true); + assert.strictEqual(peer._isPeerBanned(origin), true); + }); + + it('banOnHardMisbehavior=false disconnects without installing a ban', function () { + const peer = offlinePeer({ + peerScore: { banOnHardMisbehavior: false, banTtlMs: 60_000 } + }); + const origin = '127.0.0.1:rc1-noban'; + const wasDestroyed = seedScore(peer, origin, 'peer-noban', 90); + const buf = Buffer.from(signPing(peer.key, 'noban').toBuffer()); + if (buf.length > HEADER_SIZE) buf[HEADER_SIZE] ^= 0xff; + + peer._handleFabricMessage(buf, { name: origin }, null); + assert.strictEqual(peer._state.peers['peer-noban'].score, 0, 'score floors at 0'); + assert.strictEqual(wasDestroyed(), true); + assert.strictEqual(peer._isPeerBanned(origin), false); + }); + + it('banned origin: a later valid frame is dropped (no score change, no cache fill)', function () { + const peer = offlinePeer({ peerScore: { banTtlMs: 60_000 } }); + const origin = '127.0.0.1:rc1-banned'; + seedScore(peer, origin, 'peer-banned', 120); + const bad = Buffer.from(signPing(peer.key, 'ban-me').toBuffer()); + if (bad.length > HEADER_SIZE) bad[HEADER_SIZE] ^= 0xff; + peer._handleFabricMessage(bad, { name: origin }, null); + assert.strictEqual(peer._isPeerBanned(origin), true); + const scoreAfterBan = peer._state.peers['peer-banned'].score; + + const good = signPing(peer.key, 'after-ban').toBuffer(); + const hash = crypto.createHash('sha256').update(good).digest('hex'); + peer.connections[origin] = { + destroy () {}, + destroyed: false + }; + peer._handleFabricMessage(good, { name: origin }, null); + assert.strictEqual(peer._state.peers['peer-banned'].score, scoreAfterBan); + assert.strictEqual(peer.messages[hash], undefined); + }); + + it('exact verified replay is silent (no second score change)', function () { + const peer = offlinePeer(); + const origin = '127.0.0.1:rc1-replay'; + const key = new Key(); + seedScore(peer, origin, 'peer-replay', 70, key.pubkey); + peer.peers[origin] = { publicKey: key.pubkey }; + peer.connections[origin] = wireConn([]); + const wire = signPing(key, 'replay-once').toBuffer(); + + peer._handleFabricMessage(wire, { name: origin }, null); + const score = peer._state.peers['peer-replay'].score; + peer._handleFabricMessage(wire, { name: origin }, null); + assert.strictEqual(peer._state.peers['peer-replay'].score, score); + }); + }); + + describe('identity / Key / wallets', function () { + it('unknown network names stay on coin type 7778 (never fail-open to 7777)', function () { + assert.strictEqual(fabricCoinTypeForNetwork('mainnet'), FABRIC_COIN_TYPE_MAINNET); + assert.strictEqual(fabricCoinTypeForNetwork('bitcoin'), FABRIC_COIN_TYPE_MAINNET); + assert.strictEqual(fabricCoinTypeForNetwork('livenet'), FABRIC_COIN_TYPE_MAINNET); + assert.strictEqual(fabricCoinTypeForNetwork('main'), FABRIC_COIN_TYPE_MAINNET); + assert.strictEqual(fabricCoinTypeForNetwork('signet'), FABRIC_COIN_TYPE_TESTNET); + assert.strictEqual(fabricCoinTypeForNetwork('not-a-network'), FABRIC_COIN_TYPE_TESTNET); + assert.strictEqual(fabricCoinTypeForNetwork(''), FABRIC_COIN_TYPE_TESTNET); + assert.strictEqual(fabricCoinTypeForNetwork(null), FABRIC_COIN_TYPE_TESTNET); + assert.strictEqual(fabricIdentityDerivationPath(0, 0, 7777), "m/44'/7777'/0'/0/0"); + assert.strictEqual(fabricIdentityAccountPath(0, 'not-a-network'), "m/44'/7778'/0'"); + assert.strictEqual(fabricIdentityAccountPath(0, 'mainnet'), "m/44'/7777'/0'"); + assert.strictEqual(resolveFabricIdentityCoinType('not-a-network'), FABRIC_COIN_TYPE_TESTNET); + assert.strictEqual(resolveFabricIdentityCoinType(7777), FABRIC_COIN_TYPE_MAINNET); + }); + + it('Bitcoin fund paths stay on coin type 0 and never equal the Fabric identity key', function () { + const identity = new Identity({ seed: SAMPLE_SEED, network: 'mainnet' }); + const fundPath = bitcoinReceiveDerivationPath(0, 0); + const fund = identity.master.derive(fundPath); + assert.match(fundPath, /^m\/44'\/0'\//); + assert.notEqual(fund.pubkey, identity.pubkey); + assert.strictEqual(identity.derivation, "m/44'/7777'/0'/0/0"); + assert.notEqual(identity.pubkey, identity.master.pubkey); + }); + + it('fabricIdentityIdFromPubkeyHex requires compressed 66-hex (rejects x-only)', function () { + const identity = new Identity({ seed: SAMPLE_SEED }); + const compressed = identity.pubkey; + assert.match(compressed, /^0[23][0-9a-f]{64}$/i); + const id = fabricIdentityIdFromPubkeyHex(compressed); + assert.match(id, /^id1/); + assert.throws( + () => fabricIdentityIdFromPubkeyHex(compressed.slice(2)), + /66 hex/i + ); + }); + + it('sealed wallet.json public object and setup snapshot omit seed/xprv', function () { + const iso = withIsolatedHome('fabric-rc1-wallet-'); + iso.clearIdentityEnv(); + process.env.NODE_ENV = 'test'; + try { + const environment = new Environment({ + home: iso.home, + path: iso.walletPath, + store: iso.store, + lockTimeoutMinutes: 0 + }); + environment.start(); + environment.setWallet(new Wallet({ key: { seed: FIXTURE_SEED } }), true, { + password: 'test-pass-ok!', + encrypt: true + }); + const doc = JSON.parse(fs.readFileSync(iso.walletPath, 'utf8')); + assert.strictEqual(doc.passwordProtected, true); + assert.ok(doc.object); + assert.strictEqual(doc.object.seed, undefined); + assert.strictEqual(doc.object.xprv, undefined); + assert.ok(doc.seal && doc.seal.ciphertext); + + const snap = snapshotEnvironment(environment); + const blob = JSON.stringify(snap); + assert.ok(!/xprv[A-Za-z0-9]{20,}/i.test(blob), 'snapshot must not contain an xprv payload'); + assert.ok(!blob.includes(FIXTURE_SEED)); + assert.ok(!blob.includes(SAMPLE_SEED)); + } finally { + iso.restore(); + } + }); + }); +}); + +// Coverage locks from FabricLabs/fabric PR review comments. +// Sources: PR #185 (open) and closed PR #183. Heavy-lift product work +// (isolatePeerContent collections, unique Service commit ids, Blessed TUI +// markup) stays deferred in docs/OUTSTANDING.md. + +const { EventEmitter } = require('events'); + + +describe('@fabric/core RC1 PR-review coverage', function () { + function offlinePeer (overrides = {}) { + return new Peer(offlinePeerSettings(overrides)); + } + + function wireConn (writes) { + return { + _writeFabric (buf) { writes.push(Buffer.from(buf)); }, + destroy () { this.destroyed = true; }, + destroyed: false + }; + } + + it('isolatePeerContent treats null and array state as empty maps', function () { + const fromNull = new Peer(offlinePeerSettings({ state: null })); + const fromArray = new Peer(offlinePeerSettings({ state: [] })); + assert.deepStrictEqual(fromNull._state.content.documents, {}); + assert.deepStrictEqual(fromArray._state.content.messages, {}); + assert.ok(!fromNull._state.content.collections); + }); + + it('beat emits a clock-only snapshot (no full state tree)', function () { + const peer = offlinePeer(); + let beat = null; + peer.on('beat', (ev) => { beat = ev; }); + peer.beat(); + assert.ok(beat); + assert.strictEqual(typeof beat.clock, 'number'); + assert.deepStrictEqual(beat.state, { clock: beat.clock }); + assert.ok(!beat.state.collections); + assert.ok(!beat.state.documents); + }); + + it('AMP P2P_PEERING_OFFER with JSON type 98 still enqueues a candidate', function () { + const peer = offlinePeer({ + constraints: { peers: { max: 32 } }, + peering: { maxCandidates: 8 } + }); + const origin = '127.0.0.1:pr185-98'; + const author = new Key(); + peer.connections[origin] = wireConn([]); + peer.peers[origin] = { publicKey: author.pubkey }; + const wire = Message.fromVector(['P2P_PEERING_OFFER', JSON.stringify({ + type: 98, + host: '203.0.113.98', + port: 7777, + transport: 'fabric' + })]).signWithKey(author); + peer._handleFabricMessage(wire.toBuffer(), { name: origin }, null); + assert.ok(peer.candidates.some((c) => c.host === '203.0.113.98' && Number(c.port) === 7777)); + }); + + it('AMP P2P_INVENTORY_RESPONSE cannot smuggle a peering offer via JSON type 98', function () { + const peer = offlinePeer({ + constraints: { peers: { max: 32 } }, + peering: { maxCandidates: 8 } + }); + const origin = '127.0.0.1:pr185-inv'; + const author = new Key(); + peer.connections[origin] = wireConn([]); + peer.peers[origin] = { publicKey: author.pubkey }; + let inventory = 0; + peer.on('inventoryResponse', () => { inventory++; }); + const wire = Message.fromVector(['P2P_INVENTORY_RESPONSE', JSON.stringify({ + type: 98, + host: '203.0.113.99', + port: 7777, + transport: 'fabric' + })]).signWithKey(author); + peer._handleFabricMessage(wire.toBuffer(), { name: origin }, null); + assert.strictEqual(peer.candidates.length, 0); + assert.ok(!peer._candidateKeys || !peer._candidateKeys.has('203.0.113.99:7777')); + assert.ok(inventory >= 1); + }); + + it('_attachNoiseStreamErrorHandlers is a no-op without both sides, and re-attach detaches first', function () { + const peer = offlinePeer(); + assert.doesNotThrow(() => peer._attachNoiseStreamErrorHandlers(null)); + assert.doesNotThrow(() => peer._attachNoiseStreamErrorHandlers({ encrypt: {} })); + + function side () { + const ee = new EventEmitter(); + ee.removeListener = EventEmitter.prototype.removeListener; + return ee; + } + const noise = { encrypt: side(), decrypt: side() }; + peer._attachNoiseStreamErrorHandlers(noise, 'NOISE'); + assert.ok(noise._fabricNoiseErrorHandlers); + const first = noise._fabricNoiseErrorHandlers; + peer._attachNoiseStreamErrorHandlers(noise, 'NOISE'); + assert.ok(noise._fabricNoiseErrorHandlers); + assert.notStrictEqual(noise._fabricNoiseErrorHandlers, first); + assert.strictEqual(noise.encrypt.listenerCount('error'), 1); + assert.strictEqual(noise.decrypt.listenerCount('error'), 1); + }); + + it('inbound socket errors warn instead of throwing without an error listener', function () { + const { PassThrough } = require('stream'); + const peer = offlinePeer(); + const warnings = []; + peer.on('warning', (msg) => warnings.push(String(msg))); + const socket = new PassThrough(); + socket.remoteAddress = '192.0.2.8'; + socket.remotePort = 19108; + socket.destroy = function () { this.destroyed = true; }; + peer._NOISESocketHandler(socket); + assert.doesNotThrow(() => socket.emit('error', new Error('EPROTO inbound'))); + assert.ok(warnings.some((msg) => /Inbound socket error/.test(msg))); + if (typeof socket._destroyFabric === 'function') socket._destroyFabric(); + socket.destroy(); + }); +}); diff --git a/tests/fabric.peer.js b/tests/fabric.peer.js index b5da84ebe..4ef382fe4 100644 --- a/tests/fabric.peer.js +++ b/tests/fabric.peer.js @@ -8,6 +8,7 @@ const Key = require('../types/key'); const Actor = require('../types/actor'); const assert = require('assert'); const net = require('net'); +const { EventEmitter } = require('events'); // Node configs may be JSON (node-a.json) or JS; resolve accordingly let NODEA, NODEB; @@ -88,6 +89,39 @@ describe('@fabric/core/types/peer', function () { assert.strictEqual(peer.documentation.name, 'Fabric'); }); + describe('commit and socket actor leaks', function () { + it('does not alias caller Hub collections into observed peer content', function () { + const collections = { documents: { leak: { id: 'leak' } } }; + const shared = { collections: collections, documents: { keep: 'x' } }; + const peer = new Peer({ listen: false, peersDb: null, state: shared }); + collections.documents.more = { id: 'more' }; + shared.documents.keep = 'mutated'; + assert.strictEqual(peer._state.content.documents.keep, 'x'); + assert.ok(!peer._state.content.collections); + assert.notStrictEqual(peer._state.content, shared); + }); + + it('unregisters ephemeral socket actors on disconnect', function () { + const peer = new Peer({ listen: false, peersDb: null }); + const address = '127.0.0.1:59999'; + peer._registerActor({ name: address }); + const actorId = new Actor({ name: address }).id; + assert.ok(peer.actors[actorId]); + peer.connections[address] = { destroy () {} }; + peer._disconnect(address); + assert.ok(!peer.actors[actorId]); + assert.ok(!peer._actorsByName[address]); + }); + + it('does not commit on _writeFabric', function () { + const peer = new Peer({ listen: false, peersDb: null }); + const commits = []; + peer.on('commit', (c) => commits.push(c)); + peer._writeFabric(Buffer.from('hi'), null); + assert.strictEqual(commits.length, 0); + }); + }); + describe('getters', function () { it('exposes id from key.pubkey', function () { const peer = new Peer({ listen: false, peersDb: null }); @@ -155,6 +189,7 @@ describe('@fabric/core/types/peer', function () { assert.ok(ev.created); assert.ok(ev.initial); assert.ok(ev.state); + assert.ok(!ev.state.collections); done(); }); const out = peer.beat(); @@ -187,9 +222,31 @@ describe('@fabric/core/types/peer', function () { describe('connectTo', function () { it('calls _connect and returns this', function () { + const originalCreateConnection = net.createConnection; const peer = new Peer({ listen: false, peersDb: null }); - const out = peer.connectTo('127.0.0.1:9'); // invalid port, will fail but we only assert chain - assert.strictEqual(out, peer); + let createdSocket = null; + net.createConnection = function () { + const sock = new EventEmitter(); + sock.destroyed = false; + sock.setTimeout = function () {}; + sock.unref = function () {}; + sock.destroy = function () { + sock.destroyed = true; + sock.emit('close'); + }; + createdSocket = sock; + return sock; + }; + try { + const out = peer.connectTo('127.0.0.1:9'); + assert.strictEqual(out, peer); + } finally { + net.createConnection = originalCreateConnection; + if (createdSocket && typeof createdSocket.destroy === 'function') createdSocket.destroy(); + if (typeof peer._destroyFabric === 'function') { + try { peer._destroyFabric(createdSocket || {}, '127.0.0.1:9'); } catch (_) { /* ignore */ } + } + } }); }); @@ -232,6 +289,170 @@ describe('@fabric/core/types/peer', function () { peer._connect('127.0.0.1:9999'); }); + it('skips overlapping outbound dials before connections[target] is set', function () { + const originalCreateConnection = net.createConnection; + const peer = new Peer({ listen: false, peersDb: null }); + let created = 0; + let createdSocket = null; + net.createConnection = function () { + created += 1; + const sock = new EventEmitter(); + sock.destroyed = false; + sock.setTimeout = function () {}; + sock.unref = function () {}; + sock.destroy = function () { + sock.destroyed = true; + sock.emit('close'); + }; + createdSocket = sock; + return sock; + }; + try { + peer._connect('127.0.0.1:9'); + peer._connect('127.0.0.1:9'); + assert.strictEqual(created, 1); + assert.strictEqual(peer._outboundDialTargets.has('127.0.0.1:9'), true); + } finally { + net.createConnection = originalCreateConnection; + if (createdSocket && typeof createdSocket.destroy === 'function') createdSocket.destroy(); + if (typeof peer._destroyFabric === 'function') { + try { peer._destroyFabric(createdSocket || {}, '127.0.0.1:9'); } catch (_) { /* ignore */ } + } + } + }); + + it('strips URL IPv6 brackets before dialing [::1]', function () { + const originalCreateConnection = net.createConnection; + const peer = new Peer({ listen: false, peersDb: null }); + let createdHost = null; + let createdPort = null; + let createdSocket = null; + net.createConnection = function (port, hostname) { + createdPort = port; + createdHost = hostname; + const sock = new EventEmitter(); + sock.destroyed = false; + sock.setTimeout = function () {}; + sock.unref = function () {}; + sock.destroy = function () { + sock.destroyed = true; + sock.emit('close'); + }; + createdSocket = sock; + return sock; + }; + try { + peer._connect('[::1]:9'); + assert.strictEqual(createdHost, '::1'); + assert.strictEqual(Number(createdPort), 9); + assert.strictEqual(peer._outboundDialTargets.has('[::1]:9'), true); + } finally { + net.createConnection = originalCreateConnection; + if (createdSocket && typeof createdSocket.destroy === 'function') createdSocket.destroy(); + if (typeof peer._destroyFabric === 'function') { + try { peer._destroyFabric(createdSocket || {}, '[::1]:9'); } catch (_) { /* ignore */ } + } + } + }); + + it('formats IPv6 peering candidates as [host]:port before retry and dial', function () { + const originalCreateConnection = net.createConnection; + const peer = new Peer({ listen: false, peersDb: null, networking: false }); + peer.settings.constraints.peers.max = 8; + let createdHost = null; + let createdSocket = null; + net.createConnection = function (port, hostname) { + createdHost = hostname; + const sock = new EventEmitter(); + sock.destroyed = false; + sock.setTimeout = function () {}; + sock.unref = function () {}; + sock.destroy = function () { + sock.destroyed = true; + sock.emit('close'); + }; + createdSocket = sock; + return sock; + }; + try { + peer._enqueuePeeringCandidate('::1', 9); + assert.ok(peer._candidateKeys.has('[::1]:9')); + assert.ok(!peer._candidateKeys.has('::1:9')); + peer._fillPeerSlots(); + assert.strictEqual(createdHost, '::1'); + assert.strictEqual(peer._outboundDialTargets.has('[::1]:9'), true); + assert.ok(peer._candidateRetryAt.has('[::1]:9')); + } finally { + net.createConnection = originalCreateConnection; + if (createdSocket && typeof createdSocket.destroy === 'function') createdSocket.destroy(); + if (typeof peer._destroyFabric === 'function') { + try { peer._destroyFabric(createdSocket || {}, '[::1]:9'); } catch (_) { /* ignore */ } + } + } + }); + + it('canonicalizes pubkey@host:port onto the same outbound dial slot', function () { + const originalCreateConnection = net.createConnection; + const peer = new Peer({ listen: false, peersDb: null }); + let created = 0; + let createdSocket = null; + net.createConnection = function () { + created += 1; + const sock = new EventEmitter(); + sock.destroyed = false; + sock.setTimeout = function () {}; + sock.unref = function () {}; + sock.destroy = function () { + sock.destroyed = true; + sock.emit('close'); + }; + createdSocket = sock; + return sock; + }; + try { + peer._connect('aa'.repeat(32) + '@127.0.0.1:9'); + peer._connect('127.0.0.1:9'); + assert.strictEqual(created, 1); + assert.strictEqual(peer._outboundDialTargets.has('127.0.0.1:9'), true); + } finally { + net.createConnection = originalCreateConnection; + if (createdSocket && typeof createdSocket.destroy === 'function') createdSocket.destroy(); + if (typeof peer._destroyFabric === 'function') { + try { peer._destroyFabric(createdSocket || {}, '127.0.0.1:9'); } catch (_) { /* ignore */ } + } + } + }); + + it('connect timeout destroys the outbound socket', function () { + const originalCreateConnection = net.createConnection; + const peer = new Peer({ listen: false, peersDb: null, connectTimeout: 50 }); + let createdSocket = null; + net.createConnection = function () { + const sock = new EventEmitter(); + sock.destroyed = false; + sock.setTimeout = function () {}; + sock.unref = function () {}; + sock.destroy = function () { + sock.destroyed = true; + sock.emit('close'); + }; + createdSocket = sock; + return sock; + }; + try { + peer._connect('192.0.2.8:9'); + assert.ok(createdSocket); + assert.strictEqual(typeof createdSocket._destroyFabric, 'function'); + createdSocket.emit('timeout'); + assert.strictEqual(createdSocket.destroyed, true); + } finally { + net.createConnection = originalCreateConnection; + if (createdSocket && typeof createdSocket.destroy === 'function' && !createdSocket.destroyed) { + createdSocket.destroy(); + } + } + }); + it('emits derived key debug summaries for missing/short/long public keys', function () { const originalCreateConnection = net.createConnection; net.createConnection = function () { @@ -426,6 +647,52 @@ describe('@fabric/core/types/peer', function () { peer._fillPeerSlots(); assert.strictEqual(peer.candidates.length, 1); }); + + it('does not immediately redial the same candidate', function () { + const peer = new Peer({ listen: false, peersDb: null, networking: false }); + peer.settings.constraints.peers.max = 8; + peer.settings.peering = { candidateRetryMs: 60000 }; + let dials = 0; + peer._connect = () => { dials += 1; }; + peer._enqueuePeeringCandidate('192.0.2.77', 7778); + peer._fillPeerSlots(); + peer._fillPeerSlots(); + assert.strictEqual(dials, 1); + assert.strictEqual(peer.candidates.length, 1); + }); + + it('ignores non-positive candidateRetryMs and prunes expired retry timestamps', function () { + const peer = new Peer({ listen: false, peersDb: null, networking: false }); + peer.settings.constraints.peers.max = 8; + peer.settings.peering = { candidateRetryMs: -1, maxCandidates: 2 }; + let dials = 0; + peer._connect = () => { dials += 1; }; + peer._candidateRetryAt = new Map(); + peer._candidateRetryAt.set('198.51.100.1:1', Date.now() - 10); + peer._candidateRetryAt.set('keep-a:1', Date.now() + 60000); + peer._candidateRetryAt.set('keep-b:1', Date.now() + 60000); + peer._candidateRetryAt.set('keep-c:1', Date.now() + 60000); + peer._enqueuePeeringCandidate('192.0.2.88', 7778); + peer._fillPeerSlots(); + assert.strictEqual(dials, 1); + assert.strictEqual(peer._candidateRetryAt.has('198.51.100.1:1'), false); + assert.strictEqual(peer._candidateRetryAt.has('keep-a:1'), false); + assert.ok(peer._candidateRetryAt.has('192.0.2.88:7778')); + const until = peer._candidateRetryAt.get('192.0.2.88:7778'); + assert.ok(until > Date.now() + 1000, 'negative retry ms must not redial immediately'); + }); + + it('Infinity candidateRetryMs falls back to the default cooldown', function () { + const peer = new Peer({ listen: false, peersDb: null, networking: false }); + peer.settings.constraints.peers.max = 8; + peer.settings.peering = { candidateRetryMs: Infinity }; + let dials = 0; + peer._connect = () => { dials += 1; }; + peer._enqueuePeeringCandidate('192.0.2.89', 7778); + peer._fillPeerSlots(); + peer._fillPeerSlots(); + assert.strictEqual(dials, 1); + }); }); describe('_registerContract', function () { @@ -1201,10 +1468,45 @@ describe('@fabric/core/types/peer', function () { it('emits debug for unhandled generic type', function (done) { const peer = new Peer({ listen: false, peersDb: null }); peer.once('debug', (m) => { - assert.ok(/Unhandled Generic Message/.test(m)); + assert.strictEqual(m, 'Unhandled Generic Message: UNKNOWN_TYPE'); + assert.ok(!String(m).includes('pad'), 'must not include generic body fields'); done(); }); - peer._handleGenericMessage({ type: 'UNKNOWN_TYPE', object: {} }, { name: 'o' }); + peer._handleGenericMessage({ type: 'UNKNOWN_TYPE', object: { pad: 'x'.repeat(2048) } }, { name: 'o' }); + }); + it('coerces numeric opcode 98 to P2P_PEERING_OFFER', function (done) { + const peer = new Peer({ listen: false, peersDb: null }); + peer.once('peeringOffer', (ev) => { + assert.strictEqual(ev.message.type, 'P2P_PEERING_OFFER'); + done(); + }); + peer._handleGenericMessage({ + type: 98, + object: { host: '10.1.2.3', port: 7777, transport: 'fabric' } + }, { name: '9.9.9.9:1' }); + }); + it('reads flat JSON peering offers (type 98 + host/port on the outer object)', function () { + const peer = new Peer({ + listen: false, + peersDb: null, + constraints: { peers: { max: 32 } } + }); + peer._handleGenericMessage({ + type: 98, + host: '10.4.5.6', + port: 7777, + transport: 'fabric' + }, { name: '9.9.9.9:2' }); + assert.ok(peer.candidates.some((c) => c.host === '10.4.5.6' && Number(c.port) === 7777)); + }); + it('derived-key debug summary uses Key.pubkey not settings.public', function () { + const peer = new Peer({ listen: false, peersDb: null }); + const derived = peer.identity.key.derive("m/44'/7778'/0'/0/0"); + assert.ok(derived.pubkey && derived.pubkey.length >= 66); + assert.ok(derived.settings.public == null || derived.settings.public === ''); + const summary = Peer.debugDerivedPublicSummary(derived); + assert.notStrictEqual(summary, '(no public key)'); + assert.ok(summary.includes('…') || summary.length <= 28); }); it('emits warning on broken JSON body in Fabric message path', function (done) { const peer = new Peer({ listen: false, peersDb: null }); @@ -1825,6 +2127,13 @@ describe('@fabric/core/types/peer', function () { assert.ok(server._isSelfDialSuppressed('192.0.2.91:7777')); }); + it('_isTransientSocketError matches ECONNREFUSED without error.code', function () { + const peer = new Peer({ listen: false, peersDb: null, networking: false }); + assert.strictEqual(peer._isTransientSocketError({ code: 'ECONNREFUSED' }), true); + assert.strictEqual(peer._isTransientSocketError(new Error('connect ECONNREFUSED 65.21.231.149:7778')), true); + assert.strictEqual(peer._isTransientSocketError(new Error('application protocol failure')), false); + }); + it('records verifiedPubkey on queued third-party candidates', function () { const server = new Peer({ listen: false, peersDb: null }); const attacker = new Key(); @@ -2235,3 +2544,327 @@ describe('@fabric/core/types/peer', function () { }); }); }); + +const { + resolveFabricPeerInterface +} = require('../functions/fabricListenInterface'); + +describe('@fabric/core/functions/fabricListenInterface', function () { + it('defaults to 0.0.0.0', function () { + assert.strictEqual(resolveFabricPeerInterface({ env: {} }), '0.0.0.0'); + }); + + it('prefers FABRIC_INTERFACE over FABRIC_PEER_INTERFACE and settings', function () { + assert.strictEqual(resolveFabricPeerInterface({ + env: { FABRIC_INTERFACE: '65.21.231.149', FABRIC_PEER_INTERFACE: '1.2.3.4' }, + interface: '9.9.9.9' + }), '65.21.231.149'); + }); + + it('falls back to FABRIC_PEER_INTERFACE when FABRIC_INTERFACE is empty', function () { + assert.strictEqual(resolveFabricPeerInterface({ + env: { FABRIC_INTERFACE: ' ', FABRIC_PEER_INTERFACE: '1.2.3.4' }, + interface: '9.9.9.9' + }), '1.2.3.4'); + }); + + it('falls back to settings.interface when env is unset', function () { + assert.strictEqual(resolveFabricPeerInterface({ + env: {}, + interface: '65.21.231.149' + }), '65.21.231.149'); + }); + + it('accepts settings.host as an alias for interface', function () { + assert.strictEqual(resolveFabricPeerInterface({ + env: {}, + host: '192.0.2.8' + }), '192.0.2.8'); + }); + + it('honours custom envKeys and explicit fallback', function () { + assert.strictEqual(resolveFabricPeerInterface({ + env: { FABRIC_INTERFACE: '9.9.9.9', OTHER_BIND: '10.0.0.1' }, + envKeys: ['OTHER_BIND'], + fallback: '127.0.0.1' + }), '10.0.0.1'); + assert.strictEqual(resolveFabricPeerInterface({ + env: {}, + envKeys: ['OTHER_BIND'], + fallback: '127.0.0.1' + }), '127.0.0.1'); + }); +}); + +const { individualPk, verifyAggregatedSchnorr } = require('../functions/musig2'); +const { offlinePeerSettings } = require('./helpers/peer'); + +function makePeer (label, extra = {}) { + const seed = new Key(); + const peer = new Peer(offlinePeerSettings({ + key: { mnemonic: seed.mnemonic }, + debug: false, + musig2: Object.assign({ autoAccept: true }, extra.musig2 || {}) + })); + return { peer, label, seed }; +} + +function wirePair (alice, bob) { + const aliceAddr = '127.0.0.1:19101'; + const bobAddr = '127.0.0.1:19102'; + const aliceWrites = []; + const bobWrites = []; + alice.connections[bobAddr] = { + _writeFabric (buf) { aliceWrites.push(Buffer.from(buf)); } + }; + bob.connections[aliceAddr] = { + _writeFabric (buf) { bobWrites.push(Buffer.from(buf)); } + }; + alice.peers[bobAddr] = { id: bob.key.pubkey, publicKey: bob.key.pubkey }; + bob.peers[aliceAddr] = { id: alice.key.pubkey, publicKey: alice.key.pubkey }; + return { aliceAddr, bobAddr, aliceWrites, bobWrites }; +} + +function exchange (aliceWrites, bob, aliceAddr, bobWrites, alice, bobAddr, max = 20) { + for (let i = 0; i < max; i++) { + if (!aliceWrites.length && !bobWrites.length) return i; + while (aliceWrites.length) { + bob._handleFabricMessage(aliceWrites.shift(), { name: aliceAddr }, null); + } + while (bobWrites.length) { + alice._handleFabricMessage(bobWrites.shift(), { name: bobAddr }, null); + } + } + return max; +} + +describe('@fabric/core Peer P2P_MUSIG_*', function () { + it('defaults musig2.autoAccept to false', function () { + const seed = new Key(); + const peer = new Peer(offlinePeerSettings({ + key: { mnemonic: seed.mnemonic }, + debug: false + })); + assert.strictEqual(peer.settings.musig2.autoAccept, false); + }); + + it('completes a directed 2-of-2 session', function () { + const { peer: alice } = makePeer('alice'); + const { peer: bob } = makePeer('bob'); + const { aliceAddr, bobAddr, aliceWrites, bobWrites } = wirePair(alice, bob); + const started = alice.startMusig2({ + dest: bob.key.pubkey, + msg: Buffer.from('gooncitizen-musig2'), + purpose: 'test' + }); + assert.ok(started); + assert.strictEqual(started.n, 2); + exchange(aliceWrites, bob, aliceAddr, bobWrites, alice, bobAddr); + const aDone = alice.getMusig2Session(started.sessionId); + const bDone = bob.getMusig2Session(started.sessionId); + assert.ok(aDone && aDone.signature, 'initiator missing aggregate signature'); + assert.ok(bDone && bDone.signature, 'co-signer missing aggregate signature'); + assert.strictEqual(aDone.signature, bDone.signature); + const pubkeys = [ + individualPk(alice.key.private), + individualPk(bob.key.private) + ]; + const session = alice._musigGet(started.sessionId); + assert.strictEqual( + verifyAggregatedSchnorr(session.challenge, Buffer.from(aDone.signature, 'hex'), pubkeys), + true + ); + }); + + it('ignores peel/relay-as-is START (no session)', function () { + const { peer: bob } = makePeer('bob'); + const author = new Key(); + const sessionId = Buffer.alloc(32, 7); + const body = { + sessionId: sessionId.toString('hex'), + msg: Buffer.from('nope').toString('hex'), + pubkeys: Buffer.concat([ + Buffer.from(author.pubkey, 'hex'), + Buffer.from(bob.key.pubkey, 'hex') + ]).toString('hex'), + pubnonce: Buffer.concat([ + Buffer.from(author.pubkey, 'hex'), + Buffer.from(author.pubkey, 'hex') + ]).toString('hex'), + purpose: 'peel' + }; + const inner = Message.fromVector(['P2P_MUSIG_START', JSON.stringify(body)]).signWithKey(author); + bob._handleFabricMessage(inner.toBuffer(), { name: '127.0.0.1:1' }, null, { + peeledForward: true + }); + assert.strictEqual(bob._musigSessions.size, 0); + const relayBody = Object.assign({}, body, { + sessionId: Buffer.alloc(32, 8).toString('hex') + }); + const relayInner = Message.fromVector(['P2P_MUSIG_START', JSON.stringify(relayBody)]).signWithKey(author); + bob._handleFabricMessage(relayInner.toBuffer(), { name: '127.0.0.1:1' }, null, { + relayedAsIs: true + }); + assert.strictEqual(bob._musigSessions.size, 0); + }); + + it('rejects START whose AMP signer is not in the pubkey list', function () { + const { peer: bob } = makePeer('bob'); + const alice = new Key(); + const rogue = new Key(); + const origin = '127.0.0.1:19103'; + bob.connections[origin] = { _writeFabric () {}, destroy () {} }; + bob.peers[origin] = { id: rogue.pubkey, publicKey: rogue.pubkey }; + const body = { + sessionId: Buffer.alloc(32, 3).toString('hex'), + msg: Buffer.from('rogue').toString('hex'), + pubkeys: Buffer.concat([ + Buffer.from(alice.pubkey, 'hex'), + Buffer.from(bob.key.pubkey, 'hex') + ]).toString('hex'), + pubnonce: Buffer.concat([ + Buffer.from(alice.pubkey, 'hex'), + Buffer.from(alice.pubkey, 'hex') + ]).toString('hex') + }; + const frame = Message.fromVector(['P2P_MUSIG_START', JSON.stringify(body)]).signWithKey(rogue); + bob._handleFabricMessage(frame.toBuffer(), { name: origin }, null); + assert.strictEqual(bob._musigSessions.size, 0); + }); + + it('ignores P2P_MUSIG_START smuggled in a generic carrier', function () { + const { peer: bob } = makePeer('bob'); + const alice = new Key(); + const origin = '127.0.0.1:19104'; + bob.connections[origin] = { _writeFabric () {}, destroy () {} }; + bob.peers[origin] = { id: alice.pubkey, publicKey: alice.pubkey }; + const inner = { + type: 'P2P_MUSIG_START', + sessionId: Buffer.alloc(32, 9).toString('hex'), + msg: 'hi', + pubkeys: Buffer.concat([ + Buffer.from(alice.pubkey, 'hex'), + Buffer.from(bob.key.pubkey, 'hex') + ]).toString('hex'), + pubnonce: Buffer.concat([ + Buffer.from(alice.pubkey, 'hex'), + Buffer.from(alice.pubkey, 'hex') + ]).toString('hex') + }; + const frame = Message.fromVector(['P2P_BASE_MESSAGE', JSON.stringify(inner)]).signWithKey(alice); + bob._handleFabricMessage(frame.toBuffer(), { name: origin }, null); + assert.strictEqual(bob._musigSessions.size, 0); + }); + + it('does not auto-sign inbound START when musig2.autoAccept is off', function () { + const { peer: bob } = makePeer('bob', { musig2: { autoAccept: false } }); + const alice = new Key(); + const origin = '127.0.0.1:19105'; + const writes = []; + bob.connections[origin] = { _writeFabric (buf) { writes.push(buf); }, destroy () {} }; + bob.peers[origin] = { id: alice.pubkey, publicKey: alice.pubkey }; + const body = { + sessionId: Buffer.alloc(32, 4).toString('hex'), + msg: Buffer.from('oracle').toString('hex'), + pubkeys: Buffer.concat([ + Buffer.from(alice.pubkey, 'hex'), + Buffer.from(bob.key.pubkey, 'hex') + ]).toString('hex'), + pubnonce: Buffer.concat([ + Buffer.from(alice.pubkey, 'hex'), + Buffer.from(alice.pubkey, 'hex') + ]).toString('hex') + }; + const frame = Message.fromVector(['P2P_MUSIG_START', JSON.stringify(body)]).signWithKey(alice); + bob._handleFabricMessage(frame.toBuffer(), { name: origin }, null); + assert.strictEqual(bob._musigSessions.size, 0); + assert.strictEqual(writes.length, 0); + }); + + it('initiator still completes when autoAccept is off and the co-signer has it on', function () { + const { peer: alice } = makePeer('alice', { musig2: { autoAccept: false } }); + const { peer: bob } = makePeer('bob', { musig2: { autoAccept: true } }); + const { aliceAddr, bobAddr, aliceWrites, bobWrites } = wirePair(alice, bob); + const started = alice.startMusig2({ + dest: bob.key.pubkey, + msg: Buffer.from('initiator-no-auto'), + purpose: 'test' + }); + assert.ok(started); + exchange(aliceWrites, bob, aliceAddr, bobWrites, alice, bobAddr); + const aDone = alice.getMusig2Session(started.sessionId); + const bDone = bob.getMusig2Session(started.sessionId); + assert.ok(aDone && aDone.signature, 'initiator missing aggregate signature'); + assert.ok(bDone && bDone.signature, 'co-signer missing aggregate signature'); + assert.strictEqual(aDone.signature, bDone.signature); + }); + + it('clamps invalid musig2.maxSessions instead of looping', function () { + this.timeout(2000); + const { peer: alice } = makePeer('alice', { musig2: { maxSessions: -1 } }); + const dest = new Key(); + const origin = '127.0.0.1:19106'; + alice.connections[origin] = { _writeFabric () {}, destroy () {} }; + alice.peers[origin] = { id: dest.pubkey, publicKey: dest.pubkey }; + alice.startMusig2({ dest: dest.pubkey, msg: Buffer.from('one') }); + alice.startMusig2({ dest: dest.pubkey, msg: Buffer.from('two') }); + assert.ok(alice._musigSessions.size >= 1); + assert.ok(alice._musigSessions.size <= 32); + }); + + it('evicts the oldest MuSig2 session at maxSessions', function () { + const { peer: alice } = makePeer('alice', { musig2: { maxSessions: 1 } }); + const dest = new Key(); + const origin = '127.0.0.1:19107'; + alice.connections[origin] = { _writeFabric () {}, destroy () {} }; + alice.peers[origin] = { id: dest.pubkey, publicKey: dest.pubkey }; + const first = alice.startMusig2({ dest: dest.pubkey, msg: Buffer.from('keep-first') }); + const second = alice.startMusig2({ dest: dest.pubkey, msg: Buffer.from('keep-second') }); + assert.ok(first && second); + assert.strictEqual(alice._musigSessions.size, 1); + assert.strictEqual(alice.getMusig2Session(first.sessionId), null); + assert.ok(alice.getMusig2Session(second.sessionId)); + }); + + it('wipes stored MuSig2 sessions on stop', async function () { + const { peer: alice } = makePeer('alice', { musig2: { autoAccept: false } }); + const dest = new Key(); + const origin = '127.0.0.1:19199'; + alice.connections[origin] = { _writeFabric () {} }; + alice.peers[origin] = { id: dest.pubkey, publicKey: dest.pubkey }; + alice.startMusig2({ dest: dest.pubkey, msg: Buffer.from('stop-wipe') }); + assert.strictEqual(alice._musigSessions.size, 1); + const held = [...alice._musigSessions.values()][0].secnonce; + assert.ok(Buffer.isBuffer(held)); + await alice.stop(); + assert.strictEqual(alice._musigSessions.size, 0); + assert.ok(held.every((b) => b === 0)); + }); + + it('rejects SEND_PROPOSAL unless AMP signer is the session initiator', function () { + const { peer: alice } = makePeer('alice'); + const { peer: bob } = makePeer('bob'); + const { aliceAddr, bobAddr, aliceWrites, bobWrites } = wirePair(alice, bob); + const started = alice.startMusig2({ + dest: bob.key.pubkey, + msg: Buffer.from('proposal-role'), + purpose: 'test' + }); + assert.ok(started); + exchange(aliceWrites, bob, aliceAddr, bobWrites, alice, bobAddr); + const session = alice._musigGet(started.sessionId); + assert.ok(session && session.aggnonce); + const warnings = []; + alice.on('warning', (m) => warnings.push(String(m))); + const body = { + sessionId: started.sessionId, + aggnonce: Buffer.from(session.aggnonce).toString('hex') + }; + const msg = Message.fromVector(['P2P_MUSIG_SEND_PROPOSAL', JSON.stringify(body)]).signWithKey(bob.key); + alice._handleFabricMessage(msg.toBuffer(), { name: bobAddr }, null); + assert.ok( + warnings.some((w) => /SEND_PROPOSAL rejected: not-initiator/i.test(w)), + `expected not-initiator warning, got: ${warnings.join(' | ')}` + ); + }); +}); diff --git a/tests/fabric.promise.js b/tests/fabric.promise.js index 340cf3239..734b2a42a 100644 --- a/tests/fabric.promise.js +++ b/tests/fabric.promise.js @@ -22,6 +22,9 @@ describe('@fabric/core/types/promise', function () { it('encrypt updates internal state flag', function () { const p = new EncryptedPromise({ password: 'pw' }); assert.strictEqual(p._state.state, 'ENCRYPTED'); + p._state.state = 'UNSAFE'; + p.encrypt('pw'); + assert.strictEqual(p._state.state, 'ENCRYPTED'); }); it('_assignState updates serialized data and status', async function () { diff --git a/tests/fabric.reader.js b/tests/fabric.reader.js index 0f7e0f4b9..c54330b87 100644 --- a/tests/fabric.reader.js +++ b/tests/fabric.reader.js @@ -8,6 +8,8 @@ const Reader = require('../types/reader'); const Message = require('../types/message'); describe('@fabric/core/types/reader', function () { + // Compatibility scanner only (@deprecated on types/reader.js). + // Production AMP ingest lives on Peer / Message. describe('Reader', function () { it('is a constructor', function () { assert.strictEqual(Reader instanceof Function, true); diff --git a/tests/fabric.service.js b/tests/fabric.service.js index 3b51e9c69..2b7c555b6 100644 --- a/tests/fabric.service.js +++ b/tests/fabric.service.js @@ -215,7 +215,7 @@ describe('@fabric/core/types/service', function () { }); describe('_listActors()', function () { - it('can list channels successfully', async function () { + it('can list actors successfully', async function () { const service = new Service(FAST_SERVICE); await service.start(); const registration = await service._registerActor({ name: 'Chad' }); @@ -304,8 +304,6 @@ describe('@fabric/core/types/service', function () { assert.strictEqual(result.length, 1); assert.ok(service); assert.ok(link); - assert.ok(result); - assert.ok(result.length, 1); }); it('provides the posted document in the expected location', async function () { @@ -331,6 +329,30 @@ describe('@fabric/core/types/service', function () { assert.strictEqual(commits.length, 1); assert.ok(commits[0].id && typeof commits[0].id === 'string'); assert.ok(commits[0].id.length > 0); + assert.strictEqual(commits[0].type, 'Commit'); + assert.ok(commits[0].state === undefined); + }); + + it('does not embed service state in commit actors', async function () { + const service = new Service(FAST_SERVICE); + await service.start(); + const commits = []; + service.on('commit', (c) => commits.push(c)); + service.set('/visibilityBlob', { pad: 'z'.repeat(2048) }); + await service.stop(); + assert.ok(commits[0]); + assert.ok(commits[0].id); + assert.ok(!('state' in commits[0])); + assert.ok(typeof commits[0].clock === 'number'); + }); + + it('caps patch history at commitHistoryMax', async function () { + const service = new Service({ ...FAST_SERVICE, commitHistoryMax: 3 }); + await service.start(); + for (let i = 0; i < 8; i++) service.set('/cap' + i, i); + await service.stop(); + assert.ok(service.history.length <= 3); + assert.strictEqual(service.history.length, 3); }); it('patches and history expose RFC6902 deltas for audits', async function () { @@ -755,3 +777,37 @@ describe('@fabric/core/types/service', function () { }); }); }); + +const Bitcoin = require('../services/bitcoin'); +const State = require('../types/state'); +const Store = require('../types/store'); + +describe('Hub crash-report follow-ups (core)', function () { + it('State.serialize does not throw on null', function () { + const state = new State({ ok: true }); + const serialized = state.serialize(null); + assert.ok(serialized); + assert.doesNotThrow(() => new State(null)); + }); + + it('Store._POST rejects a null body', async function () { + const store = new Store({ persistent: false }); + await assert.rejects(() => store._POST('/widgets', null), /JSON body/); + }); + + it('applyP2pAddNodes treats RPC -23 already-added as success', async function () { + const btc = Object.create(Bitcoin.prototype); + btc.settings = { debug: false, network: 'regtest' }; + btc._normalizeP2pPeerAddress = Bitcoin.prototype._normalizeP2pPeerAddress; + btc._makeRPCRequest = async function () { + throw new Error('addnode failed hub.fabric.pub:18444: [-23] Node already added'); + }; + const warnings = []; + btc.emit = function (ev, msg) { + if (ev === 'warning') warnings.push(msg); + }; + const done = await Bitcoin.prototype.applyP2pAddNodes.call(btc, ['hub.fabric.pub:18444']); + assert.deepStrictEqual(done, ['hub.fabric.pub:18444']); + assert.strictEqual(warnings.length, 0); + }); +}); diff --git a/tests/fabric.stack.js b/tests/fabric.stack.js index a5389130d..0b09204b8 100644 --- a/tests/fabric.stack.js +++ b/tests/fabric.stack.js @@ -10,12 +10,10 @@ describe('@fabric/core/types/stack', function () { assert.equal(Fabric.Stack instanceof Function, true); }); - it('can restore state from an Array-like object', function () { - let stack = new Fabric.Stack(['test']); - // console.log('stack:', stack); - // console.log('stack.render():', stack.render()); - - // TODO: move to constants, verify + it('constructs from an Array-like object without hashing the list into id', function () { + const stack = new Fabric.Stack(['test']); + assert.ok(stack.size >= 1); + // Actor/State id is the empty-content identity, not a hash of ['test']. assert.equal(stack.id, 'a5b08f19adfd2918e354af8c11e1b4efd963b5f5a525900d63a01cd2fd28176f'); }); diff --git a/tests/fabric.tree.js b/tests/fabric.tree.js index 101c15f79..d2f63ce8c 100644 --- a/tests/fabric.tree.js +++ b/tests/fabric.tree.js @@ -14,14 +14,16 @@ describe('@fabric/core/types/tree', function () { assert.ok(tree); assert.ok(tree._tree); assert.ok(tree.root); - assert.strictEqual(tree.root.toString('hex'), ''); // TODO: wat? + // merkletreejs + isBitcoinTree: no leaves → empty Buffer (hex ''). + assert.strictEqual(tree.root.toString('hex'), ''); }); it('can construct a known tree', async function () { let tree = new Tree(['foo', 'bar']); assert.ok(tree); assert.ok(tree._tree); - assert.strictEqual(tree.root.toString('hex'), '906b5aaf65ae98f8c98848de5e81ba865659f16fd53aefa4c78b34176f068079'); // TODO: wat? + // Bitcoin-style merkle of string leaves ['foo','bar'] via Hash256.digest. + assert.strictEqual(tree.root.toString('hex'), '906b5aaf65ae98f8c98848de5e81ba865659f16fd53aefa4c78b34176f068079'); }); it('addLeaf accumulates leaves for growing merkle roots', function () { diff --git a/tests/fabric.wallet.js b/tests/fabric.wallet.js index 9d1010425..e22e44875 100644 --- a/tests/fabric.wallet.js +++ b/tests/fabric.wallet.js @@ -68,6 +68,17 @@ describe('@fabric/core/types/wallet', function () { assert.ok(exported.object.master); assert.ok(exported.object.seed); assert.ok(exported.object.xprv); + assert.match(String(exported.object.xprv), /^xprv/); + }); + + it('export() is the plaintext payload Environment seals (secrets stay in object)', function () { + const wallet = new Wallet({ key: { seed: require('../constants').FIXTURE_SEED } }); + const exported = wallet.export(); + const json = JSON.stringify(exported.object); + assert.ok(json.includes('xprv')); + assert.ok(exported.object.seed); + assert.strictEqual(exported.passwordProtected, undefined); + assert.strictEqual(exported.seal, undefined); }); it('can get an unused address', async () => { @@ -348,3 +359,55 @@ describe('@fabric/core/types/wallet', function () { }); }); }); + +const { + BITCOIN_KEY_DERIVATION_PATH, + bitcoinReceiveDerivationPath, + bitcoinChangeDerivationPath, + bitcoinBip49ReceiveDerivationPath, + bitcoinBip49ChangeDerivationPath, + bitcoinBip84ReceiveDerivationPath, + bitcoinBip84ChangeDerivationPath, + bitcoinBip86ReceiveDerivationPath, + bitcoinBip86ChangeDerivationPath, + bitcoinBip48P2shP2wshReceiveDerivationPath, + bitcoinBip48P2shP2wshChangeDerivationPath, + bitcoinBip48P2wshReceiveDerivationPath, + bitcoinBip48P2wshChangeDerivationPath +} = require('../constants'); + +describe('@fabric/core Bitcoin derivation path templates', function () { + it('keeps default funds path on BIP-44', function () { + assert.strictEqual(BITCOIN_KEY_DERIVATION_PATH, "m/44'/0'/0'/0/0"); + assert.strictEqual(bitcoinReceiveDerivationPath(0, 0), "m/44'/0'/0'/0/0"); + assert.strictEqual(bitcoinChangeDerivationPath(1, 7), "m/44'/0'/1'/1/7"); + }); + + it('names BIP-49 wrapped-SegWit paths without making them the default', function () { + assert.strictEqual(bitcoinBip49ReceiveDerivationPath(), "m/49'/0'/0'/0/0"); + assert.strictEqual(bitcoinBip49ChangeDerivationPath(2, 1), "m/49'/0'/2'/1/1"); + }); + + it('names BIP-84 native SegWit paths', function () { + assert.strictEqual(bitcoinBip84ReceiveDerivationPath(0, 0), "m/84'/0'/0'/0/0"); + assert.strictEqual(bitcoinBip84ChangeDerivationPath(0, 3), "m/84'/0'/0'/1/3"); + }); + + it('names BIP-86 Taproot paths', function () { + assert.strictEqual(bitcoinBip86ReceiveDerivationPath(), "m/86'/0'/0'/0/0"); + assert.strictEqual(bitcoinBip86ChangeDerivationPath(1, 0), "m/86'/0'/1'/1/0"); + }); + + it('names BIP-48 multisig paths without switching group vaults', function () { + assert.strictEqual(bitcoinBip48P2shP2wshReceiveDerivationPath(), "m/48'/0'/0'/1'/0/0"); + assert.strictEqual(bitcoinBip48P2wshReceiveDerivationPath(), "m/48'/0'/0'/2'/0/0"); + assert.strictEqual(bitcoinBip48P2wshChangeDerivationPath(0, 1), "m/48'/0'/0'/2'/1/1"); + assert.strictEqual(bitcoinBip48P2shP2wshChangeDerivationPath(1, 0), "m/48'/0'/1'/1'/1/0"); + }); + + it('rejects out-of-range account or index', function () { + assert.throws(() => bitcoinReceiveDerivationPath(-1, 0), /account/); + assert.throws(() => bitcoinBip84ReceiveDerivationPath(0, 0x80000000), /index/); + assert.throws(() => bitcoinBip86ChangeDerivationPath(1.5, 0), /account/); + }); +}); diff --git a/tests/fixtures/application-contract-chain.json b/tests/fixtures/application-contract-chain.json new file mode 100644 index 000000000..dd1422486 --- /dev/null +++ b/tests/fixtures/application-contract-chain.json @@ -0,0 +1,307 @@ +{ + "type": "FabricApplicationContractChainFixture", + "v": 1, + "createdAt": "2026-08-16T00:00:00.000Z", + "contractId": "a4a211e86a62a95bd685dc108353ca7163180a48f192c52cd26d80e05401ddd3", + "keyMaterial": { + "alice": { + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + }, + "bob": { + "mnemonic": "legal winner thank year wave sausage worth useful legal winner thank yellow" + }, + "carol": { + "mnemonic": "letter advice cage absurd amount doctor acoustic avoid letter advice cage above" + }, + "dave": { + "seedHex": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "eve": { + "seedHex": "2222222222222222222222222222222222222222222222222222222222222222" + } + }, + "mnemonics": { + "alice": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "bob": "legal winner thank year wave sausage worth useful legal winner thank yellow", + "carol": "letter advice cage absurd amount doctor acoustic avoid letter advice cage above" + }, + "definition": { + "name": "GenericApplication", + "version": 1, + "createdAt": "2026-08-16T00:00:00.000Z", + "creator": "03d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494", + "groupId": "app-generic-1", + "parties": [ + "03d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494", + "02c4c9a1e37f5601ed4c7d0eb202e0b55e7891c4787853bef6c3b96a187de12d83" + ], + "validators": [ + "03d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494", + "02c4c9a1e37f5601ed4c7d0eb202e0b55e7891c4787853bef6c3b96a187de12d83" + ], + "proposedPolicy": { + "validators": [ + "03d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494", + "02c4c9a1e37f5601ed4c7d0eb202e0b55e7891c4787853bef6c3b96a187de12d83" + ], + "threshold": 2 + }, + "messageTypes": [ + "GroupChat", + "GroupChange", + "GroupChangeProposal", + "GroupChangeVote", + "FederationContractInvite", + "FederationContractInviteResponse", + "GroupJournalRequest", + "GroupJournalBatch" + ], + "state": { + "label": "fixture-generic-app" + } + }, + "collection": { + "type": "FabricMessageCollection", + "v": 1, + "count": 13, + "root": "c23cea6d14266ddf02cf09122b993ae6a31da89499d98a022379144b36b85fb6", + "messages": [ + { + "hash": "d7765d116cfa3603c1833dac1b51611aa725d0000eca4699929588492c02488b", + "hex": "c0d3f33d000000010000000000000000000000000000000000000000000000000000000000000000d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee04940000005f00000385d7765d116cfa3603c1833dac1b51611aa725d0000eca4699929588492c02488b0000000000000000000000000000000000000000000000000000000000000000efe2ce3ec8c2239456d09a17b87dc2f03124ce8d9933cb67defc5c68db91e49626a41cf39672c31fb600a0430094ef92a1bbba40525c1c8f2598f67cf5455d8f7b226e616d65223a2247656e657269634170706c69636174696f6e222c2276657273696f6e223a312c22637265617465644174223a22323032362d30382d31365430303a30303a30302e3030305a222c2263726561746f72223a22303364393032663335663536306530343730633633333133633733363931363864396437646632643439626632393566643966623763623130396363656530343934222c2267726f75704964223a226170702d67656e657269632d31222c2270617274696573223a5b22303364393032663335663536306530343730633633333133633733363931363864396437646632643439626632393566643966623763623130396363656530343934222c22303263346339613165333766353630316564346337643065623230326530623535653738393163343738373835336265663663336239366131383764653132643833225d2c2276616c696461746f7273223a5b22303364393032663335663536306530343730633633333133633733363931363864396437646632643439626632393566643966623763623130396363656530343934222c22303263346339613165333766353630316564346337643065623230326530623535653738393163343738373835336265663663336239366131383764653132643833225d2c2270726f706f736564506f6c696379223a7b2276616c696461746f7273223a5b22303364393032663335663536306530343730633633333133633733363931363864396437646632643439626632393566643966623763623130396363656530343934222c22303263346339613165333766353630316564346337643065623230326530623535653738393163343738373835336265663663336239366131383764653132643833225d2c227468726573686f6c64223a327d2c226d6573736167655479706573223a5b2247726f757043686174222c2247726f75704368616e6765222c2247726f75704368616e676550726f706f73616c222c2247726f75704368616e6765566f7465222c2246656465726174696f6e436f6e7472616374496e76697465222c2246656465726174696f6e436f6e7472616374496e76697465526573706f6e7365222c2247726f75704a6f75726e616c52657175657374222c2247726f75704a6f75726e616c4261746368225d2c227374617465223a7b226c6162656c223a22666978747572652d67656e657269632d617070227d7d", + "type": "CONTRACT_PUBLISH", + "author": "d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494", + "origin": "publish" + }, + { + "hash": "a3a45040886272bb4a0e8923818755e78f9a4f6e5568a5c98cb53e2abcf988cf", + "hex": "c0d3f33d000000010000000000000000000000000000000000000000000000000000000000000000d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee049400000060000000f6a3a45040886272bb4a0e8923818755e78f9a4f6e5568a5c98cb53e2abcf988cf000000000000000000000000000000000000000000000000000000000000000026e990eb1c95adbc79656886ef921cae1d8834a2dcddf2d347af5e789ed09b924632fe7b8c9cb0f0037b63d3168554e5b19e5f924cffe6bc7b13b35281cd55c07b22636f6e7472616374223a2261346132313165383661363261393562643638356463313038333533636137313633313830613438663139326335326364323664383065303534303164646433222c2274797065223a2247726f757043686174222c226f626a656374223a7b22626f6479223a2268656c6c6f206170706c69636174696f6e222c22617574686f72223a22303364393032663335663536306530343730633633333133633733363931363864396437646632643439626632393566643966623763623130396363656530343934222c227473223a22323032362d30382d31365430303a30303a30312e3030305a227d7d", + "type": "CONTRACT_MESSAGE", + "author": "d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494", + "appType": "GroupChat", + "contract": "a4a211e86a62a95bd685dc108353ca7163180a48f192c52cd26d80e05401ddd3", + "origin": "chat-alice" + }, + { + "hash": "caed8f1ed3791f86cc517af07aa9b82502a5141546f1df64e5674c1c567a1d39", + "hex": "c0d3f33d000000010000000000000000000000000000000000000000000000000000000000000000d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494000000600000011ccaed8f1ed3791f86cc517af07aa9b82502a5141546f1df64e5674c1c567a1d3900000000000000000000000000000000000000000000000000000000000000005a81bd06816cb0fc3d3bcfafe80215ed7e18cdeea02272f2bc7c608583d8189357ee493f0039793a29439742e878d169982a34a921c2413f0cc47fbc7afc3c927b22636f6e7472616374223a2261346132313165383661363261393562643638356463313038333533636137313633313830613438663139326335326364323664383065303534303164646433222c2274797065223a2247726f75704368616e6765222c226f626a656374223a7b22616374696f6e223a226d656d626572732e736574222c226d656d62657273223a5b22303364393032663335663536306530343730633633333133633733363931363864396437646632643439626632393566643966623763623130396363656530343934222c22303263346339613165333766353630316564346337643065623230326530623535653738393163343738373835336265663663336239366131383764653132643833225d7d7d", + "type": "CONTRACT_MESSAGE", + "author": "d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494", + "appType": "GroupChange", + "contract": "a4a211e86a62a95bd685dc108353ca7163180a48f192c52cd26d80e05401ddd3", + "origin": "change-seed-roster" + }, + { + "hash": "2170b92b3582d8a8290703aa29da69c6f13d70f8d463904334daa0e351f318d0", + "hex": "c0d3f33d000000010000000000000000000000000000000000000000000000000000000000000000d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee049400000060000000e32170b92b3582d8a8290703aa29da69c6f13d70f8d463904334daa0e351f318d00000000000000000000000000000000000000000000000000000000000000000a7d1a68af13fbd367ecf60dfd4d95ca6e05e5abc0256edbbcf5ec143e85a30826da4a9220f2896dbba5a48e9f14d24f653f2138c98c4e14b6cfa3231ac06254e7b22636f6e7472616374223a2261346132313165383661363261393562643638356463313038333533636137313633313830613438663139326335326364323664383065303534303164646433222c2274797065223a2247726f75704368616e6765222c226f626a656374223a7b22616374696f6e223a226d656d6265722e616464222c226d656d626572223a22303239363932383630323735383135306432623461386132353334353162383837363235623934616230613931663830316631343038636233336239636630663833222c22726f6c65223a227369676e6572227d7d", + "type": "CONTRACT_MESSAGE", + "author": "d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494", + "appType": "GroupChange", + "contract": "a4a211e86a62a95bd685dc108353ca7163180a48f192c52cd26d80e05401ddd3", + "origin": "change-member-add-dave" + }, + { + "hash": "bb20388e813e77ce07331263a7c8874e8ac8ed05aa9df16074c6f89be9fc0b32", + "hex": "c0d3f33d000000010000000000000000000000000000000000000000000000000000000000000000d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee049400000060000000e3bb20388e813e77ce07331263a7c8874e8ac8ed05aa9df16074c6f89be9fc0b320000000000000000000000000000000000000000000000000000000000000000b121bcf190b0b11d33d7c22a6febf1271774c8168ba8bbfc0d846c7b9fd04f2b8eb1bc61aed43a0523f14e4858679488918c982652a4676ed92c1366634771347b22636f6e7472616374223a2261346132313165383661363261393562643638356463313038333533636137313633313830613438663139326335326364323664383065303534303164646433222c2274797065223a2247726f75704368616e6765222c226f626a656374223a7b22616374696f6e223a226d656d6265722e616464222c226d656d626572223a22303236616464383131646531396332663637373938656266336364666666386162656338396132623632386435386230393962396562643763613465336336323830222c22726f6c65223a22726561646572227d7d", + "type": "CONTRACT_MESSAGE", + "author": "d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494", + "appType": "GroupChange", + "contract": "a4a211e86a62a95bd685dc108353ca7163180a48f192c52cd26d80e05401ddd3", + "origin": "change-member-add-eve-reader" + }, + { + "hash": "e78e6ed5c99390b44f0d2e23be1acd7073dbf9acffa0bbbefbbe840c5a40304b", + "hex": "c0d3f33d000000010000000000000000000000000000000000000000000000000000000000000000d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee049400000060000000d6e78e6ed5c99390b44f0d2e23be1acd7073dbf9acffa0bbbefbbe840c5a40304b0000000000000000000000000000000000000000000000000000000000000000ae6cdd46c51943a37fac08b0610a1a18ed19c9ef4e130a44dbfe0117b2ffdbe8c749376b6b7632e2568eb77d5f6b1142d7f078b24434583406131765b5a75aaa7b22636f6e7472616374223a2261346132313165383661363261393562643638356463313038333533636137313633313830613438663139326335326364323664383065303534303164646433222c2274797065223a2247726f75704368616e6765222c226f626a656374223a7b22616374696f6e223a226d656d6265722e72656d6f7665222c226d656d626572223a22303239363932383630323735383135306432623461386132353334353162383837363235623934616230613931663830316631343038636233336239636630663833227d7d", + "type": "CONTRACT_MESSAGE", + "author": "d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494", + "appType": "GroupChange", + "contract": "a4a211e86a62a95bd685dc108353ca7163180a48f192c52cd26d80e05401ddd3", + "origin": "change-member-remove-dave" + }, + { + "hash": "baa829a9c97dd75e68ef985b4ed5fedd490f122dc51af48011bc0860b56165be", + "hex": "c0d3f33d000000010000000000000000000000000000000000000000000000000000000000000000d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee049400000060000001a6baa829a9c97dd75e68ef985b4ed5fedd490f122dc51af48011bc0860b56165be000000000000000000000000000000000000000000000000000000000000000097479f767d45e754c997eabd968ef61ff20ec26153c4c5c4c6153e17701a8a4b253a575685f372d52ffe7aa72663cfe24e915922af277e3f0f152be99ac3c0f77b22636f6e7472616374223a2261346132313165383661363261393562643638356463313038333533636137313633313830613438663139326335326364323664383065303534303164646433222c2274797065223a2247726f75704368616e6765222c226f626a656374223a7b22616374696f6e223a226d656d626572732e736574222c226d656d62657273223a5b22303364393032663335663536306530343730633633333133633733363931363864396437646632643439626632393566643966623763623130396363656530343934222c22303263346339613165333766353630316564346337643065623230326530623535653738393163343738373835336265663663336239366131383764653132643833222c22303364306163636362663636353335323335376463653930306531396437356133383733363962316333333165623362653630356139373139333234393131333731222c22303236616464383131646531396332663637373938656266336364666666386162656338396132623632386435386230393962396562643763613465336336323830225d7d7d", + "type": "CONTRACT_MESSAGE", + "author": "d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494", + "appType": "GroupChange", + "contract": "a4a211e86a62a95bd685dc108353ca7163180a48f192c52cd26d80e05401ddd3", + "origin": "change-members-set" + }, + { + "hash": "8d85db5fe69525facc2b91e1bcdeccededcf762b1e7a0f86654bccedc7885cfa", + "hex": "c0d3f33d000000010000000000000000000000000000000000000000000000000000000000000000d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494000000600000011c8d85db5fe69525facc2b91e1bcdeccededcf762b1e7a0f86654bccedc7885cfa000000000000000000000000000000000000000000000000000000000000000065d58ab67db7629a6235e807fbf18e514c500405e8bfa6c4cb81d3d3e36666467ec29c29e19ebc2d8397e822014a7fe8804e987d7f8732b53539e115e198723c7b22636f6e7472616374223a2261346132313165383661363261393562643638356463313038333533636137313633313830613438663139326335326364323664383065303534303164646433222c2274797065223a2247726f75704368616e6765222c226f626a656374223a7b22616374696f6e223a227369676e6572732e736574222c227369676e657273223a5b22303364393032663335663536306530343730633633333133633733363931363864396437646632643439626632393566643966623763623130396363656530343934222c22303263346339613165333766353630316564346337643065623230326530623535653738393163343738373835336265663663336239366131383764653132643833225d7d7d", + "type": "CONTRACT_MESSAGE", + "author": "d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494", + "appType": "GroupChange", + "contract": "a4a211e86a62a95bd685dc108353ca7163180a48f192c52cd26d80e05401ddd3", + "origin": "change-signers-set" + }, + { + "hash": "262a7bfa293a563bd1f120c26a1222c0b0eaea077aca108554b1a3e546c654e5", + "hex": "c0d3f33d000000010000000000000000000000000000000000000000000000000000000000000000d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee049400000060000000c2262a7bfa293a563bd1f120c26a1222c0b0eaea077aca108554b1a3e546c654e5000000000000000000000000000000000000000000000000000000000000000088073ea2d38eedfce21a45110d76c697a22aea854f71a50694487a40c188332e45423c3b8fa05bdf4b2518b11aad1e09f5e9f7ff89c0a797549a6be6f2aea69a7b22636f6e7472616374223a2261346132313165383661363261393562643638356463313038333533636137313633313830613438663139326335326364323664383065303534303164646433222c2274797065223a2247726f75704368616e6765222c226f626a656374223a7b22616374696f6e223a22757064617465222c227061746368223a7b2270696e6e65644368616e6e656c73223a5b2267726f75703a6170702d67656e657269632d31225d2c227468726573686f6c64223a327d7d7d", + "type": "CONTRACT_MESSAGE", + "author": "d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494", + "appType": "GroupChange", + "contract": "a4a211e86a62a95bd685dc108353ca7163180a48f192c52cd26d80e05401ddd3", + "origin": "change-update" + }, + { + "hash": "839c4a39dad1dae4dec56d45b3e64910834989a123448863bc5a1c66e2a218f0", + "hex": "c0d3f33d000000010000000000000000000000000000000000000000000000000000000000000000d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee049400000060000001fe839c4a39dad1dae4dec56d45b3e64910834989a123448863bc5a1c66e2a218f00000000000000000000000000000000000000000000000000000000000000000fa84a73e10cf2c7bf9173b0de7ef43462c51e256dad64ee9a2c778dc10f44f483f7d1b2f850c7e591b4e874cb288429747c6fb99effbda41c7165a9133bac8677b22636f6e7472616374223a2261346132313165383661363261393562643638356463313038333533636137313633313830613438663139326335326364323664383065303534303164646433222c2274797065223a2247726f75704368616e676550726f706f73616c222c226f626a656374223a7b226964223a226770726f702d666978747572652d31222c22636f6e74726163744964223a2261346132313165383661363261393562643638356463313038333533636137313633313830613438663139326335326364323664383065303534303164646433222c2267726f75704964223a226170702d67656e657269632d31222c22616374696f6e223a226d656d6265722e616464222c226d656d626572223a22303364306163636362663636353335323335376463653930306531396437356133383733363962316333333165623362653630356139373139333234393131333731222c22726f6c65223a227369676e6572222c227061746368223a6e756c6c2c2270726f706f7365644279223a2264393032663335663536306530343730633633333133633733363931363864396437646632643439626632393566643966623763623130396363656530343934222c22637265617465644174223a22323032362d30382d31365430303a30303a30302e3030305a222c227468726573686f6c64223a327d7d", + "type": "CONTRACT_MESSAGE", + "author": "d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494", + "appType": "GroupChangeProposal", + "contract": "a4a211e86a62a95bd685dc108353ca7163180a48f192c52cd26d80e05401ddd3", + "origin": "proposal-add-carol" + }, + { + "hash": "622c9bb6e1adbff7cbd932fcc45f660932c8165230b06b1bc7acac71095e7c75", + "hex": "c0d3f33d000000010000000000000000000000000000000000000000000000000000000000000000c4c9a1e37f5601ed4c7d0eb202e0b55e7891c4787853bef6c3b96a187de12d830000006000000216622c9bb6e1adbff7cbd932fcc45f660932c8165230b06b1bc7acac71095e7c750000000000000000000000000000000000000000000000000000000000000000e1af7e3317eee5778da77c7240e7b0267a90e8c95a04d39c65a7d034923f580bbc5bd0391c2202c4dff95ea1aef37943b58ccb50fe6e20dbee81a8790a4c80237b22636f6e7472616374223a2261346132313165383661363261393562643638356463313038333533636137313633313830613438663139326335326364323664383065303534303164646433222c2274797065223a2247726f75704368616e6765566f7465222c226f626a656374223a7b2274797065223a2247726f75704368616e6765566f7465222c2270726f706f73616c4964223a226770726f702d666978747572652d31222c22636f6e74726163744964223a2261346132313165383661363261393562643638356463313038333533636137313633313830613438663139326335326364323664383065303534303164646433222c2267726f75704964223a226170702d67656e657269632d31222c22766f746572223a22303263346339613165333766353630316564346337643065623230326530623535653738393163343738373835336265663663336239366131383764653132643833222c227369676e6174757265223a226232633938363663306661653136353066323663633231306132666261656630396565646133656130336632656263616634366133643766393033623861353335616133333466323964353962643335376461613030356565633062643337393166666365386135363762336536643636303137333538376164613831373334222c22766f7465644174223a22323032362d30382d31365430303a30303a30322e3030305a227d7d", + "type": "CONTRACT_MESSAGE", + "author": "c4c9a1e37f5601ed4c7d0eb202e0b55e7891c4787853bef6c3b96a187de12d83", + "appType": "GroupChangeVote", + "contract": "a4a211e86a62a95bd685dc108353ca7163180a48f192c52cd26d80e05401ddd3", + "origin": "vote-bob" + }, + { + "hash": "bd92d2cc6c160a2af91afd94d464ea6151f23b570a6bea6bdadbb110533562a7", + "hex": "c0d3f33d000000010000000000000000000000000000000000000000000000000000000000000000c4c9a1e37f5601ed4c7d0eb202e0b55e7891c4787853bef6c3b96a187de12d8300000060000000f0bd92d2cc6c160a2af91afd94d464ea6151f23b570a6bea6bdadbb110533562a70000000000000000000000000000000000000000000000000000000000000000f6204053588828a6d6e7304c7e23583da451187f4b34f1452cbe2170cab8c1a0779def2c25eae5f96b6200efe1523bd4c88eadc00b508fe434bfb113352b5a517b22636f6e7472616374223a2261346132313165383661363261393562643638356463313038333533636137313633313830613438663139326335326364323664383065303534303164646433222c2274797065223a2247726f757043686174222c226f626a656374223a7b22626f6479223a226361726f6c20697320696e222c22617574686f72223a22303263346339613165333766353630316564346337643065623230326530623535653738393163343738373835336265663663336239366131383764653132643833222c227473223a22323032362d30382d31365430303a30303a30332e3030305a227d7d", + "type": "CONTRACT_MESSAGE", + "author": "c4c9a1e37f5601ed4c7d0eb202e0b55e7891c4787853bef6c3b96a187de12d83", + "appType": "GroupChat", + "contract": "a4a211e86a62a95bd685dc108353ca7163180a48f192c52cd26d80e05401ddd3", + "origin": "chat-bob" + }, + { + "hash": "146ffc9c3acc277588a0a21cb1df59ede207064ba759316917063907d0adf9a7", + "hex": "c0d3f33d000000010000000000000000000000000000000000000000000000000000000000000000d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee049400000060000000d5146ffc9c3acc277588a0a21cb1df59ede207064ba759316917063907d0adf9a70000000000000000000000000000000000000000000000000000000000000000d73acdc8f50104f08c5a495f88d4355aef6894baede25e1131fd5ebcd8ab47ff257052a12c6eefdb411d682140f9979e06041b9ba46d567fe172ce23a6db9ee27b22636f6e7472616374223a2261346132313165383661363261393562643638356463313038333533636137313633313830613438663139326335326364323664383065303534303164646433222c2274797065223a2247726f75704a6f75726e616c52657175657374222c226f626a656374223a7b2266726f6d436c6f636b223a312c22636f6e74726163744964223a2261346132313165383661363261393562643638356463313038333533636137313633313830613438663139326335326364323664383065303534303164646433227d7d", + "type": "CONTRACT_MESSAGE", + "author": "d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494", + "appType": "GroupJournalRequest", + "contract": "a4a211e86a62a95bd685dc108353ca7163180a48f192c52cd26d80e05401ddd3", + "origin": "journal-request" + } + ] + }, + "tip": { + "contractId": "a4a211e86a62a95bd685dc108353ca7163180a48f192c52cd26d80e05401ddd3", + "clock": 12, + "stateDigest": "9510d57e8fe15999cf8c32329942f80d8a16cb55195c638fde30eb5351b32b91", + "members": [ + "c4c9a1e37f5601ed4c7d0eb202e0b55e7891c4787853bef6c3b96a187de12d83", + "d0acccbf665352357dce900e19d75a387369b1c331eb3be605a9719324911371", + "d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494" + ], + "signers": [ + "c4c9a1e37f5601ed4c7d0eb202e0b55e7891c4787853bef6c3b96a187de12d83", + "d0acccbf665352357dce900e19d75a387369b1c331eb3be605a9719324911371", + "d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494" + ], + "threshold": 2, + "chatBodies": [ + "hello application", + "carol is in" + ], + "proposalStatus": "adopted" + }, + "groupChangeActions": [ + "member.add", + "member.remove", + "members.set", + "signers.set", + "update" + ], + "labels": [ + { + "label": "publish", + "hash": "d7765d116cfa3603c1833dac1b51611aa725d0000eca4699929588492c02488b", + "type": "CONTRACT_PUBLISH", + "appType": null, + "action": null + }, + { + "label": "chat-alice", + "hash": "a3a45040886272bb4a0e8923818755e78f9a4f6e5568a5c98cb53e2abcf988cf", + "type": "CONTRACT_MESSAGE", + "appType": "GroupChat", + "action": null + }, + { + "label": "change-seed-roster", + "hash": "caed8f1ed3791f86cc517af07aa9b82502a5141546f1df64e5674c1c567a1d39", + "type": "CONTRACT_MESSAGE", + "appType": "GroupChange", + "action": "members.set" + }, + { + "label": "change-member-add-dave", + "hash": "2170b92b3582d8a8290703aa29da69c6f13d70f8d463904334daa0e351f318d0", + "type": "CONTRACT_MESSAGE", + "appType": "GroupChange", + "action": "member.add" + }, + { + "label": "change-member-add-eve-reader", + "hash": "bb20388e813e77ce07331263a7c8874e8ac8ed05aa9df16074c6f89be9fc0b32", + "type": "CONTRACT_MESSAGE", + "appType": "GroupChange", + "action": "member.add" + }, + { + "label": "change-member-remove-dave", + "hash": "e78e6ed5c99390b44f0d2e23be1acd7073dbf9acffa0bbbefbbe840c5a40304b", + "type": "CONTRACT_MESSAGE", + "appType": "GroupChange", + "action": "member.remove" + }, + { + "label": "change-members-set", + "hash": "baa829a9c97dd75e68ef985b4ed5fedd490f122dc51af48011bc0860b56165be", + "type": "CONTRACT_MESSAGE", + "appType": "GroupChange", + "action": "members.set" + }, + { + "label": "change-signers-set", + "hash": "8d85db5fe69525facc2b91e1bcdeccededcf762b1e7a0f86654bccedc7885cfa", + "type": "CONTRACT_MESSAGE", + "appType": "GroupChange", + "action": "signers.set" + }, + { + "label": "change-update", + "hash": "262a7bfa293a563bd1f120c26a1222c0b0eaea077aca108554b1a3e546c654e5", + "type": "CONTRACT_MESSAGE", + "appType": "GroupChange", + "action": "update" + }, + { + "label": "proposal-add-carol", + "hash": "839c4a39dad1dae4dec56d45b3e64910834989a123448863bc5a1c66e2a218f0", + "type": "CONTRACT_MESSAGE", + "appType": "GroupChangeProposal", + "action": "member.add" + }, + { + "label": "vote-bob", + "hash": "622c9bb6e1adbff7cbd932fcc45f660932c8165230b06b1bc7acac71095e7c75", + "type": "CONTRACT_MESSAGE", + "appType": "GroupChangeVote", + "action": null + }, + { + "label": "chat-bob", + "hash": "bd92d2cc6c160a2af91afd94d464ea6151f23b570a6bea6bdadbb110533562a7", + "type": "CONTRACT_MESSAGE", + "appType": "GroupChat", + "action": null + }, + { + "label": "journal-request", + "hash": "146ffc9c3acc277588a0a21cb1df59ede207064ba759316917063907d0adf9a7", + "type": "CONTRACT_MESSAGE", + "appType": "GroupJournalRequest", + "action": null + } + ] +} diff --git a/tests/fixtures/filesystem/.fabric/STATE.json b/tests/fixtures/filesystem/.fabric/STATE.json index e13b26cd5..6707c9dd2 100644 --- a/tests/fixtures/filesystem/.fabric/STATE.json +++ b/tests/fixtures/filesystem/.fabric/STATE.json @@ -1,7 +1 @@ -{ - "files": [ - "SAMPLE.md" - ], - "parent": null, - "status": "STARTED" -} \ No newline at end of file +{"files":["SAMPLE.md"],"parent":null,"status":"STARTED"} \ No newline at end of file diff --git a/tests/functions.bip39.js b/tests/functions.bip39.js index f7386eaca..044305dc1 100644 --- a/tests/functions.bip39.js +++ b/tests/functions.bip39.js @@ -114,3 +114,357 @@ describe('@fabric/core/functions/bip39', function () { } }); }); + +const { fromBase58 } = require('../functions/bip32'); +const { + BIP85_PURPOSE, + bip85Path, + entropyFromPrivateKey, + truncateEntropy, + deriveBip85Entropy, + deriveBip85Mnemonic, + deriveBip85Hex, + deriveBip85Xprv, + deriveBip85Wif +} = require('../functions/bip85'); + +const MASTER_XPRV = 'xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb'; + +describe('@fabric/core/functions/bip85', function () { + it('uses purpose 83696968 for BIP-85 paths', function () { + assert.strictEqual(BIP85_PURPOSE, 83696968); + assert.strictEqual(bip85Path(0, [0]), "m/83696968'/0'/0'"); + }); + + it('matches BIP-85 test case 1 (path m/83696968\'/0\'/0\')', function () { + const got = deriveBip85Entropy(MASTER_XPRV, "m/83696968'/0'/0'"); + assert.strictEqual( + got.derivedKeyHex, + 'cca20ccb0e9a90feb0912870c3323b24874b0ca3d8018c4b96d0b97c0e82ded0' + ); + assert.strictEqual( + got.entropy.toString('hex'), + 'efecfbccffea313214232d29e71563d941229afb4338c21f9517c41aaa0d16f00b83d2a09ef747e7a64e8e2bd5a14869e693da66ce94ac2da570ab7ee48618f7' + ); + }); + + it('matches BIP-85 test case 2 (path m/83696968\'/0\'/1\')', function () { + const got = deriveBip85Entropy(MASTER_XPRV, "m/83696968'/0'/1'"); + assert.strictEqual( + got.derivedKeyHex, + '503776919131758bb7de7beb6c0ae24894f4ec042c26032890c29359216e21ba' + ); + assert.strictEqual( + got.entropy.toString('hex'), + '70c6e3e8ebee8dc4c0dbba66076819bb8c09672527c4277ca8729532ad711872218f826919f6b67218adde99018a6df9095ab2b58d803b5b93ec9802085a690e' + ); + }); + + it('derives the BIP-39 12-word English mnemonic vector', function () { + const got = deriveBip85Mnemonic(MASTER_XPRV, { language: 0, words: 12, index: 0 }); + assert.strictEqual(got.path, "m/83696968'/39'/0'/12'/0'"); + assert.strictEqual(got.entropy.toString('hex'), '6250b68daf746d12a24d58b4787a714b'); + assert.strictEqual( + got.mnemonic, + 'girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose' + ); + }); + + it('derives the BIP-39 18-word English mnemonic vector', function () { + const got = deriveBip85Mnemonic(MASTER_XPRV, { words: 18, index: 0 }); + assert.strictEqual(got.entropy.toString('hex'), '938033ed8b12698449d4bbca3c853c66b293ea1b1ce9d9dc'); + assert.strictEqual( + got.mnemonic, + 'near account window bike charge season chef number sketch tomorrow excuse sniff circle vital hockey outdoor supply token' + ); + }); + + it('derives the BIP-39 24-word English mnemonic vector', function () { + const got = deriveBip85Mnemonic(MASTER_XPRV, { words: 24, index: 0 }); + assert.strictEqual( + got.entropy.toString('hex'), + 'ae131e2312cdc61331542efe0d1077bac5ea803adf24b313a4f0e48e9c51f37f' + ); + assert.strictEqual( + got.mnemonic, + 'puppy ocean match cereal symbol another shed magic wrap hammer bulb intact gadget divorce twin tonight reason outdoor destroy simple truth cigar social volcano' + ); + }); + + it('derives the HD-seed WIF vector', function () { + const got = deriveBip85Wif(MASTER_XPRV, { index: 0 }); + assert.strictEqual(got.path, "m/83696968'/2'/0'"); + assert.strictEqual( + got.entropy.toString('hex'), + '7040bb53104f27367f317558e78a994ada7296c6fde36a364e5baf206e502bb1' + ); + assert.strictEqual(got.wif, 'Kzyv4uF39d4Jrw2W7UryTHwZr1zQVNk4dAFyqE6BuMrMh1Za7uhp'); + }); + + it('derives the XPRV application vector', function () { + const got = deriveBip85Xprv(MASTER_XPRV, { index: 0 }); + assert.strictEqual(got.path, "m/83696968'/32'/0'"); + assert.strictEqual( + got.xprv, + 'xprv9s21ZrQH143K2srSbCSg4m4kLvPMzcWydgmKEnMmoZUurYuBuYG46c6P71UGXMzmriLzCCBvKQWBUv3vPB3m1SATMhp3uEjXHJ42jFg7myX' + ); + }); + + it('derives the HEX 64-byte application vector', function () { + const got = deriveBip85Hex(MASTER_XPRV, { numBytes: 64, index: 0 }); + assert.strictEqual(got.path, "m/83696968'/128169'/64'/0'"); + assert.strictEqual( + got.entropy.toString('hex'), + '492db4698cf3b73a5a24998aa3e9d7fa96275d85724a91e71aa2d645442f878555d078fd1f1f67e368976f04137b1f7a0d19232136ca50c44614af72b5582a5c' + ); + }); + + it('rejects non-mnemonic word counts', function () { + assert.throws(() => deriveBip85Mnemonic(MASTER_XPRV, { words: 13 }), /12, 15, 18, 21, or 24/); + }); + + it('accepts an HDNode root and rejects xpub / bad paths', function () { + const root = fromBase58(MASTER_XPRV); + const fromNode = deriveBip85Mnemonic(root, { words: 12, index: 0 }); + const fromXprv = deriveBip85Mnemonic(MASTER_XPRV, { words: 12, index: 0 }); + assert.strictEqual(fromNode.mnemonic, fromXprv.mnemonic); + assert.throws(() => deriveBip85Entropy(root.neutered(), "m/83696968'/0'/0'"), /private/); + assert.throws(() => deriveBip85Entropy(MASTER_XPRV, "83696968'/0'/0'"), /absolute/); + assert.throws(() => deriveBip85Entropy(null, "m/83696968'/0'/0'"), /HDNode or xprv/); + }); + + it('enforces HEX length and private-key / truncate bounds', function () { + const hex16 = deriveBip85Hex(MASTER_XPRV, { numBytes: 16, index: 0 }); + assert.strictEqual(hex16.path, "m/83696968'/128169'/16'/0'"); + assert.strictEqual(hex16.entropy.length, 16); + assert.throws(() => deriveBip85Hex(MASTER_XPRV, { numBytes: 15 }), /16–64/); + assert.throws(() => entropyFromPrivateKey(Buffer.alloc(31)), /32 bytes/); + assert.throws(() => truncateEntropy(Buffer.alloc(64), 0), /1–64/); + const full = deriveBip85Entropy(MASTER_XPRV, "m/83696968'/0'/0'"); + assert.ok(truncateEntropy(full.entropy, 16).equals(full.entropy.subarray(0, 16))); + }); +}); + +const { + sortInputs, + sortOutputs, + sortTransaction, + compareInputs, + prevoutHashDisplay +} = require('../functions/bip69'); + +/** BIP-69 example tx 0a6a357e… — display-order prevouts. */ +const EXAMPLE_1_INPUTS = [ + ['0e53ec5dfb2cb8a71fec32dc9a634a35b7e24799295ddd5278217822e0b31f57', 0], + ['26aa6e6d8b9e49bb0630aac301db6757c02e3619feb4ee0eea81eb1672947024', 1], + ['28e0fdd185542f2c6ea19030b0796051e7772b6026dd5ddccd7a2f93b73e6fc2', 0], + ['381de9b9ae1a94d9c17f6a08ef9d341a5ce29e2e60c36a52d333ff6203e58d5d', 1], + ['3b8b2f8efceb60ba78ca8bba206a137f14cb5ea4035e761ee204302d46b98de2', 0], + ['402b2c02411720bf409eff60d05adad684f135838962823f3614cc657dd7bc0a', 1], + ['54ffff182965ed0957dba1239c27164ace5a73c9b62a660c74b7b7f15ff61e7a', 1], + ['643e5f4e66373a57251fb173151e838ccd27d279aca882997e005016bb53d5aa', 0], + ['6c1d56f31b2de4bfc6aaea28396b333102b1f600da9c6d6149e96ca43f1102b1', 1], + ['7a1de137cbafb5c70405455c49c5104ca3057a1f1243e6563bb9245c9c88c191', 0], + ['7d037ceb2ee0dc03e82f17be7935d238b35d1deabf953a892a4507bfbeeb3ba4', 1], + ['a5e899dddb28776ea9ddac0a502316d53a4a3fca607c72f66c470e0412e34086', 0], + ['b4112b8f900a7ca0c8b0e7c4dfad35c6be5f6be46b3458974988e1cdb2fa61b8', 0], + ['bafd65e3c7f3f9fdfdc1ddb026131b278c3be1af90a4a6ffa78c4658f9ec0c85', 0], + ['de0411a1e97484a2804ff1dbde260ac19de841bebad1880c782941aca883b4e9', 1], + ['f0a130a84912d03c1d284974f563c5949ac13f8342b8112edff52971599e6a45', 0], + ['f320832a9d2e2452af63154bc687493484a0e7745ebd3aaf9ca19eb80834ad60', 0] +]; + +describe('@fabric/core/functions/bip69', function () { + it('sorts BIP-69 example 1 inputs (display txid then vout)', function () { + const shuffled = EXAMPLE_1_INPUTS.slice().reverse().map(([txid, vout]) => ({ txid, vout })); + const sorted = sortInputs(shuffled); + assert.strictEqual(sorted.length, EXAMPLE_1_INPUTS.length); + for (let i = 0; i < EXAMPLE_1_INPUTS.length; i++) { + assert.strictEqual(sorted[i].txid, EXAMPLE_1_INPUTS[i][0]); + assert.strictEqual(sorted[i].vout, EXAMPLE_1_INPUTS[i][1]); + } + }); + + it('sorts same-txid inputs by vout (BIP-69 example 2)', function () { + const txid = '35288d269cee1941eaebb2ea85e32b42cdb2b04284a56d8b14dcc3f5c65d6055'; + const sorted = sortInputs([{ txid, vout: 1 }, { txid, vout: 0 }]); + assert.strictEqual(sorted[0].vout, 0); + assert.strictEqual(sorted[1].vout, 1); + }); + + it('sorts outputs by amount then scriptPubKey (BIP-69 example 1)', function () { + const a = { + value: 40000000000, + script: Buffer.from('76a9145be32612930b8323add2212a4ec03c1562084f8488ac', 'hex') + }; + const b = { + value: 400057456, + script: Buffer.from('76a9144a5fba237213a062f6f57978f796390bdcf8d01588ac', 'hex') + }; + const sorted = sortOutputs([a, b]); + assert.strictEqual(sorted[0].value, 400057456); + assert.strictEqual(sorted[1].value, 40000000000); + }); + + it('breaks output ties on scriptPubKey bytes', function () { + const scriptA = Buffer.from('76a91400', 'hex'); + const scriptB = Buffer.from('76a914ff', 'hex'); + const sorted = sortOutputs([ + { value: 1000, script: scriptB }, + { value: 1000, script: scriptA } + ]); + assert.ok(sorted[0].script.equals(scriptA)); + assert.ok(sorted[1].script.equals(scriptB)); + }); + + it('treats bitcoinjs wire hashes as reversed display txids', function () { + const txid = EXAMPLE_1_INPUTS[3][0]; + const wire = Buffer.from(txid, 'hex').reverse(); + const display = prevoutHashDisplay({ hash: wire }); + assert.strictEqual(display.toString('hex'), txid); + const a = { hash: Buffer.from(EXAMPLE_1_INPUTS[1][0], 'hex').reverse(), index: 1 }; + const b = { hash: Buffer.from(EXAMPLE_1_INPUTS[0][0], 'hex').reverse(), index: 0 }; + assert.ok(compareInputs(a, b) > 0); + }); + + it('does not mutate the caller array', function () { + const ins = [ + { txid: EXAMPLE_1_INPUTS[5][0], vout: 1 }, + { txid: EXAMPLE_1_INPUTS[0][0], vout: 0 } + ]; + const copy = ins.slice(); + sortInputs(ins); + assert.strictEqual(ins[0].txid, copy[0].txid); + }); + + it('sorts BIP-69 example 2 outputs by amount', function () { + const high = { + value: 2400000000, + scriptPubKey: Buffer.from('41044a656f065871a353f216ca26cef8dde2f03e8c16202d2e8ad769f02032cb86a5eb5e56842e92e19141d60a01928f8dd2c875a390f67c1f6c94cfc617c0ea45afac', 'hex') + }; + const low = { + amount: 100000000n, + script: Buffer.from('41046a0765b5865641ce08dd39690aade26dfbf5511430ca428a3089261361cef170e3929a68aee3d8d4848b0c5111b0a37b82b86ad559fd2a745b44d8e8d9dfdc0cac', 'hex') + }; + const sorted = sortOutputs([high, low]); + assert.strictEqual(Number(sorted[0].amount), 100000000); + assert.strictEqual(sorted[1].value, 2400000000); + }); + + it('sortTransaction copies ins/outs and accepts bitcoinjs-style hashIsDisplay', function () { + const txid = EXAMPLE_1_INPUTS[0][0]; + const got = sortTransaction({ + ins: [ + { txid: EXAMPLE_1_INPUTS[1][0], vout: 1 }, + { hash: Buffer.from(txid, 'hex'), hashIsDisplay: true, index: 0 } + ], + outputs: [ + { value: 2, script: Buffer.from('51', 'hex') }, + { value: 1, script: Buffer.from('00', 'hex') } + ] + }); + assert.strictEqual(got.ins[0].index, 0); + assert.strictEqual(got.outs[0].value, 1); + const empty = sortTransaction(null); + assert.deepStrictEqual(empty, { ins: [], outs: [] }); + }); + + it('rejects malformed prevouts', function () { + assert.throws(() => sortInputs([{ txid: 'dead', vout: 0 }]), /32-byte hex/); + assert.throws(() => sortInputs([{ txid: EXAMPLE_1_INPUTS[0][0], vout: -1 }]), /uint32/); + assert.throws(() => sortOutputs([{ value: 1 }]), /script/); + assert.throws(() => sortInputs('nope'), /array/); + assert.deepStrictEqual(sortInputs([]), []); + assert.deepStrictEqual(sortOutputs([]), []); + }); +}); + +const { sortPubkeysBip67, comparePubkeysBip67 } = require('../functions/bip67'); +const { parseCompressedPubkeysSorted } = require('../functions/contractTaproot'); + +const KEY_LOW = '02' + '11'.repeat(32); +const KEY_HIGH = '03' + '11'.repeat(32); + +describe('@fabric/core/functions/bip67', function () { + it('sorts compressed pubkeys lexicographically', function () { + const sorted = sortPubkeysBip67([KEY_HIGH, KEY_LOW]); + assert.strictEqual(sorted[0].toString('hex'), KEY_LOW); + assert.strictEqual(sorted[1].toString('hex'), KEY_HIGH); + assert.ok(comparePubkeysBip67(KEY_LOW, KEY_HIGH) < 0); + }); + + it('accepts Buffer keys and does not mutate the input array', function () { + const a = Buffer.from(KEY_HIGH, 'hex'); + const b = Buffer.from(KEY_LOW, 'hex'); + const input = [a, b]; + const sorted = sortPubkeysBip67(input); + assert.strictEqual(input[0], a); + assert.ok(sorted[0].equals(b)); + }); + + it('is the sort used by contractTaproot.parseCompressedPubkeysSorted', function () { + const keys = [KEY_HIGH, KEY_LOW, KEY_HIGH]; + const parsed = parseCompressedPubkeysSorted(keys); + assert.strictEqual(parsed.length, 2); + assert.strictEqual(parsed[0].toString('hex'), KEY_LOW); + assert.strictEqual(parsed[1].toString('hex'), KEY_HIGH); + }); + + it('rejects uncompressed or truncated keys', function () { + assert.throws(() => sortPubkeysBip67(['04' + '11'.repeat(64)]), /compressed/); + assert.throws(() => sortPubkeysBip67(['02aabb']), /compressed/); + assert.throws(() => sortPubkeysBip67(null), /array/); + assert.throws(() => sortPubkeysBip67([1]), /Buffer, Uint8Array, or hex/); + }); + + it('accepts 0x-prefixed hex and Uint8Array keys', function () { + const u8 = Uint8Array.from(Buffer.from(KEY_HIGH, 'hex')); + const sorted = sortPubkeysBip67(['0x' + KEY_LOW, u8]); + assert.strictEqual(sorted[0].toString('hex'), KEY_LOW); + assert.strictEqual(sorted[1].toString('hex'), KEY_HIGH); + }); +}); + +const bitcoin = require('bitcoinjs-lib'); +const { networks } = bitcoin; +const bip32Module = require('../functions/bip32'); +const BIP32 = bip32Module.default; +const ecc = require('../types/ecc'); +const { + BITCOIN_KEY_DERIVATION_PATH, + bitcoinBip49ReceiveDerivationPath +} = require('../constants'); +const { p2shP2wpkhAddress, p2shP2wpkhPayment } = require('../functions/bip69'); + +describe('@fabric/core/functions/bip49', function () { + it('keeps default funds path on BIP-44', function () { + assert.strictEqual(BITCOIN_KEY_DERIVATION_PATH, "m/44'/0'/0'/0/0"); + assert.notStrictEqual(bitcoinBip49ReceiveDerivationPath(), BITCOIN_KEY_DERIVATION_PATH); + }); + + it('matches the published BIP-49 testnet vector (m/49\'/1\'/0\'/0/0)', function () { + const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + const seed = mnemonicToSeedSync(mnemonic); + const root = new BIP32(ecc).fromSeed(seed); + const child = root.derivePath("m/49'/1'/0'/0/0"); + const pubkey = Buffer.from(child.publicKey); + assert.strictEqual( + pubkey.toString('hex'), + '03a1af804ac108a8a51782198c2d034b28bf90c8803f5a53f76276fa69a4eae77f' + ); + const addr = p2shP2wpkhAddress({ + pubkey, + network: networks.testnet + }); + assert.strictEqual(addr, '2Mww8dCYPUpKHofjgcXcBCEGmniw9CoaiD2'); + const pay = p2shP2wpkhPayment({ pubkey, network: networks.bitcoin }); + assert.ok(pay.output && pay.output.length === 23); + }); + + it('rejects uncompressed pubkeys', function () { + assert.throws( + () => p2shP2wpkhAddress({ pubkey: Buffer.alloc(65, 4) }), + /compressed/ + ); + }); +}); diff --git a/tests/functions.cliPasswordArgv.js b/tests/functions.cliPasswordArgv.js deleted file mode 100644 index 160d7a48a..000000000 --- a/tests/functions.cliPasswordArgv.js +++ /dev/null @@ -1,63 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const { readCliPasswordFromArgv } = require('../functions/cliPasswordArgv'); - -describe('@fabric/core/functions/cliPasswordArgv', function () { - it('reads --password=VALUE and --password VALUE', function () { - assert.strictEqual(readCliPasswordFromArgv(['node', 'fabric', '--password=secret=ok'], {}), 'secret=ok'); - assert.strictEqual( - readCliPasswordFromArgv(['node', 'fabric', '--password', 'separate'], {}), - 'separate' - ); - }); - - it('does not consume a following long option as the password', function () { - assert.strictEqual( - readCliPasswordFromArgv(['--password', '--force'], {}), - '' - ); - }); - - it('accepts a separate value that begins with a single dash', function () { - assert.strictEqual( - readCliPasswordFromArgv(['--password', '-secret'], {}), - '-secret' - ); - }); - - it('treats explicit --password= as empty (no env fallback)', function () { - assert.strictEqual( - readCliPasswordFromArgv(['--password='], { FABRIC_PASSWORD: 'from-env' }), - '' - ); - }); - - it('stops option scanning at a standalone -- terminator', function () { - assert.strictEqual( - readCliPasswordFromArgv( - ['node', 'fabric', '--', '--password=positional'], - { FABRIC_PASSWORD: 'from-env' } - ), - 'from-env' - ); - }); - - it('does not fall back to env when --password is present without a value', function () { - assert.strictEqual( - readCliPasswordFromArgv(['--password'], { FABRIC_PASSWORD: 'from-env' }), - '' - ); - }); - - it('falls back to FABRIC_PASSWORD only (not generic PASSWORD)', function () { - assert.strictEqual( - readCliPasswordFromArgv([], { FABRIC_PASSWORD: 'from-env', PASSWORD: 'generic' }), - 'from-env' - ); - assert.strictEqual( - readCliPasswordFromArgv([], { PASSWORD: 'generic' }), - '' - ); - }); -}); diff --git a/tests/functions.documentOfferBook.js b/tests/functions.documentOfferBook.js index 7c4309964..08aa6d77a 100644 --- a/tests/functions.documentOfferBook.js +++ b/tests/functions.documentOfferBook.js @@ -40,6 +40,11 @@ describe('documentOfferBook + blobs + relay', function () { assert.ok(best); // High score + low latency should beat slightly cheaper sybil. assert.ok(best.sellerAddress === 'a:1' || best.sellerAddress === 'c:3'); + assert.ok(book.pickBest('missing') == null); + const cheap = book.pickBest('doc1', { maxSats: 95 }); + assert.ok(cheap && cheap.sellerAddress === 'b:2'); + const excluded = book.pickBest('doc1', { excludeSellers: ['b:2', 'a:1'] }); + assert.ok(excluded && excluded.sellerAddress === 'c:3'); }); it('prefers high peerScore over equal-price zero-score sybil', function () { @@ -229,4 +234,48 @@ describe('documentOfferBook + blobs + relay', function () { assert.strictEqual(fee.feeSats, 10); assert.ok(hubList - origin >= fee.feeSats, 'republish spread should cover a same-bps hop fee'); }); + + it('does not keep peer contentBase64 or costBasis on ranked offers', function () { + const book = new DocumentOfferBook(); + book.ingestInventoryResponse({ + origin: 'seller:1', + peerScore: 400, + latencyMs: 20, + items: [{ + id: 'doc-leak', + rateSats: 25, + contentHash: '11'.repeat(32), + contentBase64: Buffer.from('secret').toString('base64'), + costBasisSats: 1 + }] + }); + const offers = book.listOffers({ documentId: 'doc-leak' }); + assert.strictEqual(offers.length, 1); + assert.strictEqual(offers[0].rateSats, 25); + assert.strictEqual(offers[0].contentBase64, undefined); + assert.strictEqual(offers[0].costBasisSats, undefined); + }); + + it('skips inventory items without an id and filters listOffers by blobIndex', function () { + const book = new DocumentOfferBook(); + book.ingestInventoryResponse({ + origin: 'seller:1', + items: [ + { rateSats: 1 }, + { + id: 'blob-doc', + rateSats: 40, + contentHash: '22'.repeat(32), + blobs: [ + { index: 0, total: 2, blobHashHex: 'b0'.repeat(32), rateSats: 10 }, + { index: 1, total: 2, blobHashHex: 'b1'.repeat(32), rateSats: 30 } + ] + } + ] + }); + assert.strictEqual(book.listOffers({ documentId: '' }).filter((o) => !o.documentId).length, 0); + assert.strictEqual(book.listOffers({ documentId: 'blob-doc' }).length, 2); + assert.strictEqual(book.listOffers({ documentId: 'blob-doc', blobIndex: 1 }).length, 1); + assert.strictEqual(book.listOffers({ documentId: 'blob-doc', blobIndex: 1 })[0].rateSats, 30); + }); }); diff --git a/tests/functions.fabricHallmark.js b/tests/functions.fabricHallmark.js deleted file mode 100644 index ba8b4f830..000000000 --- a/tests/functions.fabricHallmark.js +++ /dev/null @@ -1,147 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const fh = require('../functions/fabricHallmark'); - -describe('@fabric/core/functions/fabricHallmark', function () { - const tip = 'ab'.repeat(32); - const contractId = 'cd'.repeat(32); - const sidechain = { clock: 3, stateDigest: '11'.repeat(32) }; - const contracts = { clock: 1, stateDigest: '22'.repeat(32) }; - - it('derives a stable 32-byte commitment over tip + contract + state digests', function () { - const a = fh.deriveFabricHallmarkCommitmentHex({ - tipBlockHashHex: tip, - contractIdHex: contractId, - sidechain, - contracts - }); - const b = fh.deriveFabricHallmarkCommitmentHex({ - tipBlockHashHex: tip, - contractIdHex: contractId, - sidechain: { clock: 3, stateDigest: '11'.repeat(32) }, - contracts: { clock: 1, stateDigest: '22'.repeat(32) } - }); - assert.strictEqual(a.length, 64); - assert.strictEqual(a, b); - }); - - it('changes commitment when sidechain stateDigest changes', function () { - const base = fh.deriveFabricHallmarkCommitmentHex({ - tipBlockHashHex: tip, - contractIdHex: contractId, - sidechain, - contracts - }); - const next = fh.deriveFabricHallmarkCommitmentHex({ - tipBlockHashHex: tip, - contractIdHex: contractId, - sidechain: { clock: 3, stateDigest: '33'.repeat(32) }, - contracts - }); - assert.notStrictEqual(base, next); - }); - - it('ignores balance-like fields not in the commitment schema', function () { - const a = fh.deriveFabricHallmarkCommitmentHex({ - tipBlockHashHex: tip, - contractIdHex: contractId, - sidechain, - contracts - }); - const b = fh.deriveFabricHallmarkCommitmentHex({ - tipBlockHashHex: tip, - contractIdHex: contractId, - sidechain: Object.assign({ balanceSats: 999 }, sidechain), - contracts: Object.assign({ height: 42 }, contracts) - }); - assert.strictEqual(a, b); - }); - - it('encodes a 40-byte payload with magic + tip suffix + commitment', function () { - const { payload, commitmentHex, tipHashSuffixHex } = fh.encodeFabricHallmarkFromState({ - tipBlockHashHex: tip, - contractIdHex: contractId, - sidechain, - contracts - }); - assert.strictEqual(payload.length, fh.HALLMARK_PAYLOAD_LENGTH); - assert.strictEqual(payload.subarray(0, 4).toString('hex'), fh.HALLMARK_MAGIC_HEX); - assert.strictEqual(tipHashSuffixHex, tip.slice(-8)); - assert.strictEqual(payload.subarray(4, 8).toString('hex'), tipHashSuffixHex); - assert.strictEqual(payload.subarray(8, 40).toString('hex'), commitmentHex); - }); - - it('round-trips decode and verify', function () { - const { payload, commitmentHex } = fh.encodeFabricHallmarkFromState({ - tipBlockHashHex: tip, - contractIdHex: contractId, - sidechain, - contracts - }); - const decoded = fh.decodeFabricHallmark(payload); - assert.ok(decoded); - assert.strictEqual(decoded.commitmentHex, commitmentHex); - assert.strictEqual(fh.verifyFabricHallmark(payload, { tipBlockHashHex: tip, commitmentHex }), true); - assert.strictEqual(fh.verifyFabricHallmark(payload.toString('hex'), { tipBlockHashHex: tip }), true); - }); - - it('rejects wrong magic, length, or tip suffix', function () { - const { payload, commitmentHex } = fh.encodeFabricHallmarkFromState({ - tipBlockHashHex: tip, - contractIdHex: contractId, - sidechain, - contracts - }); - const badMagic = Buffer.from(payload); - badMagic[0] = 0xff; - assert.strictEqual(fh.decodeFabricHallmark(badMagic), null); - assert.strictEqual(fh.decodeFabricHallmark(payload.subarray(0, 20)), null); - - const otherTip = 'ef'.repeat(32); - assert.strictEqual( - fh.verifyFabricHallmark(payload, { tipBlockHashHex: otherTip, commitmentHex }), - false - ); - }); - - it('tipHashSuffixFromBlockHash takes the last four bytes', function () { - const hash = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff'; - assert.strictEqual(fh.tipHashSuffixFromBlockHash(hash).toString('hex'), 'ccddeeff'); - }); - - it('omits malformed stateDigest from the commitment (same as absent)', function () { - const withValid = fh.deriveFabricHallmarkCommitmentHex({ - tipBlockHashHex: tip, - contractIdHex: contractId, - sidechain, - contracts - }); - const withBad = fh.deriveFabricHallmarkCommitmentHex({ - tipBlockHashHex: tip, - contractIdHex: contractId, - sidechain: { clock: 3, stateDigest: 'not-a-digest' }, - contracts - }); - const without = fh.deriveFabricHallmarkCommitmentHex({ - tipBlockHashHex: tip, - contractIdHex: contractId, - sidechain: { clock: 3 }, - contracts - }); - assert.notStrictEqual(withValid, withBad); - assert.strictEqual(withBad, without); - }); - - it('rejects non-hex and wrong-length tip hashes without a dynamic RegExp', function () { - assert.throws(() => fh.tipHashBytesFromBlockHash('zz'.repeat(32)), /expected 64 hex chars/); - assert.throws(() => fh.tipHashBytesFromBlockHash('ab'.repeat(31)), /expected 64 hex chars/); - assert.throws(() => fh.tipHashBytesFromBlockHash(''), /expected 64 hex chars/); - }); - - it('exports a copy of HALLMARK_MAGIC (mutation-safe)', function () { - const a = fh.HALLMARK_MAGIC; - a[0] = 0xff; - assert.strictEqual(fh.HALLMARK_MAGIC.toString('hex'), fh.HALLMARK_MAGIC_HEX); - }); -}); diff --git a/tests/functions.fabricSetup.js b/tests/functions.fabricSetup.js deleted file mode 100644 index 4113b9837..000000000 --- a/tests/functions.fabricSetup.js +++ /dev/null @@ -1,479 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const fs = require('fs'); -const os = require('os'); -const path = require('path'); - -const Environment = require('../types/environment'); -const SetupTui = require('../types/setup'); -const fabricSetup = require('../functions/fabricSetup'); - -const TEST_PASSWORD = 'test-pass-ok!'; - -function isolatedEnv () { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-setup-')); - const store = path.join(home, '.fabric'); - const walletPath = path.join(store, 'wallet.json'); - const environment = new Environment({ home, path: walletPath, store, lockTimeoutMinutes: 0 }); - environment.start(); - return { home, store, walletPath, environment }; -} - -function withEnv (overrides, fn) { - const keys = Object.keys(overrides); - const prev = {}; - for (const k of keys) { - prev[k] = process.env[k]; - if (overrides[k] === undefined) delete process.env[k]; - else process.env[k] = overrides[k]; - } - try { - return fn(); - } finally { - for (const k of keys) { - if (prev[k] === undefined) delete process.env[k]; - else process.env[k] = prev[k]; - } - } -} - -describe('@fabric/core/functions/fabricSetup', function () { - this.timeout(20000); - - let prevNodeEnv; - let prevSeed; - let prevXprv; - let prevXpub; - - beforeEach(function () { - prevNodeEnv = process.env.NODE_ENV; - prevSeed = process.env.FABRIC_SEED; - prevXprv = process.env.FABRIC_XPRV; - prevXpub = process.env.FABRIC_XPUB; - process.env.NODE_ENV = 'production'; - delete process.env.FABRIC_SEED; - delete process.env.FABRIC_XPRV; - delete process.env.FABRIC_XPUB; - }); - - afterEach(function () { - if (prevNodeEnv === undefined) delete process.env.NODE_ENV; - else process.env.NODE_ENV = prevNodeEnv; - if (prevSeed === undefined) delete process.env.FABRIC_SEED; - else process.env.FABRIC_SEED = prevSeed; - if (prevXprv === undefined) delete process.env.FABRIC_XPRV; - else process.env.FABRIC_XPRV = prevXprv; - if (prevXpub === undefined) delete process.env.FABRIC_XPUB; - else process.env.FABRIC_XPUB = prevXpub; - }); - - describe('resolveSetupPath', function () { - it('rejects empty, null-byte, and oversize paths', function () { - assert.strictEqual(fabricSetup.resolveSetupPath(''), null); - assert.strictEqual(fabricSetup.resolveSetupPath('foo\0bar'), null); - assert.strictEqual(fabricSetup.resolveSetupPath('x'.repeat(fabricSetup.SETUP_PATH_MAX_LEN + 1)), null); - }); - - it('resolves relative paths against cwd', function () { - const cwd = os.tmpdir(); - const abs = fabricSetup.resolveSetupPath('wallet-backup.json', { cwd }); - assert.strictEqual(abs, path.normalize(path.join(cwd, 'wallet-backup.json'))); - }); - - it('accepts absolute paths', function () { - const abs = path.join(os.tmpdir(), 'fabric-usb', 'wallet.json'); - assert.strictEqual(fabricSetup.resolveSetupPath(abs), path.normalize(abs)); - }); - }); - - describe('shouldLaunchTui', function () { - const tty = { isTTY: true }; - - it('is true on a TTY without one-shot flags', function () { - withEnv({ CI: undefined, FABRIC_SETUP_TUI: undefined }, () => { - assert.strictEqual(fabricSetup.shouldLaunchTui({}, { stdout: tty }), true); - }); - }); - - it('is false without a TTY', function () { - withEnv({ CI: undefined, FABRIC_SETUP_TUI: undefined }, () => { - assert.strictEqual(fabricSetup.shouldLaunchTui({}, { stdout: { isTTY: false } }), false); - assert.strictEqual(fabricSetup.shouldLaunchTui({}, { stdout: {} }), false); - }); - }); - - it('is false for json, force, backup, restore, --no-tui, and CI', function () { - withEnv({ CI: undefined, FABRIC_SETUP_TUI: undefined }, () => { - assert.strictEqual(fabricSetup.shouldLaunchTui({ json: true }, { stdout: tty }), false); - assert.strictEqual(fabricSetup.shouldLaunchTui({ force: true }, { stdout: tty }), false); - assert.strictEqual(fabricSetup.shouldLaunchTui({ backup: true }, { stdout: tty }), false); - assert.strictEqual(fabricSetup.shouldLaunchTui({ backup: '/tmp/w.json' }, { stdout: tty }), false); - assert.strictEqual(fabricSetup.shouldLaunchTui({ unlock: true }, { stdout: tty }), false); - assert.strictEqual(fabricSetup.shouldLaunchTui({ lock: true }, { stdout: tty }), false); - assert.strictEqual(fabricSetup.shouldLaunchTui({ timeout: '15' }, { stdout: tty }), false); - assert.strictEqual(fabricSetup.shouldLaunchTui({ noTui: true }, { stdout: tty }), false); - assert.strictEqual(fabricSetup.shouldLaunchTui({ tui: false }, { stdout: tty }), false); - }); - withEnv({ CI: '1', FABRIC_SETUP_TUI: undefined }, () => { - assert.strictEqual(fabricSetup.shouldLaunchTui({}, { stdout: tty }), false); - }); - withEnv({ CI: undefined, FABRIC_SETUP_TUI: '0' }, () => { - assert.strictEqual(fabricSetup.shouldLaunchTui({}, { stdout: tty }), false); - }); - }); - }); - - describe('snapshot / generate / backup / restore', function () { - it('snapshots a missing wallet without secrets', function () { - const { home, environment } = isolatedEnv(); - try { - const snapshot = fabricSetup.snapshotEnvironment(environment); - assert.strictEqual(snapshot.status, 'missing'); - assert.strictEqual(snapshot.walletLoaded, false); - assert.strictEqual(snapshot.identity, null); - const text = fabricSetup.formatSnapshot(snapshot); - assert.ok(text.includes('missing')); - assert.ok(!/xprv[A-Za-z0-9]{20,}/i.test(text), 'snapshot text must not contain an xprv payload'); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it('generates, snapshots public fields, and refuses a second generate without force', function () { - const { home, environment } = isolatedEnv(); - try { - const created = fabricSetup.generateWallet(environment, { passphrase: '', password: TEST_PASSWORD }); - assert.strictEqual(created.ok, true); - assert.ok(created.seed && created.seed.split(' ').length >= 12); - assert.ok(environment.walletExists()); - - const snapshot = fabricSetup.snapshotEnvironment(environment); - assert.strictEqual(snapshot.status, 'unlocked'); - assert.strictEqual(snapshot.passwordProtected, true); - assert.ok(snapshot.identity && snapshot.identity.startsWith('id')); - assert.ok(snapshot.pubkey); - assert.ok(snapshot.xpub && snapshot.xpub.startsWith('xpub')); - const text = fabricSetup.formatSnapshot(snapshot); - assert.ok(!text.includes(created.seed)); - assert.ok(!/xprv[A-Za-z0-9]/i.test(text)); - - const blocked = fabricSetup.generateWallet(environment); - assert.strictEqual(blocked.ok, false); - assert.ok(/already configured/i.test(blocked.error)); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it('backs up and restores a wallet file', function () { - const { home, store, environment } = isolatedEnv(); - try { - const created = fabricSetup.generateWallet(environment, { password: TEST_PASSWORD }); - assert.strictEqual(created.ok, true); - const identity = fabricSetup.snapshotEnvironment(environment).identity; - const backupPath = path.join(store, 'backups', 'roundtrip.json'); - const backed = fabricSetup.backupWallet(environment, backupPath); - assert.strictEqual(backed.ok, true); - assert.ok(fs.existsSync(backupPath)); - - environment.destroyWallet(); - environment.wallet = false; - environment.walletLocked = false; - environment.walletPublic = null; - - const restored = fabricSetup.restoreWalletFromFile(environment, backupPath, { password: TEST_PASSWORD }); - // file is gone so restore should succeed without force - assert.strictEqual(restored.ok, true); - assert.strictEqual(fabricSetup.snapshotEnvironment(environment).identity, identity); - - environment.destroyWallet(); - environment.wallet = false; - environment.walletLocked = false; - environment.walletPublic = null; - const badPassword = fabricSetup.restoreWalletFromFile(environment, backupPath, { - force: true, - password: 'wrong-password-xx' - }); - assert.strictEqual(badPassword.ok, false); - assert.ok(badPassword.error); - - const again = fabricSetup.restoreWalletFromFile(environment, backupPath); - assert.strictEqual(again.ok, false); - - const forced = fabricSetup.restoreWalletFromFile(environment, backupPath, { force: true, password: TEST_PASSWORD }); - assert.strictEqual(forced.ok, true); - assert.strictEqual(fabricSetup.snapshotEnvironment(environment).identity, identity); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it('restores from a seed phrase', function () { - const { home, environment } = isolatedEnv(); - try { - const created = fabricSetup.generateWallet(environment, { password: TEST_PASSWORD }); - const phrase = created.seed; - const identity = fabricSetup.snapshotEnvironment(environment).identity; - - environment.destroyWallet(); - environment.wallet = false; - environment.walletLocked = false; - environment.walletPublic = null; - - const restored = fabricSetup.restoreWalletFromSeed(environment, phrase, { password: TEST_PASSWORD }); - assert.strictEqual(restored.ok, true); - assert.strictEqual(fabricSetup.snapshotEnvironment(environment).identity, identity); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it('rejects plaintext restore when the encryption password is missing', function () { - const { home, store, environment } = isolatedEnv(); - try { - const created = fabricSetup.generateWallet(environment, { encrypt: false }); - assert.strictEqual(created.ok, true); - const backupPath = path.join(store, 'backups', 'plaintext.json'); - const backed = fabricSetup.backupWallet(environment, backupPath); - assert.strictEqual(backed.ok, true); - - environment.destroyWallet(); - environment.wallet = false; - environment.walletLocked = false; - environment.walletPublic = null; - - const missing = fabricSetup.restoreWalletFromFile(environment, backupPath); - assert.strictEqual(missing.ok, false); - assert.match(String(missing.error), /at least/i); - assert.strictEqual(environment.walletExists(), false); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it('does not print the master xprv even when FABRIC_DEBUG is set', function () { - const warnings = []; - const logs = []; - const origWarn = console.warn; - const origLog = console.log; - console.warn = (...args) => warnings.push(args.join(' ')); - console.log = (...args) => logs.push(args.join(' ')); - try { - withEnv({ FABRIC_DEBUG: '1' }, () => { - fabricSetup.printGeneratedWallet('abandon ability able', { - walletId: 'id1', - xpub: 'xpubPUBLIC', - xprv: 'xprvTESTSECRET' - }); - }); - } finally { - console.warn = origWarn; - console.log = origLog; - } - const text = warnings.concat(logs).join('\n'); - assert.ok(!/xprvTESTSECRET/.test(text)); - assert.ok(/abandon ability able/.test(text)); - }); - - it('rejects an invalid seed phrase', function () { - const { home, environment } = isolatedEnv(); - try { - const result = fabricSetup.restoreWalletFromSeed(environment, 'not a mnemonic at all'); - assert.strictEqual(result.ok, false); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it('reveals seed only on demand', function () { - const { home, environment } = isolatedEnv(); - try { - assert.strictEqual(fabricSetup.revealSecrets(environment).ok, false); - const created = fabricSetup.generateWallet(environment, { password: TEST_PASSWORD }); - const secrets = fabricSetup.revealSecrets(environment); - assert.strictEqual(secrets.ok, true); - assert.strictEqual(secrets.seed, created.seed); - assert.ok(secrets.xprv && secrets.xprv.startsWith('xprv')); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it('refuses generate without an encryption password', function () { - const { home, environment } = isolatedEnv(); - try { - const result = fabricSetup.generateWallet(environment); - assert.strictEqual(result.ok, false); - assert.ok(/password/i.test(result.error)); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it('locks, unlocks, and persists idle timeout', function () { - const { home, environment } = isolatedEnv(); - try { - const created = fabricSetup.generateWallet(environment, { password: TEST_PASSWORD }); - assert.strictEqual(created.ok, true); - assert.strictEqual(fabricSetup.snapshotEnvironment(environment).status, 'unlocked'); - - environment.lockWallet(); - const locked = fabricSetup.snapshotEnvironment(environment); - assert.strictEqual(locked.status, 'locked'); - assert.strictEqual(locked.passwordProtected, true); - assert.ok(locked.xpub && locked.xpub.startsWith('xpub')); - assert.strictEqual(fabricSetup.revealSecrets(environment).ok, false); - - environment.unlockWallet(TEST_PASSWORD); - assert.strictEqual(fabricSetup.snapshotEnvironment(environment).status, 'unlocked'); - assert.strictEqual(fabricSetup.revealSecrets(environment).seed, created.seed); - - environment.setLockTimeoutMinutes(15); - const snap = fabricSetup.snapshotEnvironment(environment); - assert.strictEqual(snap.lockTimeoutMinutes, 15); - const doc = JSON.parse(fs.readFileSync(environment.WALLET_FILE, 'utf8')); - assert.strictEqual(doc.lockTimeoutMinutes, 15); - assert.ok(doc.seal && doc.seal.ciphertext); - assert.ok(!JSON.stringify(doc.object).includes('xprv')); - } finally { - environment.setLockTimeoutMinutes(0); - fs.rmSync(home, { recursive: true, force: true }); - } - }); - }); - - describe('runSetupCli', function () { - it('prints JSON snapshot for --json', async function () { - const { home, environment } = isolatedEnv(); - const logs = []; - const orig = console.log; - console.log = function () { logs.push(Array.from(arguments).join(' ')); }; - try { - const result = await fabricSetup.runSetupCli(environment, { json: true }); - assert.strictEqual(result.ok, true); - assert.strictEqual(result.snapshot.status, 'missing'); - const parsed = JSON.parse(logs.join('\n')); - assert.strictEqual(parsed.status, 'missing'); - assert.ok(!parsed.seed); - assert.ok(!parsed.xprv); - } finally { - console.log = orig; - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it('writes a backup via --backup', async function () { - const { home, store, environment } = isolatedEnv(); - const origLog = console.log; - console.log = function () {}; - try { - const created = fabricSetup.generateWallet(environment, { password: TEST_PASSWORD }); - assert.strictEqual(created.ok, true); - const dest = path.join(store, 'cli-backup.json'); - const result = await fabricSetup.runSetupCli(environment, { backup: dest }); - assert.strictEqual(result.ok, true); - assert.strictEqual(result.path, dest); - assert.ok(fs.existsSync(dest)); - } finally { - console.log = origLog; - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it('rejects a non-numeric --timeout', async function () { - const { home, environment } = isolatedEnv(); - const origErr = console.error; - console.error = function () {}; - try { - const result = await fabricSetup.runSetupCli(environment, { timeout: 'abc' }); - assert.strictEqual(result.ok, false); - assert.match(result.error, /integer/i); - } finally { - console.error = origErr; - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it('accepts integer --timeout including 0', async function () { - const { home, environment } = isolatedEnv(); - const origLog = console.log; - console.log = function () {}; - try { - fabricSetup.generateWallet(environment, { password: TEST_PASSWORD }); - const zero = await fabricSetup.runSetupCli(environment, { timeout: '0' }); - assert.strictEqual(zero.ok, true); - assert.strictEqual(environment.lockSession.timeoutMinutes, 0); - const fifteen = await fabricSetup.runSetupCli(environment, { timeout: 15 }); - assert.strictEqual(fifteen.ok, true); - assert.strictEqual(environment.lockSession.timeoutMinutes, 15); - } finally { - console.log = origLog; - fs.rmSync(home, { recursive: true, force: true }); - } - }); - }); - - describe('applyWalletFileOption', function () { - it('fails closed on an invalid path', function () { - const { home, environment } = isolatedEnv(); - try { - const result = fabricSetup.applyWalletFileOption(environment, 'bad\0path.json'); - assert.strictEqual(result.ok, false); - assert.match(result.error, /Invalid --wallet path/i); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } - }); - }); -}); - -describe('@fabric/core/types/setup', function () { - this.timeout(20000); - - it('returns a public snapshot when render is false', async function () { - const prev = { - NODE_ENV: process.env.NODE_ENV, - FABRIC_SEED: process.env.FABRIC_SEED, - FABRIC_XPRV: process.env.FABRIC_XPRV, - FABRIC_XPUB: process.env.FABRIC_XPUB - }; - process.env.NODE_ENV = 'production'; - delete process.env.FABRIC_SEED; - delete process.env.FABRIC_XPRV; - delete process.env.FABRIC_XPUB; - const { home, environment } = isolatedEnv(); - try { - fabricSetup.generateWallet(environment, { password: TEST_PASSWORD }); - const ui = new SetupTui(environment, { render: false }); - const snapshot = await ui.start(); - assert.strictEqual(snapshot.status, 'unlocked'); - assert.ok(snapshot.identity && snapshot.identity.startsWith('id')); - assert.ok(!snapshot.seed); - } finally { - for (const k of Object.keys(prev)) { - if (prev[k] === undefined) delete process.env[k]; - else process.env[k] = prev[k]; - } - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it('compares encryption passwords in constant time', function () { - assert.ok(fabricSetup.passwordsMatch('same-secret', 'same-secret')); - assert.ok(!fabricSetup.passwordsMatch('same-secret', 'same-secreT')); - assert.ok(!fabricSetup.passwordsMatch('short', 'longer-than-short')); - assert.ok(!fabricSetup.passwordsMatch(null, 'x')); - assert.ok(fabricSetup.passwordsMatch(null, null)); - }); - - it('fails closed on an invalid --wallet path before opening the TUI', async function () { - const { home, environment } = isolatedEnv(); - try { - const ui = new SetupTui(environment, { render: false, walletFile: 'bad\0path.json' }); - await assert.rejects(() => ui.start(), /Invalid --wallet path/); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } - }); -}); diff --git a/tests/functions.identityLock.js b/tests/functions.identityLock.js deleted file mode 100644 index 27f0f4380..000000000 --- a/tests/functions.identityLock.js +++ /dev/null @@ -1,105 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const { - IdentityLock, - clampLockTimeoutMinutes, - lockTimeoutMinutesToMs, - DEFAULT_LOCK_TIMEOUT_MINUTES, - MAX_LOCK_TIMEOUT_MINUTES -} = require('../functions/identityLock'); - -function fakeClock () { - let now = 1_000_000; - const timers = new Map(); - let nextId = 1; - return { - now: () => now, - advance (ms) { - now += ms; - for (const [id, t] of [...timers.entries()]) { - if (t.fireAt <= now) { - timers.delete(id); - t.fn(); - } - } - }, - setTimeout (fn, ms) { - const id = nextId++; - timers.set(id, { fn, fireAt: now + ms }); - return id; - }, - clearTimeout (id) { - timers.delete(id); - } - }; -} - -describe('@fabric/core/functions/identityLock', function () { - it('clamps timeout minutes (0 disables)', function () { - assert.strictEqual(clampLockTimeoutMinutes(0), 0); - assert.strictEqual(clampLockTimeoutMinutes(-3), DEFAULT_LOCK_TIMEOUT_MINUTES); - assert.strictEqual(clampLockTimeoutMinutes(99999), MAX_LOCK_TIMEOUT_MINUTES); - assert.strictEqual(lockTimeoutMinutesToMs(0), 0); - assert.strictEqual(lockTimeoutMinutesToMs(1), 60 * 1000); - }); - - it('holds a payload only while unlocked', function () { - const lock = new IdentityLock({ timeoutMinutes: 0 }); - assert.strictEqual(lock.locked, true); - lock.unlock({ xprv: 'secret' }); - assert.strictEqual(lock.locked, false); - assert.deepStrictEqual(lock.peek(), { xprv: 'secret' }); - lock.lock(); - assert.strictEqual(lock.locked, true); - assert.strictEqual(lock.peek(), null); - }); - - it('auto-locks after idle timeout and resets on touch', function () { - const clock = fakeClock(); - const lock = new IdentityLock({ - timeoutMs: 50, - now: clock.now, - setTimeout: clock.setTimeout, - clearTimeout: clock.clearTimeout - }); - const events = []; - lock.on('lock', () => events.push('lock')); - lock.on('unlock', () => events.push('unlock')); - lock.on('touch', () => events.push('touch')); - - lock.unlock({ ok: true }); - clock.advance(40); - assert.strictEqual(lock.locked, false); - lock.touch(); - clock.advance(40); - assert.strictEqual(lock.locked, false, 'touch should reset the idle timer'); - clock.advance(20); - assert.strictEqual(lock.locked, true); - assert.deepStrictEqual(events, ['unlock', 'touch', 'lock']); - }); - - it('setTimeoutMinutes(0) disables auto-lock', function () { - const clock = fakeClock(); - const lock = new IdentityLock({ - timeoutMs: 10, - now: clock.now, - setTimeout: clock.setTimeout, - clearTimeout: clock.clearTimeout - }); - lock.unlock({ ok: true }); - lock.setTimeoutMinutes(0); - clock.advance(1000); - assert.strictEqual(lock.locked, false); - assert.strictEqual(lock.timeoutMinutes, 0); - }); - - it('unref()s the idle timer so Node can exit', function () { - const lock = new IdentityLock({ timeoutMinutes: 30 }); - lock.unlock({ ok: true }); - assert.ok(lock._timer); - assert.strictEqual(typeof lock._timer.unref, 'function'); - assert.strictEqual(lock._timer.hasRef(), false); - lock.lock(); - }); -}); diff --git a/tests/functions.inventoryHtlc.js b/tests/functions.inventoryHtlc.js index 263fdb1ed..f38b38818 100644 --- a/tests/functions.inventoryHtlc.js +++ b/tests/functions.inventoryHtlc.js @@ -6,6 +6,13 @@ const bitcoin = require('bitcoinjs-lib'); const ecc = require('../types/ecc'); const inventoryHtlc = require('../functions/inventoryHtlc'); const publishedDocumentEnvelope = require('../functions/publishedDocumentEnvelope'); +const { + BIP125_SEQUENCE_FINAL, + BIP125_SEQUENCE_LOCKTIME_ONLY, + BIP125_SEQUENCE_RBF, + sequenceSignalsOptInRbf, + transactionSignalsOptInRbf +} = require('../functions/bip125'); bitcoin.initEccLib(ecc); @@ -173,5 +180,209 @@ describe('functions/inventoryHtlc', function () { assert.ok(txHex.length > 100); const tx = bitcoin.Transaction.fromHex(txHex); assert.strictEqual(tx.locktime, lock); + assert.strictEqual(tx.ins[0].sequence, BIP125_SEQUENCE_LOCKTIME_ONLY); + assert.strictEqual(sequenceSignalsOptInRbf(tx.ins[0].sequence), false); + }); +}); + +const { encodeBitcoinUri, parseBitcoinUri } = require('../functions/bip21'); + +describe('@fabric/core/functions/bip21', function () { + it('encodes amount and label like inventory HTLC funding hints', function () { + const uri = encodeBitcoinUri({ + address: 'bcrt1pv25g0waah5e7rhkdcrmnanexxsng93uew5xe8tzzpuq5ckkwzltsalzjxr', + amount: '0.00100000', + label: 'Test doc' + }); + assert.ok(uri.startsWith('bitcoin:bcrt1')); + assert.ok(uri.includes('amount=0.00100000')); + const parsed = parseBitcoinUri(uri); + assert.strictEqual(parsed.address, 'bcrt1pv25g0waah5e7rhkdcrmnanexxsng93uew5xe8tzzpuq5ckkwzltsalzjxr'); + assert.strictEqual(parsed.amount, '0.00100000'); + assert.strictEqual(parsed.label, 'Test doc'); + }); + + it('round-trips opaque BIP-321 extras (lightning, pj)', function () { + const uri = encodeBitcoinUri({ + address: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + extras: { lightning: 'lnbc1qq', pj: 'https://example.test/pj' } + }); + const parsed = parseBitcoinUri(uri); + assert.strictEqual(parsed.extras.lightning, 'lnbc1qq'); + assert.strictEqual(parsed.extras.pj, 'https://example.test/pj'); + }); + + it('is used by buildHtlcFundingHints', function () { + const h = inventoryHtlc.buildHtlcFundingHints({ + paymentAddress: 'bcrt1pv25g0waah5e7rhkdcrmnanexxsng93uew5xe8tzzpuq5ckkwzltsalzjxr', + amountSats: 100000, + label: 'Test doc' + }); + const parsed = parseBitcoinUri(h.bitcoinUri); + assert.strictEqual(parsed.amount, '0.00100000'); + assert.strictEqual(parsed.label, 'Test doc'); + }); + + it('parses official BIP-21 examples (address, amount, label, message)', function () { + const addr = '175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W'; + const bare = parseBitcoinUri(`bitcoin:${addr}`); + assert.strictEqual(bare.address, addr); + assert.strictEqual(bare.amount, null); + const paid = parseBitcoinUri(`bitcoin:${addr}?amount=20.3&label=Luke-Jr`); + assert.strictEqual(paid.amount, '20.3'); + assert.strictEqual(paid.label, 'Luke-Jr'); + const msg = parseBitcoinUri( + `bitcoin:${addr}?amount=50&label=Luke-Jr&message=Donation%20for%20project%20xyz` + ); + assert.strictEqual(msg.message, 'Donation for project xyz'); + const upper = parseBitcoinUri(`BITCOIN:${addr}`); + assert.strictEqual(upper.address, addr); + const slash = parseBitcoinUri(`bitcoin://${addr}?amount=1`); + assert.strictEqual(slash.address, addr); + assert.strictEqual(slash.amount, '1'); + }); + + it('encodes message and address-only URIs; extras cannot override amount', function () { + const uri = encodeBitcoinUri({ + address: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + amount: 1, + extras: { amount: '9', lightning: 'lnbc1qq', unused: '' } + }); + const parsed = parseBitcoinUri(uri); + assert.strictEqual(parsed.amount, '1.00000000'); + assert.strictEqual(parsed.extras.lightning, 'lnbc1qq'); + assert.ok(!Object.prototype.hasOwnProperty.call(parsed.extras, 'unused')); + assert.strictEqual( + encodeBitcoinUri({ address: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4' }), + 'bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4' + ); + const withMsg = encodeBitcoinUri({ + address: '175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W', + message: 'Donation for project xyz' + }); + assert.strictEqual(parseBitcoinUri(withMsg).message, 'Donation for project xyz'); + }); + + it('rejects missing address and invalid amounts', function () { + assert.throws(() => encodeBitcoinUri({}), /address/); + assert.throws(() => encodeBitcoinUri({ address: 'bc1qxx', amount: -1 }), /non-negative/); + assert.throws(() => encodeBitcoinUri({ address: 'bc1q?bad' }), /\? or #/); + assert.throws(() => encodeBitcoinUri({ address: 'bc1qxx', amount: '1btc' }), /decimal BTC/); + assert.throws( + () => encodeBitcoinUri({ address: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', amount: 0.000000009 }), + /non-negative BTC/ + ); + assert.throws( + () => encodeBitcoinUri({ address: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', amount: 0.000000004 }), + /non-negative BTC/ + ); + assert.throws(() => parseBitcoinUri('https://example.test'), /bitcoin:/); + assert.throws(() => parseBitcoinUri('bitcoin:addr?amount=-1'), /amount is invalid/); + assert.throws( + () => parseBitcoinUri('bitcoin:addr?req-something=1'), + /unsupported required BIP21 parameter/ + ); + assert.throws( + () => parseBitcoinUri('bitcoin:addr?REQ-exp=1'), + /unsupported required BIP21 parameter/ + ); + }); +}); + +describe('@fabric/core/functions/bip125', function () { + it('treats MAX-2 as opt-in RBF and MAX-1 as locktime-only', function () { + assert.strictEqual(BIP125_SEQUENCE_FINAL, (2 ** 32) - 1); + assert.strictEqual(BIP125_SEQUENCE_LOCKTIME_ONLY, (2 ** 32) - 2); + assert.strictEqual(BIP125_SEQUENCE_RBF, (2 ** 32) - 3); + assert.strictEqual(sequenceSignalsOptInRbf(BIP125_SEQUENCE_FINAL), false); + assert.strictEqual(sequenceSignalsOptInRbf(BIP125_SEQUENCE_LOCKTIME_ONLY), false); + assert.strictEqual(sequenceSignalsOptInRbf(BIP125_SEQUENCE_RBF), true); + assert.strictEqual(sequenceSignalsOptInRbf(0), true); + }); + + it('opts the transaction in when any input signals RBF', function () { + assert.strictEqual(transactionSignalsOptInRbf([ + { sequence: BIP125_SEQUENCE_LOCKTIME_ONLY }, + { nSequence: BIP125_SEQUENCE_RBF } + ]), true); + assert.strictEqual(transactionSignalsOptInRbf([ + { sequence: BIP125_SEQUENCE_LOCKTIME_ONLY } + ]), false); + assert.strictEqual(transactionSignalsOptInRbf([]), false); + assert.strictEqual(transactionSignalsOptInRbf(null), false); + assert.strictEqual(sequenceSignalsOptInRbf(undefined), false); + assert.strictEqual(sequenceSignalsOptInRbf(Number.NaN), false); + assert.strictEqual(sequenceSignalsOptInRbf(0.5), false); + assert.strictEqual(sequenceSignalsOptInRbf(4294967296), false); + }); +}); + +const { LEAF_VERSION_TAPSCRIPT, tapLeafScriptEntry, taprootPsbtInputFields } = require('../functions/inventoryHtlc'); + +describe('@fabric/core/functions/bip371', function () { + const internal = Buffer.from( + '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0', + 'hex' + ); + const script = Buffer.from('51', 'hex'); + const controlBlock = Buffer.concat([Buffer.from([0xc0]), internal]); + + it('builds PSBT_IN_TAP_INTERNAL_KEY and PSBT_IN_TAP_LEAF_SCRIPT bags', function () { + const fields = taprootPsbtInputFields({ + tapInternalKey: internal, + script, + controlBlock, + leafVersion: LEAF_VERSION_TAPSCRIPT + }); + assert.strictEqual(fields.tapInternalKey.length, 32); + assert.ok(fields.tapInternalKey.equals(internal)); + assert.strictEqual(fields.tapLeafScript.length, 1); + assert.strictEqual(fields.tapLeafScript[0].leafVersion, 0xc0); + assert.ok(fields.tapLeafScript[0].script.equals(script)); + assert.ok(fields.tapLeafScript[0].controlBlock.equals(controlBlock)); + assert.ok(!Object.prototype.hasOwnProperty.call(fields, 'tapMerkleRoot')); + }); + + it('includes optional tapMerkleRoot and rejects short control blocks', function () { + const root = Buffer.alloc(32, 7); + const fields = taprootPsbtInputFields({ + tapInternalKey: internal.toString('hex'), + script, + controlBlock, + tapMerkleRoot: root + }); + assert.ok(fields.tapMerkleRoot.equals(root)); + assert.throws( + () => tapLeafScriptEntry({ script, controlBlock: Buffer.alloc(32) }), + /controlBlock/ + ); + assert.throws( + () => tapLeafScriptEntry({ script, controlBlock: Buffer.alloc(34) }), + /33 \+ 32\*m/ + ); + assert.throws( + () => tapLeafScriptEntry({ script, controlBlock, leafVersion: 0xc1 }), + /even/ + ); + assert.throws( + () => tapLeafScriptEntry({ + script, + controlBlock, + leafVersion: 0xc2 + }), + /match the control block/ + ); + assert.throws( + () => taprootPsbtInputFields({ + tapInternalKey: Buffer.alloc(33, 2), + script, + controlBlock + }), + /tapInternalKey/ + ); + }); + + it('is the bag shape used by inventory HTLC NUMS internal key', function () { + assert.ok(inventoryHtlc.TAPROOT_INTERNAL_NUMS.equals(internal)); }); }); diff --git a/tests/functions.sealedBlob.js b/tests/functions.sealedBlob.js deleted file mode 100644 index 7f75f60e6..000000000 --- a/tests/functions.sealedBlob.js +++ /dev/null @@ -1,69 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const { - sealJson, - openSealedJson, - isSealedEnvelope, - isPasswordProtectedDocument, - SEAL_SCHEME, - PASSWORD_MIN_LENGTH -} = require('../functions/sealedBlob'); - -describe('@fabric/core/functions/sealedBlob', function () { - const password = 'test-pass-ok!'; - const secret = { seed: 'abandon abandon', xprv: 'xprv-test', note: 'keep me' }; - - it('round-trips JSON through AES-256-GCM', function () { - const envelope = sealJson(secret, password); - assert.strictEqual(envelope.type, 'FabricSealedBlob'); - assert.strictEqual(envelope.encryption, SEAL_SCHEME); - assert.ok(envelope.ciphertext); - assert.ok(envelope.iv); - assert.ok(envelope.kdf && envelope.kdf.salt); - assert.strictEqual(envelope.kdf.iterations, 210000); - const opened = openSealedJson(envelope, password); - assert.deepStrictEqual(opened, secret); - }); - - it('opens a nested seal field (wallet document shape)', function () { - const envelope = sealJson(secret, password); - const document = { - type: 'FabricWallet', - format: SEAL_SCHEME, - passwordProtected: true, - object: { id: 'public', xpub: 'xpub1' }, - seal: envelope - }; - assert.strictEqual(isPasswordProtectedDocument(document), true); - assert.deepStrictEqual(openSealedJson(document, password), secret); - }); - - it('opens a Hub backup v2-shaped envelope (ciphertext + kdf + iv)', function () { - const core = sealJson(secret, password); - const hubShaped = { - format: SEAL_SCHEME, - kdf: core.kdf, - iv: core.iv, - ciphertext: core.ciphertext - }; - assert.strictEqual(isSealedEnvelope(hubShaped), true); - assert.deepStrictEqual(openSealedJson(hubShaped, password), secret); - }); - - it('rejects a short password and a wrong password', function () { - assert.throws(() => sealJson(secret, 'short'), (err) => err.code === 'FABRIC_PASSWORD_POLICY'); - const envelope = sealJson(secret, password); - assert.throws(() => openSealedJson(envelope, 'wrong-pass-ok!'), (err) => err.code === 'FABRIC_SEAL_DECRYPT'); - }); - - it('does not treat plaintext wallets as sealed', function () { - assert.strictEqual(PASSWORD_MIN_LENGTH, 8); - assert.strictEqual(isPasswordProtectedDocument({ - type: 'FabricWallet', - format: 'plaintext', - object: { xprv: 'xprv1', seed: 'words' } - }), false); - assert.strictEqual(isSealedEnvelope({ object: { xprv: 'xprv1' } }), false); - }); -}); diff --git a/tests/functions.sidechainState.js b/tests/functions.sidechainState.js index a09c5dce6..97e85b7b9 100644 --- a/tests/functions.sidechainState.js +++ b/tests/functions.sidechainState.js @@ -57,6 +57,12 @@ describe('@fabric/core/functions/sidechainState', function () { sc.validatePatchesAgainstPolicy([{ op: 'add', path: '/app/ok', value: 1 }], policy).ok, true ); + const rootPolicy = sc.parseStatechainPathPolicy({ allowedPathPrefixes: ['/'] }); + assert.strictEqual( + sc.validatePatchesAgainstPolicy([{ op: 'add', path: '/anywhere', value: 1 }], rootPolicy).ok, + true, + "'/' is a wildcard prefix — operators must not allow it" + ); assert.strictEqual( sc.validatePatchesAgainstPolicy([{ op: 'copy', @@ -337,3 +343,148 @@ describe('@fabric/core/functions/sidechainState', function () { assert.ok(sc.loadSnapshotForBeaconClock(fs, 2)); }); }); + +const fh = require('../functions/fabricHallmark'); + +describe('@fabric/core/functions/fabricHallmark', function () { + const tip = 'ab'.repeat(32); + const contractId = 'cd'.repeat(32); + const sidechain = { clock: 3, stateDigest: '11'.repeat(32) }; + const contracts = { clock: 1, stateDigest: '22'.repeat(32) }; + + it('derives a stable 32-byte commitment over tip + contract + state digests', function () { + const a = fh.deriveFabricHallmarkCommitmentHex({ + tipBlockHashHex: tip, + contractIdHex: contractId, + sidechain, + contracts + }); + const b = fh.deriveFabricHallmarkCommitmentHex({ + tipBlockHashHex: tip, + contractIdHex: contractId, + sidechain: { clock: 3, stateDigest: '11'.repeat(32) }, + contracts: { clock: 1, stateDigest: '22'.repeat(32) } + }); + assert.strictEqual(a.length, 64); + assert.strictEqual(a, b); + }); + + it('changes commitment when sidechain stateDigest changes', function () { + const base = fh.deriveFabricHallmarkCommitmentHex({ + tipBlockHashHex: tip, + contractIdHex: contractId, + sidechain, + contracts + }); + const next = fh.deriveFabricHallmarkCommitmentHex({ + tipBlockHashHex: tip, + contractIdHex: contractId, + sidechain: { clock: 3, stateDigest: '33'.repeat(32) }, + contracts + }); + assert.notStrictEqual(base, next); + }); + + it('ignores balance-like fields not in the commitment schema', function () { + const a = fh.deriveFabricHallmarkCommitmentHex({ + tipBlockHashHex: tip, + contractIdHex: contractId, + sidechain, + contracts + }); + const b = fh.deriveFabricHallmarkCommitmentHex({ + tipBlockHashHex: tip, + contractIdHex: contractId, + sidechain: Object.assign({ balanceSats: 999 }, sidechain), + contracts: Object.assign({ height: 42 }, contracts) + }); + assert.strictEqual(a, b); + }); + + it('encodes a 40-byte payload with magic + tip suffix + commitment', function () { + const { payload, commitmentHex, tipHashSuffixHex } = fh.encodeFabricHallmarkFromState({ + tipBlockHashHex: tip, + contractIdHex: contractId, + sidechain, + contracts + }); + assert.strictEqual(payload.length, fh.HALLMARK_PAYLOAD_LENGTH); + assert.strictEqual(payload.subarray(0, 4).toString('hex'), fh.HALLMARK_MAGIC_HEX); + assert.strictEqual(tipHashSuffixHex, tip.slice(-8)); + assert.strictEqual(payload.subarray(4, 8).toString('hex'), tipHashSuffixHex); + assert.strictEqual(payload.subarray(8, 40).toString('hex'), commitmentHex); + }); + + it('round-trips decode and verify', function () { + const { payload, commitmentHex } = fh.encodeFabricHallmarkFromState({ + tipBlockHashHex: tip, + contractIdHex: contractId, + sidechain, + contracts + }); + const decoded = fh.decodeFabricHallmark(payload); + assert.ok(decoded); + assert.strictEqual(decoded.commitmentHex, commitmentHex); + assert.strictEqual(fh.verifyFabricHallmark(payload, { tipBlockHashHex: tip, commitmentHex }), true); + assert.strictEqual(fh.verifyFabricHallmark(payload.toString('hex'), { tipBlockHashHex: tip }), true); + }); + + it('rejects wrong magic, length, or tip suffix', function () { + const { payload, commitmentHex } = fh.encodeFabricHallmarkFromState({ + tipBlockHashHex: tip, + contractIdHex: contractId, + sidechain, + contracts + }); + const badMagic = Buffer.from(payload); + badMagic[0] = 0xff; + assert.strictEqual(fh.decodeFabricHallmark(badMagic), null); + assert.strictEqual(fh.decodeFabricHallmark(payload.subarray(0, 20)), null); + + const otherTip = 'ef'.repeat(32); + assert.strictEqual( + fh.verifyFabricHallmark(payload, { tipBlockHashHex: otherTip, commitmentHex }), + false + ); + }); + + it('tipHashSuffixFromBlockHash takes the last four bytes', function () { + const hash = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff'; + assert.strictEqual(fh.tipHashSuffixFromBlockHash(hash).toString('hex'), 'ccddeeff'); + }); + + it('omits malformed stateDigest from the commitment (same as absent)', function () { + const withValid = fh.deriveFabricHallmarkCommitmentHex({ + tipBlockHashHex: tip, + contractIdHex: contractId, + sidechain, + contracts + }); + const withBad = fh.deriveFabricHallmarkCommitmentHex({ + tipBlockHashHex: tip, + contractIdHex: contractId, + sidechain: { clock: 3, stateDigest: 'not-a-digest' }, + contracts + }); + const without = fh.deriveFabricHallmarkCommitmentHex({ + tipBlockHashHex: tip, + contractIdHex: contractId, + sidechain: { clock: 3 }, + contracts + }); + assert.notStrictEqual(withValid, withBad); + assert.strictEqual(withBad, without); + }); + + it('rejects non-hex and wrong-length tip hashes without a dynamic RegExp', function () { + assert.throws(() => fh.tipHashBytesFromBlockHash('zz'.repeat(32)), /expected 64 hex chars/); + assert.throws(() => fh.tipHashBytesFromBlockHash('ab'.repeat(31)), /expected 64 hex chars/); + assert.throws(() => fh.tipHashBytesFromBlockHash(''), /expected 64 hex chars/); + }); + + it('exports a copy of HALLMARK_MAGIC (mutation-safe)', function () { + const a = fh.HALLMARK_MAGIC; + a[0] = 0xff; + assert.strictEqual(fh.HALLMARK_MAGIC.toString('hex'), fh.HALLMARK_MAGIC_HEX); + }); +}); diff --git a/tests/functions.taggedHash.js b/tests/functions.taggedHash.js index 6d8602552..ccd9af1f4 100644 --- a/tests/functions.taggedHash.js +++ b/tests/functions.taggedHash.js @@ -8,8 +8,14 @@ describe('functions/taggedHash', function () { const tag = 'Fabric/Message'; const data = Buffer.from('hello'); const h = taggedHash(tag, data); + const crypto = require('crypto'); + const tagHash = crypto.createHash('sha256').update(tag, 'utf8').digest(); + const expected = crypto.createHash('sha256') + .update(Buffer.concat([tagHash, tagHash, data])) + .digest(); assert.ok(Buffer.isBuffer(h)); assert.strictEqual(h.length, 32); + assert.ok(h.equals(expected)); }); it('accepts Buffer tag', function () { diff --git a/tests/fuzz/p2p.chaosPeers.fuzz.js b/tests/fuzz/p2p.chaosPeers.fuzz.js index bdc66629d..ea1df9a30 100644 --- a/tests/fuzz/p2p.chaosPeers.fuzz.js +++ b/tests/fuzz/p2p.chaosPeers.fuzz.js @@ -136,14 +136,18 @@ describe('fuzz: live peer chaos (TCP + NOISE)', function () { let exchanges = 0; for (let i = 0; i < n; i++) { - const sender = (i % 2 === 0) ? hub : client; + const prefer = (i % 2 === 0) ? hub : client; + const sender = connectionKeys(prefer).length + ? prefer + : (connectionKeys(hub).length ? hub : (connectionKeys(client).length ? client : null)); + if (!sender) break; const label = sender === hub ? 'hub' : 'client'; - const keys = connectionKeys(sender); - if (!keys.length) break; const roll = crypto.randomInt(0, 10); let buf; - if (roll < 6) { + // Land a dozen signed frames first so a hostile random AMP that + // tears the session cannot fail the storm before it starts. + if (exchanges < 12 || roll < 6) { const body = randomGenericPayload(sender); const msg = Message.fromVector(['P2P_BASE_MESSAGE', JSON.stringify(body)]); msg.signWithKey(sender.key); @@ -172,3 +176,130 @@ describe('fuzz: live peer chaos (TCP + NOISE)', function () { await client.stop(); }); }); + +/** + * Opt-in public playnet neighbor storm (signed Ping / BASE / chat / unknown). + * Does not send P2P_FLUSH_CHAIN, P2P_FILE_SEND, or GHSA advisory documents. + * + * FABRIC_PLAYNET_CHAOS=1 FABRIC_PLAYNET_PEERS=hub.fabric.pub:7777,relay.goon.vc:7777 \ + * npm test -- tests/fuzz/p2p.chaosPeers.fuzz.js + */ +describe('fuzz: playnet neighbor chaos', function () { + const runLive = process.env.FABRIC_PLAYNET_CHAOS === '1' || + process.env.FABRIC_PLAYNET_CHAOS === 'true'; + const fs = require('fs'); + const path = require('path'); + + (runLive ? it : it.skip)('dials playnet peers and sends a signed neighbor storm', async function () { + this.timeout(120000); + try { + require('../../functions/fabricHomeEnv').loadFabricHomeEnv(); + } catch (_) { /* older pin */ } + + const xprv = String(process.env.FABRIC_XPRV || '').trim(); + const mnemonic = String(process.env.FABRIC_MNEMONIC || '').trim(); + const seed = String(process.env.FABRIC_SEED || '').trim(); + const key = xprv.startsWith('xprv') || xprv.startsWith('tprv') + ? { xprv } + : (seed.startsWith('xprv') || seed.startsWith('tprv') + ? { xprv: seed } + : (mnemonic ? { mnemonic } : (seed ? { seed } : null))); + if (!key) { + this.skip(); + return; + } + + const targets = String(process.env.FABRIC_PLAYNET_PEERS || + 'hub.fabric.pub:7777,relay.goon.vc:7777') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + const client = new Peer({ + listen: false, + networking: true, + peers: targets, + key, + peersDb: null, + reconnectToKnownPeers: false + }); + const stopClient = async () => { + try { await client.stop(); } catch (_) { /* ignore */ } + }; + + const hardErrors = []; + const writes = { ok: 0, fail: 0, types: {} }; + client.on('error', (err) => { + const msg = err && (err.message || String(err)); + if (msg && /Attempted to write to a closed|ECONNRESET|EPIPE/i.test(msg)) return; + hardErrors.push(msg); + }); + + try { + await client.start(); + await waitUntil(() => connectionKeys(client).length >= 1, 25000); + const conns = connectionKeys(client); + assert.ok(conns.length >= 1, `expected a connection to ${targets.join(',')}`); + + const n = Math.max(16, Math.min(fuzzPeerChaosIterations(48), 80)); + const stormTypes = [ + 'P2P_PING', + 'P2P_CHAT_MESSAGE', + 'P2P_PEER_GOSSIP', + 'P2P_PEERING_OFFER', + 'P2P_PEER_ALIAS', + 'P2P_BASE_MESSAGE' + ]; + for (let i = 0; i < n; i++) { + const keys = connectionKeys(client); + if (!keys.length) break; + const t = stormTypes[i % stormTypes.length]; + let body; + if (t === 'P2P_CHAT_MESSAGE') { + body = `playnet-chaos-${i}-${Date.now()}`; + } else if (t === 'P2P_PEER_ALIAS') { + body = `chaos-${i}`.slice(0, 24); + } else { + body = JSON.stringify({ + type: t, + nonce: `playnet-chaos-${i}`, + created: new Date().toISOString() + }); + } + const msg = Message.fromVector([t, body]); + msg.signWithKey(client.key); + try { + writeFabric(client, 'playnet', msg.toBuffer()); + writes.ok += 1; + writes.types[t] = (writes.types[t] || 0) + 1; + } catch (_) { + writes.fail += 1; + } + if ((i & 3) === 3) await new Promise((r) => setImmediate(r)); + } + + await new Promise((r) => setTimeout(r, 2000)); + + const report = { + at: new Date().toISOString(), + targets, + connections: connectionKeys(client), + writes, + hardErrors, + pubkey: client.key && client.key.pubkey + ? String(client.key.pubkey).slice(0, 16) + '…' + : null + }; + const outDir = path.join(__dirname, '..', '..', 'reports'); + try { fs.mkdirSync(outDir, { recursive: true }); } catch (_) { /* ignore */ } + fs.writeFileSync(path.join(outDir, 'playnet-chaos-neighbors.json'), JSON.stringify(report, null, 2)); + + // writes.ok is invocations after `_writeFabric` returned; a closed stream + // skips without throwing. Fail on unexpected peer errors instead. + assert.ok(writes.ok >= 8, `expected signed write attempts, got ${writes.ok}`); + assert.strictEqual(hardErrors.length, 0, `unexpected peer errors: ${JSON.stringify(hardErrors)}`); + } finally { + await stopClient(); + } + }); +}); diff --git a/tests/groupChangeGovernance.test.js b/tests/groupChangeGovernance.test.js deleted file mode 100644 index 10aadfeec..000000000 --- a/tests/groupChangeGovernance.test.js +++ /dev/null @@ -1,252 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const Key = require('../types/key'); -const { - signingStringForGroupChangeProposal, - signProposalVote, - verifyProposalVote, - applyGroupChangeAction, - foldProposals -} = require('../functions/groupChangeGovernance'); -const { pubkeyXOnly } = require('../functions/groupChatSeal'); - -describe('@fabric/core groupChangeGovernance', function () { - it('signs and verifies a proposal vote (canonical application string)', function () { - const a = new Key(); - const proposal = { - id: 'gprop-test', - contractId: 'ab'.repeat(32), - groupId: 'group-1', - action: 'member.add', - member: new Key().pubkey, - role: 'signer', - patch: null, - proposedBy: pubkeyXOnly(a.pubkey), - createdAt: '2026-01-01T00:00:00.000Z' - }; - const signed = signProposalVote(a, proposal); - assert.ok(signed.signature); - assert.strictEqual(signed.message, signingStringForGroupChangeProposal(proposal)); - assert.strictEqual(verifyProposalVote(proposal, a.pubkey, signed.signature), true); - assert.strictEqual(verifyProposalVote(proposal, a.pubkey, 'ab'.repeat(64)), false); - assert.strictEqual(verifyProposalVote(proposal, new Key().pubkey, signed.signature), false); - }); - - it('applyGroupChangeAction member.add / remove / set', function () { - const a = pubkeyXOnly(new Key().pubkey); - const b = pubkeyXOnly(new Key().pubkey); - const ctx = { members: new Set(), signers: new Set(), readerOnlyAdds: false, threshold: null }; - applyGroupChangeAction(ctx, { action: 'member.add', member: a, role: 'signer' }); - applyGroupChangeAction(ctx, { action: 'member.add', member: b, role: 'reader' }); - assert.ok(ctx.members.has(a) && ctx.members.has(b)); - assert.ok(ctx.signers.has(a) && !ctx.signers.has(b)); - applyGroupChangeAction(ctx, { action: 'member.remove', member: b }); - assert.ok(!ctx.members.has(b)); - applyGroupChangeAction(ctx, { action: 'members.set', members: [a, b] }); - assert.ok(ctx.signers.has(b)); - }); - - it('foldProposals adopts at threshold using genesis signers', function () { - const owner = new Key(); - const recruit = new Key(); - const ox = pubkeyXOnly(owner.pubkey); - const rx = pubkeyXOnly(recruit.pubkey); - const ctx = { members: new Set(), signers: new Set(), readerOnlyAdds: false, threshold: 1 }; - const entries = [{ - hash: 'aa', - type: 'GroupChangeProposal', - author: ox, - object: { - id: 'p1', - action: 'member.add', - member: rx, - role: 'signer', - proposedBy: ox, - threshold: 1 - } - }]; - const out = foldProposals(entries, ctx, 1, [ox]); - assert.strictEqual(out[0].status, 'adopted'); - assert.ok(ctx.members.has(ox) && ctx.members.has(rx)); - }); - - it('foldProposals adopts member.remove and update threshold (Federation actions)', function () { - const owner = new Key(); - const extra = new Key(); - const ox = pubkeyXOnly(owner.pubkey); - const ex = pubkeyXOnly(extra.pubkey); - const ctx = { members: new Set([ox, ex]), signers: new Set([ox, ex]), readerOnlyAdds: false, threshold: 2 }; - const remove = { - hash: '11', - type: 'GroupChangeProposal', - author: ox, - object: { - id: 'p-remove', - action: 'member.remove', - member: ex, - proposedBy: ox, - threshold: 1 - } - }; - const removed = foldProposals([remove], ctx, 1, [ox, ex]); - assert.strictEqual(removed[0].status, 'adopted'); - assert.ok(!ctx.members.has(ex)); - - const update = { - hash: '22', - type: 'GroupChangeProposal', - author: ox, - object: { - id: 'p-update', - action: 'update', - patch: { threshold: 1, pinnedChannels: ['discord:ops'] }, - proposedBy: ox, - threshold: 1 - } - }; - const updated = foldProposals([update], ctx, 1, [ox]); - assert.strictEqual(updated[0].status, 'adopted'); - assert.strictEqual(ctx.threshold, 1); - }); - - it('foldProposals ignores a proposer-chosen threshold below genesis k-of-n', function () { - const owner = new Key(); - const co = new Key(); - const ox = pubkeyXOnly(owner.pubkey); - const cx = pubkeyXOnly(co.pubkey); - const ctx = { members: new Set([ox, cx]), signers: new Set([ox, cx]), readerOnlyAdds: false, threshold: 2 }; - const entries = [{ - hash: 'cc', - type: 'GroupChangeProposal', - author: ox, - object: { - id: 'p-bypass', - action: 'members.set', - members: [ox], - proposedBy: ox, - threshold: 1 - } - }]; - const out = foldProposals(entries, ctx, 2, [ox, cx]); - assert.strictEqual(out[0].status, 'pending'); - assert.ok(ctx.members.has(cx)); - assert.ok(ctx.signers.has(cx)); - }); - - it('signing string v2 binds members/signers (order-insensitive)', function () { - const a = pubkeyXOnly(new Key().pubkey); - const b = pubkeyXOnly(new Key().pubkey); - const first = [a, b].sort(); - const second = [b, a]; - const left = JSON.parse(signingStringForGroupChangeProposal({ - id: 'p-set', - action: 'members.set', - members: first - })); - const right = JSON.parse(signingStringForGroupChangeProposal({ - id: 'p-set', - action: 'members.set', - members: second - })); - assert.strictEqual(left.v, 2); - assert.deepStrictEqual(left.members, right.members); - assert.strictEqual(left.signers, null); - }); - - it('foldProposals rejects votes when members.set roster is swapped after signing', function () { - const owner = new Key(); - const co = new Key(); - const attacker = new Key(); - const ox = pubkeyXOnly(owner.pubkey); - const cx = pubkeyXOnly(co.pubkey); - const ax = pubkeyXOnly(attacker.pubkey); - const ctx = { members: new Set([ox, cx]), signers: new Set([ox, cx]), readerOnlyAdds: false, threshold: 2 }; - const honest = { - id: 'p-roster', - action: 'members.set', - members: [ox, cx], - proposedBy: ox - }; - const vote = signProposalVote(co, honest); - const entries = [{ - hash: 'dd', - type: 'GroupChangeProposal', - author: ox, - object: { - id: 'p-roster', - action: 'members.set', - members: [ax], - proposedBy: ox - } - }, { - hash: 'ee', - type: 'GroupChangeVote', - author: cx, - object: { - proposalId: 'p-roster', - voter: cx, - signature: vote.signature - } - }]; - const out = foldProposals(entries, ctx, 2, [ox, cx]); - assert.strictEqual(out[0].status, 'pending'); - assert.ok(ctx.members.has(ox) && ctx.members.has(cx)); - assert.strictEqual(ctx.members.has(ax), false); - }); - - it('foldProposals rejects votes when signers.set roster is swapped after signing', function () { - const owner = new Key(); - const co = new Key(); - const attacker = new Key(); - const ox = pubkeyXOnly(owner.pubkey); - const cx = pubkeyXOnly(co.pubkey); - const ax = pubkeyXOnly(attacker.pubkey); - const ctx = { members: new Set([ox, cx]), signers: new Set([ox, cx]), readerOnlyAdds: false, threshold: 2 }; - const honest = { - id: 'p-signers', - action: 'signers.set', - signers: [ox, cx], - proposedBy: ox - }; - const vote = signProposalVote(co, honest); - const entries = [{ - hash: 'ff', - type: 'GroupChangeProposal', - author: ox, - object: { - id: 'p-signers', - action: 'signers.set', - signers: [ax], - proposedBy: ox - } - }, { - hash: '00', - type: 'GroupChangeVote', - author: cx, - object: { - proposalId: 'p-signers', - voter: cx, - signature: vote.signature - } - }]; - const out = foldProposals(entries, ctx, 2, [ox, cx]); - assert.strictEqual(out[0].status, 'pending'); - assert.ok(ctx.signers.has(ox) && ctx.signers.has(cx)); - assert.strictEqual(ctx.signers.has(ax), false); - }); - - it('signing string v2 unique-sorts duplicate members and signers', function () { - const a = pubkeyXOnly(new Key().pubkey); - const b = pubkeyXOnly(new Key().pubkey); - const parsed = JSON.parse(signingStringForGroupChangeProposal({ - id: 'p-dup', - action: 'members.set', - members: [b, a, a], - signers: [b, b, a] - })); - assert.strictEqual(parsed.v, 2); - assert.deepStrictEqual(parsed.members, [a, b].sort()); - assert.deepStrictEqual(parsed.signers, [a, b].sort()); - }); -}); diff --git a/tests/groupChatSeal.test.js b/tests/groupChatSeal.test.js deleted file mode 100644 index 976ca9775..000000000 --- a/tests/groupChatSeal.test.js +++ /dev/null @@ -1,225 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const { - SEAL_SCHEME, - SEAL_SCHEME_PARTICIPANT, - sortedMemberXOnlys, - deriveTimelineCipherKey, - sealGroupChatBody, - sealParticipantGroupChatBody, - openGroupChatBody, - isSealedGroupChat, - isParticipantSealedGroupChat, - pubkeyXOnly -} = require('../functions/groupChatSeal'); -const Key = require('../types/key'); - -describe('@fabric/core groupChatSeal', function () { - const a = new Key(); - const b = new Key(); - const contractId = 'a'.repeat(64); - const digest = require('crypto').createHash('sha256').update('tip-content').digest('hex'); - - it('sortedMemberXOnlys normalizes compressed and x-only', function () { - const xa = pubkeyXOnly(a.pubkey); - assert.ok(xa && xa.length === 64); - assert.strictEqual(pubkeyXOnly(`02${xa}`), xa); - assert.strictEqual(pubkeyXOnly(`03${xa}`), xa); - const list = sortedMemberXOnlys([b.pubkey, a.pubkey, xa, 'nope']); - assert.strictEqual(list.length, 2); - assert.ok(list.includes(xa)); - assert.ok(list.includes(pubkeyXOnly(b.pubkey))); - assert.deepStrictEqual(list, list.slice().sort()); - }); - - it('deriveTimelineCipherKey is deterministic for same tip+members', function () { - const opts = { - contractId, - clock: 7, - stateDigest: digest, - memberPubkeys: [a.pubkey, b.pubkey] - }; - const k1 = deriveTimelineCipherKey(opts); - const k2 = deriveTimelineCipherKey({ - ...opts, - memberPubkeys: [b.pubkey, a.pubkey] - }); - assert.ok(Buffer.isBuffer(k1) && k1.length === 32); - assert.strictEqual(k1.toString('hex'), k2.toString('hex')); - }); - - it('deriveTimelineCipherKey changes when tip or roster changes', function () { - const base = { - contractId, - clock: 7, - stateDigest: digest, - memberPubkeys: [a.pubkey, b.pubkey] - }; - const k0 = deriveTimelineCipherKey(base); - const kClock = deriveTimelineCipherKey({ ...base, clock: 8 }); - const c = new Key(); - const kMembers = deriveTimelineCipherKey({ - ...base, - memberPubkeys: [a.pubkey, b.pubkey, c.pubkey] - }); - assert.notStrictEqual(k0.toString('hex'), kClock.toString('hex')); - assert.notStrictEqual(k0.toString('hex'), kMembers.toString('hex')); - }); - - it('deriveTimelineCipherKey rejects missing or non-integer clocks', function () { - const base = { - contractId, - stateDigest: digest, - memberPubkeys: [a.pubkey] - }; - assert.throws(() => deriveTimelineCipherKey({ ...base }), /non-negative integer/); - assert.throws(() => deriveTimelineCipherKey({ ...base, clock: null }), /non-negative integer/); - assert.throws(() => deriveTimelineCipherKey({ ...base, clock: 'nope' }), /non-negative integer/); - assert.throws(() => deriveTimelineCipherKey({ ...base, clock: 1.5 }), /non-negative integer/); - assert.throws(() => deriveTimelineCipherKey({ ...base, clock: -1 }), /non-negative integer/); - const ok = deriveTimelineCipherKey({ ...base, clock: 0 }); - assert.ok(Buffer.isBuffer(ok) && ok.length === 32); - }); - - it('seal/open round-trip (v1 tip)', function () { - const tip = { - contractId, - clock: 2, - stateDigest: digest, - memberPubkeys: [a.pubkey, b.pubkey] - }; - const seal = sealGroupChatBody({ body: 'hello sealed group', ...tip }); - assert.strictEqual(seal.scheme, SEAL_SCHEME); - assert.strictEqual(seal.basisClock, 2); - assert.ok(isSealedGroupChat({ seal })); - const plain = openGroupChatBody(seal, tip); - assert.strictEqual(plain, 'hello sealed group'); - }); - - it('open fails on wrong tip clock', function () { - const tip = { - contractId, - clock: 2, - stateDigest: digest, - memberPubkeys: [a.pubkey, b.pubkey] - }; - const seal = sealGroupChatBody({ body: 'secret', ...tip }); - assert.throws(() => openGroupChatBody(seal, { ...tip, clock: 3 }), /clock/); - }); - - it('open fails on wrong stateDigest, roster, or tampered ciphertext', function () { - const tip = { - contractId, - clock: 2, - stateDigest: digest, - memberPubkeys: [a.pubkey, b.pubkey] - }; - const seal = sealGroupChatBody({ body: 'secret', ...tip }); - - const otherDigest = require('crypto').createHash('sha256').update('other-tip').digest('hex'); - assert.throws( - () => openGroupChatBody(seal, { ...tip, stateDigest: otherDigest }), - /stateDigest/ - ); - - // Same clock/digest but different roster → different HKDF salt → AES-GCM auth fail. - const c = new Key(); - assert.throws( - () => openGroupChatBody(seal, { - ...tip, - memberPubkeys: [a.pubkey, b.pubkey, c.pubkey] - }), - /auth|Unsupported state|bad decrypt|unable to authenticate/i - ); - - const ct = Buffer.from(seal.ciphertext, 'base64'); - ct[0] ^= 0xff; - const tamperedSeal = { ...seal, ciphertext: ct.toString('base64') }; - assert.throws( - () => openGroupChatBody(tamperedSeal, tip), - /auth|Unsupported state|bad decrypt|unable to authenticate/i - ); - }); - - it('participant seal opens for each member and not for outsiders', function () { - const seal = sealParticipantGroupChatBody({ - body: 'hub-blind ops', - memberPubkeys: [a.pubkey, b.pubkey], - contractId, - clock: 9, - stateDigest: digest - }); - assert.strictEqual(seal.scheme, SEAL_SCHEME_PARTICIPANT); - assert.ok(isParticipantSealedGroupChat(seal)); - assert.ok(isSealedGroupChat({ seal })); - assert.ok(Array.isArray(seal.wraps) && seal.wraps.length === 2); - assert.ok(seal.ephemeralPub); - // Tip fields on the seal are bound as AES-GCM AAD (not key material). - assert.strictEqual(seal.basisClock, 9); - assert.strictEqual(seal.contractId, contractId); - assert.strictEqual(seal.stateDigest, digest); - - assert.throws( - () => sealParticipantGroupChatBody({ - body: 'x', - memberPubkeys: [a.pubkey], - clock: -1 - }), - /non-negative integer/ - ); - assert.throws( - () => sealParticipantGroupChatBody({ - body: 'x', - memberPubkeys: [a.pubkey], - clock: 1.5 - }), - /non-negative integer/ - ); - assert.throws( - () => sealParticipantGroupChatBody({ - body: 'x', - memberPubkeys: [a.pubkey], - clock: 'nope' - }), - /non-negative integer/ - ); - - assert.strictEqual( - openGroupChatBody(seal, { keyOrPrivate: a }), - 'hub-blind ops' - ); - assert.strictEqual( - openGroupChatBody(seal, { keyOrPrivate: b }), - 'hub-blind ops' - ); - - const outsider = new Key(); - assert.throws( - () => openGroupChatBody(seal, { keyOrPrivate: outsider }), - /no wrap/ - ); - - // Cut-and-paste across contract id fails AAD auth even with a valid wrap key. - assert.throws( - () => openGroupChatBody({ ...seal, contractId: 'b'.repeat(64) }, { keyOrPrivate: a }), - /auth|Unsupported state|bad decrypt|unable to authenticate/i - ); - }); - - it('participant seal does not use tip HKDF (hub tip holders stay blind)', function () { - const seal = sealGroupChatBody({ - mode: 'participant', - body: 'still secret', - memberPubkeys: [a.pubkey, b.pubkey], - contractId, - clock: 1, - stateDigest: digest - }); - // ECDH wrap opens without supplying tip opts; tip holders without privkeys stay blind. - assert.strictEqual( - openGroupChatBody(seal, { keyOrPrivate: a.xprv ? { xprv: a.xprv } : a }), - 'still secret' - ); - }); -}); diff --git a/tests/lightning/lightning.service.js b/tests/lightning/lightning.service.js index 02c5e2423..a6c053cb1 100644 --- a/tests/lightning/lightning.service.js +++ b/tests/lightning/lightning.service.js @@ -217,23 +217,24 @@ d('@fabric/core/services/lightning', function () { assert.equal(Lightning instanceof Function, true); }); + it('constructs in unmanaged mode without lightningd', function () { + const local = new Lightning({ + network: 'regtest', + managed: false, + bitcoin: { + rpcport: 18443, + rpcuser: 'bitcoinrpc', + rpcpassword: 'password', + host: '127.0.0.1', + datadir: './stores/bitcoin-regtest-test' + } + }); + assert.ok(local); + assert.strictEqual(typeof local._makeRPCRequest, 'function'); + }); + it('can complete a payment (happy path)', async function () { - if (!runLightning) { - const local = new Lightning({ - network: 'regtest', - managed: false, - bitcoin: { - rpcport: 18443, - rpcuser: 'bitcoinrpc', - rpcpassword: 'password', - host: '127.0.0.1', - datadir: './stores/bitcoin-regtest-test' - } - }); - assert.ok(local); - assert.strictEqual(typeof local._makeRPCRequest, 'function'); - return; - } + if (!runLightning) this.skip(); this.timeout(180000); // 3 minutes for the test diff --git a/tests/onionChatSeal.test.js b/tests/onionChatSeal.test.js deleted file mode 100644 index 9c935471f..000000000 --- a/tests/onionChatSeal.test.js +++ /dev/null @@ -1,195 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const Key = require('../types/key'); -const Message = require('../types/message'); -const Peer = require('../types/peer'); -const { offlinePeerSettings } = require('./helpers/peer'); -const { - xOnlyFromKey, - wrapOnionPath, - tryDecodeForward -} = require('../functions/fabricOnion'); -const { - ONION_CHAT_SEAL_TYPE, - isOnionChatSeal, - sealOnionChatText, - tryOpenOnionChatText, - onionPathRecipientXOnly -} = require('../functions/onionChatSeal'); - -describe('@fabric/core onionChatSeal', function () { - it('seals to recipient and opens only with matching key', function () { - const dest = new Key(); - const other = new Key(); - const wire = sealOnionChatText('secret-hop', dest.pubkey); - assert.ok(isOnionChatSeal(wire)); - assert.ok(wire.includes(ONION_CHAT_SEAL_TYPE)); - assert.ok(!wire.includes('secret-hop')); - - const ok = tryOpenOnionChatText(wire, { keyOrPrivate: dest }); - assert.strictEqual(ok.sealed, true); - assert.strictEqual(ok.opened, true); - assert.strictEqual(ok.text, 'secret-hop'); - - const bad = tryOpenOnionChatText(wire, { keyOrPrivate: other }); - assert.strictEqual(bad.sealed, true); - assert.strictEqual(bad.opened, false); - assert.strictEqual(bad.text, null); - }); - - it('onionPathRecipientXOnly uses path tip', function () { - const a = new Key(); - const b = new Key(); - const tip = onionPathRecipientXOnly([ - xOnlyFromKey(a).toString('hex'), - xOnlyFromKey(b).toString('hex') - ]); - assert.strictEqual(tip, xOnlyFromKey(b).toString('hex')); - - const compressed = Buffer.from(b.public.encodeCompressed('hex'), 'hex'); - assert.strictEqual(onionPathRecipientXOnly([compressed]), xOnlyFromKey(b).toString('hex')); - assert.strictEqual(onionPathRecipientXOnly([xOnlyFromKey(b)]), xOnlyFromKey(b).toString('hex')); - }); - - it('Peer peels sealed onion chat and emits plaintext', function () { - const destKey = new Key(); - const originKey = new Key(); - const peer = new Peer(offlinePeerSettings({ - key: { mnemonic: destKey.mnemonic } - })); - const addr = '127.0.0.1:9300'; - peer.connections[addr] = { _writeFabric () {}, destroy () {} }; - peer.peers[addr] = { id: 'relay', publicKey: originKey.public.encodeCompressed('hex') }; - - // Seal to the Peer's Fabric protocol pubkey (Identity#fabricKey), not BIP44 master. - const sealed = sealOnionChatText('sealed-via-onion', peer.key.pubkey); - const payload = Message.fromVector(['P2P_CHAT_MESSAGE', sealed]); - payload.signWithKey(originKey); - const outer = wrapOnionPath({ - path: [xOnlyFromKey(peer.key)], - payload, - key: originKey - }); - - const chats = []; - peer.on('chat', (body, meta) => chats.push({ body, meta })); - peer._handleFabricMessage(outer.toBuffer(), { name: addr }, null); - - assert.strictEqual(chats.length, 1); - assert.strictEqual(chats[0].body.text, 'sealed-via-onion'); - assert.strictEqual(chats[0].body.onionSealed, true); - assert.strictEqual(chats[0].meta.onionSealed, true); - }); - - it('Peer drops sealed chat it cannot open', function () { - const destKey = new Key(); - const wrongPeer = new Peer(offlinePeerSettings()); - const originKey = new Key(); - const addr = '127.0.0.1:9301'; - wrongPeer.connections[addr] = { _writeFabric () {}, destroy () {} }; - wrongPeer.peers[addr] = { id: 'relay', publicKey: originKey.public.encodeCompressed('hex') }; - - const sealed = sealOnionChatText('not-for-you', destKey.pubkey); - const payload = Message.fromVector(['P2P_CHAT_MESSAGE', sealed]); - payload.signWithKey(originKey); - const outer = wrapOnionPath({ - path: [xOnlyFromKey(wrongPeer.key)], - payload, - key: originKey - }); - - const chats = []; - wrongPeer.on('chat', (body) => chats.push(body)); - wrongPeer._handleFabricMessage(outer.toBuffer(), { name: addr }, null); - assert.strictEqual(chats.length, 0); - }); - - it('multi-hop: curious relay can recurse to chat body but sees ciphertext only', function () { - const originKey = new Key(); - const relay = new Peer(offlinePeerSettings()); - const destKey = new Key(); - const dest = new Peer(offlinePeerSettings({ key: { mnemonic: destKey.mnemonic } })); - const fromAddr = '127.0.0.1:9310'; - const destAddr = '127.0.0.1:9311'; - const destWrites = []; - - relay.connections[fromAddr] = { _writeFabric () {}, destroy () {} }; - relay.connections[destAddr] = { - _writeFabric (buf) { destWrites.push(buf); }, - destroy () {} - }; - relay.peers[fromAddr] = { id: 'src', publicKey: originKey.public.encodeCompressed('hex') }; - relay.peers[destAddr] = { id: 'dest', publicKey: dest.key.public.encodeCompressed('hex') }; - - const plaintext = 'multi-hop-secret'; - const sealed = sealOnionChatText(plaintext, dest.key.pubkey); - const payload = Message.fromVector(['P2P_CHAT_MESSAGE', sealed]).signWithKey(originKey); - const outer = wrapOnionPath({ - path: [xOnlyFromKey(relay.key), xOnlyFromKey(dest.key)], - payload, - key: originKey - }); - - const relayChats = []; - relay.on('chat', (body) => relayChats.push(body)); - relay._handleFabricMessage(outer.toBuffer(), { name: fromAddr }, null); - - assert.strictEqual(relayChats.length, 0, 'relay must not emit chat on mid-hop peel'); - assert.strictEqual(destWrites.length, 1); - - // Snooping relay: recurse nested P2P_FORWARD inners until the application Message. - let cursor = Message.fromBuffer(destWrites[0]); - for (let i = 0; i < 8; i++) { - const fields = tryDecodeForward(cursor); - if (!fields) break; - cursor = Message.fromBuffer(fields.inner); - } - assert.strictEqual(cursor.type, 'P2P_CHAT_MESSAGE'); - const snooped = cursor.data.toString('utf8'); - assert.ok(isOnionChatSeal(snooped)); - assert.ok(!snooped.includes(plaintext)); - assert.strictEqual(tryOpenOnionChatText(snooped, { keyOrPrivate: relay.key }).opened, false); - - const tipChats = []; - dest.connections[destAddr] = { _writeFabric () {}, destroy () {} }; - dest.peers[destAddr] = { id: 'relay', publicKey: relay.key.public.encodeCompressed('hex') }; - dest.on('chat', (body) => tipChats.push(body)); - dest._handleFabricMessage(destWrites[0], { name: destAddr }, null); - assert.strictEqual(tipChats.length, 1); - assert.strictEqual(tipChats[0].text, plaintext); - assert.strictEqual(tipChats[0].onionSealed, true); - }); - - it('mesh flood P2P_CHAT_MESSAGE stays cleartext (no onion seal path)', function () { - const peer = new Peer(offlinePeerSettings()); - const author = new Key(); - const addr = '127.0.0.1:9320'; - const peersWrites = []; - peer.connections[addr] = { - _writeFabric (buf) { peersWrites.push(buf); }, - destroy () {} - }; - peer.peers[addr] = { id: 'neighbor', publicKey: author.public.encodeCompressed('hex') }; - peer._addressToId[addr] = 'neighbor'; - peer._state.peers = { - neighbor: { - id: 'neighbor', - address: addr, - score: 100, - publicKey: author.public.encodeCompressed('hex') - } - }; - - const clear = 'mesh-cleartext-hello'; - const msg = Message.fromVector(['P2P_CHAT_MESSAGE', clear]).signWithKey(author); - const chats = []; - peer.on('chat', (body, meta) => chats.push({ body, meta })); - peer._handleFabricMessage(msg.toBuffer(), { name: addr }, null); - - assert.strictEqual(chats.length, 1); - assert.strictEqual(chats[0].body.text, clear); - assert.ok(!chats[0].body.onionSealed); - assert.strictEqual(isOnionChatSeal(clear), false); - }); -}); diff --git a/tests/opcodeAllowList.test.js b/tests/opcodeAllowList.test.js deleted file mode 100644 index e0040b6d8..000000000 --- a/tests/opcodeAllowList.test.js +++ /dev/null @@ -1,36 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const { - normalizeOpcodeAllowList, - opcodesFromGenesis, - assertStepsAllowed -} = require('../functions/opcodeAllowList'); - -describe('opcodeAllowList', function () { - it('normalizes and dedupes', function () { - assert.deepStrictEqual( - normalizeOpcodeAllowList([' OP_TRUE ', 'OP_TRUE', '', 'OP_ADD']), - ['OP_TRUE', 'OP_ADD'] - ); - }); - - it('opcodesFromGenesis prefers primitives.opcodes', function () { - assert.deepStrictEqual( - opcodesFromGenesis({ - primitives: { opcodes: ['OP_A'] }, - opcodes: ['OP_B'] - }), - ['OP_A'] - ); - assert.deepStrictEqual(opcodesFromGenesis({ opcodes: ['OP_B'] }), ['OP_B']); - assert.deepStrictEqual(opcodesFromGenesis({}), []); - }); - - it('assertStepsAllowed fails closed', function () { - assert.strictEqual(assertStepsAllowed(['OP_TRUE'], ['OP_TRUE']).ok, true); - const bad = assertStepsAllowed(['OP_TRUE'], ['OP_TRUE', 'OP_EVIL']); - assert.strictEqual(bad.ok, false); - assert.deepStrictEqual(bad.unknown, ['OP_EVIL']); - }); -}); diff --git a/tests/peer.message.integration.js b/tests/peer.message.integration.js index 2baa9a6ff..a8eb86c02 100644 --- a/tests/peer.message.integration.js +++ b/tests/peer.message.integration.js @@ -383,6 +383,11 @@ describe('peer/message integration (mesh & secure delivery)', function () { assert.ok(events[0].wireMessage.toBuffer().equals(wire)); assert.strictEqual(events[0].messageHex, wire.toString('hex')); assert.strictEqual(events[0].messageId, msg.id); + const firstHex = events[0].messageHex; + events[0].wireMessage.toBuffer = function () { + throw new Error('messageHex must not recompute toBuffer after the first read'); + }; + assert.strictEqual(events[0].messageHex, firstHex); }); it('CONTRACT_MESSAGE without a contract namespace is dropped with a warning', function () { @@ -480,7 +485,33 @@ describe('peer/message integration (mesh & secure delivery)', function () { assert.strictEqual(event.contract, 'goon-contract'); }); - it('drops body hash mismatch before signature (wire integrity warning)', function () { + it('drops body hash mismatch before signature (on-wire XOR)', function () { + const hub = mockHub(); + const k = new Key(); + const addr = '127.0.0.1:7601'; + hub.connections[addr] = { _writeFabric: () => {}, destroy: () => {} }; + hub.peers[addr] = { id: 'p', publicKey: k.pubkey }; + hub._addressToId[addr] = 'xor-peer'; + hub._state.peers = { + 'xor-peer': { id: 'xor-peer', address: addr, score: 180, publicKey: k.pubkey } + }; + + const msg = Message.fromVector(['P2P_CHAT_MESSAGE', 'x']).signWithKey(k); + const buf = Buffer.from(msg.toBuffer()); + if (buf.length > 208) buf[208] ^= 0xff; + + let warned = false; + let chat = null; + hub.on('warning', (w) => { + if (/body hash mismatch/i.test(String(w))) warned = true; + }); + hub.once('chat', (m) => { chat = m; }); + hub._handleFabricMessage(buf, { name: addr }, null); + assert.ok(warned); + assert.strictEqual(chat, null); + }); + + it('drops body hash mismatch when parsed header hash is zeroed', function () { const hub = mockHub(); const k = new Key(); const addr = '127.0.0.1:7600'; diff --git a/tests/peer.onion.forward.js b/tests/peer.onion.forward.js index 84a671ef7..2e6fd592d 100644 --- a/tests/peer.onion.forward.js +++ b/tests/peer.onion.forward.js @@ -1040,3 +1040,510 @@ describe('Peer P2P_FORWARD onion', function () { } }); }); + +const { + chatTextOf, + chatActorIdOf, + formatChatLogLine, + sanitizeChatDisplayField +} = require('../functions/fabricChatText'); + +describe('fabricChatText', function () { + it('reads Peer { text } first, then Hub object.content / application body', function () { + assert.strictEqual(chatTextOf({ text: 'mesh' }), 'mesh'); + assert.strictEqual(chatTextOf({ object: { content: 'hub' } }), 'hub'); + assert.strictEqual(chatTextOf({ object: { body: 'app' } }), 'app'); + assert.strictEqual(chatTextOf('plain'), 'plain'); + assert.strictEqual(chatTextOf({ text: ' ' }), ''); + }); + + it('prefers AMP signer over actor.id and x-only-normalizes compressed keys', function () { + const compressed = '02' + 'aa'.repeat(32); + const xonly = 'aa'.repeat(32); + assert.strictEqual( + chatActorIdOf({ actor: { id: 'other' } }, { signer: compressed }), + xonly + ); + assert.strictEqual(chatActorIdOf({ actor: { id: xonly } }), xonly); + }); + + it('formats TUI/Hub-style log lines with alias or truncated id', function () { + assert.strictEqual(formatChatLogLine('aa'.repeat(32), 'neorion', 'hi'), '[neorion]: hi'); + assert.strictEqual( + formatChatLogLine('abcdefghij', null, 'yo'), + '[@abcdefghij]: yo' + ); + assert.strictEqual( + formatChatLogLine('abcdefghijklmnop', null, 'z', (id) => id.slice(0, 4)), + '[@abcd]: z' + ); + assert.strictEqual(formatChatLogLine(null, ' ', 'plain'), '[@]: plain'); + }); + + it('strips Blessed tags and control characters from alias/text', function () { + assert.strictEqual(sanitizeChatDisplayField('hi{bold}x{/bold}'), 'hiboldx/bold'); + assert.strictEqual( + formatChatLogLine('aa'.repeat(32), '{red-fg}evil{/red-fg}', 'ok\u0007{tag}'), + '[red-fgevil/red-fg]: oktag' + ); + }); +}); + +const { + CHAT_SURFACE, + classifyChatSurface, + isPublicMeshShoutbox, + isConfidentialChatSurface, + chatSurfaceLabel +} = require('../functions/fabricChatKind'); +const { + ONION_CHAT_SEAL_TYPE, + isOnionChatSeal, + sealOnionChatText, + tryOpenOnionChatText, + onionPathRecipientXOnly +} = require('../functions/onionChatSeal'); + +describe('fabricChatKind', function () { + it('classifies cleartext UTF-8 as public shoutbox', function () { + assert.strictEqual(classifyChatSurface('hello mesh'), CHAT_SURFACE.SHOUTBOX); + assert.ok(isPublicMeshShoutbox('hello mesh')); + assert.ok(!isConfidentialChatSurface('hello mesh')); + assert.match(chatSurfaceLabel(CHAT_SURFACE.SHOUTBOX), /Public/i); + }); + + it('classifies a missing body as unknown, not shoutbox', function () { + assert.strictEqual(classifyChatSurface(null), CHAT_SURFACE.UNKNOWN); + assert.strictEqual(classifyChatSurface(undefined), CHAT_SURFACE.UNKNOWN); + assert.ok(!isPublicMeshShoutbox(null)); + assert.ok(!isPublicMeshShoutbox(undefined)); + }); + + it('classifies onion-sealed bodies as confidential', function () { + const tip = new Key(); + const sealed = sealOnionChatText('secret', tip.pubkey); + assert.strictEqual(classifyChatSurface(sealed), CHAT_SURFACE.ONION_SEAL); + assert.ok(isConfidentialChatSurface(sealed)); + assert.ok(!isPublicMeshShoutbox(sealed)); + }); + + it('classifies GroupChat objects with and without seals', function () { + assert.strictEqual( + classifyChatSurface({ body: 'hi' }, { appType: 'GroupChat' }), + CHAT_SURFACE.CONTRACT_CLEARTEXT + ); + assert.strictEqual( + classifyChatSurface({ + type: 'GroupChat', + seal: { + scheme: 'aes-256-gcm-participant-v1', + ephemeralPub: 'aa', + wraps: [], + nonce: 'bb', + ciphertext: 'cc' + }, + body: '' + }), + CHAT_SURFACE.GROUP_SEAL + ); + }); +}); + +describe('@fabric/core onionChatSeal', function () { + it('seals to recipient and opens only with matching key', function () { + const dest = new Key(); + const other = new Key(); + const wire = sealOnionChatText('secret-hop', dest.pubkey); + assert.ok(isOnionChatSeal(wire)); + assert.ok(wire.includes(ONION_CHAT_SEAL_TYPE)); + assert.ok(!wire.includes('secret-hop')); + + const ok = tryOpenOnionChatText(wire, { keyOrPrivate: dest }); + assert.strictEqual(ok.sealed, true); + assert.strictEqual(ok.opened, true); + assert.strictEqual(ok.text, 'secret-hop'); + + const bad = tryOpenOnionChatText(wire, { keyOrPrivate: other }); + assert.strictEqual(bad.sealed, true); + assert.strictEqual(bad.opened, false); + assert.strictEqual(bad.text, null); + }); + + it('onionPathRecipientXOnly uses path tip', function () { + const a = new Key(); + const b = new Key(); + const tip = onionPathRecipientXOnly([ + xOnlyFromKey(a).toString('hex'), + xOnlyFromKey(b).toString('hex') + ]); + assert.strictEqual(tip, xOnlyFromKey(b).toString('hex')); + + const compressed = Buffer.from(b.public.encodeCompressed('hex'), 'hex'); + assert.strictEqual(onionPathRecipientXOnly([compressed]), xOnlyFromKey(b).toString('hex')); + assert.strictEqual(onionPathRecipientXOnly([xOnlyFromKey(b)]), xOnlyFromKey(b).toString('hex')); + }); + + it('Peer peels sealed onion chat and emits plaintext', function () { + const destKey = new Key(); + const originKey = new Key(); + const peer = new Peer(offlinePeerSettings({ + key: { mnemonic: destKey.mnemonic } + })); + const addr = '127.0.0.1:9300'; + peer.connections[addr] = { _writeFabric () {}, destroy () {} }; + peer.peers[addr] = { id: 'relay', publicKey: originKey.public.encodeCompressed('hex') }; + + // Seal to the Peer's Fabric protocol pubkey (Identity#fabricKey), not BIP44 master. + const sealed = sealOnionChatText('sealed-via-onion', peer.key.pubkey); + const payload = Message.fromVector(['P2P_CHAT_MESSAGE', sealed]); + payload.signWithKey(originKey); + const outer = wrapOnionPath({ + path: [xOnlyFromKey(peer.key)], + payload, + key: originKey + }); + + const chats = []; + peer.on('chat', (body, meta) => chats.push({ body, meta })); + peer._handleFabricMessage(outer.toBuffer(), { name: addr }, null); + + assert.strictEqual(chats.length, 1); + assert.strictEqual(chats[0].body.text, 'sealed-via-onion'); + assert.strictEqual(chats[0].body.onionSealed, true); + assert.strictEqual(chats[0].meta.onionSealed, true); + }); + + it('Peer drops sealed chat it cannot open', function () { + const destKey = new Key(); + const wrongPeer = new Peer(offlinePeerSettings()); + const originKey = new Key(); + const addr = '127.0.0.1:9301'; + wrongPeer.connections[addr] = { _writeFabric () {}, destroy () {} }; + wrongPeer.peers[addr] = { id: 'relay', publicKey: originKey.public.encodeCompressed('hex') }; + + const sealed = sealOnionChatText('not-for-you', destKey.pubkey); + const payload = Message.fromVector(['P2P_CHAT_MESSAGE', sealed]); + payload.signWithKey(originKey); + const outer = wrapOnionPath({ + path: [xOnlyFromKey(wrongPeer.key)], + payload, + key: originKey + }); + + const chats = []; + wrongPeer.on('chat', (body) => chats.push(body)); + wrongPeer._handleFabricMessage(outer.toBuffer(), { name: addr }, null); + assert.strictEqual(chats.length, 0); + }); + + it('multi-hop: curious relay can recurse to chat body but sees ciphertext only', function () { + const originKey = new Key(); + const relay = new Peer(offlinePeerSettings()); + const destKey = new Key(); + const dest = new Peer(offlinePeerSettings({ key: { mnemonic: destKey.mnemonic } })); + const fromAddr = '127.0.0.1:9310'; + const destAddr = '127.0.0.1:9311'; + const destWrites = []; + + relay.connections[fromAddr] = { _writeFabric () {}, destroy () {} }; + relay.connections[destAddr] = { + _writeFabric (buf) { destWrites.push(buf); }, + destroy () {} + }; + relay.peers[fromAddr] = { id: 'src', publicKey: originKey.public.encodeCompressed('hex') }; + relay.peers[destAddr] = { id: 'dest', publicKey: dest.key.public.encodeCompressed('hex') }; + + const plaintext = 'multi-hop-secret'; + const sealed = sealOnionChatText(plaintext, dest.key.pubkey); + const payload = Message.fromVector(['P2P_CHAT_MESSAGE', sealed]).signWithKey(originKey); + const outer = wrapOnionPath({ + path: [xOnlyFromKey(relay.key), xOnlyFromKey(dest.key)], + payload, + key: originKey + }); + + const relayChats = []; + relay.on('chat', (body) => relayChats.push(body)); + relay._handleFabricMessage(outer.toBuffer(), { name: fromAddr }, null); + + assert.strictEqual(relayChats.length, 0, 'relay must not emit chat on mid-hop peel'); + assert.strictEqual(destWrites.length, 1); + + // Snooping relay: recurse nested P2P_FORWARD inners until the application Message. + let cursor = Message.fromBuffer(destWrites[0]); + for (let i = 0; i < 8; i++) { + const fields = tryDecodeForward(cursor); + if (!fields) break; + cursor = Message.fromBuffer(fields.inner); + } + assert.strictEqual(cursor.type, 'P2P_CHAT_MESSAGE'); + const snooped = cursor.data.toString('utf8'); + assert.ok(isOnionChatSeal(snooped)); + assert.ok(!snooped.includes(plaintext)); + assert.strictEqual(tryOpenOnionChatText(snooped, { keyOrPrivate: relay.key }).opened, false); + + const tipChats = []; + dest.connections[destAddr] = { _writeFabric () {}, destroy () {} }; + dest.peers[destAddr] = { id: 'relay', publicKey: relay.key.public.encodeCompressed('hex') }; + dest.on('chat', (body) => tipChats.push(body)); + dest._handleFabricMessage(destWrites[0], { name: destAddr }, null); + assert.strictEqual(tipChats.length, 1); + assert.strictEqual(tipChats[0].text, plaintext); + assert.strictEqual(tipChats[0].onionSealed, true); + }); + + it('mesh flood P2P_CHAT_MESSAGE stays cleartext (no onion seal path)', function () { + const peer = new Peer(offlinePeerSettings()); + const author = new Key(); + const addr = '127.0.0.1:9320'; + const peersWrites = []; + peer.connections[addr] = { + _writeFabric (buf) { peersWrites.push(buf); }, + destroy () {} + }; + peer.peers[addr] = { id: 'neighbor', publicKey: author.public.encodeCompressed('hex') }; + peer._addressToId[addr] = 'neighbor'; + peer._state.peers = { + neighbor: { + id: 'neighbor', + address: addr, + score: 100, + publicKey: author.public.encodeCompressed('hex') + } + }; + + const clear = 'mesh-cleartext-hello'; + const msg = Message.fromVector(['P2P_CHAT_MESSAGE', clear]).signWithKey(author); + const chats = []; + peer.on('chat', (body, meta) => chats.push({ body, meta })); + peer._handleFabricMessage(msg.toBuffer(), { name: addr }, null); + + assert.strictEqual(chats.length, 1); + assert.strictEqual(chats[0].body.text, clear); + assert.ok(!chats[0].body.onionSealed); + assert.strictEqual(isOnionChatSeal(clear), false); + }); +}); + +const { + SEAL_SCHEME, + SEAL_SCHEME_PARTICIPANT, + sortedMemberXOnlys, + deriveTimelineCipherKey, + sealGroupChatBody, + sealParticipantGroupChatBody, + openGroupChatBody, + isSealedGroupChat, + isParticipantSealedGroupChat, + pubkeyXOnly +} = require('../functions/groupChatSeal'); + +describe('@fabric/core groupChatSeal', function () { + const a = new Key(); + const b = new Key(); + const contractId = 'a'.repeat(64); + const digest = require('crypto').createHash('sha256').update('tip-content').digest('hex'); + + it('sortedMemberXOnlys normalizes compressed and x-only', function () { + const xa = pubkeyXOnly(a.pubkey); + assert.ok(xa && xa.length === 64); + assert.strictEqual(pubkeyXOnly(`02${xa}`), xa); + assert.strictEqual(pubkeyXOnly(`03${xa}`), xa); + const list = sortedMemberXOnlys([b.pubkey, a.pubkey, xa, 'nope']); + assert.strictEqual(list.length, 2); + assert.ok(list.includes(xa)); + assert.ok(list.includes(pubkeyXOnly(b.pubkey))); + assert.deepStrictEqual(list, list.slice().sort()); + }); + + it('deriveTimelineCipherKey is deterministic for same tip+members', function () { + const opts = { + contractId, + clock: 7, + stateDigest: digest, + memberPubkeys: [a.pubkey, b.pubkey] + }; + const k1 = deriveTimelineCipherKey(opts); + const k2 = deriveTimelineCipherKey({ + ...opts, + memberPubkeys: [b.pubkey, a.pubkey] + }); + assert.ok(Buffer.isBuffer(k1) && k1.length === 32); + assert.strictEqual(k1.toString('hex'), k2.toString('hex')); + }); + + it('deriveTimelineCipherKey changes when tip or roster changes', function () { + const base = { + contractId, + clock: 7, + stateDigest: digest, + memberPubkeys: [a.pubkey, b.pubkey] + }; + const k0 = deriveTimelineCipherKey(base); + const kClock = deriveTimelineCipherKey({ ...base, clock: 8 }); + const c = new Key(); + const kMembers = deriveTimelineCipherKey({ + ...base, + memberPubkeys: [a.pubkey, b.pubkey, c.pubkey] + }); + assert.notStrictEqual(k0.toString('hex'), kClock.toString('hex')); + assert.notStrictEqual(k0.toString('hex'), kMembers.toString('hex')); + }); + + it('deriveTimelineCipherKey rejects missing or non-integer clocks', function () { + const base = { + contractId, + stateDigest: digest, + memberPubkeys: [a.pubkey] + }; + assert.throws(() => deriveTimelineCipherKey({ ...base }), /non-negative integer/); + assert.throws(() => deriveTimelineCipherKey({ ...base, clock: null }), /non-negative integer/); + assert.throws(() => deriveTimelineCipherKey({ ...base, clock: 'nope' }), /non-negative integer/); + assert.throws(() => deriveTimelineCipherKey({ ...base, clock: 1.5 }), /non-negative integer/); + assert.throws(() => deriveTimelineCipherKey({ ...base, clock: -1 }), /non-negative integer/); + const ok = deriveTimelineCipherKey({ ...base, clock: 0 }); + assert.ok(Buffer.isBuffer(ok) && ok.length === 32); + }); + + it('seal/open round-trip (v1 tip)', function () { + const tip = { + contractId, + clock: 2, + stateDigest: digest, + memberPubkeys: [a.pubkey, b.pubkey] + }; + const seal = sealGroupChatBody({ body: 'hello sealed group', ...tip }); + assert.strictEqual(seal.scheme, SEAL_SCHEME); + assert.strictEqual(seal.basisClock, 2); + assert.ok(isSealedGroupChat({ seal })); + const plain = openGroupChatBody(seal, tip); + assert.strictEqual(plain, 'hello sealed group'); + }); + + it('open fails on wrong tip clock', function () { + const tip = { + contractId, + clock: 2, + stateDigest: digest, + memberPubkeys: [a.pubkey, b.pubkey] + }; + const seal = sealGroupChatBody({ body: 'secret', ...tip }); + assert.throws(() => openGroupChatBody(seal, { ...tip, clock: 3 }), /clock/); + }); + + it('open fails on wrong stateDigest, roster, or tampered ciphertext', function () { + const tip = { + contractId, + clock: 2, + stateDigest: digest, + memberPubkeys: [a.pubkey, b.pubkey] + }; + const seal = sealGroupChatBody({ body: 'secret', ...tip }); + + const otherDigest = require('crypto').createHash('sha256').update('other-tip').digest('hex'); + assert.throws( + () => openGroupChatBody(seal, { ...tip, stateDigest: otherDigest }), + /stateDigest/ + ); + + // Same clock/digest but different roster → different HKDF salt → AES-GCM auth fail. + const c = new Key(); + assert.throws( + () => openGroupChatBody(seal, { + ...tip, + memberPubkeys: [a.pubkey, b.pubkey, c.pubkey] + }), + /auth|Unsupported state|bad decrypt|unable to authenticate/i + ); + + const ct = Buffer.from(seal.ciphertext, 'base64'); + ct[0] ^= 0xff; + const tamperedSeal = { ...seal, ciphertext: ct.toString('base64') }; + assert.throws( + () => openGroupChatBody(tamperedSeal, tip), + /auth|Unsupported state|bad decrypt|unable to authenticate/i + ); + }); + + it('participant seal opens for each member and not for outsiders', function () { + const seal = sealParticipantGroupChatBody({ + body: 'hub-blind ops', + memberPubkeys: [a.pubkey, b.pubkey], + contractId, + clock: 9, + stateDigest: digest + }); + assert.strictEqual(seal.scheme, SEAL_SCHEME_PARTICIPANT); + assert.ok(isParticipantSealedGroupChat(seal)); + assert.ok(isSealedGroupChat({ seal })); + assert.ok(Array.isArray(seal.wraps) && seal.wraps.length === 2); + assert.ok(seal.ephemeralPub); + // Tip fields on the seal are bound as AES-GCM AAD (not key material). + assert.strictEqual(seal.basisClock, 9); + assert.strictEqual(seal.contractId, contractId); + assert.strictEqual(seal.stateDigest, digest); + + assert.throws( + () => sealParticipantGroupChatBody({ + body: 'x', + memberPubkeys: [a.pubkey], + clock: -1 + }), + /non-negative integer/ + ); + assert.throws( + () => sealParticipantGroupChatBody({ + body: 'x', + memberPubkeys: [a.pubkey], + clock: 1.5 + }), + /non-negative integer/ + ); + assert.throws( + () => sealParticipantGroupChatBody({ + body: 'x', + memberPubkeys: [a.pubkey], + clock: 'nope' + }), + /non-negative integer/ + ); + + assert.strictEqual( + openGroupChatBody(seal, { keyOrPrivate: a }), + 'hub-blind ops' + ); + assert.strictEqual( + openGroupChatBody(seal, { keyOrPrivate: b }), + 'hub-blind ops' + ); + + const outsider = new Key(); + assert.throws( + () => openGroupChatBody(seal, { keyOrPrivate: outsider }), + /no wrap/ + ); + + // Cut-and-paste across contract id fails AAD auth even with a valid wrap key. + assert.throws( + () => openGroupChatBody({ ...seal, contractId: 'b'.repeat(64) }, { keyOrPrivate: a }), + /auth|Unsupported state|bad decrypt|unable to authenticate/i + ); + }); + + it('participant seal does not use tip HKDF (hub tip holders stay blind)', function () { + const seal = sealGroupChatBody({ + mode: 'participant', + body: 'still secret', + memberPubkeys: [a.pubkey, b.pubkey], + contractId, + clock: 1, + stateDigest: digest + }); + // ECDH wrap opens without supplying tip opts; tip holders without privkeys stay blind. + assert.ok(a.xprv, 'fixture Key must expose xprv for the wrap-only open path'); + assert.strictEqual( + openGroupChatBody(seal, { keyOrPrivate: { xprv: a.xprv } }), + 'still secret' + ); + }); +}); diff --git a/tests/peering.cross-implementation.js b/tests/peering.cross-implementation.js index a24187631..5883ed39d 100644 --- a/tests/peering.cross-implementation.js +++ b/tests/peering.cross-implementation.js @@ -120,12 +120,60 @@ describe('peering (cross-implementation)', function () { server._handleFabricMessage(msg.toBuffer(), { name: connAddress }, null); assert.ok(chatReceived); - const restored = Message.fromBuffer(msg.toBuffer()); - assert.ok(restored.verifyWithKey(new Key({ public: clientKey.pubkey }))); + assert.ok(Message.fromBuffer(msg.toBuffer()).verifyWithKey(new Key({ public: clientKey.pubkey }))); peers.push(server); }); - it('rejects message with incorrect body hash (wire integrity)', function () { + it('drops chat when the AMP signature does not verify', function () { + const server = new Peer({ listen: false, peersDb: null }); + const clientKey = new Key(); + const connAddress = '127.0.0.1:9998'; + server.connections[connAddress] = { _writeFabric: () => {}, destroy: () => {} }; + server.peers[connAddress] = { id: 'client-1', publicKey: clientKey.pubkey }; + server._addressToId[connAddress] = 'sig-peer'; + server._state.peers = { + 'sig-peer': { id: 'sig-peer', address: connAddress, score: 200, publicKey: clientKey.pubkey } + }; + + const msg = Message.fromVector(['P2P_CHAT_MESSAGE', 'signed']).signWithKey(clientKey); + const buf = Buffer.from(msg.toBuffer()); + // Flip a signature byte in the AMP header (bytes 8–71). + buf[16] ^= 0xff; + + let chatReceived = null; + server.once('chat', (m) => { chatReceived = m; }); + server._handleFabricMessage(buf, { name: connAddress }, null); + assert.strictEqual(chatReceived, null); + peers.push(server); + }); + + it('rejects an on-wire body-hash mismatch (XORed payload bytes)', function () { + const peer = new Peer({ listen: false, peersDb: null }); + const origin = '127.0.0.1:9997'; + peer.connections[origin] = { _writeFabric: () => {}, destroy: () => {} }; + peer.peers[origin] = { id: 'p', publicKey: peer.key.pubkey }; + peer._addressToId[origin] = 'bh-peer'; + peer._state.peers = { + 'bh-peer': { id: 'bh-peer', address: origin, score: 200, publicKey: peer.key.pubkey } + }; + + const msg = Message.fromVector(['P2P_CHAT_MESSAGE', 'x']).signWithKey(peer.key); + const buf = Buffer.from(msg.toBuffer()); + if (buf.length > 208) buf[208] ^= 0xff; + + let chatReceived = null; + let warned = false; + peer.once('chat', (m) => { chatReceived = m; }); + peer.on('warning', (w) => { + if (/body hash mismatch/i.test(String(w))) warned = true; + }); + peer._handleFabricMessage(buf, { name: origin }, null); + assert.ok(warned, 'expected warning when on-wire body hash does not match payload'); + assert.strictEqual(chatReceived, null); + peers.push(peer); + }); + + it('compares parsed header hash after Message.fromBuffer (post-parse oracle)', function () { const peer = new Peer({ listen: false, peersDb: null }); const msg = Message.fromVector(['P2P_CHAT_MESSAGE', 'x']); msg.signWithKey(peer.key); diff --git a/tests/playnet.contract.publish.integration.js b/tests/playnet.contract.publish.integration.js deleted file mode 100644 index 5fe0d1cf8..000000000 --- a/tests/playnet.contract.publish.integration.js +++ /dev/null @@ -1,155 +0,0 @@ -'use strict'; - -/** - * Playnet-oriented CONTRACT_PUBLISH → register → re-publish integration. - * - * Always runs offline (in-process Peer). Optional live dial when - * FABRIC_PLAYNET_PUBLISH=1 (connects FABRIC_PLAYNET_PEERS, default - * 127.0.0.1:7777) — skipped unless that env is set. Live mode also requires - * an explicit FABRIC_MNEMONIC (no public fallback). - * - * npm test -- --grep 'playnet contract publish' - * FABRIC_PLAYNET_PUBLISH=1 FABRIC_MNEMONIC='…' npm test -- --grep 'playnet contract publish live' - */ - -const assert = require('assert'); -const Peer = require('../types/peer'); -const Key = require('../types/key'); -const Message = require('../types/message'); -const Actor = require('../types/actor'); - -describe('playnet contract publish + re-publish', function () { - it('publishes, registers Actor id, and re-publishes without allow-list hijack', function () { - const peer = new Peer({ listen: false, peersDb: null, networking: false }); - const owner = new Key(); - const attacker = new Key(); - const ownerPub = String(owner.pubkey).toLowerCase(); - const attackerPub = String(attacker.pubkey).toLowerCase(); - - const definition = { - name: 'PlaynetDemoContract', - version: 1, - messageTypes: ['GroupChat', 'GroupChange'], - parties: [ownerPub], - validators: [ownerPub], - threshold: 1, - state: { network: 'regtest', label: 'playnet-leader' } - }; - const contractId = new Actor(definition).id; - assert.match(contractId, /^[0-9a-f]{64}$/); - - let publishes = 0; - peer.on('contract:publish', () => { publishes++; }); - - const wire = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(definition)]).signWithKey(owner); - peer._handleFabricMessage(wire.toBuffer(), { name: '127.0.0.1:18444' }); - - assert.ok(peer.contracts[contractId] || Object.keys(peer.contracts || {}).length >= 1); - const id = peer.contracts[contractId] ? contractId : Object.keys(peer.contracts)[0]; - assert.ok(peer._signerMayPatchContract(id, ownerPub)); - assert.strictEqual(peer._signerMayPatchContract(id, attackerPub), false); - - // Re-publish identical body (owner) — first-writer-wins / stable registration. - const again = peer._registerContract(definition, ownerPub); - assert.strictEqual(again, true); - assert.ok(peer._signerMayPatchContract(id, ownerPub)); - - // Attacker re-signs identical body — must not gain patch rights. - assert.strictEqual(peer._registerContract(definition, attackerPub), true); - assert.strictEqual( - peer._signerMayPatchContract(id, attackerPub), - false, - 're-publish must not grant patch rights to a new wire signer' - ); - - assert.ok(publishes >= 0); - }); - - it('broadcast writes CONTRACT_PUBLISH frames to connected peers', function () { - const peer = new Peer({ listen: false, peersDb: null, networking: false }); - const writes = []; - peer.connections['127.0.0.1:7777'] = { - _writeFabric (buf) { writes.push(Buffer.isBuffer(buf) ? Buffer.from(buf) : Buffer.from(buf)); } - }; - const owner = new Key(); - const definition = { - name: 'PlaynetWriteAssert', - version: 1, - parties: [String(owner.pubkey).toLowerCase()], - state: { network: 'regtest' } - }; - const msg = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(definition)]).signWithKey(owner); - const sent = peer.broadcast(msg.toBuffer()); - assert.ok(sent > 0, `expected CONTRACT_PUBLISH fan-out > 0, got ${sent}`); - assert.strictEqual(writes.length, 1); - const parsed = Message.fromBuffer(writes[0]); - assert.strictEqual(parsed.type, 'CONTRACT_PUBLISH'); - }); - - it('deterministic Actor id is stable across JSON clone (re-publish body)', function () { - const owner = new Key(); - const ownerPub = String(owner.pubkey).toLowerCase(); - const definition = { - name: 'PlaynetDemoContract', - version: 1, - messageTypes: ['GroupChat'], - parties: [ownerPub], - state: {} - }; - const a = new Actor(definition).id; - const b = new Actor(JSON.parse(JSON.stringify(definition))).id; - assert.strictEqual(a, b); - }); -}); - -describe('playnet contract publish live', function () { - const runLive = process.env.FABRIC_PLAYNET_PUBLISH === '1' || process.env.FABRIC_PLAYNET_PUBLISH === 'true'; - const mnemonic = String(process.env.FABRIC_MNEMONIC || '').trim(); - - (runLive && mnemonic ? it : it.skip)('dials configured peers and emits CONTRACT_PUBLISH', async function () { - this.timeout(60000); - const peers = String(process.env.FABRIC_PLAYNET_PEERS || '127.0.0.1:7777') - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - - const peer = new Peer({ - listen: false, - networking: true, - peers, - key: { mnemonic }, - peersDb: null - }); - peer.on('error', () => {}); - try { - await peer.start(); - - const deadline = Date.now() + Number(process.env.FABRIC_PLAYNET_WAIT_MS || 20000); - while (Date.now() < deadline && Object.keys(peer.connections || {}).length === 0) { - await new Promise((r) => setTimeout(r, 250)); - } - const conns = Object.keys(peer.connections || {}); - assert.ok(conns.length > 0, `expected at least one connection to ${peers.join(',')}`); - - const ownerPub = String(peer.key.pubkey).toLowerCase(); - const definition = { - name: 'PlaynetLivePublish', - version: 1, - parties: [ownerPub], - state: { network: process.env.FABRIC_FLUSH_NETWORK || 'regtest' } - }; - const msg = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(definition)]).signWithKey(peer.key); - const sent1 = peer.broadcast(msg.toBuffer()); - assert.ok(sent1 > 0, `expected CONTRACT_PUBLISH fan-out > 0, got ${sent1}`); - - // Brief observe, then re-publish once. - await new Promise((r) => setTimeout(r, 1000)); - const msg2 = Message.fromVector(['CONTRACT_PUBLISH', JSON.stringify(definition)]).signWithKey(peer.key); - const sent2 = peer.broadcast(msg2.toBuffer()); - assert.ok(sent2 > 0, `expected second CONTRACT_PUBLISH fan-out > 0, got ${sent2}`); - await new Promise((r) => setTimeout(r, 1000)); - } finally { - await peer.stop(); - } - }); -}); diff --git a/tests/protocol-v1/helpers.js b/tests/protocol-v1/helpers.js deleted file mode 100644 index 77da79efc..000000000 --- a/tests/protocol-v1/helpers.js +++ /dev/null @@ -1,429 +0,0 @@ -'use strict'; - -/** - * @fileoverview Strict Protocol V1 test helpers. - * - * Contract: every injected frame is a raw, length-bounded, well-formed Fabric - * {@link Message} (valid body hash + BIP-340 unless the case is integrity-fail). - * Delivery modes exercise the meshDeliveryContext trust boundary. - */ - -const assert = require('assert'); -const Key = require('../../types/key'); -const Message = require('../../types/message'); -const Peer = require('../../types/peer'); -const { wrapOnionPath, xOnlyFromKey } = require('../../functions/fabricOnion'); -const { offlinePeerSettings } = require('../helpers/peer'); - -/** Curated mesh / identity / control opcodes for the V1 delivery matrix. */ -const BASE_V1_TYPES = Object.freeze([ - 'P2P_PING', - 'P2P_CHAT_MESSAGE', - 'P2P_PEER_GOSSIP', - 'P2P_PEERING_OFFER', - 'P2P_PEER_ALIAS', - 'P2P_PEER_ANNOUNCE', - 'P2P_INVENTORY_REQUEST', - 'P2P_SESSION_OFFER', - 'P2P_BASE_MESSAGE' -]); - -/** Additional first-class opcodes covered by the extended matrix. */ -const EXTENDED_V1_TYPES = Object.freeze([ - 'CONTRACT_PUBLISH', - 'CONTRACT_MESSAGE', - 'DocumentRequest', - 'P2P_FILE_SEND', - 'P2P_FLUSH_CHAIN', - 'BitcoinBlock' -]); - -/** Unregistered opcode used to probe UNKNOWN_MESSAGE handling. */ -const UNKNOWN_OPCODE = 0x00abcdef; - -function offlinePeer (overrides = {}) { - return new Peer(offlinePeerSettings(overrides)); -} - -function wireConn (writes) { - return { - _writeFabric (buf) { writes.push(Buffer.isBuffer(buf) ? Buffer.from(buf) : Buffer.from(buf)); }, - destroy () { this.destroyed = true; }, - destroyed: false - }; -} - -/** - * Build a signed AMP frame. - * @param {object} key Fabric Key - * @param {string} type Wire type name - * @param {string|object|Buffer} body UTF-8 string, JSON object, or raw bytes - * @returns {Message} - */ -function signFrame (key, type, body) { - let data; - if (Buffer.isBuffer(body) || body instanceof Uint8Array) { - data = Buffer.from(body); - } else if (typeof body === 'string') { - data = body; - } else { - data = JSON.stringify(body == null ? {} : body); - } - return Message.fromVector([type, data]).signWithKey(key); -} - -/** - * Signed frame whose on-wire opcode is not in the canonical catalog. - * Bypasses the type setter (which remaps unknown names to P2P_BASE_MESSAGE). - * @param {object} key - * @param {number} [opcode] - * @param {string|object} [body] - * @returns {Message} - */ -function signUnknownOpcode (key, opcode = UNKNOWN_OPCODE, body = { probe: true }) { - const data = typeof body === 'string' ? body : JSON.stringify(body); - const msg = Message.fromVector(['P2P_PING', data]); - msg.raw.type.writeUInt32BE((opcode >>> 0), 0); - return msg.signWithKey(key); -} - -/** - * Default ok bodies per type for matrix rows. - * @param {string} type - * @param {object} [opts] - * @returns {string|object} - */ -function defaultOkBody (type, opts = {}) { - const nonce = opts.nonce != null ? opts.nonce : `n-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; - switch (type) { - case 'P2P_PING': - return { created: new Date().toISOString(), nonce }; - case 'P2P_CHAT_MESSAGE': - return `v1-chat-${nonce}`; - case 'P2P_PEER_GOSSIP': - return { host: '198.51.100.50', port: 7777, gossipHop: 5, nonce }; - case 'P2P_PEERING_OFFER': - return { - transport: 'fabric', - host: opts.host || '203.0.113.50', - port: opts.port != null ? opts.port : 7777, - peeringHop: 5, - nonce - }; - case 'P2P_PEER_ALIAS': - return `alias-${nonce}`.slice(0, 32); - case 'P2P_PEER_ANNOUNCE': - return { host: opts.host || '203.0.113.60', port: opts.port != null ? opts.port : 7778, nonce }; - case 'P2P_INVENTORY_REQUEST': - return { type: 'INVENTORY_REQUEST', object: { kind: 'documents', maxSats: 1, nonce } }; - case 'P2P_SESSION_OFFER': { - const attacker = opts.sessionKey || new Key(); - const attackerId = opts.sessionId || `v1-session-${nonce}`; - // Peer builds proof with its own method; for matrix we only need a signed shell — - // peel/RELAY must not rebind regardless of proof validity. - return { - type: 'P2P_SESSION_OFFER', - actor: { - id: attackerId, - pubkey: attacker.pubkey, - parentPubkey: attacker.pubkey, - parentSignature: '00'.repeat(64) - }, - object: { challenge: nonce } - }; - } - case 'P2P_BASE_MESSAGE': - return { type: opts.innerType || 'UNKNOWN_CHAOS', object: { nonce }, note: 'v1' }; - case 'CONTRACT_PUBLISH': { - const pk = opts.publisherPubkey || (opts.sessionKey && opts.sessionKey.pubkey) || 'ab'.repeat(33); - return { - id: opts.contractId || `v1-contract-${nonce}`, - state: { v: 1, nonce }, - parties: [String(pk).toLowerCase()] - }; - } - case 'CONTRACT_MESSAGE': - return { - contract: opts.contractId || `v1-contract-${nonce}`, - type: 'GroupChat', - body: `hello-${nonce}`, - ops: opts.ops || [] - }; - case 'DocumentRequest': - return { document: opts.documentId || `doc-${nonce}` }; - case 'P2P_FILE_SEND': - return { - documentId: opts.documentId || `doc-${nonce}`, - blobIndex: 0, - total: 1, - bytes: Buffer.from(`blob-${nonce}`).toString('base64') - }; - case 'P2P_FLUSH_CHAIN': - return { - snapshotBlockHash: opts.snapshotBlockHash || 'aa'.repeat(32), - nonce - }; - case 'BitcoinBlock': - return { - hash: opts.blockHash || 'bb'.repeat(32), - height: opts.height != null ? opts.height : 1, - nonce - }; - default: - return { type, object: { nonce } }; - } -} - -/** - * Hostile-but-parseable bodies (valid JSON / UTF-8, adversarial intent). - * @param {string} type - * @returns {string|object} - */ -function hostileBody (type) { - switch (type) { - case 'P2P_PEERING_OFFER': - return { transport: 'fabric', host: '127.0.0.1', port: 1, peeringHop: 5 }; - case 'P2P_PEER_GOSSIP': - return { host: '0.0.0.0', port: 0, gossipHop: 5, nonce: 'hostile-gossip' }; - case 'P2P_PEER_ANNOUNCE': - return { host: '255.255.255.255', port: 65535 }; - case 'P2P_CHAT_MESSAGE': - return `{"type":"INVENTORY_REQUEST","object":{"offerBtc":true}}`; - case 'P2P_BASE_MESSAGE': - return { - type: 'INVENTORY_REQUEST', - object: { offerBtc: true, maxSats: 1e12, escalate: true } - }; - case 'P2P_INVENTORY_REQUEST': - return { type: 'INVENTORY_REQUEST', object: { offerBtc: true, maxSats: 1e12 } }; - case 'P2P_PEER_ALIAS': - return `{"not":"an-alias"}`; - case 'P2P_SESSION_OFFER': - return { - type: 'P2P_SESSION_OFFER', - actor: { id: 'rebind-me', pubkey: 'ab'.repeat(33), parentPubkey: 'cd'.repeat(33) }, - object: { challenge: 'hostile' } - }; - case 'CONTRACT_PUBLISH': - return { id: 'hostile-cpub', state: {}, parties: ['ee'.repeat(33)] }; - case 'CONTRACT_MESSAGE': - return { - contract: 'missing-ns', - ops: [{ op: 'replace', path: '/balance', value: 1e9 }] - }; - case 'DocumentRequest': - return { document: '../etc/passwd' }; - case 'P2P_FILE_SEND': - return { documentId: 'x', blobIndex: 99, total: 1, bytes: '!!!!' }; - case 'P2P_FLUSH_CHAIN': - return { snapshotBlockHash: 'not-a-hash' }; - case 'BitcoinBlock': - return { hash: 'zz', height: -1 }; - default: - return defaultOkBody(type, { nonce: 'hostile' }); - } -} - -/** - * Flip a body byte after the header so header.hash mismatches (integrity fail). - * @param {Message|Buffer} msgOrBuf - * @returns {Buffer} - */ -function corruptBodyHash (msgOrBuf) { - const buf = Buffer.isBuffer(msgOrBuf) - ? Buffer.from(msgOrBuf) - : Buffer.from(msgOrBuf.toBuffer()); - if (buf.length > 208) buf[208] ^= 0xff; - return buf; -} - -/** - * Flip a signature byte (BIP-340 verify fail). - * @param {Message|Buffer} msgOrBuf - * @returns {Buffer} - */ -function corruptSignature (msgOrBuf) { - const buf = Buffer.isBuffer(msgOrBuf) - ? Buffer.from(msgOrBuf) - : Buffer.from(msgOrBuf.toBuffer()); - if (buf.length >= 208) buf[144] ^= 0xff; - return buf; -} - -/** - * @typedef {'direct'|'foreignRelay'|'peel'} DeliveryMode - */ - -/** - * Deliver a signed application Message under a delivery mode. - * @param {object} opts - * @param {Peer} opts.peer - * @param {Message} opts.inner signed application frame - * @param {DeliveryMode} opts.mode - * @param {object} opts.authorKey signer of the application (and foreign RELAY outer) - * @param {object} [opts.forwarderKey] TCP-pinned key (foreignRelay / peel last hop) - * @param {object} [opts.destKey] local peer key for peel (must match peer identity) - * @param {string} [opts.originName] - * @param {string[]} [opts.otherEdges] other connection addresses to observe mesh writes - * @returns {{ originName: string, edgeWrites: Record, forwarderKey: object }} - */ -function deliver (opts = {}) { - const authorKey = opts.authorKey || new Key(); - const forwarderKey = opts.forwarderKey || new Key(); - const mode = opts.mode || 'direct'; - const originName = opts.originName || '127.0.0.1:17000'; - const peer = opts.peer; - const otherEdges = Array.isArray(opts.otherEdges) ? opts.otherEdges : ['127.0.0.1:17001']; - - const edgeWrites = {}; - peer.connections[originName] = wireConn([]); - for (const edge of otherEdges) { - edgeWrites[edge] = []; - peer.connections[edge] = wireConn(edgeWrites[edge]); - if (!peer.peers[edge]) { - peer.peers[edge] = { id: edge, publicKey: new Key().pubkey }; - } - } - - const pinKey = (mode === 'direct') ? authorKey : forwarderKey; - peer.peers[originName] = { id: originName, publicKey: pinKey.pubkey }; - peer._addressToId[originName] = peer._addressToId[originName] || `peer-${originName}`; - if (!peer._state.peers[peer._addressToId[originName]]) { - peer._state.peers[peer._addressToId[originName]] = { - id: peer._addressToId[originName], - address: originName, - score: 200, - publicKey: pinKey.pubkey - }; - } - - const before = snapshotSideEffects(peer, originName, edgeWrites); - - let wire; - if (opts.rawWire) { - wire = Buffer.isBuffer(opts.rawWire) ? opts.rawWire : Buffer.from(opts.rawWire); - } else if (mode === 'direct') { - wire = opts.inner.toBuffer(); - } else if (mode === 'foreignRelay') { - wire = Message.fromVector(['P2P_RELAY', opts.inner.toBuffer()]).signWithKey(authorKey).toBuffer(); - } else if (mode === 'peel') { - const destKey = opts.destKey; - if (!destKey) throw new Error('deliver peel requires destKey matching peer identity'); - wire = wrapOnionPath({ - path: [xOnlyFromKey(destKey)], - payload: opts.inner, - key: authorKey - }).toBuffer(); - } else { - throw new Error(`unknown delivery mode: ${mode}`); - } - - peer._handleFabricMessage(wire, { name: originName }, null); - const after = snapshotSideEffects(peer, originName, edgeWrites); - return { - originName, - edgeWrites, - forwarderKey: pinKey, - authorKey, - before, - after - }; -} - -/** - * Snapshot peer side-effect surface for invariant checks. - * @param {Peer} peer - * @param {string} originName - * @param {Record} [edgeWrites] - */ -function snapshotSideEffects (peer, originName, edgeWrites = {}) { - const regId = peer._addressToId && peer._addressToId[originName]; - const reg = regId && peer._state && peer._state.peers && peer._state.peers[regId]; - const conn = peer.connections && peer.connections[originName]; - const edgeCounts = {}; - for (const [k, arr] of Object.entries(edgeWrites)) { - edgeCounts[k] = Array.isArray(arr) ? arr.length : 0; - } - return { - candidates: (peer.candidates && peer.candidates.length) || 0, - candidateKeys: peer._candidateKeys ? [...peer._candidateKeys].sort() : [], - score: reg && typeof reg.score === 'number' ? reg.score : null, - banned: !!(peer._isPeerBanned && peer._isPeerBanned(originName)), - alias: conn && conn._alias ? String(conn._alias) : null, - addressMap: regId || null, - pin: peer.peers && peer.peers[originName] && peer.peers[originName].publicKey - ? String(peer.peers[originName].publicKey) - : null, - edgeCounts, - destroyed: !!(conn && conn.destroyed) - }; -} - -/** - * Assert local-observe delivery (foreign RELAY unwrap / peel): no mesh of app - * frames to other edges, no candidate growth, no alias/pin rebind, no ban. - * @param {object} before - * @param {object} after - * @param {object} [opts] - */ -function assertLocalObserve (before, after, opts = {}) { - assert.strictEqual(after.banned, false, 'must not ban TCP last hop'); - assert.strictEqual(after.destroyed, false, 'must not destroy TCP last hop'); - if (before.score != null && after.score != null) { - assert.strictEqual(after.score, before.score, 'must not derank TCP last hop'); - } - assert.strictEqual(after.alias, before.alias, 'must not bind alias on last hop'); - assert.strictEqual(after.addressMap, before.addressMap, 'must not rebind address map'); - assert.strictEqual(after.pin, before.pin, 'must not rebind peer pin'); - if (opts.allowCandidateGrowth !== true) { - assert.strictEqual(after.candidates, before.candidates, 'must not enqueue candidates'); - assert.deepStrictEqual(after.candidateKeys, before.candidateKeys); - } - if (opts.allowMesh !== true) { - for (const [edge, n] of Object.entries(after.edgeCounts || {})) { - const prev = (before.edgeCounts && before.edgeCounts[edge]) || 0; - assert.strictEqual(n, prev, `must not mesh-write ${edge} under local-observe`); - } - } -} - -/** - * Peer identity key for peel tests (mnemonic-driven). - * {@code destKey} is the Peer's live Fabric key ({@link Identity#fabricKey}), - * not the master Key from the seed mnemonic — onion {@code nextPeer} must match - * {@link Peer#_localXOnlyPeerId}. - * @returns {{ peer: Peer, destKey: Key }} - */ -function peelReadyPeer (overrides = {}) { - const seed = new Key(); - const peer = offlinePeer(Object.assign({ - key: { mnemonic: seed.mnemonic }, - constraints: { peers: { max: 32 } }, - peering: { maxCandidates: 16, maxRelaysPerOriginPerMinute: 30 }, - gossip: { maxRelaysPerOriginPerMinute: 30 }, - chat: { maxRelaysPerOriginPerMinute: 30 } - }, overrides)); - return { peer, destKey: peer.key }; -} - -module.exports = { - BASE_V1_TYPES, - EXTENDED_V1_TYPES, - UNKNOWN_OPCODE, - offlinePeer, - wireConn, - signFrame, - signUnknownOpcode, - defaultOkBody, - hostileBody, - corruptBodyHash, - corruptSignature, - deliver, - snapshotSideEffects, - assertLocalObserve, - peelReadyPeer, - Key, - Message, - Peer, - xOnlyFromKey -}; diff --git a/tests/simulator/lib/beaconAuditReport.js b/tests/simulator/lib/beaconAuditReport.js index ba19b9be0..8ee7747d9 100644 --- a/tests/simulator/lib/beaconAuditReport.js +++ b/tests/simulator/lib/beaconAuditReport.js @@ -111,7 +111,7 @@ function pickDeepSampleIndexes (n, maxDeep = 24) { * Enrich sealed epoch payloads with L1 inspection + local beacon chain entry ids. * @param {object} harness from startRegtestBeaconHarness * @param {object[]} epochs sealed BEACON_EPOCH payloads - * @param {{ deepIndexes?: Set }} [opts] + * @param {{ deepIndexes: (Set|undefined)}} [opts] */ async function buildEpochAuditRows (harness, epochs = [], opts = {}) { const rows = []; @@ -376,7 +376,7 @@ function formatBeaconAuditMarkdown (report) { /** * @param {object} report - * @param {{ jsonPath?: string, mdPath?: string }} [paths] + * @param {{ jsonPath: (string|undefined), mdPath: (string|undefined)}} [paths] */ function writeBeaconAuditReport (report, paths = {}) { const jsonPath = paths.jsonPath || DEFAULT_AUDIT_JSON; diff --git a/tests/simulator/simulator.smoke.js b/tests/simulator/simulator.smoke.js index 7f218d43f..fda85f0ff 100644 --- a/tests/simulator/simulator.smoke.js +++ b/tests/simulator/simulator.smoke.js @@ -72,7 +72,7 @@ describe('fabric network simulator', function () { const scenario = getScenario('mixed-fuzz'); const { report } = await runWithAssert('mixed-fuzz', { seed: scenario.seed, - steps: 80, + steps: 160, peers: scenario.peerCount, topology: scenario.topology }); diff --git a/tests/types.beacon.js b/tests/types.beacon.js index 40d2fc9fc..4fb75a80e 100644 --- a/tests/types.beacon.js +++ b/tests/types.beacon.js @@ -246,3 +246,188 @@ describe('@fabric/core/types/beacon', function () { assert.ok(beacon._pendingEpochRounds.has(digest)); }); }); + +const { + BEACON_CONTRACT_NAME, + BEACON_MESSAGE_TYPES, + beaconContractDefinition, + beaconContractId, + beaconRedeploySteps, + normalizeValidatorPubkeys +} = require('../functions/beaconContractDefinition'); +const { normalizeArcGenesis, resolveSpend } = require('../functions/contractSpend'); +const { buildFederationVaultFromPolicy, DEFAULT_CSV_BLOCKS } = require('../functions/contractTaproot'); + +describe('beaconContractDefinition', function () { + it('builds network-agnostic ARC genesis with stable Actor id', function () { + const a = new Key({ private: '1111111111111111111111111111111111111111111111111111111111111111' }); + const b = new Key({ private: '2222222222222222222222222222222222222222222222222222222222222222' }); + const def1 = beaconContractDefinition({ + validators: [b.pubkey, a.pubkey], + threshold: 1, + publisher: a.pubkey + }); + const def2 = beaconContractDefinition({ + validators: [a.pubkey, b.pubkey], + threshold: 1, + publisher: a.pubkey + }); + assert.strictEqual(def1.name, BEACON_CONTRACT_NAME); + assert.ok(!Object.prototype.hasOwnProperty.call(def1.spendPolicy, 'network')); + assert.ok(!Object.prototype.hasOwnProperty.call(def1, 'bitcoinAnchor')); + assert.deepStrictEqual(def1.primitives.messageTypes, BEACON_MESSAGE_TYPES.slice()); + assert.strictEqual(beaconContractId(def1), beaconContractId(def2)); + assert.strictEqual(def1.spendPolicy.csvBlocks, DEFAULT_CSV_BLOCKS); + }); + + it('normalizeValidatorPubkeys sorts and dedupes', function () { + const a = new Key({ private: '3333333333333333333333333333333333333333333333333333333333333333' }); + const list = normalizeValidatorPubkeys([a.pubkey, a.pubkey, String(a.pubkey).toUpperCase()]); + assert.strictEqual(list.length, 1); + }); + + it('normalizeValidatorPubkeys drops invalid x-only points', function () { + const a = new Key({ private: '6666666666666666666666666666666666666666666666666666666666666666' }); + const invalidXOnly = '00'.repeat(32); // not a lift-able curve x + const list = normalizeValidatorPubkeys([invalidXOnly, a.pubkey, 'not-a-key']); + assert.deepStrictEqual(list, [String(a.pubkey).toLowerCase()]); + }); + + it('spendAddress matches federation vault on each network overlay', function () { + const a = new Key({ private: '4444444444444444444444444444444444444444444444444444444444444444' }); + const b = new Key({ private: '5555555555555555555555555555555555555555555555555555555555555555' }); + const def = beaconContractDefinition({ + validators: [a.pubkey, b.pubkey], + threshold: 2, + publisher: a.pubkey, + csvBlocks: 144 + }); + const arc = normalizeArcGenesis(def); + for (const network of ['regtest', 'signet', 'mainnet']) { + const spend = resolveSpend({ + genesis: arc, + tip: { + content: { members: arc.members.signers.map((p) => String(p).replace(/^0[23]/i, '')) }, + stateDigest: 'aa'.repeat(32), + bitcoinBlockHash: 'bb'.repeat(32) + }, + overrides: { network } + }); + const vault = buildFederationVaultFromPolicy({ + validatorPubkeysHex: arc.spendPolicy.validators, + threshold: 2, + networkName: network, + publisher: a.pubkey, + csvBlocks: 144 + }); + assert.strictEqual(spend.address, vault.address, network); + } + }); + + it('exposes redeploy steps', function () { + const steps = beaconRedeploySteps(); + assert.ok(Array.isArray(steps)); + assert.ok(steps.length >= 4); + assert.ok(steps.some((s) => /FABRIC_BEACON_RESET_NETWORK/.test(s))); + }); +}); + +const { + BEACON_NETWORK_PATH, + BEACON_CHAIN_PATH, + SIDECHAIN_STATE_PATH, + normalizeBitcoinNetwork, + readNetworkBinding, + writeNetworkBinding, + assertBeaconNetworkCompatible, + resetBeaconNetworkStores, + ensureBeaconNetworkBinding, + hasBeaconOrSidechainData +} = require('../functions/beaconNetworkGuard'); + +function memoryFsBeaconGuard (seed = {}) { + const data = Object.assign(Object.create(null), seed); + return { + _data: data, + readFile (p) { + return Object.prototype.hasOwnProperty.call(data, p) ? data[p] : null; + }, + writeFile (p, v) { + data[p] = typeof v === 'string' ? v : JSON.stringify(v); + }, + async publish (p, v) { + data[p] = typeof v === 'string' ? v : JSON.stringify(v); + } + }; +} + +describe('beaconNetworkGuard', function () { + it('normalizes bitcoin / testnet3 labels', function () { + assert.strictEqual(normalizeBitcoinNetwork('Bitcoin'), 'mainnet'); + assert.strictEqual(normalizeBitcoinNetwork('testnet3'), 'testnet'); + assert.strictEqual(normalizeBitcoinNetwork('signet'), 'signet'); + }); + + it('allows fresh stores and binds network', async function () { + const fs = memoryFsBeaconGuard(); + const ensured = await ensureBeaconNetworkBinding(fs, 'regtest'); + assert.strictEqual(ensured.ok, true); + assert.strictEqual(ensured.binding.network, 'regtest'); + assert.ok(readNetworkBinding(fs)); + assert.strictEqual(readNetworkBinding(fs).network, 'regtest'); + }); + + it('rejects mismatch without reset', async function () { + const fs = memoryFsBeaconGuard(); + await writeNetworkBinding(fs, 'regtest'); + fs.writeFile(BEACON_CHAIN_PATH, JSON.stringify({ messages: [{ id: '1' }] })); + const check = assertBeaconNetworkCompatible(fs, 'signet'); + assert.strictEqual(check.ok, false); + assert.strictEqual(check.code, 'NETWORK_MISMATCH'); + const blocked = await ensureBeaconNetworkBinding(fs, 'signet'); + assert.strictEqual(blocked.ok, false); + }); + + it('resets and rebinds when FABRIC_BEACON_RESET_NETWORK=1', async function () { + const fs = memoryFsBeaconGuard(); + await writeNetworkBinding(fs, 'regtest'); + fs.writeFile(BEACON_CHAIN_PATH, JSON.stringify({ messages: [{ id: 'old' }] })); + const prev = process.env.FABRIC_BEACON_RESET_NETWORK; + process.env.FABRIC_BEACON_RESET_NETWORK = '1'; + try { + const ensured = await ensureBeaconNetworkBinding(fs, 'signet'); + assert.strictEqual(ensured.ok, true); + assert.strictEqual(ensured.binding.network, 'signet'); + assert.ok(ensured.reset); + assert.ok(ensured.reset.cleared.includes(BEACON_CHAIN_PATH)); + const chain = JSON.parse(fs.readFile(BEACON_CHAIN_PATH)); + assert.deepStrictEqual(chain.messages, []); + } finally { + if (prev == null) delete process.env.FABRIC_BEACON_RESET_NETWORK; + else process.env.FABRIC_BEACON_RESET_NETWORK = prev; + } + }); + + it('flags unbound legacy stores with data', function () { + const fs = memoryFsBeaconGuard(); + fs.writeFile(BEACON_CHAIN_PATH, JSON.stringify({ messages: [{ id: 'legacy' }] })); + assert.strictEqual(hasBeaconOrSidechainData(fs), true); + const check = assertBeaconNetworkCompatible(fs, 'mainnet'); + assert.strictEqual(check.ok, false); + assert.strictEqual(check.code, 'NETWORK_UNBOUND'); + }); + + it('does not treat empty sidechain content (clock 0) as used data', function () { + const fs = memoryFsBeaconGuard(); + fs.writeFile(SIDECHAIN_STATE_PATH, JSON.stringify({ content: {}, clock: 0 })); + assert.strictEqual(hasBeaconOrSidechainData(fs), false); + }); + + it('resetBeaconNetworkStores clears listed paths', async function () { + const fs = memoryFsBeaconGuard(); + fs.writeFile(BEACON_CHAIN_PATH, JSON.stringify({ messages: [1] })); + fs.writeFile(BEACON_NETWORK_PATH, '{"network":"regtest"}'); + const { cleared } = await resetBeaconNetworkStores(fs); + assert.ok(cleared.length >= 1); + }); +}); diff --git a/tests/vectors/bip327.json b/tests/vectors/bip327.json new file mode 100644 index 000000000..dc01322b7 --- /dev/null +++ b/tests/vectors/bip327.json @@ -0,0 +1 @@ +{"det_sign_vectors.json":{"sk":"7FB9E0E687ADA1EEBF7ECFE2F21E73EBDB51A7D450948DFE8D76D7F2D1007671","pubkeys":["03935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9","02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9","02DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659","020000000000000000000000000000000000000000000000000000000000000007"],"msgs":["F95466D086770E689964664219266FE5ED215C92AE20BAB5C9D79ADDDDF3C0CF","2626262626262626262626262626262626262626262626262626262626262626262626262626"],"valid_test_cases":[{"rand":"0000000000000000000000000000000000000000000000000000000000000000","aggothernonce":"0337C87821AFD50A8644D820A8F3E02E499C931865C2360FB43D0A0D20DAFE07EA0287BF891D2A6DEAEBADC909352AA9405D1428C15F4B75F04DAE642A95C2548480","key_indices":[0,1,2],"tweaks":[],"is_xonly":[],"msg_index":0,"signer_index":0,"expected":["03D96275257C2FCCBB6EEB77BDDF51D3C88C26EE1626C6CDA8999B9D34F4BA13A60309BE2BF883C6ABE907FA822D9CA166D51A3DCC28910C57528F6983FC378B7843","41EA65093F71D084785B20DC26A887CD941C9597860A21660CBDB9CC2113CAD3"]},{"rand":null,"aggothernonce":"0337C87821AFD50A8644D820A8F3E02E499C931865C2360FB43D0A0D20DAFE07EA0287BF891D2A6DEAEBADC909352AA9405D1428C15F4B75F04DAE642A95C2548480","key_indices":[1,0,2],"tweaks":[],"is_xonly":[],"msg_index":0,"signer_index":1,"expected":["028FBCCF5BB73A7B61B270BAD15C0F9475D577DD85C2157C9D38BEF1EC922B48770253BE3638C87369BC287E446B7F2C8CA5BEB9FFBD1EA082C62913982A65FC214D","AEAA31262637BFA88D5606679018A0FEEEC341F3107D1199857F6C81DE61B8DD"]},{"rand":"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF","aggothernonce":"0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F817980279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798","key_indices":[1,2,0],"tweaks":[],"is_xonly":[],"msg_index":1,"signer_index":2,"expected":["024FA8D774F0C8743FAA77AFB4D08EE5A013C2E8EEAD8A6F08A77DDD2D28266DB803050905E8C994477F3F2981861A2E3791EF558626E645FBF5AA131C5D6447C2C2","FEE28A56B8556B7632E42A84122C51A4861B1F2DEC7E81B632195E56A52E3E13"],"comment":"Message longer than 32 bytes"},{"rand":"0000000000000000000000000000000000000000000000000000000000000000","aggothernonce":"032DE2662628C90B03F5E720284EB52FF7D71F4284F627B68A853D78C78E1FFE9303E4C5524E83FFE1493B9077CF1CA6BEB2090C93D930321071AD40B2F44E599046","key_indices":[0,1,2],"tweaks":["E8F791FF9225A2AF0102AFFF4A9A723D9612A682A25EBE79802B263CDFCD83BB"],"is_xonly":[true],"msg_index":0,"signer_index":0,"expected":["031E07C0D11A0134E55DB1FC16095ADCBD564236194374AA882BFB3C78273BF673039D0336E8CA6288C00BFC1F8B594563529C98661172B9BC1BE85C23A4CE1F616B","7B1246C5889E59CB0375FA395CC86AC42D5D7D59FD8EAB4FDF1DCAB2B2F006EA"],"comment":"Tweaked public key"}],"error_test_cases":[{"rand":"0000000000000000000000000000000000000000000000000000000000000000","aggothernonce":"0337C87821AFD50A8644D820A8F3E02E499C931865C2360FB43D0A0D20DAFE07EA0287BF891D2A6DEAEBADC909352AA9405D1428C15F4B75F04DAE642A95C2548480","key_indices":[1,0,3],"tweaks":[],"is_xonly":[],"msg_index":0,"signer_index":1,"error":{"type":"invalid_contribution","signer":2,"contrib":"pubkey"},"comment":"Signer 2 provided an invalid public key"},{"rand":"0000000000000000000000000000000000000000000000000000000000000000","aggothernonce":"0337C87821AFD50A8644D820A8F3E02E499C931865C2360FB43D0A0D20DAFE07EA0287BF891D2A6DEAEBADC909352AA9405D1428C15F4B75F04DAE642A95C2548480","key_indices":[1,2],"tweaks":[],"is_xonly":[],"msg_index":0,"signer_index":1,"error":{"type":"value","message":"The signer's pubkey must be included in the list of pubkeys."},"comment":"The signers pubkey is not in the list of pubkeys"},{"rand":"0000000000000000000000000000000000000000000000000000000000000000","aggothernonce":"0437C87821AFD50A8644D820A8F3E02E499C931865C2360FB43D0A0D20DAFE07EA0287BF891D2A6DEAEBADC909352AA9405D1428C15F4B75F04DAE642A95C2548480","key_indices":[1,2,0],"tweaks":[],"is_xonly":[],"msg_index":0,"signer_index":2,"error":{"type":"invalid_contribution","signer":null,"contrib":"aggothernonce"},"comment":"aggothernonce is invalid due wrong tag, 0x04, in the first half"},{"rand":"0000000000000000000000000000000000000000000000000000000000000000","aggothernonce":"0000000000000000000000000000000000000000000000000000000000000000000287BF891D2A6DEAEBADC909352AA9405D1428C15F4B75F04DAE642A95C2548480","key_indices":[1,2,0],"tweaks":[],"is_xonly":[],"msg_index":0,"signer_index":2,"error":{"type":"invalid_contribution","signer":null,"contrib":"aggothernonce"},"comment":"aggothernonce is invalid because first half corresponds to point at infinity"},{"rand":"0000000000000000000000000000000000000000000000000000000000000000","aggothernonce":"0337C87821AFD50A8644D820A8F3E02E499C931865C2360FB43D0A0D20DAFE07EA0287BF891D2A6DEAEBADC909352AA9405D1428C15F4B75F04DAE642A95C2548480","key_indices":[1,2,0],"tweaks":["FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"],"is_xonly":[false],"msg_index":0,"signer_index":2,"error":{"type":"value","message":"The tweak must be less than n."},"comment":"Tweak is invalid because it exceeds group size"}]},"key_agg_vectors.json":{"pubkeys":["02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9","03DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659","023590A94E768F8E1815C2F24B4D80A8E3149316C3518CE7B7AD338368D038CA66","020000000000000000000000000000000000000000000000000000000000000005","02FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30","04F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9","03935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9"],"tweaks":["FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141","252E4BD67410A76CDF933D30EAA1608214037F1B105A013ECCD3C5C184A6110B"],"valid_test_cases":[{"key_indices":[0,1,2],"expected":"90539EEDE565F5D054F32CC0C220126889ED1E5D193BAF15AEF344FE59D4610C"},{"key_indices":[2,1,0],"expected":"6204DE8B083426DC6EAF9502D27024D53FC826BF7D2012148A0575435DF54B2B"},{"key_indices":[0,0,0],"expected":"B436E3BAD62B8CD409969A224731C193D051162D8C5AE8B109306127DA3AA935"},{"key_indices":[0,0,1,1],"expected":"69BC22BFA5D106306E48A20679DE1D7389386124D07571D0D872686028C26A3E"}],"error_test_cases":[{"key_indices":[0,3],"tweak_indices":[],"is_xonly":[],"error":{"type":"invalid_contribution","signer":1,"contrib":"pubkey"},"comment":"Invalid public key"},{"key_indices":[0,4],"tweak_indices":[],"is_xonly":[],"error":{"type":"invalid_contribution","signer":1,"contrib":"pubkey"},"comment":"Public key exceeds field size"},{"key_indices":[5,0],"tweak_indices":[],"is_xonly":[],"error":{"type":"invalid_contribution","signer":0,"contrib":"pubkey"},"comment":"First byte of public key is not 2 or 3"},{"key_indices":[0,1],"tweak_indices":[0],"is_xonly":[true],"error":{"type":"value","message":"The tweak must be less than n."},"comment":"Tweak is out of range"},{"key_indices":[6],"tweak_indices":[1],"is_xonly":[false],"error":{"type":"value","message":"The result of tweaking cannot be infinity."},"comment":"Intermediate tweaking result is point at infinity"}]},"key_sort_vectors.json":{"pubkeys":["02DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EB8","02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9","03DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659","023590A94E768F8E1815C2F24B4D80A8E3149316C3518CE7B7AD338368D038CA66","02DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EFF","02DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EB8"],"sorted_pubkeys":["023590A94E768F8E1815C2F24B4D80A8E3149316C3518CE7B7AD338368D038CA66","02DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EB8","02DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EB8","02DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EFF","02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9","03DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659"]},"nonce_agg_vectors.json":{"pnonces":["020151C80F435648DF67A22B749CD798CE54E0321D034B92B709B567D60A42E66603BA47FBC1834437B3212E89A84D8425E7BF12E0245D98262268EBDCB385D50641","03FF406FFD8ADB9CD29877E4985014F66A59F6CD01C0E88CAA8E5F3166B1F676A60248C264CDD57D3C24D79990B0F865674EB62A0F9018277A95011B41BFC193B833","020151C80F435648DF67A22B749CD798CE54E0321D034B92B709B567D60A42E6660279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798","03FF406FFD8ADB9CD29877E4985014F66A59F6CD01C0E88CAA8E5F3166B1F676A60379BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798","04FF406FFD8ADB9CD29877E4985014F66A59F6CD01C0E88CAA8E5F3166B1F676A60248C264CDD57D3C24D79990B0F865674EB62A0F9018277A95011B41BFC193B833","03FF406FFD8ADB9CD29877E4985014F66A59F6CD01C0E88CAA8E5F3166B1F676A60248C264CDD57D3C24D79990B0F865674EB62A0F9018277A95011B41BFC193B831","03FF406FFD8ADB9CD29877E4985014F66A59F6CD01C0E88CAA8E5F3166B1F676A602FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30"],"valid_test_cases":[{"pnonce_indices":[0,1],"expected":"035FE1873B4F2967F52FEA4A06AD5A8ECCBE9D0FD73068012C894E2E87CCB5804B024725377345BDE0E9C33AF3C43C0A29A9249F2F2956FA8CFEB55C8573D0262DC8"},{"pnonce_indices":[2,3],"expected":"035FE1873B4F2967F52FEA4A06AD5A8ECCBE9D0FD73068012C894E2E87CCB5804B000000000000000000000000000000000000000000000000000000000000000000","comment":"Sum of second points encoded in the nonces is point at infinity which is serialized as 33 zero bytes"}],"error_test_cases":[{"pnonce_indices":[0,4],"error":{"type":"invalid_contribution","signer":1,"contrib":"pubnonce"},"comment":"Public nonce from signer 1 is invalid due wrong tag, 0x04, in the first half"},{"pnonce_indices":[5,1],"error":{"type":"invalid_contribution","signer":0,"contrib":"pubnonce"},"comment":"Public nonce from signer 0 is invalid because the second half does not correspond to an X coordinate"},{"pnonce_indices":[6,1],"error":{"type":"invalid_contribution","signer":0,"contrib":"pubnonce"},"comment":"Public nonce from signer 0 is invalid because second half exceeds field size"}]},"nonce_gen_vectors.json":{"test_cases":[{"rand_":"0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F","sk":"0202020202020202020202020202020202020202020202020202020202020202","pk":"024D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766","aggpk":"0707070707070707070707070707070707070707070707070707070707070707","msg":"0101010101010101010101010101010101010101010101010101010101010101","extra_in":"0808080808080808080808080808080808080808080808080808080808080808","expected_secnonce":"B114E502BEAA4E301DD08A50264172C84E41650E6CB726B410C0694D59EFFB6495B5CAF28D045B973D63E3C99A44B807BDE375FD6CB39E46DC4A511708D0E9D2024D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766","expected_pubnonce":"02F7BE7089E8376EB355272368766B17E88E7DB72047D05E56AA881EA52B3B35DF02C29C8046FDD0DED4C7E55869137200FBDBFE2EB654267B6D7013602CAED3115A"},{"rand_":"0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F","sk":"0202020202020202020202020202020202020202020202020202020202020202","pk":"024D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766","aggpk":"0707070707070707070707070707070707070707070707070707070707070707","msg":"","extra_in":"0808080808080808080808080808080808080808080808080808080808080808","expected_secnonce":"E862B068500320088138468D47E0E6F147E01B6024244AE45EAC40ACE5929B9F0789E051170B9E705D0B9EB49049A323BBBBB206D8E05C19F46C6228742AA7A9024D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766","expected_pubnonce":"023034FA5E2679F01EE66E12225882A7A48CC66719B1B9D3B6C4DBD743EFEDA2C503F3FD6F01EB3A8E9CB315D73F1F3D287CAFBB44AB321153C6287F407600205109"},{"rand_":"0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F","sk":"0202020202020202020202020202020202020202020202020202020202020202","pk":"024D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766","aggpk":"0707070707070707070707070707070707070707070707070707070707070707","msg":"2626262626262626262626262626262626262626262626262626262626262626262626262626","extra_in":"0808080808080808080808080808080808080808080808080808080808080808","expected_secnonce":"3221975ACBDEA6820EABF02A02B7F27D3A8EF68EE42787B88CBEFD9AA06AF3632EE85B1A61D8EF31126D4663A00DD96E9D1D4959E72D70FE5EBB6E7696EBA66F024D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766","expected_pubnonce":"02E5BBC21C69270F59BD634FCBFA281BE9D76601295345112C58954625BF23793A021307511C79F95D38ACACFF1B4DA98228B77E65AA216AD075E9673286EFB4EAF3"},{"rand_":"0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F","sk":null,"pk":"02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9","aggpk":null,"msg":null,"extra_in":null,"expected_secnonce":"89BDD787D0284E5E4D5FC572E49E316BAB7E21E3B1830DE37DFE80156FA41A6D0B17AE8D024C53679699A6FD7944D9C4A366B514BAF43088E0708B1023DD289702F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9","expected_pubnonce":"02C96E7CB1E8AA5DAC64D872947914198F607D90ECDE5200DE52978AD5DED63C000299EC5117C2D29EDEE8A2092587C3909BE694D5CFF0667D6C02EA4059F7CD9786"}]},"sig_agg_vectors.json":{"pubkeys":["03935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9","02D2DC6F5DF7C56ACF38C7FA0AE7A759AE30E19B37359DFDE015872324C7EF6E05","03C7FB101D97FF930ACD0C6760852EF64E69083DE0B06AC6335724754BB4B0522C","02352433B21E7E05D3B452B81CAE566E06D2E003ECE16D1074AABA4289E0E3D581"],"pnonces":["036E5EE6E28824029FEA3E8A9DDD2C8483F5AF98F7177C3AF3CB6F47CAF8D94AE902DBA67E4A1F3680826172DA15AFB1A8CA85C7C5CC88900905C8DC8C328511B53E","03E4F798DA48A76EEC1C9CC5AB7A880FFBA201A5F064E627EC9CB0031D1D58FC5103E06180315C5A522B7EC7C08B69DCD721C313C940819296D0A7AB8E8795AC1F00","02C0068FD25523A31578B8077F24F78F5BD5F2422AFF47C1FADA0F36B3CEB6C7D202098A55D1736AA5FCC21CF0729CCE852575C06C081125144763C2C4C4A05C09B6","031F5C87DCFBFCF330DEE4311D85E8F1DEA01D87A6F1C14CDFC7E4F1D8C441CFA40277BF176E9F747C34F81B0D9F072B1B404A86F402C2D86CF9EA9E9C69876EA3B9","023F7042046E0397822C4144A17F8B63D78748696A46C3B9F0A901D296EC3406C302022B0B464292CF9751D699F10980AC764E6F671EFCA15069BBE62B0D1C62522A","02D97DDA5988461DF58C5897444F116A7C74E5711BF77A9446E27806563F3B6C47020CBAD9C363A7737F99FA06B6BE093CEAFF5397316C5AC46915C43767AE867C00"],"tweaks":["B511DA492182A91B0FFB9A98020D55F260AE86D7ECBD0399C7383D59A5F2AF7C","A815FE049EE3C5AAB66310477FBC8BCCCAC2F3395F59F921C364ACD78A2F48DC","75448A87274B056468B977BE06EB1E9F657577B7320B0A3376EA51FD420D18A8"],"psigs":["B15D2CD3C3D22B04DAE438CE653F6B4ECF042F42CFDED7C41B64AAF9B4AF53FB","6193D6AC61B354E9105BBDC8937A3454A6D705B6D57322A5A472A02CE99FCB64","9A87D3B79EC67228CB97878B76049B15DBD05B8158D17B5B9114D3C226887505","66F82EA90923689B855D36C6B7E032FB9970301481B99E01CDB4D6AC7C347A15","4F5AEE41510848A6447DCD1BBC78457EF69024944C87F40250D3EF2C25D33EFE","DDEF427BBB847CC027BEFF4EDB01038148917832253EBC355FC33F4A8E2FCCE4","97B890A26C981DA8102D3BC294159D171D72810FDF7C6A691DEF02F0F7AF3FDC","53FA9E08BA5243CBCB0D797C5EE83BC6728E539EB76C2D0BF0F971EE4E909971","FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"],"msg":"599C67EA410D005B9DA90817CF03ED3B1C868E4DA4EDF00A5880B0082C237869","valid_test_cases":[{"aggnonce":"0341432722C5CD0268D829C702CF0D1CBCE57033EED201FD335191385227C3210C03D377F2D258B64AADC0E16F26462323D701D286046A2EA93365656AFD9875982B","nonce_indices":[0,1],"key_indices":[0,1],"tweak_indices":[],"is_xonly":[],"psig_indices":[0,1],"expected":"041DA22223CE65C92C9A0D6C2CAC828AAF1EEE56304FEC371DDF91EBB2B9EF0912F1038025857FEDEB3FF696F8B99FA4BB2C5812F6095A2E0004EC99CE18DE1E"},{"aggnonce":"0224AFD36C902084058B51B5D36676BBA4DC97C775873768E58822F87FE437D792028CB15929099EEE2F5DAE404CD39357591BA32E9AF4E162B8D3E7CB5EFE31CB20","nonce_indices":[0,2],"key_indices":[0,2],"tweak_indices":[],"is_xonly":[],"psig_indices":[2,3],"expected":"1069B67EC3D2F3C7C08291ACCB17A9C9B8F2819A52EB5DF8726E17E7D6B52E9F01800260A7E9DAC450F4BE522DE4CE12BA91AEAF2B4279219EF74BE1D286ADD9"},{"aggnonce":"0208C5C438C710F4F96A61E9FF3C37758814B8C3AE12BFEA0ED2C87FF6954FF186020B1816EA104B4FCA2D304D733E0E19CEAD51303FF6420BFD222335CAA402916D","nonce_indices":[0,3],"key_indices":[0,2],"tweak_indices":[0],"is_xonly":[false],"psig_indices":[4,5],"expected":"5C558E1DCADE86DA0B2F02626A512E30A22CF5255CAEA7EE32C38E9A71A0E9148BA6C0E6EC7683B64220F0298696F1B878CD47B107B81F7188812D593971E0CC"},{"aggnonce":"02B5AD07AFCD99B6D92CB433FBD2A28FDEB98EAE2EB09B6014EF0F8197CD58403302E8616910F9293CF692C49F351DB86B25E352901F0E237BAFDA11F1C1CEF29FFD","nonce_indices":[0,4],"key_indices":[0,3],"tweak_indices":[0,1,2],"is_xonly":[true,false,true],"psig_indices":[6,7],"expected":"839B08820B681DBA8DAF4CC7B104E8F2638F9388F8D7A555DC17B6E6971D7426CE07BF6AB01F1DB50E4E33719295F4094572B79868E440FB3DEFD3FAC1DB589E"}],"error_test_cases":[{"aggnonce":"02B5AD07AFCD99B6D92CB433FBD2A28FDEB98EAE2EB09B6014EF0F8197CD58403302E8616910F9293CF692C49F351DB86B25E352901F0E237BAFDA11F1C1CEF29FFD","nonce_indices":[0,4],"key_indices":[0,3],"tweak_indices":[0,1,2],"is_xonly":[true,false,true],"psig_indices":[7,8],"error":{"type":"invalid_contribution","signer":1,"contrib":"psig"},"comment":"Partial signature is invalid because it exceeds group size"}]},"sign_verify_vectors.json":{"sk":"7FB9E0E687ADA1EEBF7ECFE2F21E73EBDB51A7D450948DFE8D76D7F2D1007671","pubkeys":["03935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9","02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9","02DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA661","020000000000000000000000000000000000000000000000000000000000000007"],"secnonces":["508B81A611F100A6B2B6B29656590898AF488BCF2E1F55CF22E5CFB84421FE61FA27FD49B1D50085B481285E1CA205D55C82CC1B31FF5CD54A489829355901F703935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9","0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9"],"pnonces":["0337C87821AFD50A8644D820A8F3E02E499C931865C2360FB43D0A0D20DAFE07EA0287BF891D2A6DEAEBADC909352AA9405D1428C15F4B75F04DAE642A95C2548480","0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F817980279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798","032DE2662628C90B03F5E720284EB52FF7D71F4284F627B68A853D78C78E1FFE9303E4C5524E83FFE1493B9077CF1CA6BEB2090C93D930321071AD40B2F44E599046","0237C87821AFD50A8644D820A8F3E02E499C931865C2360FB43D0A0D20DAFE07EA0387BF891D2A6DEAEBADC909352AA9405D1428C15F4B75F04DAE642A95C2548480","0200000000000000000000000000000000000000000000000000000000000000090287BF891D2A6DEAEBADC909352AA9405D1428C15F4B75F04DAE642A95C2548480"],"aggnonces":["028465FCF0BBDBCF443AABCCE533D42B4B5A10966AC09A49655E8C42DAAB8FCD61037496A3CC86926D452CAFCFD55D25972CA1675D549310DE296BFF42F72EEEA8C9","000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","048465FCF0BBDBCF443AABCCE533D42B4B5A10966AC09A49655E8C42DAAB8FCD61037496A3CC86926D452CAFCFD55D25972CA1675D549310DE296BFF42F72EEEA8C9","028465FCF0BBDBCF443AABCCE533D42B4B5A10966AC09A49655E8C42DAAB8FCD61020000000000000000000000000000000000000000000000000000000000000009","028465FCF0BBDBCF443AABCCE533D42B4B5A10966AC09A49655E8C42DAAB8FCD6102FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30"],"msgs":["F95466D086770E689964664219266FE5ED215C92AE20BAB5C9D79ADDDDF3C0CF","","2626262626262626262626262626262626262626262626262626262626262626262626262626"],"valid_test_cases":[{"key_indices":[0,1,2],"nonce_indices":[0,1,2],"aggnonce_index":0,"msg_index":0,"signer_index":0,"expected":"012ABBCB52B3016AC03AD82395A1A415C48B93DEF78718E62A7A90052FE224FB"},{"key_indices":[1,0,2],"nonce_indices":[1,0,2],"aggnonce_index":0,"msg_index":0,"signer_index":1,"expected":"9FF2F7AAA856150CC8819254218D3ADEEB0535269051897724F9DB3789513A52"},{"key_indices":[1,2,0],"nonce_indices":[1,2,0],"aggnonce_index":0,"msg_index":0,"signer_index":2,"expected":"FA23C359F6FAC4E7796BB93BC9F0532A95468C539BA20FF86D7C76ED92227900"},{"key_indices":[0,1],"nonce_indices":[0,3],"aggnonce_index":1,"msg_index":0,"signer_index":0,"expected":"AE386064B26105404798F75DE2EB9AF5EDA5387B064B83D049CB7C5E08879531","comment":"Both halves of aggregate nonce correspond to point at infinity"},{"key_indices":[0,1,2],"nonce_indices":[0,1,2],"aggnonce_index":0,"msg_index":1,"signer_index":0,"expected":"D7D63FFD644CCDA4E62BC2BC0B1D02DD32A1DC3030E155195810231D1037D82D","comment":"Empty message"},{"key_indices":[0,1,2],"nonce_indices":[0,1,2],"aggnonce_index":0,"msg_index":2,"signer_index":0,"expected":"E184351828DA5094A97C79CABDAAA0BFB87608C32E8829A4DF5340A6F243B78C","comment":"38-byte message"}],"sign_error_test_cases":[{"key_indices":[1,2],"aggnonce_index":0,"msg_index":0,"secnonce_index":0,"error":{"type":"value","message":"The signer's pubkey must be included in the list of pubkeys."},"comment":"The signers pubkey is not in the list of pubkeys. This test case is optional: it can be skipped by implementations that do not check that the signer's pubkey is included in the list of pubkeys."},{"key_indices":[1,0,3],"aggnonce_index":0,"msg_index":0,"secnonce_index":0,"error":{"type":"invalid_contribution","signer":2,"contrib":"pubkey"},"comment":"Signer 2 provided an invalid public key"},{"key_indices":[1,2,0],"aggnonce_index":2,"msg_index":0,"secnonce_index":0,"error":{"type":"invalid_contribution","signer":null,"contrib":"aggnonce"},"comment":"Aggregate nonce is invalid due wrong tag, 0x04, in the first half"},{"key_indices":[1,2,0],"aggnonce_index":3,"msg_index":0,"secnonce_index":0,"error":{"type":"invalid_contribution","signer":null,"contrib":"aggnonce"},"comment":"Aggregate nonce is invalid because the second half does not correspond to an X coordinate"},{"key_indices":[1,2,0],"aggnonce_index":4,"msg_index":0,"secnonce_index":0,"error":{"type":"invalid_contribution","signer":null,"contrib":"aggnonce"},"comment":"Aggregate nonce is invalid because second half exceeds field size"},{"key_indices":[0,1,2],"aggnonce_index":0,"msg_index":0,"signer_index":0,"secnonce_index":1,"error":{"type":"value","message":"first secnonce value is out of range."},"comment":"Secnonce is invalid which may indicate nonce reuse"}],"verify_fail_test_cases":[{"sig":"FED54434AD4CFE953FC527DC6A5E5BE8F6234907B7C187559557CE87A0541C46","key_indices":[0,1,2],"nonce_indices":[0,1,2],"msg_index":0,"signer_index":0,"comment":"Wrong signature (which is equal to the negation of valid signature)"},{"sig":"012ABBCB52B3016AC03AD82395A1A415C48B93DEF78718E62A7A90052FE224FB","key_indices":[0,1,2],"nonce_indices":[0,1,2],"msg_index":0,"signer_index":1,"comment":"Wrong signer"},{"sig":"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141","key_indices":[0,1,2],"nonce_indices":[0,1,2],"msg_index":0,"signer_index":0,"comment":"Signature exceeds group size"}],"verify_error_test_cases":[{"sig":"012ABBCB52B3016AC03AD82395A1A415C48B93DEF78718E62A7A90052FE224FB","key_indices":[0,1,2],"nonce_indices":[4,1,2],"msg_index":0,"signer_index":0,"error":{"type":"invalid_contribution","signer":0,"contrib":"pubnonce"},"comment":"Invalid pubnonce"},{"sig":"012ABBCB52B3016AC03AD82395A1A415C48B93DEF78718E62A7A90052FE224FB","key_indices":[3,1,2],"nonce_indices":[0,1,2],"msg_index":0,"signer_index":0,"error":{"type":"invalid_contribution","signer":0,"contrib":"pubkey"},"comment":"Invalid pubkey"}]},"tweak_vectors.json":{"sk":"7FB9E0E687ADA1EEBF7ECFE2F21E73EBDB51A7D450948DFE8D76D7F2D1007671","pubkeys":["03935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9","02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9","02DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659"],"secnonce":"508B81A611F100A6B2B6B29656590898AF488BCF2E1F55CF22E5CFB84421FE61FA27FD49B1D50085B481285E1CA205D55C82CC1B31FF5CD54A489829355901F703935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9","pnonces":["0337C87821AFD50A8644D820A8F3E02E499C931865C2360FB43D0A0D20DAFE07EA0287BF891D2A6DEAEBADC909352AA9405D1428C15F4B75F04DAE642A95C2548480","0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F817980279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798","032DE2662628C90B03F5E720284EB52FF7D71F4284F627B68A853D78C78E1FFE9303E4C5524E83FFE1493B9077CF1CA6BEB2090C93D930321071AD40B2F44E599046"],"aggnonce":"028465FCF0BBDBCF443AABCCE533D42B4B5A10966AC09A49655E8C42DAAB8FCD61037496A3CC86926D452CAFCFD55D25972CA1675D549310DE296BFF42F72EEEA8C9","tweaks":["E8F791FF9225A2AF0102AFFF4A9A723D9612A682A25EBE79802B263CDFCD83BB","AE2EA797CC0FE72AC5B97B97F3C6957D7E4199A167A58EB08BCAFFDA70AC0455","F52ECBC565B3D8BEA2DFD5B75A4F457E54369809322E4120831626F290FA87E0","1969AD73CC177FA0B4FCED6DF1F7BF9907E665FDE9BA196A74FED0A3CF5AEF9D","FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"],"msg":"F95466D086770E689964664219266FE5ED215C92AE20BAB5C9D79ADDDDF3C0CF","valid_test_cases":[{"key_indices":[1,2,0],"nonce_indices":[1,2,0],"tweak_indices":[0],"is_xonly":[true],"signer_index":2,"expected":"E28A5C66E61E178C2BA19DB77B6CF9F7E2F0F56C17918CD13135E60CC848FE91","comment":"A single x-only tweak"},{"key_indices":[1,2,0],"nonce_indices":[1,2,0],"tweak_indices":[0],"is_xonly":[false],"signer_index":2,"expected":"38B0767798252F21BF5702C48028B095428320F73A4B14DB1E25DE58543D2D2D","comment":"A single plain tweak"},{"key_indices":[1,2,0],"nonce_indices":[1,2,0],"tweak_indices":[0,1],"is_xonly":[false,true],"signer_index":2,"expected":"408A0A21C4A0F5DACAF9646AD6EB6FECD7F7A11F03ED1F48DFFF2185BC2C2408","comment":"A plain tweak followed by an x-only tweak"},{"key_indices":[1,2,0],"nonce_indices":[1,2,0],"tweak_indices":[0,1,2,3],"is_xonly":[false,false,true,true],"signer_index":2,"expected":"45ABD206E61E3DF2EC9E264A6FEC8292141A633C28586388235541F9ADE75435","comment":"Four tweaks: plain, plain, x-only, x-only."},{"key_indices":[1,2,0],"nonce_indices":[1,2,0],"tweak_indices":[0,1,2,3],"is_xonly":[true,false,true,false],"signer_index":2,"expected":"B255FDCAC27B40C7CE7848E2D3B7BF5EA0ED756DA81565AC804CCCA3E1D5D239","comment":"Four tweaks: x-only, plain, x-only, plain. If an implementation prohibits applying plain tweaks after x-only tweaks, it can skip this test vector or return an error."}],"error_test_cases":[{"key_indices":[1,2,0],"nonce_indices":[1,2,0],"tweak_indices":[4],"is_xonly":[false],"signer_index":2,"error":{"type":"value","message":"The tweak must be less than n."},"comment":"Tweak is invalid because it exceeds group size"}]}} \ No newline at end of file diff --git a/types/cli.js b/types/cli.js index 7affebe31..edaff1569 100644 --- a/types/cli.js +++ b/types/cli.js @@ -33,6 +33,7 @@ const Key = require('./key'); // Functions const truncateMiddle = require('../functions/truncateMiddle'); +const { chatTextOf, chatActorIdOf, formatChatLogLine } = require('../functions/fabricChatText'); const { tryParsePersistedJson, tryParseWireJson, messageDataToString } = require('../functions/wireJson'); const { CliDocumentExchange, @@ -366,6 +367,7 @@ class CLI extends FabricShell { // Create and assign Peer instance as the `node` property this.node = new Peer(peerOpts); + this._peerAliasByPubkey = this._peerAliasByPubkey || {}; if (this.settings.debug) { this.node.on('debug', (msg) => { @@ -480,6 +482,7 @@ class CLI extends FabricShell { this.node.on('commit', this._handlePeerCommit.bind(this)); this.node.on('state', this._handlePeerState.bind(this)); this.node.on('chat', this._handlePeerChat.bind(this)); + this.node.on('peerAlias', this._handlePeerAlias.bind(this)); this.node.on('upnp', this._handlePeerUPNP.bind(this)); this.node.on('actorset', this._handleActorSet.bind(this)); this.node.on('contractset', this._handleContractSet.bind(this)); @@ -2010,11 +2013,26 @@ class CLI extends FabricShell { // this._appendDebug(`[NODE] [COMMIT] ${JSON.stringify(_commit)}`); } - async _handlePeerChat (chat) { - const actor = chat.actor || {}; - const idPrefer = actor.id != null && String(actor.id).length ? String(actor.id) : null; - const truncatedId = truncateMiddle(idPrefer || actor.username || '', 10, '…', 5); - this._appendMessage(`[@${truncatedId}]: ${chat.object.content}`); + async _handlePeerChat (chat, meta = {}) { + const text = chatTextOf(chat); + if (!text.trim()) return; + const actorId = chatActorIdOf(chat, { + signer: meta && meta.signer, + defaultActorId: this.node && this.node.id + }); + const aliases = this._peerAliasByPubkey || {}; + const alias = (actorId && aliases[actorId]) || (meta && meta.signer && aliases[meta.signer]) || null; + this._appendMessage(formatChatLogLine(actorId, alias, text, truncateMiddle)); + } + + async _handlePeerAlias (ev) { + const alias = ev && ev.alias != null ? String(ev.alias).trim().slice(0, 64) : ''; + const signer = ev && ev.signer ? String(ev.signer) : ''; + if (!alias || !signer) return; + this._peerAliasByPubkey = this._peerAliasByPubkey || {}; + this._peerAliasByPubkey[signer] = alias; + const xonly = chatActorIdOf(null, { signer }); + if (xonly && xonly !== signer) this._peerAliasByPubkey[xonly] = alias; } async _handlePeerUPNP (upnp) { @@ -2039,16 +2057,11 @@ class CLI extends FabricShell { this._appendError('Could not parse data: expected object'); break; } - const actor = parsed.actor; - const obj = parsed.object; - const truncatedId = truncateMiddle( - actor && (actor.username || actor), - 10, - '…', - 5 - ); - const content = obj && obj.content != null ? String(obj.content) : ''; - this._appendMessage(`[@${truncatedId}]: ${content}`); + const content = chatTextOf(parsed); + const actorId = chatActorIdOf(parsed, { defaultActorId: this.node && this.node.id }); + const aliases = this._peerAliasByPubkey || {}; + const alias = (actorId && aliases[actorId]) || null; + this._appendMessage(formatChatLogLine(actorId, alias, content, truncateMiddle)); break; } case 'BlockCandidate': @@ -2170,34 +2183,21 @@ class CLI extends FabricShell { self.history.push(data.input); } - // Send as Chat Message if no handler registered + // Mesh shoutbox: first-class UTF-8 P2P_CHAT_MESSAGE (Hub UI + GoonCitizen). if (!self._processInput(data.input)) { - // Describe the activity for use in P2P message - const msg = { - type: 'P2P_CHAT_MESSAGE', - actor: { - id: self.node.id - }, - object: { - created: Date.now(), - content: content - }, - target: '/messages' - }; - - let message = Message.fromVector(['ChatMessage', JSON.stringify(msg)]); + const text = String(content); + let message = Message.fromVector(['P2P_CHAT_MESSAGE', text]); message = message.signWithKey(this.key); self.setPane('messages'); - // Log own message - self._handlePeerChat(msg); + self._handlePeerChat({ text, type: 'P2P_CHAT_MESSAGE' }, { + signer: self.node && self.node.id + }); - // Relay to peers self.node.relayFrom(self.node.id, message); - // Notify services - self._sendToAllServices(msg); + self._sendToAllServices({ type: 'P2P_CHAT_MESSAGE', text }); } self.elements['form'].reset(); diff --git a/types/collection.js b/types/collection.js index e6884eab3..8a0e9998a 100644 --- a/types/collection.js +++ b/types/collection.js @@ -112,8 +112,17 @@ class Collection extends Stack { * Retrieve the most recent element in the collection. */ getLatest () { - let items = pointer.get(this.value, this.path); - return items[items.length - 1]; + const items = pointer.get(this.value, this.path); + if (!items) return undefined; + if (Array.isArray(items)) return items[items.length - 1]; + const ids = (this['@entity'] && this['@entity']['@data']) || this['@data']; + if (Array.isArray(ids) && ids.length) { + const lastId = ids[ids.length - 1]; + const fromOrder = items[lastId] || items[String(lastId)]; + if (fromOrder) return fromOrder; + } + const keys = Object.keys(items); + return keys.length ? items[keys[keys.length - 1]] : undefined; } /** diff --git a/types/ecc.selftest.js b/types/ecc.selftest.js index 4aa0a9d97..67aa30960 100644 --- a/types/ecc.selftest.js +++ b/types/ecc.selftest.js @@ -1,8 +1,13 @@ 'use strict'; /** - * Lightweight ECC sanity check for real browsers (see types/ecc.js). - * @param {object} ecc — exports from types/ecc.js + * @fileoverview Lightweight ECC sanity check for real browsers (see types/ecc.js). + * @module types/ecc.selftest + */ + +/** + * Run a small sign/verify round-trip against an `ecc` binding. + * @param {object} ecc exports from types/ecc.js */ module.exports = function runFabricEccSelftest (ecc) { try { diff --git a/types/environment.js b/types/environment.js index bfad7e5ee..b45c62272 100644 --- a/types/environment.js +++ b/types/environment.js @@ -18,6 +18,7 @@ const { } = require('../functions/identityLock'); // Dependencies +const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const os = require('os'); @@ -136,7 +137,7 @@ class Environment extends Entity { _writeWalletDocument (document) { const content = JSON.stringify(document, null, ' ') + '\n'; const target = this.WALLET_FILE; - const temporary = `${target}.${process.pid}.tmp`; + const temporary = `${target}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`; fs.writeFileSync(temporary, content, { encoding: 'utf8', mode: 0o600 }); try { fs.chmodSync(temporary, 0o600); @@ -278,9 +279,22 @@ class Environment extends Entity { return this.readVariable('PASSPHRASE'); } + get mnemonic () { + const explicit = [ + this.settings.mnemonic, + this['FABRIC_MNEMONIC'], + this.readVariable('FABRIC_MNEMONIC') + ]; + const normalized = explicit.map((candidate) => ( + typeof candidate === 'string' ? candidate.trim() : candidate + )); + return normalized.find(any); + } + get seed () { // Precedence: settings / env only. CWD `.FABRIC_SEED` (`this.local`) is opt-in // via `settings.allowCwdSeed` so a planted file cannot outrank `~/.fabric/wallet.json`. + // FABRIC_SEED is raw BIP32 seed hex (or a legacy mnemonic / xprv string). const explicit = [ this.settings.seed, this['FABRIC_SEED'], @@ -787,7 +801,31 @@ class Environment extends Entity { return this; } + /** + * Load identity from env, fixture (`NODE_ENV=test`), or `wallet.json`. + * @param {Object} [opts] + * @param {string} [opts.password] unlocks a sealed wallet.json + * @param {boolean} [opts.fromFile] skip env/fixture identity; read wallet.json only + * @returns {Environment} + */ loadWallet (opts = {}) { + // Wallet-file fallback (ensure-home-env / fabricWalletIdentity) must not + // inherit ambient FABRIC_SEED / NODE_ENV=test fixture identity. + if (opts.fromFile === true) return this._loadWalletFromPath(opts); + + // NODE_ENV=test injects FIXTURE_SEED into `seed`; keep that ahead of a + // developer FABRIC_XPRV so mocha stays deterministic. + const preferFixtureSeed = process.env.NODE_ENV === 'test' && this.seed; + if (!preferFixtureSeed && this.xprv) { + this.wallet = new Wallet({ + key: { + xprv: this.xprv + } + }); + this.walletLocked = false; + this.walletPublic = this._publicRecordFromWallet(this.wallet); + return this; + } if (this.seed) { this.wallet = new Wallet({ key: { @@ -797,15 +835,20 @@ class Environment extends Entity { }); this.walletLocked = false; this.walletPublic = this._publicRecordFromWallet(this.wallet); - } else if (this.xprv) { + return this; + } + if (this.mnemonic) { this.wallet = new Wallet({ key: { - xprv: this.xprv + mnemonic: this.mnemonic, + passphrase: this.passphrase } }); this.walletLocked = false; this.walletPublic = this._publicRecordFromWallet(this.wallet); - } else if (this.xpub) { + return this; + } + if (this.xpub) { this.wallet = new Wallet({ key: { xpub: this.xpub @@ -813,67 +856,89 @@ class Environment extends Entity { }); this.walletLocked = false; this.walletPublic = this._publicRecordFromWallet(this.wallet); - } else if (this.walletExists()) { - try { - const data = this.readWallet(); - const text = typeof data === 'string' ? data : String(data ?? ''); + return this; + } + if (this.walletExists()) { + return this._loadWalletFromPath(opts); + } - if (text.trim() === '') { - if (this.emit) { - this.emit('warning', `[FABRIC:KEYGEN] Wallet file is empty (${this.settings.path}); remove it or regenerate with fabric setup`); - } - this.wallet = false; - this.walletLocked = false; - this.walletPublic = null; - return this; + this.wallet = false; + this.walletLocked = false; + this.walletPublic = null; + return this; + } + + /** + * Read `wallet.json` only (no env / fixture identity). + * @private + * @param {Object} [opts] + * @param {string} [opts.password] + * @returns {Environment} + */ + _loadWalletFromPath (opts = {}) { + if (!this.walletExists()) { + this.wallet = false; + this.walletLocked = false; + this.walletPublic = null; + return this; + } + + try { + const data = this.readWallet(); + const text = typeof data === 'string' ? data : String(data ?? ''); + + if (text.trim() === '') { + if (this.emit) { + this.emit('warning', `[FABRIC:KEYGEN] Wallet file is empty (${this.settings.path}); remove it or regenerate with fabric setup`); } + this.wallet = false; + this.walletLocked = false; + this.walletPublic = null; + return this; + } - const pr = tryParsePersistedJson(text); - if (!pr.ok) throw pr.error; - const input = pr.value; - - if (isPasswordProtectedDocument(input)) { - this.wallet = false; - this.walletLocked = true; - this.walletPublic = (input.object && typeof input.object === 'object') - ? Object.assign({}, input.object) - : {}; - if (input.lockTimeoutMinutes != null) { - this.lockSession.setTimeoutMinutes(clampLockTimeoutMinutes(input.lockTimeoutMinutes)); - } - const password = opts.password || this.readVariable('FABRIC_PASSWORD'); - if (password) { - try { - this.unlockWallet(password); - } catch (exception) { - if (this.emit) { - this.emit('warning', `[FABRIC:KEYGEN] Wallet is locked (unlock failed): ${exception.message || exception}`); - } + const pr = tryParsePersistedJson(text); + if (!pr.ok) throw pr.error; + const input = pr.value; + + if (isPasswordProtectedDocument(input)) { + this.wallet = false; + this.walletLocked = true; + this.walletPublic = (input.object && typeof input.object === 'object') + ? Object.assign({}, input.object) + : {}; + if (input.lockTimeoutMinutes != null) { + this.lockSession.setTimeoutMinutes(clampLockTimeoutMinutes(input.lockTimeoutMinutes)); + } + const password = opts.password || this.readVariable('FABRIC_PASSWORD'); + if (password) { + try { + this.unlockWallet(password); + } catch (exception) { + if (this.emit) { + this.emit('warning', `[FABRIC:KEYGEN] Wallet is locked (unlock failed): ${exception.message || exception}`); } } - return this; - } - - if (!input.object || !input.object.xprv) { - throw new Error(`Corrupt or out-of-date wallet: ${this.settings.path}`); } + return this; + } - this.wallet = new Wallet({ - key: { - seed: input.object.seed, - xprv: input.object.xprv, - xpub: input.object.xpub - } - }); - this.walletLocked = false; - this.walletPublic = this._publicRecordFromWallet(this.wallet); - } catch (exception) { - // Recoverable user-data issue; do not emit "error" (EventEmitter kills the process with no listeners). - if (this.emit) this.emit('warning', `[FABRIC:KEYGEN] Could not load wallet data: ${exception.message || exception}`); - this.wallet = false; - this.walletLocked = false; + if (!input.object || !input.object.xprv) { + throw new Error(`Corrupt or out-of-date wallet: ${this.settings.path}`); } - } else { + + this.wallet = new Wallet({ + key: { + seed: input.object.seed, + xprv: input.object.xprv, + xpub: input.object.xpub + } + }); + this.walletLocked = false; + this.walletPublic = this._publicRecordFromWallet(this.wallet); + } catch (exception) { + // Recoverable user-data issue; do not emit "error" (EventEmitter kills the process with no listeners). + if (this.emit) this.emit('warning', `[FABRIC:KEYGEN] Could not load wallet data: ${exception.message || exception}`); this.wallet = false; this.walletLocked = false; this.walletPublic = null; diff --git a/types/filesystem.js b/types/filesystem.js index 5cc5fa9fd..7fdafe07d 100644 --- a/types/filesystem.js +++ b/types/filesystem.js @@ -91,9 +91,91 @@ class Filesystem extends Actor { return this._state.documents; } + /** + * Lexical containment: `name` resolves under this store root. + * @param {string} name + * @returns {string|null} + * @private + */ + _resolveContained (name) { + const base = path.resolve(this.path); + const file = path.resolve(base, String(name || '')); + const prefix = base.endsWith(path.sep) ? base : base + path.sep; + if (file === base || file.startsWith(prefix)) return file; + return null; + } + + /** + * @param {string} candidate + * @returns {boolean} + * @private + */ + _isInsideRoot (candidate) { + const base = path.resolve(this.path); + const resolved = path.resolve(candidate); + const prefix = base.endsWith(path.sep) ? base : base + path.sep; + return resolved === base || resolved.startsWith(prefix); + } + + /** + * Realpath of the nearest existing ancestor must stay under the store root + * (intermediate directory symlinks that escape are refused). + * @param {string} file + * @returns {boolean} + * @private + */ + _parentTreeContained (file) { + let cur = path.dirname(file); + const root = path.resolve(this.path); + for (;;) { + if (!this._isInsideRoot(cur) && cur !== root) return false; + try { + if (fs.existsSync(cur)) { + return this._isInsideRoot(fs.realpathSync(cur)); + } + } catch { + return false; + } + const next = path.dirname(cur); + if (next === cur) return this._isInsideRoot(root); + cur = next; + } + } + + /** + * Refuse a final-component symlink (`O_NOFOLLOW`). Missing path is allowed + * unless `mustExist`. + * @param {string} file + * @param {object} [opts] + * @param {boolean} [opts.mustExist] + * @returns {string|null} + * @private + */ + _nofollowContained (file, opts = {}) { + if (!this._parentTreeContained(file)) return null; + try { + const st = fs.lstatSync(file); + if (st.isSymbolicLink()) return null; + } catch (e) { + if (e && e.code === 'ENOENT') { + if (opts.mustExist) return null; + return file; + } + return null; + } + return file; + } + delete (name) { - const file = path.join(this.path, name); - if (fs.existsSync(file)) fs.rmSync(file); + const file = this._resolveContained(name); + if (!file || !this._parentTreeContained(file)) return false; + try { + const st = fs.lstatSync(file); + if (st.isSymbolicLink() || st.isFile()) fs.unlinkSync(file); + else if (st.isDirectory()) return false; + } catch (e) { + if (!e || e.code !== 'ENOENT') return false; + } return true; } @@ -130,13 +212,18 @@ class Filesystem extends Actor { * @returns {Buffer} Contents of the file. */ readFile (name) { - const file = path.join(this.path, name); - if (!fs.existsSync(file)) return null; + const file = this._resolveContained(name); + if (!file) return null; + const safe = this._nofollowContained(file, { mustExist: true }); + if (!safe) return null; - // Skip directories - if (fs.statSync(file).isDirectory()) return null; - - return fs.readFileSync(file); + try { + const st = fs.lstatSync(safe); + if (!st.isFile()) return null; + return fs.readFileSync(safe); + } catch { + return null; + } } /** @@ -146,25 +233,44 @@ class Filesystem extends Actor { * @returns {Boolean} `true` if the write succeeded, `false` if it did not. */ writeFile (name, content) { - // Ensure the file path is absolute and properly resolved - const file = path.resolve(this.path, name); + const file = this._resolveContained(name); + if (!file) { + const msg = `Could not write file ${name}: path escapes filesystem root`; + if (this.listenerCount('error') > 0) this.emit('error', msg); + else this.emit('warning', msg); + return false; + } + + const safe = this._nofollowContained(file); + if (!safe) { + const msg = `Could not write file ${name}: path escapes filesystem root or is a symlink`; + if (this.listenerCount('error') > 0) this.emit('error', msg); + else this.emit('warning', msg); + return false; + } try { - // Ensure parent directory exists - const parentDir = path.dirname(file); + const parentDir = path.dirname(safe); if (!fs.existsSync(parentDir)) { mkdirp.sync(parentDir); } - this.touch(file); - fs.writeFileSync(file, content); + const follow = fs.constants.O_NOFOLLOW || 0; + const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | follow; + const fd = fs.openSync(safe, flags); + try { + fs.writeFileSync(fd, content); + } finally { + fs.closeSync(fd); + } - // Emit file update event this._handleDiskChange('change', name); return true; } catch (exception) { - this.emit('error', `Could not write file: ${content} ${exception}`); + const msg = `Could not write file ${name}: ${exception}`; + if (this.listenerCount('error') > 0) this.emit('error', msg); + else this.emit('warning', msg); return false; } } @@ -228,29 +334,42 @@ class Filesystem extends Actor { } const actor = new Actor(document); - const hash = Hash256.digest(document); - - this._state.documents[hash] = document; return { id: actor.id }; } + /** + * Remember a published path in the top-level file list without re-reading the tree. + * Nested paths only add the first segment (same as `readdir` of `this.path`). + * @param {string} name + * @returns {void} + */ + _notePublishedName (name) { + const resolved = this._resolveContained(name); + if (!resolved) return; + const rel = path.relative(this.path, resolved); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return; + const top = rel.split(path.sep).filter(Boolean)[0]; + if (!top || top === '.fabric') return; + if (!Array.isArray(this._state.content.files)) this._state.content.files = []; + const files = this._state.content.files; + if (!files.includes(top)) { + files.push(top); + files.sort(); + } + } + async publish (name, document) { const content = typeof document === 'string' ? document : JSON.stringify(document, null, ' '); const actor = new Actor(document); const hash = Hash256.digest(content); - // Update state - this._state.actors[actor.id] = actor; - this._state.documents[hash] = content; - - // Write the file last, after state is set - this.writeFile(name, content); - - // Ensure changes are persisted - await this.synchronize(); + if (!this.writeFile(name, content)) { + throw new Error(`Could not publish ${name}`); + } + this._notePublishedName(name); return { id: actor.id, @@ -328,16 +447,16 @@ class Filesystem extends Actor { } commit () { - const state = new Actor(this.state); + const content = this._state.content || {}; + const serialized = JSON.stringify(content); // Write state to STATE file using absolute path const statePath = path.resolve(this.path, '.fabric', 'STATE'); - // console.debug('[FILESYSTEM]', 'Writing state:', this.state); - const stateHex = Buffer.from(JSON.stringify(this.state)).toString('hex'); + const stateHex = Buffer.from(serialized).toString('hex'); this.writeFile(statePath, stateHex); - this.writeFile(statePath + '.json', JSON.stringify(this.state, null, ' ')); + this.writeFile(statePath + '.json', serialized); - const commit = Message.fromVector(['COMMIT', state]); + const commit = Message.fromVector(['COMMIT', serialized]); commit.signatures = commit.signatures || []; // Only sign if we have a key with private key component diff --git a/types/identity.js b/types/identity.js index aaa6b9e11..f8fcb01bd 100644 --- a/types/identity.js +++ b/types/identity.js @@ -30,7 +30,8 @@ class Identity extends Actor { /** * Create an instance of an Identity. * @param {Object} [settings] Settings for the Identity. - * @param {String} [settings.seed] BIP 39 seed phrase. + * @param {String} [settings.seed] Raw BIP32 seed hex, or a legacy BIP39 mnemonic. + * @param {String} [settings.mnemonic] BIP39 mnemonic (preferred over putting words in `seed`). * @param {String} [settings.xprv] Serialized BIP 32 master private key. * @param {String} [settings.xpub] Serialized BIP 32 master public key. * @param {Number} [settings.account=0] BIP 44 account index (Fabric coin type 7777/7778). @@ -46,6 +47,7 @@ class Identity extends Actor { this.settings = Object.assign({ seed: null, + mnemonic: null, xprv: null, passphrase: null, account: 0, @@ -58,6 +60,7 @@ class Identity extends Actor { this.key = settings; } else { this.key = new Key({ + mnemonic: this.settings.mnemonic, seed: this.settings.seed, xprv: this.settings.xprv, passphrase: this.settings.passphrase diff --git a/types/key.js b/types/key.js index 6a24d3c8e..2fe354a37 100644 --- a/types/key.js +++ b/types/key.js @@ -126,10 +126,12 @@ function secpPointToRawBytes (point, compressed = true) { const { encodeCheck, decodeCheck } = require('../functions/base58'); const bip39 = require('../functions/bip39'); +const { parseRawSeedHex, classifyFabricKeyMaterial } = require('../functions/fabricKeyMaterial'); const bip32Module = require('../functions/bip32'); const BIP32 = bip32Module.default; const BIP32_DEFAULT_NETWORK = bip32Module.DEFAULT_NETWORK; const { payments } = require('bitcoinjs-lib'); +const { p2shP2wpkhPayment } = require('../functions/bip69'); // Fabric Dependencies const Actor = require('./actor'); @@ -162,7 +164,8 @@ class Key extends EventEmitter { * create it from a known public key. * @param {Object} [settings] Initialization for the key. * @param {String} [settings.network] Network string. - * @param {String} [settings.seed] Mnemonic seed for initializing the key. + * @param {String} [settings.seed] Raw BIP32 seed hex (16–64 bytes) or a legacy BIP39 mnemonic. + * @param {String} [settings.mnemonic] BIP39 mnemonic (preferred over putting words in `seed`). * @param {String} [settings.public] Public key in hex. * @param {String} [settings.private] Private key in hex. * @param {String} [settings.wif] WIF-encoded private key. @@ -187,6 +190,7 @@ class Key extends EventEmitter { bits: 256, hd: true, seed: null, + mnemonic: null, passphrase: '', password: null, index: 0, @@ -250,7 +254,21 @@ class Key extends EventEmitter { if (this.settings.mnemonic) { this._mode = 'FROM_MNEMONIC'; } else if (this.settings.seed) { - this._mode = 'FROM_SEED'; + const raw = this.settings.seed; + if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) { + this._mode = 'FROM_SEED'; + } else { + const classified = classifyFabricKeyMaterial(raw); + if (classified.kind === 'xprv') { + this._mode = 'FROM_XPRV'; + this.settings.xprv = classified.xprv; + } else if (classified.kind === 'mnemonic') { + this._mode = 'FROM_MNEMONIC'; + this.settings.mnemonic = classified.mnemonic; + } else { + this._mode = 'FROM_SEED'; + } + } } else if (this.settings.wif) { this._mode = 'FROM_WIF'; } else if (this.settings.private) { @@ -279,16 +297,25 @@ class Key extends EventEmitter { this._point = secpPointFromPrivateKey(root.privateKey); this.status = 'seeded'; break; - case 'FROM_SEED': - // TODO: allow setting of raw seed (deprecates passing a mnemonic in the `seed` property) - seed = bip39.mnemonicToSeedSync(this.settings.seed, this.settings.passphrase); + case 'FROM_SEED': { + const rawSeed = parseRawSeedHex(this.settings.seed); + if (rawSeed) { + seed = rawSeed; + this.seed = rawSeed.toString('hex'); + } else if (Buffer.isBuffer(this.settings.seed) || this.settings.seed instanceof Uint8Array) { + throw new Error('Seed must be 16–64 bytes'); + } else { + seed = bip39.mnemonicToSeedSync(this.settings.seed, this.settings.passphrase); + this.seed = this.settings.seed; + } root = this.bip32.fromSeed(seed, bip32NetworkFromKeySettings(this.settings)); - this.seed = this.settings.seed; this.xprv = root.toBase58(); this.xpub = root.neutered().toBase58(); this.master = root; this._point = secpPointFromPrivateKey(root.privateKey); + this.status = 'seeded'; break; + } case 'FROM_WIF': const decoded = decodeCheck(this.settings.wif); const privateKey = decoded.slice(1, 33); @@ -479,6 +506,18 @@ class Key extends EventEmitter { publicKey: key.publicKey, privateKey: key.privateKey }; + case 'p2sh-p2wpkh': + case 'p2shp2wpkh': { + const nested = p2shP2wpkhPayment({ + pubkey: Buffer.from(key.publicKey, 'hex'), + network: network + }); + return { + address: nested.address, + publicKey: key.publicKey, + privateKey: key.privateKey + }; + } case 'p2tr': // For p2tr, we need to use the x-only pubkey (first 32 bytes after the prefix) const pubkeyBuffer = Buffer.from(key.publicKey, 'hex'); diff --git a/types/message.js b/types/message.js index 60f9f9a37..a42c6dc20 100644 --- a/types/message.js +++ b/types/message.js @@ -33,6 +33,13 @@ const { P2P_PEERING_OFFER, P2P_SESSION_OFFER, P2P_SESSION_OPEN, + P2P_SESSION_ACK, + P2P_MUSIG_START, + P2P_MUSIG_ACCEPT, + P2P_MUSIG_RECEIVE_COUNTER, + P2P_MUSIG_SEND_PROPOSAL, + P2P_MUSIG_REPLY_TO_PROPOSAL, + P2P_MUSIG_ACCEPT_PROPOSAL, P2P_CONTRACT_PUBLISH, P2P_CONTRACT_MESSAGE, P2P_STATE_ROOT, @@ -111,8 +118,8 @@ const taggedHash = require('../functions/taggedHash'); * Encode accepts **either** name via merged {@link Message#types} (canonical wire + legacy friendly). * {@link Message.wireTypeFromFriendly} / {@link Message.friendlyTypeFromWire} convert between them. * - * Opcode → wire string order matches the historical `type` switch: when multiple labels share one - * opcode (e.g. P2P vs Lightning), **first listed** in {@link Message.WIRE_TYPE_DECODE_ORDER} wins. + * Each opcode appears once in {@link Message.WIRE_TYPE_DECODE_ORDER} (Fabric low numbers, + * Lightning in 0x2000–0x2FFF). Duplicate opcodes fail at module load. * Those maps are **not** global exports: use {@link Message} statics `WIRE_TYPE_DECODE_ORDER` and * `FRIENDLY_TYPE_BY_WIRE`. */ @@ -144,12 +151,20 @@ const WIRE_TYPE_DECODE_ORDER = Object.freeze([ [P2P_PEERING_OFFER, 'P2P_PEERING_OFFER'], [P2P_SESSION_OFFER, 'P2P_SESSION_OFFER'], [P2P_SESSION_OPEN, 'P2P_SESSION_OPEN'], + [P2P_SESSION_ACK, 'P2P_SESSION_ACK'], + [P2P_MUSIG_START, 'P2P_MUSIG_START'], + [P2P_MUSIG_ACCEPT, 'P2P_MUSIG_ACCEPT'], + [P2P_MUSIG_RECEIVE_COUNTER, 'P2P_MUSIG_RECEIVE_COUNTER'], + [P2P_MUSIG_SEND_PROPOSAL, 'P2P_MUSIG_SEND_PROPOSAL'], + [P2P_MUSIG_REPLY_TO_PROPOSAL, 'P2P_MUSIG_REPLY_TO_PROPOSAL'], + [P2P_MUSIG_ACCEPT_PROPOSAL, 'P2P_MUSIG_ACCEPT_PROPOSAL'], [P2P_CONTRACT_PUBLISH, 'CONTRACT_PUBLISH'], [P2P_CONTRACT_MESSAGE, 'CONTRACT_MESSAGE'], [P2P_IDENT_REQUEST, 'P2P_IDENT_REQUEST'], [P2P_IDENT_RESPONSE, 'P2P_IDENT_RESPONSE'], [P2P_BASE_MESSAGE, 'P2P_BASE_MESSAGE'], [P2P_STATE_ROOT, 'P2P_STATE_ROOT'], + [P2P_STATE_COMMITTMENT, 'P2P_STATE_COMMITTMENT'], [P2P_STATE_CHANGE, 'P2P_STATE_CHANGE'], [P2P_STATE_REQUEST, 'P2P_STATE_REQUEST'], [P2P_TRANSACTION, 'P2P_TRANSACTION'], @@ -165,11 +180,9 @@ const WIRE_TYPE_DECODE_ORDER = Object.freeze([ [PATCH_MESSAGE_TYPE, 'JSON_PATCH'], [CONTRACT_PROPOSAL_TYPE, 'CONTRACT_PROPOSAL'], [P2P_START_CHAIN, 'P2P_START_CHAIN'], - [LIGHTNING_WARNING, 'LIGHTNING_WARNING'], + [P2P_INSTRUCTION, 'P2P_INSTRUCTION'], [LIGHTNING_INIT, 'LIGHTNING_INIT'], [LIGHTNING_ERROR, 'LIGHTNING_ERROR'], - [LIGHTNING_PING, 'LIGHTNING_PING'], - [LIGHTNING_PONG, 'LIGHTNING_PONG'], [LIGHTNING_OPEN_CHANNEL, 'LIGHTNING_OPEN_CHANNEL'], [LIGHTNING_ACCEPT_CHANNEL, 'LIGHTNING_ACCEPT_CHANNEL'], [LIGHTNING_FUNDING_CREATED, 'LIGHTNING_FUNDING_CREATED'], @@ -184,7 +197,10 @@ const WIRE_TYPE_DECODE_ORDER = Object.freeze([ [LIGHTNING_REVOKE_AND_ACK, 'LIGHTNING_REVOKE_AND_ACK'], [LIGHTNING_CHANNEL_ANNOUNCEMENT, 'LIGHTNING_CHANNEL_ANNOUNCEMENT'], [LIGHTNING_NODE_ANNOUNCEMENT, 'LIGHTNING_NODE_ANNOUNCEMENT'], - [LIGHTNING_CHANNEL_UPDATE, 'LIGHTNING_CHANNEL_UPDATE'] + [LIGHTNING_CHANNEL_UPDATE, 'LIGHTNING_CHANNEL_UPDATE'], + [LIGHTNING_WARNING, 'LIGHTNING_WARNING'], + [LIGHTNING_PING, 'LIGHTNING_PING'], + [LIGHTNING_PONG, 'LIGHTNING_PONG'] ]); const CANONICAL_WIRE_TYPE_BY_OPCODE = Object.freeze( @@ -192,7 +208,9 @@ const CANONICAL_WIRE_TYPE_BY_OPCODE = Object.freeze( const code = pair[0]; const name = pair[1]; if (typeof code !== 'number' || !Number.isFinite(code)) return acc; - if (acc[code] !== undefined) return acc; + if (acc[code] !== undefined) { + throw new Error(`duplicate AMP opcode ${code}: ${acc[code]} and ${name}`); + } acc[code] = name; return acc; }, {}) @@ -237,6 +255,12 @@ const LEGACY_MESSAGE_TYPE_ALIASES = Object.freeze({ PeeringOffer: P2P_PEERING_OFFER, SessionOffer: P2P_SESSION_OFFER, SessionOpen: P2P_SESSION_OPEN, + MusigStart: P2P_MUSIG_START, + MusigAccept: P2P_MUSIG_ACCEPT, + MusigReceiveCounter: P2P_MUSIG_RECEIVE_COUNTER, + MusigSendProposal: P2P_MUSIG_SEND_PROPOSAL, + MusigReplyToProposal: P2P_MUSIG_REPLY_TO_PROPOSAL, + MusigAcceptProposal: P2P_MUSIG_ACCEPT_PROPOSAL, FileSend: P2P_FILE_SEND, DocumentPricingPublish: P2P_DOCUMENT_PUBLISH, Ping: P2P_PING, @@ -470,9 +494,6 @@ class Message extends Actor { if (messageData && messageType) { this.type = messageType; - // Set the type field to the numeric constant - const typeCode = this.types[messageType] || this.types.P2P_BASE_MESSAGE; - this.raw.type.writeUInt32BE(typeCode, 0); if (Buffer.isBuffer(messageData) || messageData instanceof Uint8Array) { this.data = Buffer.from(messageData); @@ -867,14 +888,12 @@ class Message extends Actor { if (!input) return null; // Convert various buffer-like inputs to Buffer let buffer; - if (input instanceof Buffer) { + if (Buffer.isBuffer(input)) { buffer = input; - } else if (input instanceof Uint8Array) { - buffer = Buffer.from(input.buffer); + } else if (ArrayBuffer.isView(input)) { + buffer = Buffer.from(input.buffer, input.byteOffset, input.byteLength); } else if (input instanceof ArrayBuffer) { buffer = Buffer.from(input); - } else if (input.buffer instanceof ArrayBuffer) { - buffer = Buffer.from(input.buffer); } else { throw new Error('Input must be a buffer or buffer-like object.'); } @@ -903,18 +922,10 @@ class Message extends Actor { } static fromVector (vector = ['LogMessage', 'No vector provided.']) { - let message = null; - - try { - message = new Message({ - type: vector[0], - data: vector[1] - }); - } catch (exception) { - console.error('[FABRIC:MESSAGE]', 'Could not construct Message:', exception); - } - - return message; + return new Message({ + type: vector[0], + data: vector[1] + }); } /** @@ -1036,7 +1047,6 @@ Object.defineProperty(Message.prototype, 'type', { return this.wireType; }, set (value) { - // console.trace('setting type:', value); let code = this.types[value]; // Default to P2P_BASE_MESSAGE for unknown/unregistered names. if (!code) { @@ -1334,6 +1344,30 @@ const SCHEMA_P2P_PEER_ANNOUNCE = Object.freeze([ { name: 'host', type: 'string' }, { name: 'port', type: 'u32' } ]); +/** @private BIP-327 directed MuSig2 session (JSON bodies still accepted). */ +const SCHEMA_P2P_MUSIG_START = Object.freeze([ + { name: 'sessionId', type: 'bytes32' }, + { name: 'msg', type: 'bytes' }, + { name: 'pubkeys', type: 'bytes' }, + { name: 'pubnonce', type: 'bytes' }, + { name: 'purpose', type: 'string', optional: true } +]); +const SCHEMA_P2P_MUSIG_PUBNONCE = Object.freeze([ + { name: 'sessionId', type: 'bytes32' }, + { name: 'pubnonce', type: 'bytes' } +]); +const SCHEMA_P2P_MUSIG_AGGNONCE = Object.freeze([ + { name: 'sessionId', type: 'bytes32' }, + { name: 'aggnonce', type: 'bytes' } +]); +const SCHEMA_P2P_MUSIG_PSIG = Object.freeze([ + { name: 'sessionId', type: 'bytes32' }, + { name: 'psig', type: 'bytes' } +]); +const SCHEMA_P2P_MUSIG_SIGNATURE = Object.freeze([ + { name: 'sessionId', type: 'bytes32' }, + { name: 'signature', type: 'bytes' } +]); /** * Parse an inbound AMP body: prefer JSON (legacy), else registered field schema. @@ -1392,6 +1426,24 @@ function tryParseMessageBody (message) { registerBodySchema(P2P_PEER_ANNOUNCE, SCHEMA_P2P_PEER_ANNOUNCE); registerBodySchema('P2P_PEER_ANNOUNCE', SCHEMA_P2P_PEER_ANNOUNCE); registerBodySchema('PeerAnnounce', SCHEMA_P2P_PEER_ANNOUNCE); + registerBodySchema(P2P_MUSIG_START, SCHEMA_P2P_MUSIG_START); + registerBodySchema('P2P_MUSIG_START', SCHEMA_P2P_MUSIG_START); + registerBodySchema('MusigStart', SCHEMA_P2P_MUSIG_START); + registerBodySchema(P2P_MUSIG_ACCEPT, SCHEMA_P2P_MUSIG_PUBNONCE); + registerBodySchema('P2P_MUSIG_ACCEPT', SCHEMA_P2P_MUSIG_PUBNONCE); + registerBodySchema('MusigAccept', SCHEMA_P2P_MUSIG_PUBNONCE); + registerBodySchema(P2P_MUSIG_RECEIVE_COUNTER, SCHEMA_P2P_MUSIG_PUBNONCE); + registerBodySchema('P2P_MUSIG_RECEIVE_COUNTER', SCHEMA_P2P_MUSIG_PUBNONCE); + registerBodySchema('MusigReceiveCounter', SCHEMA_P2P_MUSIG_PUBNONCE); + registerBodySchema(P2P_MUSIG_SEND_PROPOSAL, SCHEMA_P2P_MUSIG_AGGNONCE); + registerBodySchema('P2P_MUSIG_SEND_PROPOSAL', SCHEMA_P2P_MUSIG_AGGNONCE); + registerBodySchema('MusigSendProposal', SCHEMA_P2P_MUSIG_AGGNONCE); + registerBodySchema(P2P_MUSIG_REPLY_TO_PROPOSAL, SCHEMA_P2P_MUSIG_PSIG); + registerBodySchema('P2P_MUSIG_REPLY_TO_PROPOSAL', SCHEMA_P2P_MUSIG_PSIG); + registerBodySchema('MusigReplyToProposal', SCHEMA_P2P_MUSIG_PSIG); + registerBodySchema(P2P_MUSIG_ACCEPT_PROPOSAL, SCHEMA_P2P_MUSIG_SIGNATURE); + registerBodySchema('P2P_MUSIG_ACCEPT_PROPOSAL', SCHEMA_P2P_MUSIG_SIGNATURE); + registerBodySchema('MusigAcceptProposal', SCHEMA_P2P_MUSIG_SIGNATURE); })(); Message.FIELD_TYPES = BODY_FIELD_TYPES; @@ -1408,5 +1460,170 @@ Message.SCHEMA_P2P_FORWARD = SCHEMA_P2P_FORWARD; Message.SCHEMA_P2P_PEER_GOSSIP = SCHEMA_P2P_PEER_GOSSIP; Message.SCHEMA_P2P_PEERING_OFFER = SCHEMA_P2P_PEERING_OFFER; Message.SCHEMA_P2P_PEER_ANNOUNCE = SCHEMA_P2P_PEER_ANNOUNCE; +Message.SCHEMA_P2P_MUSIG_START = SCHEMA_P2P_MUSIG_START; + +const fs = require('fs'); +const path = require('path'); +const WIRE_CATALOG_ROOT = path.resolve(__dirname, '..'); +const WIRE_CATALOG_PATH = path.join(WIRE_CATALOG_ROOT, 'MESSAGES.md'); +const WIRE_CATALOG_BEGIN = ''; +const WIRE_CATALOG_END = ''; + +function wireCatalogHexOf (n) { + const h = n.toString(16); + if (n <= 0xffff) return '0x' + h.padStart(4, '0'); + return '0x' + h; +} + +function wireCatalogFamilyOf (name, code) { + if (name.indexOf('LIGHTNING_') === 0) return 'lightning'; + if (name.indexOf('BITCOIN_') === 0) return 'bitcoin'; + if (name === 'SIDECHAIN_STATE_PATCH' || name.indexOf('CONTRACT_') === 0) return 'contracts'; + if ( + name.indexOf('DOCUMENT_') === 0 || + name === 'P2P_INVENTORY_REQUEST' || + name === 'P2P_INVENTORY_RESPONSE' || + name === 'P2P_FILE_SEND' || + name === 'P2P_DOCUMENT_PUBLISH' + ) { + return 'documents'; + } + if ( + name === 'JSON_CALL' || + name === 'JSON_PATCH' || + name === 'GENERIC_MESSAGE' || + name === 'P2P_MESSAGE_RECEIPT' || + name === 'CHAT_MESSAGE' + ) { + return 'hub'; + } + if ( + name.indexOf('P2P_SESSION_') === 0 || + name.indexOf('P2P_IDENT_') === 0 || + name.indexOf('P2P_MUSIG_') === 0 || + name === 'P2P_INSTRUCTION' + ) { + return 'session'; + } + if ( + name === 'LOG_MESSAGE' || + name === 'GENERIC_LIST' || + name === 'BLOCK_CANDIDATE' || + name === 'PEER_CANDIDATE' || + name === 'SESSION_START' || + name === 'P2P_BASE_MESSAGE' || + name === 'P2P_GENERIC' || + name === 'P2P_START_CHAIN' || + name === 'P2P_STATE_ROOT' || + name === 'P2P_STATE_COMMITTMENT' || + name === 'P2P_STATE_CHANGE' || + name === 'P2P_STATE_REQUEST' || + name === 'P2P_TRANSACTION' || + name === 'P2P_CALL' + ) { + return 'legacy'; + } + if (code >= 0x2000 && code <= 0x2fff) return 'lightning'; + return 'mesh'; +} + +function collectWireCatalog () { + require('../functions/documentRegistrySidechain'); + const aliasesByCode = Object.create(null); + const types = new Message().types; + for (const name of Object.keys(types)) { + const code = types[name]; + if (typeof code !== 'number' || !Number.isFinite(code)) continue; + const decode = Message.canonicalTypeName(code); + if (!decode || name === decode) continue; + if (!aliasesByCode[code]) aliasesByCode[code] = []; + if (aliasesByCode[code].indexOf(name) === -1) aliasesByCode[code].push(name); + } + for (const code of Object.keys(aliasesByCode)) { + aliasesByCode[code].sort(); + } + const rows = []; + for (const pair of Message.WIRE_TYPE_DECODE_ORDER) { + const code = pair[0]; + const name = pair[1]; + if (typeof code !== 'number' || !Number.isFinite(code)) continue; + const schema = Message.getBodySchema(code) || Message.getBodySchema(name); + rows.push({ + name, + opcode: code, + hex: wireCatalogHexOf(code), + aliases: aliasesByCode[code] ? aliasesByCode[code].slice() : [], + schema: Array.isArray(schema) && schema.length > 0, + family: wireCatalogFamilyOf(name, code) + }); + } + return rows; +} + +function renderWireCatalogTable (rows) { + const lines = [ + '| Decode name | Dec | Hex | Encode aliases | Field schema | Family |', + '|---|---:|---|---|---|---|' + ]; + for (const row of rows) { + const aliases = row.aliases.length ? '`' + row.aliases.join('`, `') + '`' : '—'; + lines.push( + `| \`${row.name}\` | ${row.opcode} | \`${row.hex}\` | ${aliases} | ${row.schema ? 'yes' : 'no'} | ${row.family} |` + ); + } + return lines.join('\n'); +} + +function wireCatalogBlock (rows) { + const unique = new Set(rows.map((row) => row.opcode)); + const withSchema = rows.filter((row) => row.schema).length; + const lightning = rows.filter((row) => row.family === 'lightning').length; + return `${WIRE_CATALOG_BEGIN} + +This catalog: **${rows.length}** decode names, **${unique.size}** unique opcodes, **${withSchema}** with a registered field schema, **${lightning}** Lightning AMP rows. Regenerated by \`npm run docs:message-types\`. Do not hand-edit the table. + +${renderWireCatalogTable(rows)} + +${WIRE_CATALOG_END}`; +} + +function writeWireCatalog () { + const rows = collectWireCatalog(); + const block = wireCatalogBlock(rows); + const here = process.cwd(); + try { + if (here !== WIRE_CATALOG_ROOT) process.chdir(WIRE_CATALOG_ROOT); + // Literal filename: Codacy Semgrep flags fs.*(constructedPath) as critical. + let md = fs.readFileSync('MESSAGES.md', 'utf8'); + const start = md.indexOf(WIRE_CATALOG_BEGIN); + const stop = md.indexOf(WIRE_CATALOG_END); + if (start < 0 || stop < 0 || stop < start) { + throw new Error('MESSAGES.md is missing ' + WIRE_CATALOG_BEGIN + ' / ' + WIRE_CATALOG_END); + } + md = md.slice(0, start) + block + md.slice(stop + WIRE_CATALOG_END.length); + fs.writeFileSync('MESSAGES.md', md); + } finally { + if (process.cwd() !== here) process.chdir(here); + } + return rows; +} + +Message.wireCatalog = { + OUTPUT_PATH: WIRE_CATALOG_PATH, + CATALOG_BEGIN: WIRE_CATALOG_BEGIN, + CATALOG_END: WIRE_CATALOG_END, + collectCatalog: collectWireCatalog, + catalogBlock: wireCatalogBlock, + renderMarkdown: wireCatalogBlock, + renderTable: renderWireCatalogTable, + writeConsolidation: writeWireCatalog, + hexOf: wireCatalogHexOf, + familyOf: wireCatalogFamilyOf +}; module.exports = Message; + +if (require.main === module) { + const rows = writeWireCatalog(); + process.stdout.write('Wrote MESSAGES.md (' + rows.length + ' decode names)\n'); +} diff --git a/types/node.js b/types/node.js index edaf2b568..a67af0492 100644 --- a/types/node.js +++ b/types/node.js @@ -61,8 +61,12 @@ class Node extends Service { self.emit('log', `connection close: ${JSON.stringify(data)}`); }); - source.on('chat', function (chat) { - self.emit('chat', chat); + source.on('chat', function (chat, meta) { + self.emit('chat', chat, meta); + }); + + source.on('peerAlias', function (ev, meta) { + self.emit('peerAlias', ev, meta); }); source.on('info', function (info) { diff --git a/types/peer.js b/types/peer.js index 0f7021904..fdf24f7ad 100644 --- a/types/peer.js +++ b/types/peer.js @@ -11,6 +11,7 @@ const { PEERING_OFFER_MAX_PAYLOAD_CACHE, PEERING_OFFER_MAX_RELAYS_PER_ORIGIN_PER_MINUTE, PEER_MAX_CANDIDATES_QUEUE, + PEER_CANDIDATE_RETRY_MS, P2P_PEER_GOSSIP, P2P_PEERING_OFFER, P2P_PEER_ALIAS, @@ -35,6 +36,8 @@ const { CHAT_MAX_RELAYS_PER_ORIGIN_PER_MINUTE, PEER_MAX_PENDING_SEALED_DELIVERIES, PEER_MAX_DOCUMENT_RELAY_ROUTES, + PEER_MAX_MUSIG_SESSIONS, + PEER_MUSIG_SESSION_TTL_MS, HEADER_SIZE, MAX_MESSAGE_SIZE, P2P_PORT, @@ -55,12 +58,75 @@ const { isOnionChatSeal, tryOpenOnionChatText } = require('../functions/onionChatSeal'); +const { individualPk } = require('../functions/musig2'); +const musig2Session = require('../functions/musig2Session'); /** @private Max UTF-8 code units for first-class P2P_CHAT_MESSAGE body (text only). */ const P2P_CHAT_MAX_CHARS = 2000; /** @private Max UTF-8 code units for first-class P2P_PEER_ALIAS body (nickname). */ const P2P_PEER_ALIAS_MAX_CHARS = 64; +/** + * Peer-owned content maps. Hub passes `new Peer(this.settings)` so lodash.merge + * would otherwise alias nested `collections` / message logs into the observed + * tree; commits then JSON-cloned Hub state on every socket. + * @param {object} [input] + * @returns {object} + * @private + */ +function isolatePeerContent (input) { + const defaults = { + actors: {}, + channels: {}, + contracts: {}, + documents: {}, + documentRates: {}, + documentSealed: {}, + documentContentKeys: {}, + messages: {}, + services: {} + }; + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return defaults; + } + const out = Object.assign({}, defaults); + for (const key of Object.keys(defaults)) { + if (input[key] && typeof input[key] === 'object' && !Array.isArray(input[key])) { + out[key] = Object.assign({}, input[key]); + } + } + return out; +} + +/** + * Node's URL parser keeps brackets on IPv6 hostnames (`tcp://[::1]:7777` + * → hostname `[::1]`). Strip one pair so `net.createConnection` gets `::1` + * and canonical keys stay `[::1]:port` rather than `[[::1]]:port`. + * @param {*} hostname + * @returns {string} + * @private + */ +function hostnameWithoutIpv6Brackets (hostname) { + const h = String(hostname || ''); + if (h.length >= 4 && h.charAt(0) === '[' && h.charAt(h.length - 1) === ']') { + return h.slice(1, -1); + } + return h; +} + +/** + * Dial / candidate key: `host:port`, or `[v6]:port` so URL parse and retry maps agree. + * @param {*} host + * @param {*} port + * @returns {string} + * @private + */ +function canonicalPeerAddress (host, port) { + const h = hostnameWithoutIpv6Brackets(String(host == null ? '' : host).trim()); + if (!h) return ''; + return h.includes(':') ? `[${h}]:${port}` : `${h}:${port}`; +} + // Dependencies const net = require('net'); const crypto = require('crypto'); @@ -140,18 +206,42 @@ const PROLOGUE = 'FABRIC'; /** * Safe debug label for a derived Key — never log private material. + * `Key.derive()` returns `new Key({ xprv })`; compressed pubkey lives on + * `.pubkey`, not `settings.public` (that field is only set for FROM_PUBLIC_KEY). * @private */ function peerDebugDerivedPublicSummary (keyInstance) { - if (!keyInstance || !keyInstance.settings) return '(unavailable)'; - const pubHex = typeof keyInstance.settings.public === 'string' - ? keyInstance.settings.public.trim() - : ''; + if (!keyInstance) return '(unavailable)'; + let pubHex = ''; + try { + if (typeof keyInstance.pubkey === 'string') pubHex = keyInstance.pubkey.trim(); + } catch (err) { + pubHex = ''; + } + if (!pubHex && keyInstance.settings) { + const fromSettings = keyInstance.settings.pubkey || keyInstance.settings.public; + if (typeof fromSettings === 'string') pubHex = fromSettings.trim(); + } if (!pubHex) return '(no public key)'; if (pubHex.length > 28) return `${pubHex.slice(0, 12)}…${pubHex.slice(-10)}`; return pubHex; } +/** + * Peering / gossip JSON may be nested `{ object: { host, port } }` or flat + * `{ type, host, port }` (numeric AMP opcode as `type`). Prefer nested fields. + * @private + */ +function genericOfferObject (message) { + const nested = message && message.object; + if (nested && typeof nested === 'object' && !Array.isArray(nested) && + (nested.host || nested.port || nested.transport || + nested.peeringHop != null || nested.gossipHop != null)) { + return nested; + } + return message || {}; +} + /** * Lowercase hex for comparing {@link P2P_FLUSH_CHAIN} authorized pubkeys. * @private @@ -393,6 +483,18 @@ const FIRST_CLASS_OPCODE_ONLY_TYPES = new Set([ 'P2P_PEER_ALIAS', 'P2P_SESSION_OFFER', 'P2P_SESSION_OPEN', + 'P2P_MUSIG_START', + 'P2P_MUSIG_ACCEPT', + 'P2P_MUSIG_RECEIVE_COUNTER', + 'P2P_MUSIG_SEND_PROPOSAL', + 'P2P_MUSIG_REPLY_TO_PROPOSAL', + 'P2P_MUSIG_ACCEPT_PROPOSAL', + 'MusigStart', + 'MusigAccept', + 'MusigReceiveCounter', + 'MusigSendProposal', + 'MusigReplyToProposal', + 'MusigAcceptProposal', 'P2P_CHAT_MESSAGE', 'CONTRACT_PUBLISH', 'CONTRACT_MESSAGE', @@ -508,6 +610,7 @@ class Peer extends Service { // Optionally relay INVENTORY_RESPONSE to peers other than the sender. relayInventoryResponse: false, connectTimeout: 5000, + commitHistoryMax: 256, // Relay amplification controls for P2P_PEER_GOSSIP. gossip: { maxHops: GOSSIP_MAX_HOPS, @@ -567,6 +670,12 @@ class Peer extends Service { flushChainAuthorizedPubkeys: [] }, config); + this.settings.musig2 = Object.assign({ + autoAccept: false, + maxSessions: PEER_MAX_MUSIG_SESSIONS, + sessionTtlMs: PEER_MUSIG_SESSION_TTL_MS + }, config.musig2); + // FABRIC_INTERFACE / FABRIC_PEER_INTERFACE override bind when set. this.settings.interface = resolveFabricPeerInterface({ interface: this.settings.interface, @@ -624,6 +733,9 @@ class Peer extends Service { // Internal properties this.actors = {}; + /** Connection name (`host:port`) → Actor id. */ + /** @type {Object} */ + this._actorsByName = Object.create(null); this.contracts = {}; this.chains = {}; this.candidates = []; @@ -690,6 +802,8 @@ class Peer extends Service { this._peerBans = new Map(); /** `host:port` keys for {@link P2P_PEERING_OFFER} candidate queue dedup. */ this._candidateKeys = new Set(); + /** `host:port` → next allowed dial (ms). Caps refused-candidate storms. */ + this._candidateRetryAt = new Map(); /** * `host:port` strings we opened via {@link Peer#_connect} (outbound dials). * {@link P2P_SESSION_OFFER} must not destroy these when the same peer also opens an inbound @@ -704,6 +818,12 @@ class Peer extends Service { */ this._selfDialSuppressUntil = new Map(); this.sessions = {}; + /** + * BIP-327 directed sessions keyed by sessionId hex. + * @type {Map} + */ + this._musigSessions = new Map(); + this._musigSessionOrder = []; // Internal Stack Machine this.machine = new Machine({ key: this.settings.key }); @@ -716,6 +836,7 @@ class Peer extends Service { } }; + this.settings.state = isolatePeerContent(this.settings.state); this._state = { content: this.settings.state, peers: {}, // Peer registry keyed by public key (id). Persisted to LevelDB. @@ -1226,7 +1347,7 @@ class Peer extends Service { normalizedPort > 65535) { return; } - const key = `${normalizedHost}:${normalizedPort}`; + const key = canonicalPeerAddress(normalizedHost, normalizedPort); if (this._isSelfDialSuppressed(key)) return; // Only suppress from a verified AMP/session pubkey — remote `obj.pubkey` is // attacker-controlled and must not poison dial targets for ~10 minutes. @@ -1242,7 +1363,9 @@ class Peer extends Service { const old = this.candidates.shift(); const o = old && (old.object || old); if (o && o.host != null && o.port != null) { - this._candidateKeys.delete(`${String(o.host)}:${Number(o.port)}`); + const evicted = canonicalPeerAddress(o.host, o.port); + this._candidateKeys.delete(evicted); + if (this._candidateRetryAt) this._candidateRetryAt.delete(evicted); } } this._candidateKeys.add(key); @@ -1347,7 +1470,7 @@ class Peer extends Service { if (this._candidateKeys) this._candidateKeys.delete(addr); if (Array.isArray(this.candidates)) { this.candidates = this.candidates.filter((c) => { - const h = c && (c.address || `${c.host}:${c.port}`); + const h = c && (c.address || canonicalPeerAddress(c.host, c.port)); return h !== addr; }); } @@ -1548,14 +1671,14 @@ class Peer extends Service { } beat () { - const initial = new Actor(this.state); const now = (new Date()).toISOString(); - + const clock = this._clock | 0; this.commit(); this.emit('beat', { created: now, - initial: initial.toGenericMessage(), - state: this.state + clock: clock, + initial: { type: 'FabricActorState', object: { clock: clock } }, + state: { clock: clock } }); return this; @@ -1679,6 +1802,382 @@ class Peer extends Service { return true; } + /** + * Begin a directed BIP-327 MuSig2 session (n-of-n) with connected peers. + * Co-signers ignore inbound START unless `settings.musig2.autoAccept === true`. + * + * @param {object} [opts] + * @param {Buffer|string} opts.msg message to sign (hashed to 32 bytes unless already 32) + * @param {Array} [opts.pubkeys] compressed participants; default local + dest + * @param {Buffer|string} [opts.dest] peer pubkey or `host:port` for the first hop + * @param {string} [opts.purpose] + * @returns {object|null} public session view + */ + startMusig2 (opts = {}) { + const sk = this._musigLocalSecret(); + if (!sk) { + this.emit('warning', '[FABRIC:PEER] startMusig2: no local secret'); + return null; + } + const localPk = individualPk(sk); + let pubkeys = opts.pubkeys; + if (!pubkeys || !pubkeys.length) { + const destPk = this._musigDestPubkey(opts.dest || opts.peerId || opts.address); + if (!destPk) { + this.emit('warning', '[FABRIC:PEER] startMusig2: dest pubkey required'); + return null; + } + pubkeys = [localPk, destPk]; + } + let session; + try { + session = musig2Session.createLocalSession({ + msg: opts.msg, + pubkeys, + sk, + purpose: opts.purpose || '', + initiator: true + }); + } catch (err) { + this.emit('warning', `[FABRIC:PEER] startMusig2: ${err && err.message ? err.message : err}`); + return null; + } + this._musigPut(session); + this._musigSend('P2P_MUSIG_START', musig2Session.startFields(session), session); + this.emit('musig2:start', musig2Session.sessionPublicView(session)); + return musig2Session.sessionPublicView(session); + } + + /** + * Public MuSig2 session snapshot (no secret nonce). + * @param {Buffer|string} sessionId + * @returns {object|null} + */ + getMusig2Session (sessionId) { + return musig2Session.sessionPublicView(this._musigGet(sessionId)); + } + + /** + * @returns {Buffer|null} + * @private + */ + _musigLocalSecret () { + const k = this.key && this.key.private; + if (Buffer.isBuffer(k) && k.length === 32) return k; + if (typeof k === 'string' && /^[0-9a-f]{64}$/i.test(k.trim())) { + return Buffer.from(k.trim(), 'hex'); + } + return null; + } + + /** + * @param {Buffer|string} dest + * @returns {Buffer|null} + * @private + */ + _musigDestPubkey (dest) { + if (dest == null || dest === '') return null; + try { + const buf = Buffer.isBuffer(dest) ? dest : Buffer.from(String(dest).replace(/^0x/i, ''), 'hex'); + if (buf.length === 33) return buf; + } catch { + // host:port or non-hex + } + const addr = this._resolveAddressByXOnly(dest) || this._resolveToAddress(String(dest)); + if (!addr) return null; + const rec = (this.peers && this.peers[addr]) || null; + const pk = rec && (rec.publicKey || rec.pubkey); + if (!pk) return null; + try { + const buf = Buffer.isBuffer(pk) ? pk : Buffer.from(String(pk).replace(/^0x/i, ''), 'hex'); + if (buf.length === 33) return buf; + } catch { + return null; + } + return null; + } + + /** + * @param {Buffer|string} sessionId + * @returns {string} + * @private + */ + _musigSessionKey (sessionId) { + if (Buffer.isBuffer(sessionId)) return sessionId.toString('hex'); + return String(sessionId || '').toLowerCase().replace(/^0x/i, ''); + } + + /** + * Opt-in only. Default `musig2.autoAccept` is false so inbound START cannot + * turn the identity key into a remote signing oracle. + * @returns {boolean} + * @private + */ + _musigAutoAccept () { + return !!(this.settings.musig2 && this.settings.musig2.autoAccept === true); + } + + /** + * @returns {number} + * @private + */ + _musigSessionCap () { + const raw = Number(this.settings.musig2 && this.settings.musig2.maxSessions); + if (!Number.isFinite(raw) || raw < 1) return PEER_MAX_MUSIG_SESSIONS; + return Math.min(Math.floor(raw), PEER_MAX_MUSIG_SESSIONS); + } + + /** + * @returns {number} + * @private + */ + _musigSessionTtlMs () { + const raw = Number(this.settings.musig2 && this.settings.musig2.sessionTtlMs); + if (!Number.isFinite(raw) || raw < 1) return PEER_MUSIG_SESSION_TTL_MS; + return Math.floor(raw); + } + + /** + * @param {object} session + * @private + */ + _musigPut (session) { + const id = this._musigSessionKey(session.sessionId); + if (this._musigSessions.has(id)) { + const idx = this._musigSessionOrder.indexOf(id); + if (idx >= 0) this._musigSessionOrder.splice(idx, 1); + } + this._musigSessions.set(id, session); + this._musigSessionOrder.push(id); + const cap = this._musigSessionCap(); + while (this._musigSessionOrder.length > cap) { + const evict = this._musigSessionOrder.shift(); + const old = this._musigSessions.get(evict); + musig2Session.wipeSession(old); + this._musigSessions.delete(evict); + } + } + + /** + * @param {Buffer|string} sessionId + * @returns {object|null} + * @private + */ + _musigGet (sessionId) { + const id = this._musigSessionKey(sessionId); + const session = this._musigSessions.get(id); + if (!session) return null; + const ttl = this._musigSessionTtlMs(); + if (ttl > 0 && Date.now() - (session.createdAt || 0) > ttl) { + musig2Session.wipeSession(session); + this._musigSessions.delete(id); + const idx = this._musigSessionOrder.indexOf(id); + if (idx >= 0) this._musigSessionOrder.splice(idx, 1); + return null; + } + return session; + } + + /** + * @param {string} type + * @param {object} fields + * @param {object} session + * @private + */ + _musigSend (type, fields, session) { + let frame; + try { + frame = Message.fromFields(type, fields).signWithKey(this.key); + } catch (err) { + this.emit('warning', `[FABRIC:PEER] MuSig2 encode ${type}: ${err && err.message ? err.message : err}`); + return; + } + const wire = frame.toBuffer(); + const localX = musig2Session.xOnlyHex(session.pubkeys[session.localIndex]); + for (const pk of session.pubkeys) { + if (musig2Session.xOnlyHex(pk) === localX) continue; + const addr = this._resolveAddressByXOnly(pk); + if (!addr || !this.connections[addr] || !this.connections[addr]._writeFabric) { + this.emit('warning', `[FABRIC:PEER] MuSig2: no live connection for ${musig2Session.xOnlyHex(pk)}`); + continue; + } + this.connections[addr]._writeFabric(wire); + } + } + + /** + * @param {object} session + * @private + */ + _musigMaybeProgress (session) { + if (!session || session.signature) return; + const auto = this._musigAutoAccept(); + if (session.initiator && musig2Session.allPubnoncesPresent(session) && !session.aggnonce) { + const aggnonce = musig2Session.computeAggNonce(session); + if (aggnonce) { + session.aggnonce = aggnonce; + this._musigSend('P2P_MUSIG_SEND_PROPOSAL', { + sessionId: session.sessionId, + aggnonce + }, session); + } + } + if ((session.initiator || auto) && + session.aggnonce && + session.secnonce && + !session.psigs[session.localIndex]) { + const sk = this._musigLocalSecret(); + const signed = sk ? musig2Session.createPartial(session, sk) : { ok: false }; + if (signed.ok) { + this._musigSend('P2P_MUSIG_REPLY_TO_PROPOSAL', { + sessionId: session.sessionId, + psig: signed.psig + }, session); + } + } + if (session.initiator && musig2Session.allPartialsPresent(session) && !session.signature) { + const agg = musig2Session.aggregatePartials(session); + if (agg.ok) { + this._musigSend('P2P_MUSIG_ACCEPT_PROPOSAL', { + sessionId: session.sessionId, + signature: agg.signature + }, session); + this.emit('musig2:complete', musig2Session.sessionPublicView(session)); + } else { + this.emit('warning', `[FABRIC:PEER] MuSig2 aggregate failed: ${agg.reason}`); + } + } + } + + /** + * Directed BIP-327 frames — never mesh-flooded; peel / foreign RELAY ignored. + * @private + */ + _handleMusig2Message (message, origin, socket, opts = {}) { + const delivery = meshDeliveryContext(opts, origin && origin.name); + if (!delivery.allowTcpOriginSideEffects) { + this.emit('warning', + `[FABRIC:PEER] Ignoring ${message.type} delivered via peel/relay-as-is`); + return this; + } + const signerXOnly = opts.signerPubkeyHex || this._verifiedFabricSignerPubkeyHex(message); + if (!signerXOnly) { + this.emit('warning', `[FABRIC:PEER] ${message.type} missing verified signer`); + return this; + } + const parsedBody = Message.tryParseMessageBody(message); + if (!parsedBody.ok) { + this.emit('warning', `[FABRIC:PEER] ${message.type} parse failed: ${parsedBody.error.message}`); + return this; + } + const body = musig2Session.parseMusigBody(parsedBody.value); + if (!body) { + this.emit('warning', `[FABRIC:PEER] ${message.type} invalid MuSig2 body`); + return this; + } + const type = message.type; + if (type === 'P2P_MUSIG_START') { + this._handleMusig2Start(body, signerXOnly); + return this; + } + const session = this._musigGet(body.sessionId); + if (!session) { + this.emit('warning', `[FABRIC:PEER] ${type} for unknown session`); + return this; + } + if (type === 'P2P_MUSIG_ACCEPT' || type === 'P2P_MUSIG_RECEIVE_COUNTER') { + const rec = musig2Session.recordPubnonce(session, signerXOnly, body.pubnonce); + if (!rec.ok) { + this.emit('warning', `[FABRIC:PEER] ${type} rejected: ${rec.reason}`); + return this; + } + this._musigMaybeProgress(session); + return this; + } + if (type === 'P2P_MUSIG_SEND_PROPOSAL') { + const initiatorPk = session.pubkeys && session.pubkeys[session.initiatorIndex]; + const initiatorXOnly = initiatorPk ? musig2Session.xOnlyHex(initiatorPk) : ''; + if (!initiatorXOnly || String(signerXOnly).toLowerCase() !== initiatorXOnly) { + this.emit('warning', `[FABRIC:PEER] SEND_PROPOSAL rejected: not-initiator`); + return this; + } + const set = musig2Session.setAggNonce(session, body.aggnonce); + if (!set.ok) { + this.emit('warning', `[FABRIC:PEER] SEND_PROPOSAL rejected: ${set.reason}`); + return this; + } + this._musigMaybeProgress(session); + return this; + } + if (type === 'P2P_MUSIG_REPLY_TO_PROPOSAL') { + const rec = musig2Session.recordPartial(session, signerXOnly, body.psig); + if (!rec.ok) { + this.emit('warning', `[FABRIC:PEER] REPLY rejected: ${rec.reason}`); + return this; + } + this._musigMaybeProgress(session); + return this; + } + if (type === 'P2P_MUSIG_ACCEPT_PROPOSAL') { + const v = musig2Session.verifyFinalSignature(session, body.signature); + if (!v.ok) { + this.emit('warning', `[FABRIC:PEER] ACCEPT_PROPOSAL rejected: ${v.reason}`); + return this; + } + this.emit('musig2:complete', musig2Session.sessionPublicView(session)); + return this; + } + return this; + } + + /** + * @param {object} body + * @param {string} signerXOnly + * @private + */ + _handleMusig2Start (body, signerXOnly) { + const existing = this._musigGet(body.sessionId); + if (existing) { + const rec = musig2Session.recordPubnonce(existing, signerXOnly, body.pubnonce); + if (!rec.ok) { + this.emit('warning', `[FABRIC:PEER] duplicate START rejected: ${rec.reason}`); + } + return this; + } + if (!this._musigAutoAccept()) { + this.emit('warning', '[FABRIC:PEER] P2P_MUSIG_START ignored: musig2.autoAccept is off'); + return this; + } + const sk = this._musigLocalSecret(); + if (!sk) { + this.emit('warning', '[FABRIC:PEER] P2P_MUSIG_START: no local secret'); + return this; + } + const made = musig2Session.createFromStart(body, { sk, signerXOnly }); + if (!made.ok) { + this.emit('warning', `[FABRIC:PEER] P2P_MUSIG_START ignored: ${made.reason}`); + return this; + } + const session = made.session; + this._musigPut(session); + if (this._musigAutoAccept()) { + const others = []; + for (let i = 0; i < session.pubkeys.length; i++) { + if (i !== session.initiatorIndex) others.push(i); + } + const first = others.length ? Math.min.apply(null, others) : session.localIndex; + const replyType = session.localIndex === first + ? 'P2P_MUSIG_ACCEPT' + : 'P2P_MUSIG_RECEIVE_COUNTER'; + this._musigSend(replyType, { + sessionId: session.sessionId, + pubnonce: session.ownPubnonce + }, session); + } + this.emit('musig2:start', musig2Session.sessionPublicView(session)); + return this; + } + /** * Handle inbound {@code P2P_FORWARD}: peel when {@code nextPeer} is local, else * forward the bit-identical outer frame to that peer only (no mesh flood). @@ -1900,11 +2399,7 @@ class Peer extends Service { return; } - if (this.connections[target]) { - this.emit('debug', `[FABRIC:PEER:_connect] Already connected to ${target}; skipping`); - return; - } - + // Do not dedup on the raw string: pubkey@host:port must share the host:port slot. if (this._isPeerBanned(target)) { this.emit('warning', `[FABRIC:PEER:_connect] Refusing dial to banned peer ${target}`); return; @@ -1915,48 +2410,63 @@ class Peer extends Service { return; } - this.emit('debug', `[FABRIC:PEER:_connect] Attempting to connect to: ${target}`); const url = new URL(`tcp://${target}`); const id = url.username; + const host = hostnameWithoutIpv6Brackets(url.hostname); + const port = url.port || P2P_PORT; + // Dedup on host:port so pubkey@host:port and host:port share one in-flight slot. + const canonical = canonicalPeerAddress(host, port); // pubkey@host:port — refuse when the pin is our own Fabric key (pre-TCP). if (id && this._isOwnFabricPubkey(id)) { this.emit('warning', `[FABRIC:PEER:_connect] Refusing dial to ${target}: pinned pubkey is our own`); - // Match later dial checks (`host:7777`) when the pin omits an explicit port. - const normalizedAddress = `${url.hostname}:${url.port || P2P_PORT}`; - this._suppressSelfDialAddress(normalizedAddress); + this._suppressSelfDialAddress(canonical); this.emit('peer:self', { address: target, reason: 'pinned pubkey is own Fabric key' }); return; } - if (!url.port) target += `:${P2P_PORT}`; + target = canonical; - // After normalizing port, check suppress on host:port form. + // After normalizing port, check suppress and in-flight dials on host:port. if (this._isSelfDialSuppressed(target)) { this.emit('warning', `[FABRIC:PEER:_connect] Refusing dial to self-suppressed ${target}`); return; } - this._outboundDialTargets.add(target); + if (this.connections[target] || + (this._outboundDialTargets && this._outboundDialTargets.has(target))) { + this.emit('debug', `[FABRIC:PEER:_connect] Already connected or dialing ${target}; skipping`); + return; + } - const _derived = this.identity.key.derive(FABRIC_KEY_DERIVATION_PATH); - this.emit('debug', `[FABRIC:PEER:_connect] Local derived key (public hex, truncated): ${peerDebugDerivedPublicSummary(_derived)} path=${FABRIC_KEY_DERIVATION_PATH}`); + this._outboundDialTargets.add(target); + this.emit('debug', `[FABRIC:PEER:_connect] Attempting to connect to: ${target}`); - // Store the user's public key if provided - if (id) { - this.peers[target] = { - ...this.peers[target], - publicKey: id - }; - } + let socket; + let _derived; + try { + _derived = this.identity.key.derive(FABRIC_KEY_DERIVATION_PATH); + this.emit('debug', `[FABRIC:PEER:_connect] Local derived key (public hex, truncated): ${peerDebugDerivedPublicSummary(_derived)} path=${FABRIC_KEY_DERIVATION_PATH}`); + + // Store the user's public key if provided + if (id) { + this.peers[target] = { + ...this.peers[target], + publicKey: id + }; + } - this._registerActor({ name: target }); - this._registerPeer({ identity: id }); - this._upsertPeerRegistry(target, { address: target }); + this._registerActor({ name: target }); + this._registerPeer({ identity: id }); + this._upsertPeerRegistry(target, { address: target }); - // Set up the NOISE socket - const socket = net.createConnection(url.port || P2P_PORT, url.hostname); + // Set up the NOISE socket + socket = net.createConnection(url.port || P2P_PORT, host); + } catch (err) { + if (this._outboundDialTargets) this._outboundDialTargets.delete(target); + throw err; + } // Don't keep the test runner alive just because we're connecting. if (typeof socket.unref === 'function') socket.unref(); @@ -1966,25 +2476,20 @@ class Peer extends Service { : 5000; socket.setTimeout(connectTimeoutMs); socket.once('timeout', () => { - const msg = `Socket timeout: connect ${url.hostname}:${url.port || P2P_PORT} after ${connectTimeoutMs}ms`; + const msg = `Socket timeout: connect ${host}:${url.port || P2P_PORT} after ${connectTimeoutMs}ms`; this.emit('warning', msg); socket.destroy(new Error(msg)); }); - socket.once('connect', () => socket.setTimeout(0)); - const client = noise({ - initiator: true, - prologue: Buffer.from(PROLOGUE), - // privateKey: _derived.privkey — enable when NOISE static === Fabric derived key. - verify: this._verifyNOISE.bind(this) - }); + socket._destroyFabric = () => { + this._destroyFabric(socket, target); + }; socket.on('error', (error) => { this.emit('debug', `--- debug error from _connect() ---`); - if (error && (error.code === 'EPIPE' || error.code === 'ECONNRESET' || - error.code === 'ECONNREFUSED' || error.code === 'EHOSTUNREACH' || - error.code === 'ENETUNREACH' || error.code === 'ETIMEDOUT')) { - this.emit('warning', `Suppressing transient outbound socket error (${error.code}) from _connect().`); + if (this._isTransientSocketError(error)) { + const code = (error && error.code) || 'ECONNREFUSED'; + this.emit('warning', `Suppressing transient outbound socket error (${code}) from _connect().`); } else { const msg = `Socket error: ${error}`; // Avoid crashing consumers/tests that haven't registered an 'error' listener. @@ -1999,7 +2504,7 @@ class Peer extends Service { socket.on('close', (info) => { this.emit('debug', `Outbound socket closed: (${target}) ${info}`); - socket._destroyFabric(); + if (typeof socket._destroyFabric === 'function') socket._destroyFabric(); // this._scheduleReconnect(target); }); @@ -2008,28 +2513,35 @@ class Peer extends Service { // delete this.connections[target]; }); - // Attach before pipe — noise-protocol-stream destroy(err) emits on encrypt/decrypt; - // missing listeners become uncaught Exceptions (noise_stream_new / malloc). - this._attachNoiseStreamErrorHandlers(client, 'NOISE'); - - // Handle trusted Fabric messages - client.decrypt.on('data', (data) => { - this._handleFabricMessage(data, { name: target }, client); - }); - - // Start stream - client.encrypt.pipe(socket).pipe(client.decrypt); - - // TODO: output stream - // client.decrypt.pipe(this.stream); - - this._registerNOISEClient(target, socket, client); - this._beginFabricHandshake(client); - - this.emit('connections:open', { - address: target, - id: target, - url: url + // Do not construct NOISE until TCP is up. noise-protocol-stream shares one + // EventEmitter for handshake callbacks; refused dials were leaking listeners. + socket.once('connect', () => { + try { + socket.setTimeout(0); + if (socket.destroyed) return; + const client = noise({ + initiator: true, + prologue: Buffer.from(PROLOGUE), + // privateKey: _derived.privkey — enable when NOISE static === Fabric derived key. + verify: this._verifyNOISE.bind(this) + }); + this._attachNoiseStreamErrorHandlers(client, 'NOISE'); + client.decrypt.on('data', (data) => { + this._handleFabricMessage(data, { name: target }, client); + }); + client.encrypt.pipe(socket).pipe(client.decrypt); + this._registerNOISEClient(target, socket, client); + this._beginFabricHandshake(client); + this.emit('connections:open', { + address: target, + id: target, + url: url + }); + } catch (err) { + this.emit('warning', + `[FABRIC:PEER:_connect] outbound setup failed (${target}): ${err && err.message ? err.message : err}`); + if (typeof socket.destroy === 'function') socket.destroy(); + } }); } @@ -2051,7 +2563,8 @@ class Peer extends Service { } _destroyFabric (socket, target) { - if (socket._keepalive) clearInterval(socket._keepalive); + if (socket && socket._keepalive) clearInterval(socket._keepalive); + this._teardownNoiseClient(socket); if (this._outboundDialTargets) this._outboundDialTargets.delete(target); @@ -2059,6 +2572,7 @@ class Peer extends Service { delete this.peers[target]; if (this._inboundNoiseStaticPubkeyByAddress) delete this._inboundNoiseStaticPubkeyByAddress[target]; if (this._addressToId) delete this._addressToId[target]; + this._unregisterSocketActor(target); this.emit('connections:close', { address: target, @@ -2293,8 +2807,23 @@ class Peer extends Service { _fillPeerSlots () { if (this.connections.length >= this.settings.constraints.peers.max) return; const openCount = this.settings.constraints.peers.max - Object.keys(this.connections).length; + if (!this._candidateRetryAt) this._candidateRetryAt = new Map(); + const configuredRetry = Number(this.settings.peering && this.settings.peering.candidateRetryMs); + const retryAfter = (Number.isFinite(configuredRetry) && configuredRetry > 0) + ? configuredRetry + : PEER_CANDIDATE_RETRY_MS; + const now = Date.now(); + for (const [addr, until] of this._candidateRetryAt) { + if (until <= now) this._candidateRetryAt.delete(addr); + } + const maxRetry = (this.settings.peering && this.settings.peering.maxCandidates) || PEER_MAX_CANDIDATES_QUEUE; + while (this._candidateRetryAt.size > maxRetry) { + const oldest = this._candidateRetryAt.keys().next().value; + this._candidateRetryAt.delete(oldest); + } + const deferred = []; for (let i = 0; i < openCount; i++) { - if (!this.candidates.length) continue; + if (!this.candidates.length) break; const candidate = this.candidates.shift(); this.emit('debug', `Filling peer slot ${i} of ${openCount} (max ${this.settings.constraints.peers.max}) with candidate: ${JSON.stringify(candidate, null, ' ')}`); @@ -2303,7 +2832,7 @@ class Peer extends Service { const port = candidate.object ? candidate.object.port : candidate.port; const pubkey = candidate.pubkey || (candidate.object && (candidate.object.pubkey || candidate.object.publicKey)); - const addr = `${host}:${port}`; + const addr = canonicalPeerAddress(host, port); if (pubkey && this._isOwnFabricPubkey(pubkey)) { this.emit('warning', `[FABRIC:PEER] Dropping candidate ${addr}: pubkey is our own`); @@ -2313,16 +2842,25 @@ class Peer extends Service { this._suppressSelfDialAddress(addr); } if (this._candidateKeys) this._candidateKeys.delete(addr); + if (this._candidateRetryAt) this._candidateRetryAt.delete(addr); + continue; + } + const nextOk = this._candidateRetryAt.get(addr) || 0; + if (now < nextOk || + this.connections[addr] || + (this._outboundDialTargets && this._outboundDialTargets.has(addr))) { + deferred.push(candidate); continue; } + this._candidateRetryAt.set(addr, now + retryAfter); this._connect(addr); } catch (exception) { this.emit('error', `Unable to fill open peer slot ${i}: ${exception}`); } - // Place the candidate back in the list - this.candidates.push(candidate); + deferred.push(candidate); } + for (const c of deferred) this.candidates.push(c); return this; } @@ -2348,7 +2886,9 @@ class Peer extends Service { const peeledForward = suppressTcpOriginPunish; // soft-punish alias for nested handlers const scoreOrigin = delivery.scoreOrigin; - // Frame-size gate before parse / hash / signature work. + // Frame-size gate before parse / hash / signature work. Undersize frames are + // not body-hash mismatches — treating them as such would hard-ban a TCP peer + // for a partial NOISE chunk. Unparseable frames are dropped the same way. let wire = buffer; if (!Buffer.isBuffer(wire)) { if (wire instanceof Uint8Array) wire = Buffer.from(wire); @@ -2362,6 +2902,11 @@ class Peer extends Service { `[FABRIC:PEER] Dropping oversized frame (${wire.length} > ${maxWire}) from ${originName || 'unknown'}`); return this; } + if (wire.length < HEADER_SIZE) { + this.emit('warning', + `[FABRIC:PEER] Dropping undersize frame (${wire.length} < ${HEADER_SIZE}) from ${originName || 'unknown'}`); + return this; + } if (originName && this._isPeerBanned(originName)) { this.emit('warning', `[FABRIC:PEER] Dropping message from banned peer ${originName}`); @@ -2371,7 +2916,16 @@ class Peer extends Service { } const hash = crypto.createHash('sha256').update(wire).digest('hex'); - const message = Message.fromBuffer(wire); + let message; + try { + message = Message.fromBuffer(wire); + } catch (err) { + this.emit('warning', + `[FABRIC:PEER] Dropping unparseable frame from ${originName || 'unknown'}: ` + + `${err && err.message ? err.message : err}`); + return this; + } + if (!message) return this; if (this.settings.debug) this.emit('debug', `Got Fabric message: ${message}`); // Have we seen this exact wire envelope before? (silent — no score change) @@ -2458,6 +3012,16 @@ class Peer extends Service { default: this.emit('debug', `Unhandled message type: ${message.type}`); break; + case 'P2P_MUSIG_START': + case 'P2P_MUSIG_ACCEPT': + case 'P2P_MUSIG_RECEIVE_COUNTER': + case 'P2P_MUSIG_SEND_PROPOSAL': + case 'P2P_MUSIG_REPLY_TO_PROPOSAL': + case 'P2P_MUSIG_ACCEPT_PROPOSAL': + this._handleMusig2Message(message, origin, socket, Object.assign({}, opts, { + signerPubkeyHex + })); + break; case 'P2P_RELAY': if (!origin || origin.name == null) break; { @@ -2750,8 +3314,12 @@ class Peer extends Service { // intact under `object` so handlers see the full body. genericBody = { type: message.type, object: parsed }; } else { - genericBody = (parsed && typeof parsed === 'object' && (parsed.actor || parsed.object || parsed.type)) - ? Object.assign({}, parsed, { type: parsed.type || innerType }) + // AMP wire name wins. Numeric JSON `type: 98` is P2P_PEERING_OFFER only + // on a peering-offer (or generic) carrier — never on inventory/gossip. + // Bare `type` is not an envelope (that dropped host/port onto the outer + // object and JSON.stringified the full body on every offer). + genericBody = (parsed && typeof parsed === 'object' && (parsed.actor || parsed.object)) + ? Object.assign({}, parsed, { type: innerType }) : { type: innerType, object: parsed }; } this._handleGenericMessage(genericBody, origin, socket, message, opts); @@ -2870,7 +3438,7 @@ class Peer extends Service { } break; - // Lightning BOLT JSON payload fall-through (if any are sent with JSON bodies) + // Lightning AMP types (0x2000+). Channel ops stay local; announcements may relay. case 'LIGHTNING_WARNING': case 'LightningWarning': case 'LIGHTNING_INIT': @@ -2923,8 +3491,6 @@ class Peer extends Service { break; } - this.commit(); - return this; } @@ -3119,6 +3685,8 @@ class Peer extends Service { // Peel / relay-as-is: never attribute logical-register soft/hijack penalties to the TCP hop. const punishOrigin = delivery.scoreOrigin; const msg = normalizeFabricDocumentOfferEnvelopeForHandlers(message); + const namedType = Message.canonicalTypeName(msg && msg.type); + if (namedType) msg.type = namedType; if (this.settings.debug) this.emit('debug', `Generic message:\n\tFrom: ${JSON.stringify(origin)}\n\tType: ${msg.type}\n\tBody:\n\`\`\`\n${JSON.stringify(msg.object, null, ' ')}\n\`\`\``); // Strict Protocol V1: first-class mesh / session opcodes must not escalate from @@ -3138,7 +3706,7 @@ class Peer extends Service { switch (msg.type) { default: - this.emit('debug', `Unhandled Generic Message: ${msg.type} ${JSON.stringify(msg, null, ' ')}`); + this.emit('debug', `Unhandled Generic Message: ${msg.type}`); break; case 'INVENTORY_REQUEST': // Upstream Inventory request (typically for documents). Emit an 'inventory' @@ -3251,7 +3819,7 @@ class Peer extends Service { const maxHops = g.maxHops != null ? g.maxHops : GOSSIP_MAX_HOPS; const payloadKey = this._gossipPayloadDedupKey(message); if (this._gossipPayloadSeen.has(payloadKey)) break; - const obj = message.object || {}; + const obj = genericOfferObject(message); let hop = obj.gossipHop != null ? Number(obj.gossipHop) : maxHops; if (!Number.isFinite(hop) || hop < 0) hop = maxHops; hop = Math.min(hop, maxHops); @@ -3276,7 +3844,7 @@ class Peer extends Service { const maxHops = p.maxHops != null ? p.maxHops : PEERING_OFFER_MAX_HOPS; const payloadKey = this._peeringOfferPayloadDedupKey(message); if (this._peeringPayloadSeen.has(payloadKey)) break; - const obj = message.object || {}; + const obj = genericOfferObject(message); let hop = obj.peeringHop != null ? Number(obj.peeringHop) : maxHops; if (!Number.isFinite(hop) || hop < 0) hop = maxHops; hop = Math.min(hop, maxHops); @@ -3533,10 +4101,15 @@ class Peer extends Service { // 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, - genesis: this._contractGenesis[contractId] || null + genesis: this._contractGenesis[contractId] || null, + // Lazy: skip toBuffer() unless a listener reads messageHex. + get messageHex () { + if (this._cachedMessageHex !== undefined) return this._cachedMessageHex; + this._cachedMessageHex = wireMessage && typeof wireMessage.toBuffer === 'function' + ? wireMessage.toBuffer().toString('hex') + : null; + return this._cachedMessageHex; + } }); // Same outermost-only flood rule as CONTRACT_PUBLISH / chat / gossip. if (delivery.allowMeshRelay && origin && origin.name && wireMessage) { @@ -3547,6 +4120,22 @@ class Peer extends Service { } } + /** + * True when a TCP/NOISE error is a routine connect/reset (not an application fault). + * Node sometimes omits `error.code` and only puts `ECONNREFUSED` in the message. + * @param {*} error + * @returns {boolean} + */ + _isTransientSocketError (error) { + const code = error && error.code; + if (code === 'EPIPE' || code === 'ECONNRESET' || code === 'ECONNREFUSED' || + code === 'EHOSTUNREACH' || code === 'ENETUNREACH' || code === 'ETIMEDOUT') { + return true; + } + const text = String((error && error.message) || error || ''); + return /ECONNREFUSED|ECONNRESET|EPIPE|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH/.test(text); + } + /** * Bind encrypt/decrypt 'error' before piping. noise-protocol-stream calls * stream.destroy(err) on handshake failure; without listeners Node raises @@ -3558,9 +4147,13 @@ class Peer extends Service { */ _attachNoiseStreamErrorHandlers (noiseStream, label = 'NOISE') { if (!noiseStream || !noiseStream.encrypt || !noiseStream.decrypt) return; + if (noiseStream._fabricNoiseErrorHandlers) { + this._detachNoiseStreamErrorHandlers(noiseStream); + } const onSide = (side) => (error) => { - if (error && (error.code === 'EPIPE' || error.code === 'ECONNRESET')) { - this.emit('warning', `Suppressing transient ${label} ${side} error (${error.code}).`); + if (this._isTransientSocketError(error)) { + const code = (error && error.code) || 'ECONNRESET'; + this.emit('warning', `Suppressing transient ${label} ${side} error (${code}).`); return; } const text = String((error && error.message) || error || ''); @@ -3573,8 +4166,66 @@ class Peer extends Service { if (this.listenerCount('error') > 0) this.emit('error', msg); else this.emit('warning', msg); }; - noiseStream.encrypt.on('error', onSide('encrypt')); - noiseStream.decrypt.on('error', onSide('decrypt')); + const onEncrypt = onSide('encrypt'); + const onDecrypt = onSide('decrypt'); + noiseStream.encrypt.on('error', onEncrypt); + noiseStream.decrypt.on('error', onDecrypt); + noiseStream._fabricNoiseErrorHandlers = { onEncrypt, onDecrypt }; + } + + /** + * Remove encrypt/decrypt 'error' listeners attached by {@link Peer#_attachNoiseStreamErrorHandlers}. + * @param {Object} noiseStream + * @returns {void} + */ + _detachNoiseStreamErrorHandlers (noiseStream) { + const handlers = noiseStream && noiseStream._fabricNoiseErrorHandlers; + if (!handlers) return; + try { + if (noiseStream.encrypt && typeof noiseStream.encrypt.removeListener === 'function') { + noiseStream.encrypt.removeListener('error', handlers.onEncrypt); + } + } catch (_) { /* ignore */ } + try { + if (noiseStream.decrypt && typeof noiseStream.decrypt.removeListener === 'function') { + noiseStream.decrypt.removeListener('error', handlers.onDecrypt); + } + } catch (_) { /* ignore */ } + noiseStream._fabricNoiseErrorHandlers = null; + } + + /** + * Unpipe and destroy a NOISE pair so shared handshake emitters do not leak listeners. + * @param {object|null} socket + * @returns {void} + */ + _teardownNoiseClient (socket) { + if (!socket) return; + const noiseClient = socket._noiseClient || socket._noiseHandler; + if (!noiseClient) return; + this._detachNoiseStreamErrorHandlers(noiseClient); + try { + if (noiseClient.encrypt && typeof noiseClient.encrypt.unpipe === 'function') { + noiseClient.encrypt.unpipe(socket); + } + } catch (_) { /* ignore */ } + try { + if (typeof socket.unpipe === 'function' && noiseClient.decrypt) { + socket.unpipe(noiseClient.decrypt); + } + } catch (_) { /* ignore */ } + try { + if (noiseClient.encrypt && typeof noiseClient.encrypt.destroy === 'function') { + noiseClient.encrypt.destroy(); + } + } catch (_) { /* ignore */ } + try { + if (noiseClient.decrypt && typeof noiseClient.decrypt.destroy === 'function') { + noiseClient.decrypt.destroy(); + } + } catch (_) { /* ignore */ } + socket._noiseClient = null; + socket._noiseHandler = null; } _NOISESocketHandler (socket) { @@ -3605,10 +4256,13 @@ class Peer extends Service { // Handle low-level socket errors for inbound connections socket.on('error', (error) => { if (this.settings.debug) this.emit('debug', `--- debug error from _NOISESocketHandler() ---`); - if (error && (error.code === 'EPIPE' || error.code === 'ECONNRESET')) { - this.emit('warning', `Suppressing transient inbound socket error (${error.code}) from _NOISESocketHandler().`); + if (this._isTransientSocketError(error)) { + const code = (error && error.code) || 'ECONNRESET'; + this.emit('warning', `Suppressing transient inbound socket error (${code}) from _NOISESocketHandler().`); } else { - this.emit('error', `Inbound socket error: ${error}`); + const msg = `Inbound socket error (${target}): ${error}`; + if (this.listenerCount('error') > 0) this.emit('error', msg); + else this.emit('warning', msg); } }); @@ -3625,6 +4279,7 @@ class Peer extends Service { this._inboundNoiseStaticPubkeyByAddress[target] = pkHex; if (this._isPeerBanned(target, pkHex)) { this.emit('warning', `[FABRIC:PEER] Closing inbound: banned Noise static ${pkHex.slice(0, 16)}…`); + this._destroyFabric(socket, target); if (typeof socket.destroy === 'function') socket.destroy(); } } @@ -3632,14 +4287,11 @@ class Peer extends Service { }); handler.encrypt.on('end', (data) => { if (this.settings.debug) this.emit('debug', `Peer encrypt end: ${data}`); - // socket.destroy(); - delete this.connections[target]; - if (this.peers[target] && typeof this.peers[target] === 'object') { - this.peers[target].status = 'disconnected'; - } + this._destroyFabric(socket, target); }); this._attachNoiseStreamErrorHandlers(handler, 'NOISE'); + socket._noiseHandler = handler; handler.decrypt.on('close', (data) => { if (this.settings.debug) this.emit('debug', `Peer decrypt close: ${data}`); @@ -4741,7 +5393,10 @@ class Peer extends Service { } _registerActor (object) { - this.emit('debug', `Registering actor: ${JSON.stringify(object, null, ' ')}`); + if (this.settings.debug) { + const name = object && object.name != null ? String(object.name) : '(unnamed)'; + this.emit('debug', `Registering actor: ${name}`); + } const actor = new Actor(object); /* actor.adopt([ @@ -4751,12 +5406,32 @@ class Peer extends Service { if (this.actors[actor.id]) return this; this.actors[actor.id] = actor; - this.commit(); + const name = object && object.name != null ? String(object.name) : ''; + if (name) this._actorsByName[name] = actor.id; this.emit('actorset', this.actors); return this; } + /** + * Drop the ephemeral socket Actor keyed by `host:port`. + * @param {string} address + * @returns {void} + */ + _unregisterSocketActor (address) { + if (!address) return; + const name = String(address); + let id = this._actorsByName[name]; + if (!id) id = new Actor({ name: name }).id; + delete this._actorsByName[name]; + if (!this.actors[id]) return; + delete this.actors[id]; + if (this._state.content && this._state.content.actors) { + delete this._state.content.actors[id]; + } + this.emit('actorset', this.actors); + } + /** * 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 @@ -4779,7 +5454,10 @@ class Peer extends Service { * @returns {boolean} true when newly registered (or already present no-op) */ _registerContract (object, publisherPubkeyHex = null) { - this.emit('debug', `Registering contract: ${JSON.stringify(object, null, ' ')}`); + if (this.settings.debug) { + const idHint = object && (object.id || object.contractId || object.name) || 'contract'; + this.emit('debug', `Registering contract: ${idHint}`); + } const actor = new Actor(object); // Duplicate CONTRACT_PUBLISH must not expand the patch allow-list. Otherwise an @@ -4897,6 +5575,7 @@ class Peer extends Service { socket._failureCount = 0; socket._lastMessage = null; socket._messageLog = []; + socket._noiseClient = client; this._startFabricPingKeepalive(socket, client.encrypt); @@ -4978,7 +5657,6 @@ class Peer extends Service { _writeFabric (msg, stream) { const hash = crypto.createHash('sha256').update(msg).digest('hex'); this._rememberWireHash(hash); - this.commit(); if (!stream || !stream.encrypt) return; const encrypt = stream.encrypt; @@ -5099,6 +5777,7 @@ class Peer extends Service { const registry = this._state.peers; const toReconnect = Object.keys(registry).filter((addr) => { if (this.connections[addr]) return false; + if (this._outboundDialTargets && this._outboundDialTargets.has(addr)) return false; const listenAddr = this.listenAddress || `${this.interface}:${this.port}`; if (addr === listenAddr) return false; if (this._isPeerBanned(addr)) return false; @@ -5166,6 +5845,14 @@ class Peer extends Service { // Stop the heart if (this._heart) clearInterval(this._heart); + if (this._musigSessions) { + for (const session of this._musigSessions.values()) { + musig2Session.wipeSession(session); + } + this._musigSessions.clear(); + this._musigSessionOrder = []; + } + this.emit('debug', 'Closing all connections...'); for (const id in this.connections) { const c = this.connections[id]; @@ -5238,8 +5925,11 @@ class Peer extends Service { if (socket._keepalive) clearInterval(socket._keepalive); if (socket.heartbeat) clearInterval(socket.heartbeat); + this._teardownNoiseClient(socket); + if (this._outboundDialTargets) this._outboundDialTargets.delete(address); delete this.connections[address]; delete this.peers[address]; + this._unregisterSocketActor(address); if (typeof socket.destroy === 'function') socket.destroy(); this._upsertPeerRegistry(address, { lastSeen: new Date().toISOString() }); @@ -5522,3 +6212,5 @@ class Swarm extends Actor { module.exports = Peer; module.exports.Swarm = Swarm; +/** @private Test/debug helper — never log private material. */ +Peer.debugDerivedPublicSummary = peerDebugDerivedPublicSummary; diff --git a/types/reader.js b/types/reader.js index 28e1e6826..cdc89808f 100644 --- a/types/reader.js +++ b/types/reader.js @@ -9,6 +9,9 @@ const { const merge = require('lodash.merge'); const EventEmitter = require('events').EventEmitter; /** + * @deprecated Frame-scan helpers belong on {@link Peer} / {@link Message} ingest + * for RC. Do not take a new production dependency on {@link Reader}; keep this + * class as a compatibility scanner only. * Read from a byte stream, seeking valid Fabric messages. */ class Reader extends EventEmitter { diff --git a/types/service.js b/types/service.js index 4fc75f332..1c9b0121f 100644 --- a/types/service.js +++ b/types/service.js @@ -26,6 +26,7 @@ const Key = require('./key'); const Message = require('./message'); const Resource = require('./resource'); const Store = require('./store'); +const { SERVICE_COMMIT_HISTORY_MAX } = require('../constants'); const { createDefaultOpcodeRegistry, defineOpcode: defineOpcodeEntry, @@ -1075,11 +1076,19 @@ class Service extends Actor { return this; } + /** + * Record outstanding JSON Patch deltas and emit a compact commitment id. + * Does **not** wrap {@link Service#state} in a new {@link Actor} — that + * JSON-cloned the entire tree on every write/connect and OOMed Hub. + * @returns {String} 32-byte commitment id + */ commit () { // this.emit('debug', `[FABRIC:SERVICE] Committing ${OP_TRACE()}`); + let patches = []; if (PATCHES_ENABLED && this.observer) { try { - const patches = manager.generate(this.observer); + const generated = manager.generate(this.observer); + patches = Array.isArray(generated) ? generated : []; if (patches.length) { this.history.push(patches); this.emit('patches', patches); @@ -1089,15 +1098,24 @@ class Service extends Actor { } } + const maxRaw = this.settings && this.settings.commitHistoryMax; + const max = (typeof maxRaw === 'number' && Number.isFinite(maxRaw) && maxRaw >= 0) + ? Math.floor(maxRaw) + : SERVICE_COMMIT_HISTORY_MAX; + while (this.history.length > max) this.history.shift(); + + const clock = (this._clock | 0); const commit = new Actor({ type: 'Commit', - state: this.state + clock: clock, + patchCount: patches.length }); + const id = commit.id; - this.emit('commit', { ...commit.toObject(), id: commit.id }); + this.emit('commit', { type: 'Commit', clock: clock, id: id, patchCount: patches.length }); // this.emit('state', this.state); - return commit.id; + return id; } async _handleBitcoinCommit (commit) { @@ -1629,9 +1647,16 @@ class FabricShell extends Service { this.services[name].on('message', function (msg) { self._appendMessage(`@services/${name} -- (${typeof msg}) ${JSON.stringify(msg, null, ' ')}`); switch (msg['@type']) { - case 'ChatMessage': - self.node.relayFrom(self.node.id, Message.fromVector(['ChatMessage', JSON.stringify(msg)])); + case 'ChatMessage': { + const { chatTextOf } = require('../functions/fabricChatText'); + const text = chatTextOf(msg) || chatTextOf(msg.object) || ''; + if (text.trim()) { + const packet = Message.fromVector(['P2P_CHAT_MESSAGE', String(text)]); + if (self.node.key) packet.signWithKey(self.node.key); + self.node.relayFrom(self.node.id, packet); + } break; + } default: break; } diff --git a/types/state.js b/types/state.js index e8f3f914d..5eb5ca111 100644 --- a/types/state.js +++ b/types/state.js @@ -406,6 +406,7 @@ When you're ready to continue, visit the following URL: https://dev.fabric.pub/W * @return {Buffer} {@link Store}-able blob. */ serialize (input = this.state, _encoding = 'json') { + if (input == null) input = {}; const state = {}; let result = null; diff --git a/types/store.js b/types/store.js index d5bb450b7..7214b4b78 100644 --- a/types/store.js +++ b/types/store.js @@ -255,6 +255,9 @@ class Store extends Actor { * @return {Promise} Resolves on success with a String pointer. */ async _POST (key, value) { + if (value == null) { + throw new Error('Store._POST requires a JSON body'); + } if (this.settings.verbosity >= 5) console.log('[STORE]', '_POST', key, typeof value, value); this['@method'] = '_POST';