diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0aa5f4..0e95c19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,5 +23,8 @@ jobs: - run: | python -m venv /tmp/nockchain-wheel /tmp/nockchain-wheel/bin/pip install dist/*.whl + python -c 'import json; from pathlib import Path; data=json.loads(Path("testdata/signing-bundles.json").read_text()); Path("/tmp/nockchain-bundle.json").write_text(json.dumps(data["cases"][0]["response"]["bundle"]))' cd /tmp /tmp/nockchain-wheel/bin/python -c 'from nockchain import parse_nocks; assert parse_nocks("1.25") == 81920' + /tmp/nockchain-wheel/bin/nockchain inspect /tmp/nockchain-bundle.json + /tmp/nockchain-wheel/bin/python -m nockchain inspect /tmp/nockchain-bundle.json diff --git a/README.md b/README.md index 2708acd..7a1e290 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,46 @@ Python 3.11+; the package uses the standard library and has no runtime dependenc pip install nockchain ``` +## 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 package command runs locally without a native binary or a local MCP server: + +```sh +pip install nockchain==0.3.0 +python -m nockchain inspect bundle.json +python -m nockchain sign bundle.json --out signed.json +``` + +The installed `nockchain` command provides the same interface. `sign` shows the +locally reconstructed 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 assigned to the fee is shown in +`feeNicks`. The output file has 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 come from 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 `inspect_signing_bundle(value)` for an `InspectedSigningBundle` +with `bundle`, `transaction`, and `review`, and `sign_signing_bundle(value, key)` +for a signed bundle. Both validate the bundle. Applications obtain user +authorization before calling the signing function. +`verify_signatures(require_threshold=False)` validates existing signatures and +fee coverage while allowing missing signatures. + ## Receive from a public branch ```python diff --git a/pyproject.toml b/pyproject.toml index baa7d2c..292614c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nockchain" -version = "0.2.0" +version = "0.3.0" description = "Native Python Nockchain wallet planning, lock policies and transactions" readme = "README.md" requires-python = ">=3.11" @@ -17,6 +17,9 @@ classifiers = ["Programming Language :: Python :: 3", "Typing :: Typed"] [project.urls] Repository = "https://github.com/SWPSCO/nockchain-py" +[project.scripts] +nockchain = "nockchain.cli:main" + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/nockchain/__init__.py b/src/nockchain/__init__.py index 67f0bed..b1e9921 100644 --- a/src/nockchain/__init__.py +++ b/src/nockchain/__init__.py @@ -1,5 +1,6 @@ """Nockchain wallet planning and transactions with bounded, immutable nouns.""" +from .signing import InspectedSigningBundle, inspect_signing_bundle, sign_signing_bundle from .migration import LockV0, NoteV0, note_first_v0 from ._core import ( PRIME, @@ -115,9 +116,12 @@ plan, ) -__version__ = "0.2.0" +__version__ = "0.3.0" __all__ = [ + "InspectedSigningBundle", + "inspect_signing_bundle", + "sign_signing_bundle", "LockV0", "NoteV0", "note_first_v0", diff --git a/src/nockchain/__main__.py b/src/nockchain/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/src/nockchain/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/src/nockchain/cli.py b/src/nockchain/cli.py new file mode 100644 index 0000000..e5ba3a2 --- /dev/null +++ b/src/nockchain/cli.py @@ -0,0 +1,101 @@ +"""Local, interactive transaction inspection and signing.""" + +import argparse +import getpass +import hashlib +import json +import os +import stat +import sys + +from .keys import parse_extended_key +from .seed import restore_wallet +from .signing import inspect_signing_bundle, sign_signing_bundle + + +def _read_bounded(path, maximum): + with open(path, "rb") as handle: + info = os.fstat(handle.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_size > maximum: + raise ValueError("expected a bounded regular file") + result = handle.read(maximum + 1) + if len(result) > maximum: + raise ValueError("file is too large") + return result + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Inspect and sign Nockblocks bundles locally.") + subparsers = parser.add_subparsers(dest="command", required=True) + inspect = subparsers.add_parser("inspect", help="validate a bundle and print its review") + inspect.add_argument("bundle") + sign = subparsers.add_parser("sign", help="review and sign in an interactive terminal") + sign.add_argument("bundle") + sign.add_argument("--out", required=True, help="create a new signed bundle file") + sign.add_argument("--key-file", help="local plain-text zprv or recovery phrase") + sign.add_argument("--mnemonic", action="store_true", help="use a recovery phrase") + sign.add_argument( + "--path", help="explicit child derivation path; required for recovery phrases" + ) + sign.add_argument("--passphrase", action="store_true", help="prompt for a recovery passphrase") + args = parser.parse_args(argv) + try: + if args.command == "sign": + if args.mnemonic and not args.path: + raise ValueError("--mnemonic requires an explicit --path") + if args.passphrase and not args.mnemonic: + raise ValueError("--passphrase requires --mnemonic") + raw = _read_bounded(args.bundle, 8 * 1024 * 1024) + inspected = inspect_signing_bundle(json.loads(raw)) + if args.command == "inspect": + print(json.dumps(inspected.review, indent=2)) + return 0 + if not sys.stdin.isatty() or not sys.stderr.isatty(): + raise ValueError("signing requires an interactive terminal for review and approval") + print(json.dumps(inspected.review, indent=2), file=sys.stderr) + print( + "Network and tip are indexed context, not proof that inputs are unspent.", + file=sys.stderr, + ) + print(f"Bundle SHA-256: {hashlib.sha256(raw).hexdigest()}", file=sys.stderr) + print( + "Check recipients, amounts and fee. Type SIGN to approve: ", + end="", + file=sys.stderr, + flush=True, + ) + if input() != "SIGN": + raise ValueError("signing declined; no output written") + secret = ( + _read_bounded(args.key_file, 16384).decode().strip() + if args.key_file + else getpass.getpass( + "Recovery phrase (hidden): " if args.mnemonic else "zprv (hidden): ", + stream=sys.stderr, + ) + ) + passphrase = ( + getpass.getpass("Passphrase (hidden): ", stream=sys.stderr) if args.passphrase else "" + ) + key = restore_wallet(secret, passphrase) if args.mnemonic else parse_extended_key(secret) + if args.path: + key = key.derive(args.path) + signed = sign_signing_bundle(inspected.bundle, key) + descriptor = os.open(args.out, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w") as output: + json.dump(signed, output, indent=2) + output.write("\n") + print( + json.dumps( + { + "output": args.out, + "transactionId": inspect_signing_bundle(signed).review["transactionId"], + "fullySigned": True, + }, + indent=2, + ) + ) + return 0 + except (ValueError, TypeError, KeyError, OSError, EOFError, KeyboardInterrupt) as error: + print(f"nockchain: {error or 'signing cancelled'}", file=sys.stderr) + return 1 diff --git a/src/nockchain/signing.py b/src/nockchain/signing.py new file mode 100644 index 0000000..18b106d --- /dev/null +++ b/src/nockchain/signing.py @@ -0,0 +1,221 @@ +"""Validate and sign data-only bundles prepared by the Nockblocks V1 API.""" + +import base64 +import copy +import re +from dataclasses import dataclass +from typing import Any + +from ._core import NockchainError, parse_digest, cue, jam, noun_equal +from .fee import FeeParameters, ChainContext +from .keys import ExtendedKey +from .locks import LockV1, NoteName, NoteV1 +from .transaction import TransactionV1, parse_transaction_v1 +from .planner import PlannedOutput, PlanRequest, plan +from .lock_resolver import SigningKeyLockMatcher + + +def _check(condition, message): + if not condition: + raise NockchainError(message) + + +def _object(value, fields): + _check(isinstance(value, dict), "expected an object") + _check(value.keys() <= set(fields.split()), "unexpected bundle field") + return value + + +def _integer(value): + _check( + isinstance(value, str) and re.fullmatch(r"(0|[1-9][0-9]{0,19})", value), + "expected an unsigned decimal integer", + ) + result = int(value) + _check(result < 1 << 64, "integer exceeds u64") + return result + + +def _address(value): + _check(isinstance(value, str) and 0 < len(value) <= 60, "invalid digest") + _check(str(parse_digest(value)) == value, "noncanonical digest") + return value + + +def _array(value): + _check(isinstance(value, list) and 0 < len(value) <= 64, "array count out of range") + return value + + +def _bytes(value, maximum): + _check( + isinstance(value, str) and len(value) <= (maximum + 2) // 3 * 4, "encoded data is too large" + ) + try: + result = base64.b64decode(value, validate=True) + except (ValueError, TypeError): + raise NockchainError("invalid base64") from None + _check( + 0 < len(result) <= maximum and base64.b64encode(result).decode() == value, + "expected bounded canonical base64", + ) + return result + + +def _data(value): + encoded = _bytes(value, 65536) + noun = cue(encoded) + _check(jam(noun) == encoded, "noncanonical note-data JAM") + return noun + + +@dataclass(frozen=True) +class InspectedSigningBundle: + bundle: dict[str, Any] + transaction: TransactionV1 + review: dict[str, Any] + + +def inspect_signing_bundle(value: Any) -> InspectedSigningBundle: + """Check intent, commitments and signatures; indexed context is not a chain proof.""" + value = _object( + value, + "format version network tip candidateHeight sourceAddress payments rawTransaction inputs", + ) + _check( + value.get("format") == "nockchain-signing-bundle" + and type(value.get("version")) is int + and value["version"] == 1, + "unsupported signing bundle", + ) + network = value.get("network") + _check( + isinstance(network, str) and re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", network), + "invalid network", + ) + tip = _object(value.get("tip"), "blockId height") + tip_height, height = _integer(tip.get("height")), _integer(value.get("candidateHeight")) + _address(tip.get("blockId")) + _check(height == tip_height + 1, "candidate height must follow the indexed tip") + source = _address(value.get("sourceAddress")) + source_hash = parse_digest(source) + payments = [] + for item in _array(value.get("payments")): + item = _object(item, "address amountNicks") + amount = _integer(item.get("amountNicks")) + _check(amount > 0, "payment must be positive") + payments.append({"address": _address(item.get("address")), "amountNicks": str(amount)}) + notes = [] + for item in _array(value.get("inputs")): + item = _object(item, "name originPage amountNicks noteData") + name = _object(item.get("name"), "firstName lastName") + notes.append( + NoteV1( + _integer(item.get("originPage")), + NoteName( + parse_digest(_address(name.get("firstName"))), + parse_digest(_address(name.get("lastName"))), + ), + _data(item.get("noteData")), + _integer(item.get("amountNicks")), + ) + ) + encoded = _bytes(value.get("rawTransaction"), 262144) + transaction = parse_transaction_v1(encoded, fees=FeeParameters(height=height)) + _check(transaction.jam() == encoded, "noncanonical transaction JAM") + transaction.validate_inputs(notes, height) + transaction.verify_signatures(require_threshold=False) + summary = transaction.summary() + planned = plan( + PlanRequest( + tuple(notes), + tuple( + PlannedOutput.payment(parse_digest(p["address"]), int(p["amountNicks"])) + for p in payments + ), + PlannedOutput.payment(source_hash, 0), + ChainContext.at_height(height), + selection=tuple(note.name for note in notes), + ), + SigningKeyLockMatcher(source_hash), + ) + _check(summary.fee_nicks == planned.final_fee, "bundle fee differs from the local plan") + planned_spends = {s.name: s.signature_hash for s in planned.transaction.summary().spends} + for spend in summary.spends: + _check( + planned_spends.get(spend.name) == spend.signature_hash, + "transaction differs from the local payment plan", + ) + expected = {} + for payment in payments + [{"address": source, "amountNicks": "0"}]: + lock = LockV1(1, (parse_digest(payment["address"]),)) + address, amount, _ = expected.get(lock.root, (payment["address"], 0, lock)) + amount += int(payment["amountNicks"]) + _check(amount < 1 << 64, "payment total overflow") + expected[lock.root] = (address, amount, lock) + actual = {} + for spend in summary.spends: + _check( + spend.proof is not None + and isinstance(spend.lock, LockV1) + and spend.lock.threshold == 1 + and source_hash in spend.lock.keys + and len(spend.outputs) > 0, + "input is not controlled by the source address", + ) + for output in spend.outputs: + target = expected.get(output.lock_root) + _check( + target is not None and noun_equal(output.note_data, target[2].metadata()), + "undeclared output or unexpected output metadata", + ) + actual[output.lock_root] = actual.get(output.lock_root, 0) + output.nicks + change = 0 + for root, (address, expected_amount, _) in expected.items(): + amount = actual.get(root, 0) + if address == source: + _check(amount >= expected_amount, "source payment amount mismatch") + change = amount - expected_amount + else: + _check(amount == expected_amount, "payment amount mismatch") + bundle = copy.deepcopy( + { + "format": value["format"], + "version": value["version"], + "network": network, + "tip": {"blockId": tip["blockId"], "height": tip["height"]}, + "candidateHeight": value["candidateHeight"], + "sourceAddress": source, + "payments": payments, + "rawTransaction": value["rawTransaction"], + "inputs": value["inputs"], + } + ) + review = { + "transactionId": str(summary.id), + "network": network, + "tip": bundle["tip"], + "candidateHeight": bundle["candidateHeight"], + "sourceAddress": source, + "payments": payments, + "changeNicks": str(change), + "feeNicks": str(summary.fee_nicks), + "minimumFeeNicks": str(summary.minimum_fee_nicks), + "inputCount": len(notes), + "fullySigned": all(len(s.signed_keys) == s.lock.threshold for s in summary.spends), + } + return InspectedSigningBundle(bundle, transaction, review) + + +def sign_signing_bundle(value: Any, key: ExtendedKey) -> dict[str, Any]: + """Revalidate, sign with the source key, and return a fully signed bundle.""" + inspected = inspect_signing_bundle(value) + _check( + key.is_private and key.address() == inspected.bundle["sourceAddress"], + "signing key does not match source address", + ) + signed = inspected.transaction.sign(key) + signed.verify_signatures() + result = {**inspected.bundle, "rawTransaction": base64.b64encode(signed.jam()).decode()} + inspect_signing_bundle(result) + return result diff --git a/src/nockchain/transaction.py b/src/nockchain/transaction.py index 675c16c..3f75e4f 100644 --- a/src/nockchain/transaction.py +++ b/src/nockchain/transaction.py @@ -338,9 +338,9 @@ def _verify(self, require_threshold: bool) -> None: ): raise NockchainError("invalid or unauthorized signature") - def verify_signatures(self) -> None: - """Check exact thresholds, every signature, authorization and minimum fee.""" - self._verify(True) + def verify_signatures(self, *, require_threshold: bool = True) -> None: + """Check signatures, authorization and minimum fee; optionally allow missing signatures.""" + self._verify(require_threshold) review = self.summary() if review.fee_nicks < review.minimum_fee_nicks: raise NockchainError("fee below minimum") 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" +} diff --git a/tests/test_signing.py b/tests/test_signing.py new file mode 100644 index 0000000..db058c8 --- /dev/null +++ b/tests/test_signing.py @@ -0,0 +1,147 @@ +import base64 +import copy +import contextlib +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +import nockchain as n +from nockchain.cli import main + +FIXTURE = json.loads((Path(__file__).parent.parent / "testdata/signing-bundles.json").read_text()) +SAMPLE = FIXTURE["cases"][0]["response"]["bundle"] +KEY = n.master(bytes.fromhex(FIXTURE["seed"])) + + +def note(record): + return n.NoteV1( + int(record["originPage"]), + n.NoteName( + n.parse_digest(record["name"]["firstName"]), n.parse_digest(record["name"]["lastName"]) + ), + n.cue(base64.b64decode(record["noteData"])), + int(record["amountNicks"]), + ) + + +class SigningTests(unittest.TestCase): + def test_rust_bundles_match_review_and_signed_bytes(self): + for case in FIXTURE["cases"]: + with self.subTest(case=case["name"]): + bundle, review = case["response"]["bundle"], case["response"]["review"] + checked = n.inspect_signing_bundle(bundle) + for field in ("transactionId", "feeNicks", "minimumFeeNicks"): + self.assertEqual(checked.review[field], review[field]) + self.assertFalse(checked.review["fullySigned"]) + signed = n.sign_signing_bundle(bundle, KEY) + self.assertEqual(signed["rawTransaction"], case["signedRawTransaction"]) + signed_review = n.inspect_signing_bundle(signed).review + self.assertEqual(signed_review["transactionId"], case["signedTransactionId"]) + self.assertTrue(signed_review["fullySigned"]) + self.assertNotEqual(signed_review["transactionId"], review["transactionId"]) + + def test_rejects_altered_intent_commitments_and_encoding(self): + mutations = ( + lambda b: b["payments"][0].update(amountNicks="999999"), + lambda b: b["payments"][0].update(address=str(n.Digest((1, 2, 3, 4, 5)))), + lambda b: b["inputs"][0].update(amountNicks="40000001"), + lambda b: b["inputs"][0].update(originPage="53899"), + lambda b: b["inputs"][0].update(noteData=base64.b64encode(n.jam(0)).decode()), + lambda b: b.update(sourceAddress=b["payments"][0]["address"]), + lambda b: b.update(rawTransaction=b["rawTransaction"] + "\n"), + lambda b: b.update( + rawTransaction=base64.b64encode( + base64.b64decode(b["rawTransaction"]) + b"\0" + ).decode() + ), + lambda b: b.update(candidateHeight="18446744073709551616"), + lambda b: b["payments"][0].update(amountNicks="01"), + lambda b: b.update(inputs=[]), + lambda b: b["inputs"][0].update(privateKey="unexpected"), + lambda b: b.update(network="mainnet\nPAY SOMEBODY ELSE"), + ) + for index, mutate in enumerate(mutations): + with self.subTest(index=index), self.assertRaises(n.NockchainError): + bundle = copy.deepcopy(SAMPLE) + mutate(bundle) + n.inspect_signing_bundle(bundle) + wrong = n.master(bytes([42]) * 64) + for key in (wrong, KEY.public()): + with self.assertRaisesRegex(n.NockchainError, "source address"): + n.sign_signing_bundle(SAMPLE, key) + + def test_canonical_transaction_cannot_raise_the_planned_fee(self): + bundle = copy.deepcopy(SAMPLE) + checked = n.inspect_signing_bundle(bundle) + spend = checked.transaction.summary().spends[0] + source_root = n.LockV1(1, (n.parse_digest(bundle["sourceAddress"]),)).root + changed = n.prepare_transaction_v1( + ( + n.LockedSpendPlanV1( + note(bundle["inputs"][0]), + spend.lock, + tuple( + n.LockedPaymentV1( + output.lock(), + output.nicks - (1 if output.lock_root == source_root else 0), + output.note_data, + ) + for output in spend.outputs + ), + spend.fee + 1, + ), + ), + fees=n.FeeParameters(height=int(bundle["candidateHeight"])), + ) + changed.validate_inputs(tuple(map(note, bundle["inputs"])), int(bundle["candidateHeight"])) + changed.verify_signatures(require_threshold=False) + bundle["rawTransaction"] = base64.b64encode(changed.jam()).decode() + with self.assertRaisesRegex(n.NockchainError, "fee differs"): + n.inspect_signing_bundle(bundle) + + def test_cli_inspect_and_approval_boundary(self): + with tempfile.TemporaryDirectory() as directory: + input_path, output_path = ( + Path(directory) / "bundle.json", + Path(directory) / "signed.json", + ) + input_path.write_text(json.dumps(SAMPLE)) + result = subprocess.run( + [sys.executable, "-m", "nockchain", "inspect", str(input_path)], + capture_output=True, + text=True, + check=True, + ) + self.assertEqual( + json.loads(result.stdout)["transactionId"], + FIXTURE["cases"][0]["response"]["review"]["transactionId"], + ) + args = [ + "sign", + str(input_path), + "--out", + str(output_path), + "--key-file", + str(Path(directory) / "missing-key"), + ] + with contextlib.redirect_stderr(io.StringIO()) as errors: + self.assertEqual(main(args), 1) + self.assertIn("interactive terminal", errors.getvalue()) + with ( + patch("sys.stdin.isatty", return_value=True), + contextlib.redirect_stderr(io.StringIO()) as errors, + patch.object(errors, "isatty", return_value=True), + patch("builtins.input", return_value="no"), + ): + self.assertEqual(main(args), 1) + self.assertIn("declined", errors.getvalue()) + self.assertFalse(output_path.exists()) + + +if __name__ == "__main__": + unittest.main()