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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"]

Expand Down
6 changes: 5 additions & 1 deletion src/nockchain/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/nockchain/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .cli import main

raise SystemExit(main())
101 changes: 101 additions & 0 deletions src/nockchain/cli.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading