Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ jobs:
- run: |
archive="$PWD/$(node -p 'require("./package.json").name.replace("@", "").replace("/", "-") + "-" + require("./package.json").version + ".tgz"')"
package_test="$(mktemp -d)"
node --input-type=module -e 'import fs from "node:fs"; const fixture = JSON.parse(fs.readFileSync("testdata/signing-bundles.json")); fs.writeFileSync(process.argv[1], JSON.stringify(fixture.cases[0].response.bundle));' "$package_test/bundle.json"
cd "$package_test"
npm init -y
npm install "$archive"
node --input-type=module -e 'import {parseNocks} from "@swpsco/nockchain"; if (parseNocks("1.25") !== 81920n) throw Error("package smoke test");'
./node_modules/.bin/nockchain inspect bundle.json
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,43 @@ declarations are included. Browser applications can bundle the package with thei
usual build tool. The only runtime dependency is `@noble/hashes` for SHA-2, HMAC,
PBKDF2, and secure random bytes.

## Sign a Nockblocks bundle locally

The remote Nockblocks MCP exposes `v1_prepareTransaction` through its `search`
and `execute` tools. Save the returned `result.bundle` object as `bundle.json`.
The npm command runs locally without a native binary or a local MCP server:

```sh
npx @swpsco/nockchain@0.3.0 inspect bundle.json
npx @swpsco/nockchain@0.3.0 sign bundle.json --out signed.json
```

`sign` shows the locally reconstructed payment review and asks for `SIGN` before
prompting for a hidden zprv. It requires an interactive terminal. `--key-file`
reads a local plain-text zprv; `--path` derives an explicitly named child key.
For recovery phrases, use `--mnemonic --path "m/..."` and add `--passphrase` when
needed. The path must identify the key for the bundle's source address.
Keep secrets out of MCP requests, chats, shell arguments, and transaction files.

The command checks canonical encoding, existing signatures, full input
commitments, maturity, recipients, change, and the locally recomputed payment
plan and fee. Bundles support V1 inputs that one source key can spend, including
mature coinbase notes. A small remainder that the planner assigns to the fee is
shown in `feeNicks`. The output file is created with owner-only permissions and
must not already exist. The command does not broadcast. After explicit approval
to submit, pass the signed file's `rawTransaction` to `v1_submitTransaction`.

Network, tip, and input records are supplied by the indexer. They are not proof
of unspent status or finality, and the network label is not a consensus signature
domain. Compare the review with the intended payment and a trusted chain-state
source. The HTTP MCP returns data; it cannot launch the local command.

Applications use `inspectSigningBundle(value)` for `{ bundle, transaction,
review }` and `signSigningBundle(value, key)` for a signed bundle. Both validate
the bundle. Applications obtain the user's authorization before calling the
signing function. `verifySignatures({ requireThreshold: false })` checks existing
signatures and fee coverage while allowing missing signatures.

## Receive from a public branch

```ts
Expand Down
154 changes: 154 additions & 0 deletions bin/nockchain.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/usr/bin/env node
import { open } from "node:fs/promises";
import { parseArgs } from "node:util";
import { createHash } from "node:crypto";
import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
import {
inspectSigningBundle,
signSigningBundle,
parseExtendedKey,
restoreWallet,
} from "../dist/index.js";

const usage = `Usage:
nockchain inspect BUNDLE.json
nockchain sign BUNDLE.json --out SIGNED.json [--key-file FILE]
nockchain sign BUNDLE.json --out SIGNED.json --mnemonic --path "m/..." [--passphrase]

