From 07dbb288c4a59bec9319227e2184cc654225835b Mon Sep 17 00:00:00 2001 From: reid Date: Tue, 20 Oct 2026 14:43:48 -0500 Subject: [PATCH] Add local signing bundles and the nockchain CLI --- .github/workflows/ci.yml | 2 + README.md | 37 ++++ bin/nockchain.mjs | 154 +++++++++++++++ package-lock.json | 7 +- package.json | 10 +- src/index.ts | 6 + src/signing.ts | 310 ++++++++++++++++++++++++++++++ src/transaction.ts | 6 +- test/signing.test.mjs | 181 ++++++++++++++++++ testdata/signing-bundles.json | 351 ++++++++++++++++++++++++++++++++++ 10 files changed, 1056 insertions(+), 8 deletions(-) create mode 100755 bin/nockchain.mjs create mode 100644 src/signing.ts create mode 100644 test/signing.test.mjs create mode 100644 testdata/signing-bundles.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22b46b3..b580261 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 58176dc..54c6e2e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bin/nockchain.mjs b/bin/nockchain.mjs new file mode 100755 index 0000000..b6d34ce --- /dev/null +++ b/bin/nockchain.mjs @@ -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; +}); diff --git a/package-lock.json b/package-lock.json index 9145d6b..7bc2f07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@swpsco/nockchain", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@swpsco/nockchain", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@noble/hashes": "2.4.0" @@ -18,6 +18,9 @@ }, "engines": { "node": ">=22" + }, + "bin": { + "nockchain": "bin/nockchain.mjs" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/package.json b/package.json index ed40797..32a8a04 100644 --- a/package.json +++ b/package.json @@ -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", @@ -13,6 +13,7 @@ }, "files": [ "dist", + "bin", "src", "README.md", "SECURITY.md", @@ -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" @@ -46,5 +47,8 @@ "esbuild": "0.28.2", "prettier": "3.9.10", "typescript": "7.0.2" + }, + "bin": { + "nockchain": "./bin/nockchain.mjs" } } diff --git a/src/index.ts b/src/index.ts index a42814f..e12516c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/signing.ts b/src/signing.ts new file mode 100644 index 0000000..9f25395 --- /dev/null +++ b/src/signing.ts @@ -0,0 +1,310 @@ +import { + NockchainError, + parseDigest, + formatDigest, + digestEqual, + nounEqual, +} from "./core.js"; +import { cue, jam } from "./codec.js"; +import { FeeParameters, ChainContext } from "./fee.js"; +import { ExtendedKey } from "./keys.js"; +import { LockV1, NoteName, NoteV1, newPKHLockV1 } from "./locks.js"; +import { parseTransactionV1, TransactionV1 } from "./transaction.js"; +import { PlannedOutput, plan } from "./planner.js"; +import { SigningKeyLockMatcher } from "./lock-resolver.js"; + +export interface SigningPayment { + readonly address: string; + readonly amountNicks: string; +} +export interface SigningBundleV1 { + readonly format: "nockchain-signing-bundle"; + readonly version: 1; + readonly network: string; + readonly tip: { readonly blockId: string; readonly height: string }; + readonly candidateHeight: string; + readonly sourceAddress: string; + readonly payments: readonly SigningPayment[]; + readonly rawTransaction: string; + readonly inputs: readonly { + readonly name: { readonly firstName: string; readonly lastName: string }; + readonly originPage: string; + readonly amountNicks: string; + readonly noteData: string; + }[]; +} +export interface SigningReviewV1 { + readonly transactionId: string; + readonly network: string; + readonly tip: SigningBundleV1["tip"]; + readonly candidateHeight: string; + readonly sourceAddress: string; + readonly payments: readonly SigningPayment[]; + readonly changeNicks: string; + readonly feeNicks: string; + readonly minimumFeeNicks: string; + readonly inputCount: number; + readonly fullySigned: boolean; +} +function check(value: unknown, message: string): asserts value { + if (!value) throw new NockchainError(message); +} +function object(value: unknown, fields: string): Record { + check( + value !== null && typeof value === "object" && !Array.isArray(value), + "Expected an object", + ); + const allowed = fields.split(" "); + check( + Object.keys(value).every((key) => allowed.includes(key)), + "Unexpected bundle field", + ); + return value as Record; +} +function integer(value: unknown): bigint { + check( + typeof value === "string" && /^(0|[1-9][0-9]{0,19})$/.test(value), + "Expected an unsigned decimal integer", + ); + const result = BigInt(value); + check(result < 1n << 64n, "Integer exceeds u64"); + return result; +} +function address(value: unknown): string { + check( + typeof value === "string" && value.length > 0 && value.length <= 60, + "Invalid digest", + ); + check(formatDigest(parseDigest(value)) === value, "Noncanonical digest"); + return value; +} +function array(value: unknown): unknown[] { + check( + Array.isArray(value) && value.length > 0 && value.length <= 64, + "Array count out of range", + ); + return value; +} +function base64(bytes: Uint8Array): string { + let text = ""; + for (const byte of bytes) text += String.fromCharCode(byte); + return btoa(text); +} +function bytes(value: unknown, maximum: number): Uint8Array { + check( + typeof value === "string" && value.length <= Math.ceil(maximum / 3) * 4, + "Encoded data is too large", + ); + let decoded: Uint8Array; + try { + decoded = Uint8Array.from(atob(value), (c) => c.charCodeAt(0)); + } catch { + throw new NockchainError("Invalid base64"); + } + check( + decoded.length > 0 && + decoded.length <= maximum && + base64(decoded) === value, + "Expected bounded canonical base64", + ); + return decoded; +} +function data(value: unknown) { + const encoded = bytes(value, 65536), + noun = cue(encoded); + check(base64(jam(noun)) === value, "Noncanonical note-data JAM"); + return noun; +} + +/** Validate a data-only bundle against its transaction; indexed context is not a chain proof. */ +export function inspectSigningBundle(value: unknown): { + readonly bundle: SigningBundleV1; + readonly transaction: TransactionV1; + readonly review: SigningReviewV1; +} { + const input = object( + value, + "format version network tip candidateHeight sourceAddress payments rawTransaction inputs", + ); + check( + input.format === "nockchain-signing-bundle" && input.version === 1, + "Unsupported signing bundle", + ); + check( + typeof input.network === "string" && + /^[a-z0-9][a-z0-9_-]{0,63}$/.test(input.network), + "Invalid network", + ); + const tip = object(input.tip, "blockId height"), + tipHeight = integer(tip.height), + height = integer(input.candidateHeight); + address(tip.blockId); + check( + height === tipHeight + 1n, + "Candidate height must follow the indexed tip", + ); + const source = address(input.sourceAddress), + sourceHash = parseDigest(source); + const payments = array(input.payments).map((p) => { + const payment = object(p, "address amountNicks"), + amount = integer(payment.amountNicks); + check(amount > 0n, "Payment must be positive"); + return { + address: address(payment.address), + amountNicks: amount.toString(), + }; + }); + const notes = array(input.inputs).map((entry) => { + const note = object(entry, "name originPage amountNicks noteData"), + name = object(note.name, "firstName lastName"); + return new NoteV1({ + name: new NoteName( + parseDigest(address(name.firstName)), + parseDigest(address(name.lastName)), + ), + originPage: integer(note.originPage), + nicks: integer(note.amountNicks), + noteData: data(note.noteData), + }); + }); + const encoded = bytes(input.rawTransaction, 262144); + const transaction = parseTransactionV1(encoded, { + fees: new FeeParameters({ height }), + }); + check( + base64(transaction.jam()) === input.rawTransaction, + "Noncanonical transaction JAM", + ); + transaction.validateInputs(notes, height); + transaction.verifySignatures({ requireThreshold: false }); + const summary = transaction.summary(); + const planned = plan( + { + candidates: notes, + selection: notes.map((note) => note.name), + outputs: payments.map((payment) => + PlannedOutput.payment( + parseDigest(payment.address), + BigInt(payment.amountNicks), + ), + ), + refund: PlannedOutput.payment(sourceHash, 0n), + chain: ChainContext.atHeight(height), + }, + new SigningKeyLockMatcher(sourceHash), + ); + check( + summary.feeNicks === planned.finalFee, + "Bundle fee differs from the local plan", + ); + const plannedSpends = new Map( + planned.transaction + .summary() + .spends.map((spend) => [spend.name.key(), spend.signatureHash]), + ); + for (const spend of summary.spends) { + const expectedHash = plannedSpends.get(spend.name.key()); + check( + expectedHash && digestEqual(spend.signatureHash, expectedHash), + "Transaction differs from the local payment plan", + ); + } + const expected = new Map< + string, + { address: string; amount: bigint; lock: LockV1 } + >(); + for (const payment of [...payments, { address: source, amountNicks: "0" }]) { + const lock = newPKHLockV1(1, [parseDigest(payment.address)]), + root = formatDigest(lock.root); + const entry = expected.get(root) ?? { + address: payment.address, + amount: 0n, + lock, + }; + entry.amount += BigInt(payment.amountNicks); + check(entry.amount < 1n << 64n, "Payment total overflow"); + expected.set(root, entry); + } + const actual = new Map(); + for (const spend of summary.spends) { + check( + spend.proof !== null && + spend.lock instanceof LockV1 && + spend.lock.threshold === 1 && + spend.lock.authorizes(sourceHash) && + spend.outputs.length > 0, + "Input is not controlled by the source address", + ); + for (const output of spend.outputs) { + const root = formatDigest(output.lockRoot), + target = expected.get(root); + check( + target && nounEqual(output.noteData, target.lock.metadata()), + "Undeclared output or unexpected output metadata", + ); + actual.set(root, (actual.get(root) ?? 0n) + output.nicks); + } + } + let change = 0n; + for (const [root, target] of expected) { + const amount = actual.get(root) ?? 0n; + if (target.address === source) { + check(amount >= target.amount, "Source payment amount mismatch"); + change = amount - target.amount; + } else check(amount === target.amount, "Payment amount mismatch"); + } + // Copy only the validated contract fields; callers cannot mutate the reviewed input. + const bundle = JSON.parse( + JSON.stringify({ + format: input.format, + version: input.version, + network: input.network, + tip: { blockId: tip.blockId, height: tip.height }, + candidateHeight: input.candidateHeight, + sourceAddress: source, + payments, + rawTransaction: input.rawTransaction, + inputs: input.inputs, + }), + ) as SigningBundleV1; + return { + bundle, + transaction, + review: { + transactionId: formatDigest(summary.id), + network: bundle.network, + tip: bundle.tip, + candidateHeight: bundle.candidateHeight, + sourceAddress: source, + payments, + changeNicks: change.toString(), + feeNicks: summary.feeNicks.toString(), + minimumFeeNicks: summary.minimumFeeNicks.toString(), + inputCount: notes.length, + fullySigned: summary.spends.every( + (spend) => spend.signedKeys.length === spend.lock.threshold, + ), + }, + }; +} + +/** Revalidate before signing and return a bundle suitable for inspection or submission. */ +export function signSigningBundle( + value: unknown, + key: ExtendedKey, +): SigningBundleV1 { + const { bundle, transaction } = inspectSigningBundle(value); + check( + key.isPrivate && + digestEqual( + parseDigest(key.address()), + parseDigest(bundle.sourceAddress), + ), + "Signing key does not match source address", + ); + const signed = transaction.sign(key); + signed.verifySignatures(); + const result = { ...bundle, rawTransaction: base64(signed.jam()) }; + inspectSigningBundle(result); + return result; +} diff --git a/src/transaction.ts b/src/transaction.ts index 7e21341..f98b1fc 100644 --- a/src/transaction.ts +++ b/src/transaction.ts @@ -360,9 +360,9 @@ export class TransactionV1 { } } } - /** Check exact thresholds, every signature, authorization and minimum fee. */ - verifySignatures(): void { - this.#verify(true); + /** Check signatures, authorization and minimum fee; optionally allow missing signatures. */ + verifySignatures({ requireThreshold = true } = {}): void { + this.#verify(requireThreshold); const review = this.summary(); if (review.feeNicks < review.minimumFeeNicks) throw new NockchainError("Fee below minimum"); diff --git a/test/signing.test.mjs b/test/signing.test.mjs new file mode 100644 index 0000000..65e07c8 --- /dev/null +++ b/test/signing.test.mjs @@ -0,0 +1,181 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + readFileSync, + mkdtempSync, + writeFileSync, + existsSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import * as n from "../dist/index.js"; + +const fixture = JSON.parse( + readFileSync(new URL("../testdata/signing-bundles.json", import.meta.url)), +); +const key = n.master(Uint8Array.from(Buffer.from(fixture.seed, "hex"))); +const sample = fixture.cases[0].response.bundle; +const copy = (value = sample) => structuredClone(value); +const encode = (bytes) => Buffer.from(bytes).toString("base64"); +const note = (record) => + new n.NoteV1({ + name: new n.NoteName( + n.parseDigest(record.name.firstName), + n.parseDigest(record.name.lastName), + ), + originPage: BigInt(record.originPage), + nicks: BigInt(record.amountNicks), + noteData: n.cue(Buffer.from(record.noteData, "base64")), + }); + +test("Rust bundles produce identical locally reviewed and signed transactions", () => { + for (const entry of fixture.cases) { + const { bundle, review } = entry.response; + const checked = n.inspectSigningBundle(bundle); + assert.equal( + checked.review.transactionId, + review.transactionId, + entry.name, + ); + assert.equal(checked.review.feeNicks, review.feeNicks, entry.name); + assert.equal( + checked.review.minimumFeeNicks, + review.minimumFeeNicks, + entry.name, + ); + assert.equal(checked.review.fullySigned, false); + const signed = n.signSigningBundle(bundle, key); + assert.equal(signed.rawTransaction, entry.signedRawTransaction, entry.name); + const signedReview = n.inspectSigningBundle(signed).review; + assert.equal(signedReview.transactionId, entry.signedTransactionId); + assert.equal(signedReview.fullySigned, true); + assert.notEqual(signedReview.transactionId, checked.review.transactionId); + } +}); + +test("bundle validation rejects altered intent, input commitments and unsafe encoding", () => { + for (const mutate of [ + (b) => { + b.payments[0].amountNicks = "999999"; + }, + (b) => { + b.payments[0].address = n.formatDigest(n.digest([1n, 2n, 3n, 4n, 5n])); + }, + (b) => { + b.inputs[0].amountNicks = "40000001"; + }, + (b) => { + b.inputs[0].originPage = "53899"; + }, + (b) => { + b.inputs[0].noteData = encode(n.jam(0n)); + }, + (b) => { + b.sourceAddress = b.payments[0].address; + }, + (b) => { + b.rawTransaction += "\n"; + }, + (b) => { + b.rawTransaction = encode( + Uint8Array.from([...Buffer.from(b.rawTransaction, "base64"), 0]), + ); + }, + (b) => { + b.candidateHeight = "18446744073709551616"; + }, + (b) => { + b.payments[0].amountNicks = "01"; + }, + (b) => { + b.inputs = []; + }, + (b) => { + b.inputs[0].privateKey = "unexpected"; + }, + (b) => { + b.network = "mainnet\nPAY SOMEBODY ELSE"; + }, + ]) { + const bundle = copy(); + mutate(bundle); + assert.throws(() => n.inspectSigningBundle(bundle)); + } + const wrongKey = n.master(new Uint8Array(64).fill(42)); + assert.throws(() => n.signSigningBundle(sample, wrongKey), /source address/); + assert.throws( + () => n.signSigningBundle(sample, key.public()), + /source address/, + ); +}); + +test("a canonical transaction with a valid input commitment cannot raise the planned fee", () => { + const bundle = copy(), + checked = n.inspectSigningBundle(bundle); + const spend = checked.transaction.summary().spends[0]; + const sourceRoot = n.newPKHLockV1(1, [ + n.parseDigest(bundle.sourceAddress), + ]).root; + const changed = n.prepareTransactionV1( + [ + { + note: note(bundle.inputs[0]), + lock: spend.lock, + fee: spend.fee + 1n, + outputs: spend.outputs.map((output) => ({ + lock: output.lock(), + noteData: output.noteData, + nicks: + output.nicks - + (n.digestEqual(output.lockRoot, sourceRoot) ? 1n : 0n), + })), + }, + ], + { fees: new n.FeeParameters({ height: BigInt(bundle.candidateHeight) }) }, + ); + changed.validateInputs( + bundle.inputs.map(note), + BigInt(bundle.candidateHeight), + ); + changed.verifySignatures({ requireThreshold: false }); + bundle.rawTransaction = encode(changed.jam()); + assert.throws(() => n.inspectSigningBundle(bundle), /fee differs/); +}); + +test("CLI inspects without secrets and refuses signing without interactive approval", () => { + const dir = mkdtempSync(join(tmpdir(), "nockchain-cli-")); + try { + const input = join(dir, "bundle.json"), + out = join(dir, "signed.json"); + writeFileSync(input, JSON.stringify(sample)); + const cli = new URL("../bin/nockchain.mjs", import.meta.url).pathname; + const inspected = spawnSync(process.execPath, [cli, "inspect", input], { + encoding: "utf8", + }); + assert.equal(inspected.status, 0, inspected.stderr); + assert.equal( + JSON.parse(inspected.stdout).transactionId, + fixture.cases[0].response.review.transactionId, + ); + const rejected = spawnSync( + process.execPath, + [ + cli, + "sign", + input, + "--out", + out, + "--key-file", + join(dir, "missing-key"), + ], + { encoding: "utf8" }, + ); + assert.notEqual(rejected.status, 0); + assert.match(rejected.stderr, /interactive terminal/); + assert.equal(existsSync(out), false); + } finally { + rmSync(dir, { recursive: true }); + } +}); diff --git a/testdata/signing-bundles.json b/testdata/signing-bundles.json new file mode 100644 index 0000000..d84412e --- /dev/null +++ b/testdata/signing-bundles.json @@ -0,0 +1,351 @@ +{ + "cases": [ + { + "name": "payment", + "response": { + "bundle": { + "candidateHeight": "54000", + "format": "nockchain-signing-bundle", + "inputs": [ + { + "amountNicks": "40000000", + "name": { + "firstName": "9hh3S3QNP7xctoQiQuVfnSAzKd69r3a8u5DVaXJCxsmb9JAXkgPqhbc", + "lastName": "2V9arU36gvtaofWmNowewoj9u7gbNA2qsJZEQ3WPky5mQ" + }, + "noteData": "BXy2t7F1FvDg1tBxAX4zE87MMa2MdIC+VgHIs3W1Tgwg0KXhGQlXJUAZQMDhlqjSQCdYE/ATDlS1aGyJElOHSQ8=", + "originPage": "53900" + } + ], + "network": "testnet", + "payments": [ + { + "address": "4GXV5RgR7eMh44rTxKP1EJEjJ9Tygdv3DckgscLTdJ46x", + "amountNicks": "1000000" + } + ], + "rawTransaction": "cQF+xl/KQfXZ8G8Agf1j+1tgDtfjAALXC/wo1/1R/AH6Yg2OKdiDLRAwdxoKotN50q0CCDDjhRO71q7pBxDQr7mWQhdB3A4goGRPjR+RJ9EYQIBjE880DVo+GiCwJLrCnR4D8locMnSYuMYV8M3q2NgV8ODW0HEBfjMTzswxrYx0gL5WAcizdbVODCDQpeEZCVclQBlAwOGWqNJAJ1gT8BMOVLVobIkSU+MCCLC57YJsF31cBxCYDOvTJ1rHAg3w94uAOsSX7ssD/Ar1M4bEa8BkgIDMOO4WeLYlvjlcUCsLIIDNQUVSvrRmHEBgf18asTeVYTWAwDafe5gcBQVyAAETth1P12RDQoBACZUN+hqAgWoFfLa3sXXWUI7HhR8Eg2QQhexwQXW4oAYkIKEPIDBp72DelckeH0CAhhREKLkoFj3Ar+4GsYVtLQYOIPDm7VbG0feYDBDgCVwYSXhwD69suKhWQ3JiDqX4cEENqMAXSQ45l8MFVSABAOhwQQU=", + "sourceAddress": "4Qo7o9rHwWr1qSKFdvvDe4DvJNsG2hJMnUwPXfExN7NyvkZn7ihJbiq", + "tip": { + "blockId": "5TSkZQ7J4nfmDfkGLf1EkdF7ZVzEZdAqTADfBzDqYBNKz", + "height": "53999" + }, + "version": 1 + }, + "review": { + "feeNicks": "655360", + "minimumFeeNicks": "655360", + "outputNicks": "39344640", + "spends": [ + { + "feeNicks": "655360", + "name": { + "firstName": "9hh3S3QNP7xctoQiQuVfnSAzKd69r3a8u5DVaXJCxsmb9JAXkgPqhbc", + "lastName": "2V9arU36gvtaofWmNowewoj9u7gbNA2qsJZEQ3WPky5mQ" + }, + "outputs": [ + { + "amountNicks": "1000000", + "lockRoot": "9SzCP28uJkJqgcvWcqjE89ePY2CFiDMhyDwLXoVysp2JjrdrA85pbRA", + "noteData": "BXy2t7F1FvDg1tBx4QfBIBlEIZs6BCc=" + }, + { + "amountNicks": "38344640", + "lockRoot": "DMQNTMaozdKTP61H83hgQwvddLTf7vuJYzQNCAEFkwBVzazG3H3g2ym", + "noteData": "BXy2t7F1FvDg1tBxAX4zE87MMa2MdIC+VgHIs3W1Tgwg0KXhGQlXJUAZQMDhlqjSQCdYE/ATDlS1aGyJElOHSQ8=" + } + ], + "requiredSignatures": 1, + "signatureHash": "5fUpxwrn8GreFVfHx8fvEbhGfit8cTQM91oQx53eF3PbeLyuMPR8xyB", + "signerHashes": [] + } + ], + "transactionId": "5CHwa8ERbQu1HMDPqhqq14hTg1fh8pobHLhqPrUAvQ6uoKnLcKyBFm" + } + }, + "signedRawTransaction": "cQEEOm5S+19opgwDCFi2XIUsESp8BhCgLWQd47xazg8gQA+Rhsp2oikPEKD5LocLbiQPrQIIMOOFE7vWrukHENCvuZZCF0HcDiCgZE+NH5En0RhAgGMTzzQNWj4aILAkusKdHgPyWhwydJi4xhXwzerY2BXw4NbQcQF+MxPOzDGtjHSAvlYByLN1tU4MINCl4RkJVyVAGUDA4Zao0kAnWBPwEw5UtWhsiRJT4wIIsLntgmwXfVwHEJgM69MnWscCDfD3i4A6xJfuywP8CvUzhsRrwGSAgMw47hZ4tiW+1VDIV4DeRZqMHIzlPA0gsDudNF1LgFoaoIcO/8kbX3oxAwhgpm+MNL4RJQf41UW5eh8lb40AAdBKzd+jEv3CAuTB8GVQ589AGUBArLWaXBwwGT2AwLhiyRoEs95ngBx/FKTCzzz4AwjcbwRtA35bZwN639trYq6uzJ0L6AwdbnbA51vdUwf8u7SccIBgQ04jNmBnU+LOAAEy9tdnQH9A0R+BGrMpYMDX59qaBgiSf4gkAwQowt3RAYLx8tHMAIG8KyFtgMDoUoI1IKcXmjLQg5q3weGiOlxUKwsggM1BRVK+tGYcQGB/XxqxN5VhNYDANp97mBwFBXIAARO2HU/XZENCgEAJlQ36GoCBagV8trexddZQnseFHwSDZBCF7HBRdbioBiQgoQ8gMGnvYN6VyR4fQICGFEQouSgWPcCv7gaxhW0tBg4g8ObtVsbR95gMEOAJXBhJeHAPr2y4sFZDUn0OpflwUQ2owBdJDrsCh4uqQAIA0OGiCg==", + "signedTransactionId": "93ZAj9rCGuCBDouu5GGjwEZ45V66tjWUVTm9swmEuxh2ASp4NsV5DRG" + }, + { + "name": "preBythos", + "response": { + "bundle": { + "candidateHeight": "53999", + "format": "nockchain-signing-bundle", + "inputs": [ + { + "amountNicks": "40000000", + "name": { + "firstName": "9hh3S3QNP7xctoQiQuVfnSAzKd69r3a8u5DVaXJCxsmb9JAXkgPqhbc", + "lastName": "2V9arU36gvtaofWmNowewoj9u7gbNA2qsJZEQ3WPky5mQ" + }, + "noteData": "BXy2t7F1FvDg1tBxAX4zE87MMa2MdIC+VgHIs3W1Tgwg0KXhGQlXJUAZQMDhlqjSQCdYE/ATDlS1aGyJElOHSQ8=", + "originPage": "53900" + } + ], + "network": "testnet", + "payments": [ + { + "address": "4GXV5RgR7eMh44rTxKP1EJEjJ9Tygdv3DckgscLTdJ46x", + "amountNicks": "1000000" + } + ], + "rawTransaction": "cQEEtgNHjZwvc/MD7B12SIbZEJU5wB6aAiwqzseGA/zkp1P92+XfQYAAUol5aASWYHwVQIAZL5zYtXZNP4CAfs21FLoI4nYAASV7avyIPInGAAIcm3imadDy0QCBJdEV7vQYkNfikKHDxDWuCnhwa+i4AL+ZCWfmmFZGOkBfqwDk2bpaJwYQ6NLwjISrEqAMIOBwS1RpoBOsCfgJB6paNLZEialxAQTY3HZBtos+rgMITIb16ROtY4EG+PtFQB3iS/flAX6F+hlD4jVgMkBAZhx3CzzbEt8cDqOVBRDA5qAiKV9aMw4gsL8vjdibyrAaQGCbzz1MjoICOYCACduOp2uyISFAoITKBn0NwEC1Aj7b29g6a6iy48IPgkEyiEJ2OIw6HEYDEpDQBxCYtHcw78pkjw8gQEMKIpRcFIse4Fd3g9jCthYDBxB483Yr4+h7TAYI8AQujCQ8uIdn2XAorYacwxwq7OEwGlCBL1UcUimHwyjQAADR4TAK", + "sourceAddress": "4Qo7o9rHwWr1qSKFdvvDe4DvJNsG2hJMnUwPXfExN7NyvkZn7ihJbiq", + "tip": { + "blockId": "5TSkZQ7J4nfmDfkGLf1EkdF7ZVzEZdAqTADfBzDqYBNKz", + "height": "53998" + }, + "version": 1 + }, + "review": { + "feeNicks": "2654208", + "minimumFeeNicks": "2654208", + "outputNicks": "37345792", + "spends": [ + { + "feeNicks": "2654208", + "name": { + "firstName": "9hh3S3QNP7xctoQiQuVfnSAzKd69r3a8u5DVaXJCxsmb9JAXkgPqhbc", + "lastName": "2V9arU36gvtaofWmNowewoj9u7gbNA2qsJZEQ3WPky5mQ" + }, + "outputs": [ + { + "amountNicks": "36345792", + "lockRoot": "DMQNTMaozdKTP61H83hgQwvddLTf7vuJYzQNCAEFkwBVzazG3H3g2ym", + "noteData": "BXy2t7F1FvDg1tBxAX4zE87MMa2MdIC+VgHIs3W1Tgwg0KXhGQlXJUAZQMDhlqjSQCdYE/ATDlS1aGyJElOHSQ8=" + }, + { + "amountNicks": "1000000", + "lockRoot": "9SzCP28uJkJqgcvWcqjE89ePY2CFiDMhyDwLXoVysp2JjrdrA85pbRA", + "noteData": "BXy2t7F1FvDg1tBx4QfBIBlEIZs6BCc=" + } + ], + "requiredSignatures": 1, + "signatureHash": "B6e9i5S5SDozud3bsUZ1BwUNxjgg2nuRJWXCuNed5gMGPQbxeqyp3GG", + "signerHashes": [] + } + ], + "transactionId": "CzzwVULSbVfwjGZW3WBGCw45KAESgxi2HVCcYN9VAbzfsridd1eqW4t" + } + }, + "signedRawTransaction": "cQH+yp74Jf1wFHaAv3SboD/z4ToeQGBECgDh2xQ8M4CAq2pDaYTi6jRAgEgIK03RxHq6CiDAjBdO7Fq7ph9AQL/mWgpdBHE7gICSPTV+RJ5EYwABjk080zRo+WiAwJLoCnd6DMhrccjQYeIaVwU8uDV0XIDfzIQzc0wrIx2gr1UA8mxdrRMDCHRpeEbCVQlQBhBwuCWqNNAJ1gT8hANVLRpbosTUuAACbG67INtFH9cBBCbD+vSJ1rFAA/z9IqAO8aX78gC/Qv2MIfEaMBkgIDOOuwWebYlvNZTlFaB3kSYjB2M5TwMI7E4nTdcSoJYG6KHDf/LGl17MAAKY6RsjjW9EyQF+dVGu3kfJWyNAALRS8/eoRL+wAHkwfBnU+TNQBhAQa60mFwdMRg8gMK5YsgbBrPcZIMcfBanwMw/+AAL3G0HbgN/W2YDe9/aamKsrc+cC/oHHoDHgL3aMUwO+FAfIM0CQRBuIGNBfllDVAAEtrNd4gADlKzwW2Om7BTDgey7DwwP61uWTHfAP8I6fAR1+eX0MEJQGvS8HCOipRGgD7gvTW4HeE33ED/fR4T5aWQABbA4qkvKlNeMAAvv70oi9qQyrAQS2+dzD5CgokAMImLDteLomGxICBEqobNDXAAxUK+CzvY2ts4ZiOy78IBgkgyhkh/uow300IAEJfQCBSXsH865M9vgAAjSkIELJRbHoAX51N4gtbGsxcACBN2+3Mo6+x2SAAE/gwkjCg3t4lg330mrIj8+h0B7uowEV+FLFIe1/uI8CDQAQHe6j", + "signedTransactionId": "BXVCVPBpQwSqfnCjZFCNdUJvYW4Qt84TezC4YwMMz4gut4iUeEMPB6v" + }, + { + "name": "coinbase", + "response": { + "bundle": { + "candidateHeight": "54000", + "format": "nockchain-signing-bundle", + "inputs": [ + { + "amountNicks": "40000000", + "name": { + "firstName": "4UnFAPYyN7PrDBUmExuy4MnhNS51TV1Xscm2saL7sgiXdhJC2tKFPs7", + "lastName": "2V9arU36gvtaofWmNowewoj9u7gbNA2qsJZEQ3WPky5mQ" + }, + "noteData": "Ag==", + "originPage": "53900" + } + ], + "network": "testnet", + "payments": [ + { + "address": "4GXV5RgR7eMh44rTxKP1EJEjJ9Tygdv3DckgscLTdJ46x", + "amountNicks": "1000000" + } + ], + "rawTransaction": "cQEEpCz/GtkA8JADCHD0n7AM1kpbB+iX6rlfpn3M9gD/6EOmPgJYkxcgYGvz5+zbRtVaBYgxEfLCkpY2A/RjT5JlRFnIcoD8UlMTtgn6OAMIoItvyUDbQtYDfvSk1qfsKwJjccjQYeIaV8A3q2NjV8CDW0PHBfjNTDgzx7Qy0gH6WgUgz9bVOjGAQJeGZyRclQBlAAGHW6JKA51gTcBPOFDVorElSkwLeHRp7Uo42eFmalwAgcsbqegFDTzCAL26qVqZhySSHkAAa/o1CBdfUD+AwKLOpkb7Fd85oMc8UG4vQxNrzeFmWlkAATa3XZDtoo/rAAKTYX36ROtYoAH+fhFQh/jSfXmAX6F+xpB4DZgMEJAZx90Cz7bEr4DP9ja2zoby2+FmGlCBb5EMsLuxonVqRNHoAAJFI9Z0pzS/hAF+G7NGFKiGpX8AgYwbYxZrM1VSgIDrSdKXFNOPcWUBBLA5qEjKl9aMAwjs70sj9qYyrAYQ2OZzD5OjoEAOIGDCtuPpmmxICBAoobJBXwMwUK2GzMasoQCPCz8IBskgCtnhZupwMw1IQEI/5FYON1OBBACmw80U", + "sourceAddress": "4Qo7o9rHwWr1qSKFdvvDe4DvJNsG2hJMnUwPXfExN7NyvkZn7ihJbiq", + "tip": { + "blockId": "5TSkZQ7J4nfmDfkGLf1EkdF7ZVzEZdAqTADfBzDqYBNKz", + "height": "53999" + }, + "version": 1 + }, + "review": { + "feeNicks": "679936", + "minimumFeeNicks": "679936", + "outputNicks": "39320064", + "spends": [ + { + "feeNicks": "679936", + "name": { + "firstName": "4UnFAPYyN7PrDBUmExuy4MnhNS51TV1Xscm2saL7sgiXdhJC2tKFPs7", + "lastName": "2V9arU36gvtaofWmNowewoj9u7gbNA2qsJZEQ3WPky5mQ" + }, + "outputs": [ + { + "amountNicks": "38320064", + "lockRoot": "DMQNTMaozdKTP61H83hgQwvddLTf7vuJYzQNCAEFkwBVzazG3H3g2ym", + "noteData": "BXy2t7F1FvDg1tBxAX4zE87MMa2MdIC+VgHIs3W1Tgwg0KXhGQlXJUAZQMDhlqjSQCdYE/ATDlS1aGyJElOHSQ8=" + }, + { + "amountNicks": "1000000", + "lockRoot": "9SzCP28uJkJqgcvWcqjE89ePY2CFiDMhyDwLXoVysp2JjrdrA85pbRA", + "noteData": "BXy2t7F1FvDg1tBx4QfBIBlEIZs6BCc=" + } + ], + "requiredSignatures": 1, + "signatureHash": "GWWwGjZZUPzEuWozPhAoiCQfDG43Ly2SfT3qHwgnAhidJDS18wZw5n", + "signerHashes": [] + } + ], + "transactionId": "9cWrFnLkznRDxKCW1ygwHYyf3uzTeC6954K2DS527zg2nkwoKymnBQm" + } + }, + "signedRawTransaction": "cQEEHlYhGcIebb0D/KT5lwO0LbT/AAJ1eCnSOR4/zAHuOHozBKtvRgIEknBR/kEYqLerADEmQl5Y0tJmgH7sSbKMKAtZDpBfamrCNkEfZwABdPEtGWhbyHrAj57U+pR9RWAsDhk6TFzjCvhmdWzsCnhwa+i4AL+ZCWfmmFZGOkBfqwDk2bpaJwYQ6NLwjISrEqAMIOBwS1RpoBOsCfgJB6paNLZEiWkBjy6tXQknOxxMjQsgcHkjFb2ggUcYoFc3VSvzkETSAwhgTb8G4eIL6gcQWNTZ1Gi/4jsH9JgHyu1laGKt1VCnV4DeRZqMHIzlPA0gsDudNF1LgFoaoIcO/8kbX3oxAwhgpm+MNL4RJQf41UW5eh8lb40AAdBKzd+jEv3CAuTB8GVQ589AGUBArLWaXBwwGT2AwLhiyRoEs95ngBx/FKTCzzz4AwjcbwRtA35bZwN639trYq6uzJ0LEGCW6zUH/B9vPWRAp7oulQP6n9KzGPDxWpbwAd3UktIMEEDo6joD+zCX+AZ8o/qCY0C7oAEDBCMCJqUBP2edEBrwM1bPjwH9XVZ6DBCQnwH/A3sBpFgOB9PhYFpZAAE2t12Q7aKP6wACk2F9+kTrWKAB/n4RUIf40n15gF+hfsaQeA2YDBCQGcfdAs+2xK+Az/Y2ts6G2tvhYBpQgW+RDLC7saJ1akTR6AACRSPWdKc0v4QBfhuzRhSohqV/AIGMG2MWazNVUoCA60nSlxTTj3FlAQSwOahIypfWjAMI7O9LI/amMqwGENjmcw+To6BADiBgwrbj6ZpsSAgQKKGyQV8DMFCthoz7rKH6jgs/CAbJIArZ4WDqcDANSEBCP+T8DwdTgQQApsPBFA==", + "signedTransactionId": "BvX1qVDqRSeYV8freywiZr6bRzGYHazV99Dx1MXAm9fQtiG58n4Xjn9" + }, + { + "name": "sweep", + "response": { + "bundle": { + "candidateHeight": "54000", + "format": "nockchain-signing-bundle", + "inputs": [ + { + "amountNicks": "40000000", + "name": { + "firstName": "9hh3S3QNP7xctoQiQuVfnSAzKd69r3a8u5DVaXJCxsmb9JAXkgPqhbc", + "lastName": "2V9arU36gvtaofWmNowewoj9u7gbNA2qsJZEQ3WPky5mQ" + }, + "noteData": "BXy2t7F1FvDg1tBxAX4zE87MMa2MdIC+VgHIs3W1Tgwg0KXhGQlXJUAZQMDhlqjSQCdYE/ATDlS1aGyJElOHSQ8=", + "originPage": "53900" + }, + { + "amountNicks": "5000000", + "name": { + "firstName": "9hh3S3QNP7xctoQiQuVfnSAzKd69r3a8u5DVaXJCxsmb9JAXkgPqhbc", + "lastName": "2V9arU36gvtaofWmNowewoj9u7gbNA2qsJZEQ3WPky5mR" + }, + "noteData": "BXy2t7F1FvDg1tBxAX4zE87MMa2MdIC+VgHIs3W1Tgwg0KXhGQlXJUAZQMDhlqjSQCdYE/ATDlS1aGyJElOHSQ8=", + "originPage": "53900" + } + ], + "network": "testnet", + "payments": [ + { + "address": "4GXV5RgR7eMh44rTxKP1EJEjJ9Tygdv3DckgscLTdJ46x", + "amountNicks": "44328256" + } + ], + "rawTransaction": "cQEEzjEjU+7XiTMD/GKgWlP/sk7JAAKV6EmtR0W7xwEEwlywrzGC7nMBAill3BsfR6bRVQABZrxwYtfaNf0AAvo111LoIojbAQSU7KnxI/IkGgMIcGzimaZBy0cDBJZEV7jTY0Bei0OGDhPXuAK+WR0buwIe3Bo6LsBvZsKZOaaVkQ7Q1yoAebau1okBBLo0PCPhqgQoAwg43BJVGugEawJ+woGqFo0tUWJqXAABNrddkO2ij+sAApNhffpE61igAf5+EVCH+NJ9eYBfoX7GkHgNmAwQkBnH3QLPtsQ3h3NqZQEEsDmoSMqX1owDCOzvSyP2pjKsBhDY5nMPk6OgQA4gYMK24+mabEgIECihskFfAzBQrYDP9ja2zhpq87jwg2CQDKKQHc6pDufUgAqggWUAgUl7B/OuTPb4AAI0pCBCyUWx6AF+dTeILWxrMXAAgTdvtzKOvsdkgABP4MJIwoN7+HBOBRIAkGY1jOdCDpWzcUOdXdlwEG/IUgx4QEvMAALeYx65mU83yQF2JcPePofi1x3glwaOsaYjttwHEGCyXz/DeNd+Bwg4rqvkAF4N2g7nVIdzKg==", + "sourceAddress": "4Qo7o9rHwWr1qSKFdvvDe4DvJNsG2hJMnUwPXfExN7NyvkZn7ihJbiq", + "tip": { + "blockId": "5TSkZQ7J4nfmDfkGLf1EkdF7ZVzEZdAqTADfBzDqYBNKz", + "height": "53999" + }, + "version": 1 + }, + "review": { + "feeNicks": "671744", + "minimumFeeNicks": "671744", + "outputNicks": "44328256", + "spends": [ + { + "feeNicks": "0", + "name": { + "firstName": "9hh3S3QNP7xctoQiQuVfnSAzKd69r3a8u5DVaXJCxsmb9JAXkgPqhbc", + "lastName": "2V9arU36gvtaofWmNowewoj9u7gbNA2qsJZEQ3WPky5mR" + }, + "outputs": [ + { + "amountNicks": "5000000", + "lockRoot": "9SzCP28uJkJqgcvWcqjE89ePY2CFiDMhyDwLXoVysp2JjrdrA85pbRA", + "noteData": "BXy2t7F1FvDg1tBx4QfBIBlEIZs6BCc=" + } + ], + "requiredSignatures": 1, + "signatureHash": "5c41MEKHoXkgBsLtPDD5VPmLyrZjTA4fDb287jN1Rk7aWJ2UtAZwuTN", + "signerHashes": [] + }, + { + "feeNicks": "671744", + "name": { + "firstName": "9hh3S3QNP7xctoQiQuVfnSAzKd69r3a8u5DVaXJCxsmb9JAXkgPqhbc", + "lastName": "2V9arU36gvtaofWmNowewoj9u7gbNA2qsJZEQ3WPky5mQ" + }, + "outputs": [ + { + "amountNicks": "39328256", + "lockRoot": "9SzCP28uJkJqgcvWcqjE89ePY2CFiDMhyDwLXoVysp2JjrdrA85pbRA", + "noteData": "BXy2t7F1FvDg1tBx4QfBIBlEIZs6BCc=" + } + ], + "requiredSignatures": 1, + "signatureHash": "3dFT81j6dw5Bfy5Yhxo9jBowcwoMktGHeQkb8VP4xqZp5eA2RV7KYnu", + "signerHashes": [] + } + ], + "transactionId": "BRBeKVCxvhnUiCEAPyhMhRqdYQ86zjW3BM6dHdrQoFXfhPLN8GpiMxj" + } + }, + "signedRawTransaction": "cQH+4/2Qsn0pS36AvTviLzdZlLYG+NcyO0vhl9GkAQSKkxINeJkuPwECweNLswTUHahVAAFmvHBi19o1/QAC+jXXUugiiNsBBJTsqfEj8iQaAwhwbOKZpkHLRwMElkRXuNNjQF6LQ4YOE9e4Ar5ZHRu7Ah7cGjouwG9mwpk5ppWRDtDXKgB5tq7WiQEEujQ8I+GqBCgDCDjcElUa6ARrAn7CgaoWjS1RYmpcAAE2t12Q7aKP6wACk2F9+kTrWKAB/n4RUIf40n15gF+hfsaQeA2YDBCQGcfdAs+2xLcaqvcK0LtIk5GDsZynAQR2p5OmawlQSwP00OE/eeNLL2YAAcz0jZHGN6LkAL+6KFfvo+StESAAWqn5e1SiX1iAPBi+DOr8GSgDCIi1VpOLAyajBxAYVyxZg2DW+wyQ44+CVPiZB38AgfuNoG3Ab+tsQO97e03M1ZW5cwEbllKUBggAy4RAAwQ57bT+AQKEBufbgO8+hVsPECzDb8gG/K2tSTDw0YIOjgEbTqgVBwj4eDNUAwS+2vbVAd0VVokO6OWmMGnAF0KYKgds+GbGDPyXpHPhcEYdzqiVBRDA5qAiKV9aMw4gsL8vjdibyrAaQGCbzz1MjoICOYCACduOp2uyISFAoITKBn0NwEC1Aj7b29g6a6jJ48IPgkEyiEJ2OKM6nFEDKoAGlgEEJu0dzLsy2eMDCNCQggglF8WiB/jV3SC2sK3FwAEE3rzdyjj6HpMBAjyBCyMJD+7hwxkVSABAmtUwlgs5VMzGNVTY1VC9N1zAC9gb/tg7oO/X1cABgly4BNIAAa75Un7A5yCubAd8SAmpeUB2rH5HoHtPs9ABgngal8kAwWJYq2bAriIFzQECXtQZ3QBBxTPmfEAnQ/DcA76mzvoT+LFYysjhjDqcUSsbUrYb8ugDHtASM4CA95hHbubTTXKAXcmwt8+h+HUH+KWBY6zpiC33AQSY7NfPMN613wECjusqOYBXg7bDGdXhjAo=", + "signedTransactionId": "9MVTSAonh3nZBGhVaweZTWz7FGBQULPWXHyZPwft4V98k8ufWN1ibzU" + }, + { + "name": "feeRemainder", + "response": { + "bundle": { + "candidateHeight": "54000", + "format": "nockchain-signing-bundle", + "inputs": [ + { + "amountNicks": "1450000", + "name": { + "firstName": "9hh3S3QNP7xctoQiQuVfnSAzKd69r3a8u5DVaXJCxsmb9JAXkgPqhbc", + "lastName": "2V9arU36gvtaofWmNowewoj9u7gbNA2qsJZEQ3WPky5mQ" + }, + "noteData": "BXy2t7F1FvDg1tBxAX4zE87MMa2MdIC+VgHIs3W1Tgwg0KXhGQlXJUAZQMDhlqjSQCdYE/ATDlS1aGyJElOHSQ8=", + "originPage": "53900" + } + ], + "network": "testnet", + "payments": [ + { + "address": "4GXV5RgR7eMh44rTxKP1EJEjJ9Tygdv3DckgscLTdJ46x", + "amountNicks": "1000000" + } + ], + "rawTransaction": "cQF+UNvF5jFbj3aA/k93q4xDC/cNIKChLIoV/fnNG6DXa4hWWwCmMgH/FuJUIUfd9r8KIMCMF07sWrumH0BAv+ZaCl0EcTuAgJI9NX5EnkRjAAGOTTzTNGj5aIDAkugKd3oMyGtxyNBh4hpXwDerY2NXwINbQ8cF+M1MODPHtDLSAfpaBSDP1tU6MYBAl4ZnJFyVAGUAAYdbokoDnWBNwE84UNWisSVKTI0LIMDmtguyXfRxHUBgMqxPn2gdCzTA3y8C6hBfui8P8CvUzxgSrwGTAQIy47hb4NmW+OZwQ60sgAA2BxVJ+dKacQCB/X1pxN5UhtUAAtt87mFyFBTIAQRM2HY8XZMNCQECJVQ26GsABqoV8NnextZZQ0EeF34QDJJBFLLDDdXhhhqQgIQ+gADImilm7ywfa4A+mPG7PuzXXg/Qn8E1wWusDIEBBC5z7iXVy9kBAb8iUKbLJZrie7ihAge628MNFQ==", + "sourceAddress": "4Qo7o9rHwWr1qSKFdvvDe4DvJNsG2hJMnUwPXfExN7NyvkZn7ihJbiq", + "tip": { + "blockId": "5TSkZQ7J4nfmDfkGLf1EkdF7ZVzEZdAqTADfBzDqYBNKz", + "height": "53999" + }, + "version": 1 + }, + "review": { + "feeNicks": "450000", + "minimumFeeNicks": "442368", + "outputNicks": "1000000", + "spends": [ + { + "feeNicks": "450000", + "name": { + "firstName": "9hh3S3QNP7xctoQiQuVfnSAzKd69r3a8u5DVaXJCxsmb9JAXkgPqhbc", + "lastName": "2V9arU36gvtaofWmNowewoj9u7gbNA2qsJZEQ3WPky5mQ" + }, + "outputs": [ + { + "amountNicks": "1000000", + "lockRoot": "9SzCP28uJkJqgcvWcqjE89ePY2CFiDMhyDwLXoVysp2JjrdrA85pbRA", + "noteData": "BXy2t7F1FvDg1tBx4QfBIBlEIZs6BCc=" + } + ], + "requiredSignatures": 1, + "signatureHash": "BWnAeVR27unYbaLaGuJH5MXaFQNcrLKtRUJhbCDGkXce66BRuEjrz81", + "signerHashes": [] + } + ], + "transactionId": "7MUB31addWvu4wzdtd1D7e8uWG6os4bk1ZdawsKvnSe3GSjvVDZ8UUD" + } + }, + "signedRawTransaction": "cQH6TfEGlwN7EziAwMKQFuAqtUpgAIHouFMDPV0RyQA/j+8aYR5+Zxwg0E596G+EDqBaBRBgxgsndq1d0w8goF9zLYUugrgdQEDJnho/Ik+iMYAAxyaeaRq0fDRAYEl0hTs9BuS1OGToMHGNK+Cb1bGxK+DBraHjAvxmJpyZY1oZ6QB9rQKQZ+tqnRhAoEvDMxKuSoAygIDDLVGlgU6wJuAnHKhq0dgSJabGBRBgc9sF2S76uA4gMBnWp0+0jgUa4O8XAXWIL92XB/gV6mcMideAyQABmXHcLfBsS3yroYKvAL2LNBk5GMt5GkBgdzppupYAtTRADx3+kze+9GIGEMBM3xhpfCNKDvCri3L1PkreGgECoJWav0cl+oUFyIPhy6DOn4EygIBYazW5OGAyegCBccWSNQhmvc8AOf4oSIWfefAHELjfCNoG/LbOBvS+t9fEXF2ZOxcggLXDGQz4zsf8/AABnQEadICALHC7PkCQuTmxGnBnOY8f0MEhBFjgv6ij2QMEp5820QGCdFWp3QBB4EHBeoCA4dJMNqCXl/DXAZ9J2cIaIFCBmQ0FOjQOnTmcUodTamUBBLA5qEjKl9aMAwjs70sj9qYyrAYQ2OZzD5OjoEAOIGDCtuPpmmxICBAoobJBXwMwUK2Az/Y2ts4a6vK48INgkAyikB1OqQ6n1IAEJPQBBEDWTDF7Z/lYA/TBjN/1Yb/2eoD+DK4JXmNlCAwgcJlzL6lezg4I+BWBMl0u0RTfwykVONDdHk6p", + "signedTransactionId": "9SvsTg9fTboF6EjeC5SrfZXPLEZAgCH9goR783PdY66D5dfbtx9e3gT" + } + ], + "seed": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f" +}