From ac2281d895d5526705a712df6a98fb699af6321b Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 14 Aug 2026 15:56:58 -0500 Subject: [PATCH 1/4] feat(templates): add R1-K1 hardware wallet contract --- docs/packages/helpers/index.md | 2 +- docs/packages/helpers/templates.md | 57 ++- docs/reference/package-api-migrations.md | 10 +- docs/reference/stack-facts.md | 2 +- governance/package-release-notes.json | 8 +- governance/repository-health/baselines.json | 2 +- packages/helpers/ts-templates/CHANGELOG.md | 3 +- packages/helpers/ts-templates/README.md | 50 +++ .../helpers/ts-templates/browser-budget.json | 1 + packages/helpers/ts-templates/mod.ts | 8 + packages/helpers/ts-templates/package.json | 4 +- .../ts-templates/src/R1K1Wallet.artifact.ts | 104 ++++++ .../helpers/ts-templates/src/R1K1Wallet.ts | 352 ++++++++++++++++++ .../src/__tests/R1K1Wallet.test.ts | 188 ++++++++++ .../ts-templates/src/__tests/exports.test.ts | 3 +- 15 files changed, 775 insertions(+), 19 deletions(-) create mode 100644 packages/helpers/ts-templates/src/R1K1Wallet.artifact.ts create mode 100644 packages/helpers/ts-templates/src/R1K1Wallet.ts create mode 100644 packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts diff --git a/docs/packages/helpers/index.md b/docs/packages/helpers/index.md index 3aa36c47e..8fc3d3cc2 100644 --- a/docs/packages/helpers/index.md +++ b/docs/packages/helpers/index.md @@ -20,7 +20,7 @@ Utility libraries and helper tools for common BSV operations. Includes high-leve | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | [@bsv/simple](simple.md) | High-level wallet API for browser and server — manage payments, tokens, inscriptions, DIDs, and credentials | | [@bsv/wallet-helper](wallet-helper.md) | Fluent transaction builder for explicit wallet-backed transactions, BRC-29 derivation, and ordinal support | -| [@bsv/templates](templates.md) | Predefined ScriptTemplate examples for protocol engineers, including OpReturn, MultiPushDrop, and P2MSKH lock/unlock patterns | +| [@bsv/templates](templates.md) | ScriptTemplate implementations including OpReturn, MultiPushDrop, P2MSKH, and the hardware-backed R1K1Wallet | | [@bsv/did](did.md) | SD-JWT VC and optional `did:key` helpers for BSV SDK identity keys | | [@bsv/did-client](did-client.md) | DID client for creating, revoking, and querying on-chain DIDs with overlay broadcast | | [@bsv/air-gap](air-gap.md) | One-directional optical air-gap transport — fountain-coded QR parts for arbitrary bytes | diff --git a/docs/packages/helpers/templates.md b/docs/packages/helpers/templates.md index 063347d83..4fd85db56 100644 --- a/docs/packages/helpers/templates.md +++ b/docs/packages/helpers/templates.md @@ -3,10 +3,10 @@ id: pkg-templates title: '@bsv/templates' kind: package domain: helpers -version: '1.9.6' +version: '1.10.0' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-07-30' -last_verified: '2026-07-30' +last_updated: '2026-08-14' +last_verified: '2026-08-14' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/templates' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates' @@ -43,6 +43,7 @@ console.log(decodedData) // ['APP', '{"action":"vote"}'] - **OpReturn** — Non-spendable data storage; create and decode OP_RETURN scripts - **MultiPushDrop** — Encrypted data tokens with multiple trusted owners; BRC-95 format - **P2MSKH** — Pay-to-Multisig-Key-Hash; M-of-N threshold signing with wallet support +- **R1K1Wallet** — Salted P-256 hardware signing with independent secp256k1 recovery - **Script utilities** — Type detection, parsing, serialization helpers - **Wallet integration** — Templates accept WalletInterface for BRC-29/BRC-42 derivation @@ -121,6 +122,52 @@ const customInstructions = { const unlocker = new P2MSKH().unlock(wallet, customInstructions) ``` +### Create an R1-K1 hardware-backed output + +```typescript +import { Hash, type PrivateKey, Utils } from '@bsv/sdk' +import { R1K1Wallet } from '@bsv/templates' + +declare const compressedP256PublicKeyHex: string +declare const k1RecoveryPrivateKey: PrivateKey +declare const signWithYubiKeyPiv: (digest: Uint8Array) => Promise + +const template = new R1K1Wallet() +const r1PublicKey = Utils.toArray(compressedP256PublicKeyHex, 'hex') +const salt = crypto.getRandomValues(new Uint8Array(32)) +const lockingScript = await template.lock( + Hash.hash160([...r1PublicKey, ...salt]), + Hash.hash160(k1RecoveryPrivateKey.toPublicKey().encode(true) as number[]) +) + +const normalSpend = template.unlock({ + path: 'r1', + publicKey: r1PublicKey, + salt, + signDigest: signWithYubiKeyPiv +}) +const recoverySpend = template.unlock({ + path: 'k1', + privateKey: k1RecoveryPrivateKey +}) +``` + +The R1 signer receives the final 32-byte transaction digest. A PIV adapter must +submit it unchanged to GENERAL AUTHENTICATE and return either the DER ECDSA +signature produced by the device or raw 64-byte `r || s`; applying SHA-256 +again creates an invalid signature. Preserve each private 32-byte salt with the +wallet metadata. The salt hides a reused PIV public key only until the R1 output +is spent. + +The generated locking script is 959,632 bytes after both commitments are +baked, above common 500 KB miner policy. Confirm target-miner policy before +funding it. + +PIV proves that the hardware key signed the supplied digest, but a YubiKey +does not display or independently validate the Bitcoin transaction. PIN and +touch policies protect key use, not transaction intent; review the transaction +on a trusted host before approving it. + ## Key concepts - **ScriptTemplate Interface** — Implements `lock()` to create locking script and `unlock()` to sign/spend @@ -129,6 +176,7 @@ const unlocker = new P2MSKH().unlock(wallet, customInstructions) - **Multisig** — M-of-N threshold signing; requires m private keys to unlock - **Wallet Integration** — Templates accept WalletInterface for wallet-compatible key derivation (BRC-29, BRC-42) - **Direct Key Mode** — Can also use raw public/private keys without wallet +- **Hardware Digest Signer** — R1K1Wallet accepts DER output from a PIV signer without exposing the P-256 private key - **Protocol ID** — Identifier for script family; used in wallet derivation contexts - **Reasonableness Limit** — Anti-DoS measure for PushDrop templates @@ -138,6 +186,7 @@ const unlocker = new P2MSKH().unlock(wallet, customInstructions) - Building applications that need P2PKH or multisig payment flows - Storing data on-chain with OP_RETURN - Implementing multi-owner token systems with PushDrop +- Building hardware-backed outputs with an intentionally inconvenient K1 recovery path - Learning Bitcoin Script patterns ## When NOT to use this @@ -161,6 +210,8 @@ const unlocker = new P2MSKH().unlock(wallet, customInstructions) - **Lock/Unlock consistency** — Lock and unlock must use same protocol ID, key ID, and counterparty parameters - **Wallet context required** — Some templates require WalletInterface if using wallet derivation; pass explicitly - **Signature generation async** — All `unlock().sign()` calls are async; use await +- **R1 salt backup** — Losing an output's private salt makes its R1 path unusable; retain the K1 recovery key and salt metadata separately +- **Miner policy** — R1K1Wallet's synthesized P-256 script exceeds common 500 KB policy limits - **OP_RETURN encoding** — Data is UTF-8 by default; encode as hex first and specify `enc: 'hex'` for binary ## Related packages diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index f216bf8db..c0fa33a6e 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -3,8 +3,8 @@ id: package-api-migrations title: 'Package API, Declarations, and Migration Ledger' kind: reference version: '1.0.0' -last_updated: '2026-08-13' -last_verified: '2026-08-13' +last_updated: '2026-08-14' +last_verified: '2026-08-14' review_cadence_days: 30 status: stable tags: [reference, packages, api, declarations, migrations, release-notes] @@ -47,7 +47,7 @@ and clean-consumer tests remain the executable type authority. | `@bsv/payment-express-middleware` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. | | `@bsv/sdk` | `2.3.1` | `2.4.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Existing mainnet, testnet, and local routing is unchanged. TTN consumers select teratestnet explicitly because BRC-100 getNetwork continues to report testnet for all test-family chains. | | `@bsv/simple` | `0.4.1` | `0.5.0` | minor | [API and usage](../packages/helpers/simple.md) | Existing overlay configurations are unchanged. TTN consumers select network teratestnet and use @bsv/sdk 2.4 or later. | -| `@bsv/templates` | `1.9.1` | `1.9.6` | patch | [API and usage](../packages/helpers/templates.md) | No consumer migration is required; template APIs, supported imports, and generated script semantics are unchanged. | +| `@bsv/templates` | `1.9.1` | `1.10.0` | minor | [API and usage](../packages/helpers/templates.md) | No existing consumer migration is required. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. | | `@bsv/teranode-listener` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. | | `@bsv/verifast` | `0.3.0` | `0.3.4` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. | | `@bsv/wallet-helper` | `0.1.1` | `0.1.6` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. | @@ -411,8 +411,8 @@ CLI entry points: `{"fund-metanet":"./dist/index.mjs"}`. - Package documentation: [docs/packages/helpers/templates.md](../packages/helpers/templates.md) - Source: [packages/helpers/ts-templates](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates) -- Release note: Consolidates MultiPushDrop script construction while preserving the exact generated locking-script sequence, adds an exact-tarball browser-consumer and bundle-size contract, and points contributors to the canonical root policy. -- Migration: No consumer migration is required; template APIs, supported imports, and generated script semantics are unchanged. +- Release note: Adds the R1K1Wallet ScriptTemplate for salted P-256 hardware signing with an independent secp256k1 recovery path, including a checksummed static Runar artifact and browser-safe lazy decompression. +- Migration: No existing consumer migration is required. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | --------------------------------------- | ------------------------------------------- | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 685361356..0dd41484d 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -43,7 +43,7 @@ authorized release action. | helpers | `@bsv/did-client` | `1.3.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/helpers/did-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did-client) | | helpers | `@bsv/fund-wallet` | `1.4.3` | cli | cli | node | `>=22` | [packages/helpers/fund-wallet](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/fund-wallet) | | helpers | `@bsv/simple` | `0.5.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/simple](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple) | -| helpers | `@bsv/templates` | `1.9.6` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/ts-templates](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates) | +| helpers | `@bsv/templates` | `1.10.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/ts-templates](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates) | | helpers | `@bsv/wallet-helper` | `0.1.6` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/bsv-wallet-helper](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper) | | helpers | `create-bsv-app` | `1.1.0` | cli | cli | node | `>=22` | [packages/helpers/create-bsv-app](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/create-bsv-app) | | messaging | `@bsv/authsocket` | `2.1.5` | node-library | node-cjs, node-esm | node | `>=22` | [packages/messaging/authsocket](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket) | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 88a31ee47..a051c3996 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "lastReviewed": "2026-08-13", + "lastReviewed": "2026-08-14", "owner": "ts-stack-maintainers", "entries": [ { @@ -160,9 +160,9 @@ { "name": "@bsv/templates", "publishedVersion": "1.9.1", - "releaseType": "patch", - "summary": "Consolidates MultiPushDrop script construction while preserving the exact generated locking-script sequence, adds an exact-tarball browser-consumer and bundle-size contract, and points contributors to the canonical root policy.", - "migration": "No consumer migration is required; template APIs, supported imports, and generated script semantics are unchanged." + "releaseType": "minor", + "summary": "Adds the R1K1Wallet ScriptTemplate for salted P-256 hardware signing with an independent secp256k1 recovery path, including a checksummed static Runar artifact and browser-safe lazy decompression.", + "migration": "No existing consumer migration is required. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again." }, { "name": "@bsv/teranode-listener", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index d65928b65..8807d8363 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -305,7 +305,7 @@ "@bsv/did-client": "1.3.0", "@bsv/fund-wallet": "1.4.3", "@bsv/simple": "0.5.0", - "@bsv/templates": "1.9.6", + "@bsv/templates": "1.10.0", "@bsv/authsocket": "2.1.5", "@bsv/authsocket-client": "2.1.4", "@bsv/message-box-client": "2.4.0", diff --git a/packages/helpers/ts-templates/CHANGELOG.md b/packages/helpers/ts-templates/CHANGELOG.md index 53f603990..6973c96d9 100644 --- a/packages/helpers/ts-templates/CHANGELOG.md +++ b/packages/helpers/ts-templates/CHANGELOG.md @@ -11,7 +11,8 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] ### Added -- (Include new features or significant user-visible enhancements here.) +- `R1K1Wallet`, a static Runar contract template with a salted P-256 hardware + signing path and an independent secp256k1 recovery path. ### Changed - Point contributors and AI agents to the canonical stack-level contribution diff --git a/packages/helpers/ts-templates/README.md b/packages/helpers/ts-templates/README.md index 9ed40130b..990445a79 100644 --- a/packages/helpers/ts-templates/README.md +++ b/packages/helpers/ts-templates/README.md @@ -32,6 +32,56 @@ tx.addOutput({ | [OpReturn](./src/OpReturn.ts) | Tag data in a non-spendable script | | [Metant](./src/Metanet.ts) | Create transactions that follow the Metanet protocol | | [MultiPushDrop](./src/MultiPushDrop.ts) | Create data tokens with multiple trusted owners | +| [P2MSKH](./src/P2MSKH.ts) | Spend with an M-of-N public-key threshold | +| [R1K1Wallet](./src/R1K1Wallet.ts) | Use P-256 hardware normally and a K1 recovery key | + +### R1-K1 hardware wallet + +`R1K1Wallet` commits to `HASH160(compressedR1PublicKey || privateSalt)` and a +separate secp256k1 public-key hash. The salt hides reuse of a PIV public key +until an R1 spend reveals both values. Keep every 32-byte salt backed up with +the wallet metadata; losing it disables that output's R1 path but not K1 +recovery. + +```ts +import { Hash, type PrivateKey, Utils } from '@bsv/sdk' +import { R1K1Wallet } from '@bsv/templates' + +declare const compressedP256PublicKeyHex: string +declare const k1RecoveryPrivateKey: PrivateKey +declare const signWithYubiKeyPiv: (digest: Uint8Array) => Promise + +const template = new R1K1Wallet() +const r1PublicKey = Utils.toArray(compressedP256PublicKeyHex, 'hex') +const salt = crypto.getRandomValues(new Uint8Array(32)) +const lockingScript = await template.lock( + Hash.hash160([...r1PublicKey, ...salt]), + Hash.hash160(k1RecoveryPrivateKey.toPublicKey().encode(true) as number[]) +) + +const normalSpend = template.unlock({ + path: 'r1', + publicKey: r1PublicKey, + salt, + // Submit this 32-byte digest unchanged to PIV GENERAL AUTHENTICATE. + // Return the YubiKey DER ECDSA signature (or raw 64-byte r || s). + signDigest: digest => signWithYubiKeyPiv(digest) +}) + +const recoverySpend = template.unlock({ + path: 'k1', + privateKey: k1RecoveryPrivateKey +}) +``` + +The synthesized P-256 verifier produces a 959,632-byte locking script after +constructor commitments are baked. This exceeds common 500 KB miner policy; +confirm the target miner's limits before funding an output. + +PIV proves that the hardware key signed the supplied digest, but a YubiKey +does not display or independently validate the Bitcoin transaction. A PIN or +touch policy protects key use, not transaction intent; review transactions on +a trusted host before approving them. ## Contribution Guidelines diff --git a/packages/helpers/ts-templates/browser-budget.json b/packages/helpers/ts-templates/browser-budget.json index d1bfb433a..84f4554fd 100644 --- a/packages/helpers/ts-templates/browser-budget.json +++ b/packages/helpers/ts-templates/browser-budget.json @@ -12,6 +12,7 @@ "MultiPushDrop", "OpReturn", "P2MSKH", + "R1K1Wallet", "StasToken" ], "prohibitedExports": [], diff --git a/packages/helpers/ts-templates/mod.ts b/packages/helpers/ts-templates/mod.ts index 549815a91..771f0f40b 100644 --- a/packages/helpers/ts-templates/mod.ts +++ b/packages/helpers/ts-templates/mod.ts @@ -19,3 +19,11 @@ export { Bsv21Token } from './src/Bsv21Token.js' export type { Bsv21TokenDecoded } from './src/Bsv21Token.js' export { DstasToken } from './src/DstasToken.js' export type { DstasTokenDecoded } from './src/DstasToken.js' +export { R1K1Wallet } from './src/R1K1Wallet.js' +export type { + R1K1Bytes, + R1K1P256DigestSigner, + R1K1R1UnlockParams, + R1K1K1UnlockParams, + R1K1UnlockParams +} from './src/R1K1Wallet.js' diff --git a/packages/helpers/ts-templates/package.json b/packages/helpers/ts-templates/package.json index 54958c205..b3c425b80 100644 --- a/packages/helpers/ts-templates/package.json +++ b/packages/helpers/ts-templates/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/templates", - "version": "1.9.6", + "version": "1.10.0", "sideEffects": false, "engines": { "node": ">=22" @@ -45,7 +45,7 @@ "dev": "pnpm build --watch", "format:check": "pnpm --workspace-root exec prettier --check \"packages/helpers/ts-templates/src/**/*.ts\" \"packages/helpers/ts-templates/__tests/*.ts\" \"packages/helpers/ts-templates/*.{js,json,ts}\"", "lint": "oxlint src __tests --deny-warnings", - "pack:check": "node ../../../scripts/check-package-artifact.mjs . --exports ADMIN_PROTOCOL,Bsv21Token,DstasToken,MandalaAdmin,MandalaToken,MultiPushDrop,OpReturn,P2MSKH,StasToken", + "pack:check": "node ../../../scripts/check-package-artifact.mjs . --exports ADMIN_PROTOCOL,Bsv21Token,DstasToken,MandalaAdmin,MandalaToken,MultiPushDrop,OpReturn,P2MSKH,R1K1Wallet,StasToken", "prepublishOnly": "pnpm build", "test": "jest", "test:browser": "pnpm build && node ../../../scripts/check-browser-package.mjs .", diff --git a/packages/helpers/ts-templates/src/R1K1Wallet.artifact.ts b/packages/helpers/ts-templates/src/R1K1Wallet.artifact.ts new file mode 100644 index 000000000..c5d948918 --- /dev/null +++ b/packages/helpers/ts-templates/src/R1K1Wallet.artifact.ts @@ -0,0 +1,104 @@ +/** + * Generated from the Runar R1K1Wallet artifact in icellan/runar#152. + * Source commit: c4ed1ab6c593e0d0a5f57c0db1267c16e362d9cf + * Do not edit the compressed payload by hand. + */ +export const R1_K1_TEMPLATE_BYTE_LENGTH = 959592 +export const R1_K1_TEMPLATE_SHA256 = + 'd5a824bfb1d3ea48ca4e9f70ba987f545d4d171aad689c1264b7339fa6928f2b' +export const R1_K1_R1_SLOT_OFFSET = 16 +export const R1_K1_K1_SLOT_OFFSET = 959588 +export const R1_K1_R1_CODE_SEPARATOR_OFFSET = 39 + +export const R1_K1_TEMPLATE_GZIP_BASE64 = [ + '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', + 'XOISl7jEpSCXernEJS5xiUtcCnLpeC5xiUtc4hKXglyqc4lLXOISl7gU5FKNS1ziEpe4xKUgl6pc4hKXuMQlLgW5VOESl7jEJS5xKcilHi5xiUtc4hKXglya', + 'ySUucYlLXOJSkEvdXOISl7jEJS4FudTFJS5xiUtc4lKQS5/lEpe4xCUucSnIpeO4xCUucYlLXApy6TNc4hKXuMQlLgW59GkucYlLXOISl4JcOpZLXOISl7jE', + 'pSCXjuESl7jEJS5xKcilGVziEpe4xCUuBbl0NJe4xCUucYlLQS5N5xKXuMQlLnEpyKVpXOISl7jEJS4FufQpLnGJS1ziEpeCXDqKS1ziEpe4xKUgl6ZyiUtc', + '4hKXuBTk0pFc4hKXuMQlLgW5dASXuMQlLnGJS0EuHc4lLnGJS1ziUpBLn+QSl7jEJS5xKcilKVziEpe4xCUuBbl0GJe4xCUucYlLQS59gktc4hKXuMSlIJcm', + 'c4lLXOISl7gU5NKhXOISl7jEJS4FuTSJS1ziEpe4xKUglw7hEpe4xCUucSnIpYlc4hKXuMQlLgW5NIFLXOISl7jEpSCXDuYSl7jEJS5xKcil8VziEpe4xCUu', + 'BbnUySUucYlLXOJSkEsdXOISl7jEJS4FudTOJS5xiUtc4lKQS21c4hKXuMQlLgW5VOYSl7jEJS5xKcilEpe4xCUucYlLQS61colLXOISl7gU5FILl7jEJS5x', + 'iUtBLh3EJS5xiUtc4lKQSwdyiUtc4hKXuBTk0se5xCUucYlLXApy6WNc4hKXuMQlLgW59FEucYlLXOISl4JcOoBLXOISl7jEpSCX9ucSl7jEJS5xKcilj3CJ', + 'S1ziEpe4FOTSflziEpe4xCUuBbm0L5e4xCUucYlLQS7twyUucYlLXOJSkEt7c4lLXOISl7gU5NKHucQlLnGJS1wKculDXOISl7jEJS4FubQXl7jEJS5xiUtB', + 'Lo3jEpe4xCUucSnIpbFc4hKXuMQlLgW59EEucYlLXOISl4Jc2pNLXOISl7jEpSCXPsAlLnGJS1ziUpBLe3CJS1ziEpe4FOTS7lziEpe4xCUuBbm0G5e4xCUu', + 'cYlLQS7tyiUucYlLXOJSkEvv5xKXuMQlLnEpyKVduMQlLnGJS1wKcmlnLnGJS1ziEpeCXNqJS1ziEpe4xKUgl97HJS5xiUtc4lKQSztyiUtc4hKXuBTk0nu5', + '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', + 'S1ziEpe4xKUglzbjEpe4xCUucSnIpbdyiUtc4hKXuBTk0qZc4hKXuMQlLgW5tAmXuMQlLnGJS0EuvYVLXOISl7jEpSCX3swlLnGJS1ziUpBLo7nEJS5xiUtc', + 'CnJpYy5xiUtc4hKXglx6E5e4xCUucYlLQS69kUtc4hKXuMSlIJfewCUucYlLXOJSkEuv5xKXuMQlLnEpyKXXcYlLXOISl7iU49JkLGEJS1jCEpZyWDoUS1jC', + 'EpawhKUcliZhCUtYwhKWsJTD0iFYwhKWsIQlLOWwNBFLWMISlrCEpRyWJmAJS1jCEpawlMPSwVjCEpawhCUs5bA0HktYwhKWsISlHJY6sYQlLGEJS1jKYakD', + 'S1jCEpawhKUcltqxhCUsYQlLWMphqQ1LWMISlrCEpRyWyljCEpawhCUs5bBUwhKWsIQlLGEph6VWLGEJS1jCEpZyWJpLJSpRiUpUolLOF0sLpgydgPVL7+Bi', + '11npqHQOJ04TC12hE4rhNL4QTp1F0jOG5qet0j6cPLUXSrcN8dRZGY+nF03icPHUVpincvW0wc+BIddK1fb1H8ACFrD+abAGb+2DTJULLSDFFsj6vCJrcNsw', + 'jqVcaCzlankYx1IqNJZir8t7iqwYevXq1atXr169evW+Qr2ysrKystZqWVlZWVlZWVlZWVlZWVlZWd8z16tXr169evXq1atXr169evXq1atXr169evXq1atX', + 'r169evW+UmOod3UX2ymlu1ZoD71CWxrVim3mt35PkK56Y/Osxqa+roZ6S1+tVu999d6NpEPtnT54oCPgOBsbemfXR8px1pzTV91xliqtlYXrf+mpD9R7+k9r', + 'rbRVCm0d1VEk3lFof7EiO5eVCo2iyBZWlUKbdLVVhm1bptZiIykX2u6uyEBsD6VXr169evXq1avX9lCysrKystZqWVlZWVlZWVlZWVlZWVlZ39fQq1evXr16', + '9erVq1evXr169erVq1evXr169erVq1evXr169Y7o/3tWbHuoIoMotpNUe7FtQvqLNPf0F9m5Yn6R3TmKjKPa1Vbt+q+Nquqtlfn12vyu5pYtpqxbcdOyNScu', + 'Gr1k6VOrlz+/6QWPnj69VpszcGTL2d0XNJx88dT/BJiTCZlopA4A' +].join('') diff --git a/packages/helpers/ts-templates/src/R1K1Wallet.ts b/packages/helpers/ts-templates/src/R1K1Wallet.ts new file mode 100644 index 000000000..003688382 --- /dev/null +++ b/packages/helpers/ts-templates/src/R1K1Wallet.ts @@ -0,0 +1,352 @@ +import { + Hash, + LockingScript, + OP, + PrivateKey, + Script, + type ScriptTemplate, + Signature, + Transaction, + TransactionSignature, + UnlockingScript, + Utils +} from '@bsv/sdk' +import { + R1_K1_K1_SLOT_OFFSET, + R1_K1_R1_CODE_SEPARATOR_OFFSET, + R1_K1_R1_SLOT_OFFSET, + R1_K1_TEMPLATE_BYTE_LENGTH, + R1_K1_TEMPLATE_GZIP_BASE64, + R1_K1_TEMPLATE_SHA256 +} from './R1K1Wallet.artifact.js' + +export type R1K1Bytes = string | number[] | Uint8Array + +/** + * Signs an already-hashed 32-byte transaction digest with a P-256 key. + * + * A YubiKey PIV implementation should pass this digest unchanged to the PIV + * GENERAL AUTHENTICATE command and return either its DER ECDSA signature or a + * raw 64-byte r || s signature. + */ +export type R1K1P256DigestSigner = (digest: Uint8Array) => Promise | R1K1Bytes + +export interface R1K1R1UnlockParams { + path: 'r1' + publicKey: R1K1Bytes + salt: R1K1Bytes + signDigest: R1K1P256DigestSigner + sourceSatoshis?: number + lockingScript?: Script +} + +export interface R1K1K1UnlockParams { + path: 'k1' + privateKey: PrivateKey + sourceSatoshis?: number + lockingScript?: Script +} + +export type R1K1UnlockParams = R1K1R1UnlockParams | R1K1K1UnlockParams + +interface SourceDetails { + sourceTXID: string + sourceSatoshis: number + lockingScript: Script +} + +const SIGHASH_ALL_FORKID = TransactionSignature.SIGHASH_ALL | TransactionSignature.SIGHASH_FORKID +const CONSTRUCTOR_SLOT_EXPANSION = 20 +const BAKED_SCRIPT_BYTE_LENGTH = R1_K1_TEMPLATE_BYTE_LENGTH + 2 * CONSTRUCTOR_SLOT_EXPANSION +const BAKED_R1_CODE_SEPARATOR_OFFSET = R1_K1_R1_CODE_SEPARATOR_OFFSET + CONSTRUCTOR_SLOT_EXPANSION +const BAKED_K1_SLOT_OFFSET = R1_K1_K1_SLOT_OFFSET + CONSTRUCTOR_SLOT_EXPANSION + +let templateBytesPromise: Promise | undefined + +/** + * Static R1-K1 Runar contract template. + * + * The normal path verifies a P-256 signature made by hardware such as a + * YubiKey PIV device. The recovery path uses an independent secp256k1 key. + * Each output commits to HASH160(compressedR1PublicKey || privateSalt), which + * keeps reuse of the PIV public key unlinkable until the output is spent. + */ +export class R1K1Wallet implements ScriptTemplate { + static readonly compiledTemplateByteLength = R1_K1_TEMPLATE_BYTE_LENGTH + static readonly lockingScriptByteLength = BAKED_SCRIPT_BYTE_LENGTH + + /** + * Creates the R1-K1 locking script from its two 20-byte commitments. + */ + async lock(r1SaltedPublicKeyHash: R1K1Bytes, k1PublicKeyHash: R1K1Bytes): Promise { + const r1Hash = normalizeBytes(r1SaltedPublicKeyHash, 'R1 salted public key hash', 20) + const k1Hash = normalizeBytes(k1PublicKeyHash, 'K1 public key hash', 20) + const template = await loadTemplateBytes() + const scriptBytes = substituteConstructorSlots(template, r1Hash, k1Hash) + return new LockingScript([], scriptBytes, undefined, false) + } + + /** + * Creates an unlocking template for either the hardware R1 path or the K1 + * recovery path. Both paths are fixed to SIGHASH_ALL | SIGHASH_FORKID. + */ + unlock(params: R1K1UnlockParams): { + sign: (tx: Transaction, inputIndex: number) => Promise + estimateLength: (tx: Transaction, inputIndex: number) => Promise + } { + return params.path === 'r1' ? this.unlockR1(params) : this.unlockK1(params) + } + + /** Creates the normal P-256 hardware signing path. */ + unlockR1(params: Omit | R1K1R1UnlockParams): { + sign: (tx: Transaction, inputIndex: number) => Promise + estimateLength: (tx: Transaction, inputIndex: number) => Promise + } { + const publicKey = normalizeBytes(params.publicKey, 'R1 public key', 33) + if (publicKey[0] !== 0x02 && publicKey[0] !== 0x03) { + throw new Error('R1 public key must use compressed P-256 encoding') + } + const salt = normalizeBytes(params.salt, 'R1 salt', 32) + + const buildPreimage = async (tx: Transaction, inputIndex: number): Promise => { + const source = resolveSourceDetails( + tx, + inputIndex, + params.sourceSatoshis, + params.lockingScript + ) + const lockingBytes = await validateLockingScript(source.lockingScript) + const expectedCommitment = lockingBytes.subarray( + R1_K1_R1_SLOT_OFFSET + 1, + R1_K1_R1_SLOT_OFFSET + 21 + ) + if (!equalBytes(Hash.hash160([...publicKey, ...salt]), expectedCommitment)) { + throw new Error('R1 public key and salt do not match the locking script commitment') + } + const subscriptBytes = lockingBytes.subarray(BAKED_R1_CODE_SEPARATOR_OFFSET + 1) + const subscript = new Script([], subscriptBytes, undefined, false) + return formatPreimage(tx, inputIndex, source, subscript) + } + + return { + sign: async (tx: Transaction, inputIndex: number) => { + const preimage = await buildPreimage(tx, inputIndex) + const digest = Uint8Array.from(Hash.hash256(preimage)) + const signature = normalizeP256Signature(await params.signDigest(digest)) + return new UnlockingScript() + .writeBin(signature) + .writeBin(publicKey) + .writeBin(salt) + .writeBin(Array.from(preimage)) + .writeOpCode(OP.OP_0) + }, + estimateLength: async (tx: Transaction, inputIndex: number) => { + const preimageLength = (await buildPreimage(tx, inputIndex)).length + return ( + encodedPushLength(64) + + encodedPushLength(publicKey.length) + + encodedPushLength(salt.length) + + encodedPushLength(preimageLength) + + 1 + ) + } + } + } + + /** Creates the mnemonic-derived secp256k1 recovery path. */ + unlockK1(params: Omit | R1K1K1UnlockParams): { + sign: (tx: Transaction, inputIndex: number) => Promise + estimateLength: () => Promise<109> + } { + return { + sign: async (tx: Transaction, inputIndex: number) => { + const source = resolveSourceDetails( + tx, + inputIndex, + params.sourceSatoshis, + params.lockingScript + ) + const lockingBytes = await validateLockingScript(source.lockingScript) + const publicKey = params.privateKey.toPublicKey().encode(true) as number[] + const expectedCommitment = lockingBytes.subarray( + BAKED_K1_SLOT_OFFSET + 1, + BAKED_K1_SLOT_OFFSET + 21 + ) + if (!equalBytes(Hash.hash160(publicKey), expectedCommitment)) { + throw new Error('K1 private key does not match the locking script commitment') + } + + const preimage = formatPreimage(tx, inputIndex, source, source.lockingScript) + // PrivateKey.sign hashes once internally, so pre-hash once to produce + // the HASH256(preimage) digest used by OP_CHECKSIG. + const rawSignature = params.privateKey.sign(Hash.sha256(preimage)) + const signature = new TransactionSignature( + rawSignature.r, + rawSignature.s, + SIGHASH_ALL_FORKID + ).toChecksigFormat() + return new UnlockingScript().writeBin(signature).writeBin(publicKey).writeOpCode(OP.OP_1) + }, + estimateLength: async () => 109 + } + } +} + +async function loadTemplateBytes(): Promise { + templateBytesPromise ??= (async () => { + const compressed = Uint8Array.from(Utils.toArray(R1_K1_TEMPLATE_GZIP_BASE64, 'base64')) + const input = new Blob([compressed.buffer as ArrayBuffer]).stream() + const decompressed = input.pipeThrough(new DecompressionStream('gzip')) + const bytes = new Uint8Array(await new Response(decompressed).arrayBuffer()) + if (bytes.length !== R1_K1_TEMPLATE_BYTE_LENGTH) { + throw new Error(`R1-K1 artifact length mismatch: expected ${R1_K1_TEMPLATE_BYTE_LENGTH}`) + } + if (Utils.toHex(Hash.sha256(bytes)) !== R1_K1_TEMPLATE_SHA256) { + throw new Error('R1-K1 artifact checksum mismatch') + } + return bytes + })() + return await templateBytesPromise +} + +function substituteConstructorSlots( + template: Uint8Array, + r1Hash: number[], + k1Hash: number[] +): Uint8Array { + if (template[R1_K1_R1_SLOT_OFFSET] !== OP.OP_0 || template[R1_K1_K1_SLOT_OFFSET] !== OP.OP_0) { + throw new Error('R1-K1 artifact constructor slots are invalid') + } + const output = new Uint8Array(BAKED_SCRIPT_BYTE_LENGTH) + let sourceOffset = 0 + let outputOffset = 0 + for (const [slotOffset, value] of [ + [R1_K1_R1_SLOT_OFFSET, r1Hash], + [R1_K1_K1_SLOT_OFFSET, k1Hash] + ] as const) { + output.set(template.subarray(sourceOffset, slotOffset), outputOffset) + outputOffset += slotOffset - sourceOffset + output[outputOffset++] = 20 + output.set(value, outputOffset) + outputOffset += value.length + sourceOffset = slotOffset + 1 + } + output.set(template.subarray(sourceOffset), outputOffset) + return output +} + +function resolveSourceDetails( + tx: Transaction, + inputIndex: number, + sourceSatoshis?: number, + lockingScript?: Script +): SourceDetails { + const input = tx.inputs[inputIndex] + if (input == null) throw new Error(`Transaction input ${inputIndex} does not exist`) + const sourceTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex') + if (sourceTXID == null || sourceTXID.length === 0) { + throw new Error('The input sourceTXID or sourceTransaction is required for signing') + } + const sourceOutput = input.sourceTransaction?.outputs[input.sourceOutputIndex] + const resolvedSatoshis = sourceSatoshis ?? sourceOutput?.satoshis + if (resolvedSatoshis == null) { + throw new Error('The sourceSatoshis or input sourceTransaction is required for signing') + } + const resolvedScript = lockingScript ?? sourceOutput?.lockingScript + if (resolvedScript == null) { + throw new Error('The lockingScript or input sourceTransaction is required for signing') + } + return { sourceTXID, sourceSatoshis: resolvedSatoshis, lockingScript: resolvedScript } +} + +function formatPreimage( + tx: Transaction, + inputIndex: number, + source: SourceDetails, + subscript: Script +): Uint8Array { + const input = tx.inputs[inputIndex]! + return Uint8Array.from( + TransactionSignature.format({ + sourceTXID: source.sourceTXID, + sourceOutputIndex: input.sourceOutputIndex, + sourceSatoshis: source.sourceSatoshis, + transactionVersion: tx.version, + otherInputs: tx.inputs.filter((_, index) => index !== inputIndex), + allInputs: tx.inputs, + outputs: tx.outputs, + inputIndex, + inputSequence: input.sequence ?? 0xffffffff, + subscript, + lockTime: tx.lockTime, + scope: SIGHASH_ALL_FORKID, + cache: tx.getSignatureHashCache() + }) + ) +} + +async function validateLockingScript(lockingScript: Script): Promise { + const bytes = lockingScript.toUint8Array() + if (bytes.length !== BAKED_SCRIPT_BYTE_LENGTH) { + throw new Error(`R1-K1 locking script must be ${BAKED_SCRIPT_BYTE_LENGTH} bytes`) + } + const template = await loadTemplateBytes() + if ( + bytes[R1_K1_R1_SLOT_OFFSET] !== 20 || + bytes[BAKED_K1_SLOT_OFFSET] !== 20 || + !equalBytes( + bytes.subarray(0, R1_K1_R1_SLOT_OFFSET), + template.subarray(0, R1_K1_R1_SLOT_OFFSET) + ) || + !equalBytes( + bytes.subarray(R1_K1_R1_SLOT_OFFSET + 21, BAKED_K1_SLOT_OFFSET), + template.subarray(R1_K1_R1_SLOT_OFFSET + 1, R1_K1_K1_SLOT_OFFSET) + ) || + !equalBytes( + bytes.subarray(BAKED_K1_SLOT_OFFSET + 21), + template.subarray(R1_K1_K1_SLOT_OFFSET + 1) + ) + ) { + throw new Error('R1-K1 locking script structure is invalid') + } + return bytes +} + +function normalizeP256Signature(value: R1K1Bytes): number[] { + const signature = normalizeBytes(value, 'P-256 signature') + if (signature.length === 64) return signature + try { + const parsed = Signature.fromDER(signature) + return [...parsed.r.toArray('be', 32), ...parsed.s.toArray('be', 32)] + } catch (error) { + throw new Error('P-256 signer must return a DER signature or raw 64-byte r || s', { + cause: error + }) + } +} + +function normalizeBytes(value: R1K1Bytes, label: string, length?: number): number[] { + const bytes = typeof value === 'string' ? Utils.toArray(value, 'hex') : Array.from(value) + if (bytes.some(byte => !Number.isInteger(byte) || byte < 0 || byte > 255)) { + throw new Error(`${label} must contain only bytes`) + } + if (length != null && bytes.length !== length) { + throw new Error(`${label} must be ${length} bytes`) + } + return bytes +} + +function encodedPushLength(dataLength: number): number { + if (dataLength <= 75) return 1 + dataLength + if (dataLength <= 0xff) return 2 + dataLength + if (dataLength <= 0xffff) return 3 + dataLength + return 5 + dataLength +} + +function equalBytes(left: ArrayLike, right: ArrayLike): boolean { + if (left.length !== right.length) return false + for (let index = 0; index < left.length; index++) { + if (left[index] !== right[index]) return false + } + return true +} diff --git a/packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts b/packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts new file mode 100644 index 000000000..4c792dbbc --- /dev/null +++ b/packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts @@ -0,0 +1,188 @@ +import { + BigNumber, + Hash, + LockingScript, + OP, + PrivateKey, + Secp256r1, + Signature, + Spend, + Transaction, + UnlockingScript, + Utils +} from '@bsv/sdk' +import { R1K1Wallet } from '../R1K1Wallet.js' + +const p256 = new Secp256r1() +const r1PrivateKey = '01'.padStart(64, '0') +const r1PublicKey = Utils.toArray( + p256.pointToHex(p256.publicKeyFromPrivate(r1PrivateKey), true), + 'hex' +) +const r1Salt = Array.from({ length: 32 }, (_, index) => index + 1) +const r1Commitment = Hash.hash160([...r1PublicKey, ...r1Salt]) +const k1PrivateKey = PrivateKey.fromHex('02'.padStart(64, '0')) +const k1PublicKey = k1PrivateKey.toPublicKey().encode(true) as number[] +const k1Commitment = Hash.hash160(k1PublicKey) + +function transactionFor(lockingScript: LockingScript): { + sourceTransaction: Transaction + spendingTransaction: Transaction +} { + const sourceTransaction = new Transaction() + sourceTransaction.addOutput({ lockingScript, satoshis: 1000 }) + const spendingTransaction = new Transaction() + spendingTransaction.addInput({ + sourceTransaction, + sourceOutputIndex: 0, + sequence: 0xffffffff + }) + spendingTransaction.addOutput({ + lockingScript: new LockingScript([{ op: OP.OP_TRUE }]), + satoshis: 900 + }) + return { sourceTransaction, spendingTransaction } +} + +function spend( + lockingScript: LockingScript, + unlockingScript: UnlockingScript, + sourceTransaction: Transaction, + spendingTransaction: Transaction +): Spend { + return new Spend({ + sourceTXID: sourceTransaction.id('hex'), + sourceOutputIndex: 0, + sourceSatoshis: 1000, + lockingScript, + transactionVersion: spendingTransaction.version, + otherInputs: [], + outputs: spendingTransaction.outputs, + unlockingScript, + inputIndex: 0, + inputSequence: 0xffffffff, + lockTime: spendingTransaction.lockTime + }) +} + +describe('R1K1Wallet', () => { + const template = new R1K1Wallet() + + it('bakes both 20-byte constructor commitments into the static artifact', async () => { + const lockingScript = await template.lock(r1Commitment, k1Commitment) + const bytes = lockingScript.toUint8Array() + + expect(bytes).toHaveLength(R1K1Wallet.lockingScriptByteLength) + expect(Array.from(bytes.subarray(17, 37))).toEqual(r1Commitment) + expect(Array.from(bytes.subarray(959609, 959629))).toEqual(k1Commitment) + expect(bytes[59]).toBe(OP.OP_CODESEPARATOR) + }) + + it('rejects malformed constructor hashes', async () => { + await expect(template.lock(r1Commitment.slice(1), k1Commitment)).rejects.toThrow( + 'R1 salted public key hash must be 20 bytes' + ) + await expect(template.lock(r1Commitment, k1Commitment.slice(1))).rejects.toThrow( + 'K1 public key hash must be 20 bytes' + ) + }) + + it('builds the R1 witness from a YubiKey-style DER digest signer', async () => { + const lockingScript = await template.lock(r1Commitment, k1Commitment) + const { sourceTransaction, spendingTransaction } = transactionFor(lockingScript) + let signedDigest: Uint8Array | undefined + const unlocker = template.unlock({ + path: 'r1', + publicKey: r1PublicKey, + salt: r1Salt, + signDigest: digest => { + signedDigest = digest + const signature = p256.sign(digest, r1PrivateKey, { prehashed: true }) + return new Signature( + new BigNumber(signature.r, 16), + new BigNumber(signature.s, 16) + ).toDER() as number[] + } + }) + + const unlockingScript = await unlocker.sign(spendingTransaction, 0) + expect(signedDigest).toHaveLength(32) + expect(unlockingScript.chunks).toHaveLength(5) + expect(unlockingScript.chunks[0].data).toHaveLength(64) + expect(unlockingScript.chunks[1].data).toEqual(r1PublicKey) + expect(unlockingScript.chunks[2].data).toEqual(r1Salt) + expect(unlockingScript.chunks[3].data?.slice(-4)).toEqual([0x41, 0, 0, 0]) + expect(unlockingScript.chunks[4].op).toBe(OP.OP_0) + await expect(unlocker.estimateLength(spendingTransaction, 0)).resolves.toBe( + unlockingScript.toUint8Array().length + ) + expect( + spend(lockingScript, unlockingScript, sourceTransaction, spendingTransaction).validate() + ).toBe(true) + }, 60000) + + it('does not ask the hardware to sign when the private salt is wrong', async () => { + const lockingScript = await template.lock(r1Commitment, k1Commitment) + const { spendingTransaction } = transactionFor(lockingScript) + let called = false + const signer = template.unlock({ + path: 'r1', + publicKey: r1PublicKey, + salt: [...r1Salt.slice(0, -1), 0xff], + signDigest: () => { + called = true + return new Uint8Array(64) + } + }) + + await expect(signer.sign(spendingTransaction, 0)).rejects.toThrow( + 'do not match the locking script commitment' + ) + expect(called).toBe(false) + }) + + it('does not ask the hardware to sign for a modified contract script', async () => { + const lockingScript = await template.lock(r1Commitment, k1Commitment) + const tamperedBytes = lockingScript.toUint8Array() + tamperedBytes[tamperedBytes.length - 1] ^= 1 + const tamperedScript = new LockingScript([], tamperedBytes, undefined, false) + const { spendingTransaction } = transactionFor(tamperedScript) + let called = false + const signer = template.unlock({ + path: 'r1', + publicKey: r1PublicKey, + salt: r1Salt, + signDigest: () => { + called = true + return new Uint8Array(64) + } + }) + + await expect(signer.sign(spendingTransaction, 0)).rejects.toThrow( + 'locking script structure is invalid' + ) + expect(called).toBe(false) + }) + + it('spends through the K1 recovery branch with a real transaction signature', async () => { + const lockingScript = await template.lock(r1Commitment, k1Commitment) + const { sourceTransaction, spendingTransaction } = transactionFor(lockingScript) + const unlocker = template.unlock({ path: 'k1', privateKey: k1PrivateKey }) + const unlockingScript = await unlocker.sign(spendingTransaction, 0) + + expect(unlockingScript.chunks.at(-1)?.op).toBe(OP.OP_1) + expect( + spend(lockingScript, unlockingScript, sourceTransaction, spendingTransaction).validate() + ).toBe(true) + }, 30000) + + it('rejects an unrelated K1 recovery key before signing', async () => { + const lockingScript = await template.lock(r1Commitment, k1Commitment) + const { spendingTransaction } = transactionFor(lockingScript) + const unrelatedKey = PrivateKey.fromHex('03'.padStart(64, '0')) + + await expect( + template.unlock({ path: 'k1', privateKey: unrelatedKey }).sign(spendingTransaction, 0) + ).rejects.toThrow('does not match the locking script commitment') + }) +}) diff --git a/packages/helpers/ts-templates/src/__tests/exports.test.ts b/packages/helpers/ts-templates/src/__tests/exports.test.ts index c7d08ffac..4de8f702c 100644 --- a/packages/helpers/ts-templates/src/__tests/exports.test.ts +++ b/packages/helpers/ts-templates/src/__tests/exports.test.ts @@ -1,9 +1,10 @@ -import { MandalaToken, MandalaAdmin } from '../../mod.js' +import { MandalaToken, MandalaAdmin, R1K1Wallet } from '../../mod.js' describe('package exports', () => { it('exposes the Mandala templates from the package entrypoint', () => { expect(typeof MandalaToken).toBe('function') expect(typeof MandalaAdmin).toBe('function') expect(typeof MandalaAdmin.canonicalize).toBe('function') + expect(typeof R1K1Wallet).toBe('function') }) }) From a2264acf7239bed980104080f4864fc8fc80fba8 Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 14 Aug 2026 16:03:58 -0500 Subject: [PATCH 2/4] test(templates): cover R1-K1 validation paths --- .../src/__tests/R1K1Wallet.test.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts b/packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts index 4c792dbbc..1ba099794 100644 --- a/packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts +++ b/packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts @@ -85,6 +85,20 @@ describe('R1K1Wallet', () => { await expect(template.lock(r1Commitment, k1Commitment.slice(1))).rejects.toThrow( 'K1 public key hash must be 20 bytes' ) + await expect(template.lock([...r1Commitment.slice(0, -1), 256], k1Commitment)).rejects.toThrow( + 'must contain only bytes' + ) + }) + + it('rejects an invalid compressed P-256 key before constructing an unlocker', () => { + expect(() => + template.unlock({ + path: 'r1', + publicKey: [0x04, ...r1PublicKey.slice(1)], + salt: r1Salt, + signDigest: () => new Uint8Array(64) + }) + ).toThrow('R1 public key must use compressed P-256 encoding') }) it('builds the R1 witness from a YubiKey-style DER digest signer', async () => { @@ -164,6 +178,28 @@ describe('R1K1Wallet', () => { expect(called).toBe(false) }) + it('rejects malformed hardware signatures and accepts the raw 64-byte form', async () => { + const lockingScript = await template.lock(r1Commitment, k1Commitment) + const { spendingTransaction } = transactionFor(lockingScript) + const malformed = template.unlock({ + path: 'r1', + publicKey: r1PublicKey, + salt: r1Salt, + signDigest: () => [1] + }) + await expect(malformed.sign(spendingTransaction, 0)).rejects.toThrow( + 'must return a DER signature or raw 64-byte r || s' + ) + + const raw = template.unlock({ + path: 'r1', + publicKey: r1PublicKey, + salt: r1Salt, + signDigest: () => new Uint8Array(64) + }) + await expect(raw.sign(spendingTransaction, 0)).resolves.toBeInstanceOf(UnlockingScript) + }) + it('spends through the K1 recovery branch with a real transaction signature', async () => { const lockingScript = await template.lock(r1Commitment, k1Commitment) const { sourceTransaction, spendingTransaction } = transactionFor(lockingScript) @@ -185,4 +221,46 @@ describe('R1K1Wallet', () => { template.unlock({ path: 'k1', privateKey: unrelatedKey }).sign(spendingTransaction, 0) ).rejects.toThrow('does not match the locking script commitment') }) + + it('validates required source details and malformed locking scripts', async () => { + const recovery = template.unlockK1({ privateKey: k1PrivateKey }) + await expect(recovery.sign(new Transaction(), 0)).rejects.toThrow( + 'Transaction input 0 does not exist' + ) + + const transactionWithoutSource = { + inputs: [{ sourceOutputIndex: 0 }], + outputs: [], + version: 1, + lockTime: 0 + } as unknown as Transaction + await expect(recovery.sign(transactionWithoutSource, 0)).rejects.toThrow( + 'sourceTXID or sourceTransaction is required' + ) + + const transactionWithTXID = { + inputs: [{ sourceTXID: '00'.repeat(32), sourceOutputIndex: 0 }], + outputs: [], + version: 1, + lockTime: 0 + } as unknown as Transaction + await expect(recovery.sign(transactionWithTXID, 0)).rejects.toThrow( + 'sourceSatoshis or input sourceTransaction is required' + ) + await expect( + template + .unlockK1({ privateKey: k1PrivateKey, sourceSatoshis: 1000 }) + .sign(transactionWithTXID, 0) + ).rejects.toThrow('lockingScript or input sourceTransaction is required') + await expect( + template + .unlockK1({ + privateKey: k1PrivateKey, + sourceSatoshis: 1000, + lockingScript: new LockingScript([{ op: OP.OP_TRUE }]) + }) + .sign(transactionWithTXID, 0) + ).rejects.toThrow(`locking script must be ${R1K1Wallet.lockingScriptByteLength} bytes`) + await expect(recovery.estimateLength()).resolves.toBe(109) + }) }) From 689500b7de3be927d4c0cb579e5f691f999e8354 Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 14 Aug 2026 17:42:06 -0500 Subject: [PATCH 3/4] fix(templates): address R1-K1 review feedback --- docs/packages/helpers/templates.md | 7 +++++++ docs/reference/package-api-migrations.md | 6 +++--- governance/package-release-notes.json | 4 ++-- packages/helpers/ts-templates/README.md | 11 ++++++++-- .../helpers/ts-templates/src/R1K1Wallet.ts | 20 +------------------ .../src/__tests/R1K1Wallet.test.ts | 14 +++++++++++++ .../ts-templates/src/__tests/exports.test.ts | 2 +- 7 files changed, 37 insertions(+), 27 deletions(-) diff --git a/docs/packages/helpers/templates.md b/docs/packages/helpers/templates.md index 4fd85db56..6a08f7ca0 100644 --- a/docs/packages/helpers/templates.md +++ b/docs/packages/helpers/templates.md @@ -163,6 +163,13 @@ The generated locking script is 959,632 bytes after both commitments are baked, above common 500 KB miner policy. Confirm target-miner policy before funding it. +An R1 unlocking script also pushes the BIP-143 preimage, whose `scriptCode` +contains roughly 960 KB of the contract after `OP_CODESEPARATOR`. The R1 path +therefore involves about 2 MB of locking-plus-unlocking script material, and +the witness alone adds roughly 960 KB to the spending transaction. Account for +the resulting fees and confirm any maximum-transaction policy; `estimateLength` +includes this preimage push. The K1 unlocking script remains small. + PIV proves that the hardware key signed the supplied digest, but a YubiKey does not display or independently validate the Bitcoin transaction. PIN and touch policies protect key use, not transaction intent; review the transaction diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index c0fa33a6e..6a3779ca1 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -47,7 +47,7 @@ and clean-consumer tests remain the executable type authority. | `@bsv/payment-express-middleware` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. | | `@bsv/sdk` | `2.3.1` | `2.4.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Existing mainnet, testnet, and local routing is unchanged. TTN consumers select teratestnet explicitly because BRC-100 getNetwork continues to report testnet for all test-family chains. | | `@bsv/simple` | `0.4.1` | `0.5.0` | minor | [API and usage](../packages/helpers/simple.md) | Existing overlay configurations are unchanged. TTN consumers select network teratestnet and use @bsv/sdk 2.4 or later. | -| `@bsv/templates` | `1.9.1` | `1.10.0` | minor | [API and usage](../packages/helpers/templates.md) | No existing consumer migration is required. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. | +| `@bsv/templates` | `1.9.1` | `1.10.0` | minor | [API and usage](../packages/helpers/templates.md) | No existing consumer migration is required; existing template APIs and generated scripts are unchanged. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. | | `@bsv/teranode-listener` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. | | `@bsv/verifast` | `0.3.0` | `0.3.4` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. | | `@bsv/wallet-helper` | `0.1.1` | `0.1.6` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. | @@ -411,8 +411,8 @@ CLI entry points: `{"fund-metanet":"./dist/index.mjs"}`. - Package documentation: [docs/packages/helpers/templates.md](../packages/helpers/templates.md) - Source: [packages/helpers/ts-templates](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates) -- Release note: Adds the R1K1Wallet ScriptTemplate for salted P-256 hardware signing with an independent secp256k1 recovery path, including a checksummed static Runar artifact and browser-safe lazy decompression. -- Migration: No existing consumer migration is required. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. +- Release note: Adds the R1K1Wallet ScriptTemplate for salted P-256 hardware signing with an independent secp256k1 recovery path, including a checksummed static Runar artifact and browser-safe lazy decompression. Also consolidates MultiPushDrop script construction without changing its output, adds an exact-tarball browser-consumer and bundle-size contract, and points contributors to the canonical root policy. +- Migration: No existing consumer migration is required; existing template APIs and generated scripts are unchanged. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | --------------------------------------- | ------------------------------------------- | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index a051c3996..d18511770 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -161,8 +161,8 @@ "name": "@bsv/templates", "publishedVersion": "1.9.1", "releaseType": "minor", - "summary": "Adds the R1K1Wallet ScriptTemplate for salted P-256 hardware signing with an independent secp256k1 recovery path, including a checksummed static Runar artifact and browser-safe lazy decompression.", - "migration": "No existing consumer migration is required. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again." + "summary": "Adds the R1K1Wallet ScriptTemplate for salted P-256 hardware signing with an independent secp256k1 recovery path, including a checksummed static Runar artifact and browser-safe lazy decompression. Also consolidates MultiPushDrop script construction without changing its output, adds an exact-tarball browser-consumer and bundle-size contract, and points contributors to the canonical root policy.", + "migration": "No existing consumer migration is required; existing template APIs and generated scripts are unchanged. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again." }, { "name": "@bsv/teranode-listener", diff --git a/packages/helpers/ts-templates/README.md b/packages/helpers/ts-templates/README.md index 990445a79..b986ef65f 100644 --- a/packages/helpers/ts-templates/README.md +++ b/packages/helpers/ts-templates/README.md @@ -32,8 +32,8 @@ tx.addOutput({ | [OpReturn](./src/OpReturn.ts) | Tag data in a non-spendable script | | [Metant](./src/Metanet.ts) | Create transactions that follow the Metanet protocol | | [MultiPushDrop](./src/MultiPushDrop.ts) | Create data tokens with multiple trusted owners | -| [P2MSKH](./src/P2MSKH.ts) | Spend with an M-of-N public-key threshold | -| [R1K1Wallet](./src/R1K1Wallet.ts) | Use P-256 hardware normally and a K1 recovery key | +| [P2MSKH](./src/P2MSKH.ts) | Spend with an M-of-N public-key threshold | +| [R1K1Wallet](./src/R1K1Wallet.ts) | Use P-256 hardware normally and a K1 recovery key | ### R1-K1 hardware wallet @@ -78,6 +78,13 @@ The synthesized P-256 verifier produces a 959,632-byte locking script after constructor commitments are baked. This exceeds common 500 KB miner policy; confirm the target miner's limits before funding an output. +An R1 unlocking script also pushes the BIP-143 preimage, whose `scriptCode` +contains roughly 960 KB of the contract after `OP_CODESEPARATOR`. The R1 path +therefore involves about 2 MB of locking-plus-unlocking script material, and +the witness alone adds roughly 960 KB to the spending transaction. Account for +the resulting fees and confirm any maximum-transaction policy; `estimateLength` +includes this preimage push. The K1 unlocking script remains small. + PIV proves that the hardware key signed the supplied digest, but a YubiKey does not display or independently validate the Bitcoin transaction. A PIN or touch policy protects key use, not transaction intent; review transactions on diff --git a/packages/helpers/ts-templates/src/R1K1Wallet.ts b/packages/helpers/ts-templates/src/R1K1Wallet.ts index 003688382..2f383795e 100644 --- a/packages/helpers/ts-templates/src/R1K1Wallet.ts +++ b/packages/helpers/ts-templates/src/R1K1Wallet.ts @@ -63,21 +63,10 @@ const BAKED_K1_SLOT_OFFSET = R1_K1_K1_SLOT_OFFSET + CONSTRUCTOR_SLOT_EXPANSION let templateBytesPromise: Promise | undefined -/** - * Static R1-K1 Runar contract template. - * - * The normal path verifies a P-256 signature made by hardware such as a - * YubiKey PIV device. The recovery path uses an independent secp256k1 key. - * Each output commits to HASH160(compressedR1PublicKey || privateSalt), which - * keeps reuse of the PIV public key unlinkable until the output is spent. - */ export class R1K1Wallet implements ScriptTemplate { static readonly compiledTemplateByteLength = R1_K1_TEMPLATE_BYTE_LENGTH static readonly lockingScriptByteLength = BAKED_SCRIPT_BYTE_LENGTH - /** - * Creates the R1-K1 locking script from its two 20-byte commitments. - */ async lock(r1SaltedPublicKeyHash: R1K1Bytes, k1PublicKeyHash: R1K1Bytes): Promise { const r1Hash = normalizeBytes(r1SaltedPublicKeyHash, 'R1 salted public key hash', 20) const k1Hash = normalizeBytes(k1PublicKeyHash, 'K1 public key hash', 20) @@ -86,10 +75,6 @@ export class R1K1Wallet implements ScriptTemplate { return new LockingScript([], scriptBytes, undefined, false) } - /** - * Creates an unlocking template for either the hardware R1 path or the K1 - * recovery path. Both paths are fixed to SIGHASH_ALL | SIGHASH_FORKID. - */ unlock(params: R1K1UnlockParams): { sign: (tx: Transaction, inputIndex: number) => Promise estimateLength: (tx: Transaction, inputIndex: number) => Promise @@ -97,7 +82,6 @@ export class R1K1Wallet implements ScriptTemplate { return params.path === 'r1' ? this.unlockR1(params) : this.unlockK1(params) } - /** Creates the normal P-256 hardware signing path. */ unlockR1(params: Omit | R1K1R1UnlockParams): { sign: (tx: Transaction, inputIndex: number) => Promise estimateLength: (tx: Transaction, inputIndex: number) => Promise @@ -153,7 +137,6 @@ export class R1K1Wallet implements ScriptTemplate { } } - /** Creates the mnemonic-derived secp256k1 recovery path. */ unlockK1(params: Omit | R1K1K1UnlockParams): { sign: (tx: Transaction, inputIndex: number) => Promise estimateLength: () => Promise<109> @@ -279,8 +262,7 @@ function formatPreimage( inputSequence: input.sequence ?? 0xffffffff, subscript, lockTime: tx.lockTime, - scope: SIGHASH_ALL_FORKID, - cache: tx.getSignatureHashCache() + scope: SIGHASH_ALL_FORKID }) ) } diff --git a/packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts b/packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts index 1ba099794..016e0c6f0 100644 --- a/packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts +++ b/packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts @@ -212,6 +212,20 @@ describe('R1K1Wallet', () => { ).toBe(true) }, 30000) + it('does not depend on the SDK internal signature-hash cache API', async () => { + const lockingScript = await template.lock(r1Commitment, k1Commitment) + const { sourceTransaction, spendingTransaction } = transactionFor(lockingScript) + Object.defineProperty(spendingTransaction, 'getSignatureHashCache', { value: undefined }) + + const unlockingScript = await template + .unlock({ path: 'k1', privateKey: k1PrivateKey }) + .sign(spendingTransaction, 0) + + expect( + spend(lockingScript, unlockingScript, sourceTransaction, spendingTransaction).validate() + ).toBe(true) + }, 30000) + it('rejects an unrelated K1 recovery key before signing', async () => { const lockingScript = await template.lock(r1Commitment, k1Commitment) const { spendingTransaction } = transactionFor(lockingScript) diff --git a/packages/helpers/ts-templates/src/__tests/exports.test.ts b/packages/helpers/ts-templates/src/__tests/exports.test.ts index 4de8f702c..97aa4f179 100644 --- a/packages/helpers/ts-templates/src/__tests/exports.test.ts +++ b/packages/helpers/ts-templates/src/__tests/exports.test.ts @@ -1,7 +1,7 @@ import { MandalaToken, MandalaAdmin, R1K1Wallet } from '../../mod.js' describe('package exports', () => { - it('exposes the Mandala templates from the package entrypoint', () => { + it('exposes public templates from the package entrypoint', () => { expect(typeof MandalaToken).toBe('function') expect(typeof MandalaAdmin).toBe('function') expect(typeof MandalaAdmin.canonicalize).toBe('function') From 3d054b7bb01c408d4b0b981fe26fa6c13cb91843 Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 14 Aug 2026 21:21:39 -0500 Subject: [PATCH 4/4] test(templates): reach full R1-K1 patch coverage --- .../helpers/ts-templates/src/R1K1Wallet.ts | 17 ++--- .../src/__tests/R1K1Wallet.test.ts | 64 +++++++++++++++++++ 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/packages/helpers/ts-templates/src/R1K1Wallet.ts b/packages/helpers/ts-templates/src/R1K1Wallet.ts index 2f383795e..f23c5ad10 100644 --- a/packages/helpers/ts-templates/src/R1K1Wallet.ts +++ b/packages/helpers/ts-templates/src/R1K1Wallet.ts @@ -319,16 +319,17 @@ function normalizeBytes(value: R1K1Bytes, label: string, length?: number): numbe } function encodedPushLength(dataLength: number): number { - if (dataLength <= 75) return 1 + dataLength - if (dataLength <= 0xff) return 2 + dataLength - if (dataLength <= 0xffff) return 3 + dataLength - return 5 + dataLength + // Direct pushes and PUSHDATA1/2/4 add 1, 2, 3, and 5 prefix bytes respectively. + const prefixLength = + 1 + Number(dataLength > 75) + Number(dataLength > 0xff) + 2 * Number(dataLength > 0xffff) + return prefixLength + dataLength } function equalBytes(left: ArrayLike, right: ArrayLike): boolean { - if (left.length !== right.length) return false - for (let index = 0; index < left.length; index++) { - if (left[index] !== right[index]) return false + let difference = Number(left.length !== right.length) + const sharedLength = Math.min(left.length, right.length) + for (let index = 0; index < sharedLength; index++) { + difference |= left[index]! ^ right[index]! } - return true + return difference === 0 } diff --git a/packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts b/packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts index 016e0c6f0..9f6f301f8 100644 --- a/packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts +++ b/packages/helpers/ts-templates/src/__tests/R1K1Wallet.test.ts @@ -12,6 +12,27 @@ import { Utils } from '@bsv/sdk' import { R1K1Wallet } from '../R1K1Wallet.js' +import { R1_K1_R1_SLOT_OFFSET, R1_K1_TEMPLATE_BYTE_LENGTH } from '../R1K1Wallet.artifact.js' + +type ArtifactModule = typeof import('../R1K1Wallet.artifact.js') +type WidenLiteral = T extends number ? number : T extends string ? string : T +type ArtifactOverrides = { + [Key in keyof ArtifactModule]?: WidenLiteral +} + +function walletWithArtifact(overrides: ArtifactOverrides): R1K1Wallet { + let Wallet: typeof R1K1Wallet | undefined + jest.isolateModules(() => { + jest.doMock('../R1K1Wallet.artifact.js', () => ({ + ...jest.requireActual('../R1K1Wallet.artifact.js'), + ...overrides + })) + Wallet = jest.requireActual('../R1K1Wallet.js').R1K1Wallet + }) + jest.dontMock('../R1K1Wallet.artifact.js') + if (Wallet == null) throw new Error('Failed to load isolated R1K1Wallet module') + return new Wallet() +} const p256 = new Secp256r1() const r1PrivateKey = '01'.padStart(64, '0') @@ -78,6 +99,38 @@ describe('R1K1Wallet', () => { expect(bytes[59]).toBe(OP.OP_CODESEPARATOR) }) + it('accepts constructor commitments encoded as hex strings', async () => { + const lockingScript = await template.lock(Utils.toHex(r1Commitment), Utils.toHex(k1Commitment)) + + expect(lockingScript.toUint8Array()).toHaveLength(R1K1Wallet.lockingScriptByteLength) + }) + + it('rejects an artifact whose decompressed length does not match its manifest', async () => { + const isolated = walletWithArtifact({ + R1_K1_TEMPLATE_BYTE_LENGTH: R1_K1_TEMPLATE_BYTE_LENGTH + 1 + }) + + await expect(isolated.lock(r1Commitment, k1Commitment)).rejects.toThrow( + `artifact length mismatch: expected ${R1_K1_TEMPLATE_BYTE_LENGTH + 1}` + ) + }) + + it('rejects an artifact whose checksum does not match its manifest', async () => { + const isolated = walletWithArtifact({ R1_K1_TEMPLATE_SHA256: '00'.repeat(32) }) + + await expect(isolated.lock(r1Commitment, k1Commitment)).rejects.toThrow( + 'artifact checksum mismatch' + ) + }) + + it('rejects an artifact whose constructor slot is not an OP_0 placeholder', async () => { + const isolated = walletWithArtifact({ R1_K1_R1_SLOT_OFFSET: R1_K1_R1_SLOT_OFFSET + 1 }) + + await expect(isolated.lock(r1Commitment, k1Commitment)).rejects.toThrow( + 'artifact constructor slots are invalid' + ) + }) + it('rejects malformed constructor hashes', async () => { await expect(template.lock(r1Commitment.slice(1), k1Commitment)).rejects.toThrow( 'R1 salted public key hash must be 20 bytes' @@ -226,6 +279,17 @@ describe('R1K1Wallet', () => { ).toBe(true) }, 30000) + it('defaults a missing input sequence to the final sequence number', async () => { + const lockingScript = await template.lock(r1Commitment, k1Commitment) + const { spendingTransaction } = transactionFor(lockingScript) + const input = spendingTransaction.inputs[0] as { sequence?: number } + delete input.sequence + + await expect( + template.unlockK1({ privateKey: k1PrivateKey }).sign(spendingTransaction, 0) + ).resolves.toBeInstanceOf(UnlockingScript) + }) + it('rejects an unrelated K1 recovery key before signing', async () => { const lockingScript = await template.lock(r1Commitment, k1Commitment) const { spendingTransaction } = transactionFor(lockingScript)