Signing requires an interactive terminal. Keys and recovery phrases stay local.
--key-file reads a plain-text zprv (or recovery phrase with --mnemonic).
--path derives the explicitly named child key. Recovery phrases require --path.
The command never broadcasts a transaction.`;

async function readBounded(path, maximum) {
const file = await open(path, "r");
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size > maximum)
throw new Error("Expected a bounded regular file");
const buffer = Buffer.alloc(maximum + 1);
let size = 0;
while (size <= maximum) {
const { bytesRead } = await file.read(buffer, size, maximum + 1 - size);
if (bytesRead === 0) break;
size += bytesRead;
}
if (size > maximum) throw new Error("File is too large");
return buffer.subarray(0, size).toString("utf8");
} finally {
await file.close();
}
}

async function prompt(text, hidden = false) {
const output = hidden
? new Writable({
write(_chunk, _encoding, callback) {
callback();
},
})
: process.stderr;
const reader = createInterface({
input: process.stdin,
output,
terminal: true,
});
const abort = () => {
reader.close();
process.exitCode = 130;
};
reader.on("SIGINT", abort);
if (hidden) process.stderr.write(text);
try {
return await reader.question(hidden ? "" : text);
} finally {
reader.close();
if (hidden) process.stderr.write("\n");
}
}

async function main() {
const { positionals, values } = parseArgs({
allowPositionals: true,
options: {
out: { type: "string" },
"key-file": { type: "string" },
mnemonic: { type: "boolean" },
path: { type: "string" },
passphrase: { type: "boolean" },
help: { type: "boolean", short: "h" },
},
});
if (values.help) {
console.log(usage);
return;
}
const [command, file] = positionals;
if (positionals.length !== 2 || !["inspect", "sign"].includes(command))
throw new Error(usage);
if (command === "inspect" && Object.keys(values).length)
throw new Error("inspect accepts only the bundle path");
if (values.mnemonic && !values.path)
throw new Error("--mnemonic requires an explicit --path");
if (values.passphrase && !values.mnemonic)
throw new Error("--passphrase requires --mnemonic");
const text = await readBounded(file, 8 * 1024 * 1024);
const { bundle, review } = inspectSigningBundle(JSON.parse(text));
if (command === "inspect") {
console.log(JSON.stringify(review, null, 2));
return;
}
if (!values.out) throw new Error("sign requires --out");
if (!process.stdin.isTTY || !process.stderr.isTTY)
throw new Error(
"Signing requires an interactive terminal for review and approval",
);
process.stderr.write(`${JSON.stringify(review, null, 2)}\n`);
process.stderr.write(
"Network and tip are indexed context, not proof that inputs are unspent.\n",
);
const fingerprint = createHash("sha256").update(text).digest("hex");
process.stderr.write(`Bundle SHA-256: ${fingerprint}\n`);
const approval = await prompt(
"Check recipients, amounts and fee. Type SIGN to approve: ",
);
if (approval !== "SIGN")
throw new Error("Signing declined; no output written");
const secret = values["key-file"]
? (await readBounded(values["key-file"], 16384)).trim()
: await prompt(
values.mnemonic ? "Recovery phrase (hidden): " : "zprv (hidden): ",
true,
);
const passphrase = values.passphrase
? await prompt("Passphrase (hidden): ", true)
: "";
let key = values.mnemonic
? restoreWallet(secret, passphrase)
: parseExtendedKey(secret);
if (values.path) key = key.derive(values.path);
const signed = signSigningBundle(bundle, key);
const output = await open(values.out, "wx", 0o600);
try {
await output.writeFile(`${JSON.stringify(signed, null, 2)}\n`);
} finally {
await output.close();
}
console.log(
JSON.stringify(
{
output: values.out,
transactionId: inspectSigningBundle(signed).review.transactionId,
fullySigned: true,
},
null,
2,
),
);
}

main().catch((error) => {
process.stderr.write(`nockchain: ${error.message}\n`);
process.exitCode = 1;
});
7 changes: 5 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@swpsco/nockchain",
"version": "0.2.0",
"version": "0.3.0",
"description": "Native TypeScript Nockchain wallet planning, lock policies and transactions",
"type": "module",
"main": "./dist/index.js",
Expand All @@ -13,6 +13,7 @@
},
"files": [
"dist",
"bin",
"src",
"README.md",
"SECURITY.md",
Expand All @@ -36,8 +37,8 @@
"build": "tsc -p tsconfig.json",
"test": "npm run build && node --test test/*.test.mjs",
"prepack": "npm run build",
"check": "tsc --noEmit && prettier --check src test package.json tsconfig.json README.md SECURITY.md",
"format": "prettier --write src test package.json tsconfig.json README.md SECURITY.md"
"check": "tsc --noEmit && prettier --check src test bin package.json tsconfig.json README.md SECURITY.md",
"format": "prettier --write src test bin package.json tsconfig.json README.md SECURITY.md"
},
"dependencies": {
"@noble/hashes": "2.4.0"
Expand All @@ -46,5 +47,8 @@
"esbuild": "0.28.2",
"prettier": "3.9.10",
"typescript": "7.0.2"
},
"bin": {
"nockchain": "./bin/nockchain.mjs"
}
}
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,9 @@ export type { PlanRequest, PlanResult } from "./planner.js";

export { LockV0, NoteV0, noteFirstV0 } from "./migration.js";
export type { NoteRecordV0 } from "./migration.js";
export { inspectSigningBundle, signSigningBundle } from "./signing.js";
export type {
SigningBundleV1,
SigningPayment,
SigningReviewV1,
} from "./signing.js";
Loading
Loading