From ae56ae9d86d6c4e61ea0e22e816cf58b6bb13ea8 Mon Sep 17 00:00:00 2001 From: reid Date: Tue, 20 Oct 2026 09:36:15 -0500 Subject: [PATCH 1/2] docs: add wallet builder migration handoff --- README.md | 6 + docs/wallet-builder-migration.md | 518 +++++++++++++++++++++++++++++++ 2 files changed, 524 insertions(+) create mode 100644 docs/wallet-builder-migration.md diff --git a/README.md b/README.md index 38fd649..ec09b9c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ Add this to your `Cargo.toml`: tx-types = { git = "https://github.com/SWPSCO/tx-types" } ``` +## Migration / handoff + +See [`docs/wallet-builder-migration.md`](docs/wallet-builder-migration.md) for the +gap between this crate, upstream `nockchain-types`/`wallet-tx-builder`, and the +170,000 consensus validation rules. + ## Vanity addresses [`vanity`](vanity/) provides allocation-free diff --git a/docs/wallet-builder-migration.md b/docs/wallet-builder-migration.md new file mode 100644 index 0000000..81f6547 --- /dev/null +++ b/docs/wallet-builder-migration.md @@ -0,0 +1,518 @@ +# Wallet transaction builder migration + +Status: design/handoff, not implemented. + +Audience: someone who has not worked in this repository. This document explains +what is missing, why it matters, where to look, and how to validate a fix. + +## TL;DR + +`tx-types` is a partial fork of two upstream things: + +1. Nockchain's transaction types and hashing + (`crates/nockchain-types`). +2. Nockchain's wallet transaction builder utilities + (`crates/wallet-tx-builder`). + +The type/hashing side is present, but simplified. The wallet-builder side is +mostly absent, and the pieces that do exist are not the same algorithm as Hoon. +The highest-risk gap is the lock Merkle proof builder: it does not model the +tagged lock tree, so it cannot produce the proofs required for multi-condition +locks or for the post-170,000 validation rules. + +The target is one shared implementation in `tx-types` with as much logic as +possible compiled for both `std` and `no_std`; version and IO differences belong +behind small traits/adapters, not copied modules. + +## Repositories and source of truth + +Local checkout: + +- `~/gits/np/tx-types` — this repository. +- Current HEAD when this was written: `f225b52` ("Wasm space api"). +- Feature matrix: `embedded` (vendored `nockchain-minimal`), `native` (upstream + Nockchain git pin), `std` (serde/JSON/base58/CLI conveniences). +- `embedded` is the default. `native` exists mostly for the RPC server. + +Upstream references: + +- `~/gits/nockchain` +- Types baseline used by this fork: `73877be29bd1fafeeade1be7b95aebb0f64d8382`. +- Newer consensus baseline: `9bcc26c6` (2026-10-19, "Security batch 3: + coordinated consensus upgrade at height 170,000"). +- Original type crate: `crates/nockchain-types`. +- Original wallet builder: `crates/wallet-tx-builder`. +- Hoon transaction engine: `hoon/common/tx-engine.hoon`, + `hoon/common/tx-engine-1.hoon`. +- Protocol notes: `changelog/protocol/017-anthropos.md`. + +Do not assume the local names match upstream. Local V1 types are in +`src/transaction_types_v1.rs`, while upstream uses +`src/tx_engine/v1/{note.rs,tx.rs,signatures.rs,hashable.rs}`. + +## What is already here + +- V0/V1 note, seed, spend, input, output and raw-transaction shapes. +- Tip5 hashing and transaction-id helpers. +- `ZMap`/`ZSet` collections. +- Cheetah/Schnorr crypto and signing. +- Fee calculation (simplified but present). +- Noun encode/decode via `noun-serde` plus handwritten encoders. +- A simplified `build_spends`/`create_spends_0`/`create_spends_1`. +- A simplified lock Merkle proof builder. + +## What is missing or incomplete + +### 1. Tagged lock tree (highest priority) + +Local: + +- `src/transaction_types_v1.rs` + - `SpendCondition { p: Vec }` + - `LockPrimitiveBody::{Pkh, Tim, Hax, Brn}` + - `MerkleProof { root, path }` + +Upstream: + +- `crates/nockchain-types/src/tx_engine/v1/tx.rs` + - `Lock::{SpendCondition, V2(LockV2), V4(LockV4), V8(LockV8), V16(LockV16)}` + - `LockV2 { p, q }`, `LockV4 { p: LockV2, q: LockV2 }`, etc. + - `spend_condition_count`, `flatten_spend_conditions`, `Lock::hash`. + +Why it matters: + +Hoon's `$lock` is not just a list. A note can commit to a single spend +condition or a tagged balanced OR tree (`%2`, `%4`, `%8`, `%16`). A flat +`Vec` cannot represent that tree, so it cannot hash or prove it. + +Needed: + +- Port the `Lock` tree and its `NounEncode`/`NounDecode`. +- Keep `SpendCondition` as the leaf/list primitive. +- Expose flattening, count, and `hash()` with the same domain/tag rules as + upstream. +- Preserve V0 compatibility separately; do not force V0 into the V1 tree. + +### 2. Lock Merkle proof builder is a stub, not a tree proof + +Local: + +- `src/block_types.rs` + - `build_lock_merkle_proof(form, leaf_number, page)` + - `build_lock_merkle_proof_stub` + - `build_lock_merkle_proof_full` + - `prove_hashable_by_index` + +Observed behavior: + +- `build_lock_merkle_proof_stub` wraps the whole condition in + `Hashable::Hash(form.to_hash())`, then calls `prove_hashable_by_index`. + Because that is a single leaf, the resulting axis is `1` and the path is + empty. +- `build_lock_merkle_proof_full` simply wraps the stub result in the `Full` + variant. It does not recompute anything over a lock tree. +- The local builder does not implement Hoon's leaf-number adjustment + (`?^ -.form` -> `+(leaf-number)`) or the `%2/%4/%8/%16` traversal. + +Upstream Hoon: + +- `hoon/common/tx-engine-1.hoon` + - `build-lock-merkle-proof-stub` + - `build-lock-merkle-proof-full` + - `lock-merkle-proof-stub::check` (requires axis `1`) + - `lock-merkle-proof-full::check` + - `authorization-leaf` (final sibling must authenticate the arity tag) + +Why it matters: + +- A `Full` proof with axis `1` and an empty path may pass only through the + `axis == 1` short-circuit. Anything with a real tree/axis cannot be built. +- After 170,000, full proofs also require the authorization sibling: + `leaf+2`, `leaf+4`, `leaf+8`, or `leaf+16` at the matching depth. The local + type/builder has no representation for that check. +- This is consensus-critical. A wallet can build a transaction that a node + rejects, or fail to build one the node would accept. + +Needed: + +- Build the hashable tree over `Lock`, not over one hash of the whole condition. +- Adjust index exactly like Hoon for a tagged head. +- Test both stub and full forms for: + - one `%pkh` condition (axis `1`, empty path), + - a `%2` tree, + - nested `%4`/`%8`/`%16`, + - invalid `axis`/`path` cases. +- Port `authorization-leaf` as a local validation helper and add tests that + compare root/path/axis against expected values. + +Do not just wrap the current stub in `Full` and call it fixed. + +### 3. Post-170,000 validation rules + +Hoon `9bcc26c6` added/changed, and says validation applies immediately, not at +170,000: + +- `valid-stub-axes`: for `%1` spends that are not the protocol fund, a stub + proof must have axis `1`. +- `authorization-leaf`: a full proof's final path sibling must authenticate the + arity tag at that depth. +- Full proofs reject an empty spend condition (`?~ spend-condition.form`). +- At 170,000, protocol-fund recovery applies the configured minimum maturity at + the spending candidate height (`fund-minimum-at`, `FundMaturity`). +- `consensus-batch-phase` is 170,000, and AI-PoW gains `%consrule`. The AI-PoW + rule is **not** tx-types' job; do not port it here. + +Why it matters: + +`tx-types` is used by wallets to plan and sign. If it does not mirror these +validation rules, it will plan invalid spends, especially around the protocol +fund and multisig/multi-condition locks. + +Needed: + +- Add `authorization_leaf` validation for local proofs. +- Add `valid_stub_axis` validation. +- Reject empty spend conditions at build/validate boundaries. +- Add fund maturity as data in the planner; do not bake a current height into a + type. +- Keep activation heights as constants/config, not `#[cfg]`. + +### 4. Wallet builder utilities are not shared + +Upstream `crates/wallet-tx-builder` is ~6,600 lines and contains the layer that +is missing here: + +| Upstream file | Purpose | Local status | +|---|---|---| +| `types.rs` | Candidate notes, planned outputs, plan requests, selection mode | Missing | +| `determinism.rs` | Canonical note ordering, spend-condition canonicalization | Missing | +| `fee.rs` | `compute_minimum_fee`, `FeeBreakdown`, `witness_divisor`, bridge fee | Partial in `fee_calculator.rs` | +| `lock_resolver.rs` | `FundMaturity`, `LockMatcher`, `LockRootLockMatcher`, multisig/fund policy | Missing | +| `note_data.rs` | Note-data normalization, lock/bridge payloads, decoded note data | Missing (local uses `UntypedNoun`) | +| `word_count.rs` | Seed/witness/note-data word accounting | Partial/inline | +| `adapter.rs` | Snapshot normalization, single-snapshot enforcement | Consumer-specific | +| `planner.rs` | Candidate selection, v0→v1 migration, refunds, conservation | Partial in `tx_builder_v1.rs`/`tx_builder_batch.rs` | + +Why it matters: + +Consumers currently reimplement selection/fee/refund rules. The recent wallet +change (`9bcc26c6`) added an effective signer choice and fund maturity policy; +those decisions belong in a shared planner, not in each wallet. + +Needed: + +- Move the pure parts into `tx-types`. +- Keep snapshot fetch, persistence, hardware signing, and transport behind + traits in consumers. +- Parameterize V0/V1 differences with `Version`/`LockData`/policy arguments. +- Do not copy the planner twice. + +### 5. Note-data: bridge deposit/withdrawal are untyped + +Local: + +- `NoteData { map: ZMap }` + - Callers insert raw nouns. + - No typed bridge payload. + - No normalization/validation of the canonical note-data map. + +Upstream `wallet-tx-builder::note_data`: + +- `BridgeDepositDataPayload` +- `BridgeWithdrawalDataPayload` +- `DecodedNoteData` / `DecodedNoteDataEntry` +- `LockDataPayload` / `ParsedLockForm` +- key constants for `lock`, `bridge`, `bridge-w`. + +Why it matters: + +Bridge deposits need canonical note-data encoding. Without it, callers can +construct a shape that hashes differently from Hoon. Base/Ethereum transaction +construction itself is **out of scope**; only note-data encoding for deposits +matters. + +Needed: + +- Add typed bridge deposit/withdrawal note-data payloads. +- Add canonical encode/decode and round-trip tests. +- Add `EthAddress` only if deposit encoding needs it. Do not port Ethereum + transaction types, Base L1 transactions, or bridge contracts. + +### 6. Lower-priority types from `nockchain-types` + +These are present upstream but absent locally: + +- `RequiredPkhPolicy` and `SpendCondition::required_pkh_policy()`. + Useful for wallet signer selection; easy to add. +- `TransactionMetadata`, `LockMetadata`, `VersionedLockMetadata`, + `LegacyLockMetadata`, `InputMetadata`, `OutputLockMap`, `WitnessData`, + `SignatureMap`, `SpendConditionMap`. + Mostly node/explorer metadata; add only if a consumer needs them. +- `BlockchainConstants`, `AsertParams`, `NoteDataConstraints`, `Seconds`. + Consensus configuration, not transaction construction. Keep out unless this + crate is meant to own consensus params. +- `EthAddress`. + Only for bridge deposit encoding; no Base/Ethereum tx construction. + +Do not port `nockchain-types` wholesale just for parity. Port what the shared +builder consumes. + +### 7. `std`/`no_std` split is wrong for sharing + +Local `src/lib.rs` gates nearly everything on `std`: + +- `fee_calculator`, `tx_builder_v1`, `tx_builder_batch`, + `transaction_types*`, `hashing`, `validation` are std-only. +- `no_std` gets only `crypto` and `pokenoun`. + +That means the fee/planner/note-data logic cannot be shared with embedded/wasm +no_std consumers, even though it is pure Rust. + +Needed: + +- Make pure modules available under `alloc`/no_std. +- Gate only serde/JSON/base58/CLI/noun-slab adapters on `std` (or introduce a + separate `serde` feature). +- Avoid serde derives on core types if they prevent no_std; use separate DTOs + or `cfg_attr`. +- Compile the matrix below on every change. + +## Target architecture + +```text +tx-types core (no_std + alloc) + transaction_types{,_v0,_v1} + lock tree + hashing + fee + word_count + note_data normalize/encode + lock_resolver (traits only) + planner core (pure, version-parameterized) + +tx-types std + serde / serde_json / bs58 / chrono / CLI + NounSlab helpers that require std conveniences + +consumer (wallet-engine, nockster-esp, RPC server, CLI) + snapshot fetch + persistence + signing transport / hardware + concrete LockMatcher / SnapshotPageFetcher impls +``` + +Rules: + +1. `std` means "convenience/IO available", not "planning available". +2. Version differences are arguments/enums, not duplicate functions. +3. Traits at the IO boundaries; pure functions in the middle. +4. No `#[cfg(feature = "native")]` duplicate implementation. One shared source, + aliased embedded/upstream dependencies only if the native build is retained. +5. Consensus heights and reference rates are data/config, not hard-coded in + tests or types. + +## Work plan + +Do these as separate PRs; each should keep the whole matrix green. + +### Slice 1: Lock tree + real LMP + +- Port `Lock` tree types and hashing. +- Replace the local `build_lock_merkle_proof_*` with the Hoon algorithm. +- Add `authorization_leaf` and `valid_stub_axis` validation helpers. +- Add tests for leaf, `%2`, `%4`, nested trees, bad axis/path. +- Acceptance: roots/paths/axes match Hoon for at least the fixtures used by + `hoon/tests/dumb/mod/unit/lock-merkle-proof.hoon`; no full proof is just a + wrapped stub. + +### Slice 2: Shared fee + word counting + +- Move `FeeBreakdown`, `witness_divisor`, bridge fee, and word counting into + shared modules. +- Keep `calculate_min_fee` behavior; add parity tests against Hoon fee cases. +- Acceptance: V0/V1, below/at Bythos, witness divisor, multi-input counts all + match Hoon. + +### Slice 3: Note-data normalization + bridge payloads + +- Add typed note-data normalization and canonical encoding. +- Add bridge deposit (`bridge`) and withdrawal (`bridge-w`) payloads. +- Do not add Base/Ethereum transaction construction. +- Acceptance: canonical note-data for lock + bridge deposit/withdrawal + round-trips and hashes match Hoon. + +### Slice 4: Lock resolver + fund maturity + +- Port `FundMaturity`, `LockMatcher`, `LockRootLockMatcher` semantics. +- Add `fund_minimum_at(height)`/candidate-height handling as data. +- Acceptance: protocol-fund and multisig selection match the wallet planner + tests in upstream `crates/nockchain-wallet/src/tests.rs`. + +### Slice 5: Pure planner core + +- Extract selection, conservation, refund, and v0→v1 migration from the + consumers/current builder. +- Keep async snapshot fetch and signing transport in consumers. +- Acceptance: a pure plan can be tested without a browser, node, or hardware; + the wallet and embedded consumers produce the same plan for the same input. + +### Slice 6: Feature split + +- Move pure modules out of `#[cfg(feature = "std")]`. +- Gate only serde/JSON/CLI and noun-slab adapters. +- Acceptance: all commands in the feature matrix compile; no duplicated + planner. + +## How to investigate + +Start from the current local code: + +```sh +cd ~/gits/np/tx-types + +sed -n '80,170p' tx-types/src/block_types.rs +sed -n '1150,1420p' tx-types/src/transaction_types_v1.rs +sed -n '1,120p' tx-types/src/fee_calculator.rs +sed -n '240,620p' tx-types/src/tx_builder_v1.rs +``` + +Read the upstream algorithms: + +```sh +cd ~/gits/nockchain + +sed -n '610,780p' crates/nockchain-types/src/tx_engine/v1/tx.rs +sed -n '1770,2075p' hoon/common/tx-engine-1.hoon +sed -n '440,560p' hoon/common/tx-engine.hoon +sed -n '1080,1130p' hoon/common/tx-engine.hoon + +# Full new wallet builder layer +ls crates/wallet-tx-builder/src +sed -n '1,220p' crates/wallet-tx-builder/src/fee.rs +sed -n '1,260p' crates/wallet-tx-builder/src/lock_resolver.rs +sed -n '1,340p' crates/wallet-tx-builder/src/note_data.rs +``` + +Read the 170,000 consensus change: + +```sh +cd ~/gits/nockchain +git show 9bcc26c6 -- hoon/common/tx-engine.hoon hoon/common/tx-engine-1.hoon +git show 9bcc26c6 -- crates/nockchain-wallet/src/create_tx.rs +sed -n '1,120p' changelog/protocol/017-anthropos.md +``` + +Note that commit did **not** change `crates/nockchain-types`. The Rust type +layout is not the new consensus change; the validation semantics and wallet +planning are. + +## How to validate + +Current matrix: + +```sh +cd ~/gits/np/tx-types + +cargo test -p tx-types --lib + +cargo build -p tx-types \ + --no-default-features --target thumbv7em-none-eabihf + +cargo build -p tx-types \ + --no-default-features --features std,embedded \ + --target wasm32-unknown-unknown +``` + +When the pure modules move out of `std`, adapt those commands: + +```sh +# no_std pure core +cargo build -p tx-types --no-default-features \ + --target thumbv7em-none-eabihf + +# no_std + serde/JSON if you introduce a serde feature +cargo build -p tx-types --no-default-features --features serde +``` + +Consumer checks (depends on how they pin this crate): + +```sh +cargo check --manifest-path ~/gits/np/nockchain-rpc-server/Cargo.toml +cargo check --manifest-path ~/gits/np/siger-esp/packages/wallet-engine/Cargo.toml +cargo test --manifest-path ~/gits/np/siger-esp/packages/wallet-engine/Cargo.toml +cargo check --manifest-path ~/gits/np/siger-esp/nockster-esp/Cargo.toml +``` + +Do not rely on tests alone. For LMP/planning, build a small scenario and +compare the local output against a Hoon-produced fixture or the upstream Rust +builder. A round-trip test only proves self-consistency, not consensus. + +## Tests to add + +- `SpendCondition` with one `%pkh`: stub and full proof, axis, path. +- `%2`, `%4`, `%8`, `%16` trees: flatten order, count, root. +- Index adjustment for a tagged head. +- `authorization_leaf`: accepts correct final sibling, rejects wrong tag/depth. +- `valid_stub_axis`: ordinary `%1` spend requires axis `1`. +- Empty spend condition rejected. +- Fund maturity: protocol-fund spend before/at/after the candidate-height + minimum. +- Fee parity: single/multiple inputs, V0/V1, Bythos on/off, witness divisor. +- Note-data: lock and bridge deposit/withdrawal canonical round-trip and hash. +- V0→V1 migration and refund conservation. +- `std` and `no_std` produce the same hashes/plans. + +## Pitfalls + +- `HashMap`/`ZMap` iteration order is not stable. Wallet lists and spend maps + must be explicitly sorted/canonicalized before planning or UI selection. +- Do not treat a successful noun round-trip as proof of consensus validity. +- Do not use the current chain height inside a type or constant. Pass the + candidate height into planning/validation. +- Do not port AI-PoW `%consrule`, ASERT anchors, or node gRPC policy. Those are + not transaction-builder concerns. +- Do not port Base/Ethereum transaction construction. Only bridge note-data + encoding for deposits is relevant. +- Keep V0 behavior frozen. The v0→v1 migration is the only place where the two + should meet. +- The fork's `native`/`embedded` split must not become two implementations. + Alias dependencies if needed; share the source. + +## Definition of done + +- One lock/type implementation, with `Lock` trees and V0/V1 differences explicit. +- LMP builder produces real tree proofs and validates them locally. +- Post-170,000 validation semantics mirrored and tested. +- Shared fee/word/note-data/lock-resolver/planner logic available to both + `std` and `no_std` consumers. +- Consumers only supply IO: snapshots, persistence, signing, transport. +- The full compile/test matrix passes. +- A new contributor can point to a single module for each rule and find tests + for it. + +## File map + +Local: + +- `tx-types/src/lib.rs` — feature gates and aliases. +- `tx-types/src/transaction_types.rs` — legacy/common types. +- `tx-types/src/transaction_types_v0.rs` — V0 types. +- `tx-types/src/transaction_types_v1.rs` — V1 types, `SpendCondition`, + `LockPrimitive`, `MerkleProof`, `LockMerkleProof`. +- `tx-types/src/block_types.rs` — coinbase/page plus LMP builder. +- `tx-types/src/fee_calculator.rs` — fee constants and minimum fee. +- `tx-types/src/tx_builder_v1.rs` — simplified spend builder. +- `tx-types/src/tx_builder_batch.rs` — simplified batch builder. +- `tx-types/src/hashing/` — Tip5, tx-id, hashable. +- `tx-types/src/crypto/` — Cheetah, Goldilocks, SLIP-10. +- `tx-types/src/validation/` — signature/transaction validation. + +Upstream: + +- `crates/nockchain-types/src/tx_engine/v1/tx.rs` +- `crates/nockchain-types/src/tx_engine/v1/note.rs` +- `crates/nockchain-types/src/tx_engine/v1/signatures.rs` +- `crates/nockchain-types/src/tx_engine/v1/hashable.rs` +- `crates/nockchain-types/src/blockchain_constants.rs` +- `crates/wallet-tx-builder/src/*` +- `hoon/common/tx-engine.hoon` +- `hoon/common/tx-engine-1.hoon` From 02d194605c475013745cc65b26f1413cd098932e Mon Sep 17 00:00:00 2001 From: reid Date: Tue, 20 Oct 2026 10:53:13 -0500 Subject: [PATCH 2/2] Share portable wallet planning and release 0.2.0 --- .github/workflows/regression.yml | 12 +- Cargo.toml | 6 +- README.md | 40 +- docs/wallet-builder-migration.md | 518 ------- pokenoun/src/arena.rs | 14 +- pokenoun/src/codec.rs | 264 ++++ pokenoun/src/jam.rs | 142 +- pokenoun/src/lib.rs | 3 +- tx-types/Cargo.toml | 10 +- tx-types/src/block_types.rs | 168 +-- tx-types/src/collections/zmap.rs | 234 ++-- tx-types/src/collections/zset.rs | 259 ++-- tx-types/src/determinism.rs | 13 + tx-types/src/fee.rs | 183 +++ tx-types/src/fee_calculator.rs | 280 +--- tx-types/src/generic_noun.rs | 90 +- tx-types/src/hashing/hashable.rs | 328 ++--- tx-types/src/hashing/hasher.rs | 170 +-- tx-types/src/hashing/mod.rs | 7 +- tx-types/src/hashing/tip5.rs | 43 +- tx-types/src/hashing/tx_id.rs | 5 +- tx-types/src/lib.rs | 62 +- tx-types/src/lock.rs | 380 +++++ tx-types/src/lock_resolver.rs | 312 +++++ tx-types/src/note_data.rs | 307 +++++ tx-types/src/noun_codec.rs | 124 ++ tx-types/src/planner.rs | 494 +++++++ tx-types/src/rpc_note_data.rs | 203 +++ tx-types/src/tests/mod.rs | 2 - tx-types/src/tests/test_lock_reference.rs | 192 +++ tx-types/src/tests/test_planner.rs | 400 ++++++ .../src/tests/test_transaction_reference.rs | 290 ++++ tx-types/src/tests/test_tx_builder_sighash.rs | 178 --- tx-types/src/tests/test_zmap_structure.rs | 10 +- tx-types/src/tests/test_zset_simple.rs | 11 +- tx-types/src/tests/test_zset_structure.rs | 11 +- tx-types/src/transaction_types.rs | 484 +++---- tx-types/src/transaction_types_v0.rs | 70 +- tx-types/src/transaction_types_v1.rs | 974 ++++--------- tx-types/src/tx_builder.rs | 91 ++ tx-types/src/tx_builder_batch.rs | 835 ----------- tx-types/src/tx_builder_v1.rs | 1226 ----------------- tx-types/src/word_count.rs | 123 ++ tx-types/tests/reference/README.md | 36 + tx-types/tests/reference/SHA256SUMS | 7 + tx-types/tests/reference/fee-oracle.hoon | 104 ++ tx-types/tests/reference/fee-oracle.jam | Bin 0 -> 1280 bytes tx-types/tests/reference/lock-proofs.hoon | 111 ++ tx-types/tests/reference/lock-proofs.jam | Bin 0 -> 7496 bytes .../tests/reference/note-data-oracle.hoon | 136 ++ tx-types/tests/reference/note-data-oracle.jam | Bin 0 -> 1344 bytes tx-types/tests/reference/raw-tx-v0.jam | Bin 0 -> 3072 bytes tx-types/tests/reference/raw-tx-v1.jam | Bin 0 -> 1064 bytes tx-types/tests/reference/regenerate.py | 65 + tx-types/tests/reference/spend1-sig-hash.jam | Bin 0 -> 152 bytes .../reference/withdrawal-transactions.jam | Bin 0 -> 2688 bytes 56 files changed, 5052 insertions(+), 4975 deletions(-) delete mode 100644 docs/wallet-builder-migration.md create mode 100644 pokenoun/src/codec.rs create mode 100644 tx-types/src/determinism.rs create mode 100644 tx-types/src/fee.rs create mode 100644 tx-types/src/lock.rs create mode 100644 tx-types/src/lock_resolver.rs create mode 100644 tx-types/src/note_data.rs create mode 100644 tx-types/src/noun_codec.rs create mode 100644 tx-types/src/planner.rs create mode 100644 tx-types/src/rpc_note_data.rs create mode 100644 tx-types/src/tests/test_lock_reference.rs create mode 100644 tx-types/src/tests/test_planner.rs create mode 100644 tx-types/src/tests/test_transaction_reference.rs delete mode 100644 tx-types/src/tests/test_tx_builder_sighash.rs create mode 100644 tx-types/src/tx_builder.rs delete mode 100644 tx-types/src/tx_builder_batch.rs delete mode 100644 tx-types/src/tx_builder_v1.rs create mode 100644 tx-types/src/word_count.rs create mode 100644 tx-types/tests/reference/README.md create mode 100644 tx-types/tests/reference/SHA256SUMS create mode 100644 tx-types/tests/reference/fee-oracle.hoon create mode 100644 tx-types/tests/reference/fee-oracle.jam create mode 100644 tx-types/tests/reference/lock-proofs.hoon create mode 100644 tx-types/tests/reference/lock-proofs.jam create mode 100644 tx-types/tests/reference/note-data-oracle.hoon create mode 100644 tx-types/tests/reference/note-data-oracle.jam create mode 100644 tx-types/tests/reference/raw-tx-v0.jam create mode 100644 tx-types/tests/reference/raw-tx-v1.jam create mode 100644 tx-types/tests/reference/regenerate.py create mode 100644 tx-types/tests/reference/spend1-sig-hash.jam create mode 100644 tx-types/tests/reference/withdrawal-transactions.jam diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml index 0c6e69b..64ecf3e 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -35,7 +35,17 @@ jobs: run: cargo test -p tx-types --no-default-features - name: Run hosted regression tests - run: cargo test -p tx-types + run: cargo test -p tx-types -p pokenoun + + - name: Verify committed reference fixtures + working-directory: tx-types/tests/reference + run: sha256sum --check SHA256SUMS + + - name: Build the embedded WASM adapter + run: cargo build -p tx-types --no-default-features --features std,embedded --target wasm32-unknown-unknown + + - name: Compile the native adapter + run: cargo check -p tx-types --no-default-features --features std,native --lib - name: Compile the no-std vanity library run: cargo check -p vanity --features mnemonic --lib --target thumbv7em-none-eabihf diff --git a/Cargo.toml b/Cargo.toml index 4baa100..2ccb3b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.1.0" +version = "0.2.0" [workspace.dependencies] # Upstream Nockchain crates. tx-types itself declares both the upstream and @@ -31,9 +31,9 @@ arrayref = "0.3.7" bincode = "2.0.0-rc.3" bitvec = "1.0.1" blake3 = { version = "1.5.1", features = ["serde"] } -bs58 = { version = "0.5.1", features = ["check"] } +bs58 = { version = "0.5.1", default-features = false, features = ["alloc", "check"] } byteorder = "1.5.0" -bytes = "1.5.0" +bytes = { version = "1.5.0", default-features = false } cfg-if = "1.0.0" chrono = "0.4.40" criterion = { git = "https://github.com/vlovich/criterion.rs.git", rev = "9b485aece85a3546126b06cc25d33e14aba829b3", features = ["html_reports"] } diff --git a/README.md b/README.md index ec09b9c..840b5e1 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,43 @@ Add this to your `Cargo.toml`: tx-types = { git = "https://github.com/SWPSCO/tx-types" } ``` -## Migration / handoff +## Shared wallet construction + +The pure transaction core runs with `no_std + alloc`. Both runtime backends use +its noun codecs, TIP5 hashes, lock trees, proof builders, note-data normalization, +fee accounting, lock admission, and deterministic planner. + +| Module | Responsibility | +| --- | --- | +| `lock` | Tagged 1/2/4/8/16-condition locks, Merkle proofs, authorization siblings, stub-axis admission | +| `note_data` | Canonical lock and bridge payloads, based-atom validation, opaque payload preservation | +| `lock_resolver` | Effective signer selection, disclosed/reconstructed locks, candidate-height timelocks and configured fund maturity | +| `planner` | Candidate ordering, manual selection, exact/minimum fees, refunds, and V0-to-V1 sweeps | +| `word_count`, `fee` | Encoded witness words, merged output note data, activation-dependent fee rates | +| `tx_builder` (`std`) | Software signatures applied to a shared plan | +| `rpc_note_data` (`std`) | RPC JSON converted to the actual on-chain note-data commitment | + +Call `planner::plan(PlanRequest { ... }, &matcher)` with notes, outputs, a refund +lock, and explicit chain/fee configuration. The result includes unsigned spends, +selected inputs, refunds, exact signed word estimates, and the required fee. +A `LockMatcher` supplies ownership/unlock context. Consumers own snapshot IO, +key storage, signing transport, and submission. Input notes retain their actual +note data; RPC lock metadata marked `reconstructed` is outside that commitment. + +`lock::Lock` is the V1 tree. `transaction_types::Lock` is the V0 signature lock. +Portable noun IO uses `pokenoun::codec::{Encode, Decode}`; `std` adds +`noun-serde` adapters for the selected `embedded` or `native` runtime. + +The [reference fixtures](tx-types/tests/reference/) record the official source +pin, generation commands, and byte/hash/fee comparisons. Run the core matrix: -See [`docs/wallet-builder-migration.md`](docs/wallet-builder-migration.md) for the -gap between this crate, upstream `nockchain-types`/`wallet-tx-builder`, and the -170,000 consensus validation rules. +```sh +cargo test -p tx-types --lib +cargo test -p tx-types --no-default-features --lib +cargo build -p tx-types --no-default-features --target thumbv7em-none-eabihf +cargo build -p tx-types --no-default-features --features std,embedded --target wasm32-unknown-unknown +cargo check -p tx-types --no-default-features --features std,native --lib +``` ## Vanity addresses diff --git a/docs/wallet-builder-migration.md b/docs/wallet-builder-migration.md deleted file mode 100644 index 81f6547..0000000 --- a/docs/wallet-builder-migration.md +++ /dev/null @@ -1,518 +0,0 @@ -# Wallet transaction builder migration - -Status: design/handoff, not implemented. - -Audience: someone who has not worked in this repository. This document explains -what is missing, why it matters, where to look, and how to validate a fix. - -## TL;DR - -`tx-types` is a partial fork of two upstream things: - -1. Nockchain's transaction types and hashing - (`crates/nockchain-types`). -2. Nockchain's wallet transaction builder utilities - (`crates/wallet-tx-builder`). - -The type/hashing side is present, but simplified. The wallet-builder side is -mostly absent, and the pieces that do exist are not the same algorithm as Hoon. -The highest-risk gap is the lock Merkle proof builder: it does not model the -tagged lock tree, so it cannot produce the proofs required for multi-condition -locks or for the post-170,000 validation rules. - -The target is one shared implementation in `tx-types` with as much logic as -possible compiled for both `std` and `no_std`; version and IO differences belong -behind small traits/adapters, not copied modules. - -## Repositories and source of truth - -Local checkout: - -- `~/gits/np/tx-types` — this repository. -- Current HEAD when this was written: `f225b52` ("Wasm space api"). -- Feature matrix: `embedded` (vendored `nockchain-minimal`), `native` (upstream - Nockchain git pin), `std` (serde/JSON/base58/CLI conveniences). -- `embedded` is the default. `native` exists mostly for the RPC server. - -Upstream references: - -- `~/gits/nockchain` -- Types baseline used by this fork: `73877be29bd1fafeeade1be7b95aebb0f64d8382`. -- Newer consensus baseline: `9bcc26c6` (2026-10-19, "Security batch 3: - coordinated consensus upgrade at height 170,000"). -- Original type crate: `crates/nockchain-types`. -- Original wallet builder: `crates/wallet-tx-builder`. -- Hoon transaction engine: `hoon/common/tx-engine.hoon`, - `hoon/common/tx-engine-1.hoon`. -- Protocol notes: `changelog/protocol/017-anthropos.md`. - -Do not assume the local names match upstream. Local V1 types are in -`src/transaction_types_v1.rs`, while upstream uses -`src/tx_engine/v1/{note.rs,tx.rs,signatures.rs,hashable.rs}`. - -## What is already here - -- V0/V1 note, seed, spend, input, output and raw-transaction shapes. -- Tip5 hashing and transaction-id helpers. -- `ZMap`/`ZSet` collections. -- Cheetah/Schnorr crypto and signing. -- Fee calculation (simplified but present). -- Noun encode/decode via `noun-serde` plus handwritten encoders. -- A simplified `build_spends`/`create_spends_0`/`create_spends_1`. -- A simplified lock Merkle proof builder. - -## What is missing or incomplete - -### 1. Tagged lock tree (highest priority) - -Local: - -- `src/transaction_types_v1.rs` - - `SpendCondition { p: Vec }` - - `LockPrimitiveBody::{Pkh, Tim, Hax, Brn}` - - `MerkleProof { root, path }` - -Upstream: - -- `crates/nockchain-types/src/tx_engine/v1/tx.rs` - - `Lock::{SpendCondition, V2(LockV2), V4(LockV4), V8(LockV8), V16(LockV16)}` - - `LockV2 { p, q }`, `LockV4 { p: LockV2, q: LockV2 }`, etc. - - `spend_condition_count`, `flatten_spend_conditions`, `Lock::hash`. - -Why it matters: - -Hoon's `$lock` is not just a list. A note can commit to a single spend -condition or a tagged balanced OR tree (`%2`, `%4`, `%8`, `%16`). A flat -`Vec` cannot represent that tree, so it cannot hash or prove it. - -Needed: - -- Port the `Lock` tree and its `NounEncode`/`NounDecode`. -- Keep `SpendCondition` as the leaf/list primitive. -- Expose flattening, count, and `hash()` with the same domain/tag rules as - upstream. -- Preserve V0 compatibility separately; do not force V0 into the V1 tree. - -### 2. Lock Merkle proof builder is a stub, not a tree proof - -Local: - -- `src/block_types.rs` - - `build_lock_merkle_proof(form, leaf_number, page)` - - `build_lock_merkle_proof_stub` - - `build_lock_merkle_proof_full` - - `prove_hashable_by_index` - -Observed behavior: - -- `build_lock_merkle_proof_stub` wraps the whole condition in - `Hashable::Hash(form.to_hash())`, then calls `prove_hashable_by_index`. - Because that is a single leaf, the resulting axis is `1` and the path is - empty. -- `build_lock_merkle_proof_full` simply wraps the stub result in the `Full` - variant. It does not recompute anything over a lock tree. -- The local builder does not implement Hoon's leaf-number adjustment - (`?^ -.form` -> `+(leaf-number)`) or the `%2/%4/%8/%16` traversal. - -Upstream Hoon: - -- `hoon/common/tx-engine-1.hoon` - - `build-lock-merkle-proof-stub` - - `build-lock-merkle-proof-full` - - `lock-merkle-proof-stub::check` (requires axis `1`) - - `lock-merkle-proof-full::check` - - `authorization-leaf` (final sibling must authenticate the arity tag) - -Why it matters: - -- A `Full` proof with axis `1` and an empty path may pass only through the - `axis == 1` short-circuit. Anything with a real tree/axis cannot be built. -- After 170,000, full proofs also require the authorization sibling: - `leaf+2`, `leaf+4`, `leaf+8`, or `leaf+16` at the matching depth. The local - type/builder has no representation for that check. -- This is consensus-critical. A wallet can build a transaction that a node - rejects, or fail to build one the node would accept. - -Needed: - -- Build the hashable tree over `Lock`, not over one hash of the whole condition. -- Adjust index exactly like Hoon for a tagged head. -- Test both stub and full forms for: - - one `%pkh` condition (axis `1`, empty path), - - a `%2` tree, - - nested `%4`/`%8`/`%16`, - - invalid `axis`/`path` cases. -- Port `authorization-leaf` as a local validation helper and add tests that - compare root/path/axis against expected values. - -Do not just wrap the current stub in `Full` and call it fixed. - -### 3. Post-170,000 validation rules - -Hoon `9bcc26c6` added/changed, and says validation applies immediately, not at -170,000: - -- `valid-stub-axes`: for `%1` spends that are not the protocol fund, a stub - proof must have axis `1`. -- `authorization-leaf`: a full proof's final path sibling must authenticate the - arity tag at that depth. -- Full proofs reject an empty spend condition (`?~ spend-condition.form`). -- At 170,000, protocol-fund recovery applies the configured minimum maturity at - the spending candidate height (`fund-minimum-at`, `FundMaturity`). -- `consensus-batch-phase` is 170,000, and AI-PoW gains `%consrule`. The AI-PoW - rule is **not** tx-types' job; do not port it here. - -Why it matters: - -`tx-types` is used by wallets to plan and sign. If it does not mirror these -validation rules, it will plan invalid spends, especially around the protocol -fund and multisig/multi-condition locks. - -Needed: - -- Add `authorization_leaf` validation for local proofs. -- Add `valid_stub_axis` validation. -- Reject empty spend conditions at build/validate boundaries. -- Add fund maturity as data in the planner; do not bake a current height into a - type. -- Keep activation heights as constants/config, not `#[cfg]`. - -### 4. Wallet builder utilities are not shared - -Upstream `crates/wallet-tx-builder` is ~6,600 lines and contains the layer that -is missing here: - -| Upstream file | Purpose | Local status | -|---|---|---| -| `types.rs` | Candidate notes, planned outputs, plan requests, selection mode | Missing | -| `determinism.rs` | Canonical note ordering, spend-condition canonicalization | Missing | -| `fee.rs` | `compute_minimum_fee`, `FeeBreakdown`, `witness_divisor`, bridge fee | Partial in `fee_calculator.rs` | -| `lock_resolver.rs` | `FundMaturity`, `LockMatcher`, `LockRootLockMatcher`, multisig/fund policy | Missing | -| `note_data.rs` | Note-data normalization, lock/bridge payloads, decoded note data | Missing (local uses `UntypedNoun`) | -| `word_count.rs` | Seed/witness/note-data word accounting | Partial/inline | -| `adapter.rs` | Snapshot normalization, single-snapshot enforcement | Consumer-specific | -| `planner.rs` | Candidate selection, v0→v1 migration, refunds, conservation | Partial in `tx_builder_v1.rs`/`tx_builder_batch.rs` | - -Why it matters: - -Consumers currently reimplement selection/fee/refund rules. The recent wallet -change (`9bcc26c6`) added an effective signer choice and fund maturity policy; -those decisions belong in a shared planner, not in each wallet. - -Needed: - -- Move the pure parts into `tx-types`. -- Keep snapshot fetch, persistence, hardware signing, and transport behind - traits in consumers. -- Parameterize V0/V1 differences with `Version`/`LockData`/policy arguments. -- Do not copy the planner twice. - -### 5. Note-data: bridge deposit/withdrawal are untyped - -Local: - -- `NoteData { map: ZMap }` - - Callers insert raw nouns. - - No typed bridge payload. - - No normalization/validation of the canonical note-data map. - -Upstream `wallet-tx-builder::note_data`: - -- `BridgeDepositDataPayload` -- `BridgeWithdrawalDataPayload` -- `DecodedNoteData` / `DecodedNoteDataEntry` -- `LockDataPayload` / `ParsedLockForm` -- key constants for `lock`, `bridge`, `bridge-w`. - -Why it matters: - -Bridge deposits need canonical note-data encoding. Without it, callers can -construct a shape that hashes differently from Hoon. Base/Ethereum transaction -construction itself is **out of scope**; only note-data encoding for deposits -matters. - -Needed: - -- Add typed bridge deposit/withdrawal note-data payloads. -- Add canonical encode/decode and round-trip tests. -- Add `EthAddress` only if deposit encoding needs it. Do not port Ethereum - transaction types, Base L1 transactions, or bridge contracts. - -### 6. Lower-priority types from `nockchain-types` - -These are present upstream but absent locally: - -- `RequiredPkhPolicy` and `SpendCondition::required_pkh_policy()`. - Useful for wallet signer selection; easy to add. -- `TransactionMetadata`, `LockMetadata`, `VersionedLockMetadata`, - `LegacyLockMetadata`, `InputMetadata`, `OutputLockMap`, `WitnessData`, - `SignatureMap`, `SpendConditionMap`. - Mostly node/explorer metadata; add only if a consumer needs them. -- `BlockchainConstants`, `AsertParams`, `NoteDataConstraints`, `Seconds`. - Consensus configuration, not transaction construction. Keep out unless this - crate is meant to own consensus params. -- `EthAddress`. - Only for bridge deposit encoding; no Base/Ethereum tx construction. - -Do not port `nockchain-types` wholesale just for parity. Port what the shared -builder consumes. - -### 7. `std`/`no_std` split is wrong for sharing - -Local `src/lib.rs` gates nearly everything on `std`: - -- `fee_calculator`, `tx_builder_v1`, `tx_builder_batch`, - `transaction_types*`, `hashing`, `validation` are std-only. -- `no_std` gets only `crypto` and `pokenoun`. - -That means the fee/planner/note-data logic cannot be shared with embedded/wasm -no_std consumers, even though it is pure Rust. - -Needed: - -- Make pure modules available under `alloc`/no_std. -- Gate only serde/JSON/base58/CLI/noun-slab adapters on `std` (or introduce a - separate `serde` feature). -- Avoid serde derives on core types if they prevent no_std; use separate DTOs - or `cfg_attr`. -- Compile the matrix below on every change. - -## Target architecture - -```text -tx-types core (no_std + alloc) - transaction_types{,_v0,_v1} - lock tree - hashing - fee + word_count - note_data normalize/encode - lock_resolver (traits only) - planner core (pure, version-parameterized) - -tx-types std - serde / serde_json / bs58 / chrono / CLI - NounSlab helpers that require std conveniences - -consumer (wallet-engine, nockster-esp, RPC server, CLI) - snapshot fetch - persistence - signing transport / hardware - concrete LockMatcher / SnapshotPageFetcher impls -``` - -Rules: - -1. `std` means "convenience/IO available", not "planning available". -2. Version differences are arguments/enums, not duplicate functions. -3. Traits at the IO boundaries; pure functions in the middle. -4. No `#[cfg(feature = "native")]` duplicate implementation. One shared source, - aliased embedded/upstream dependencies only if the native build is retained. -5. Consensus heights and reference rates are data/config, not hard-coded in - tests or types. - -## Work plan - -Do these as separate PRs; each should keep the whole matrix green. - -### Slice 1: Lock tree + real LMP - -- Port `Lock` tree types and hashing. -- Replace the local `build_lock_merkle_proof_*` with the Hoon algorithm. -- Add `authorization_leaf` and `valid_stub_axis` validation helpers. -- Add tests for leaf, `%2`, `%4`, nested trees, bad axis/path. -- Acceptance: roots/paths/axes match Hoon for at least the fixtures used by - `hoon/tests/dumb/mod/unit/lock-merkle-proof.hoon`; no full proof is just a - wrapped stub. - -### Slice 2: Shared fee + word counting - -- Move `FeeBreakdown`, `witness_divisor`, bridge fee, and word counting into - shared modules. -- Keep `calculate_min_fee` behavior; add parity tests against Hoon fee cases. -- Acceptance: V0/V1, below/at Bythos, witness divisor, multi-input counts all - match Hoon. - -### Slice 3: Note-data normalization + bridge payloads - -- Add typed note-data normalization and canonical encoding. -- Add bridge deposit (`bridge`) and withdrawal (`bridge-w`) payloads. -- Do not add Base/Ethereum transaction construction. -- Acceptance: canonical note-data for lock + bridge deposit/withdrawal - round-trips and hashes match Hoon. - -### Slice 4: Lock resolver + fund maturity - -- Port `FundMaturity`, `LockMatcher`, `LockRootLockMatcher` semantics. -- Add `fund_minimum_at(height)`/candidate-height handling as data. -- Acceptance: protocol-fund and multisig selection match the wallet planner - tests in upstream `crates/nockchain-wallet/src/tests.rs`. - -### Slice 5: Pure planner core - -- Extract selection, conservation, refund, and v0→v1 migration from the - consumers/current builder. -- Keep async snapshot fetch and signing transport in consumers. -- Acceptance: a pure plan can be tested without a browser, node, or hardware; - the wallet and embedded consumers produce the same plan for the same input. - -### Slice 6: Feature split - -- Move pure modules out of `#[cfg(feature = "std")]`. -- Gate only serde/JSON/CLI and noun-slab adapters. -- Acceptance: all commands in the feature matrix compile; no duplicated - planner. - -## How to investigate - -Start from the current local code: - -```sh -cd ~/gits/np/tx-types - -sed -n '80,170p' tx-types/src/block_types.rs -sed -n '1150,1420p' tx-types/src/transaction_types_v1.rs -sed -n '1,120p' tx-types/src/fee_calculator.rs -sed -n '240,620p' tx-types/src/tx_builder_v1.rs -``` - -Read the upstream algorithms: - -```sh -cd ~/gits/nockchain - -sed -n '610,780p' crates/nockchain-types/src/tx_engine/v1/tx.rs -sed -n '1770,2075p' hoon/common/tx-engine-1.hoon -sed -n '440,560p' hoon/common/tx-engine.hoon -sed -n '1080,1130p' hoon/common/tx-engine.hoon - -# Full new wallet builder layer -ls crates/wallet-tx-builder/src -sed -n '1,220p' crates/wallet-tx-builder/src/fee.rs -sed -n '1,260p' crates/wallet-tx-builder/src/lock_resolver.rs -sed -n '1,340p' crates/wallet-tx-builder/src/note_data.rs -``` - -Read the 170,000 consensus change: - -```sh -cd ~/gits/nockchain -git show 9bcc26c6 -- hoon/common/tx-engine.hoon hoon/common/tx-engine-1.hoon -git show 9bcc26c6 -- crates/nockchain-wallet/src/create_tx.rs -sed -n '1,120p' changelog/protocol/017-anthropos.md -``` - -Note that commit did **not** change `crates/nockchain-types`. The Rust type -layout is not the new consensus change; the validation semantics and wallet -planning are. - -## How to validate - -Current matrix: - -```sh -cd ~/gits/np/tx-types - -cargo test -p tx-types --lib - -cargo build -p tx-types \ - --no-default-features --target thumbv7em-none-eabihf - -cargo build -p tx-types \ - --no-default-features --features std,embedded \ - --target wasm32-unknown-unknown -``` - -When the pure modules move out of `std`, adapt those commands: - -```sh -# no_std pure core -cargo build -p tx-types --no-default-features \ - --target thumbv7em-none-eabihf - -# no_std + serde/JSON if you introduce a serde feature -cargo build -p tx-types --no-default-features --features serde -``` - -Consumer checks (depends on how they pin this crate): - -```sh -cargo check --manifest-path ~/gits/np/nockchain-rpc-server/Cargo.toml -cargo check --manifest-path ~/gits/np/siger-esp/packages/wallet-engine/Cargo.toml -cargo test --manifest-path ~/gits/np/siger-esp/packages/wallet-engine/Cargo.toml -cargo check --manifest-path ~/gits/np/siger-esp/nockster-esp/Cargo.toml -``` - -Do not rely on tests alone. For LMP/planning, build a small scenario and -compare the local output against a Hoon-produced fixture or the upstream Rust -builder. A round-trip test only proves self-consistency, not consensus. - -## Tests to add - -- `SpendCondition` with one `%pkh`: stub and full proof, axis, path. -- `%2`, `%4`, `%8`, `%16` trees: flatten order, count, root. -- Index adjustment for a tagged head. -- `authorization_leaf`: accepts correct final sibling, rejects wrong tag/depth. -- `valid_stub_axis`: ordinary `%1` spend requires axis `1`. -- Empty spend condition rejected. -- Fund maturity: protocol-fund spend before/at/after the candidate-height - minimum. -- Fee parity: single/multiple inputs, V0/V1, Bythos on/off, witness divisor. -- Note-data: lock and bridge deposit/withdrawal canonical round-trip and hash. -- V0→V1 migration and refund conservation. -- `std` and `no_std` produce the same hashes/plans. - -## Pitfalls - -- `HashMap`/`ZMap` iteration order is not stable. Wallet lists and spend maps - must be explicitly sorted/canonicalized before planning or UI selection. -- Do not treat a successful noun round-trip as proof of consensus validity. -- Do not use the current chain height inside a type or constant. Pass the - candidate height into planning/validation. -- Do not port AI-PoW `%consrule`, ASERT anchors, or node gRPC policy. Those are - not transaction-builder concerns. -- Do not port Base/Ethereum transaction construction. Only bridge note-data - encoding for deposits is relevant. -- Keep V0 behavior frozen. The v0→v1 migration is the only place where the two - should meet. -- The fork's `native`/`embedded` split must not become two implementations. - Alias dependencies if needed; share the source. - -## Definition of done - -- One lock/type implementation, with `Lock` trees and V0/V1 differences explicit. -- LMP builder produces real tree proofs and validates them locally. -- Post-170,000 validation semantics mirrored and tested. -- Shared fee/word/note-data/lock-resolver/planner logic available to both - `std` and `no_std` consumers. -- Consumers only supply IO: snapshots, persistence, signing, transport. -- The full compile/test matrix passes. -- A new contributor can point to a single module for each rule and find tests - for it. - -## File map - -Local: - -- `tx-types/src/lib.rs` — feature gates and aliases. -- `tx-types/src/transaction_types.rs` — legacy/common types. -- `tx-types/src/transaction_types_v0.rs` — V0 types. -- `tx-types/src/transaction_types_v1.rs` — V1 types, `SpendCondition`, - `LockPrimitive`, `MerkleProof`, `LockMerkleProof`. -- `tx-types/src/block_types.rs` — coinbase/page plus LMP builder. -- `tx-types/src/fee_calculator.rs` — fee constants and minimum fee. -- `tx-types/src/tx_builder_v1.rs` — simplified spend builder. -- `tx-types/src/tx_builder_batch.rs` — simplified batch builder. -- `tx-types/src/hashing/` — Tip5, tx-id, hashable. -- `tx-types/src/crypto/` — Cheetah, Goldilocks, SLIP-10. -- `tx-types/src/validation/` — signature/transaction validation. - -Upstream: - -- `crates/nockchain-types/src/tx_engine/v1/tx.rs` -- `crates/nockchain-types/src/tx_engine/v1/note.rs` -- `crates/nockchain-types/src/tx_engine/v1/signatures.rs` -- `crates/nockchain-types/src/tx_engine/v1/hashable.rs` -- `crates/nockchain-types/src/blockchain_constants.rs` -- `crates/wallet-tx-builder/src/*` -- `hoon/common/tx-engine.hoon` -- `hoon/common/tx-engine-1.hoon` diff --git a/pokenoun/src/arena.rs b/pokenoun/src/arena.rs index ede5fb8..098d975 100644 --- a/pokenoun/src/arena.rs +++ b/pokenoun/src/arena.rs @@ -2,16 +2,16 @@ extern crate alloc; use alloc::vec::Vec; -#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum Noun { Atom(AtomId), Cell(CellId), } -#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct AtomId(pub(crate) u32); -#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct CellId(pub(crate) u32); #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -26,13 +26,19 @@ struct AtomSlice { len: u32, } -#[derive(Debug, Default)] +#[derive(Debug)] pub struct Arena { atoms: Vec, pub(crate) atom_data: Vec, cells: Vec, } +impl Default for Arena { + fn default() -> Self { + Self::new() + } +} + #[inline] fn trim_trailing_zeros(bytes: &[u8]) -> &[u8] { let mut len = bytes.len(); diff --git a/pokenoun/src/codec.rs b/pokenoun/src/codec.rs new file mode 100644 index 0000000..ab8a8d4 --- /dev/null +++ b/pokenoun/src/codec.rs @@ -0,0 +1,264 @@ +//! Typed noun encoding over an allocation-only arena. + +use crate::{Arena, Noun}; +use alloc::{string::String, vec::Vec}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Error(pub &'static str); + +impl core::fmt::Display for Error { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.0) + } +} + +impl core::error::Error for Error {} + +pub trait Encode { + fn encode(&self, arena: &mut Arena) -> Result; + + fn to_jam(&self) -> Result, Error> { + let mut arena = Arena::new(); + let noun = self.encode(&mut arena)?; + Ok(crate::jam(noun, &arena)) + } +} + +pub trait Decode: Sized { + fn decode(noun: Noun, arena: &Arena) -> Result; + + fn from_jam(bytes: &[u8]) -> Result { + let mut arena = Arena::new(); + let noun = crate::cue(bytes, &mut arena).map_err(|_| Error("invalid jam"))?; + Self::decode(noun, &arena) + } +} + +pub fn pair(noun: Noun, arena: &Arena) -> Result<(Noun, Noun), Error> { + match noun { + Noun::Cell(id) => { + let cell = arena.cell(id); + Ok((cell.head, cell.tail)) + } + _ => Err(Error("expected cell")), + } +} + +pub fn is_null(noun: Noun, arena: &Arena) -> bool { + matches!(noun, Noun::Atom(id) if arena.atom_u64(id) == Some(0)) +} + +/// Encode a Hoon tuple: the last element is the tail, without a list terminator. +pub fn tuple(arena: &mut Arena, items: &[Noun]) -> Result { + let mut items = items.iter().rev(); + let mut tail = *items.next().ok_or(Error("empty tuple"))?; + for head in items { + tail = arena.alloc_cell(*head, tail); + } + Ok(tail) +} + +pub struct Fields { + noun: Noun, + remaining: usize, +} + +impl Fields { + pub fn new(noun: Noun, count: usize) -> Self { + Self { + noun, + remaining: count, + } + } + + pub fn next(&mut self, arena: &Arena) -> Result { + if self.remaining == 0 { + return Err(Error("no remaining tuple fields")); + } + self.remaining -= 1; + if self.remaining == 0 { + return Ok(self.noun); + } + let (head, tail) = pair(self.noun, arena)?; + self.noun = tail; + Ok(head) + } +} + +impl Encode for u64 { + fn encode(&self, arena: &mut Arena) -> Result { + Ok(arena.alloc_atom_u64(*self)) + } +} + +impl Decode for u64 { + fn decode(noun: Noun, arena: &Arena) -> Result { + match noun { + Noun::Atom(id) => arena.atom_u64(id).ok_or(Error("atom exceeds u64")), + _ => Err(Error("expected atom")), + } + } +} + +impl Encode for bool { + fn encode(&self, arena: &mut Arena) -> Result { + u64::from(!self).encode(arena) + } +} + +impl Decode for bool { + fn decode(noun: Noun, arena: &Arena) -> Result { + match u64::decode(noun, arena)? { + 0 => Ok(true), + 1 => Ok(false), + _ => Err(Error("boolean must be 0 or 1")), + } + } +} + +impl Encode for str { + fn encode(&self, arena: &mut Arena) -> Result { + Ok(arena.alloc_atom_bytes(self.as_bytes())) + } +} + +impl Encode for String { + fn encode(&self, arena: &mut Arena) -> Result { + self.as_str().encode(arena) + } +} + +impl Decode for String { + fn decode(noun: Noun, arena: &Arena) -> Result { + match noun { + Noun::Atom(id) => String::from_utf8(arena.atom_bytes_trimmed(id).to_vec()) + .map_err(|_| Error("atom is not UTF-8")), + _ => Err(Error("expected string atom")), + } + } +} + +impl Encode for &T { + fn encode(&self, arena: &mut Arena) -> Result { + (*self).encode(arena) + } +} + +impl Encode for Option { + fn encode(&self, arena: &mut Arena) -> Result { + match self { + None => Ok(arena.atom0()), + Some(value) => { + let value = value.encode(arena)?; + Ok(arena.alloc_cell(arena.atom0(), value)) + } + } + } +} + +impl Decode for Option { + fn decode(noun: Noun, arena: &Arena) -> Result { + if is_null(noun, arena) { + return Ok(None); + } + let (tag, value) = pair(noun, arena)?; + if !is_null(tag, arena) { + return Err(Error("unit tag must be zero")); + } + T::decode(value, arena).map(Some) + } +} + +impl Encode for Vec { + fn encode(&self, arena: &mut Arena) -> Result { + let mut tail = arena.atom0(); + for value in self.iter().rev() { + let head = value.encode(arena)?; + tail = arena.alloc_cell(head, tail); + } + Ok(tail) + } +} + +impl Decode for Vec { + fn decode(mut noun: Noun, arena: &Arena) -> Result { + let mut out = Vec::new(); + while !is_null(noun, arena) { + let (head, tail) = pair(noun, arena)?; + out.push(T::decode(head, arena)?); + noun = tail; + } + Ok(out) + } +} + +impl Encode for [T; N] { + fn encode(&self, arena: &mut Arena) -> Result { + let items: Result, _> = self.iter().map(|value| value.encode(arena)).collect(); + tuple(arena, &items?) + } +} + +impl Decode for [T; N] { + fn decode(noun: Noun, arena: &Arena) -> Result { + let mut fields = Fields::new(noun, N); + (0..N) + .map(|_| T::decode(fields.next(arena)?, arena)) + .collect::, _>>()? + .try_into() + .map_err(|_| Error("wrong array length")) + } +} + +impl Encode for (A, B) { + fn encode(&self, arena: &mut Arena) -> Result { + let left = self.0.encode(arena)?; + let right = self.1.encode(arena)?; + Ok(arena.alloc_cell(left, right)) + } +} + +impl Decode for (A, B) { + fn decode(noun: Noun, arena: &Arena) -> Result { + let (left, right) = pair(noun, arena)?; + Ok((A::decode(left, arena)?, B::decode(right, arena)?)) + } +} + +/// Copy a noun between arenas, preserving sharing without recursive expansion. +pub fn copy(noun: Noun, source: &Arena, target: &mut Arena) -> Noun { + use alloc::collections::BTreeMap; + fn key(noun: Noun) -> (bool, u32) { + match noun { + Noun::Atom(id) => (false, id.0), + Noun::Cell(id) => (true, id.0), + } + } + let mut copies = BTreeMap::new(); + let mut pending = alloc::vec![(noun, false)]; + while let Some((current, visited)) = pending.pop() { + if copies.contains_key(&key(current)) { + continue; + } + match current { + Noun::Atom(id) => { + copies.insert( + key(current), + target.alloc_atom_bytes(source.atom_bytes_trimmed(id)), + ); + } + Noun::Cell(id) => { + let cell = source.cell(id); + if visited { + copies.insert( + key(current), + target.alloc_cell(copies[&key(cell.head)], copies[&key(cell.tail)]), + ); + } else { + pending.extend([(current, true), (cell.tail, false), (cell.head, false)]); + } + } + } + } + copies[&key(noun)] +} diff --git a/pokenoun/src/jam.rs b/pokenoun/src/jam.rs index 68a8232..eaef09d 100644 --- a/pokenoun/src/jam.rs +++ b/pokenoun/src/jam.rs @@ -1,6 +1,9 @@ extern crate alloc; -use alloc::vec::Vec; +use alloc::{ + collections::{BTreeMap, BTreeSet}, + vec::Vec, +}; use crate::arena::{Arena, CellId, Noun}; @@ -112,6 +115,7 @@ enum Slot { Root, Head(CellId), Tail(CellId), + Complete(u64, Noun), } /// Decode a jammed noun (little-endian bytes, LSB-first bits) into an in-memory noun tree. @@ -124,6 +128,13 @@ pub fn cue(bytes: &[u8], arena: &mut Arena) -> Result { stack.push(Slot::Root); while let Some(slot) = stack.pop() { + if let Slot::Complete(position, noun) = slot { + let index = backrefs + .binary_search_by_key(&position, |entry| entry.0) + .unwrap_err(); + backrefs.insert(index, (position, noun)); + continue; + } let start = reader.pos(); let first = reader.read_bit()?; if !first { @@ -162,6 +173,7 @@ pub fn cue(bytes: &[u8], arena: &mut Arena) -> Result { Slot::Root => root = Some(noun), Slot::Head(id) => arena.cell_mut(id).head = noun, Slot::Tail(id) => arena.cell_mut(id).tail = noun, + Slot::Complete(_, _) => unreachable!(), } continue; } @@ -174,23 +186,25 @@ pub fn cue(bytes: &[u8], arena: &mut Arena) -> Result { Slot::Root => root = Some(noun), Slot::Head(id) => arena.cell_mut(id).head = noun, Slot::Tail(id) => arena.cell_mut(id).tail = noun, + Slot::Complete(_, _) => unreachable!(), } continue; } // cell (10) let cell_noun = arena.alloc_cell(arena.atom0(), arena.atom0()); - backrefs.push((start, cell_noun)); match slot { Slot::Root => root = Some(cell_noun), Slot::Head(id) => arena.cell_mut(id).head = cell_noun, Slot::Tail(id) => arena.cell_mut(id).tail = cell_noun, + Slot::Complete(_, _) => unreachable!(), } let Noun::Cell(cell_id) = cell_noun else { unreachable!(); }; // Decode head then tail; stack is LIFO so push tail first. + stack.push(Slot::Complete(start, cell_noun)); stack.push(Slot::Tail(cell_id)); stack.push(Slot::Head(cell_id)); } @@ -271,36 +285,94 @@ fn mat(writer: &mut BitWriter, atom_bits: usize, atom_bytes: &[u8]) -> Result<() Ok(()) } +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Value<'a> { + Atom(&'a [u8]), + Cell(usize, usize), +} + +fn noun_key(noun: Noun) -> (bool, u32) { + match noun { + Noun::Atom(id) => (false, id.0), + Noun::Cell(id) => (true, id.0), + } +} + +/// Assign equal nouns the same ID, independently of arena allocation and sharing. +fn intern(noun: Noun, arena: &Arena) -> Result<(usize, Vec>), CodecError> { + let mut ids = BTreeMap::new(); + let mut values = Vec::new(); + let mut interned = BTreeMap::new(); + let mut active = BTreeSet::new(); + let mut pending = alloc::vec![(noun, false)]; + while let Some((current, visited)) = pending.pop() { + let key = noun_key(current); + if ids.contains_key(&key) { + continue; + } + let value = match current { + Noun::Atom(id) => Value::Atom(arena.atom_bytes_trimmed(id)), + Noun::Cell(id) => { + let cell = arena.cell(id); + if !visited { + if !active.insert(key) { + return Err(CodecError::InvalidEncoding); + } + pending.extend([(current, true), (cell.tail, false), (cell.head, false)]); + continue; + } + active.remove(&key); + Value::Cell(ids[&noun_key(cell.head)], ids[&noun_key(cell.tail)]) + } + }; + let next = values.len(); + let id = *interned.entry(value).or_insert(next); + if id == next { + values.push(value); + } + ids.insert(key, id); + } + Ok((ids[&noun_key(noun)], values)) +} + fn jam_noun(writer: &mut BitWriter, arena: &Arena, noun: Noun) -> Result<(), CodecError> { - let mut stack: Vec = Vec::new(); - stack.push(noun); - while let Some(n) = stack.pop() { - match n { - Noun::Atom(id) => { - writer.write_bit(false); // 0 tag - let bytes = arena.atom_bytes(id); - let bits = met0_bytes(bytes); - mat(writer, bits, bytes)?; + let (root, values) = intern(noun, arena)?; + let mut backrefs = BTreeMap::::new(); + let mut pending = alloc::vec![root]; + while let Some(id) = pending.pop() { + if let Some(&position) = backrefs.get(&id) { + let use_backref = match values[id] { + Value::Cell(_, _) => true, + Value::Atom(bytes) => met0_bytes(bytes) > met0_u64(position), + }; + if use_backref { + writer.write_bit(true); + writer.write_bit(true); + mat(writer, met0_u64(position), &position.to_le_bytes())?; + continue; } - Noun::Cell(id) => { + } else { + backrefs.insert(id, writer.bit as u64); + } + match values[id] { + Value::Atom(bytes) => { + writer.write_bit(false); + mat(writer, met0_bytes(bytes), bytes)?; + } + Value::Cell(head, tail) => { writer.write_bit(true); - writer.write_bit(false); // 10 tag - let cell = arena.cell(id); - // DFS: head then tail => push tail first. - stack.push(cell.tail); - stack.push(cell.head); + writer.write_bit(false); + pending.extend([tail, head]); } } } Ok(()) } -/// Encode a noun tree into jam bytes (little-endian bytes, LSB-first bits). -/// -/// This encoder currently emits no backreferences; output is still valid jam. +/// Encode a finite noun as canonical JAM, using the first occurrence for backreferences. pub fn jam(noun: Noun, arena: &Arena) -> Vec { let mut writer = BitWriter::new(); - jam_noun(&mut writer, arena, noun).expect("jam"); + jam_noun(&mut writer, arena, noun).expect("JAM requires a finite noun"); writer.bytes } @@ -381,15 +453,29 @@ mod tests { } #[test] - fn shared_dag_walk_is_bounded() { - // A cell whose tail is a backreference to itself: legal jam, but its - // logical expansion is infinite. The walk must return an error. - let encoded = [0xf1, 0x01]; + fn cue_rejects_a_backreference_to_an_incomplete_cell() { let mut arena = Arena::new(); - let noun = cue(&encoded, &mut arena).expect("cyclic jam is decodable"); assert!(matches!( - crate::tip5::hash_noun_varlen(noun, &arena), - Err(crate::tip5::Tip5Error::WalkTooLarge) + cue(&[0xf1, 0x01], &mut arena), + Err(CodecError::InvalidBackref) )); } + + #[test] + fn equal_cells_use_the_same_backreference_without_shared_allocation() { + let mut arena = Arena::new(); + let a = arena.alloc_atom_u64(123456); + let b = arena.alloc_atom_u64(789012); + let first = arena.alloc_cell(a, b); + let shared = arena.alloc_cell(first, first); + let a = arena.alloc_atom_u64(123456); + let b = arena.alloc_atom_u64(789012); + let second = arena.alloc_cell(a, b); + let distinct = arena.alloc_cell(first, second); + assert_eq!(jam(shared, &arena), jam(distinct, &arena)); + let encoded = jam(distinct, &arena); + let mut decoded = Arena::new(); + let value = cue(&encoded, &mut decoded).unwrap(); + assert_eq!(jam(value, &decoded), encoded); + } } diff --git a/pokenoun/src/lib.rs b/pokenoun/src/lib.rs index 9b4426d..fd86bc5 100644 --- a/pokenoun/src/lib.rs +++ b/pokenoun/src/lib.rs @@ -6,6 +6,7 @@ extern crate alloc; extern crate std; mod arena; +pub mod codec; mod jam; mod tip5; mod zmap; @@ -13,6 +14,6 @@ mod zset; pub use arena::{Arena, AtomId, Cell, CellId, Noun}; pub use jam::{cue, jam, CodecError}; -pub use tip5::{hash_noun_varlen, hash_ten_cell, Tip5Error, GOLDILOCKS_P}; +pub use tip5::{hash_noun_varlen, hash_ten_cell, hash_varlen_words, Tip5Error, GOLDILOCKS_P}; pub use zmap::{canonical_zmap_put, ZMapError}; pub use zset::{canonical_zset_put, ZSetError}; diff --git a/tx-types/Cargo.toml b/tx-types/Cargo.toml index 7bdf212..6f96a43 100644 --- a/tx-types/Cargo.toml +++ b/tx-types/Cargo.toml @@ -47,14 +47,15 @@ serde_json = { workspace = true, optional = true } either = { workspace = true, optional = true } num-bigint = { workspace = true, optional = true } num-traits = { workspace = true, optional = true } -bs58 = { workspace = true, features = ["check"], optional = true } +bs58 = { workspace = true } anyhow = { workspace = true, optional = true } chrono = { workspace = true, features = ["serde"], optional = true } bip39 = { workspace = true, optional = true, features = ["zeroize"] } pbkdf2 = { workspace = true, optional = true } -bytes = { workspace = true, optional = true } +bytes = { workspace = true } once_cell = { workspace = true, optional = true } tracing = { workspace = true, optional = true } +base64 = { version = "0.22", optional = true } [[bin]] name = "sign_raw_tx" @@ -95,18 +96,19 @@ native = [ ] std = [ + "dep:base64", "dep:serde", "dep:serde_json", "dep:either", "dep:num-bigint", "dep:num-traits", - "dep:bs58", + "bs58/std", "dep:anyhow", "dep:chrono", "dep:bip39", "bip39/std", "dep:pbkdf2", - "dep:bytes", + "bytes/std", "dep:once_cell", "dep:tracing", ] diff --git a/tx-types/src/block_types.rs b/tx-types/src/block_types.rs index 70d597d..432e2d5 100644 --- a/tx-types/src/block_types.rs +++ b/tx-types/src/block_types.rs @@ -2,17 +2,17 @@ use crate::collections::zmap::ZMap; use crate::collections::zset::ZSet; use crate::fee_calculator::BYTHOS_PHASE; use crate::hashing::hashable::Hashable; -use crate::hashing::hasher::{hash_hashable, hash_ten_cell}; +use crate::hashing::hasher::hash_hashable; +use crate::lock::{build_lock_merkle_proof, Lock as V1Lock}; use crate::transaction_types::*; use crate::transaction_types_v0::*; use crate::transaction_types_v1::*; use bytes::Bytes; use chrono::{DateTime, TimeZone, Utc}; use nockapp::noun::slab::NounSlab; -use nockvm::noun::{Noun, NounSpace}; +use nockvm::noun::{Noun, NounAllocator, NounSpace}; use noun_serde::{NounDecode, NounDecodeError, NounEncode}; use num_bigint::BigUint; -use serde::{Deserialize, Serialize}; // ============================================================================ // Block Type Wrapper Types @@ -70,7 +70,8 @@ pub fn make_name(pkh_hashes: ZSet, parent_hash: Hash) -> NName { let lk: SpendCondition = SpendCondition { p: vec![pkh, tim] }; - let lmp = build_lock_merkle_proof(lk, 1, BYTHOS_PHASE); + let lmp = build_lock_merkle_proof(&V1Lock::SpendCondition(lk), 1, BYTHOS_PHASE) + .expect("coinbase condition is nonempty"); let root = lmp.proof().root.clone(); NName::new_v1( @@ -82,151 +83,6 @@ pub fn make_name(pkh_hashes: ZSet, parent_hash: Hash) -> NName { ) } -/// Build a lock merkle proof appropriate for the given page. -/// -/// Hoon rejects a `%full` proof before Bythos activation -/// (`check-context.check: (gte now bythos-phase)`), so choose the stub form -/// below `BYTHOS_PHASE` and the full form at/after it. Callers that know the -/// current chain height should pass it here. -pub fn build_lock_merkle_proof( - form: SpendCondition, - leaf_number: u64, - page: u64, -) -> LockMerkleProof { - if page >= BYTHOS_PHASE { - build_lock_merkle_proof_full(form, leaf_number) - } else { - build_lock_merkle_proof_stub(form, leaf_number) - } -} - -pub fn build_lock_merkle_proof_stub(form: SpendCondition, leaf_number: u64) -> LockMerkleProof { - let hashable_form = Hashable::Hash(form.to_hash()); - let (axis, merkle_proof) = prove_hashable_by_index(hashable_form, leaf_number); - let spend_condition = traverse_lock(form); - LockMerkleProof::new_stub(spend_condition, axis, merkle_proof) -} - -pub fn build_lock_merkle_proof_full(form: SpendCondition, leaf_number: u64) -> LockMerkleProof { - let (spend_condition, axis, merkle_proof) = - build_lock_merkle_proof_stub(form, leaf_number).into_parts(); - LockMerkleProof::new_full(spend_condition, axis, merkle_proof) -} - -/// Tree address composition (peg) - combines two axes into a single address -/// -/// This function computes the composition of tree addresses in Nock. -/// For example, peg(2, 3) = 5 (go left, then right) -/// -/// The algorithm works by bit manipulation: -/// - Take all bits of `a` -/// - Take all bits of `b` except the leading 1 -/// - Concatenate them: [b without leading 1][a] -fn peg(a: u64, b: u64) -> u64 { - assert!(a != 0, "peg: a must be non-zero"); - assert!(b != 0, "peg: b must be non-zero"); - - // Find the number of bits in b - let b_bits = 64 - b.leading_zeros(); - - // The output has (a_bits + b_bits - 1) bits - // We remove the leading 1 from b and concatenate with all of a - - // Mask to get b without its leading bit - let b_mask = (1u64 << (b_bits - 1)) - 1; - let b_without_leading = b & b_mask; - - // Shift a left by (b_bits - 1) and OR with b_without_leading - (a << (b_bits - 1)) | b_without_leading -} - -/// Count the number of leaves in a Hashable tree -fn leaf_count(h: &Hashable) -> u64 { - match h { - // Non-cell cases: Leaf, Hash, and List all count as 1 leaf - Hashable::Leaf(_) | Hashable::Hash(_) | Hashable::List(_) => 1, - // Cell case: sum of left and right subtrees - Hashable::Cell(left, right) => leaf_count(left) + leaf_count(right), - } -} - -/// Build a merkle proof for the leaf at the given index in a Hashable tree -/// -/// This is the core recursive function that: -/// - Returns the root hash, path (list of sibling hashes), and tree axis -/// - Navigates left or right based on leaf counts -/// - Builds the proof path as it recurses back up -fn prove_hashable_go(node: &Hashable, index: u64) -> (Hash, Vec, u64) { - match node { - // Base case: non-cell node (Leaf, Hash, or List) - Hashable::Leaf(_) | Hashable::Hash(_) | Hashable::List(_) => { - let root = hash_hashable(node).expect("lock hashable is u64-based"); - (root, Vec::new(), 1) - } - - // Recursive case: Cell - Hashable::Cell(left, right) => { - let lc = leaf_count(left); - - if index <= lc { - // The target leaf is in the left subtree - let (rec_root, mut rec_path, rec_axis) = prove_hashable_go(left, index); - let sibling = hash_hashable(right).expect("lock hashable is u64-based"); - - // Combine the recursive root with the sibling - let root = hash_ten_cell(rec_root, sibling.clone()); - - // Add sibling to the path - rec_path.push(sibling); - - // Compute new axis: go left (2) then follow rec_axis - let axis = peg(2, rec_axis); - - (root, rec_path, axis) - } else { - // The target leaf is in the right subtree - let (rec_root, mut rec_path, rec_axis) = prove_hashable_go(right, index - lc); - let sibling = hash_hashable(left).expect("lock hashable is u64-based"); - - // Combine sibling with the recursive root - let root = hash_ten_cell(sibling.clone(), rec_root); - - // Add sibling to the path - rec_path.push(sibling); - - // Compute new axis: go right (3) then follow rec_axis - let axis = peg(3, rec_axis); - - (root, rec_path, axis) - } - } - } -} - -/// Build a merkle proof for a hashable at a given index -/// -/// Given a Hashable tree structure and a 1-based index, this returns: -/// - The axis (tree address) of the leaf -/// - A MerkleProof containing the root hash and sibling path -/// -/// # Panics -/// Panics if index is 0 (indices are 1-based) -pub fn prove_hashable_by_index(hashable: Hashable, index: u64) -> (u64, MerkleProof) { - assert!( - index != 0, - "prove_hashable_by_index: index must be non-zero (1-based indexing)" - ); - - let (root, path, axis) = prove_hashable_go(&hashable, index); - - (axis, MerkleProof { root, path }) -} - -pub fn traverse_lock(spend_condition: SpendCondition) -> SpendCondition { - // for coinbase we don't need to implement this - spend_condition -} - /// Wrapper for transaction ID set #[derive(Debug, Clone, NounDecode, NounEncode)] pub struct TransactionIds { @@ -770,3 +626,17 @@ mod tests { ); } } + +impl pokenoun::codec::Decode for Page { + fn decode( + noun: pokenoun::Noun, + arena: &pokenoun::Arena, + ) -> Result { + let mut slab: NounSlab = NounSlab::new(); + let noun = slab + .cue_into(Bytes::from(pokenoun::jam(noun, arena))) + .map_err(|_| pokenoun::codec::Error("invalid page jam"))?; + Self::from_noun(&noun, &slab.noun_space()) + .map_err(|_| pokenoun::codec::Error("invalid page noun")) + } +} diff --git a/tx-types/src/collections/zmap.rs b/tx-types/src/collections/zmap.rs index d92318d..f643563 100644 --- a/tx-types/src/collections/zmap.rs +++ b/tx-types/src/collections/zmap.rs @@ -1,8 +1,10 @@ use crate::hashing::tip5::Tip5Error; -use crate::hashing::tip5::Tip5Hasher; +use alloc::{boxed::Box, string::ToString, vec::Vec}; +use pokenoun::codec::{is_null, pair, Decode, Encode, Error}; +use pokenoun::{Arena, Noun}; + use ibig::UBig; -use nockvm::noun::D; -use noun_serde::NounEncode; + /// Z-Map: A deterministic self-balancing binary search tree (treap) /// Based on the Hoon z-map implementation from nockchain /// @@ -10,8 +12,8 @@ use noun_serde::NounEncode; /// - Ordering: gor-tip (single hash comparison) /// - Balancing: mor-tip (double hash comparison) /// - Fallback: dor-tip (structural comparison) -use std::cmp::Ordering; -use std::fmt::Debug; +use core::cmp::Ordering; +use core::fmt::Debug; /// A z-map is a self-balancing binary search tree #[derive(Clone)] @@ -53,7 +55,7 @@ impl DorTip for T { impl ZMap where - K: NounEncode + DorTip + Clone + Debug, + K: Encode + DorTip + Clone + Debug, V: Clone + Debug, { /// Create a new empty z-map @@ -72,7 +74,9 @@ where /// Fallible insertion; see [`Self::put`]. pub fn try_put(&mut self, key: K, value: V) -> Result<(), Tip5Error> { - self.root = Self::put_recursive(self.root.take(), key, value)?; + Self::compute_tip5_hash(&key)?; + let root = Self::put_recursive(self.root.clone(), key, value)?; + self.root = root; Ok(()) } @@ -284,9 +288,9 @@ where fn tap_recursive(node: &Option>>, result: &mut Vec<(K, V)>) { if let Some(n) = node { - Self::tap_recursive(&n.left, result); - result.push((n.key.clone(), n.value.clone())); Self::tap_recursive(&n.right, result); + result.push((n.key.clone(), n.value.clone())); + Self::tap_recursive(&n.left, result); } } @@ -305,17 +309,11 @@ where /// Compute TIP5 hash of a key and convert to UBig integer /// Returns a UBig representing the hash for comparison. fn compute_tip5_hash(key: &K) -> Result { - use nockapp::noun::slab::NounSlab; - - use nockvm::noun::NounAllocator; - - // Create a noun slab and convert key to noun - let mut slab: NounSlab = NounSlab::new(); - let key_noun = key.to_noun(&mut slab); - let space = slab.noun_space(); - - // Compute the TIP5 hash - Tip5Hasher::hash_noun_varlen(key_noun, &space).map(|hash| hash.to_ubig()) + let bytes = key + .to_jam() + .map_err(|error| Tip5Error::SerializationError(error.to_string()))?; + let hash = crate::hashing::hasher::hash_noun_varlen(&bytes)?; + Ok(hash.to_ubig()) } /// gor-tip: Compare by single hash (UBig integer), fallback to dor-tip @@ -344,23 +342,11 @@ where /// Compute double-tip: hash(hash(value) ++ hash(value)) fn compute_double_tip5_hash(key: &K) -> Result { - use crate::hashing::hasher::hash_ten_cell; - use nockapp::noun::slab::NounSlab; - - use nockvm::noun::NounAllocator; - - // First compute the regular hash - let mut slab: NounSlab = NounSlab::new(); - let key_noun = key.to_noun(&mut slab); - let space = slab.noun_space(); - - let hash = Tip5Hasher::hash_noun_varlen(key_noun, &space)?; - - // Use hash_ten_cell with two copies of the hash - let double_hash = hash_ten_cell(hash.clone(), hash); - - // Convert to UBig - Ok(double_hash.to_ubig()) + let bytes = key + .to_jam() + .map_err(|error| Tip5Error::SerializationError(error.to_string()))?; + let hash = crate::hashing::hasher::hash_noun_varlen(&bytes)?; + Ok(crate::hashing::hash_ten_cell(hash.clone(), hash).to_ubig()) } /// Build from an iterator (gas equivalent) @@ -472,7 +458,7 @@ where // Implement Default impl Default for ZMap where - K: NounEncode + DorTip + Clone + Debug, + K: Encode + DorTip + Clone + Debug, V: Clone + Debug, { fn default() -> Self { @@ -482,7 +468,7 @@ where // Debug implementation for visualization impl Debug for ZMap { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "ZMap {{ root: ")?; fmt_node(&self.root, f, 0)?; write!(f, " }}") @@ -492,9 +478,9 @@ impl Debug for ZMap { // Helper function for debug formatting fn fmt_node( node: &Option>>, - f: &mut std::fmt::Formatter<'_>, + f: &mut core::fmt::Formatter<'_>, depth: usize, -) -> std::fmt::Result { +) -> core::fmt::Result { match node { None => write!(f, "~"), Some(n) => { @@ -520,6 +506,7 @@ fn fmt_node( #[cfg(all(test, feature = "legacy-tests"))] mod tests { use super::*; + use noun_serde::{NounDecode, NounEncode}; // Wrapper for i32 to implement NounEncode #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -538,6 +525,14 @@ mod tests { Atom::new(alloc, value).as_noun() } } + impl pokenoun::codec::Encode for TestInt { + fn encode( + &self, + arena: &mut pokenoun::Arena, + ) -> Result { + (self.0 as i64 as u64).encode(arena) + } + } // TestInt already has DorTip via the default implementation for Ord @@ -2270,134 +2265,67 @@ mod tests { } } -// Implement NounEncode for ZMap -impl noun_serde::NounEncode for ZMap -where - K: noun_serde::NounEncode + DorTip + Clone, - V: noun_serde::NounEncode + Clone, -{ - fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { - use nockvm::noun::{Noun, T}; - - // Convert to a tree structure matching Hoon's z-map - // Empty map is ~ - // Node is [[key value] left-tree right-tree] - match &self.root { - None => D(0), // Empty map is ~ - Some(node) => Self::node_to_noun_encode(node, alloc), +impl Encode for ZMap { + fn encode(&self, arena: &mut Arena) -> Result { + fn tree( + node: &Option>>, + arena: &mut Arena, + ) -> Result { + let Some(node) = node else { + return Ok(arena.atom0()); + }; + let key = node.key.encode(arena)?; + let value = node.value.encode(arena)?; + let pair = arena.alloc_cell(key, value); + let left = tree(&node.left, arena)?; + let right = tree(&node.right, arena)?; + let children = arena.alloc_cell(left, right); + Ok(arena.alloc_cell(pair, children)) } + tree(&self.root, arena) } } -impl ZMap +impl Decode for ZMap where - K: noun_serde::NounEncode + Clone, - V: noun_serde::NounEncode + Clone, + K: Decode + Encode + DorTip + Clone + Debug, + V: Decode + Clone + Debug, { - fn node_to_noun_encode( - node: &Node, - alloc: &mut A, - ) -> nockvm::noun::Noun { - use nockvm::noun::{Noun, T}; - - // Create [key value] pair - let key_noun = node.key.to_noun(alloc); - let value_noun = node.value.to_noun(alloc); - let pair = T(alloc, &[key_noun, value_noun]); - - // Create left and right subtrees - let left_noun = match &node.left { - None => D(0), - Some(left) => Self::node_to_noun_encode(left, alloc), - }; - - let right_noun = match &node.right { - None => D(0), - Some(right) => Self::node_to_noun_encode(right, alloc), - }; + fn decode(noun: Noun, arena: &Arena) -> Result { + let mut map = Self::new(); + let mut pending = alloc::vec![noun]; + while let Some(noun) = pending.pop() { + if is_null(noun, arena) { + continue; + } + let (entry, children) = pair(noun, arena)?; + let (key, value) = pair(entry, arena)?; + let (left, right) = pair(children, arena)?; + map.try_put(K::decode(key, arena)?, V::decode(value, arena)?) + .map_err(|_| Error("unhashable z-map key"))?; + pending.extend([right, left]); + } + Ok(map) + } +} - // Create [[key value] [left right]] - let children = T(alloc, &[left_noun, right_noun]); - T(alloc, &[pair, children]) +#[cfg(feature = "std")] +impl noun_serde::NounEncode for ZMap { + fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { + crate::noun_codec::encode_runtime(self, alloc) } } -// Implement NounDecode for ZMap +#[cfg(feature = "std")] impl noun_serde::NounDecode for ZMap where - K: noun_serde::NounDecode + DorTip + Clone + NounEncode + Debug, - V: noun_serde::NounDecode + Clone + Debug, + K: Decode + Encode + DorTip + Clone + Debug, + V: Decode + Clone + Debug, { fn from_noun( noun: &nockvm::noun::Noun, space: &nockvm::noun::NounSpace, ) -> Result { - // Check if it's ~ (empty map) - let handle = noun.in_space(space); - if let Ok(atom) = handle.as_atom() { - if atom.as_u64()? == 0 { - return Ok(ZMap::new()); - } - } - - // Otherwise it should be a cell [[key value] [left right]] - handle - .as_cell() - .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; - - // Decode the node recursively - let mut map = ZMap::new(); - Self::decode_node_recursive(noun, space, &mut map)?; - Ok(map) - } -} - -impl ZMap -where - K: noun_serde::NounDecode + DorTip + Clone + NounEncode + Debug, - V: noun_serde::NounDecode + Clone + Debug, -{ - fn decode_node_recursive( - noun: &nockvm::noun::Noun, - space: &nockvm::noun::NounSpace, - map: &mut ZMap, - ) -> Result<(), noun_serde::NounDecodeError> { - let handle = noun.in_space(space); - - // If it's ~ (0), nothing to do - if let Ok(atom) = handle.as_atom() { - if atom.as_u64()? == 0 { - return Ok(()); - } - } - - // Otherwise it's [[key value] [left right]] - let cell = handle - .as_cell() - .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; - - // Get [key value] pair - let pair_cell = cell - .head() - .as_cell() - .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; - - let key = K::from_noun(&pair_cell.head().noun(), space)?; - let value = V::from_noun(&pair_cell.tail().noun(), space)?; - - // Insert the key-value pair - map.put(key, value); - - // Get [left right] children - let children_cell = cell - .tail() - .as_cell() - .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; - - // Recursively decode left and right subtrees - Self::decode_node_recursive(&children_cell.head().noun(), space, map)?; - Self::decode_node_recursive(&children_cell.tail().noun(), space, map)?; - - Ok(()) + crate::noun_codec::decode_runtime(*noun, space) } } diff --git a/tx-types/src/collections/zset.rs b/tx-types/src/collections/zset.rs index d72ebe5..f2e867d 100644 --- a/tx-types/src/collections/zset.rs +++ b/tx-types/src/collections/zset.rs @@ -1,7 +1,14 @@ -use crate::hashing::tip5::{Tip5Error, Tip5Hasher}; +use crate::hashing::tip5::Tip5Error; +use alloc::{ + boxed::Box, + format, + string::{String, ToString}, + vec::Vec, +}; use ibig::UBig; -use nockvm::noun::D; -use noun_serde::NounEncode; +use pokenoun::codec::{is_null, pair, Decode, Encode, Error}; +use pokenoun::{Arena, Noun}; + /// Z-Set: A deterministic self-balancing binary search tree (treap) for sets /// Based on the Hoon z-set implementation from nockchain /// @@ -9,8 +16,8 @@ use noun_serde::NounEncode; /// - Ordering: gor-tip (single hash comparison) /// - Balancing: mor-tip (double hash comparison) /// - Fallback: dor-tip (structural comparison) -use std::cmp::Ordering; -use std::fmt::Debug; +use core::cmp::Ordering; +use core::fmt::Debug; /// A z-set is a self-balancing binary search tree for sets #[derive(Clone)] @@ -50,7 +57,7 @@ impl DorTip for T { impl ZSet where - T: NounEncode + DorTip + Clone + Debug + PartialEq, + T: Encode + DorTip + Clone + Debug + PartialEq, { /// Create a new empty z-set pub fn new() -> Self { @@ -68,11 +75,14 @@ where /// Fallible insertion; see [`Self::put`]. pub fn try_put(&mut self, value: T) -> Result<(), Tip5Error> { - self.root = Self::put_recursive(self.root.take(), value)?; + Self::compute_tip5_hash(&value)?; + let root = Self::put_recursive(self.root.clone(), value)?; + self.root = root; Ok(()) } /// Insert a value with debug output + #[cfg(feature = "std")] pub fn put_with_debug(&mut self, value: T) where T: Debug, @@ -125,6 +135,7 @@ where } /// Recursive insertion with debug output + #[cfg(feature = "std")] fn put_recursive_debug( node: Option>>, value: T, @@ -381,9 +392,9 @@ where fn tap_recursive(node: &Option>>, result: &mut Vec) { if let Some(n) = node { - Self::tap_recursive(&n.left, result); - result.push(n.value.clone()); Self::tap_recursive(&n.right, result); + result.push(n.value.clone()); + Self::tap_recursive(&n.left, result); } } @@ -410,7 +421,7 @@ where } /// Create an iterator over the values - pub fn iter(&self) -> ZSetIter { + pub fn iter(&self) -> ZSetIter<'_, T> { let mut stack = Vec::new(); if let Some(ref root) = self.root { Self::push_left(&mut stack, root); @@ -454,17 +465,11 @@ where /// Compute TIP5 hash of a value and convert to UBig integer fn compute_tip5_hash(value: &T) -> Result { - use nockapp::noun::slab::NounSlab; - - use nockvm::noun::NounAllocator; - - // Create a noun slab and convert value to noun - let mut slab: NounSlab = NounSlab::new(); - let value_noun = value.to_noun(&mut slab); - let space = slab.noun_space(); - - // Compute the TIP5 hash - Tip5Hasher::hash_noun_varlen(value_noun, &space).map(|hash| hash.to_ubig()) + let bytes = value + .to_jam() + .map_err(|error| Tip5Error::SerializationError(error.to_string()))?; + let hash = crate::hashing::hasher::hash_noun_varlen(&bytes)?; + Ok(hash.to_ubig()) } /// gor-tip: Compare by single hash (UBig integer), fallback to dor-tip @@ -496,23 +501,11 @@ where /// Compute double-tip: hash(hash(value) ++ hash(value)) fn compute_double_tip5_hash(value: &T) -> Result { - use crate::hashing::hasher::hash_ten_cell; - use nockapp::noun::slab::NounSlab; - - use nockvm::noun::NounAllocator; - - // First compute the regular hash - let mut slab: NounSlab = NounSlab::new(); - let value_noun = value.to_noun(&mut slab); - let space = slab.noun_space(); - - let hash = Tip5Hasher::hash_noun_varlen(value_noun, &space)?; - - // Use hash_ten_cell with two copies of the hash - let double_hash = hash_ten_cell(hash.clone(), hash); - - // Convert to UBig - Ok(double_hash.to_ubig()) + let bytes = value + .to_jam() + .map_err(|error| Tip5Error::SerializationError(error.to_string()))?; + let hash = crate::hashing::hasher::hash_noun_varlen(&bytes)?; + Ok(crate::hashing::hash_ten_cell(hash.clone(), hash).to_ubig()) } /// Build from an iterator (gas equivalent) @@ -605,33 +598,6 @@ where // This matches the Hoon z-set hashable structure Hashable::triple(node_hashable, left, right) } - - /// Convert a node to noun recursively for NounEncode - fn node_to_noun( - node: &Box>, - alloc: &mut A, - ) -> nockvm::noun::Noun { - use nockvm::noun::T; - - // Get noun for the current node's value - let value_noun = node.value.to_noun(alloc); - - // Process left subtree - let left_noun = match &node.left { - None => D(0), - Some(left_node) => Self::node_to_noun(left_node, alloc), - }; - - // Process right subtree - let right_noun = match &node.right { - None => D(0), - Some(right_node) => Self::node_to_noun(right_node, alloc), - }; - - // Return as triple: [value left-tree right-tree] - // This matches the Hoon z-set structure - T(alloc, &[value_noun, left_noun, right_noun]) - } } /// Iterator over ZSet values @@ -641,7 +607,7 @@ pub struct ZSetIter<'a, T> { impl<'a, T> Iterator for ZSetIter<'a, T> where - T: NounEncode + DorTip + Clone + Debug + PartialEq, + T: Encode + DorTip + Clone + Debug + PartialEq, { type Item = &'a T; @@ -661,7 +627,7 @@ where // Implement Default impl Default for ZSet where - T: NounEncode + DorTip + Clone + Debug + PartialEq, + T: Encode + DorTip + Clone + Debug + PartialEq, { fn default() -> Self { Self::new() @@ -670,7 +636,7 @@ where // Debug implementation for visualization impl Debug for ZSet { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "ZSet {{ root: ")?; fmt_node(&self.root, f, 0)?; write!(f, " }}") @@ -680,7 +646,7 @@ impl Debug for ZSet { // Implement PartialEq for ZSet impl PartialEq for ZSet where - T: NounEncode + DorTip + Clone + Debug + PartialEq, + T: Encode + DorTip + Clone + Debug + PartialEq, { fn eq(&self, other: &Self) -> bool { // Two sets are equal if they contain the same elements @@ -689,14 +655,14 @@ where } // Implement Eq for ZSet -impl Eq for ZSet where T: NounEncode + DorTip + Clone + Debug + PartialEq + Eq {} +impl Eq for ZSet where T: Encode + DorTip + Clone + Debug + PartialEq + Eq {} // Implement Hash for ZSet -use std::hash::{Hash, Hasher}; +use core::hash::{Hash, Hasher}; impl Hash for ZSet where - T: NounEncode + DorTip + Clone + Debug + PartialEq + Hash, + T: Encode + DorTip + Clone + Debug + PartialEq + Hash, { fn hash(&self, state: &mut H) { // Hash the elements in order @@ -708,7 +674,7 @@ where // Implement PartialOrd for ZSet impl PartialOrd for ZSet where - T: NounEncode + DorTip + Clone + Debug + PartialEq + PartialOrd, + T: Encode + DorTip + Clone + Debug + PartialEq + PartialOrd, { fn partial_cmp(&self, other: &Self) -> Option { self.tap().partial_cmp(&other.tap()) @@ -718,90 +684,19 @@ where // Implement Ord for ZSet impl Ord for ZSet where - T: NounEncode + DorTip + Clone + Debug + PartialEq + Eq + Ord, + T: Encode + DorTip + Clone + Debug + PartialEq + Eq + Ord, { fn cmp(&self, other: &Self) -> Ordering { self.tap().cmp(&other.tap()) } } -// Implement NounEncode for ZSet -impl NounEncode for ZSet -where - T: NounEncode + DorTip + Clone + Debug + PartialEq, -{ - fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { - // Convert to a tree structure matching Hoon's z-set - // Empty set is ~ - // Node is [value left-tree right-tree] - match &self.root { - None => D(0), // Empty set is ~ - Some(node) => Self::node_to_noun(node, alloc), - } - } -} - -// Implement NounDecode for ZSet -use nockvm::noun::Noun; -use noun_serde::{NounDecode, NounDecodeError}; - -impl ZSet -where - T: NounDecode + NounEncode + DorTip + Clone + Debug + PartialEq, -{ - /// Decode a node from noun recursively for NounDecode - fn node_from_noun( - noun: &Noun, - space: &nockvm::noun::NounSpace, - ) -> Result>>, NounDecodeError> { - let handle = noun.in_space(space); - - // Check if it's empty (~ = 0) - if let Ok(atom) = handle.as_atom() { - if atom.as_u64().unwrap_or(1) == 0 { - return Ok(None); - } - } - - // Otherwise it should be a cell [value left right] - let cell = handle - .as_cell() - .map_err(|_| NounDecodeError::ExpectedCell)?; - - // First element is the value - let value = T::from_noun(&cell.head().noun(), space)?; - - // Second element should be a cell [left right] - let tail_cell = cell - .tail() - .as_cell() - .map_err(|_| NounDecodeError::ExpectedCell)?; - - // Recursively decode left and right subtrees - let left = Self::node_from_noun(&tail_cell.head().noun(), space)?; - let right = Self::node_from_noun(&tail_cell.tail().noun(), space)?; - - Ok(Some(Box::new(Node { value, left, right }))) - } -} - -impl NounDecode for ZSet -where - T: NounDecode + NounEncode + DorTip + Clone + Debug + PartialEq, -{ - fn from_noun(noun: &Noun, space: &nockvm::noun::NounSpace) -> Result { - // Decode from tree structure matching Hoon's z-set - let root = Self::node_from_noun(noun, space)?; - Ok(ZSet { root }) - } -} - // Helper function for debug formatting fn fmt_node( node: &Option>>, - f: &mut std::fmt::Formatter<'_>, + f: &mut core::fmt::Formatter<'_>, depth: usize, -) -> std::fmt::Result { +) -> core::fmt::Result { match node { None => write!(f, "~"), Some(n) => { @@ -817,9 +712,10 @@ fn fmt_node( } } -#[cfg(test)] +#[cfg(all(test, feature = "std"))] mod tests { use super::*; + use noun_serde::NounEncode; // Wrapper for i32 to implement NounEncode #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -838,6 +734,14 @@ mod tests { Atom::new(alloc, value).as_noun() } } + impl pokenoun::codec::Encode for TestInt { + fn encode( + &self, + arena: &mut pokenoun::Arena, + ) -> Result { + (self.0 as i64 as u64).encode(arena) + } + } #[test] fn test_basic_operations() { @@ -968,3 +872,62 @@ mod tests { println!("Order: {:?}", ordered_values); } } + +impl Encode for ZSet { + fn encode(&self, arena: &mut Arena) -> Result { + fn tree(node: &Option>>, arena: &mut Arena) -> Result { + let Some(node) = node else { + return Ok(arena.atom0()); + }; + let value = node.value.encode(arena)?; + let left = tree(&node.left, arena)?; + let right = tree(&node.right, arena)?; + let children = arena.alloc_cell(left, right); + Ok(arena.alloc_cell(value, children)) + } + tree(&self.root, arena) + } +} + +impl Decode for ZSet +where + T: Decode + Encode + DorTip + Clone + Debug + PartialEq, +{ + fn decode(noun: Noun, arena: &Arena) -> Result { + fn tree(noun: Noun, arena: &Arena) -> Result>>, Error> { + if is_null(noun, arena) { + return Ok(None); + } + let (value, children) = pair(noun, arena)?; + let (left, right) = pair(children, arena)?; + Ok(Some(Box::new(Node { + value: T::decode(value, arena)?, + left: tree(left, arena)?, + right: tree(right, arena)?, + }))) + } + Ok(Self { + root: tree(noun, arena)?, + }) + } +} + +#[cfg(feature = "std")] +impl noun_serde::NounEncode for ZSet { + fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { + crate::noun_codec::encode_runtime(self, alloc) + } +} + +#[cfg(feature = "std")] +impl noun_serde::NounDecode for ZSet +where + T: Decode + Encode + DorTip + Clone + Debug + PartialEq, +{ + fn from_noun( + noun: &nockvm::noun::Noun, + space: &nockvm::noun::NounSpace, + ) -> Result { + crate::noun_codec::decode_runtime(*noun, space) + } +} diff --git a/tx-types/src/determinism.rs b/tx-types/src/determinism.rs new file mode 100644 index 0000000..ec03ea5 --- /dev/null +++ b/tx-types/src/determinism.rs @@ -0,0 +1,13 @@ +//! Stable wallet ordering, identical with and without std. +use crate::NName; +use core::cmp::Ordering; + +pub fn compare_names(a: &NName, b: &NName) -> Ordering { + let key = |name: &NName| { + name.p + .iter() + .map(|hash| bs58::encode(hash.to_ubig().to_be_bytes()).into_string()) + .collect::>() + }; + key(a).cmp(&key(b)) +} diff --git a/tx-types/src/fee.rs b/tx-types/src/fee.rs new file mode 100644 index 0000000..f46786b --- /dev/null +++ b/tx-types/src/fee.rs @@ -0,0 +1,183 @@ +//! Fee arithmetic shared by transaction construction and planning. + +pub const BYTHOS_PHASE: u64 = 54_000; +pub const BASE_FEE: u64 = 16_384; +pub const INPUT_FEE_DIVISOR: u64 = 4; +pub const MIN_FEE: u64 = 256; + +const DEFAULT_INPUT_FEE_DIVISOR: u64 = 1; +pub const NICKS_PER_NOCK: u64 = 65_536; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FeeInputs { + pub seed_words: u64, + pub witness_words: u64, + pub base_fee: u64, + pub input_fee_divisor: u64, + pub min_fee: u64, + pub height: u64, + pub bythos_phase: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FeeBreakdown { + pub seed_fee: u64, + pub witness_fee: u64, + pub word_fee: u64, + pub minimum_fee: u64, + pub witness_divisor: u64, +} + +/// Computes the bridge fee using the same whole-NOCK rounding rule as the +/// bridge deposit path. +pub fn compute_bridge_fee(amount: u64, nicks_fee_per_nock: u64) -> u64 { + if amount == 0 || nicks_fee_per_nock == 0 { + return 0; + } + + let rounded_nocks = 1u64.saturating_add(amount.saturating_sub(1) / NICKS_PER_NOCK); + rounded_nocks.saturating_mul(nicks_fee_per_nock) +} + +pub fn compute_minimum_fee(inputs: FeeInputs) -> FeeBreakdown { + let effective_base_fee = if inputs.height < inputs.bythos_phase { + inputs.base_fee.saturating_mul(2) + } else { + inputs.base_fee + }; + let witness_divisor = + witness_divisor(inputs.height, inputs.bythos_phase, inputs.input_fee_divisor); + let seed_fee = inputs.seed_words.saturating_mul(effective_base_fee); + let witness_fee = inputs.witness_words.saturating_mul(effective_base_fee) / witness_divisor; + let word_fee = seed_fee.saturating_add(witness_fee); + let minimum_fee = word_fee.max(inputs.min_fee); + + FeeBreakdown { + seed_fee, + witness_fee, + word_fee, + minimum_fee, + witness_divisor, + } +} + +pub fn witness_divisor(height: u64, bythos_phase: u64, input_fee_divisor: u64) -> u64 { + if height >= bythos_phase { + input_fee_divisor.max(DEFAULT_INPUT_FEE_DIVISOR) + } else { + DEFAULT_INPUT_FEE_DIVISOR + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + fn fee_inputs() -> FeeInputs { + FeeInputs { + seed_words: 0, + witness_words: 0, + base_fee: 0, + input_fee_divisor: DEFAULT_INPUT_FEE_DIVISOR, + min_fee: 0, + height: 0, + bythos_phase: 0, + } + } + + #[test] + fn pre_bythos_uses_divisor_one() { + let bythos_divisor = 8; + let divisor = witness_divisor(10, 20, bythos_divisor); + assert_eq!(divisor, DEFAULT_INPUT_FEE_DIVISOR); + } + + #[test] + fn post_bythos_uses_input_divisor() { + let bythos_divisor = 8; + let divisor = witness_divisor(20, 20, bythos_divisor); + assert_eq!(divisor, bythos_divisor); + } + + #[test] + fn compute_minimum_fee_uses_pre_bythos_divisor_one() { + let mut inputs = fee_inputs(); + inputs.seed_words = 3; + inputs.witness_words = 8; + inputs.base_fee = 5; + inputs.input_fee_divisor = 4; + inputs.height = 9; + inputs.bythos_phase = 10; + + let fee = compute_minimum_fee(inputs); + assert_eq!(fee.seed_fee, 30); + assert_eq!(fee.witness_divisor, 1); + assert_eq!(fee.witness_fee, 80); + assert_eq!(fee.word_fee, 110); + assert_eq!(fee.minimum_fee, 110); + } + + #[test] + fn compute_minimum_fee_uses_post_bythos_divisor() { + let mut inputs = fee_inputs(); + inputs.seed_words = 3; + inputs.witness_words = 8; + inputs.base_fee = 5; + inputs.input_fee_divisor = 4; + inputs.height = 10; + inputs.bythos_phase = 10; + + let fee = compute_minimum_fee(inputs); + assert_eq!(fee.seed_fee, 15); + assert_eq!(fee.witness_divisor, 4); + assert_eq!(fee.witness_fee, 10); + assert_eq!(fee.word_fee, 25); + assert_eq!(fee.minimum_fee, 25); + } + + #[test] + fn compute_minimum_fee_applies_min_fee_floor() { + let mut inputs = fee_inputs(); + inputs.seed_words = 1; + inputs.witness_words = 1; + inputs.base_fee = 2; + inputs.min_fee = 99; + + let fee = compute_minimum_fee(inputs); + assert_eq!(fee.word_fee, 4); + assert_eq!(fee.minimum_fee, 99); + } + + #[test] + fn compute_minimum_fee_uses_saturating_math() { + let mut inputs = fee_inputs(); + inputs.seed_words = u64::MAX; + inputs.witness_words = u64::MAX; + inputs.base_fee = 2; + inputs.input_fee_divisor = 1; + + let fee = compute_minimum_fee(inputs); + assert_eq!(fee.seed_fee, u64::MAX); + assert_eq!(fee.witness_fee, u64::MAX); + assert_eq!(fee.word_fee, u64::MAX); + assert_eq!(fee.minimum_fee, u64::MAX); + } + + #[test] + fn compute_bridge_fee_returns_zero_for_zero_amount() { + assert_eq!(compute_bridge_fee(0, 195), 0); + } + + #[test] + fn compute_bridge_fee_charges_fixed_fee_per_whole_nock() { + assert_eq!(compute_bridge_fee(NICKS_PER_NOCK, 195), 195); + assert_eq!(compute_bridge_fee(NICKS_PER_NOCK * 2, 195), 390); + } + + #[test] + fn compute_bridge_fee_rounds_partial_nock_up() { + assert_eq!(compute_bridge_fee(1, 195), 195); + assert_eq!(compute_bridge_fee(NICKS_PER_NOCK + 1, 195), 390); + } +} diff --git a/tx-types/src/fee_calculator.rs b/tx-types/src/fee_calculator.rs index d9dfba7..06b0f73 100644 --- a/tx-types/src/fee_calculator.rs +++ b/tx-types/src/fee_calculator.rs @@ -1,224 +1,41 @@ -//! Transaction Fee Calculator -//! -//! This module implements the current Bythos-aware minimum fee calculation logic for -//! Nockchain transactions. -//! -//! The modern consensus rules are page-aware: -//! - before `BYTHOS_PHASE`, fee accounting uses the legacy 2x base-fee rate -//! - at and after `BYTHOS_PHASE`, the base fee drops to `BASE_FEE` -//! - at and after `BYTHOS_PHASE`, witness inputs are discounted by -//! `INPUT_FEE_DIVISOR` -//! - at and after `BYTHOS_PHASE`, note-data is charged once per `lock_root`, -//! matching output construction -//! -//! The canonical implementation lives in `hoon/common/tx-engine.hoon`. +//! Minimum transaction fees from exact shared word accounting. +use crate::fee::{compute_minimum_fee, FeeBreakdown, FeeInputs}; +use crate::word_count::transaction_words; +use crate::{Coins, NName, Spend, ZMap}; +use pokenoun::codec::Error; -extern crate alloc; +pub use crate::fee::{BASE_FEE, BYTHOS_PHASE, INPUT_FEE_DIVISOR, MIN_FEE}; -use crate::collections::ZMap; -use crate::generic_noun::UntypedNoun; -#[cfg(test)] -use crate::hashing::hashable::Hashable; -use crate::transaction_types::{Coins, Hash, NName, Spend, SpendBody}; -use crate::transaction_types_v0::SpendV0; -use crate::transaction_types_v1::{NoteData, SeedV1, SeedsV1, SpendV0ToV1, Witness}; -use nockapp::noun::slab::{NockJammer, NounSlab}; -use nockvm::noun::{Noun, NounAllocator, NounSpace}; -use noun_serde::NounEncode; - -/// Activation height for the Bythos fee and witness-format upgrade. -pub const BYTHOS_PHASE: u64 = 54_000; - -/// Base fee per word for witness and note-data storage after Bythos. -pub const BASE_FEE: u64 = 16_384; - -/// Inputs pay `1 / INPUT_FEE_DIVISOR` of the output rate at/after Bythos. -pub const INPUT_FEE_DIVISOR: u64 = 4; - -/// Minimum fee in nicks (absolute floor) -/// From blockchain-constants: min-fee=256 -pub const MIN_FEE: u64 = 256; - -const LEGACY_BASE_FEE_MULTIPLIER: u64 = 2; - -/// Calculate the minimum required fee for a set of spends using modern mainnet rules. -/// -/// For height-sensitive callers, prefer `calculate_min_fee_for_page`. pub fn calculate_min_fee(spends: &ZMap) -> Coins { calculate_min_fee_for_page(spends, BYTHOS_PHASE) } -/// Calculate the minimum required fee for a set of spends at a specific page. pub fn calculate_min_fee_for_page(spends: &ZMap, page_num: u64) -> Coins { - let bythos_active = page_num >= BYTHOS_PHASE; - let effective_base_fee = if bythos_active { - BASE_FEE - } else { - BASE_FEE * LEGACY_BASE_FEE_MULTIPLIER + let inputs = FeeInputs { + seed_words: 0, + witness_words: 0, + height: page_num, + bythos_phase: BYTHOS_PHASE, + base_fee: BASE_FEE, + input_fee_divisor: INPUT_FEE_DIVISOR, + min_fee: MIN_FEE, }; - - let seed_word_count = count_seed_words(spends, page_num); - let witness_word_count = count_witness_words(spends); - let witness_divisor = if bythos_active { INPUT_FEE_DIVISOR } else { 1 }; - - let seed_fee = seed_word_count * effective_base_fee; - let witness_fee = (witness_word_count * effective_base_fee) / witness_divisor; - let word_fee = seed_fee + witness_fee; - let min_fee = core::cmp::max(word_fee, MIN_FEE); - - Coins { value: min_fee } -} - -fn count_seed_words(spends: &ZMap, page_num: u64) -> u64 { - if page_num >= BYTHOS_PHASE { - count_seed_words_merged(spends) - } else { - count_seed_words_legacy(spends) - } -} - -fn count_seed_words_legacy(spends: &ZMap) -> u64 { - spends - .tap() - .iter() - .map(|(_name, spend)| match &spend.body { - SpendBody::V1(body) => count_seeds_v1_words(&body.seeds), - SpendBody::V0ToV1(body) => count_seeds_v1_words(&body.seeds), - SpendBody::V0(_) => 0, - }) - .sum() -} - -fn count_seed_words_merged(spends: &ZMap) -> u64 { - let mut note_data_by_lock_root: ZMap = ZMap::new(); - - for (_name, spend) in spends.tap() { - for seed in v1_seeds(&spend) { - if let Some(existing) = note_data_by_lock_root.get(&seed.lock_root).cloned() { - let mut merged = existing.map; - for (key, value) in seed.note_data.map.tap() { - merged.put(key, value); - } - note_data_by_lock_root.put(seed.lock_root.clone(), NoteData { map: merged }); - } else { - note_data_by_lock_root.put(seed.lock_root.clone(), seed.note_data.clone()); - } - } - } - - note_data_by_lock_root - .tap() - .iter() - .map(|(_lock_root, note_data)| count_note_data_words(note_data)) - .sum() -} - -fn v1_seeds(spend: &Spend) -> Vec { - match &spend.body { - SpendBody::V1(body) => body.seeds.set.tap(), - SpendBody::V0ToV1(body) => body.seeds.set.tap(), - SpendBody::V0(_) => Vec::new(), - } -} - -fn count_seeds_v1_words(seeds: &SeedsV1) -> u64 { - seeds - .set - .iter() - .map(|seed| count_note_data_words(&seed.note_data)) - .sum() -} - -/// Count note-data words exactly as Hoon does: -/// `num-of-leaves:shape (~(rep z-by note-data) |=([[k v] tree] [k v tree]))`. -/// -/// The fold builds `[k1 v1 [k2 v2 ... [kn vn ~]]]`, so each entry contributes -/// one word for its key plus the leaves of its value, and the terminal `~` -/// contributes one more word. Counting the z-map noun itself would add the -/// internal `[node left right]` cells and overcharge by one word per entry. -fn count_note_data_words(note_data: &NoteData) -> u64 { - let mut total = 1; // terminal ~ - for (_key, value) in note_data.map.tap() { - total += 1; // key - total += count_untyped_noun_leaves(&value); - } - total -} - -/// Count the leaves of an `UntypedNoun` value. Values stored in note-data are -/// arbitrary nouns, so recurse through cells rather than treating the value as -/// a single atom. A value that cannot be cued is counted as one atom. -fn count_untyped_noun_leaves(value: &UntypedNoun) -> u64 { - let mut slab = NounSlab::::new(); - match slab.cue_into(value.p.clone()) { - Ok(noun) => { - let space = slab.noun_space(); - count_leaves_from_noun(noun, &space) - } - Err(_) => 1, - } -} - -fn count_witness_words(spends: &ZMap) -> u64 { - spends - .tap() - .iter() - .map(|(_name, spend)| count_spend_witness_words(spend)) - .sum() -} - -fn count_spend_witness_words(spend: &Spend) -> u64 { - match &spend.body { - SpendBody::V1(body) => count_v1_witness_words(&body.witness), - SpendBody::V0ToV1(body) => count_v0_to_v1_signature_words(body), - SpendBody::V0(body) => count_legacy_signature_words(body), + Coins { + value: calculate_fee(spends, inputs) + .expect("valid spend noun encoding") + .minimum_fee, } } -fn count_v1_witness_words(witness: &Witness) -> u64 { - count_leaves_from_encoder(|slab| witness.to_noun(slab)) -} - -fn count_v0_to_v1_signature_words(spend: &SpendV0ToV1) -> u64 { - count_leaves_from_encoder(|slab| spend.signature.to_noun(slab)) -} - -fn count_legacy_signature_words(spend: &SpendV0) -> u64 { - count_leaves_from_encoder(|slab| spend.signature.to_noun(slab)) -} - -/// Count the number of leaves in a Hashable tree -/// -/// This mirrors the Hoon `num-of-leaves:shape` function. -/// Leaves, Hashes, and Lists each count as 1. -/// Cells recursively sum their left and right subtrees. -#[cfg(test)] -fn count_leaves(h: &Hashable) -> u64 { - match h { - // Non-cell cases: Leaf, Hash, and List all count as 1 leaf - Hashable::Leaf(_) | Hashable::Hash(_) | Hashable::List(_) => 1, - // Cell case: sum of left and right subtrees - Hashable::Cell(left, right) => count_leaves(left) + count_leaves(right), - } -} - -fn count_leaves_from_encoder(build: F) -> u64 -where - F: Fn(&mut NounSlab) -> Noun, -{ - let mut slab = NounSlab::::new(); - let noun = build(&mut slab); - let space = slab.noun_space(); - count_leaves_from_noun(noun, &space) -} - -fn count_leaves_from_noun(noun: Noun, space: &NounSpace) -> u64 { - if let Ok(cell) = noun.in_space(space).as_cell() { - count_leaves_from_noun(cell.head().noun(), space) - + count_leaves_from_noun(cell.tail().noun(), space) - } else { - 1 - } +/// Fee rates and activation height come from the caller; word counts come from spends. +pub fn calculate_fee( + spends: &ZMap, + mut inputs: FeeInputs, +) -> Result { + let counts = transaction_words(spends, inputs.height >= inputs.bythos_phase)?; + inputs.seed_words = counts.seed_words; + inputs.witness_words = counts.witness_words; + Ok(compute_minimum_fee(inputs)) } #[cfg(test)] @@ -226,7 +43,29 @@ mod tests { use super::*; use crate::collections::ZSet; use crate::generic_noun::UntypedNoun; - use nockvm::noun::D; + use crate::hashing::Hashable; + use crate::word_count::{note_data_words, seed_words, witness_words}; + use crate::{Hash, NoteData, SeedV1, SeedsV1, SpendBody, SpendV0ToV1}; + use alloc::{string::ToString, vec, vec::Vec}; + use pokenoun::codec::Encode; + fn count_note_data_words(data: &NoteData) -> u64 { + note_data_words(data).unwrap() + } + fn count_seed_words_legacy(spends: &ZMap) -> u64 { + seed_words(spends, false).unwrap() + } + fn count_seed_words_merged(spends: &ZMap) -> u64 { + seed_words(spends, true).unwrap() + } + fn count_witness_words(spends: &ZMap) -> u64 { + witness_words(spends).unwrap() + } + fn count_leaves(h: &Hashable) -> u64 { + match h { + Hashable::Cell(left, right) => count_leaves(left) + count_leaves(right), + _ => 1, + } + } #[test] fn test_count_leaves() { @@ -343,10 +182,7 @@ mod tests { assert_eq!( pre_fee, - core::cmp::max( - (legacy_seed_words + witness_words) * BASE_FEE * LEGACY_BASE_FEE_MULTIPLIER, - MIN_FEE - ) + core::cmp::max((legacy_seed_words + witness_words) * BASE_FEE * 2, MIN_FEE) ); assert_eq!( post_fee, @@ -414,16 +250,14 @@ mod tests { } fn untyped_atom(value: u64) -> UntypedNoun { - let mut slab = NounSlab::::new(); - let space = slab.noun_space(); - slab.copy_into(D(value), &space); - UntypedNoun { p: slab.jam() } + UntypedNoun { + p: value.to_jam().unwrap().into(), + } } fn untyped_cell(head: u64, tail: u64) -> UntypedNoun { - let mut slab = NounSlab::::new(); - let noun = nockvm::noun::T(&mut slab, &[D(head), D(tail)]); - slab.set_root(noun); - UntypedNoun { p: slab.jam() } + UntypedNoun { + p: (head, tail).to_jam().unwrap().into(), + } } } diff --git a/tx-types/src/generic_noun.rs b/tx-types/src/generic_noun.rs index 72b3f2a..c1c4f9c 100644 --- a/tx-types/src/generic_noun.rs +++ b/tx-types/src/generic_noun.rs @@ -1,7 +1,10 @@ +use alloc::{ + format, + string::{String, ToString}, +}; use bytes::Bytes; -use nockapp::noun::slab::NounSlab; -use nockvm::noun::{Noun, NounAllocator, NounSpace}; -use noun_serde::{NounDecode, NounDecodeError, NounEncode}; +use pokenoun::codec::{Decode, Encode, Error}; +use pokenoun::{Arena, Noun}; #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct UntypedNoun { @@ -9,60 +12,49 @@ pub struct UntypedNoun { } impl UntypedNoun { - /// Deserialize the UntypedNoun back to a typed value - /// - /// This method cues (unjams) the stored bytes and then decodes them - /// into the target type T. - /// - /// # Type Parameters - /// * `T` - The type to decode into. Must implement `NounDecode`. - /// - /// # Returns - /// * `Ok(T)` - Successfully deserialized value - /// * `Err(String)` - Error message if deserialization failed - /// - /// # Example - /// ```ignore - /// let untyped: UntypedNoun = ...; - /// let lock_data: LockData = untyped.to_typed()?; - /// ``` - pub fn to_typed(&self) -> Result { - // Create a new slab for cueing - let mut slab: NounSlab = NounSlab::new(); + pub fn from_value(value: &impl Encode) -> Result { + Ok(Self { + p: value.to_jam()?.into(), + }) + } + + pub fn decode_as(&self) -> Result { + T::from_jam(&self.p) + } + + pub fn to_hashable(&self) -> Result { + let mut arena = Arena::new(); + let noun = pokenoun::cue(&self.p, &mut arena).map_err(|error| { + crate::hashing::Tip5Error::SerializationError(format!("invalid noun jam: {error:?}")) + })?; + Ok(crate::hashing::Hashable::from_value(noun, &arena)) + } - // Cue (unjam) the bytes to get back the noun + #[cfg(feature = "std")] + pub fn to_typed(&self) -> Result { + use nockvm::noun::NounAllocator; + let mut slab: nockapp::noun::slab::NounSlab = nockapp::noun::slab::NounSlab::new(); let noun = slab .cue_into(self.p.clone()) - .map_err(|e| format!("Failed to cue noun: {:?}", e))?; - - // Decode the noun into type T - let space = slab.noun_space(); - T::from_noun(&noun, &space).map_err(|e| format!("Failed to decode noun: {:?}", e)) + .map_err(|error| format!("invalid jam: {error:?}"))?; + T::from_noun(&noun, &slab.noun_space()).map_err(|error| error.to_string()) } } -impl NounDecode for UntypedNoun { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - let mut slab: NounSlab = NounSlab::new(); - slab.copy_into(*noun, space); - let noun_bytes: Bytes = slab.jam(); - Ok(UntypedNoun { p: noun_bytes }) +impl Encode for UntypedNoun { + fn encode(&self, arena: &mut Arena) -> Result { + let mut source = Arena::new(); + let noun = pokenoun::cue(&self.p, &mut source).map_err(|_| Error("invalid noun jam"))?; + Ok(pokenoun::codec::copy(noun, &source, arena)) } } -impl NounEncode for UntypedNoun { - fn to_noun(&self, alloc: &mut A) -> Noun { - // Try to downcast to NounSlab - this only works if the allocator IS a NounSlab - // We use a trick: attempt to call a NounSlab-specific method - // Since we can't downcast traits directly, we'll use std::any::Any if available - - // For now, we require that the allocator is a NounSlab. - // If it's not, this will panic at runtime. - let slab_ptr = alloc as *mut A as *mut NounSlab; - let slab = unsafe { &mut *slab_ptr }; - - // Cue directly into the slab (which is the allocator) - slab.cue_into(self.p.clone()) - .expect("Failed to cue UntypedNoun bytes into allocator - allocator must be a NounSlab") +impl Decode for UntypedNoun { + fn decode(noun: Noun, arena: &Arena) -> Result { + Ok(Self { + p: pokenoun::jam(noun, arena).into(), + }) } } + +crate::noun_codec::runtime_codec!(UntypedNoun); diff --git a/tx-types/src/hashing/hashable.rs b/tx-types/src/hashing/hashable.rs index ca06f77..199820b 100644 --- a/tx-types/src/hashing/hashable.rs +++ b/tx-types/src/hashing/hashable.rs @@ -1,291 +1,131 @@ +//! Hoon's hashable representation, shared by hosted and embedded builds. + use crate::transaction_types::Hash; -use nockapp::noun::slab::NounSlab; -use nockapp::utils::make_tas; -use nockapp::Bytes; -use nockvm::noun::{Noun, NounAllocator, NounSpace, D, T}; -use noun_serde::{NounDecode, NounEncode}; -/// Hashable enum mirroring Hoon's hashable type -/// This is an intermediate representation for hashing complex structures -use std::fmt::Debug; +use alloc::{boxed::Box, string::String, vec::Vec}; +use pokenoun::codec::{pair, Decode, Encode, Error}; +use pokenoun::{Arena, Noun}; -/// Hashable representation matching Hoon's hashable type #[derive(Debug, Clone, PartialEq, Eq)] pub enum Hashable { - /// Jammed noun bytes to be hashed (equivalent to [%leaf p=*]) - /// - /// The Vec contains a jammed (serialized) noun that will be - /// cued (deserialized) before hashing. - /// - /// For simple atoms, use `Hashable::leaf_from_atom()`. - /// - /// For arbitrary nouns (cells, lists, etc.), build the noun and jam it: - /// ```rust,ignore - /// let mut slab: NounSlab = NounSlab::new(); - /// let noun = build_your_noun(&mut slab); // Could be cell, list, etc. - /// slab.set_root(noun); - /// Hashable::Leaf(slab.jam().to_vec()) - /// ``` + /// JAM of a noun to hash as one leaf. Leaf(Vec), - - /// Pre-computed hash (equivalent to [%hash p=noun-digest]) Hash(Hash), - - /// Binary cell structure (equivalent to [p=hashable q=hashable]) Cell(Box, Box), - - /// List of hashables (equivalent to [%list p=(list hashable)]) List(Vec), } impl Hashable { - /// Create a leaf from atom data by jamming it as a noun - /// - /// **This function is for atoms only.** For complex nouns (cells, lists), - /// build the noun structure directly, jam it, and wrap in `Hashable::Leaf`. - /// - /// This function interprets the byte slice as atom data (little-endian), - /// creates an atom noun from it, jams (serializes) the noun, and stores - /// the jammed bytes in a Leaf variant. pub fn leaf_from_atom(data: impl AsRef<[u8]>) -> Self { - use nockapp::noun::slab::NounSlab; - use nockapp::{AtomExt, Bytes}; - use nockvm::noun::Atom; - - let bytes = data.as_ref(); - let mut slab: NounSlab = NounSlab::new(); - - // Create an atom from the bytes (same logic as hash_noun_varlen) - let noun = if bytes.is_empty() { - Atom::new(&mut slab, 0).as_noun() - } else if bytes.len() <= 8 { - // For small data, convert directly to u64 - let mut value = 0u64; - for (i, &byte) in bytes.iter().enumerate() { - value |= (byte as u64) << (i * 8); - } - Atom::new(&mut slab, value).as_noun() - } else { - // For larger data, use Atom::from_bytes - let b = Bytes::copy_from_slice(bytes); - Atom::from_bytes(&mut slab, &b).as_noun() - }; - - // Set the noun as the slab root and jam it - slab.set_root(noun); - Hashable::Leaf(slab.jam().to_vec()) + let mut arena = Arena::new(); + let noun = arena.alloc_atom_bytes(data.as_ref()); + Self::Leaf(pokenoun::jam(noun, &arena)) } - /// Create a leaf from an @tas (ASCII) string, matching Hoon's `leaf+%foo`. pub fn leaf_from_tas(text: &str) -> Self { - let mut slab: NounSlab = NounSlab::new(); - let noun = make_tas(&mut slab, text).as_noun(); - slab.set_root(noun); - Hashable::Leaf(slab.jam().to_vec()) + Self::leaf_from_atom(text.as_bytes()) } - - /// Create a leaf representing null/empty - /// - /// In Hoon, null (0) is represented as leaf+0. This creates - /// a noun (atom with value 0), jams it, and stores the jammed bytes. pub fn null() -> Self { - use nockapp::noun::slab::NounSlab; - use nockvm::noun::Atom; - - let mut slab: NounSlab = NounSlab::new(); - let noun = Atom::new(&mut slab, 0).as_noun(); - slab.set_root(noun); - Hashable::Leaf(slab.jam().to_vec()) + Self::Leaf(alloc::vec![2]) } - - /// Create a pre-computed hash node pub fn hash(digest: Hash) -> Self { - Hashable::Hash(digest) + Self::Hash(digest) } - - /// Create a cell from two hashables - pub fn cell(left: Hashable, right: Hashable) -> Self { - Hashable::Cell(Box::new(left), Box::new(right)) + pub fn cell(left: Self, right: Self) -> Self { + Self::Cell(Box::new(left), Box::new(right)) } - - /// Create a triple (syntactic sugar for nested cells) - /// Equivalent to Hoon's :+ operator - pub fn triple(first: Hashable, second: Hashable, third: Hashable) -> Self { - Hashable::Cell( - Box::new(first), - Box::new(Hashable::Cell(Box::new(second), Box::new(third))), - ) + pub fn triple(first: Self, second: Self, third: Self) -> Self { + Self::cell(first, Self::cell(second, third)) } - - /// Create a list of hashables - pub fn list(items: Vec) -> Self { - Hashable::List(items) + pub fn list(items: Vec) -> Self { + Self::List(items) } - /// Build a proper Hoon list (right-associated cells ending in null) - /// from the provided hashable elements. - pub fn cons_list(elements: I) -> Self - where - I: IntoIterator, - { - let mut tail = Hashable::null(); - let mut items: Vec = elements.into_iter().collect(); - while let Some(elem) = items.pop() { - tail = Hashable::cell(elem, tail); + pub fn cons_list>(elements: I) -> Self { + let mut tail = Self::null(); + let items: Vec<_> = elements.into_iter().collect(); + for item in items.into_iter().rev() { + tail = Self::cell(item, tail); } tail } - /// Build nested cells without a terminating null (Hoon `:* ... ==`). - /// - /// For example, `cell_chain([a, b, c, d])` produces `[a [b [c d]]]`. - /// If no elements are provided, this returns `Hashable::null()`. - pub fn cell_chain(elements: I) -> Self - where - I: IntoIterator, - { - let mut items: Vec = elements.into_iter().collect(); - match items.pop() { - None => Hashable::null(), - Some(mut acc) => { - while let Some(elem) = items.pop() { - acc = Hashable::cell(elem, acc); - } - acc - } - } - } - - /// Convert this hashable structure into a Hoon noun representation. - /// Primarily used for debugging to compare with the wallet's hashable output. - pub fn to_noun(&self, slab: &mut NounSlab) -> Noun { - match self { - Hashable::Leaf(bytes) => { - let tag = make_tas(slab, "leaf").as_noun(); - let mut cue_slab: NounSlab = NounSlab::new(); - let value_noun = cue_slab - .cue_into(Bytes::copy_from_slice(bytes)) - .expect("Hashable::Leaf must contain valid jam bytes"); - let cue_space = cue_slab.noun_space(); - let copied = slab.copy_into(value_noun, &cue_space); - T(slab, &[tag, copied]) - } - Hashable::Hash(hash) => { - let tag = make_tas(slab, "hash").as_noun(); - let hash_noun = hash.to_noun(slab); - T(slab, &[tag, hash_noun]) - } - Hashable::Cell(left, right) => { - let l = left.to_noun(slab); - let r = right.to_noun(slab); - T(slab, &[l, r]) - } - Hashable::List(items) => { - let tag = make_tas(slab, "list").as_noun(); - let mut tail = D(0); - for item in items.iter().rev() { - let noun = item.to_noun(slab); - tail = T(slab, &[noun, tail]); - } - T(slab, &[tag, tail]) - } + pub fn cell_chain>(elements: I) -> Self { + let mut items: Vec<_> = elements.into_iter().collect(); + let mut tail = items.pop().unwrap_or_else(Self::null); + while let Some(item) = items.pop() { + tail = Self::cell(item, tail); } + tail } - /// Reject leaves whose stored jam bytes cannot be decoded, so hashing can - /// return a real error instead of a fabricated digest. - pub(crate) fn validate_leaves(&self) -> Result<(), crate::hashing::tip5::Tip5Error> { - use crate::hashing::tip5::Tip5Error; - - match self { - Hashable::Leaf(bytes) => { - let mut slab: NounSlab = NounSlab::new(); - slab.cue_into(Bytes::copy_from_slice(bytes)) - .map(|_| ()) - .map_err(|err| { - Tip5Error::SerializationError(format!( - "Hashable::Leaf contains invalid jam bytes: {:?}", - err - )) - }) - } - Hashable::Cell(left, right) => { - left.validate_leaves()?; - right.validate_leaves() - } - Hashable::List(items) => { - for item in items { - item.validate_leaves()?; - } - Ok(()) + /// Expand arbitrary noun cells into hashable cells, leaving atoms as leaves. + pub fn from_value(noun: Noun, arena: &Arena) -> Self { + match noun { + Noun::Atom(_) => Self::Leaf(pokenoun::jam(noun, arena)), + Noun::Cell(id) => { + let cell = arena.cell(id); + Self::cell( + Self::from_value(cell.head, arena), + Self::from_value(cell.tail, arena), + ) } - Hashable::Hash(_) => Ok(()), } } - /// Serialize the hashable structure into jammed bytes of the underlying noun. pub fn to_jam(&self) -> Vec { - let mut slab: NounSlab = NounSlab::new(); - let noun = self.to_noun(&mut slab); - slab.set_root(noun); - slab.jam().to_vec() + Encode::to_jam(self).expect("hashable contains valid leaf jams") } - /// Reconstruct a `Hashable` structure from a noun that follows the Hoon - /// `%hashable` encoding (used by tx-engine and TIP5 hashing). - pub fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - let handle = noun.in_space(space); - if let Ok(cell) = handle.as_cell() { - if let Ok(tag_atom) = cell.head().as_atom() { - if tag_atom.eq_bytes(b"leaf") { - let jam = jam_noun(cell.tail().noun(), space); - return Ok(Hashable::Leaf(jam)); - } else if tag_atom.eq_bytes(b"hash") { - let digest = Hash::from_noun(&cell.tail().noun(), space) - .map_err(|e| format!("hash decode: {e}"))?; - return Ok(Hashable::Hash(digest)); - } else if tag_atom.eq_bytes(b"list") { - let items = decode_hashable_list(cell.tail().noun(), space)?; - return Ok(Hashable::List(items)); - } - } + #[cfg(feature = "std")] + pub fn to_noun(&self, slab: &mut nockapp::noun::slab::NounSlab) -> nockvm::noun::Noun { + crate::noun_codec::encode_runtime(self, slab) + } - Ok(Hashable::Cell( - Box::new(Hashable::from_noun(&cell.head().noun(), space)?), - Box::new(Hashable::from_noun(&cell.tail().noun(), space)?), - )) - } else { - Err("hashable noun must be a cell".into()) - } + #[cfg(feature = "std")] + pub fn from_noun( + noun: &nockvm::noun::Noun, + space: &nockvm::noun::NounSpace, + ) -> Result { + crate::noun_codec::decode_runtime(*noun, space).map_err(|error| error.to_string()) } } -fn jam_noun(noun: Noun, space: &NounSpace) -> Vec { - let mut slab: NounSlab = NounSlab::new(); - let copied = slab.copy_into(noun, space); - slab.set_root(copied); - slab.jam().to_vec() +impl Encode for Hashable { + fn encode(&self, arena: &mut Arena) -> Result { + let (tag, value) = match self { + Self::Leaf(jam) => { + let mut source = Arena::new(); + let noun = pokenoun::cue(jam, &mut source) + .map_err(|_| Error("invalid hashable leaf jam"))?; + ("leaf", pokenoun::codec::copy(noun, &source, arena)) + } + Self::Hash(hash) => ("hash", hash.encode(arena)?), + Self::List(items) => ("list", items.encode(arena)?), + Self::Cell(left, right) => { + let head = left.encode(arena)?; + let tail = right.encode(arena)?; + return Ok(arena.alloc_cell(head, tail)); + } + }; + let tag = tag.encode(arena)?; + Ok(arena.alloc_cell(tag, value)) + } } -fn decode_hashable_list(noun: Noun, space: &NounSpace) -> Result, String> { - let mut items = Vec::new(); - let mut cursor = noun; - loop { - let handle = cursor.in_space(space); - if let Ok(atom) = handle.as_atom() { - let end = atom - .as_u64() - .map_err(|e| format!("list terminator decode: {e}"))?; - if end != 0 { - return Err("hashable list not properly terminated".into()); - } - break; +impl Decode for Hashable { + fn decode(noun: Noun, arena: &Arena) -> Result { + let (head, tail) = pair(noun, arena)?; + if let Noun::Atom(id) = head { + return match arena.atom_bytes_trimmed(id) { + b"leaf" => Ok(Self::Leaf(pokenoun::jam(tail, arena))), + b"hash" => Ok(Self::Hash(Hash::decode(tail, arena)?)), + b"list" => Ok(Self::List(Vec::decode(tail, arena)?)), + _ => Err(Error("invalid hashable tag")), + }; } - - let cell = handle - .as_cell() - .map_err(|_| "expected cell while decoding hashable list".to_string())?; - items.push(Hashable::from_noun(&cell.head().noun(), space)?); - cursor = cell.tail().noun(); + Ok(Self::cell( + Self::decode(head, arena)?, + Self::decode(tail, arena)?, + )) } - Ok(items) } diff --git a/tx-types/src/hashing/hasher.rs b/tx-types/src/hashing/hasher.rs index bca075d..8e1ce2a 100644 --- a/tx-types/src/hashing/hasher.rs +++ b/tx-types/src/hashing/hasher.rs @@ -1,140 +1,72 @@ -/// Core hashing functions for the transaction hashing system -/// Implements TIP5 hashing compatible with the Hoon implementation -use super::hashable::Hashable; -use super::tip5::Tip5Error; +//! TIP5 hashing over the shared noun arena. +use super::{hashable::Hashable, tip5::Tip5Error}; use crate::transaction_types::Hash; -use nockapp::noun::slab::NounSlab; -use nockvm::noun::NounAllocator; +use alloc::{ + format, + string::{String, ToString}, + vec::Vec, +}; +use pokenoun::codec::Encode; -/// Goldilocks prime: 2^64 - 2^32 + 1 -pub const GOLDILOCKS_PRIME: u64 = 0xffffffff00000001; +pub const GOLDILOCKS_PRIME: u64 = pokenoun::GOLDILOCKS_P; -/// Hash a jammed noun using variable-length hashing -/// -/// This function expects `data` to contain jammed (serialized) noun bytes. -/// It will cue (deserialize) the bytes back to a noun before hashing. -/// -/// This mirrors the Hoon hash-noun-varlen function where the input is -/// a noun that gets hashed directly. pub fn hash_noun_varlen(data: &[u8]) -> Result { - use super::tip5::Tip5Hasher; - use nockapp::Bytes; - - let mut slab: NounSlab = NounSlab::new(); - - // Cue the jammed bytes back to a noun. - let noun = slab - .cue_into(Bytes::copy_from_slice(data)) - .map_err(|e| Tip5Error::SerializationError(format!("failed to cue noun: {:?}", e)))?; + let mut arena = pokenoun::Arena::new(); + let noun = pokenoun::cue(data, &mut arena) + .map_err(|error| Tip5Error::SerializationError(format!("invalid jam: {error:?}")))?; + hash_arena_noun(noun, &arena) +} - // Use the real TIP5 hasher - let space = slab.noun_space(); - Tip5Hasher::hash_noun_varlen(noun, &space) +pub fn hash_arena_noun(noun: pokenoun::Noun, arena: &pokenoun::Arena) -> Result { + pokenoun::hash_noun_varlen(noun, arena) + .map(|values| Hash { values }) + .map_err(Into::into) } -/// Hash two digests together (hash-ten-cell in Hoon). -/// -/// The hash_10 jet expects a proper Hoon list of 10 belts: [a [b [c ... [j 0]...]]] -/// This is created by flattening the ten-cell [[l0 l1 l2 l3 l4] [r0 r1 r2 r3 r4]] -/// into a list [l0 l1 l2 l3 l4 r0 r1 r2 r3 r4]. pub fn hash_ten_cell(left: Hash, right: Hash) -> Hash { - use super::tip5::Tip5Hasher; - - // Flatten left and right hash values into a single array - let values: [u64; 10] = [ - left.values[0], - left.values[1], - left.values[2], - left.values[3], - left.values[4], - right.values[0], - right.values[1], - right.values[2], - right.values[3], - right.values[4], - ]; - - // `hash_10_from_values` is pure arithmetic over already-validated limbs - // and cannot fail; keep the infallible signature. - Tip5Hasher::hash_10_from_values(&values).expect("hash_10 over 5+5 u64 limbs is infallible") + Hash { + values: pokenoun::hash_ten_cell(left.values, right.values).expect("digest limbs are based"), + } } -/// Recursively hash a Hashable structure -/// This mirrors the Hoon hash-hashable function. -/// -/// Returns an error instead of a fabricated digest when a leaf cannot be -/// decoded or contains an unbased atom (an atom that does not fit in a belt). pub fn hash_hashable(h: &Hashable) -> Result { - super::tip5::Tip5Hasher::hash_hashable(h) + match h { + Hashable::Leaf(jam) => hash_noun_varlen(jam), + Hashable::Hash(hash) => Ok(hash.clone()), + Hashable::Cell(left, right) => { + Ok(hash_ten_cell(hash_hashable(left)?, hash_hashable(right)?)) + } + Hashable::List(items) => { + let hashes = items + .iter() + .map(hash_hashable) + .collect::, _>>()?; + hash_noun_varlen( + &hashes + .to_jam() + .map_err(|error| Tip5Error::SerializationError(error.to_string()))?, + ) + } + } } -/// Convert a digest to a base58 string (for display) +#[cfg(feature = "std")] pub fn digest_to_base58(digest: &Hash) -> String { - // This mirrors the Hoon digest-to-atom and then base58 encoding - // Following the formula: a + b*p + c*p² + d*p³ + e*p⁴ - - use bs58; - use num_bigint::BigUint; - - let p = BigUint::from(GOLDILOCKS_PRIME); - let mut result = BigUint::from(digest.values[0]); - - for i in 1..5 { - let power = p.pow(i as u32); - result += BigUint::from(digest.values[i]) * power; - } - - bs58::encode(result.to_bytes_be()).into_string() + digest.to_b58() } -/// Hash a transcript list using TIP5 with proper Hoon list structure -/// -/// This function is used by the Schnorr signature process to generate nonces -/// and challenges. It takes multiple arrays of u64 values, flattens them into -/// a single list, and hashes the list using TIP5. -/// -/// The list is constructed in Hoon format: [a [b [c [d ... 0]]]] -/// (right-associative with nil terminator) -/// -/// # Arguments -/// * `element_arrays` - Arrays of u64 values to hash (e.g., public key coordinates, message) -/// -/// # Returns -/// * `Ok(Hash)` - The TIP5 hash of the transcript list -/// * `Err(Tip5Error)` - If hashing fails -/// -/// # Used in Schnorr signatures: -/// - Nonce generation: TIP5([pubkey.x, pubkey.y, message]) -/// - Challenge generation: TIP5([R.x, R.y, pubkey.x, pubkey.y, message]) -pub fn hash_transcript_list( - element_arrays: &[&[u64]], -) -> Result { - use super::tip5::Tip5Hasher; - use nockvm::noun::Cell; - - let mut slab: NounSlab = NounSlab::new(); - - // Flatten all elements into a single list - let mut all_elements = Vec::new(); - for array in element_arrays { - all_elements.extend_from_slice(array); - } - - // Build Hoon list structure: [a [b [c [d ... 0]]]] - let mut list = nockvm::noun::Atom::new(&mut slab, 0).as_noun(); // Start with nil (0) - - // Build list in reverse order (right-associative) - for &element in all_elements.iter().rev() { - let atom = nockvm::noun::Atom::new(&mut slab, element).as_noun(); - list = Cell::new(&mut slab, atom, list).as_noun(); - } - - // Hash the list using TIP5 - let space = slab.noun_space(); - Tip5Hasher::hash_varlen(list, &space) +pub fn hash_transcript_list(element_arrays: &[&[u64]]) -> Result { + let words: Vec<_> = element_arrays + .iter() + .flat_map(|array| array.iter().copied()) + .collect(); + pokenoun::hash_varlen_words(&words) + .map(|values| Hash { values }) + .map_err(Into::into) } -#[cfg(test)] +#[cfg(all(test, feature = "std"))] + mod tests { use super::*; diff --git a/tx-types/src/hashing/mod.rs b/tx-types/src/hashing/mod.rs index 264e597..bc482a4 100644 --- a/tx-types/src/hashing/mod.rs +++ b/tx-types/src/hashing/mod.rs @@ -7,18 +7,23 @@ /// - General hashing utilities pub mod hashable; pub mod hasher; +#[cfg(feature = "std")] pub mod raw; pub mod tip5; pub mod tx_id; +#[cfg(feature = "std")] pub mod u320; // Re-export commonly used items pub use hashable::Hashable; pub use hasher::{hash_hashable, hash_noun_varlen, hash_ten_cell}; +#[cfg(feature = "std")] pub use raw::{ compute_tx_id_v1_from_raw_noun, hashable_note_data_from_raw, hashable_pkh_signature_direct, hashable_pkh_signature_from_raw, hashable_seeds_from_raw, hashable_spend_from_raw, hashable_spends_from_raw, hashable_witness_from_raw, }; pub use tip5::{Tip5Error, Tip5Hasher}; -pub use tx_id::{compute_tx_id, compute_tx_id_base58}; +pub use tx_id::compute_tx_id; +#[cfg(feature = "std")] +pub use tx_id::compute_tx_id_base58; diff --git a/tx-types/src/hashing/tip5.rs b/tx-types/src/hashing/tip5.rs index 7a2535b..0d34a7a 100644 --- a/tx-types/src/hashing/tip5.rs +++ b/tx-types/src/hashing/tip5.rs @@ -1,15 +1,21 @@ -use nockapp::noun::slab::NounSlab; +use alloc::{format, string::String}; +#[cfg(feature = "std")] use nockapp::Noun; // Re-export for use by other modules in this crate -use nockapp::noun::NounAllocatorExt; +#[cfg(feature = "std")] pub use nockchain_libp2p_io::tip5_util::tip5_hash_to_base58; +#[cfg(feature = "std")] use nockchain_math::shape::do_leaf_sequence; +#[cfg(feature = "std")] use nockchain_math::tip5::hash::{hash_belts_slice, hash_noun_varlen_digest, hash_ten_cell}; +#[cfg(feature = "std")] use nockvm::jets::util::test::init_context; +#[cfg(feature = "std")] use nockvm::jets::JetErr; +#[cfg(feature = "std")] use nockvm::noun::{NounAllocator, NounSpace}; -use zkvm_jetpack::jets::tip5_jets::hash_hashable as hash_hashable_noun; +#[cfg(feature = "std")] use crate::transaction_types::Hash; /// Errors that can occur during TIP5 hashing @@ -23,8 +29,8 @@ pub enum Tip5Error { InvalidNoun(String), } -impl std::fmt::Display for Tip5Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Display for Tip5Error { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Tip5Error::SerializationError(msg) => write!(f, "Serialization error: {}", msg), Tip5Error::HashingError(msg) => write!(f, "Hashing error: {}", msg), @@ -33,8 +39,9 @@ impl std::fmt::Display for Tip5Error { } } -impl std::error::Error for Tip5Error {} +impl core::error::Error for Tip5Error {} +#[cfg(feature = "std")] impl From for Tip5Error { fn from(err: JetErr) -> Self { Tip5Error::HashingError(format!("Jet error: {:?}", err)) @@ -44,6 +51,7 @@ impl From for Tip5Error { /// TIP5 hasher for Nock nouns pub struct Tip5Hasher; +#[cfg(feature = "std")] impl Tip5Hasher { /// Parse a hash noun which can be either: /// - A 5-tuple: [a [b [c [d e]]]] (from digest_to_noundigest) @@ -158,20 +166,7 @@ impl Tip5Hasher { /// Hash a Hashable structure using the TIP5 jet. pub fn hash_hashable(hashable: &crate::hashing::hashable::Hashable) -> Result { - hashable.validate_leaves()?; - let mut slab: NounSlab = NounSlab::new(); - let noun = hashable.to_noun(&mut slab); - slab.set_root(noun); - let slab_space = slab.noun_space(); - - let context = &mut init_context(); - let copied = context.stack.copy_into(noun, &slab_space); - let stack_space = context.stack.noun_space(); - let hash_noun = hash_hashable_noun(&mut context.stack, copied, &stack_space) - .map_err(|e| Tip5Error::HashingError(format!("TIP5 hash_hashable failed: {:?}", e)))?; - - let result_space = context.stack.noun_space(); - Self::digest_from_noun(hash_noun, &result_space) + super::hasher::hash_hashable(hashable) } /// Hash a noun and convert to base58 string @@ -238,7 +233,7 @@ impl Tip5Hasher { // Tests for the TIP5Hasher struct -#[cfg(test)] +#[cfg(all(test, feature = "std"))] mod tests { use super::*; use nockvm::noun::{D, T}; @@ -318,3 +313,9 @@ mod tests { println!("hash_varlen([1, 2, 3]) = {:x?}", hash.values); } } + +impl From for Tip5Error { + fn from(error: pokenoun::Tip5Error) -> Self { + Self::HashingError(format!("TIP5: {error:?}")) + } +} diff --git a/tx-types/src/hashing/tx_id.rs b/tx-types/src/hashing/tx_id.rs index c99db5a..38bc6bb 100644 --- a/tx-types/src/hashing/tx_id.rs +++ b/tx-types/src/hashing/tx_id.rs @@ -1,7 +1,9 @@ /// Transaction ID computation /// Implements the compute-id logic from Hoon's raw-tx use super::hashable::Hashable; -use super::hasher::{digest_to_base58, hash_hashable}; +#[cfg(feature = "std")] +use super::hasher::digest_to_base58; +use super::hasher::hash_hashable; use crate::transaction_types::{Coins, Hash, Inputs, TimelockRange}; /// Compute the transaction ID from components @@ -34,6 +36,7 @@ pub fn compute_tx_id(inputs: &Inputs, timelock_range: &TimelockRange, total_fees } /// Compute tx-id and return as base58 string +#[cfg(feature = "std")] pub fn compute_tx_id_base58( inputs: &Inputs, timelock_range: &TimelockRange, diff --git a/tx-types/src/lib.rs b/tx-types/src/lib.rs index e728da0..f6c023d 100644 --- a/tx-types/src/lib.rs +++ b/tx-types/src/lib.rs @@ -23,10 +23,10 @@ extern crate nockchain_libp2p_io_minimal as nockchain_libp2p_io; #[cfg(feature = "embedded")] extern crate nockchain_math_minimal as nockchain_math; #[cfg(feature = "embedded")] -extern crate nockvm_minimal as nockvm; -#[cfg(feature = "embedded")] extern crate nockvm_macros_minimal as nockvm_macros; #[cfg(feature = "embedded")] +extern crate nockvm_minimal as nockvm; +#[cfg(feature = "embedded")] extern crate noun_serde_minimal as noun_serde; #[cfg(feature = "embedded")] extern crate zkvm_jetpack_minimal as zkvm_jetpack; @@ -40,61 +40,60 @@ extern crate nockchain_libp2p_io_upstream as nockchain_libp2p_io; #[cfg(feature = "native")] extern crate nockchain_math_upstream as nockchain_math; #[cfg(feature = "native")] -extern crate nockvm_upstream as nockvm; -#[cfg(feature = "native")] extern crate nockvm_macros_upstream as nockvm_macros; #[cfg(feature = "native")] +extern crate nockvm_upstream as nockvm; +#[cfg(feature = "native")] extern crate noun_serde_upstream as noun_serde; #[cfg(feature = "native")] extern crate zkvm_jetpack_upstream as zkvm_jetpack; pub use pokenoun; +pub mod noun_codec; // Crypto module is always available (no_std compatible) pub mod crypto; -// All other modules require std +// Runtime and IO modules require std. #[cfg(feature = "std")] pub mod block_types; -#[cfg(feature = "std")] pub mod collections; -#[cfg(feature = "std")] +pub mod determinism; +pub mod fee; pub mod fee_calculator; -#[cfg(feature = "std")] pub mod generic_noun; -#[cfg(feature = "std")] pub mod hashing; #[cfg(feature = "std")] pub mod inner_peeks; #[cfg(feature = "std")] pub mod inspect; +pub mod lock; +pub mod lock_resolver; +pub mod note_data; +pub mod planner; #[cfg(feature = "std")] -pub mod signer; +pub mod rpc_note_data; #[cfg(feature = "std")] +pub mod signer; pub mod transaction_types; -#[cfg(feature = "std")] pub mod transaction_types_v0; -#[cfg(feature = "std")] pub mod transaction_types_v1; #[cfg(feature = "std")] -pub mod tx_builder_batch; -#[cfg(feature = "std")] -pub mod tx_builder_v1; +pub mod tx_builder; #[cfg(feature = "std")] pub mod tx_to_noun; #[cfg(feature = "std")] pub mod validation; +pub mod word_count; -// Re-exports for std builds -#[cfg(feature = "std")] +// Shared transaction and hashing types pub use hashing::{ hashable::Hashable, hasher::{hash_hashable, hash_noun_varlen, hash_ten_cell}, tip5::{Tip5Error, Tip5Hasher}, - tx_id::{compute_tx_id, compute_tx_id_base58}, + tx_id::compute_tx_id, }; -#[cfg(feature = "std")] pub use collections::{ zmap::ZMap, zset::{DorTip, ZSet}, @@ -106,26 +105,17 @@ pub use validation::{schnorr_verify_digest, TransactionValidationError, Transact #[cfg(feature = "std")] pub use signer::{schnorr_sign_digest, sign_spend, sign_tx}; +pub use note_data::{lock_data_to_untyped_noun, LockData}; #[cfg(feature = "std")] -pub use tx_builder_v1::{ - build_spends, create_spends_0, create_spends_1, lock_data_to_untyped_noun, sign_spend_v0_to_v1, - sign_spend_v1, LockData, Order, -}; - -#[cfg(feature = "std")] -pub use tx_builder_batch::{build_batch_spends, BatchOrder}; +pub use tx_builder::{build_spends, sign_spend_v0_to_v1, sign_spend_v1}; -#[cfg(feature = "std")] pub use fee_calculator::{ calculate_min_fee, calculate_min_fee_for_page, BASE_FEE, BYTHOS_PHASE, INPUT_FEE_DIVISOR, MIN_FEE, }; -#[cfg(feature = "std")] pub use transaction_types::*; -#[cfg(feature = "std")] pub use transaction_types_v0::*; -#[cfg(feature = "std")] pub use transaction_types_v1::*; #[cfg(feature = "std")] @@ -134,9 +124,19 @@ pub use inspect::{spends_dump, transaction_json, transaction_stats, TransactionS #[cfg(feature = "std")] pub use block_types::*; -#[cfg(feature = "std")] pub use generic_noun::*; // Test modules #[cfg(all(test, feature = "std"))] mod tests; + +#[cfg(test)] +#[path = "tests/test_transaction_reference.rs"] +mod transaction_reference_tests; + +#[cfg(test)] +#[path = "tests/test_planner.rs"] +mod planner_tests; + +#[cfg(feature = "std")] +pub use hashing::tx_id::compute_tx_id_base58; diff --git a/tx-types/src/lock.rs b/tx-types/src/lock.rs new file mode 100644 index 0000000..d465b7e --- /dev/null +++ b/tx-types/src/lock.rs @@ -0,0 +1,380 @@ +//! V1 tagged lock trees and proofs of whole spend conditions. + +use crate::hashing::{hash_hashable, hash_ten_cell, Hashable}; +use crate::transaction_types::Hash; +use crate::transaction_types_v1::{ + Brn, LockMerkleProof, LockPrimitive, LockPrimitiveBody, MerkleProof, SpendCondition, +}; +use alloc::{vec, vec::Vec}; +use pokenoun::codec::{pair, Decode, Encode, Error}; +use pokenoun::{Arena, Noun}; + +#[derive(Debug, Clone)] +pub enum Lock { + SpendCondition(SpendCondition), + V2(LockV2), + V4(LockV4), + V8(LockV8), + V16(LockV16), +} + +#[derive(Debug, Clone)] +pub struct LockV2 { + pub p: SpendCondition, + pub q: SpendCondition, +} + +#[derive(Debug, Clone)] +pub struct LockV4 { + pub p: LockV2, + pub q: LockV2, +} + +#[derive(Debug, Clone)] +pub struct LockV8 { + pub p: LockV4, + pub q: LockV4, +} + +#[derive(Debug, Clone)] +pub struct LockV16 { + pub p: LockV8, + pub q: LockV8, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LockError { + InvalidLeafCount(usize), + InvalidLeafIndex(u64), + EmptySpendCondition, + StubTreeProof, + InvalidEncoding, +} + +impl core::fmt::Display for LockError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::InvalidLeafCount(n) => { + write!(f, "a lock requires 1 through 16 conditions, got {n}") + } + Self::InvalidLeafIndex(n) => write!(f, "lock leaf index {n} is out of range"), + Self::EmptySpendCondition => f.write_str("an empty spend condition cannot authorize"), + Self::InvalidEncoding => f.write_str("lock contains invalid or unbased data"), + Self::StubTreeProof => f.write_str("stub proofs require a single spend condition"), + } + } +} + +impl core::error::Error for LockError {} + +impl Lock { + /// Build a balanced lock, padding to the next power of two with burn leaves. + pub fn from_list(mut conditions: Vec) -> Result { + if conditions.is_empty() || conditions.len() > 16 { + return Err(LockError::InvalidLeafCount(conditions.len())); + } + if conditions.iter().any(|condition| condition.p.is_empty()) { + return Err(LockError::EmptySpendCondition); + } + conditions.resize_with(conditions.len().next_power_of_two(), || SpendCondition { + p: vec![LockPrimitive { + header: "brn".into(), + body: LockPrimitiveBody::Brn(Brn { value: 0 }), + }], + }); + fn pair(sc: &[SpendCondition]) -> LockV2 { + LockV2 { + p: sc[0].clone(), + q: sc[1].clone(), + } + } + fn four(sc: &[SpendCondition]) -> LockV4 { + LockV4 { + p: pair(sc), + q: pair(&sc[2..]), + } + } + fn eight(sc: &[SpendCondition]) -> LockV8 { + LockV8 { + p: four(sc), + q: four(&sc[4..]), + } + } + Ok(match conditions.len() { + 1 => Self::SpendCondition(conditions.remove(0)), + 2 => Self::V2(pair(&conditions)), + 4 => Self::V4(four(&conditions)), + 8 => Self::V8(eight(&conditions)), + 16 => Self::V16(LockV16 { + p: eight(&conditions), + q: eight(&conditions[8..]), + }), + _ => unreachable!(), + }) + } + + pub fn spend_condition_count(&self) -> u64 { + match self { + Self::SpendCondition(_) => 1, + Self::V2(_) => 2, + Self::V4(_) => 4, + Self::V8(_) => 8, + Self::V16(_) => 16, + } + } + + pub fn flatten_spend_conditions(&self) -> Vec { + fn two(lock: &LockV2, out: &mut Vec) { + out.extend([lock.p.clone(), lock.q.clone()]); + } + fn four(lock: &LockV4, out: &mut Vec) { + two(&lock.p, out); + two(&lock.q, out); + } + fn eight(lock: &LockV8, out: &mut Vec) { + four(&lock.p, out); + four(&lock.q, out); + } + let mut out = Vec::with_capacity(self.spend_condition_count() as usize); + match self { + Self::SpendCondition(sc) => out.push(sc.clone()), + Self::V2(lock) => two(lock, &mut out), + Self::V4(lock) => four(lock, &mut out), + Self::V8(lock) => eight(lock, &mut out), + Self::V16(lock) => { + eight(&lock.p, &mut out); + eight(&lock.q, &mut out); + } + } + out + } + + /// Only the outer arity is tagged; inner pairs have no arity tags. + pub fn to_hashable(&self) -> Hashable { + fn tree(leaves: &[SpendCondition]) -> Hashable { + if leaves.len() == 1 { + Hashable::Hash(leaves[0].to_hash()) + } else { + let mid = leaves.len() / 2; + Hashable::cell(tree(&leaves[..mid]), tree(&leaves[mid..])) + } + } + let leaves = self.flatten_spend_conditions(); + let body = tree(&leaves); + if leaves.len() == 1 { + body + } else { + Hashable::cell( + Hashable::leaf_from_atom((leaves.len() as u64).to_le_bytes()), + body, + ) + } + } + + pub fn hash(&self) -> Hash { + hash_hashable(&self.to_hashable()).expect("lock hashable contains based atoms") + } +} + +/// Build the proof version admitted at the candidate height. +pub fn build_lock_merkle_proof( + form: &Lock, + leaf_number: u64, + page: u64, +) -> Result { + if page >= crate::fee::BYTHOS_PHASE { + build_lock_merkle_proof_full(form, leaf_number) + } else { + if form.spend_condition_count() != 1 { + return Err(LockError::StubTreeProof); + } + build_lock_merkle_proof_stub(form, leaf_number) + } +} + +fn prove_lock( + form: &Lock, + leaf_number: u64, +) -> Result<(SpendCondition, u64, MerkleProof), LockError> { + if leaf_number == 0 || leaf_number > form.spend_condition_count() { + return Err(LockError::InvalidLeafIndex(leaf_number)); + } + crate::noun_codec::validate_based(form).map_err(|_| LockError::InvalidEncoding)?; + let leaves = form.flatten_spend_conditions(); + let condition = leaves[(leaf_number - 1) as usize].clone(); + if condition.p.is_empty() { + return Err(LockError::EmptySpendCondition); + } + let index = leaf_number + u64::from(leaves.len() > 1); + let (axis, proof) = prove_hashable_by_index(&form.to_hashable(), index)?; + Ok((condition, axis, proof)) +} + +/// Produce Hoon's stub representation. Tree stubs are not admitted for ordinary spends. +pub fn build_lock_merkle_proof_stub( + form: &Lock, + leaf_number: u64, +) -> Result { + let (condition, axis, proof) = prove_lock(form, leaf_number)?; + Ok(LockMerkleProof::new_stub(condition, axis, proof)) +} + +pub fn build_lock_merkle_proof_full( + form: &Lock, + leaf_number: u64, +) -> Result { + let (condition, axis, proof) = prove_lock(form, leaf_number)?; + Ok(LockMerkleProof::new_full(condition, axis, proof)) +} + +/// Prove a one-based index. Lists are hashable leaves, not Merkle branches. +pub fn prove_hashable_by_index( + form: &Hashable, + index: u64, +) -> Result<(u64, MerkleProof), LockError> { + fn count(node: &Hashable) -> u64 { + match node { + Hashable::Cell(left, right) => count(left) + count(right), + _ => 1, + } + } + fn descend(node: &Hashable, index: u64, axis: u64, path: &mut Vec) -> u64 { + match node { + Hashable::Cell(left, right) => { + let left_count = count(left); + let (child, sibling, index, axis) = if index <= left_count { + (left, right, index, axis * 2) + } else { + (right, left, index - left_count, axis * 2 + 1) + }; + let axis = descend(child, index, axis, path); + path.push(hash_hashable(sibling).expect("lock sibling is hashable")); + axis + } + _ => axis, + } + } + if index == 0 || index > count(form) { + return Err(LockError::InvalidLeafIndex(index)); + } + let mut path = Vec::new(); + let axis = descend(form, index, 1, &mut path); + Ok(( + axis, + MerkleProof { + root: hash_hashable(form).expect("lock is hashable"), + path, + }, + )) +} + +/// Authenticate the arity tag at the end of a leaf-to-root path. +pub fn authorization_leaf(axis: u64, path: &[Hash]) -> bool { + let arity: u64 = match axis { + 1 => return true, + 6..=7 => 2, + 12..=15 => 4, + 24..=31 => 8, + 48..=63 => 16, + _ => return false, + }; + path.last() + == Some( + &hash_hashable(&Hashable::leaf_from_atom(arity.to_le_bytes())) + .expect("arity is a based atom"), + ) +} + +/// Admission rule for V1 inputs; the protocol fund uses a separate recovery policy. +pub fn valid_stub_axis(proof: &LockMerkleProof, is_protocol_fund: bool) -> bool { + is_protocol_fund || matches!(proof, LockMerkleProof::Full(_)) || proof.axis() == 1 +} + +impl MerkleProof { + pub fn verify(&self, mut leaf: Hash, mut axis: u64) -> bool { + if axis == 0 { + return false; + } + for sibling in &self.path { + if axis == 1 { + return false; + } + leaf = if axis & 1 == 0 { + hash_ten_cell(leaf, sibling.clone()) + } else { + hash_ten_cell(sibling.clone(), leaf) + }; + axis >>= 1; + } + axis == 1 && leaf == self.root + } +} + +impl LockMerkleProof { + /// Verify inclusion, whole-condition authorization, and the parent commitment. + /// Primitive signatures, preimages and timelocks require the spending context. + pub fn check(&self, parent_firstname: &Hash) -> bool { + if self.spend_condition().p.is_empty() || crate::noun_codec::validate_based(self).is_err() { + return false; + } + let firstname = hash_hashable(&Hashable::cell( + Hashable::null(), + Hashable::Hash(self.proof().root.clone()), + )) + .expect("firstname is hashable"); + if &firstname != parent_firstname { + return false; + } + let authorized = match self { + Self::Stub(_) => self.axis() == 1, + Self::Full(proof) => { + proof.version == u64::from_le_bytes(*b"full\0\0\0\0") + && authorization_leaf(self.axis(), &self.proof().path) + } + }; + authorized + && self + .proof() + .verify(self.spend_condition().to_hash(), self.axis()) + } +} + +crate::noun_codec::noun_struct!( + LockV2 { p, q }, + LockV4 { p, q }, + LockV8 { p, q }, + LockV16 { p, q } +); +impl Encode for Lock { + fn encode(&self, arena: &mut Arena) -> Result { + let (tag, body): (u64, _) = match self { + Self::SpendCondition(sc) => return sc.encode(arena), + Self::V2(lock) => (2, lock.encode(arena)?), + Self::V4(lock) => (4, lock.encode(arena)?), + Self::V8(lock) => (8, lock.encode(arena)?), + Self::V16(lock) => (16, lock.encode(arena)?), + }; + let tag = tag.encode(arena)?; + Ok(arena.alloc_cell(tag, body)) + } +} +impl Decode for Lock { + fn decode(noun: Noun, arena: &Arena) -> Result { + let (head, body) = pair(noun, arena)?; + if matches!(head, Noun::Cell(_)) { + return SpendCondition::decode(noun, arena).map(Self::SpendCondition); + } + match u64::decode(head, arena)? { + 2 => LockV2::decode(body, arena).map(Self::V2), + 4 => LockV4::decode(body, arena).map(Self::V4), + 8 => LockV8::decode(body, arena).map(Self::V8), + 16 => LockV16::decode(body, arena).map(Self::V16), + _ => Err(Error("invalid lock arity")), + } + } +} +crate::noun_codec::runtime_codec!(Lock); + +#[cfg(test)] +#[path = "tests/test_lock_reference.rs"] +mod tests; diff --git a/tx-types/src/lock_resolver.rs b/tx-types/src/lock_resolver.rs new file mode 100644 index 0000000..b702057 --- /dev/null +++ b/tx-types/src/lock_resolver.rs @@ -0,0 +1,312 @@ +//! Lock discovery and admission policies for a spending candidate. + +use crate::lock::Lock; +use crate::note_data::DecodedNoteData; +use crate::{ + Hash, LockPrimitive, LockPrimitiveBody, NName, NNoteV0, NNoteV1, PageNumber, Pkh, + SchnorrPubkey, SpendCondition, Tim, TimelockRange, UntypedNoun, ZMap, ZSet, +}; +use alloc::{vec, vec::Vec}; + +pub const CONSENSUS_BATCH_PHASE: u64 = 170_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FundMaturityPolicy { + pub activation_height: u64, + pub minimum: u64, +} + +impl FundMaturityPolicy { + pub fn fund_minimum_at(self, candidate_height: u64) -> Option { + (candidate_height >= self.activation_height).then_some(self.minimum) + } + + pub fn at(self, candidate_height: u64) -> FundMaturity { + FundMaturity { + candidate_height, + minimum: self.fund_minimum_at(candidate_height), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FundMaturity { + pub candidate_height: u64, + /// An inactive minimum admits all origins; zero still rejects future origins. + pub minimum: Option, +} + +impl FundMaturity { + pub fn admits(self, origin_height: u64) -> bool { + self.minimum.is_none_or(|minimum| { + self.candidate_height + .checked_sub(origin_height) + .is_some_and(|age| age >= minimum) + }) + } +} + +#[derive(Debug, Clone)] +pub struct LockResolution { + pub lock: Lock, + /// One-based index of the authorized condition. + pub leaf_number: u64, + pub signature_count: u64, + pub preimages: ZMap, + pub fund_maturity: Option, +} + +impl LockResolution { + pub fn spend_condition(&self) -> Option { + self.leaf_number.checked_sub(1).and_then(|index| { + self.lock + .flatten_spend_conditions() + .get(index as usize) + .cloned() + }) + } + + pub fn admits(&self, note: &NNoteV1, candidate_height: Option) -> bool { + if crate::noun_codec::validate_based(&self.lock).is_err() { + return false; + } + let Some(condition) = self.spend_condition() else { + return false; + }; + if let Some(maturity) = self.fund_maturity { + if candidate_height != Some(maturity.candidate_height) + || !maturity.admits(note.origin_page.value) + { + return false; + } + } else if note.name.p.first() != Some(&NName::first_v1(self.lock.hash())) { + return false; + } + !condition.p.is_empty() + && condition.p.iter().all(|primitive| match &primitive.body { + LockPrimitiveBody::Pkh(pkh) => { + pkh.m > 0 && pkh.m <= pkh.h.wyt() as u64 && self.signature_count >= pkh.m + } + LockPrimitiveBody::Tim(tim) => { + timelock_admits(&tim.abs, &tim.rel, note.origin_page.value, candidate_height) + } + LockPrimitiveBody::Hax(hax) => hax.set.tap().iter().all(|hash| { + self.preimages + .get(hash) + .and_then(|noun| noun.to_hashable().ok()) + .and_then(|value| crate::hash_hashable(&value).ok()) + .as_ref() + == Some(hash) + }), + LockPrimitiveBody::Brn(_) => false, + }) + } +} + +pub fn timelock_admits( + absolute: &TimelockRange, + relative: &TimelockRange, + origin_height: u64, + candidate_height: Option, +) -> bool { + let Some(height) = candidate_height else { + return false; + }; + let Some(age) = height.checked_sub(origin_height) else { + return false; + }; + [(absolute, height), (relative, age)] + .into_iter() + .all(|(range, value)| { + range.min.as_ref().is_none_or(|min| value >= min.value) + && range.max.as_ref().is_none_or(|max| value <= max.value) + }) +} + +/// Consumers supply their signing and unlock context without performing IO here. +pub trait LockMatcher { + fn resolve(&self, note: &NNoteV1) -> Option; + + fn v0_signature_count(&self, _note: &NNoteV0) -> Option { + None + } +} + +pub fn simple_pkh(pkh: Hash) -> SpendCondition { + SpendCondition { + p: vec![LockPrimitive { + header: "pkh".into(), + body: LockPrimitiveBody::Pkh(Pkh { + m: 1, + h: ZSet::gas(vec![pkh]), + }), + }], + } +} + +pub fn coinbase_pkh(pkh: Hash, relative_minimum: u64) -> SpendCondition { + let mut condition = simple_pkh(pkh); + condition.p.push(LockPrimitive { + header: "tim".into(), + body: LockPrimitiveBody::Tim(Tim { + rel: TimelockRange { + min: Some(PageNumber { + value: relative_minimum, + }), + max: None, + }, + abs: TimelockRange { + min: None, + max: None, + }, + }), + }); + condition +} + +/// A signer can satisfy one 1-of-N PKH primitive and at most one timelock. +#[derive(Debug, Clone)] +pub struct SigningKeyLockMatcher { + pub signer_pkh: Hash, + pub public_key: Option, + pub coinbase_relative_min: Option, +} + +fn pkh_policy(condition: &SpendCondition) -> Option { + let mut policy = None; + let mut tim = false; + for primitive in &condition.p { + match &primitive.body { + LockPrimitiveBody::Pkh(pkh) + if policy.is_none() && pkh.m > 0 && pkh.m <= pkh.h.wyt() as u64 => + { + policy = Some(crate::RequiredPkhPolicy { + threshold: pkh.m as usize, + hashes: pkh.h.tap(), + }); + } + LockPrimitiveBody::Tim(_) if !tim => tim = true, + _ => return None, + } + } + policy +} + +fn resolution( + lock: Lock, + accept: impl Fn(&crate::RequiredPkhPolicy) -> bool, +) -> Option { + lock.flatten_spend_conditions() + .iter() + .enumerate() + .find_map(|(index, condition)| { + let policy = pkh_policy(condition)?; + accept(&policy).then(|| LockResolution { + lock: lock.clone(), + leaf_number: index as u64 + 1, + signature_count: policy.threshold as u64, + preimages: ZMap::new(), + fund_maturity: None, + }) + }) +} + +impl LockMatcher for SigningKeyLockMatcher { + fn resolve(&self, note: &NNoteV1) -> Option { + let disclosed = DecodedNoteData::from_note_data(¬e.note_data) + .ok() + .and_then(|data| data.lock().cloned()); + let mut locks: Vec = disclosed.into_iter().collect(); + locks.push(Lock::SpendCondition(simple_pkh(self.signer_pkh.clone()))); + if let Some(minimum) = self.coinbase_relative_min { + locks.push(Lock::SpendCondition(coinbase_pkh( + self.signer_pkh.clone(), + minimum, + ))); + } + locks + .into_iter() + .filter(|lock| note.name.p.first() == Some(&NName::first_v1(lock.hash()))) + .find_map(|lock| { + resolution(lock, |policy| { + policy.threshold == 1 && policy.contains(&self.signer_pkh) + }) + }) + } + + fn v0_signature_count(&self, note: &NNoteV0) -> Option { + let key = self.public_key.as_ref()?; + (key.to_hash() == self.signer_pkh && note.lock.m == 1 && note.lock.pubkeys.has(key)) + .then_some(1) + } +} + +/// Match a committed lock root for multisig planning, including partial signing. +#[derive(Debug, Clone)] +pub struct LockRootLockMatcher { + pub lock_root: Hash, + planning_lock: Option, + fund_note: Option<(Hash, FundMaturity)>, +} + +impl LockRootLockMatcher { + pub fn from_lock_root(lock_root: Hash) -> Self { + Self { + lock_root, + planning_lock: None, + fund_note: None, + } + } + + pub fn with_lock(mut self, lock: Lock) -> Self { + self.planning_lock = Some(lock); + self + } + + pub fn with_spend_condition(self, condition: SpendCondition) -> Self { + self.with_lock(Lock::SpendCondition(condition)) + } + + pub fn with_fund_notes( + mut self, + fund_lock_root: &Hash, + fund_firstname: Hash, + maturity: FundMaturity, + ) -> Self { + self.fund_note = (self.lock_root == *fund_lock_root).then_some((fund_firstname, maturity)); + self + } +} + +impl LockMatcher for LockRootLockMatcher { + fn resolve(&self, note: &NNoteV1) -> Option { + let firstname = note.name.p.first()?; + let direct = firstname == &NName::first_v1(self.lock_root.clone()); + let maturity = self + .fund_note + .as_ref() + .filter(|(name, _)| name == firstname) + .map(|(_, maturity)| *maturity); + if !direct && maturity.is_none() { + return None; + } + let disclosed = direct + .then(|| DecodedNoteData::from_note_data(¬e.note_data).ok()) + .flatten() + .and_then(|data| data.lock().cloned()); + let lock = disclosed + .filter(|lock| lock.hash() == self.lock_root) + .or_else(|| { + self.planning_lock + .clone() + .filter(|lock| lock.hash() == self.lock_root) + })?; + // Fund recovery authenticates the real multisig preimage independently of the committed wrapper. + if !direct && lock.spend_condition_count() != 1 { + return None; + } + let mut resolved = resolution(lock, |_| true)?; + resolved.fund_maturity = if direct { None } else { maturity }; + Some(resolved) + } +} diff --git a/tx-types/src/note_data.rs b/tx-types/src/note_data.rs new file mode 100644 index 0000000..83a27a0 --- /dev/null +++ b/tx-types/src/note_data.rs @@ -0,0 +1,307 @@ +//! Canonical note-data and typed lock/bridge payloads. + +use crate::lock::Lock; +use crate::{Hash, NoteData, UntypedNoun, ZMap}; +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; +use pokenoun::codec::{is_null, pair, Decode, Encode, Error, Fields}; +use pokenoun::{Arena, Noun}; + +pub const NOTE_DATA_KEY_LOCK: &str = "lock"; +pub const NOTE_DATA_KEY_BRIDGE_DEPOSIT: &str = "bridge"; +pub const NOTE_DATA_KEY_BRIDGE_WITHDRAWAL: &str = "bridge-w"; + +#[derive(Debug, Clone)] +pub enum LockData { + V0(Lock), +} + +impl LockData { + pub fn lock(&self) -> &Lock { + match self { + Self::V0(lock) => lock, + } + } +} + +impl Encode for LockData { + fn encode(&self, arena: &mut Arena) -> Result { + let lock = self.lock().encode(arena)?; + Ok(arena.alloc_cell(arena.atom0(), lock)) + } +} + +impl Decode for LockData { + fn decode(noun: Noun, arena: &Arena) -> Result { + let (version, lock) = pair(noun, arena)?; + if !is_null(version, arena) { + return Err(Error("unsupported lock-data version")); + } + Lock::decode(lock, arena).map(Self::V0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgeDepositDataPayload { + /// Little-endian base-p digits, where p is the Goldilocks prime. + pub evm_address_based: [u64; 3], +} + +impl BridgeDepositDataPayload { + /// Convert a 20-byte, big-endian EVM address to Hoon's three based words. + pub fn from_evm_address(mut bytes: [u8; 20]) -> Self { + let mut digits = [0; 3]; + for digit in &mut digits { + let mut remainder = 0u128; + for byte in &mut bytes { + let value = (remainder << 8) | u128::from(*byte); + *byte = (value / u128::from(pokenoun::GOLDILOCKS_P)) as u8; + remainder = value % u128::from(pokenoun::GOLDILOCKS_P); + } + *digit = remainder as u64; + } + Self { + evm_address_based: digits, + } + } + + pub fn to_evm_address(&self) -> Result<[u8; 20], Error> { + let p = ibig::UBig::from(pokenoun::GOLDILOCKS_P); + let mut address = ibig::UBig::from(0u8); + for digit in self.evm_address_based.iter().rev() { + if *digit >= pokenoun::GOLDILOCKS_P { + return Err(Error("unbased address digit")); + } + address = address * &p + ibig::UBig::from(*digit); + } + let bytes = address.to_be_bytes(); + if bytes.len() > 20 { + return Err(Error("EVM address exceeds 160 bits")); + } + let mut out = [0; 20]; + out[20 - bytes.len()..].copy_from_slice(&bytes); + Ok(out) + } +} + +impl Encode for BridgeDepositDataPayload { + fn encode(&self, arena: &mut Arena) -> Result { + self.to_evm_address()?; + let version = arena.atom0(); + let network = "base".encode(arena)?; + let address = self.evm_address_based.encode(arena)?; + pokenoun::codec::tuple(arena, &[version, network, address]) + } +} + +impl Decode for BridgeDepositDataPayload { + fn decode(noun: Noun, arena: &Arena) -> Result { + let mut fields = Fields::new(noun, 3); + if !is_null(fields.next(arena)?, arena) { + return Err(Error("unsupported bridge version")); + } + if String::decode(fields.next(arena)?, arena)? != "base" { + return Err(Error("unsupported bridge network")); + } + let value = Self { + evm_address_based: Decode::decode(fields.next(arena)?, arena)?, + }; + value.to_evm_address()?; + Ok(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgeWithdrawalDataPayload { + pub beid: Vec, + pub base_hash: Hash, + pub lock_root: Hash, + pub base_batch_end: u64, +} + +impl Encode for BridgeWithdrawalDataPayload { + fn encode(&self, arena: &mut Arena) -> Result { + if self + .beid + .iter() + .chain(self.base_hash.values.iter()) + .chain(self.lock_root.values.iter()) + .chain(core::iter::once(&self.base_batch_end)) + .any(|word| *word >= pokenoun::GOLDILOCKS_P) + { + return Err(Error("withdrawal payload contains an unbased word")); + } + let fields = [ + arena.atom0(), + self.beid.encode(arena)?, + self.base_hash.encode(arena)?, + self.lock_root.encode(arena)?, + self.base_batch_end.encode(arena)?, + ]; + pokenoun::codec::tuple(arena, &fields) + } +} + +impl Decode for BridgeWithdrawalDataPayload { + fn decode(noun: Noun, arena: &Arena) -> Result { + let mut fields = Fields::new(noun, 5); + if !is_null(fields.next(arena)?, arena) { + return Err(Error("unsupported bridge withdrawal version")); + } + let value = Self { + beid: Decode::decode(fields.next(arena)?, arena)?, + base_hash: Decode::decode(fields.next(arena)?, arena)?, + lock_root: Decode::decode(fields.next(arena)?, arena)?, + base_batch_end: Decode::decode(fields.next(arena)?, arena)?, + }; + value.encode(&mut Arena::new())?; + Ok(value) + } +} + +#[derive(Debug, Clone)] +pub enum DecodedNoteDataPayload { + Lock(LockData), + BridgeDeposit(BridgeDepositDataPayload), + BridgeWithdrawal(BridgeWithdrawalDataPayload), + Raw(UntypedNoun), +} + +#[derive(Debug, Clone)] +pub struct DecodedNoteDataEntry { + pub key: String, + pub payload: DecodedNoteDataPayload, +} + +impl DecodedNoteDataEntry { + /// Preserve unknown keys, versions and networks as raw based nouns. + pub fn from_raw(key: String, value: UntypedNoun) -> Result { + validate_value(&value)?; + let payload = match key.as_str() { + NOTE_DATA_KEY_LOCK => value + .decode_as::() + .ok() + .map(DecodedNoteDataPayload::Lock), + NOTE_DATA_KEY_BRIDGE_DEPOSIT => value + .decode_as::() + .ok() + .map(DecodedNoteDataPayload::BridgeDeposit), + NOTE_DATA_KEY_BRIDGE_WITHDRAWAL => value + .decode_as::() + .ok() + .map(DecodedNoteDataPayload::BridgeWithdrawal), + _ => None, + } + .unwrap_or(DecodedNoteDataPayload::Raw(value)); + Ok(Self { key, payload }) + } + + pub fn raw_value(&self) -> Result { + match &self.payload { + DecodedNoteDataPayload::Lock(value) => UntypedNoun::from_value(value), + DecodedNoteDataPayload::BridgeDeposit(value) => UntypedNoun::from_value(value), + DecodedNoteDataPayload::BridgeWithdrawal(value) => UntypedNoun::from_value(value), + DecodedNoteDataPayload::Raw(value) => Ok(value.clone()), + } + } +} + +#[derive(Debug, Clone)] +pub struct DecodedNoteData(pub Vec); + +impl DecodedNoteData { + pub fn from_note_data(note_data: &NoteData) -> Result { + let mut entries = note_data + .map + .tap() + .into_iter() + .map(|(key, value)| DecodedNoteDataEntry::from_raw(key, value)) + .collect::, _>>()?; + entries.sort_by(|a, b| a.key.cmp(&b.key)); + Ok(Self(entries)) + } + + pub fn lock(&self) -> Option<&Lock> { + self.0.iter().find_map(|entry| match &entry.payload { + DecodedNoteDataPayload::Lock(data) => Some(data.lock()), + _ => None, + }) + } +} + +fn validate_value(value: &UntypedNoun) -> Result<(), Error> { + let mut arena = Arena::new(); + let noun = value.encode(&mut arena)?; + pokenoun::hash_noun_varlen(noun, &arena) + .map_err(|_| Error("note-data must contain based atoms"))?; + Ok(()) +} + +impl NoteData { + /// Trim keys, validate based payloads, and reject conflicting duplicate keys. + pub fn normalize( + entries: impl IntoIterator, + ) -> Result { + let mut map = ZMap::::new(); + for (key, value) in entries { + let key = key.trim().to_string(); + if key.is_empty() { + return Err(Error("empty note-data key")); + } + validate_value(&value)?; + let canonical = UntypedNoun::from_value(&value)?; + if let Some(existing) = map + .try_get(&key) + .map_err(|_| Error("note-data key is not based"))? + { + if existing != &canonical { + return Err(Error("conflicting note-data values")); + } + } + map.try_put(key, canonical) + .map_err(|_| Error("note-data key is not based"))?; + } + Ok(Self { map }) + } + + pub fn from_lock(lock: Lock) -> Result { + Self::normalize([( + NOTE_DATA_KEY_LOCK.into(), + UntypedNoun::from_value(&LockData::V0(lock))?, + )]) + } + + pub fn insert_bridge_deposit( + &mut self, + payload: &BridgeDepositDataPayload, + ) -> Result<(), Error> { + self.map.put( + NOTE_DATA_KEY_BRIDGE_DEPOSIT.into(), + UntypedNoun::from_value(payload)?, + ); + Ok(()) + } + + pub fn insert_bridge_withdrawal( + &mut self, + payload: &BridgeWithdrawalDataPayload, + ) -> Result<(), Error> { + self.map.put( + NOTE_DATA_KEY_BRIDGE_WITHDRAWAL.into(), + UntypedNoun::from_value(payload)?, + ); + Ok(()) + } +} + +pub fn lock_data_to_untyped_noun(data: &LockData) -> Result { + UntypedNoun::from_value(data) +} + +crate::noun_codec::runtime_codec!( + LockData, + BridgeDepositDataPayload, + BridgeWithdrawalDataPayload +); diff --git a/tx-types/src/noun_codec.rs b/tx-types/src/noun_codec.rs new file mode 100644 index 0000000..e19a705 --- /dev/null +++ b/tx-types/src/noun_codec.rs @@ -0,0 +1,124 @@ +//! Runtime adapters for the shared allocation-only noun codec. + +pub use pokenoun::codec::{Decode, Encode, Error}; + +/// Validate that every encoded atom fits the protocol's base field. +pub fn validate_based(value: &impl Encode) -> Result<(), Error> { + let mut arena = pokenoun::Arena::new(); + let noun = value.encode(&mut arena)?; + pokenoun::hash_noun_varlen(noun, &arena).map_err(|_| Error("noun contains an unbased atom"))?; + Ok(()) +} + +macro_rules! runtime_codec { + ($($ty:ty),+ $(,)?) => {$( + #[cfg(feature = "std")] + impl noun_serde::NounEncode for $ty { + fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { + $crate::noun_codec::encode_runtime(self, alloc) + } + } + #[cfg(feature = "std")] + impl noun_serde::NounDecode for $ty { + fn from_noun(noun: &nockvm::noun::Noun, space: &nockvm::noun::NounSpace) + -> Result + { + $crate::noun_codec::decode_runtime(*noun, space) + } + } + )+}; +} +pub(crate) use runtime_codec; + +/// Named structs encode as right-associated tuples; wrappers encode transparently. +macro_rules! noun_struct { + ($($ty:ty { $($field:ident),+ $(,)? }),+ $(,)?) => {$( + impl pokenoun::codec::Encode for $ty { + fn encode(&self, arena: &mut pokenoun::Arena) -> Result { + let fields = [$(pokenoun::codec::Encode::encode(&self.$field, arena)?),+]; + pokenoun::codec::tuple(arena, &fields) + } + } + impl pokenoun::codec::Decode for $ty { + fn decode(noun: pokenoun::Noun, arena: &pokenoun::Arena) -> Result { + let count = [$(stringify!($field)),+].len(); + let mut fields = pokenoun::codec::Fields::new(noun, count); + Ok(Self { $($field: pokenoun::codec::Decode::decode(fields.next(arena)?, arena)?),+ }) + } + } + $crate::noun_codec::runtime_codec!($ty); + )+}; +} +pub(crate) use noun_struct; + +#[cfg(feature = "std")] +pub fn encode_runtime( + value: &T, + alloc: &mut A, +) -> nockvm::noun::Noun { + use nockapp::AtomExt; + use nockvm::noun::{Atom, T}; + let mut arena = pokenoun::Arena::new(); + let root = value + .encode(&mut arena) + .expect("value must have a valid noun encoding"); + let mut copies = alloc::collections::BTreeMap::new(); + let mut pending = alloc::vec![(root, false)]; + while let Some((noun, visited)) = pending.pop() { + if copies.contains_key(&noun) { + continue; + } + let copied = match noun { + pokenoun::Noun::Atom(id) => { + let bytes = arena.atom_bytes_trimmed(id); + if bytes.is_empty() { + nockvm::noun::D(0) + } else { + Atom::from_bytes(alloc, &bytes::Bytes::copy_from_slice(bytes)).as_noun() + } + } + pokenoun::Noun::Cell(id) => { + let cell = arena.cell(id); + if !visited { + pending.extend([(noun, true), (cell.tail, false), (cell.head, false)]); + continue; + } + T(alloc, &[copies[&cell.head], copies[&cell.tail]]) + } + }; + copies.insert(noun, copied); + } + copies[&root] +} + +#[cfg(feature = "std")] +pub fn decode_runtime( + noun: nockvm::noun::Noun, + space: &nockvm::noun::NounSpace, +) -> Result { + let mut slab: nockapp::noun::slab::NounSlab = nockapp::noun::slab::NounSlab::new(); + let copied = slab.copy_into(noun, space); + slab.set_root(copied); + T::from_jam(&slab.jam()).map_err(|error| noun_serde::NounDecodeError::Custom(error.to_string())) +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + #[test] + fn runtime_adapter_preserves_shared_subtrees() { + let mut arena = pokenoun::Arena::new(); + let mut noun = arena.alloc_atom_u64(42); + for _ in 0..40 { + noun = arena.alloc_cell(noun, noun); + } + let jam = pokenoun::jam(noun, &arena); + let value = crate::UntypedNoun { + p: jam.clone().into(), + }; + let mut slab: nockapp::noun::slab::NounSlab = nockapp::noun::slab::NounSlab::new(); + let noun = encode_runtime(&value, &mut slab); + slab.set_root(noun); + assert_eq!(slab.jam().as_ref(), jam.as_slice()); + } +} diff --git a/tx-types/src/planner.rs b/tx-types/src/planner.rs new file mode 100644 index 0000000..6de46fa --- /dev/null +++ b/tx-types/src/planner.rs @@ -0,0 +1,494 @@ +//! Deterministic selection, fee solving and per-input conservation without IO. + +use crate::fee::{compute_minimum_fee, FeeInputs}; +use crate::lock::{build_lock_merkle_proof_full, build_lock_merkle_proof_stub, Lock}; +use crate::lock_resolver::{simple_pkh, timelock_admits, LockMatcher, LockResolution}; +use crate::word_count::{signature_map_words, transaction_words, WordCountBreakdown}; +use crate::{ + Coins, Hash, NName, NNote, NoteData, PkhSignature, SeedV1, SeedsV1, Spend, SpendBody, + SpendV0ToV1, SpendV1, SpendsV1, Witness, ZMap, ZSet, +}; +use alloc::{collections::BTreeSet, vec::Vec}; +use pokenoun::codec::{Encode, Error}; + +#[derive(Debug, Clone)] +pub struct PlannedOutput { + pub lock_root: Hash, + pub note_data: NoteData, + pub amount: u64, +} + +impl PlannedOutput { + pub fn payment(pkh: Hash, amount: u64, private: bool) -> Result { + crate::noun_codec::validate_based(&pkh)?; + let lock = Lock::SpendCondition(simple_pkh(pkh)); + Ok(Self { + lock_root: lock.hash(), + note_data: if private { + NoteData { map: ZMap::new() } + } else { + NoteData::from_lock(lock)? + }, + amount, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectionOrder { + Ascending, + Descending, +} + +#[derive(Debug, Clone)] +pub enum SelectionMode { + Auto, + Manual(Vec), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CandidateVersionPolicy { + V0Only, + V1Only, +} + +#[derive(Debug, Clone)] +pub enum PlanningMode { + Standard, + /// Spend every admitted candidate, setting the destination amount after fees. + Sweep { + destination: PlannedOutput, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeePolicy { + /// Construct at a specified fee; the result includes the required minimum. + Exact(u64), + Minimum, +} + +#[derive(Debug, Clone)] +pub struct ChainContext { + /// Unknown height admits only conditions without timelocks or fund maturity. + pub candidate_height: Option, + pub fee_height: u64, + pub bythos_phase: u64, + pub base_fee: u64, + pub input_fee_divisor: u64, + pub min_fee: u64, +} + +impl ChainContext { + pub fn at_height(height: u64) -> Self { + Self { + candidate_height: Some(height), + fee_height: height, + bythos_phase: crate::fee::BYTHOS_PHASE, + base_fee: crate::fee::BASE_FEE, + input_fee_divisor: crate::fee::INPUT_FEE_DIVISOR, + min_fee: crate::fee::MIN_FEE, + } + } + + pub fn minimum_fee(&self, words: WordCountBreakdown) -> u64 { + compute_minimum_fee(FeeInputs { + seed_words: words.seed_words, + witness_words: words.witness_words, + height: self.fee_height, + bythos_phase: self.bythos_phase, + base_fee: self.base_fee, + input_fee_divisor: self.input_fee_divisor, + min_fee: self.min_fee, + }) + .minimum_fee + } +} + +pub struct PlanRequest<'a> { + pub candidates: &'a [NNote], + pub outputs: &'a [PlannedOutput], + /// Its amount is assigned by the planner. + pub refund: &'a PlannedOutput, + pub selection: SelectionMode, + pub order: SelectionOrder, + pub version: CandidateVersionPolicy, + pub mode: PlanningMode, + pub fee: FeePolicy, + pub chain: ChainContext, +} + +#[derive(Debug, Clone)] +pub struct PlanResult { + pub spends: SpendsV1, + pub selected: Vec, + pub selected_total: u64, + pub outputs: Vec, + pub refunds: Vec<(NName, u64)>, + pub final_fee: u64, + pub minimum_fee: u64, + pub word_counts: WordCountBreakdown, +} + +struct Candidate<'a> { + note: &'a NNote, + resolution: Option, + signatures: u64, +} + +fn identity(note: &NNote) -> (&NName, u64) { + match note { + NNote::V0(note) => (¬e.name, note.assets.value), + NNote::V1(note) => (¬e.name, note.assets.value), + } +} + +fn add(a: u64, b: u64) -> Result { + a.checked_add(b).ok_or(Error("transaction amount overflow")) +} + +fn normalize_output(output: &PlannedOutput) -> Result { + if output.amount >= pokenoun::GOLDILOCKS_P { + return Err(Error("output amount exceeds base field")); + } + if output + .lock_root + .values + .iter() + .any(|limb| *limb >= pokenoun::GOLDILOCKS_P) + { + return Err(Error("output root contains an unbased word")); + } + Ok(PlannedOutput { + lock_root: output.lock_root.clone(), + amount: output.amount, + note_data: NoteData::normalize(output.note_data.map.tap())?, + }) +} + +fn merge_output(outputs: &mut Vec, output: PlannedOutput) -> Result<(), Error> { + if output.amount == 0 { + return Ok(()); + } + if let Some(existing) = outputs + .iter_mut() + .find(|item| item.lock_root == output.lock_root) + { + if existing.note_data.to_jam()? != output.note_data.to_jam()? { + return Err(Error("conflicting note data for the same output lock")); + } + let amount = add(existing.amount, output.amount)?; + if amount >= pokenoun::GOLDILOCKS_P { + return Err(Error("merged output amount exceeds base field")); + } + existing.amount = amount; + } else { + outputs.push(output); + } + Ok(()) +} + +fn candidates<'a>( + request: &PlanRequest<'a>, + matcher: &impl LockMatcher, +) -> Result>, Error> { + let mut seen = BTreeSet::new(); + for note in request.candidates { + let (name, amount) = identity(note); + if name.p.len() != 2 || amount >= pokenoun::GOLDILOCKS_P { + return Err(Error("invalid input note")); + } + crate::noun_codec::validate_based(note)?; + if !seen.insert(name.to_jam()?) { + return Err(Error("duplicate input note")); + } + } + if let SelectionMode::Manual(names) = &request.selection { + let mut requested = BTreeSet::new(); + for name in names { + let key = name.to_jam()?; + if !requested.insert(key.clone()) { + return Err(Error("duplicate manual input")); + } + if !seen.contains(&key) { + return Err(Error("manual input is missing from snapshot")); + } + } + } + let mut result = Vec::new(); + for input in request.candidates { + let (name, amount) = identity(input); + let manual = matches!(&request.selection, SelectionMode::Manual(_)); + if let SelectionMode::Manual(names) = &request.selection { + if !names.contains(name) { + continue; + } + } + let admitted = match (input, request.version) { + (NNote::V1(note), CandidateVersionPolicy::V1Only) if note.version == 1 => matcher + .resolve(note) + .filter(|resolution| resolution.admits(note, request.chain.candidate_height)) + .filter(|resolution| { + request.chain.fee_height >= request.chain.bythos_phase + || resolution.lock.spend_condition_count() == 1 + }) + .map(|resolution| Candidate { + note: input, + signatures: resolution.signature_count, + resolution: Some(resolution), + }), + (NNote::V0(note), CandidateVersionPolicy::V0Only) if note.meta.version == 0 => { + let timelock = note.meta.timelock.intent.as_ref().is_none_or(|(abs, rel)| { + timelock_admits( + abs, + rel, + note.meta.origin_page.value, + request.chain.candidate_height, + ) + }); + let name_matches = name.p[0] + == NName::first_v0(note.lock.clone(), note.meta.timelock.intent.is_some()); + matcher + .v0_signature_count(note) + .filter(|_| timelock && name_matches) + .map(|signatures| Candidate { + note: input, + signatures, + resolution: None, + }) + } + _ => None, + }; + if let Some(candidate) = admitted.filter(|_| amount > 0) { + result.push(candidate); + } else if manual { + return Err(Error("manual input is not spendable")); + } + } + result.sort_by(|a, b| { + let (an, aa) = identity(a.note); + let (bn, ba) = identity(b.note); + let order = aa.cmp(&ba); + let order = if request.order == SelectionOrder::Descending { + order.reverse() + } else { + order + }; + order.then_with(|| crate::determinism::compare_names(an, bn)) + }); + Ok(result) +} + +fn assemble( + selected: &[Candidate<'_>], + outputs: &[PlannedOutput], + refund: &PlannedOutput, + fee: u64, + chain: &ChainContext, +) -> Result { + let selected_total = selected + .iter() + .try_fold(0, |sum, candidate| add(sum, identity(candidate.note).1))?; + let output_total = outputs + .iter() + .try_fold(0, |sum, output| add(sum, output.amount))?; + if add(output_total, fee)? > selected_total { + return Err(Error("insufficient funds")); + } + // Every spend requires a positive seed. Reserve one nick per selected input. + if fee > selected_total.saturating_sub(selected.len() as u64) { + return Err(Error("fee leaves an input without a positive seed")); + } + let mut remaining: Vec<_> = outputs.iter().map(|output| output.amount).collect(); + let mut remaining_fee = fee; + let mut spends = ZMap::new(); + let mut emitted = Vec::new(); + let mut refunds = Vec::new(); + let mut signature_words = 0; + for candidate in selected { + let (name, assets) = identity(candidate.note); + let input_fee = remaining_fee.min(assets - 1); + remaining_fee -= input_fee; + let mut capacity = assets - input_fee; + let mut allocated = Vec::new(); + for (output, left) in outputs.iter().zip(&mut remaining) { + let amount = capacity.min(*left); + if amount > 0 { + let mut output = output.clone(); + output.amount = amount; + merge_output(&mut allocated, output)?; + *left -= amount; + capacity -= amount; + } + } + if capacity > 0 { + refunds.push((name.clone(), capacity)); + let mut change = refund.clone(); + change.amount = capacity; + merge_output(&mut allocated, change)?; + } + let parent_hash = match candidate.note { + NNote::V0(note) => note.to_hash(), + NNote::V1(note) => note + .to_hash() + .map_err(|_| Error("input note cannot be hashed"))?, + }; + let mut seeds = ZSet::new(); + for output in allocated { + seeds + .try_put(SeedV1 { + output_source: None, + lock_root: output.lock_root.clone(), + note_data: output.note_data.clone(), + gift: Coins { + value: output.amount, + }, + parent_hash: parent_hash.clone(), + }) + .map_err(|_| Error("seed cannot be hashed"))?; + merge_output(&mut emitted, output)?; + } + let seeds = SeedsV1 { set: seeds }; + let spend = if let Some(resolved) = &candidate.resolution { + let proof = if chain.fee_height >= chain.bythos_phase { + build_lock_merkle_proof_full(&resolved.lock, resolved.leaf_number) + } else { + build_lock_merkle_proof_stub(&resolved.lock, resolved.leaf_number) + } + .map_err(|_| Error("cannot build lock proof"))?; + if resolved.fund_maturity.is_none() && !proof.check(&name.p[0]) { + return Err(Error("lock proof does not authorize the input")); + } + signature_words = add( + signature_words, + signature_map_words(candidate.signatures, 18)? - 1, + )?; + Spend { + version: 1, + body: SpendBody::V1(SpendV1 { + witness: Witness { + lmp: proof, + pkh: PkhSignature { map: ZMap::new() }, + hax: resolved.preimages.clone(), + tim: 0, + }, + seeds, + fee: Coins { value: input_fee }, + }), + } + } else { + signature_words = add( + signature_words, + signature_map_words(candidate.signatures, 13)? - 1, + )?; + Spend { + version: 0, + body: SpendBody::V0ToV1(SpendV0ToV1 { + signature: ZMap::new(), + seeds, + fee: Coins { value: input_fee }, + }), + } + }; + spends + .try_put(name.clone(), spend) + .map_err(|_| Error("input name cannot be hashed"))?; + } + if remaining_fee != 0 || remaining.iter().any(|amount| *amount != 0) { + return Err(Error("input allocation does not cover payments and fee")); + } + let mut word_counts = transaction_words(&spends, chain.fee_height >= chain.bythos_phase)?; + word_counts.witness_words = add(word_counts.witness_words, signature_words)?; + Ok(PlanResult { + spends: SpendsV1 { map: spends }, + selected: selected + .iter() + .map(|c| identity(c.note).0.clone()) + .collect(), + selected_total, + outputs: emitted, + refunds, + final_fee: fee, + minimum_fee: chain.minimum_fee(word_counts), + word_counts, + }) +} + +pub fn plan(request: PlanRequest<'_>, matcher: &impl LockMatcher) -> Result { + let candidates = candidates(&request, matcher)?; + if candidates.is_empty() { + return Err(Error("no spendable inputs")); + } + let refund = normalize_output(request.refund)?; + let mut outputs = Vec::new(); + for output in request.outputs { + if output.amount == 0 { + return Err(Error("payment amount must be positive")); + } + merge_output(&mut outputs, normalize_output(output)?)?; + } + let sweep = match &request.mode { + PlanningMode::Standard => None, + PlanningMode::Sweep { destination } => { + if !outputs.is_empty() { + return Err(Error("sweep has one planner-assigned destination")); + } + Some(normalize_output(destination)?) + } + }; + if sweep.is_none() && outputs.is_empty() { + return Err(Error("at least one payment is required")); + } + let output_total = outputs + .iter() + .try_fold(0, |sum, output| add(sum, output.amount))?; + let all = sweep.is_some() || matches!(request.selection, SelectionMode::Manual(_)); + let mut fee = match request.fee { + FeePolicy::Exact(fee) => fee, + FeePolicy::Minimum => request.chain.min_fee, + }; + // Selection grows as fees grow. With a fixed selection only the presence of change + // affects word counts; testing the no-change boundary handles that discontinuity. + for _ in 0..candidates.len().saturating_mul(2).saturating_add(8) { + let needed = add(output_total, fee)?; + let mut total = 0; + let mut count = 0; + for candidate in &candidates { + total = add(total, identity(candidate.note).1)?; + count += 1; + if !all && total >= needed { + break; + } + } + let selected = &candidates[..count]; + let mut round_outputs = outputs.clone(); + if let Some(destination) = &sweep { + let mut destination = destination.clone(); + destination.amount = total + .checked_sub(fee) + .filter(|amount| *amount > 0) + .ok_or(Error("balance does not cover the network fee"))?; + round_outputs.push(destination); + } + let result = assemble(selected, &round_outputs, &refund, fee, &request.chain)?; + if matches!(request.fee, FeePolicy::Exact(_)) || result.minimum_fee <= fee { + return Ok(result); + } + if sweep.is_none() { + let remainder = total + .checked_sub(output_total) + .ok_or(Error("insufficient funds"))?; + if remainder >= request.chain.min_fee && remainder <= result.minimum_fee { + if let Ok(exact) = + assemble(selected, &round_outputs, &refund, remainder, &request.chain) + { + if exact.minimum_fee <= remainder { + return Ok(exact); + } + } + } + } + fee = result.minimum_fee; + } + Err(Error("network fee does not converge")) +} diff --git a/tx-types/src/rpc_note_data.rs b/tx-types/src/rpc_note_data.rs new file mode 100644 index 0000000..b862ef7 --- /dev/null +++ b/tx-types/src/rpc_note_data.rs @@ -0,0 +1,203 @@ +//! Normalize RPC note-data JSON into its canonical on-chain noun. + +use crate::lock::Lock; +use crate::note_data::{BridgeDepositDataPayload, LockData}; +use crate::{ + Brn, Hash, Hax, LockPrimitive, LockPrimitiveBody, NoteData, PageNumber, Pkh, SpendCondition, + Tim, TimelockRange, UntypedNoun, ZSet, +}; +use base64::Engine; +use serde_json::Value; + +fn hash(value: &Value) -> Result { + Hash::from_b58(value.as_str().ok_or("hash must be a base58 string")?) +} + +fn hashes(value: &Value) -> Result, String> { + let values = value.as_array().ok_or("hashes must be an array")?; + let mut set = ZSet::new(); + for value in values { + set.put(hash(value)?); + } + Ok(set) +} + +fn range(value: Option<&Value>) -> Result { + let bound = |name: &str| -> Result, String> { + match value.and_then(|value| value.get(name)) { + None | Some(Value::Null) => Ok(None), + Some(value) => Ok(Some(PageNumber { + value: value + .as_u64() + .ok_or("timelock bound must be a non-negative integer")?, + })), + } + }; + Ok(TimelockRange { + min: bound("min")?, + max: bound("max")?, + }) +} + +fn condition(value: &Value) -> Result { + let primitives = value.as_array().ok_or("lock condition must be an array")?; + if primitives.is_empty() { + return Err("lock condition must not be empty".into()); + } + let mut result = Vec::new(); + for primitive in primitives { + let object = primitive + .as_object() + .ok_or("lock primitive must be an object")?; + if object.len() != 1 { + return Err("lock primitive requires one tag".into()); + } + let (tag, value) = object.iter().next().unwrap(); + let body = match tag.as_str() { + "pkh" => { + let m = value + .get("m") + .and_then(Value::as_u64) + .ok_or("pkh.m is missing")?; + let h = hashes(value.get("h").ok_or("pkh.h is missing")?)?; + if m == 0 || m > h.wyt() as u64 { + return Err("invalid PKH threshold".into()); + } + LockPrimitiveBody::Pkh(Pkh { m, h }) + } + "tim" => LockPrimitiveBody::Tim(Tim { + rel: range(value.get("relative"))?, + abs: range(value.get("absolute"))?, + }), + "hax" => LockPrimitiveBody::Hax(Hax { + set: hashes(value)?, + }), + "brn" => LockPrimitiveBody::Brn(Brn { value: 0 }), + _ => return Err("unsupported lock primitive".into()), + }; + result.push(LockPrimitive { + header: tag.clone(), + body, + }); + } + Ok(SpendCondition { p: result }) +} + +pub fn lock_from_json(value: &Value) -> Result { + if value.get("version").and_then(Value::as_u64) != Some(0) { + return Err("unsupported lock-data version".into()); + } + let lock = if let Some(branches) = value.get("branches") { + let branches = branches + .as_array() + .ok_or("lock branches must be an array")?; + Lock::from_list(branches.iter().map(condition).collect::>()?) + .map_err(|error| error.to_string())? + } else { + Lock::SpendCondition(condition(value.get("data").ok_or("lock.data is missing")?)?) + }; + if let Some(expected) = value.get("lockRoot") { + if lock.hash() != hash(expected)? { + return Err("lock data does not match its root".into()); + } + } + Ok(lock) +} + +pub fn note_data_from_json(value: &Value) -> Result { + let entries = value.as_object().ok_or("note data must be an object")?; + let mut decoded = Vec::new(); + for (key, value) in entries { + // Reconstructed spending metadata is outside the input-note commitment. + if key == "lock" && value.get("source").and_then(Value::as_str) == Some("reconstructed") { + continue; + } + let noun = if key == "lock" && value.is_object() { + UntypedNoun::from_value(&LockData::V0(lock_from_json(value)?)) + .map_err(|error| error.to_string())? + } else if key == "bridge" && value.as_str().is_some_and(|value| value.starts_with("0x")) { + let text = value.as_str().unwrap(); + if text.len() != 42 || !text.is_ascii() { + return Err("bridge address must be 20 bytes".into()); + } + let mut address = [0; 20]; + for (index, byte) in address.iter_mut().enumerate() { + *byte = u8::from_str_radix(&text[2 + 2 * index..4 + 2 * index], 16) + .map_err(|_| "invalid bridge address")?; + } + UntypedNoun::from_value(&BridgeDepositDataPayload::from_evm_address(address)) + .map_err(|error| error.to_string())? + } else { + let encoded = value + .as_str() + .ok_or("opaque note data requires a base64 JAM string")?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|_| "invalid note-data base64")?; + UntypedNoun { p: bytes.into() } + }; + decoded.push((key.clone(), noun)); + } + NoteData::normalize(decoded).map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use pokenoun::codec::Encode; + use serde_json::json; + + #[test] + fn rpc_trees_encode_the_complete_tagged_lock() { + let pkh = Hash { values: [7; 5] }; + let condition = crate::lock_resolver::simple_pkh(pkh.clone()); + for size in [1, 2, 4, 8, 16] { + let lock = Lock::from_list(vec![condition.clone(); size]).unwrap(); + let branches: Vec<_> = (0..size) + .map(|_| json!([{"pkh": {"m": 1, "h": [pkh.to_b58()]}}])) + .collect(); + let value = if size == 1 { + json!({"version": 0, "data": branches[0], "lockRoot": lock.hash().to_b58()}) + } else { + json!({"version": 0, "branches": branches, "lockRoot": lock.hash().to_b58()}) + }; + let data = note_data_from_json(&json!({"lock": value})).unwrap(); + assert_eq!( + data.to_jam().unwrap(), + NoteData::from_lock(lock).unwrap().to_jam().unwrap() + ); + } + } + + #[test] + fn rpc_metadata_is_excluded_and_opaque_note_data_is_preserved() { + let opaque = UntypedNoun::from_value(&(42u64, 99u64)).unwrap(); + let raw = base64::engine::general_purpose::STANDARD.encode(&opaque.p); + let data = note_data_from_json(&json!({ + "lock": {"source": "reconstructed"}, "custom": raw, + "bridge": "0x1234567890abcdef1234567890abcdef12345678", + })) + .unwrap(); + assert!(!data.map.has(&"lock".into())); + assert_eq!(data.map.get(&"custom".into()).unwrap().p, opaque.p); + let payload = data + .map + .get(&"bridge".into()) + .unwrap() + .decode_as::() + .unwrap(); + assert_eq!( + payload.to_evm_address().unwrap(), + [ + 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, + 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78 + ] + ); + assert!(note_data_from_json(&Value::Null).is_err()); + assert!(note_data_from_json(&json!({"custom": "invalid!"})).is_err()); + assert!(lock_from_json( + &json!({"version": 0, "data": [], "lockRoot": Hash { values: [0; 5] }.to_b58()}) + ) + .is_err()); + } +} diff --git a/tx-types/src/tests/mod.rs b/tx-types/src/tests/mod.rs index 2af72b9..d43cfc0 100644 --- a/tx-types/src/tests/mod.rs +++ b/tx-types/src/tests/mod.rs @@ -16,8 +16,6 @@ pub mod test_schnorr_signature_hash; pub mod test_signature_hash; #[cfg(test)] pub mod test_signature_hashable; -#[cfg(all(test, feature = "legacy-tests"))] -pub mod test_tx_builder_sighash; #[cfg(test)] pub mod test_zmap_structure; #[cfg(test)] diff --git a/tx-types/src/tests/test_lock_reference.rs b/tx-types/src/tests/test_lock_reference.rs new file mode 100644 index 0000000..2110293 --- /dev/null +++ b/tx-types/src/tests/test_lock_reference.rs @@ -0,0 +1,192 @@ +use crate::hashing::{hash_hashable, Hashable}; +use crate::lock::*; +use crate::{Brn, Hash, LockPrimitive, LockPrimitiveBody, NName, SpendCondition}; +use pokenoun::codec::{Decode, Encode}; + +struct LockFixture { + lock: Lock, + root: Hash, + proofs: Vec, +} + +struct ProofFixture { + index: u64, + stub: crate::LockMerkleProof, + full: crate::LockMerkleProof, + stub_hash: Hash, + full_hash: Hash, + stub_valid: bool, + full_valid: bool, +} + +crate::noun_codec::noun_struct!( + LockFixture { lock, root, proofs }, + ProofFixture { + index, + stub, + full, + stub_hash, + full_hash, + stub_valid, + full_valid + } +); + +fn jam(value: &impl Encode) -> Vec { + value.to_jam().unwrap() +} + +fn fixtures() -> Vec { + Vec::::from_jam(include_bytes!("../../tests/reference/lock-proofs.jam")).unwrap() +} + +#[test] +fn every_lock_leaf_matches_official_hoon_jam_hash_and_validation() { + let fixtures = fixtures(); + assert_eq!(fixtures.len(), 5); + for (arity, fixture) in [1, 2, 4, 8, 16].into_iter().zip(fixtures) { + assert_eq!(fixture.lock.spend_condition_count(), arity); + assert_eq!(fixture.proofs.len() as u64, arity); + assert_eq!(fixture.lock.hash(), fixture.root); + let leaves = fixture.lock.flatten_spend_conditions(); + assert_eq!( + jam(&Lock::from_list(leaves.clone()).unwrap()), + jam(&fixture.lock) + ); + let firstname = NName::first_v1(fixture.root); + for expected in fixture.proofs { + let stub = build_lock_merkle_proof_stub(&fixture.lock, expected.index).unwrap(); + let full = build_lock_merkle_proof_full(&fixture.lock, expected.index).unwrap(); + assert_eq!( + jam(&stub), + jam(&expected.stub), + "stub arity={arity} leaf={}", + expected.index + ); + assert_eq!( + jam(&full), + jam(&expected.full), + "full arity={arity} leaf={}", + expected.index + ); + assert_eq!(stub.to_hash(), expected.stub_hash); + assert_eq!(full.to_hash(), expected.full_hash); + assert_eq!(stub.check(&firstname), expected.stub_valid); + assert_eq!(full.check(&firstname), expected.full_valid); + assert_eq!( + jam(full.spend_condition()), + jam(&leaves[(expected.index - 1) as usize]) + ); + assert_eq!( + full.axis(), + if arity == 1 { + 1 + } else { + 3 * arity + expected.index - 1 + } + ); + assert!(authorization_leaf(full.axis(), &full.proof().path)); + assert_eq!(valid_stub_axis(&stub, false), arity == 1); + assert!(valid_stub_axis(&stub, true)); + } + } +} + +#[test] +fn proofs_reject_modified_paths_axes_roots_conditions_and_parent() { + for fixture in fixtures() { + let firstname = NName::first_v1(fixture.root); + for item in fixture.proofs { + let proof = item.full; + for axis in [0, 2, 3, 4, 5, 8, 11, 16, 23, 32, 47, 64, u64::MAX] { + let mut bad = proof.clone(); + if let crate::LockMerkleProof::Full(ref mut full) = bad { + full.axis = axis; + } + assert!(!bad.check(&firstname), "axis={axis}"); + } + let mut full = match proof { + crate::LockMerkleProof::Full(full) => full, + _ => unreachable!(), + }; + let mut wrong_parent = firstname.clone(); + wrong_parent.values[0] ^= 1; + assert!(!crate::LockMerkleProof::Full(full.clone()).check(&wrong_parent)); + let root = full.merkle_proof.root.clone(); + full.merkle_proof.root.values[0] ^= 1; + assert!(!crate::LockMerkleProof::Full(full.clone()).check(&firstname)); + full.merkle_proof.root = root; + let path = full.merkle_proof.path.clone(); + for i in 0..path.len() { + full.merkle_proof.path[i].values[0] ^= 1; + assert!(!crate::LockMerkleProof::Full(full.clone()).check(&firstname)); + full.merkle_proof.path = path.clone(); + } + full.merkle_proof.path.push(Hash { values: [0; 5] }); + assert!(!crate::LockMerkleProof::Full(full.clone()).check(&firstname)); + full.merkle_proof.path = path; + if !full.merkle_proof.path.is_empty() { + full.merkle_proof.path.pop(); + assert!(!crate::LockMerkleProof::Full(full.clone()).check(&firstname)); + } + full.spend_condition.p.clear(); + assert!(!crate::LockMerkleProof::Full(full).check(&firstname)); + } + } +} + +#[test] +fn arity_sibling_must_authenticate_the_axis_depth() { + for (axis, arity) in [(6, 2u64), (12, 4), (24, 8), (48, 16)] { + let tag = hash_hashable(&Hashable::leaf_from_atom(arity.to_le_bytes())).unwrap(); + assert!(authorization_leaf(axis, &[tag.clone()])); + assert!(!authorization_leaf(axis, &[])); + for wrong in [2u64, 4, 8, 16].into_iter().filter(|n| *n != arity) { + let wrong_tag = hash_hashable(&Hashable::leaf_from_atom(wrong.to_le_bytes())).unwrap(); + assert!(!authorization_leaf(axis, &[wrong_tag])); + } + } +} + +#[test] +fn build_boundaries_reject_empty_and_out_of_range_and_pad_with_burn() { + let leaf = fixtures() + .remove(0) + .lock + .flatten_spend_conditions() + .remove(0); + assert!(matches!( + Lock::from_list(vec![]), + Err(LockError::InvalidLeafCount(0)) + )); + assert!(Lock::from_list(vec![leaf.clone(); 17]).is_err()); + assert!(Lock::from_list(vec![SpendCondition { p: vec![] }]).is_err()); + let empty = Lock::SpendCondition(SpendCondition { p: vec![] }); + assert!(build_lock_merkle_proof_full(&empty, 1).is_err()); + for length in 1usize..=16 { + let lock = Lock::from_list(vec![leaf.clone(); length]).unwrap(); + assert_eq!( + lock.spend_condition_count() as usize, + length.next_power_of_two() + ); + assert!(build_lock_merkle_proof_full(&lock, 0).is_err()); + assert!(build_lock_merkle_proof_full(&lock, lock.spend_condition_count() + 1).is_err()); + for padded in &lock.flatten_spend_conditions()[length..] { + assert_eq!( + jam(padded), + jam(&SpendCondition { + p: vec![LockPrimitive { + header: "brn".into(), + body: LockPrimitiveBody::Brn(Brn { value: 0 }) + }], + }) + ); + } + let before = build_lock_merkle_proof(&lock, 1, crate::fee::BYTHOS_PHASE - 1); + assert_eq!(before.is_ok(), length == 1); + assert!(matches!( + build_lock_merkle_proof(&lock, 1, crate::fee::BYTHOS_PHASE).unwrap(), + crate::LockMerkleProof::Full(_) + )); + } +} diff --git a/tx-types/src/tests/test_planner.rs b/tx-types/src/tests/test_planner.rs new file mode 100644 index 0000000..2258ea5 --- /dev/null +++ b/tx-types/src/tests/test_planner.rs @@ -0,0 +1,400 @@ +use crate::lock::{build_lock_merkle_proof_full, Lock as V1Lock}; +use crate::lock_resolver::*; +use crate::planner::*; +use crate::*; +use pokenoun::codec::Encode; + +fn hash(n: u64) -> Hash { + Hash { values: [n; 5] } +} +fn matcher() -> SigningKeyLockMatcher { + SigningKeyLockMatcher { + signer_pkh: hash(1), + public_key: None, + coinbase_relative_min: Some(100), + } +} +fn note(id: u64, amount: u64, lock: &V1Lock) -> NNote { + NNote::V1(NNoteV1 { + version: 1, + origin_page: PageNumber { value: 1000 }, + name: NName { + p: vec![NName::first_v1(lock.hash()), hash(id)], + }, + assets: Coins { value: amount }, + note_data: NoteData::from_lock(lock.clone()).unwrap(), + }) +} +fn simple_note(id: u64, amount: u64) -> NNote { + note(id, amount, &V1Lock::SpendCondition(simple_pkh(hash(1)))) +} +fn chain() -> ChainContext { + ChainContext { + base_fee: 1, + min_fee: 1, + ..ChainContext::at_height(60_000) + } +} +fn request<'a>( + notes: &'a [NNote], + outputs: &'a [PlannedOutput], + refund: &'a PlannedOutput, +) -> PlanRequest<'a> { + PlanRequest { + candidates: notes, + outputs, + refund, + selection: SelectionMode::Auto, + order: SelectionOrder::Descending, + version: CandidateVersionPolicy::V1Only, + mode: PlanningMode::Standard, + fee: FeePolicy::Minimum, + chain: chain(), + } +} +fn refund() -> PlannedOutput { + PlannedOutput::payment(hash(1), 0, false).unwrap() +} +fn output(amount: u64) -> PlannedOutput { + PlannedOutput::payment(hash(2), amount, false).unwrap() +} + +fn check_conservation(result: &PlanResult, notes: &[NNote]) { + let mut fee = 0; + for (name, spend) in result.spends.map.tap() { + let input = notes + .iter() + .find(|note| match note { + NNote::V0(note) => note.name == name, + NNote::V1(note) => note.name == name, + }) + .unwrap(); + let (assets, parent_hash) = match input { + NNote::V0(note) => (note.assets.value, note.to_hash()), + NNote::V1(note) => (note.assets.value, note.to_hash().unwrap()), + }; + let (seeds, input_fee) = match spend.body { + SpendBody::V1(spend) => { + assert!(spend.witness.lmp.check(&name.p[0])); + (spend.seeds, spend.fee.value) + } + SpendBody::V0ToV1(spend) => (spend.seeds, spend.fee.value), + _ => panic!(), + }; + assert!(seeds.set.wyt() > 0); + assert!(seeds + .set + .tap() + .iter() + .all(|seed| seed.gift.value > 0 && seed.parent_hash == parent_hash)); + assert_eq!( + assets, + input_fee + + seeds + .set + .tap() + .iter() + .map(|seed| seed.gift.value) + .sum::() + ); + fee += input_fee; + } + assert_eq!(fee, result.final_fee); + assert_eq!( + result.selected_total, + fee + result + .outputs + .iter() + .map(|output| output.amount) + .sum::() + ); +} + +#[test] +fn selection_is_permutation_invariant_and_every_input_balances() { + let mut notes = vec![ + simple_note(3, 100), + simple_note(4, 100), + simple_note(5, 100), + ]; + let outputs = [output(230)]; + let refund = refund(); + let a = plan(request(¬es, &outputs, &refund), &matcher()).unwrap(); + assert!(a.final_fee >= a.minimum_fee); + check_conservation(&a, ¬es); + notes.reverse(); + let b = plan(request(¬es, &outputs, &refund), &matcher()).unwrap(); + assert_eq!(a.spends.to_jam().unwrap(), b.spends.to_jam().unwrap()); + assert_eq!(a.selected, b.selected); +} + +#[test] +fn signed_word_estimate_matches_actual_signature_map_shape() { + let notes = [simple_note(3, 1000), simple_note(4, 1000)]; + let refund = refund(); + let outputs = [output(1800)]; + let result = plan(request(¬es, &outputs, &refund), &matcher()).unwrap(); + let mut signed = ZMap::new(); + for (name, mut spend) in result.spends.map.tap() { + let SpendBody::V1(ref mut body) = spend.body else { + panic!() + }; + body.witness.pkh.map.put( + hash(1), + PkhSignatureValue { + pk: SchnorrPubkey { + x: F6LT { values: [1; 6] }, + y: F6LT { values: [2; 6] }, + inf: false, + }, + sig: SchnorrSignature { + chal: Chal { + values: T8 { values: [3; 8] }, + }, + sig: Sig { + values: T8 { values: [4; 8] }, + }, + }, + }, + ); + signed.put(name, spend); + } + assert_eq!( + word_count::transaction_words(&signed, true).unwrap(), + result.word_counts + ); +} + +#[test] +fn exact_fee_allocation_keeps_a_seed_on_fee_funding_inputs() { + let notes = [simple_note(3, 100), simple_note(4, 90), simple_note(5, 80)]; + let refund = refund(); + let outputs = [output(110)]; + let mut req = request(¬es, &outputs, &refund); + req.fee = FeePolicy::Exact(130); + let result = plan(req, &matcher()).unwrap(); + check_conservation(&result, ¬es); +} + +#[test] +fn duplicate_inputs_manual_missing_inputs_and_overflow_fail() { + let notes = [simple_note(3, 1000), simple_note(3, 1000)]; + let refund = refund(); + let outputs = [output(100)]; + assert!(plan(request(¬es, &outputs, &refund), &matcher()).is_err()); + let mut req = request(¬es[..1], &outputs, &refund); + req.selection = SelectionMode::Manual(vec![NName { + p: vec![hash(99), hash(100)], + }]); + assert!(plan(req, &matcher()).is_err()); + assert!(plan( + request(¬es[..1], &[output(u64::MAX), output(1)], &refund), + &matcher() + ) + .is_err()); + let p = pokenoun::GOLDILOCKS_P; + let large = [simple_note(3, p - 100), simple_note(4, 500)]; + assert!(plan( + request(&large, &[output(p / 2), output(p - p / 2)], &refund), + &matcher() + ) + .is_err()); +} + +#[test] +fn change_fee_boundary_spends_the_remainder_when_change_is_unaffordable() { + let refund = refund(); + let outputs = [output(100)]; + let base = [simple_note(3, 1000)]; + let generous = plan(request(&base, &outputs, &refund), &matcher()).unwrap(); + for remainder in 1..generous.final_fee { + let notes = [simple_note(3, 100 + remainder)]; + if let Ok(result) = plan(request(¬es, &outputs, &refund), &matcher()) { + assert_eq!(result.final_fee, remainder); + assert!(result.final_fee >= result.minimum_fee); + assert_eq!(result.outputs.len(), 1); + check_conservation(&result, ¬es); + return; + } + } + panic!("expected a fee boundary with no change"); +} + +#[test] +fn one_of_many_and_tagged_lock_authorize_the_effective_signer() { + let condition = SpendCondition { + p: vec![LockPrimitive { + header: "pkh".into(), + body: LockPrimitiveBody::Pkh(Pkh { + m: 1, + h: ZSet::gas(vec![hash(1), hash(8)]), + }), + }], + }; + let lock = V1Lock::from_list(vec![simple_pkh(hash(9)), condition]).unwrap(); + let notes = [note(3, 1000, &lock)]; + let result = plan(request(¬es, &[output(100)], &refund()), &matcher()).unwrap(); + let SpendBody::V1(body) = &result.spends.map.tap()[0].1.body else { + panic!() + }; + assert_eq!(body.witness.lmp.axis(), 7); + assert_eq!( + body.witness.lmp.to_jam().unwrap(), + build_lock_merkle_proof_full(&lock, 2) + .unwrap() + .to_jam() + .unwrap() + ); +} + +#[test] +fn timelocks_use_candidate_height_and_both_endpoints() { + let lock = V1Lock::SpendCondition(coinbase_pkh(hash(1), 100)); + let notes = [note(3, 1000, &lock)]; + let outputs = [output(100)]; + let refund = refund(); + for (height, expected) in [ + (None, false), + (Some(999), false), + (Some(1099), false), + (Some(1100), true), + ] { + let mut req = request(¬es, &outputs, &refund); + req.chain.candidate_height = height; + assert_eq!(plan(req, &matcher()).is_ok(), expected); + } + let range = TimelockRange { + min: Some(PageNumber { value: 10 }), + max: Some(PageNumber { value: 20 }), + }; + let empty = TimelockRange { + min: None, + max: None, + }; + for (height, expected) in [(9, false), (10, true), (20, true), (21, false)] { + assert_eq!(timelock_admits(&range, &empty, 0, Some(height)), expected); + } +} + +#[test] +fn fund_recovery_pins_identity_and_keeps_maturity_outside_the_multisig() { + let condition = SpendCondition { + p: vec![LockPrimitive { + header: "pkh".into(), + body: LockPrimitiveBody::Pkh(Pkh { + m: 2, + h: ZSet::gas(vec![hash(1), hash(2), hash(3)]), + }), + }], + }; + let lock = V1Lock::SpendCondition(condition.clone()); + let root = lock.hash(); + let NNote::V1(mut fund) = note(3, 1000, &lock) else { + panic!() + }; + fund.name.p[0] = hash(90); + fund.origin_page.value = 169_950; + let policy = FundMaturityPolicy { + activation_height: 170_000, + minimum: 100, + }; + assert_eq!(policy.fund_minimum_at(169_999), None); + for (height, expected) in [ + (169_999, true), + (170_000, false), + (170_049, false), + (170_050, true), + (170_051, true), + ] { + let matcher = LockRootLockMatcher::from_lock_root(root.clone()) + .with_spend_condition(condition.clone()) + .with_fund_notes(&root, hash(90), policy.at(height)); + let resolved = matcher.resolve(&fund).unwrap(); + assert_eq!(resolved.admits(&fund, Some(height)), expected); + assert_eq!( + resolved.spend_condition().unwrap().to_jam().unwrap(), + condition.to_jam().unwrap() + ); + assert!(!resolved.admits(&fund, Some(height + 1))); + let notes = [NNote::V1(fund.clone())]; + let outputs = [output(100)]; + let refund = refund(); + let mut req = request(¬es, &outputs, &refund); + req.chain.candidate_height = Some(height); + let planned = plan(req, &matcher); + assert_eq!(planned.is_ok(), expected); + if let Ok(planned) = planned { + let SpendBody::V1(body) = &planned.spends.map.tap()[0].1.body else { + panic!() + }; + assert_eq!(body.witness.lmp.proof().root, root); + assert_eq!( + body.witness.lmp.spend_condition().to_jam().unwrap(), + condition.to_jam().unwrap() + ); + } + } + assert!(FundMaturity { + candidate_height: 1, + minimum: None + } + .admits(2)); + assert!(!FundMaturity { + candidate_height: 1, + minimum: Some(0) + } + .admits(2)); + let wrong = LockRootLockMatcher::from_lock_root(root.clone()) + .with_spend_condition(condition) + .with_fund_notes(&hash(99), hash(90), policy.at(170_050)); + assert!(wrong.resolve(&fund).is_none()); +} + +#[test] +fn v0_sweep_uses_full_note_parents_and_preserves_value() { + let key = SchnorrPubkey { + x: F6LT { values: [1; 6] }, + y: F6LT { values: [2; 6] }, + inf: false, + }; + let lock = crate::Lock { + m: 1, + pubkeys: ZSet::gas(vec![key.clone()]), + }; + let matcher = SigningKeyLockMatcher { + signer_pkh: key.to_hash(), + public_key: Some(key), + coinbase_relative_min: None, + }; + let notes: Vec<_> = [3, 4] + .into_iter() + .map(|id| { + NNote::V0(NNoteV0 { + meta: NNoteHead { + version: 0, + origin_page: PageNumber { value: 1000 }, + timelock: Timelock { intent: None }, + }, + name: NName { + p: vec![NName::first_v0(lock.clone(), false), hash(id)], + }, + lock: lock.clone(), + source: Source { + p: hash(9), + is_coinbase: false, + }, + assets: Coins { value: 1000 }, + }) + }) + .collect(); + let refund = refund(); + let mut req = request(¬es, &[], &refund); + req.version = CandidateVersionPolicy::V0Only; + req.mode = PlanningMode::Sweep { + destination: output(0), + }; + let result = plan(req, &matcher).unwrap(); + assert_eq!(result.selected.len(), 2); + assert_eq!(result.outputs.len(), 1); + check_conservation(&result, ¬es); +} diff --git a/tx-types/src/tests/test_transaction_reference.rs b/tx-types/src/tests/test_transaction_reference.rs new file mode 100644 index 0000000..494201f --- /dev/null +++ b/tx-types/src/tests/test_transaction_reference.rs @@ -0,0 +1,290 @@ +use crate::note_data::{DecodedNoteData, DecodedNoteDataPayload}; +use crate::word_count::{note_data_words, transaction_words}; +use crate::*; +use alloc::{string::String, vec::Vec}; +use pokenoun::codec::{Decode, Encode}; + +struct NoteDataFixture { + case: String, + data: NoteData, + hash: Hash, + words: u64, +} +struct WithdrawalTransaction { + version: u64, + name: String, + spends: SpendsV1, + metadata: UntypedNoun, + witness_data: UntypedNoun, +} +struct WithdrawalFixture { + case: String, + transaction: WithdrawalTransaction, + height: u64, + min_fee: u64, + seed_words: u64, + witness_words: u64, +} +crate::noun_codec::noun_struct!( + NoteDataFixture { + case, + data, + hash, + words + }, + WithdrawalTransaction { + version, + name, + spends, + metadata, + witness_data + }, + WithdrawalFixture { + case, + transaction, + height, + min_fee, + seed_words, + witness_words + } +); + +#[test] +fn note_data_matches_official_hashes_words_and_typed_encodings() { + let bytes = include_bytes!("../../tests/reference/note-data-oracle.jam"); + let fixtures = Vec::::from_jam(bytes).unwrap(); + assert!(fixtures.len() >= 14); + for fixture in fixtures { + assert_eq!( + fixture.data.to_hash().unwrap(), + fixture.hash, + "{}", + fixture.case + ); + assert_eq!( + note_data_words(&fixture.data).unwrap(), + fixture.words, + "{}", + fixture.case + ); + let decoded = DecodedNoteData::from_note_data(&fixture.data).unwrap(); + let entries = decoded + .0 + .iter() + .map(|entry| (entry.key.clone(), entry.raw_value().unwrap())); + let normalized = NoteData::normalize(entries).unwrap(); + assert_eq!( + normalized.to_jam().unwrap(), + fixture.data.to_jam().unwrap(), + "{}", + fixture.case + ); + let payload = &decoded.0[0].payload; + if fixture.case.contains("unsupported") || fixture.case == "wildcard" { + assert!( + matches!(payload, DecodedNoteDataPayload::Raw(_)), + "{}", + fixture.case + ); + } else { + assert!( + !matches!(payload, DecodedNoteDataPayload::Raw(_)), + "{}", + fixture.case + ); + } + } +} + +#[test] +fn raw_transactions_match_official_ids_signature_hashes_and_word_counts() { + let bytes = include_bytes!("../../tests/reference/raw-tx-v1.jam"); + let raw = RawTransactionV1::from_jam(bytes).unwrap(); + assert_eq!(compute_tx_id_v1(&raw.spends).unwrap(), raw.id); + let oracle = + Vec::<(NName, Hash)>::from_jam(include_bytes!("../../tests/reference/spend1-sig-hash.jam")) + .unwrap(); + assert!(!oracle.is_empty()); + for (name, hash) in oracle { + let spend = raw.spends.map.get(&name).unwrap(); + assert_eq!(spend.body.sig_hash().unwrap(), hash); + } + let words = transaction_words(&raw.spends.map, true).unwrap(); + // Seed fees count the flattened key/value reduction, including one terminator. + assert_eq!((words.seed_words, words.witness_words), (5, 133)); + assert_eq!(calculate_min_fee(&raw.spends.map).value, 626_688); + let raw = + RawTransactionV0::from_jam(include_bytes!("../../tests/reference/raw-tx-v0.jam")).unwrap(); + assert_eq!( + compute_tx_id(&Inputs::V0(raw.inputs), &raw.timelock_range, raw.total_fees), + raw.id + ); +} + +#[test] +fn withdrawal_fixtures_match_hoon_fee_configuration() { + let fixtures = Vec::::from_jam(include_bytes!( + "../../tests/reference/withdrawal-transactions.jam" + )) + .unwrap(); + assert!(fixtures.len() >= 4); + for fixture in fixtures { + let active = fixture.height >= 10; + let mut words = transaction_words(&fixture.transaction.spends.map, active).unwrap(); + // Wallet fixtures carry unsigned spends; their fee oracle includes the required signatures. + for (_, spend) in fixture.transaction.spends.map.tap() { + let SpendBody::V1(spend) = spend.body else { + panic!("withdrawal witness spend") + }; + let signatures = spend + .witness + .lmp + .spend_condition() + .required_pkh_policy() + .unwrap() + .threshold as u64; + words.witness_words += + crate::word_count::signature_map_words(signatures, 18).unwrap() - 1; + } + assert_eq!(words.seed_words, fixture.seed_words, "{}", fixture.case); + assert_eq!( + words.witness_words, fixture.witness_words, + "{}", + fixture.case + ); + let chain = planner::ChainContext { + base_fee: 256, + min_fee: 0, + bythos_phase: 10, + ..planner::ChainContext::at_height(fixture.height) + }; + assert_eq!( + chain.minimum_fee(words), + fixture.min_fee, + "{}", + fixture.case + ); + } +} + +#[test] +fn bridge_addresses_and_note_data_reject_invalid_encodings() { + use crate::note_data::BridgeDepositDataPayload; + for address in [[0; 20], [255; 20], [42; 20]] { + let payload = BridgeDepositDataPayload::from_evm_address(address); + assert_eq!(payload.to_evm_address().unwrap(), address); + let decoded = BridgeDepositDataPayload::from_jam(&payload.to_jam().unwrap()).unwrap(); + assert_eq!(decoded, payload); + } + assert!(BridgeDepositDataPayload { + evm_address_based: [0, 0, u64::MAX] + } + .to_jam() + .is_err()); + let one = UntypedNoun::from_value(&1u64).unwrap(); + let two = UntypedNoun::from_value(&2u64).unwrap(); + assert!(NoteData::normalize([("k".into(), one.clone()), (" k ".into(), two)]).is_err()); + assert_eq!( + NoteData::normalize([("k".into(), one.clone()), (" k ".into(), one)]) + .unwrap() + .map + .wyt(), + 1 + ); + assert!(NoteData::normalize([("".into(), UntypedNoun::from_value(&0u64).unwrap())]).is_err()); +} + +#[test] +fn invalid_collection_insertions_preserve_existing_entries() { + let mut map = ZMap::::new(); + assert!(map.try_put(u64::MAX, 0).is_err()); + map.put(1, 2); + let before = map.to_jam().unwrap(); + assert!(map.try_put(u64::MAX, 3).is_err()); + assert_eq!(map.to_jam().unwrap(), before); + let mut set = ZSet::::new(); + assert!(set.try_put(u64::MAX).is_err()); + set.put(1); + let before = set.to_jam().unwrap(); + assert!(set.try_put(u64::MAX).is_err()); + assert_eq!(set.to_jam().unwrap(), before); +} + +struct FeeFixture { + case: String, + spends: SpendsV1, + height: u64, + base: u64, + divisor: u64, + minimum: u64, + seed_words: u64, + witness_words: u64, + fee: u64, +} +crate::noun_codec::noun_struct!(FeeFixture { + case, + spends, + height, + base, + divisor, + minimum, + seed_words, + witness_words, + fee +}); + +#[test] +fn fees_match_hoon_for_multiple_inputs_and_migration_on_both_sides_of_bythos() { + let fixtures = + Vec::::from_jam(include_bytes!("../../tests/reference/fee-oracle.jam")) + .unwrap(); + assert_eq!(fixtures.len(), 12); + for row in fixtures { + let words = transaction_words(&row.spends.map, row.height >= 10).unwrap(); + assert_eq!( + (words.seed_words, words.witness_words), + (row.seed_words, row.witness_words), + "{} at {}", + row.case, + row.height + ); + let chain = planner::ChainContext { + base_fee: row.base, + min_fee: row.minimum, + input_fee_divisor: row.divisor, + bythos_phase: 10, + ..planner::ChainContext::at_height(row.height) + }; + assert_eq!( + chain.minimum_fee(words), + row.fee, + "{} at {}", + row.case, + row.height + ); + } +} + +#[test] +fn portable_jam_matches_official_bytes_with_canonical_backreferences() { + let fixtures: &[&[u8]] = &[ + include_bytes!("../../tests/reference/lock-proofs.jam"), + include_bytes!("../../tests/reference/note-data-oracle.jam"), + include_bytes!("../../tests/reference/fee-oracle.jam"), + include_bytes!("../../tests/reference/raw-tx-v0.jam"), + include_bytes!("../../tests/reference/raw-tx-v1.jam"), + include_bytes!("../../tests/reference/spend1-sig-hash.jam"), + include_bytes!("../../tests/reference/withdrawal-transactions.jam"), + ]; + for bytes in fixtures { + let mut arena = pokenoun::Arena::new(); + let noun = pokenoun::cue(bytes, &mut arena).unwrap(); + // Official generator files pad the JAM atom to an eight-byte word. + let end = bytes.iter().rposition(|byte| *byte != 0).unwrap() + 1; + assert_eq!(pokenoun::jam(noun, &arena), bytes[..end]); + } + let bytes = include_bytes!("../../tests/reference/raw-tx-v1.jam"); + let raw = RawTransactionV1::from_jam(bytes).unwrap(); + let end = bytes.iter().rposition(|byte| *byte != 0).unwrap() + 1; + assert_eq!(raw.to_jam().unwrap(), bytes[..end]); +} diff --git a/tx-types/src/tests/test_tx_builder_sighash.rs b/tx-types/src/tests/test_tx_builder_sighash.rs deleted file mode 100644 index 1b973fe..0000000 --- a/tx-types/src/tests/test_tx_builder_sighash.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Tests for transaction builder signature hash computation -//! -//! These tests focus on the critical signature hash functions that were implemented -//! to match the Hoon tx-engine specifications. - -#[cfg(test)] -mod test_tx_builder_sighash { - use crate::collections::ZSet; - use crate::hashing::hashable::Hashable; - use crate::hashing::hasher::hash_hashable; - use crate::transaction_types::*; - use crate::transaction_types_v0::*; - use crate::transaction_types_v1::*; - use crate::tx_builder_v1::*; - - #[test] - fn test_signature_hash_v0_non_zero() { - println!("\n=== Testing V0 Signature Hash Computation ===\n"); - - // Create empty seeds for simplicity - let seeds = SeedsV0 { set: ZSet::new() }; - let fee = Coins { value: 100 }; - - let sig_hash = compute_spend_v0_sig_hash(&seeds, &fee); - - println!("✓ Computed V0 signature hash"); - - // Hash should not be all zeros (empty seeds + fee should produce deterministic hash) - let is_zero = sig_hash.values.values.iter().all(|&v| v == 0); - assert!(!is_zero, "Signature hash should not be all zeros"); - } - - #[test] - fn test_signature_hash_v1_non_zero() { - println!("\n=== Testing V1 Signature Hash Computation ===\n"); - - // Create empty seeds for simplicity - let seeds = SeedsV1 { set: ZSet::new() }; - let fee = Coins { value: 100 }; - - let sig_hash = compute_spend_v1_sig_hash(&seeds, &fee); - - println!("✓ Computed V1 signature hash"); - - // Hash should not be all zeros - let is_zero = sig_hash.values.values.iter().all(|&v| v == 0); - assert!(!is_zero, "Signature hash should not be all zeros"); - } - - #[test] - fn test_signature_hash_v0_deterministic() { - println!("\n=== Testing V0 Signature Hash is Deterministic ===\n"); - - let seeds = SeedsV0 { set: ZSet::new() }; - let fee = Coins { value: 100 }; - - let hash1 = compute_spend_v0_sig_hash(&seeds, &fee); - let hash2 = compute_spend_v0_sig_hash(&seeds, &fee); - - println!("✓ Hash computed twice with same inputs"); - - assert_eq!(hash1, hash2, "Same inputs should produce same hash"); - } - - #[test] - fn test_signature_hash_v1_deterministic() { - println!("\n=== Testing V1 Signature Hash is Deterministic ===\n"); - - let seeds = SeedsV1 { set: ZSet::new() }; - let fee = Coins { value: 100 }; - - let hash1 = compute_spend_v1_sig_hash(&seeds, &fee); - let hash2 = compute_spend_v1_sig_hash(&seeds, &fee); - - println!("✓ Hash computed twice with same inputs"); - - assert_eq!(hash1, hash2, "Same inputs should produce same hash"); - } - - #[test] - fn test_signature_hash_v0_different_fees() { - println!("\n=== Testing V0 Signature Hash with Different Fees ===\n"); - - let seeds = SeedsV0 { set: ZSet::new() }; - - let fee1 = Coins { value: 100 }; - let fee2 = Coins { value: 200 }; - - let hash1 = compute_spend_v0_sig_hash(&seeds, &fee1); - let hash2 = compute_spend_v0_sig_hash(&seeds, &fee2); - - println!("✓ Hashes computed with different fees"); - - assert_ne!( - hash1, hash2, - "Different fees should produce different hashes" - ); - } - - #[test] - fn test_signature_hash_v1_different_fees() { - println!("\n=== Testing V1 Signature Hash with Different Fees ===\n"); - - let seeds = SeedsV1 { set: ZSet::new() }; - - let fee1 = Coins { value: 100 }; - let fee2 = Coins { value: 200 }; - - let hash1 = compute_spend_v1_sig_hash(&seeds, &fee1); - let hash2 = compute_spend_v1_sig_hash(&seeds, &fee2); - - println!("✓ Hashes computed with different fees"); - - assert_ne!( - hash1, hash2, - "Different fees should produce different hashes" - ); - } - - #[test] - fn test_lock_data_to_noun_v0() { - println!("\n=== Testing LockData V0 Serialization ===\n"); - - // Create a simple spend condition - let spend_condition: SpendCondition = vec![]; - let lock_data = LockData::V0(spend_condition); - - let result = lock_data_to_untyped_noun(&lock_data); - assert!( - result.is_ok(), - "Failed to serialize V0 LockData: {:?}", - result.err() - ); - - let untyped_noun = result.unwrap(); - println!("✓ Serialized V0 LockData to UntypedNoun"); - println!(" Jammed bytes length: {}", untyped_noun.jammed_bytes.len()); - - // Should have non-empty jammed bytes - assert!( - !untyped_noun.jammed_bytes.is_empty(), - "Jammed bytes should not be empty" - ); - } - - #[test] - fn test_spend_condition_to_noun_empty() { - println!("\n=== Testing Empty SpendCondition Serialization ===\n"); - - let spend_condition: SpendCondition = vec![]; - - let result = spend_condition_to_noun(&spend_condition); - assert!(result.is_ok(), "Failed to serialize empty SpendCondition"); - - println!("✓ Successfully serialized empty SpendCondition"); - } - - #[test] - fn test_hashable_structure() { - println!("\n=== Testing Hashable Structure for Signature Hash ===\n"); - - // Test that we can build the hashable structure matching the Hoon formula: - // hash([sig-hashable(seeds), leaf(fee)]) - let seeds_hashable = Hashable::Hash(Hash { - values: T8 { values: [0; 8] }, - }); - let fee_hashable = Hashable::leaf_from_atom(&100u64.to_le_bytes()); - - let combined = Hashable::cell(seeds_hashable, fee_hashable); - let hash = hash_hashable(&combined); - - println!("✓ Built hashable structure and computed hash"); - - // Verify hash is not all zeros - let is_zero = hash.values.values.iter().all(|&v| v == 0); - assert!(!is_zero, "Hash should not be all zeros"); - } -} diff --git a/tx-types/src/tests/test_zmap_structure.rs b/tx-types/src/tests/test_zmap_structure.rs index 7f18cab..fd8fcce 100644 --- a/tx-types/src/tests/test_zmap_structure.rs +++ b/tx-types/src/tests/test_zmap_structure.rs @@ -1,7 +1,6 @@ #[cfg(test)] mod tests { use crate::collections::zmap::ZMap; - use crate::collections::zset::DorTip; use crate::hashing::hashable::Hashable; use crate::hashing::hasher::hash_hashable; use crate::transaction_types::Hash; @@ -11,6 +10,15 @@ mod tests { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] struct NumKey(u64); + impl pokenoun::codec::Encode for NumKey { + fn encode( + &self, + arena: &mut pokenoun::Arena, + ) -> Result { + pokenoun::codec::Encode::encode(&self.0, arena) + } + } + impl noun_serde::NounEncode for NumKey { fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { use nockvm::noun::Atom; diff --git a/tx-types/src/tests/test_zset_simple.rs b/tx-types/src/tests/test_zset_simple.rs index 3bfd8df..4d505eb 100644 --- a/tx-types/src/tests/test_zset_simple.rs +++ b/tx-types/src/tests/test_zset_simple.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod tests { - use crate::collections::zset::{DorTip, ZSet}; + use crate::collections::zset::ZSet; use crate::hashing::hashable::Hashable; use crate::hashing::hasher::hash_hashable; use crate::transaction_types::Hash; @@ -10,6 +10,15 @@ mod tests { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] struct NumKey(u64); + impl pokenoun::codec::Encode for NumKey { + fn encode( + &self, + arena: &mut pokenoun::Arena, + ) -> Result { + pokenoun::codec::Encode::encode(&self.0, arena) + } + } + impl noun_serde::NounEncode for NumKey { fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { use nockvm::noun::Atom; diff --git a/tx-types/src/tests/test_zset_structure.rs b/tx-types/src/tests/test_zset_structure.rs index cb369b6..c33fcb0 100644 --- a/tx-types/src/tests/test_zset_structure.rs +++ b/tx-types/src/tests/test_zset_structure.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod tests { - use crate::collections::zset::{DorTip, ZSet}; + use crate::collections::zset::ZSet; use crate::hashing::hashable::Hashable; use crate::hashing::hasher::hash_hashable; use crate::transaction_types::Hash; @@ -10,6 +10,15 @@ mod tests { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] struct NumKey(u64); + impl pokenoun::codec::Encode for NumKey { + fn encode( + &self, + arena: &mut pokenoun::Arena, + ) -> Result { + pokenoun::codec::Encode::encode(&self.0, arena) + } + } + impl noun_serde::NounEncode for NumKey { fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { use nockvm::noun::Atom; diff --git a/tx-types/src/transaction_types.rs b/tx-types/src/transaction_types.rs index 1d91db8..2250bbe 100644 --- a/tx-types/src/transaction_types.rs +++ b/tx-types/src/transaction_types.rs @@ -1,23 +1,24 @@ -use nockapp::noun::slab::NounSlab; -use nockapp::noun::AtomExt; -use nockapp::Noun; -use nockvm::noun::{Atom, NounAllocator, NounSpace, D, T}; -use noun_serde::{NounDecode, NounDecodeError, NounEncode}; -use std::collections::HashMap; - -use crate::collections::{ZMap, ZSet}; +use crate::collections::ZSet; use crate::hashing::hashable::Hashable; use crate::hashing::hasher::hash_hashable; -use crate::hashing::tip5::{Tip5Error, Tip5Hasher}; +use crate::hashing::tip5::Tip5Error; use crate::transaction_types_v0::*; use crate::transaction_types_v1::*; - -use bytes::Bytes; +use alloc::{ + format, + string::{String, ToString}, + vec, + vec::Vec, +}; +#[cfg(feature = "std")] use num_bigint::BigUint; +#[cfg(feature = "std")] use num_traits::{One, Zero}; +use pokenoun::codec::{Decode, Encode, Error}; +use pokenoun::{Arena, Noun}; // Coin name structure -#[derive(Debug, Clone, Copy, NounEncode, NounDecode, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Coins { pub value: u64, } @@ -29,7 +30,7 @@ impl Coins { } // page number name structure -#[derive(Debug, Clone, Copy, NounEncode, NounDecode, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct PageNumber { pub value: u64, } @@ -41,19 +42,19 @@ impl PageNumber { } // Hash wrapper for transaction IDs and other hashes -#[derive(Debug, Clone, NounEncode, NounDecode, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Hash { pub values: [u64; 5], } impl PartialOrd for Hash { - fn partial_cmp(&self, other: &Self) -> Option { + fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for Hash { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { // Compare as UBig integers, not lexicographically // This ensures hash comparisons match the gor-tip behavior self.to_ubig().cmp(&other.to_ubig()) @@ -99,7 +100,6 @@ impl Hash { // Parse each u64 from the bytes for i in 0..5 { let start = i * 8; - let end = (i + 1) * 8; if start < bytes.len() { // Get up to 8 bytes for this u64 @@ -121,6 +121,7 @@ impl Hash { /// tip5 domain) and the canonical base58 spelling. Values with a non-zero /// sixth quotient and aliases such as a leading `1` (zero byte) are /// rejected instead of being silently truncated or re-encoded. + #[cfg(feature = "std")] pub fn from_b58(base58: &str) -> Result { use crate::hashing::u320::U320; @@ -155,9 +156,9 @@ impl Hash { /// Convert Hash to base58 encoded string /// Implements the Hoon to-b58 function: /// ++ to-b58 |=(has=form `cord`(crip (en-base58 (digest-to-atom:tip5 has)))) + #[cfg(feature = "std")] pub fn to_b58(&self) -> String { use bs58; - use num_bigint::BigUint; // The Goldilocks prime const GOLDILOCKS_PRIME: u64 = 0xFFFF_FFFF_0000_0001; @@ -176,7 +177,7 @@ impl Hash { } // Note name structure -#[derive(Debug, Clone, NounEncode, NounDecode, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct NName { pub p: Vec, } @@ -297,6 +298,7 @@ impl NName { .expect("last_v1 hashable is u64-based") } + #[cfg(feature = "std")] pub fn display(&self) -> Vec { self.p.iter().map(|h| h.to_b58()).collect() } @@ -364,21 +366,8 @@ impl Timelock { } } -impl NounEncode for Timelock { - fn to_noun(&self, allocator: &mut A) -> Noun { - self.intent.to_noun(allocator) - } -} - -impl NounDecode for Timelock { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - let intent = TimelockIntent::from_noun(noun, space)?; - Timelock::new(intent).map_err(|e| NounDecodeError::Custom(e)) - } -} - // Timelock range structure -#[derive(Debug, Clone, NounEncode, NounDecode, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct TimelockRange { pub min: Option, pub max: Option, @@ -414,46 +403,9 @@ pub struct F6LT { } // Manual NounEncode/NounDecode implementations for F6LT -impl NounEncode for F6LT { - fn to_noun(&self, allocator: &mut A) -> nockvm::noun::Noun { - use nockvm::noun::{Atom, T}; - let atoms = self.values.map(|v| Atom::new(allocator, v).as_noun()); - T(allocator, &atoms) - } -} - -impl NounDecode for F6LT { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - // Extract 6 values from nested cell structure - let mut values = [0u64; 6]; - let mut current = *noun; - - for i in 0..5 { - let cell = current - .in_space(space) - .as_cell() - .map_err(|_| NounDecodeError::ExpectedCell)?; - values[i] = cell - .head() - .as_atom() - .map_err(|_| NounDecodeError::ExpectedAtom)? - .as_u64()?; - current = cell.tail().noun(); - } - - // Last element - values[5] = current - .in_space(space) - .as_atom() - .map_err(|_| NounDecodeError::ExpectedAtom)? - .as_u64()?; - - Ok(F6LT { values }) - } -} // Schnorr signature structure a a-pt:cheta -#[derive(Debug, Clone, NounEncode, NounDecode, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct SchnorrPubkey { pub x: F6LT, pub y: F6LT, @@ -462,6 +414,7 @@ pub struct SchnorrPubkey { impl SchnorrPubkey { #[inline] + #[cfg(feature = "std")] fn f6lt_words_be_desc(a: &F6LT, out: &mut Vec) { // a5...a0 as big-endian u64s out.extend_from_slice(&a.values[5].to_be_bytes()); @@ -475,10 +428,7 @@ impl SchnorrPubkey { pub fn to_hashable(&self) -> Hashable { // In Hoon, this is [%leaf form] where form is the pubkey noun. // We jam the noun and store it in Hashable::Leaf. - let mut slab: NounSlab = NounSlab::new(); - let noun = self.to_noun(&mut slab); - slab.set_root(noun); - Hashable::Leaf(slab.jam().to_vec()) + Hashable::Leaf(self.to_jam().expect("fixed-width noun encoding")) } pub fn to_hash(&self) -> Hash { @@ -490,6 +440,7 @@ impl SchnorrPubkey { /// ++ from-b58 |=(=cord `form`(base58-to-a-pt:cheetah cord)) /// /// The base58 encoding represents a compressed elliptic curve point + #[cfg(feature = "std")] pub fn from_b58(s: &str) -> Result { if s == "inf" { return Ok(SchnorrPubkey { @@ -538,9 +489,8 @@ impl SchnorrPubkey { }) } + #[cfg(feature = "std")] fn pack_le(words: &[u64]) -> num_bigint::BigUint { - use num_bigint::BigUint; - use num_traits::{One, Zero}; let mut n = BigUint::zero(); for (i, &w) in words.iter().enumerate() { n += BigUint::from(w) << (64 * i); @@ -548,8 +498,8 @@ impl SchnorrPubkey { n } + #[cfg(feature = "std")] pub fn to_base58(&self) -> String { - use num_bigint::BigUint; use num_traits::One; let mut n = BigUint::from(0u32); @@ -570,6 +520,7 @@ impl SchnorrPubkey { /// Convert SchnorrPubkey to base58 encoded string /// Implements the Hoon to-b58 function: /// ++ to-b58 |=(sop=form `cord`(a-pt-to-base58:cheetah sop)) + #[cfg(feature = "std")] pub fn to_b58(&self) -> String { if self.inf { return "inf".to_string(); @@ -581,10 +532,8 @@ impl SchnorrPubkey { bs58::encode(bytes).into_string() } + #[cfg(feature = "std")] pub fn from_base58(s: &str) -> Self { - use num_bigint::BigUint; - use num_traits::{One, Zero}; - let mut n = Self::de_base58(s); let inf = (&n & BigUint::one()) == BigUint::one(); n >>= 1; @@ -613,8 +562,8 @@ impl SchnorrPubkey { SchnorrPubkey { x, y, inf } } + #[cfg(feature = "std")] fn de_base58(s: &str) -> num_bigint::BigUint { - use num_bigint::BigUint; use num_traits::Zero; const ALPH: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; let mut n = BigUint::zero(); @@ -624,8 +573,8 @@ impl SchnorrPubkey { } n } + #[cfg(feature = "std")] fn rip64(n: &num_bigint::BigUint) -> Vec { - use num_bigint::BigUint; use num_traits::Zero; let mut x = n.clone(); let mut out = Vec::new(); @@ -645,14 +594,14 @@ impl SchnorrPubkey { // Implement PartialOrd and Ord manually to avoid conflict with DorTip impl PartialOrd for SchnorrPubkey { - fn partial_cmp(&self, other: &Self) -> Option { + fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for SchnorrPubkey { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - use std::cmp::Ordering; + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + use core::cmp::Ordering; // First try to compare based on hash (gor-tip style) // Since we don't have the hash computation here, fall back to dor comparison @@ -674,7 +623,7 @@ impl Ord for SchnorrPubkey { } } -#[derive(Debug, Clone, NounEncode, NounDecode, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Lock { pub m: u64, pub pubkeys: ZSet, @@ -691,15 +640,9 @@ impl Lock { // $(pubkeys r.pubkeys) // Use ZSet's to_hashable method which properly traverses the tree - let pubkeys_hashable = self.pubkeys.to_hashable(|pubkey| { - // Hash each pubkey - let mut slab: NounSlab = NounSlab::new(); - let pubkey_noun = pubkey.to_noun(&mut slab); - let space = slab.noun_space(); - let pubkey_hash = Tip5Hasher::hash_noun_varlen(pubkey_noun, &space) - .expect("schnorr pubkey noun is u64-based"); - Hashable::Hash(pubkey_hash) - }); + let pubkeys_hashable = self + .pubkeys + .to_hashable(|pubkey| Hashable::Hash(pubkey.to_hash())); // Create the cell [leaf+m hashable-pubkeys] Hashable::cell( @@ -723,6 +666,7 @@ impl Lock { /// %+ turn pks /// |= pk=@t /// (from-b58:schnorr-pubkey pk) + #[cfg(feature = "std")] pub fn from_b58(m: u64, pubkeys_b58: Vec) -> Result { let mut pubkeys = ZSet::new(); @@ -759,6 +703,7 @@ impl Lock { /// ^- [m=@udD pks=(list @t)] /// :- m.loc /// (turn ~(tap z-in pubkeys.loc) to-b58:schnorr-pubkey) + #[cfg(feature = "std")] pub fn to_b58(&self) -> (u64, Vec) { let pubkeys_b58: Vec = self.pubkeys.iter().map(|pk| pk.to_b58()).collect(); @@ -766,8 +711,8 @@ impl Lock { } } -impl std::hash::Hash for Lock { - fn hash(&self, state: &mut H) { +impl core::hash::Hash for Lock { + fn hash(&self, state: &mut H) { self.m.hash(state); // Sort the pubkeys to ensure consistent hashing let mut sorted_pubkeys: Vec<_> = self.pubkeys.iter().collect(); @@ -783,7 +728,7 @@ impl std::hash::Hash for Lock { } } -#[derive(Debug, Clone, NounEncode, NounDecode, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Source { pub p: Hash, pub is_coinbase: bool, @@ -822,37 +767,23 @@ impl NNote { } } -impl NounDecode for NNote { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - if let Ok(v0) = NNoteV0::from_noun(noun, space) { - return Ok(NNote::V0(v0)); - } - if let Ok(v1) = NNoteV1::from_noun(noun, space) { - return Ok(NNote::V1(v1)); - } - Err(NounDecodeError::Custom( - "NNote enum decode: unsupported format".into(), - )) - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct T8 { pub values: [u64; 8], } -#[derive(Debug, Clone, NounEncode, NounDecode)] +#[derive(Debug, Clone)] pub struct Chal { pub values: T8, } -#[derive(Debug, Clone, NounEncode, NounDecode)] +#[derive(Debug, Clone)] pub struct Sig { pub values: T8, } // Schnorr signature components -#[derive(Debug, Clone, NounEncode, NounDecode)] +#[derive(Debug, Clone)] pub struct SchnorrSignature { pub chal: Chal, pub sig: Sig, @@ -862,10 +793,7 @@ impl SchnorrSignature { pub fn to_hashable(&self) -> Hashable { // In Hoon, this is [%leaf form] where form is the signature noun. // We jam the noun and store it in Hashable::Leaf. - let mut slab: NounSlab = NounSlab::new(); - let noun = self.to_noun(&mut slab); - slab.set_root(noun); - Hashable::Leaf(slab.jam().to_vec()) + Hashable::Leaf(self.to_jam().expect("fixed-width noun encoding")) } pub fn to_hash(&self) -> Hash { @@ -887,21 +815,12 @@ pub enum Seeds { } // Spend structure -#[derive(Debug, Clone, NounDecode)] +#[derive(Debug, Clone)] pub struct Spend { pub version: u64, pub body: SpendBody, } -impl NounEncode for Spend { - fn to_noun(&self, allocator: &mut A) -> Noun { - use nockvm::noun::T; - let version_noun = self.version.to_noun(allocator); - let body_noun = self.body.to_noun(allocator); - T(allocator, &[version_noun, body_noun]) - } -} - impl Spend { /// Compute hashable for spend /// @@ -933,36 +852,6 @@ pub enum SpendBody { V1(SpendV1), } -impl NounEncode for SpendBody { - fn to_noun(&self, allocator: &mut A) -> Noun { - match self { - SpendBody::V0(v) => v.to_noun(allocator), - SpendBody::V0ToV1(v) => v.to_noun(allocator), - SpendBody::V1(v) => v.to_noun(allocator), - } - } -} - -impl NounDecode for SpendBody { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - // Try V0 first - if let Ok(v0) = SpendV0::from_noun(noun, space) { - return Ok(SpendBody::V0(v0)); - } - // Try V0ToV1 - if let Ok(v0tov1) = SpendV0ToV1::from_noun(noun, space) { - return Ok(SpendBody::V0ToV1(v0tov1)); - } - // Try V1 - if let Ok(v1) = SpendV1::from_noun(noun, space) { - return Ok(SpendBody::V1(v1)); - } - Err(NounDecodeError::Custom( - "SpendBody enum decode: unsupported format".into(), - )) - } -} - impl SpendBody { pub fn to_hashable(&self) -> Result { match self { @@ -990,26 +879,6 @@ pub enum Input { V1(InputV1), } -impl NounEncode for Input { - fn to_noun(&self, allocator: &mut A) -> Noun { - match self { - Input::V0(v) => v.to_noun(allocator), - Input::V1(_) => D(0), - } - } -} - -impl NounDecode for Input { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - if let Ok(v0) = InputV0::from_noun(noun, space) { - return Ok(Input::V0(v0)); - } - Err(NounDecodeError::Custom( - "Input enum decode: unsupported format".into(), - )) - } -} - impl Input { pub fn to_hashable(&self) -> Hashable { match self { @@ -1035,26 +904,6 @@ pub enum Inputs { V1(InputsV1), } -impl NounEncode for Inputs { - fn to_noun(&self, allocator: &mut A) -> Noun { - match self { - Inputs::V0(v) => v.to_noun(allocator), - Inputs::V1(_) => D(0), - } - } -} - -impl NounDecode for Inputs { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - if let Ok(v0) = InputsV0::from_noun(noun, space) { - return Ok(Inputs::V0(v0)); - } - Err(NounDecodeError::Custom( - "Inputs enum decode: unsupported format".into(), - )) - } -} - impl Inputs { pub fn to_hashable(&self) -> Hashable { match self { @@ -1068,7 +917,7 @@ impl Inputs { } // Hash wrapper for transaction IDs and other hashes -#[derive(Debug, Clone, NounEncode, NounDecode)] +#[derive(Debug, Clone)] pub struct Transaction { pub name: String, pub p: Inputs, @@ -1082,87 +931,18 @@ pub enum Tx { V1(TxV1), } -impl NounDecode for Tx { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - // First, peek at the version field (first field of the struct) - let cell = noun - .in_space(space) - .as_cell() - .map_err(|_| NounDecodeError::Custom("Tx must be a cell".into()))?; - - let version_atom = cell - .head() - .as_atom() - .map_err(|_| NounDecodeError::Custom("Tx version must be an atom".into()))?; - - let version = version_atom - .as_u64() - .map_err(|_| NounDecodeError::Custom("Tx version must be a u64".into()))?; - - // Dispatch based on version - match version { - 0 => TxV0::from_noun(noun, space).map(Tx::V0), - 1 => TxV1::from_noun(noun, space).map(Tx::V1), - _ => Err(NounDecodeError::Custom(format!( - "Tx enum decode: unsupported version {}", - version - ))), - } - } -} - #[derive(Debug, Clone)] pub enum Outputs { V0(OutputsV0), V1(OutputsV1), } -impl NounEncode for Outputs { - fn to_noun(&self, allocator: &mut A) -> Noun { - match self { - Outputs::V0(v) => v.to_noun(allocator), - Outputs::V1(_) => D(0), - } - } -} - -impl NounDecode for Outputs { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - if let Ok(v0) = OutputsV0::from_noun(noun, space) { - return Ok(Outputs::V0(v0)); - } - Err(NounDecodeError::Custom( - "Outputs enum decode: unsupported format".into(), - )) - } -} - #[derive(Debug, Clone)] pub enum Output { V0(OutputV0), V1(OutputV1), } -impl NounEncode for Output { - fn to_noun(&self, allocator: &mut A) -> Noun { - match self { - Output::V0(v) => v.to_noun(allocator), - Output::V1(_) => D(0), - } - } -} - -impl NounDecode for Output { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - if let Ok(v0) = OutputV0::from_noun(noun, space) { - return Ok(Output::V0(v0)); - } - Err(NounDecodeError::Custom( - "Output enum decode: unsupported format".into(), - )) - } -} - // Raw transaction structure matching Hoon raw-tx form // ++ raw-tx // $: id=tx-id :: hash of +.raw-tx @@ -1176,58 +956,22 @@ pub enum RawTransaction { V1(RawTransactionV1), } -impl NounDecode for RawTransaction { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - // Try V1 first (has version field) - if let Ok(v1) = RawTransactionV1::from_noun(noun, space) { - return Ok(RawTransaction::V1(v1)); - } - // Fall back to V0 - if let Ok(v0) = RawTransactionV0::from_noun(noun, space) { - return Ok(RawTransaction::V0(v0)); - } - Err(NounDecodeError::Custom( - "RawTransaction enum decode: unsupported format".into(), - )) - } -} - -impl NounDecode for T8 { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - let mut ret: [u64; 8] = [0; 8]; - let mut cur = *noun; - for i in 0..7 { - let cur_cell = cur - .in_space(space) - .as_cell() - .map_err(|_| NounDecodeError::ExpectedCell)?; - ret[i] = cur_cell - .head() - .as_atom() - .map_err(|_| NounDecodeError::ExpectedAtom)? - .as_u64()?; - cur = cur_cell.tail().noun(); - } - ret[7] = cur - .in_space(space) - .as_atom() - .map_err(|_| NounDecodeError::ExpectedAtom)? - .as_u64()?; - Ok(T8 { values: ret }) - } -} - -impl NounEncode for T8 { - fn to_noun(&self, alloc: &mut A) -> Noun { - let mut res_cell = Atom::new(alloc, self.values[7]).as_noun(); - for i in (0..=6).rev() { - let b = Atom::new(alloc, self.values[i]).as_noun(); - res_cell = T(alloc, &[b, res_cell]); - } - res_cell - } -} - +crate::noun_codec::noun_struct!( + Coins { value }, + PageNumber { value }, + Hash { values }, + NName { p }, + TimelockRange { min, max }, + F6LT { values }, + SchnorrPubkey { x, y, inf }, + Lock { m, pubkeys }, + Source { p, is_coinbase }, + T8 { values }, + Chal { values }, + Sig { values }, + SchnorrSignature { chal, sig }, + Transaction { name, p } +); #[cfg(all(test, feature = "legacy-tests"))] mod tests { use crate::collections::ZSet; @@ -1598,3 +1342,99 @@ mod tests { println!("Number of inputs: {}", decoded.p.p.wyt()); } } + +impl Encode for Timelock { + fn encode(&self, arena: &mut Arena) -> Result { + self.intent.encode(arena) + } +} +impl Decode for Timelock { + fn decode(noun: Noun, arena: &Arena) -> Result { + Self::new(TimelockIntent::decode(noun, arena)?).map_err(|_| Error("empty timelock intent")) + } +} + +macro_rules! noun_union { + ($ty:ident { $($variant:ident($payload:ty)),+ $(,)? }) => { + impl Encode for $ty { + fn encode(&self, arena: &mut Arena) -> Result { + match self { $(Self::$variant(value) => value.encode(arena)),+ } + } + } + impl Decode for $ty { + fn decode(noun: Noun, arena: &Arena) -> Result { + $(if let Ok(value) = <$payload>::decode(noun, arena) { return Ok(Self::$variant(value)); })+ + Err(Error(concat!("invalid ", stringify!($ty), " noun"))) + } + } + crate::noun_codec::runtime_codec!($ty); + }; +} + +noun_union!(NNote { V0(NNoteV0), V1(NNoteV1) }); +noun_union!(SpendBody { V0(SpendV0), V0ToV1(SpendV0ToV1), V1(SpendV1) }); +noun_union!(Input { V0(InputV0), V1(InputV1) }); +noun_union!(Inputs { V0(InputsV0), V1(InputsV1) }); +noun_union!(Output { V0(OutputV0), V1(OutputV1) }); +noun_union!(Outputs { V0(OutputsV0), V1(OutputsV1) }); + +impl Encode for Spend { + fn encode(&self, arena: &mut Arena) -> Result { + let version = self.version.encode(arena)?; + let body = self.body.encode(arena)?; + Ok(arena.alloc_cell(version, body)) + } +} +impl Decode for Spend { + fn decode(noun: Noun, arena: &Arena) -> Result { + let (version, body) = pokenoun::codec::pair(noun, arena)?; + let version = u64::decode(version, arena)?; + let body = match version { + 0 => SpendBody::V0ToV1(SpendV0ToV1::decode(body, arena)?), + 1 => SpendBody::V1(SpendV1::decode(body, arena)?), + _ => return Err(Error("invalid spend version")), + }; + Ok(Self { version, body }) + } +} + +impl Encode for Tx { + fn encode(&self, arena: &mut Arena) -> Result { + match self { + Self::V0(tx) => tx.encode(arena), + Self::V1(tx) => tx.encode(arena), + } + } +} +impl Decode for Tx { + fn decode(noun: Noun, arena: &Arena) -> Result { + let (version, _) = pokenoun::codec::pair(noun, arena)?; + match u64::decode(version, arena)? { + 0 => TxV0::decode(noun, arena).map(Self::V0), + 1 => TxV1::decode(noun, arena).map(Self::V1), + _ => Err(Error("invalid transaction version")), + } + } +} + +impl Encode for RawTransaction { + fn encode(&self, arena: &mut Arena) -> Result { + match self { + Self::V0(tx) => tx.encode(arena), + Self::V1(tx) => tx.encode(arena), + } + } +} +impl Decode for RawTransaction { + fn decode(noun: Noun, arena: &Arena) -> Result { + let (head, _) = pokenoun::codec::pair(noun, arena)?; + match head { + Noun::Cell(_) => RawTransactionV0::decode(noun, arena).map(Self::V0), + _ if u64::decode(head, arena)? == 1 => { + RawTransactionV1::decode(noun, arena).map(Self::V1) + } + _ => Err(Error("invalid raw transaction version")), + } + } +} +crate::noun_codec::runtime_codec!(Timelock, Spend, Tx, RawTransaction); diff --git a/tx-types/src/transaction_types_v0.rs b/tx-types/src/transaction_types_v0.rs index d79b845..7a1b231 100644 --- a/tx-types/src/transaction_types_v0.rs +++ b/tx-types/src/transaction_types_v0.rs @@ -1,19 +1,18 @@ use crate::{ZMap, ZSet}; -use noun_serde::{NounDecode, NounDecodeError, NounEncode}; use crate::hashing::hashable::Hashable; use crate::hashing::hasher::hash_hashable; use crate::transaction_types::*; // Note structure -#[derive(Debug, Clone, NounEncode, NounDecode)] +#[derive(Debug, Clone)] pub struct NNoteHead { pub version: u64, pub origin_page: PageNumber, pub timelock: Timelock, } -#[derive(Debug, Clone, NounEncode, NounDecode)] +#[derive(Debug, Clone)] pub struct NNoteV0 { pub meta: NNoteHead, pub name: NName, @@ -70,7 +69,7 @@ impl NNoteV0 { } // Seed structure -#[derive(Debug, Clone, NounEncode, NounDecode, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct SeedV0 { pub output_source: Option, pub recipient: Lock, @@ -132,7 +131,7 @@ impl SeedV0 { } // Seeds structure -#[derive(Debug, Clone, NounEncode, NounDecode)] +#[derive(Debug, Clone)] pub struct SeedsV0 { pub set: ZSet, } @@ -172,7 +171,7 @@ impl SeedsV0 { // ===================== V0 signature/spend/input/raw-tx wrappers ===================== -#[derive(Debug, Clone, NounEncode, NounDecode)] +#[derive(Debug, Clone)] pub struct Signature { pub map: ZMap, } @@ -192,7 +191,7 @@ impl Signature { } } -#[derive(Debug, Clone, NounEncode, NounDecode)] +#[derive(Debug, Clone)] pub struct SpendV0 { pub signature: Option, pub seeds: SeedsV0, @@ -221,7 +220,7 @@ impl SpendV0 { } } -#[derive(Debug, Clone, NounEncode, NounDecode)] +#[derive(Debug, Clone)] pub struct InputV0 { pub note: NNoteV0, pub spend: SpendV0, @@ -284,7 +283,7 @@ impl InputV0 { } } -#[derive(Debug, Clone, NounEncode, NounDecode)] +#[derive(Debug, Clone)] pub struct InputsV0 { pub p: ZMap, } @@ -300,18 +299,18 @@ impl InputsV0 { } } -#[derive(Debug, Clone, NounEncode, NounDecode)] +#[derive(Debug, Clone)] pub struct OutputV0 { pub note: NNoteV0, pub seeds: SeedsV0, } -#[derive(Debug, Clone, NounEncode, NounDecode)] +#[derive(Debug, Clone)] pub struct OutputsV0 { pub map: ZMap, } -#[derive(Debug, Clone, NounEncode, NounDecode)] +#[derive(Debug, Clone)] pub struct RawTransactionV0 { pub id: Hash, pub inputs: InputsV0, @@ -319,10 +318,55 @@ pub struct RawTransactionV0 { pub total_fees: Coins, } -#[derive(Debug, Clone, NounEncode, NounDecode)] +#[derive(Debug, Clone)] pub struct TxV0 { pub version: u64, pub raw_tx: RawTransactionV0, pub total_size: u64, pub outputs: OutputsV0, } + +crate::noun_codec::noun_struct!( + NNoteHead { + version, + origin_page, + timelock + }, + NNoteV0 { + meta, + name, + lock, + source, + assets + }, + SeedV0 { + output_source, + recipient, + timelock_intent, + gift, + parent_hash + }, + SeedsV0 { set }, + Signature { map }, + SpendV0 { + signature, + seeds, + fee + }, + InputV0 { note, spend }, + InputsV0 { p }, + OutputV0 { note, seeds }, + OutputsV0 { map }, + RawTransactionV0 { + id, + inputs, + timelock_range, + total_fees + }, + TxV0 { + version, + raw_tx, + total_size, + outputs + } +); diff --git a/tx-types/src/transaction_types_v1.rs b/tx-types/src/transaction_types_v1.rs index 45f9951..5067ee5 100644 --- a/tx-types/src/transaction_types_v1.rs +++ b/tx-types/src/transaction_types_v1.rs @@ -1,371 +1,30 @@ -use noun_serde::{NounDecode, NounEncode}; - use crate::collections::zset::DorTip as ZSetDorTip; use crate::collections::{ZMap, ZSet}; use crate::generic_noun::UntypedNoun; +use crate::hashing::tip5::Tip5Error; +use crate::hashing::{hash_hashable, Hashable}; use crate::transaction_types::*; -use nockapp::noun::slab::NounSlab; -use nockapp::utils::make_tas; -use nockapp::Bytes; -use nockvm::noun::{Noun, NounAllocator, NounSpace, D}; -use once_cell::sync::Lazy; - -use crate::hashing::hashable::Hashable; -use crate::hashing::hasher::{hash_hashable, hash_ten_cell}; -use crate::hashing::raw::hashable_spends_from_raw; -use crate::hashing::tip5::{Tip5Error, Tip5Hasher}; - -static LOCK_MERKLE_AXIS_HASH: Lazy = Lazy::new(|| { - Hash::from_b58("6mhCSwJQDvbkbiPAUNjetJtVoo1VLtEhmEYoU4hmdGd6ep1F6ayaV4A") - .expect("valid lock merkle proof axis hash constant") -}); - -fn hashable_from_noun_recursive( - noun: nockvm::noun::Noun, - space: &NounSpace, -) -> Result { - if let Ok(cell) = noun.in_space(space).as_cell() { - Ok(Hashable::cell( - hashable_from_noun_recursive(cell.head().noun(), space)?, - hashable_from_noun_recursive(cell.tail().noun(), space)?, - )) - } else { - let atom = noun.in_space(space).as_atom().map_err(|_| { - Tip5Error::InvalidNoun("hashable_from_noun_recursive: expected atom or cell".into()) - })?; - match atom.as_u64() { - Ok(val) => Ok(Hashable::leaf_from_atom(&val.to_le_bytes())), - Err(_) => { - let mut slab: NounSlab = NounSlab::new(); - slab.copy_into(atom.as_noun().noun(), space); - Ok(Hashable::Leaf(slab.jam().to_vec())) - } - } - } -} - -fn hashable_from_untyped_noun(untyped: &UntypedNoun) -> Result { - let mut slab: NounSlab = NounSlab::new(); - let noun = slab - .cue_into(untyped.p.clone()) - .map_err(|e| Tip5Error::SerializationError(format!("failed to cue noun: {:?}", e)))?; - - let space = slab.noun_space(); - hashable_from_noun_recursive(noun, &space) -} - -fn hashable_leaf_from_noun(noun: Noun, space: &NounSpace) -> Hashable { - let mut slab: NounSlab = NounSlab::new(); - let copy = slab.copy_into(noun, space); - slab.set_root(copy); - Hashable::Leaf(slab.jam().to_vec()) -} - -fn hashable_note_data_from_zmap(noun: Noun, space: &NounSpace) -> Result { - if let Ok(atom) = noun.in_space(space).as_atom() { - if atom.as_u64().unwrap_or(1) == 0 { - return Ok(Hashable::null()); - } - } - - let invalid = || Tip5Error::InvalidNoun("malformed note-data node".into()); - let cell = noun.in_space(space).as_cell().map_err(|_| invalid())?; - let node = cell.head(); - let children = cell.tail().as_cell().map_err(|_| invalid())?; - let left = children.head(); - let right = match children.tail().as_cell() { - Ok(right_cell) => right_cell.head(), - Err(_) => children.tail(), - }; - - let pair = node.as_cell().map_err(|_| invalid())?; - let key_noun = pair.head(); - let value_noun = pair.tail(); - - let key_leaf = hashable_leaf_from_noun(key_noun.noun(), space); - let value_hashable = hashable_from_noun_recursive(value_noun.noun(), space)?; - let node_hashable = Hashable::cell(key_leaf, value_hashable); - - Ok(Hashable::triple( - node_hashable, - hashable_note_data_from_zmap(left.noun(), space)?, - hashable_note_data_from_zmap(right.noun(), space)?, - )) -} - -pub fn hashable_spends_from_zmap(noun: Noun, space: &NounSpace) -> Result { - if noun_is_null(noun) { - return Ok(Hashable::null()); - } - - let (node, left, right) = decompose_map(noun, space)?; - let (name_noun, spend_noun) = decompose_pair(node, space)?; - - let name = NName::from_noun(&name_noun, space) - .map_err(|e| Tip5Error::InvalidNoun(format!("failed to decode spend name: {:?}", e)))?; - let spend = Spend::from_noun(&spend_noun, space) - .map_err(|e| Tip5Error::InvalidNoun(format!("failed to decode spend entry: {:?}", e)))?; - - let node_hashable = Hashable::cell(name.to_hashable(), spend.to_hashable()?); - - Ok(Hashable::triple( - node_hashable, - hashable_spends_from_zmap(left, space)?, - hashable_spends_from_zmap(right, space)?, - )) -} - -fn noun_is_null(noun: Noun) -> bool { - unsafe { noun.raw_equals(&D(0)) } -} +use alloc::{ + format, + string::{String, ToString}, + vec, + vec::Vec, +}; +use pokenoun::codec::{is_null, pair, Decode, Encode, Error, Fields}; +use pokenoun::{Arena, Noun}; + +// The stub format commits to this fixed digest in place of its axis. +const LOCK_MERKLE_AXIS_HASH: Hash = Hash { + values: [ + 1988594973584463658, + 8158631336633700141, + 2161567007650232260, + 460329990575991155, + 8368574252173164961, + ], +}; -fn atom_trimmed_be(atom: nockvm::noun::AtomHandle) -> Vec { - let mut bytes = atom.to_be_bytes(); - let first_non_zero = bytes.iter().position(|&b| b != 0).unwrap_or(bytes.len()); - if first_non_zero == bytes.len() { - vec![0] - } else { - bytes.split_off(first_non_zero) - } -} - -fn nouns_equal(a: Noun, b: Noun, space: &NounSpace) -> bool { - if unsafe { a.raw_equals(&b) } { - return true; - } - - match (a.in_space(space).as_cell(), b.in_space(space).as_cell()) { - (Ok(ac), Ok(bc)) => { - nouns_equal(ac.head().noun(), bc.head().noun(), space) - && nouns_equal(ac.tail().noun(), bc.tail().noun(), space) - } - (Err(_), Err(_)) => { - if let (Ok(a_atom), Ok(b_atom)) = - (a.in_space(space).as_atom(), b.in_space(space).as_atom()) - { - atom_trimmed_be(a_atom) == atom_trimmed_be(b_atom) - } else { - false - } - } - _ => false, - } -} - -fn atom_less_than(a: nockvm::noun::AtomHandle, b: nockvm::noun::AtomHandle) -> bool { - let a_bytes = atom_trimmed_be(a); - let b_bytes = atom_trimmed_be(b); - match a_bytes.len().cmp(&b_bytes.len()) { - std::cmp::Ordering::Less => true, - std::cmp::Ordering::Greater => false, - std::cmp::Ordering::Equal => a_bytes < b_bytes, - } -} - -fn less_than_hash(a: &[u64; 5], b: &[u64; 5]) -> bool { - // Compare using Goldilocks polynomial representation, matching Hoon's lth-tip: - // digest-to-atom: a + p*b + p²*c + p³*d + p⁴*e - // where p = 2^64 - 2^32 + 1 (Goldilocks prime) - let hash_a = Hash { values: *a }; - let hash_b = Hash { values: *b }; - hash_a.to_ubig() < hash_b.to_ubig() -} - -fn tip_hash(noun: Noun, space: &NounSpace) -> Result<[u64; 5], Tip5Error> { - Tip5Hasher::hash_noun_varlen(noun, space).map(|hash| hash.values) -} - -fn double_tip_hash(noun: Noun, space: &NounSpace) -> Result<[u64; 5], Tip5Error> { - let tip = tip_hash(noun, space)?; - let hash = Hash { values: tip }; - Ok(hash_ten_cell(hash.clone(), hash).values) -} - -fn dor_tip_compare(a: Noun, b: Noun, space: &NounSpace) -> bool { - if nouns_equal(a, b, space) { - return true; - } - - match (a.in_space(space).as_cell(), b.in_space(space).as_cell()) { - (Ok(ac), Ok(bc)) => { - if nouns_equal(ac.head().noun(), bc.head().noun(), space) { - dor_tip_compare(ac.tail().noun(), bc.tail().noun(), space) - } else { - dor_tip_compare(ac.head().noun(), bc.head().noun(), space) - } - } - (Ok(_), Err(_)) => false, - (Err(_), Ok(_)) => false, - (Err(_), Err(_)) => { - if let (Ok(a_atom), Ok(b_atom)) = - (a.in_space(space).as_atom(), b.in_space(space).as_atom()) - { - atom_less_than(a_atom, b_atom) - } else { - false - } - } - } -} - -fn gor_tip_compare(a: Noun, b: Noun, space: &NounSpace) -> Result { - let a_tip = tip_hash(a, space)?; - let b_tip = tip_hash(b, space)?; - if a_tip == b_tip { - Ok(dor_tip_compare(a, b, space)) - } else { - Ok(less_than_hash(&a_tip, &b_tip)) - } -} - -fn mor_tip_compare(a: Noun, b: Noun, space: &NounSpace) -> Result { - let a_tip = double_tip_hash(a, space)?; - let b_tip = double_tip_hash(b, space)?; - if a_tip == b_tip { - Ok(dor_tip_compare(a, b, space)) - } else { - Ok(less_than_hash(&a_tip, &b_tip)) - } -} - -fn decompose_map(noun: Noun, space: &NounSpace) -> Result<(Noun, Noun, Noun), Tip5Error> { - let cell = noun - .in_space(space) - .as_cell() - .map_err(|_| Tip5Error::InvalidNoun("map node must be a cell".into()))?; - let node = cell.head().noun(); - let tail = cell.tail(); - - if let Ok(children) = tail.as_cell() { - let left = children.head().noun(); - let right = children.tail().noun(); - Ok((node, left, right)) - } else { - Ok((node, tail.noun(), D(0))) - } -} - -fn decompose_pair(noun: Noun, space: &NounSpace) -> Result<(Noun, Noun), Tip5Error> { - let cell = noun - .in_space(space) - .as_cell() - .map_err(|_| Tip5Error::InvalidNoun("pair must be a cell".into()))?; - let key = cell.head().noun(); - let value = cell.tail().noun(); - Ok((key, value)) -} - -fn canonical_zmap_put( - alloc: &mut A, - map: Noun, - key: Noun, - value: Noun, -) -> Result { - if noun_is_null(map) { - let kv = nockvm::noun::T(alloc, &[key, value]); - let children = nockvm::noun::T(alloc, &[D(0), D(0)]); - return Ok(nockvm::noun::T(alloc, &[kv, children])); - } - - // Refresh the space after any prior allocation; a stack-backed space holds - // an epoch snapshot that allocations advance. - let space = alloc.noun_space(); - let (node, left, right) = decompose_map(map, &space)?; - let (node_key, node_value) = decompose_pair(node, &space)?; - - if nouns_equal(key, node_key, &space) { - if nouns_equal(value, node_value, &space) { - Ok(map) - } else { - let new_node = nockvm::noun::T(alloc, &[key, value]); - Ok(nockvm::noun::T(alloc, &[new_node, left, right])) - } - } else if gor_tip_compare(key, node_key, &space)? { - let new_left = canonical_zmap_put(alloc, left, key, value)?; - let space = alloc.noun_space(); - let (new_left_node, new_left_left, new_left_right) = decompose_map(new_left, &space)?; - let (new_left_key, _) = decompose_pair(new_left_node, &space)?; - - if mor_tip_compare(node_key, new_left_key, &space)? { - let children = nockvm::noun::T(alloc, &[new_left, right]); - Ok(nockvm::noun::T(alloc, &[node, children])) - } else { - let right_children = nockvm::noun::T(alloc, &[new_left_right, right]); - let new_right_branch = nockvm::noun::T(alloc, &[node, right_children]); - let left_children = nockvm::noun::T(alloc, &[new_left_left, new_right_branch]); - Ok(nockvm::noun::T(alloc, &[new_left_node, left_children])) - } - } else { - let new_right = canonical_zmap_put(alloc, right, key, value)?; - let space = alloc.noun_space(); - let (new_right_node, new_right_left, new_right_right) = decompose_map(new_right, &space)?; - let (new_right_key, _) = decompose_pair(new_right_node, &space)?; - - if mor_tip_compare(node_key, new_right_key, &space)? { - let children = nockvm::noun::T(alloc, &[left, new_right]); - Ok(nockvm::noun::T(alloc, &[node, children])) - } else { - let left_children = nockvm::noun::T(alloc, &[left, new_right_left]); - let new_left_branch = nockvm::noun::T(alloc, &[node, left_children]); - let right_children = nockvm::noun::T(alloc, &[new_left_branch, new_right_right]); - Ok(nockvm::noun::T(alloc, &[new_right_node, right_children])) - } - } -} - -fn build_canonical_zmap_from_pairs( - alloc: &mut A, - entries: I, - mut key_fn: KF, - mut value_fn: VF, -) -> Result -where - A: nockvm::noun::NounAllocator, - I: IntoIterator, - KF: FnMut(&mut A, K) -> Noun, - VF: FnMut(&mut A, V) -> Noun, -{ - let mut root = D(0); - for (key, value) in entries { - let key_noun = key_fn(alloc, key); - let value_noun = value_fn(alloc, value); - root = canonical_zmap_put(alloc, root, key_noun, value_noun)?; - } - Ok(root) -} - -fn build_canonical_note_data_noun( - alloc: &mut A, - map: &ZMap, -) -> Result { - build_canonical_zmap_from_pairs( - alloc, - map.tap().into_iter(), - |alloc, key: String| make_tas(alloc, &key).as_noun(), - |alloc, value: UntypedNoun| value.to_noun(alloc), - ) -} - -fn build_canonical_spends_noun( - alloc: &mut A, - map: &ZMap, -) -> Result { - build_canonical_zmap_from_pairs( - alloc, - map.tap().into_iter(), - |alloc, key: NName| key.to_noun(alloc), - |alloc, value: Spend| value.to_noun(alloc), - ) -} - -/* -#[derive(Debug, Clone, NounDecode, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct NNameV1 { - pub p: Vec, -} - */ - -#[derive(Debug, Clone, NounDecode, NounEncode)] +#[derive(Debug, Clone)] pub struct NNoteV1 { pub version: u64, pub origin_page: PageNumber, @@ -399,18 +58,11 @@ impl NNoteV1 { } } -#[derive(Debug, Clone, NounDecode)] +#[derive(Debug, Clone)] pub struct NoteData { pub map: ZMap, } -impl NounEncode for NoteData { - fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { - build_canonical_note_data_noun(alloc, &self.map) - .expect("note-data z-map keys are u64-based") - } -} - impl NoteData { pub fn to_hash(&self) -> Result { use crate::hashing::hasher::hash_hashable; @@ -421,23 +73,19 @@ impl NoteData { /// /// From Hoon, note-data is a z-map of @tas to * pub fn to_hashable(&self) -> Result { - let mut slab: NounSlab = NounSlab::new(); - let noun = build_canonical_note_data_noun(&mut slab, &self.map)?; - let space = slab.noun_space(); - hashable_note_data_from_zmap(noun, &space) + self.map.try_to_hashable( + |key| Ok(Hashable::leaf_from_tas(key)), + |value| value.to_hashable(), + ) } /// Return the canonical note-data noun jam used for hashing/debugging. pub fn jam_canonical(&self) -> Vec { - let mut slab: NounSlab = NounSlab::new(); - let noun = build_canonical_note_data_noun(&mut slab, &self.map) - .expect("note-data z-map keys are u64-based"); - slab.set_root(noun); - slab.jam().to_vec() + self.to_jam().expect("note-data noun encoding") } } -#[derive(Debug, Clone, NounDecode)] +#[derive(Debug, Clone)] pub struct SeedV1 { pub output_source: Option, // if Some, enforces that output note must have precisely this source pub lock_root: Hash, // merkle root of lock script @@ -517,7 +165,7 @@ impl SeedV1 { } } -#[derive(Debug, Clone, NounDecode, NounEncode)] +#[derive(Debug, Clone)] pub struct SeedsV1 { pub set: ZSet, } @@ -558,7 +206,7 @@ impl SeedsV1 { } } -#[derive(Debug, Clone, NounDecode, NounEncode)] +#[derive(Debug, Clone)] pub struct TxV1 { pub version: u64, pub raw_tx: RawTransactionV1, @@ -566,30 +214,43 @@ pub struct TxV1 { pub outputs: OutputsV1, } -#[derive(Debug, Clone, NounDecode, NounEncode)] +#[derive(Debug, Clone)] pub struct OutputsV1 { pub set: ZSet, } -#[derive(Debug, Clone, NounDecode)] +#[derive(Debug, Clone)] pub struct OutputV1 { pub note: NNoteV1, pub seeds: SeedsV1, } -#[derive(Debug, Clone, NounDecode, NounEncode)] +#[derive(Debug, Clone)] pub struct RawTransactionV1 { pub version: u64, pub id: Hash, pub spends: SpendsV1, } -#[derive(Debug, Clone, NounDecode)] +#[derive(Debug, Clone)] pub struct SpendsV1 { pub map: ZMap, } impl SpendsV1 { + pub fn valid_stub_axes(&self, fund_firstname: &Hash) -> bool { + self.map + .tap() + .iter() + .all(|(name, spend)| match &spend.body { + SpendBody::V1(spend) => crate::lock::valid_stub_axis( + &spend.witness.lmp, + name.p.first() == Some(fund_firstname), + ), + _ => true, + }) + } + /// Compute hashable for spends /// /// From Hoon (tx-engine-1.hoon lines ~800-806): @@ -604,11 +265,8 @@ impl SpendsV1 { /// $(form r.form) /// ``` pub fn to_hashable(&self) -> Result { - let mut slab: NounSlab = NounSlab::new(); - let noun = build_canonical_spends_noun(&mut slab, &self.map)?; - let space = slab.noun_space(); - hashable_spends_from_raw(noun, &space) - .map_err(|e| Tip5Error::InvalidNoun(format!("invalid spends noun: {:?}", e))) + self.map + .try_to_hashable(|name| Ok(name.to_hashable()), |spend| spend.to_hashable()) } /// Compute hash of spends @@ -627,17 +285,7 @@ impl SpendsV1 { /// Return the raw spends noun jam used for hashing/debugging. pub fn jam_canonical(&self) -> Vec { - let mut slab: NounSlab = NounSlab::new(); - let noun = build_canonical_spends_noun(&mut slab, &self.map) - .expect("spend z-map keys are u64-based"); - slab.set_root(noun); - slab.jam().to_vec() - } -} - -impl NounEncode for SpendsV1 { - fn to_noun(&self, alloc: &mut A) -> Noun { - build_canonical_spends_noun(alloc, &self.map).expect("spend z-map keys are u64-based") + self.to_jam().expect("spends noun encoding") } } @@ -657,34 +305,35 @@ pub fn compute_tx_id_v1(spends: &SpendsV1) -> Result { } /// Compute the tx-id from the raw noun representation of the spends map. -pub fn compute_tx_id_v1_from_noun(spends_noun: Noun, space: &NounSpace) -> Result { - let spends = SpendsV1::from_noun(&spends_noun, space) - .map_err(|e| Tip5Error::InvalidNoun(format!("invalid spends noun: {:?}", e)))?; +#[cfg(feature = "std")] +pub fn compute_tx_id_v1_from_noun( + spends_noun: nockvm::noun::Noun, + space: &nockvm::noun::NounSpace, +) -> Result { + let spends: SpendsV1 = crate::noun_codec::decode_runtime(spends_noun, space) + .map_err(|error| Tip5Error::InvalidNoun(format!("invalid spends: {error}")))?; compute_tx_id_v1(&spends) } /// Compute the tx-id directly from the jammed noun bytes of the spends map. pub fn compute_tx_id_v1_from_jam(spends_jam: &[u8]) -> Result { - let mut slab: NounSlab = NounSlab::new(); - let noun = slab - .cue_into(Bytes::copy_from_slice(spends_jam)) - .map_err(|e| Tip5Error::SerializationError(format!("failed to cue spends: {:?}", e)))?; - let space = slab.noun_space(); - compute_tx_id_v1_from_noun(noun, &space) + let spends = SpendsV1::from_jam(spends_jam) + .map_err(|error| Tip5Error::InvalidNoun(format!("invalid spends: {error}")))?; + compute_tx_id_v1(&spends) } -#[derive(Debug, Clone, NounDecode, NounEncode)] +#[derive(Debug, Clone)] pub struct InputsV1 { pub map: ZMap, } -#[derive(Debug, Clone, NounDecode, NounEncode)] +#[derive(Debug, Clone)] pub struct InputV1 { pub note: NNoteV1, pub spend: SpendV1, } -#[derive(Debug, Clone, NounDecode, NounEncode)] +#[derive(Debug, Clone)] pub struct SpendV1 { pub witness: Witness, pub seeds: SeedsV1, @@ -739,7 +388,7 @@ impl SpendV1 { /// /// This corresponds to `spend-0:v1:transact` in tx-engine-1.hoon. /// It uses V0 signatures (ZMap) but creates V1 seeds. -#[derive(Debug, Clone, NounDecode, NounEncode)] +#[derive(Debug, Clone)] pub struct SpendV0ToV1 { pub signature: ZMap, pub seeds: SeedsV1, @@ -791,49 +440,6 @@ impl SpendV0ToV1 { } } -#[derive(Debug, Clone, NounDecode)] -pub struct NNameV1 { - pub p: Vec, -} - -impl NounEncode for NNameV1 { - fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { - use nockvm::noun::T; - let items: Vec = self.p.iter().map(|h| h.to_noun(alloc)).collect(); - T(alloc, &items) - } -} - -impl NounEncode for SeedV1 { - fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { - use nockvm::noun::T; - - // Encode all 5 fields: output_source, lock_root, note_data, gift, parent_hash - let output_source = self.output_source.to_noun(alloc); - let lock_root = self.lock_root.to_noun(alloc); - let note_data = self.note_data.to_noun(alloc); - let gift = self.gift.to_noun(alloc); - let parent_hash = self.parent_hash.to_noun(alloc); - - T( - alloc, - &[output_source, lock_root, note_data, gift, parent_hash], - ) - } -} - -impl NounEncode for OutputV1 { - fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { - use nockvm::noun::T; - - // Encode both fields: note and seeds - let note = self.note.to_noun(alloc); - let seeds = self.seeds.to_noun(alloc); - - T(alloc, &[note, seeds]) - } -} - // Satisfy ZSet bounds without requiring full Ord on nested fields impl PartialEq for SeedV1 { fn eq(&self, other: &Self) -> bool { @@ -861,7 +467,7 @@ impl ZSetDorTip for OutputV1 { } } -#[derive(Debug, Clone, NounEncode)] +#[derive(Debug, Clone)] pub struct Witness { pub lmp: LockMerkleProof, pub pkh: PkhSignature, @@ -869,45 +475,6 @@ pub struct Witness { pub tim: u64, } -impl NounDecode for Witness { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - use noun_serde::NounDecodeError; - - let cell = noun - .in_space(space) - .as_cell() - .map_err(|_| NounDecodeError::ExpectedCell)?; - let lmp = LockMerkleProof::from_noun(&cell.head().noun(), space)?; - - let rest = cell - .tail() - .as_cell() - .map_err(|_| NounDecodeError::ExpectedCell)?; - let pkh = PkhSignature::from_noun(&rest.head().noun(), space)?; - - let rest = rest - .tail() - .as_cell() - .map_err(|_| NounDecodeError::ExpectedCell)?; - let hax = ZMap::::from_noun(&rest.head().noun(), space)?; - - let tim = rest - .tail() - .as_atom() - .map_err(|_| NounDecodeError::ExpectedAtom)? - .as_u64() - .map_err(|_| NounDecodeError::Custom("witness tim must fit in u64".into()))?; - - // Upstream `Witness::from_noun` hardcodes `tim: 0` and Hoon requires - // `~`; a non-zero value is not a valid witness encoding. - if tim != 0 { - return Err(NounDecodeError::Custom("witness tim must be 0 (~)".into())); - } - - Ok(Self { lmp, pkh, hax, tim }) - } -} - impl Witness { /// Compute hashable for witness /// @@ -961,7 +528,7 @@ impl Witness { |untyped_noun| { // hashable-noun recursively builds cells for cells, leaves for atoms // But since UntypedNoun is just jammed bytes, we treat it as a leaf - hashable_from_untyped_noun(untyped_noun) + untyped_noun.to_hashable() }, ) } @@ -972,48 +539,14 @@ impl Witness { } } -fn decode_hoon_list( - noun: &Noun, - space: &NounSpace, - context: &'static str, - mut decode_item: F, -) -> Result, noun_serde::NounDecodeError> -where - F: FnMut(&Noun) -> Result, -{ - let mut items = Vec::new(); - let mut current = *noun; - - loop { - let handle = current.in_space(space); - if let Ok(cell) = handle.as_cell() { - items.push(decode_item(&cell.head().noun())?); - current = cell.tail().noun(); - continue; - } - - let atom = handle - .as_atom() - .map_err(|_| noun_serde::NounDecodeError::ExpectedAtom)?; - match atom.as_u64()? { - 0 => return Ok(items), - _ => { - return Err(noun_serde::NounDecodeError::Custom( - format!("{context} must be a list").into(), - )) - } - } - } -} - -#[derive(Debug, Clone, NounDecode)] +#[derive(Debug, Clone)] pub struct LockMerkleProofStub { pub spend_condition: SpendCondition, pub axis: u64, pub merkle_proof: MerkleProof, } -#[derive(Debug, Clone, NounDecode)] +#[derive(Debug, Clone)] pub struct LockMerkleProofFull { pub version: u64, pub spend_condition: SpendCondition, @@ -1030,7 +563,7 @@ pub enum LockMerkleProof { impl LockMerkleProof { pub fn new_full(spend_condition: SpendCondition, axis: u64, merkle_proof: MerkleProof) -> Self { Self::Full(LockMerkleProofFull { - version: nockvm_macros::tas!(b"full"), + version: u64::from_le_bytes(*b"full\0\0\0\0"), spend_condition, axis, merkle_proof, @@ -1112,55 +645,34 @@ impl LockMerkleProof { } } -impl NounDecode for LockMerkleProof { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - if let Ok(full) = LockMerkleProofFull::from_noun(noun, space) { - if full.version != nockvm_macros::tas!(b"full") { - return Err(noun_serde::NounDecodeError::Custom( - "lock-merkle-proof version must be %full".into(), - )); - } - return Ok(Self::Full(full)); - } - - Ok(Self::Stub(LockMerkleProofStub::from_noun(noun, space)?)) - } +#[derive(Debug, Clone)] +pub struct SpendCondition { + pub p: Vec, } -impl NounEncode for LockMerkleProofStub { - fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { - let spend_condition = self.spend_condition.to_noun(alloc); - let axis = self.axis.to_noun(alloc); - let merkle_proof = self.merkle_proof.to_noun(alloc); - nockvm::noun::T(alloc, &[spend_condition, axis, merkle_proof]) - } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequiredPkhPolicy { + pub threshold: usize, + pub hashes: Vec, } -impl NounEncode for LockMerkleProofFull { - fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { - let version = self.version.to_noun(alloc); - let spend_condition = self.spend_condition.to_noun(alloc); - let axis = self.axis.to_noun(alloc); - let merkle_proof = self.merkle_proof.to_noun(alloc); - nockvm::noun::T(alloc, &[version, spend_condition, axis, merkle_proof]) +impl RequiredPkhPolicy { + pub fn contains(&self, hash: &Hash) -> bool { + self.hashes.contains(hash) } } -impl NounEncode for LockMerkleProof { - fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { - match self { - Self::Full(proof) => proof.to_noun(alloc), - Self::Stub(proof) => proof.to_noun(alloc), - } +impl SpendCondition { + pub fn required_pkh_policy(&self) -> Option { + self.p.iter().find_map(|primitive| match &primitive.body { + LockPrimitiveBody::Pkh(pkh) => Some(RequiredPkhPolicy { + threshold: usize::try_from(pkh.m).ok()?, + hashes: pkh.h.tap(), + }), + _ => None, + }) } -} -#[derive(Debug, Clone)] -pub struct SpendCondition { - pub p: Vec, -} - -impl SpendCondition { pub fn to_hashable(&self) -> Hashable { // From Hoon (tx-engine-1.hoon lines 1145-1150): // ```hoon @@ -1192,26 +704,7 @@ impl SpendCondition { } } -impl NounDecode for SpendCondition { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - Ok(Self { - p: decode_hoon_list(noun, space, "spend-condition", |item| { - LockPrimitive::from_noun(item, space) - })?, - }) - } -} - -impl NounEncode for SpendCondition { - fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { - self.p.iter().rev().fold(D(0), |acc, primitive| { - let head = primitive.to_noun(alloc); - nockvm::noun::T(alloc, &[head, acc]) - }) - } -} - -#[derive(Debug, Clone, NounDecode)] +#[derive(Debug, Clone)] pub enum LockPrimitiveBody { Pkh(Pkh), Tim(Tim), @@ -1219,7 +712,7 @@ pub enum LockPrimitiveBody { Brn(Brn), } -#[derive(Debug, Clone, NounDecode, NounEncode)] +#[derive(Debug, Clone)] pub struct Pkh { pub m: u64, pub h: ZSet, @@ -1243,7 +736,7 @@ impl Pkh { } } -#[derive(Debug, Clone, NounDecode, NounEncode)] +#[derive(Debug, Clone)] pub struct Tim { // lockscript timelock pub rel: TimelockRange, @@ -1267,7 +760,7 @@ impl Tim { } } -#[derive(Debug, Clone, NounDecode, NounEncode)] +#[derive(Debug, Clone)] pub struct Hax { pub set: ZSet, } @@ -1290,7 +783,7 @@ impl Hax { } } -#[derive(Debug, Clone, NounDecode, NounEncode)] +#[derive(Debug, Clone)] pub struct Brn { pub value: u64, // this will always be 0 } @@ -1313,43 +806,6 @@ pub struct LockPrimitive { pub body: LockPrimitiveBody, } -impl NounDecode for LockPrimitive { - fn from_noun( - noun: &nockvm::noun::Noun, - space: &nockvm::noun::NounSpace, - ) -> Result { - let cell = noun - .in_space(space) - .as_cell() - .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; - let head = cell - .head() - .as_atom() - .map_err(|_| noun_serde::NounDecodeError::ExpectedAtom)? - .into_string()?; - let body = cell.tail().noun(); - match head.as_str() { - "pkh" => Ok(LockPrimitive { - header: head, - body: LockPrimitiveBody::Pkh(Pkh::from_noun(&body, space)?), - }), - "tim" => Ok(LockPrimitive { - header: head, - body: LockPrimitiveBody::Tim(Tim::from_noun(&body, space)?), - }), - "hax" => Ok(LockPrimitive { - header: head, - body: LockPrimitiveBody::Hax(Hax::from_noun(&body, space)?), - }), - "brn" => Ok(LockPrimitive { - header: head, - body: LockPrimitiveBody::Brn(Brn::from_noun(&body, space)?), - }), - _ => Err(noun_serde::NounDecodeError::InvalidEnumVariant), - } - } -} - impl LockPrimitive { pub fn to_hashable(&self) -> Hashable { // From Hoon (tx-engine-1.hoon lines 1102-1110): @@ -1373,23 +829,6 @@ impl LockPrimitive { } } -impl NounEncode for LockPrimitive { - fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { - use nockapp::utils::make_tas; - use nockvm::noun::T; - - let header_atom = make_tas(alloc, &self.header); - let body_noun = match &self.body { - LockPrimitiveBody::Pkh(pkh) => pkh.to_noun(alloc), - LockPrimitiveBody::Tim(tim) => tim.to_noun(alloc), - LockPrimitiveBody::Hax(hax) => hax.to_noun(alloc), - LockPrimitiveBody::Brn(brn) => brn.to_noun(alloc), - }; - - T(alloc, &[header_atom.as_noun(), body_noun]) - } -} - #[derive(Debug, Clone)] pub struct MerkleProof { pub root: Hash, @@ -1431,32 +870,7 @@ impl MerkleProof { } } -impl NounDecode for MerkleProof { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - let cell = noun - .in_space(space) - .as_cell() - .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; - let root = Hash::from_noun(&cell.head().noun(), space)?; - let path = decode_hoon_list(&cell.tail().noun(), space, "merkle proof path", |item| { - Hash::from_noun(item, space) - })?; - Ok(Self { root, path }) - } -} - -impl NounEncode for MerkleProof { - fn to_noun(&self, alloc: &mut A) -> nockvm::noun::Noun { - let root = self.root.to_noun(alloc); - let path = self.path.iter().rev().fold(D(0), |acc, hash| { - let head = hash.to_noun(alloc); - nockvm::noun::T(alloc, &[head, acc]) - }); - nockvm::noun::T(alloc, &[root, path]) - } -} - -#[derive(Debug, Clone, NounDecode, NounEncode)] +#[derive(Debug, Clone)] pub struct PkhSignature { pub map: ZMap, } @@ -1492,7 +906,7 @@ impl PkhSignature { } } -#[derive(Debug, Clone, NounDecode, NounEncode)] +#[derive(Debug, Clone)] pub struct PkhSignatureValue { pub pk: SchnorrPubkey, pub sig: SchnorrSignature, @@ -1519,11 +933,14 @@ impl PkhSignatureValue { } } -#[cfg(test)] +#[cfg(all(test, feature = "std"))] mod tests { use super::*; + use nockapp::noun::slab::NounSlab; + use nockapp::utils::make_tas; + use nockvm::noun::NounAllocator; use nockvm::noun::{D, T}; - use noun_serde::NounEncode; + use noun_serde::{NounDecode, NounEncode}; fn sample_primitive() -> LockPrimitive { LockPrimitive { @@ -1569,7 +986,7 @@ mod tests { match decoded { LockMerkleProof::Full(full) => { - assert_eq!(full.version, nockvm_macros::tas!(b"full")); + assert_eq!(full.version, u64::from_le_bytes(*b"full\0\0\0\0")); assert_eq!(full.axis, 7); assert_eq!(full.spend_condition.p.len(), 1); match &full.spend_condition.p[0].body { @@ -1596,7 +1013,7 @@ mod tests { match decoded { LockMerkleProof::Full(full) => { - assert_eq!(full.version, nockvm_macros::tas!(b"full")); + assert_eq!(full.version, u64::from_le_bytes(*b"full\0\0\0\0")); assert_eq!(full.axis, 7); assert_eq!(full.spend_condition.p.len(), 1); assert_eq!(full.merkle_proof.root.values, [1, 2, 3, 4, 5]); @@ -1640,7 +1057,7 @@ mod tests { let err = SpendCondition::from_noun(&malformed_list, &space) .expect_err("spend-condition decode should reject improper list"); - assert!(err.to_string().contains("spend-condition must be a list")); + assert!(err.to_string().contains("expected cell")); } #[test] @@ -1656,14 +1073,17 @@ mod tests { let err = MerkleProof::from_noun(&malformed, &space) .expect_err("merkle proof path should reject atom"); - assert!(err.to_string().contains("merkle proof path must be a list")); + assert!(err.to_string().contains("expected cell")); } } -#[cfg(test)] +#[cfg(all(test, feature = "std"))] mod hardening_tests { use super::*; - use nockapp::AtomExt; + use nockapp::noun::slab::NounSlab; + use nockapp::{AtomExt, Bytes}; + use nockvm::noun::NounAllocator; use nockvm::noun::{D, T}; + use noun_serde::{NounDecode, NounEncode}; fn unbased_note_data() -> NoteData { let mut slab: NounSlab = NounSlab::new(); @@ -1710,15 +1130,15 @@ mod hardening_tests { }, seeds: SeedsV1 { set: { - let mut set = ZSet::new(); - set.put(SeedV1 { + let seed = SeedV1 { output_source: None, lock_root: Hash { values: [1; 5] }, note_data: unbased_note_data(), gift: Coins { value: 1 }, parent_hash: Hash { values: [2; 5] }, - }); - set + }; + let jam = pokenoun::codec::Encode::to_jam(&(seed, (0u64, 0u64))).unwrap(); + as pokenoun::codec::Decode>::from_jam(&jam).unwrap() }, }, fee: Coins { value: 1 }, @@ -1763,3 +1183,169 @@ mod hardening_tests { assert!(err.to_string().contains("tim must be 0")); } } + +crate::noun_codec::noun_struct!( + NNoteV1 { + version, + origin_page, + name, + note_data, + assets + }, + NoteData { map }, + SeedV1 { + output_source, + lock_root, + note_data, + gift, + parent_hash + }, + SeedsV1 { set }, + TxV1 { + version, + raw_tx, + total_size, + outputs + }, + OutputsV1 { set }, + OutputV1 { note, seeds }, + RawTransactionV1 { + version, + id, + spends + }, + SpendsV1 { map }, + InputsV1 { map }, + InputV1 { note, spend }, + SpendV1 { + witness, + seeds, + fee + }, + SpendV0ToV1 { + signature, + seeds, + fee + }, + LockMerkleProofStub { + spend_condition, + axis, + merkle_proof + }, + LockMerkleProofFull { + version, + spend_condition, + axis, + merkle_proof + }, + SpendCondition { p }, + Pkh { m, h }, + Tim { rel, abs }, + Hax { set }, + MerkleProof { root, path }, + PkhSignature { map }, + PkhSignatureValue { pk, sig } +); + +impl Encode for LockPrimitive { + fn encode(&self, arena: &mut Arena) -> Result { + let (tag, body) = match &self.body { + LockPrimitiveBody::Pkh(value) => ("pkh", value.encode(arena)?), + LockPrimitiveBody::Tim(value) => ("tim", value.encode(arena)?), + LockPrimitiveBody::Hax(value) => ("hax", value.encode(arena)?), + LockPrimitiveBody::Brn(value) => ("brn", value.encode(arena)?), + }; + if self.header != tag { + return Err(Error("lock primitive header disagrees with body")); + } + let tag = tag.encode(arena)?; + Ok(arena.alloc_cell(tag, body)) + } +} +impl Decode for LockPrimitive { + fn decode(noun: Noun, arena: &Arena) -> Result { + let (tag, body) = pair(noun, arena)?; + let header = String::decode(tag, arena)?; + let body = match header.as_str() { + "pkh" => LockPrimitiveBody::Pkh(Pkh::decode(body, arena)?), + "tim" => LockPrimitiveBody::Tim(Tim::decode(body, arena)?), + "hax" => LockPrimitiveBody::Hax(Hax::decode(body, arena)?), + "brn" => LockPrimitiveBody::Brn(Brn::decode(body, arena)?), + _ => return Err(Error("invalid lock primitive tag")), + }; + Ok(Self { header, body }) + } +} +impl Encode for Brn { + fn encode(&self, arena: &mut Arena) -> Result { + if self.value != 0 { + return Err(Error("burn payload must be zero")); + } + Ok(arena.atom0()) + } +} +impl Decode for Brn { + fn decode(noun: Noun, arena: &Arena) -> Result { + if !is_null(noun, arena) { + return Err(Error("burn payload must be zero")); + } + Ok(Self { value: 0 }) + } +} +impl Encode for LockMerkleProof { + fn encode(&self, arena: &mut Arena) -> Result { + match self { + Self::Stub(proof) => proof.encode(arena), + Self::Full(proof) => { + if proof.version != u64::from_le_bytes(*b"full\0\0\0\0") { + return Err(Error("full proof tag must be %full")); + } + proof.encode(arena) + } + } + } +} +impl Decode for LockMerkleProof { + fn decode(noun: Noun, arena: &Arena) -> Result { + let (head, _) = pair(noun, arena)?; + if let Noun::Atom(id) = head { + if arena.atom_bytes_trimmed(id) == b"full" { + return LockMerkleProofFull::decode(noun, arena).map(Self::Full); + } + if !is_null(head, arena) { + return Err(Error("invalid lock proof version")); + } + } + LockMerkleProofStub::decode(noun, arena).map(Self::Stub) + } +} +impl Encode for Witness { + fn encode(&self, arena: &mut Arena) -> Result { + if self.tim != 0 { + return Err(Error("witness tim must be 0 (~)")); + } + let fields = [ + self.lmp.encode(arena)?, + self.pkh.encode(arena)?, + self.hax.encode(arena)?, + self.tim.encode(arena)?, + ]; + pokenoun::codec::tuple(arena, &fields) + } +} +impl Decode for Witness { + fn decode(noun: Noun, arena: &Arena) -> Result { + let mut fields = Fields::new(noun, 4); + let value = Self { + lmp: Decode::decode(fields.next(arena)?, arena)?, + pkh: Decode::decode(fields.next(arena)?, arena)?, + hax: Decode::decode(fields.next(arena)?, arena)?, + tim: Decode::decode(fields.next(arena)?, arena)?, + }; + if value.tim != 0 { + return Err(Error("witness tim must be 0 (~)")); + } + Ok(value) + } +} +crate::noun_codec::runtime_codec!(LockPrimitive, Brn, LockMerkleProof, Witness); diff --git a/tx-types/src/tx_builder.rs b/tx-types/src/tx_builder.rs new file mode 100644 index 0000000..4e0fc59 --- /dev/null +++ b/tx-types/src/tx_builder.rs @@ -0,0 +1,91 @@ +//! Software signing adapter for the shared planner. + +use crate::lock_resolver::LockMatcher; +use crate::planner::{plan, PlanRequest, PlanResult}; +use crate::signer::schnorr_sign_digest; +use crate::{ + Chal, PkhSignatureValue, SchnorrPubkey, SchnorrSignature, Sig, Spend, SpendBody, SpendV0ToV1, + SpendV1, ZMap, T8, +}; + +/// Plan and sign using caller-supplied chain and lock policies. +pub fn build_spends( + request: PlanRequest<'_>, + matcher: &impl LockMatcher, + secret: T8, + public_key: SchnorrPubkey, +) -> Result { + let mut result = plan(request, matcher).map_err(|error| error.to_string())?; + if result.final_fee < result.minimum_fee { + return Err("fee is below the signed transaction minimum".into()); + } + let mut signed = ZMap::new(); + for (name, spend) in result.spends.map.tap() { + let spend = match spend.body { + SpendBody::V1(body) => sign_spend_v1(body, secret.clone(), public_key.clone())?, + SpendBody::V0ToV1(body) => { + sign_spend_v0_to_v1(body, secret.clone(), public_key.clone())? + } + SpendBody::V0(_) => return Err("planner emits V1 transaction spends".into()), + }; + signed.put(name, spend); + } + result.spends.map = signed; + Ok(result) +} + +pub fn sign_spend_v1( + mut body: SpendV1, + secret: T8, + public_key: SchnorrPubkey, +) -> Result { + let pkh = public_key.to_hash(); + let policy = body.witness.lmp.spend_condition().required_pkh_policy(); + let should_sign = policy.is_some_and(|policy| { + policy.hashes.contains(&pkh) + && !body.witness.pkh.map.has(&pkh) + && body.witness.pkh.map.wyt() < policy.threshold + }); + if !should_sign { + return Ok(Spend { + version: 1, + body: SpendBody::V1(body), + }); + } + let digest = body.compute_sig_hash().map_err(|error| error.to_string())?; + let (chal, sig) = schnorr_sign_digest(secret, public_key.clone(), digest); + body.witness.pkh.map.put( + pkh, + PkhSignatureValue { + pk: public_key, + sig: SchnorrSignature { + chal: Chal { values: chal }, + sig: Sig { values: sig }, + }, + }, + ); + Ok(Spend { + version: 1, + body: SpendBody::V1(body), + }) +} + +pub fn sign_spend_v0_to_v1( + mut body: SpendV0ToV1, + secret: T8, + public_key: SchnorrPubkey, +) -> Result { + let digest = body.compute_sig_hash().map_err(|error| error.to_string())?; + let (chal, sig) = schnorr_sign_digest(secret, public_key.clone(), digest); + body.signature.put( + public_key, + SchnorrSignature { + chal: Chal { values: chal }, + sig: Sig { values: sig }, + }, + ); + Ok(Spend { + version: 0, + body: SpendBody::V0ToV1(body), + }) +} diff --git a/tx-types/src/tx_builder_batch.rs b/tx-types/src/tx_builder_batch.rs deleted file mode 100644 index 4ffaf51..0000000 --- a/tx-types/src/tx_builder_batch.rs +++ /dev/null @@ -1,835 +0,0 @@ -//! Batch Transaction Builder for Multiple Recipients -//! -//! This module provides functionality to build transactions that send to multiple recipients -//! in a single transaction. It processes orders sequentially, consuming only the notes needed -//! to fulfill all orders plus the transaction fee. -//! -//! # Key Features -//! - Multiple (recipient, amount) pairs in one transaction -//! - Sequential filling: orders processed in order -//! - Efficient: only consumes notes actually needed -//! - Supports both V0 and V1 notes (but not mixed) - -extern crate alloc; -use alloc::string::ToString; -use alloc::vec::Vec; - -use crate::block_types::build_lock_merkle_proof; -use crate::collections::{ZMap, ZSet}; -use crate::hashing::hasher::hash_hashable; -use crate::transaction_types::*; -use crate::transaction_types_v0::*; -use crate::transaction_types_v1::*; -use crate::tx_builder_v1::{ - lock_data_to_untyped_noun, sign_spend_v0_to_v1, sign_spend_v1, LockData, -}; - -/// Batch order structure representing a payment to a recipient -#[derive(Debug, Clone)] -pub struct BatchOrder { - /// Recipient's pubkey hash - pub recipient: Hash, - /// Amount to send to recipient - pub amount: Coins, -} - -/// Build batch spends for multiple recipients from a list of notes -/// -/// This function creates a transaction that sends to multiple recipients by -/// sequentially consuming notes until all orders and the fee are satisfied. -/// Notes that aren't needed are not included in the transaction. -/// -/// # Arguments -/// * `notes` - List of notes to potentially spend (sorted by value descending recommended) -/// * `orders` - List of (recipient, amount) pairs to fulfill -/// * `fee` - Total transaction fee -/// * `sign_key` - Secret key for signing (T8 format) -/// * `pubkey` - Public key corresponding to sign_key -/// * `refund_pkh` - Optional pubkey hash for refund outputs -/// -/// # Returns -/// A ZMap of note names to signed spends (only for notes actually consumed) -/// -/// # Errors -/// - If any order has amount <= 0 -/// - If notes are mixed versions (some V0, some V1) -/// - If insufficient funds to cover all orders + fee -/// - If V0 notes are used without specifying refund_pkh -/// -/// # Algorithm -/// 1. Validate all orders have positive amounts -/// 2. Check all notes are same version -/// 3. Calculate total needed: sum(orders) + fee -/// 4. Process notes sequentially, distributing to orders until all fulfilled -/// 5. Only create spends for notes actually consumed -pub fn build_batch_spends( - notes: Vec, - orders: Vec, - fee: Coins, - sign_key: T8, - pubkey: SchnorrPubkey, - refund_pkh: Option, -) -> Result, String> { - // Validate orders - if orders.is_empty() { - return Err("Must have at least one order".to_string()); - } - - for order in &orders { - if order.amount.value == 0 { - return Err("Cannot create an order with zero amount".to_string()); - } - } - - // Check if all notes are V0 - let all_v0 = notes.iter().all(|note| matches!(note, NNote::V0(_))); - - if all_v0 { - // Verify refund_pkh is provided for V0 notes - if refund_pkh.is_none() { - return Err("Need to specify a refund address if spending from v0 notes".to_string()); - } - - // Extract V0 notes - let mut v0_notes: Vec = Vec::new(); - for note in notes { - match note { - NNote::V0(v0_note) => v0_notes.push(v0_note), - _ => return Err("Mixed note versions detected".to_string()), - } - } - - // Sort by assets descending - v0_notes.sort_by(|a, b| b.assets.value.cmp(&a.assets.value)); - - create_batch_spends_0(v0_notes, orders, fee, sign_key, pubkey, refund_pkh.unwrap()) - } else { - // Check if all notes are V1 - let all_v1 = notes.iter().all(|note| matches!(note, NNote::V1(_))); - - if all_v1 { - // Extract V1 notes - let mut v1_notes: Vec = Vec::new(); - for note in notes { - match note { - NNote::V1(v1_note) => v1_notes.push(v1_note), - _ => return Err("Mixed note versions detected".to_string()), - } - } - - // Sort by assets descending - v1_notes.sort_by(|a, b| b.assets.value.cmp(&a.assets.value)); - - create_batch_spends_1(v1_notes, orders, fee, sign_key, pubkey, refund_pkh) - } else { - return Err("Notes must all be the same version".to_string()); - } - } -} - -/// Create batch spends for V0 notes -fn create_batch_spends_0( - notes: Vec, - orders: Vec, - fee: Coins, - sign_key: T8, - pubkey: SchnorrPubkey, - refund_pkh: Hash, -) -> Result, String> { - // Calculate total needed - let total_orders: u64 = orders.iter().map(|o| o.amount.value).sum(); - let _total_needed = total_orders + fee.value; - - // Track remaining amounts - let mut remaining_orders: Vec<(Hash, u64)> = orders - .iter() - .map(|o| (o.recipient.clone(), o.amount.value)) - .collect(); - let mut remaining_fee = fee.value; - let mut spends = ZMap::new(); - - // Process notes sequentially - for note in notes { - // Check if we still need funds - let still_need_orders: u64 = remaining_orders.iter().map(|(_, amt)| *amt).sum(); - if still_need_orders == 0 && remaining_fee == 0 { - // All orders and fee satisfied, stop processing notes - break; - } - - let available = note.assets.value; - let mut consumed = 0u64; - - // Build seeds for this note - let mut seeds_list = Vec::new(); - - // Fill orders sequentially - let mut updated_orders = Vec::new(); - for (recipient, remaining) in remaining_orders { - if remaining == 0 { - continue; // Order already filled - } - - let to_allocate = core::cmp::min(remaining, available - consumed); - if to_allocate > 0 { - // Create output lock for recipient - let mut recipient_set = ZSet::new(); - recipient_set.put(recipient.clone()); - let output_lock = SpendCondition { - p: vec![LockPrimitive { - header: "pkh".to_string(), - body: LockPrimitiveBody::Pkh(Pkh { - m: 1, - h: recipient_set, - }), - }], - }; - - // Build note-data - let lock_data = LockData::V0(output_lock.clone()); - let mut note_data_map = ZMap::new(); - let lock_data_noun = lock_data_to_untyped_noun(&lock_data); - note_data_map.put("lock".to_string(), lock_data_noun); - let note_data = NoteData { map: note_data_map }; - - // Create seed - let seed = SeedV1 { - output_source: None, - lock_root: lock_hash(&output_lock), - note_data, - gift: Coins { value: to_allocate }, - parent_hash: note_hash_v0(¬e), - }; - - seeds_list.push(seed); - consumed += to_allocate; - updated_orders.push((recipient, remaining - to_allocate)); - } else { - updated_orders.push((recipient, remaining)); - } - } - remaining_orders = updated_orders; - - // Handle fee - if remaining_fee > 0 { - let to_fee = core::cmp::min(remaining_fee, available - consumed); - consumed += to_fee; - remaining_fee -= to_fee; - } - - // Handle refund if any leftover - if consumed < available { - let refund_amount = available - consumed; - - // Create refund seed - let mut refund_set = ZSet::new(); - refund_set.put(refund_pkh.clone()); - let refund_lock = SpendCondition { - p: vec![LockPrimitive { - header: "pkh".to_string(), - body: LockPrimitiveBody::Pkh(Pkh { - m: 1, - h: refund_set, - }), - }], - }; - - let lock_data = LockData::V0(refund_lock.clone()); - let mut note_data_map = ZMap::new(); - let lock_data_noun = lock_data_to_untyped_noun(&lock_data); - note_data_map.put("lock".to_string(), lock_data_noun); - let note_data = NoteData { map: note_data_map }; - - let refund_seed = SeedV1 { - output_source: None, - lock_root: lock_hash(&refund_lock), - note_data, - gift: Coins { - value: refund_amount, - }, - parent_hash: note_hash_v0(¬e), - }; - - seeds_list.push(refund_seed); - } - - // Only create spend if we actually consumed from this note - if consumed > 0 { - // Create seeds set - let mut seeds_set = ZSet::new(); - for seed in seeds_list { - seeds_set.put(seed); - } - let seeds = SeedsV1 { set: seeds_set }; - - // Calculate fee portion for this note - let fee_portion = fee.value - remaining_fee; - - // Create V0ToV1 spend - let spend_body = SpendV0ToV1 { - signature: ZMap::new(), // Will be filled by signing - seeds, - fee: Coins { value: fee_portion }, - }; - - // Sign the spend - let signed_spend = sign_spend_v0_to_v1(spend_body, sign_key.clone(), pubkey.clone())?; - - spends.put(note.name.clone(), signed_spend); - } - } - - // Check if we satisfied all requirements - let still_need_orders: u64 = remaining_orders.iter().map(|(_, amt)| *amt).sum(); - if still_need_orders > 0 || remaining_fee > 0 { - return Err(format!( - "Insufficient funds. Still need: {} for orders, {} for fee", - still_need_orders, remaining_fee - )); - } - - Ok(spends) -} - -/// Create batch spends for V1 notes -fn create_batch_spends_1( - notes: Vec, - orders: Vec, - fee: Coins, - sign_key: T8, - pubkey: SchnorrPubkey, - refund_pkh: Option, -) -> Result, String> { - // Calculate total needed - let total_orders: u64 = orders.iter().map(|o| o.amount.value).sum(); - let _total_needed = total_orders + fee.value; - - // Calculate our pkh for default refunds - let our_pkh = pubkey.to_hash(); - let refund_hash = refund_pkh.unwrap_or(our_pkh.clone()); - - // Track remaining amounts - let mut remaining_orders: Vec<(Hash, u64)> = orders - .iter() - .map(|o| (o.recipient.clone(), o.amount.value)) - .collect(); - let mut remaining_fee = fee.value; - let mut spends = ZMap::new(); - - // Process notes sequentially - for note in notes { - // Check if we still need funds - let still_need_orders: u64 = remaining_orders.iter().map(|(_, amt)| *amt).sum(); - if still_need_orders == 0 && remaining_fee == 0 { - // All orders and fee satisfied, stop processing notes - break; - } - - let available = note.assets.value; - let mut consumed = 0u64; - - // Build seeds for this note - let mut seeds_list = Vec::new(); - - // Fill orders sequentially - let mut updated_orders = Vec::new(); - for (recipient, remaining) in remaining_orders { - if remaining == 0 { - continue; // Order already filled - } - - let to_allocate = core::cmp::min(remaining, available - consumed); - if to_allocate > 0 { - // Create output lock for recipient - let mut recipient_set = ZSet::new(); - recipient_set.put(recipient.clone()); - let output_lock = SpendCondition { - p: vec![LockPrimitive { - header: "pkh".to_string(), - body: LockPrimitiveBody::Pkh(Pkh { - m: 1, - h: recipient_set, - }), - }], - }; - - // Build note-data - let lock_data = LockData::V0(output_lock.clone()); - let mut note_data_map = ZMap::new(); - let lock_data_noun = lock_data_to_untyped_noun(&lock_data); - note_data_map.put("lock".to_string(), lock_data_noun); - let note_data = NoteData { map: note_data_map }; - - // Create seed - let seed = SeedV1 { - output_source: None, - lock_root: lock_hash(&output_lock), - note_data, - gift: Coins { value: to_allocate }, - parent_hash: note_hash_v1(¬e), - }; - - seeds_list.push(seed); - consumed += to_allocate; - updated_orders.push((recipient, remaining - to_allocate)); - } else { - updated_orders.push((recipient, remaining)); - } - } - remaining_orders = updated_orders; - - // Handle fee - if remaining_fee > 0 { - let to_fee = core::cmp::min(remaining_fee, available - consumed); - consumed += to_fee; - remaining_fee -= to_fee; - } - - // Handle refund if any leftover - if consumed < available { - let refund_amount = available - consumed; - - // Create refund seed - let mut refund_set = ZSet::new(); - refund_set.put(refund_hash.clone()); - let refund_lock = SpendCondition { - p: vec![LockPrimitive { - header: "pkh".to_string(), - body: LockPrimitiveBody::Pkh(Pkh { - m: 1, - h: refund_set, - }), - }], - }; - - let lock_data = LockData::V0(refund_lock.clone()); - let mut note_data_map = ZMap::new(); - let lock_data_noun = lock_data_to_untyped_noun(&lock_data); - note_data_map.put("lock".to_string(), lock_data_noun); - let note_data = NoteData { map: note_data_map }; - - let refund_seed = SeedV1 { - output_source: None, - lock_root: lock_hash(&refund_lock), - note_data, - gift: Coins { - value: refund_amount, - }, - parent_hash: note_hash_v1(¬e), - }; - - seeds_list.push(refund_seed); - } - - // Only create spend if we actually consumed from this note - if consumed > 0 { - // === Extract and validate lock from note-data === - // Similar to create_spends_1 in tx_builder_v1.rs - - // Get note-data from the note - let nd = ¬e.note_data; - - // Build the simple PKH lock for our pubkey - let simple_pkh = LockPrimitive { - header: "pkh".to_string(), - body: LockPrimitiveBody::Pkh(Pkh { - m: 1, - h: { - let mut set = ZSet::new(); - set.put(our_pkh.clone()); - set - }, - }), - }; - - // Build the coinbase lock (used as fallback) - let coinbase_lock = SpendCondition { - p: vec![simple_pkh.clone()], - }; - - // Try to extract lock from note-data, or use coinbase lock as default - let input_lock: Result = { - match nd.map.get(&"lock".to_string()) { - None => { - // No lock noun found, use coinbase lock - Ok(coinbase_lock) - } - Some(lock_noun) => { - // Found a lock noun in note-data, deserialize it - match lock_noun.to_typed::() { - Ok(lock_data) => { - // Successfully deserialized the lock data - let spend_condition = match lock_data { - LockData::V0(sc) => sc, - }; - - // Validate it's spendable: must be a single PKH with m=1 - if spend_condition.p.len() != 1 { - return Err( - "Lock has multiple primitives, unsupported".to_string() - ); - } - - let primitive = &spend_condition.p[0]; - if primitive.header != "pkh" { - return Err("Lock is not a PKH lock, unsupported".to_string()); - } - - match &primitive.body { - LockPrimitiveBody::Pkh(pkh_lock) => { - if pkh_lock.m != 1 { - return Err( - "Lock requires m != 1 signatures, unsupported" - .to_string(), - ); - } - - // Check if our public key hash is in the lock's hash set - if !pkh_lock.h.has(&our_pkh) { - return Err( - "Our PKH is not in the lock's hash set".to_string() - ); - } - - Ok(spend_condition) - } - _ => Err("Unsupported lock primitive type".to_string()), - } - } - Err(e) => { - Err(format!("Failed to deserialize lock from note-data: {}", e)) - } - } - } - } - }; - - // If input-lock validation failed, error out - let input_lock = match input_lock { - Ok(lock) => lock, - Err(reason) => { - return Err(format!("Error processing note: {}", reason)); - } - }; - - // Create seeds set - let mut seeds_set = ZSet::new(); - for seed in seeds_list { - seeds_set.put(seed); - } - let seeds = SeedsV1 { set: seeds_set }; - - // Calculate fee portion for this note - let fee_portion = fee.value - remaining_fee; - - // === Build lock merkle proof === - // Build a merkle proof showing that the spend-condition is in the lock tree - let lmp = - build_lock_merkle_proof(input_lock.clone(), 1, crate::fee_calculator::BYTHOS_PHASE); - - // Create witness with proper lock merkle proof - let witness = Witness { - lmp, - pkh: PkhSignature { map: ZMap::new() }, - hax: ZMap::new(), - tim: 0, - }; - - // Create V1 spend - let spend_body = SpendV1 { - seeds, - fee: Coins { value: fee_portion }, - witness, - }; - - // Sign the spend - let signed_spend = sign_spend_v1(spend_body, sign_key.clone(), pubkey.clone())?; - - spends.put(note.name.clone(), signed_spend); - } - } - - // Check if we satisfied all requirements - let still_need_orders: u64 = remaining_orders.iter().map(|(_, amt)| *amt).sum(); - if still_need_orders > 0 || remaining_fee > 0 { - return Err(format!( - "Insufficient funds. Still need: {} for orders, {} for fee", - still_need_orders, remaining_fee - )); - } - - Ok(spends) -} - -// Helper functions - -fn lock_hash(spend_condition: &SpendCondition) -> Hash { - let hashable = spend_condition.to_hashable(); - hash_hashable(&hashable).expect("spend-condition hashable is u64-based") -} - -fn note_hash_v0(note: &NNoteV0) -> Hash { - let hashable = note.to_hashable(); - hash_hashable(&hashable).expect("v0 note hashable is u64-based") -} - -fn note_hash_v1(note: &NNoteV1) -> Hash { - note.name.to_hash() -} - -pub fn build_simple_batch_spends( - mut notes: Vec, - orders: Vec, - fee: Coins, - refund_pkh: Hash, -) -> Result, String> { - // Validate orders - if orders.is_empty() { - return Err("Must have at least one order".to_string()); - } - - for order in &orders { - if order.amount.value == 0 { - return Err("Cannot create an order with zero amount".to_string()); - } - } - - // Sort by assets descending - notes.sort_by(|a, b| b.assets.value.cmp(&a.assets.value)); - create_unsigned_batch_spends_1(notes, orders, fee, refund_pkh) -} - -/// Create batch spends for V1 notes -fn create_unsigned_batch_spends_1( - notes: Vec, - orders: Vec, - fee: Coins, - refund_pkh: Hash, -) -> Result, String> { - // Calculate total needed - let total_orders: u64 = orders.iter().map(|o| o.amount.value).sum(); - let _total_needed = total_orders + fee.value; - - // Track remaining amounts - let mut remaining_orders: Vec<(Hash, u64)> = orders - .iter() - .map(|o| (o.recipient.clone(), o.amount.value)) - .collect(); - let mut remaining_fee = fee.value; - let mut spends = ZMap::new(); - - // Process notes sequentially - for note in notes { - // Check if we still need funds - let still_need_orders: u64 = remaining_orders.iter().map(|(_, amt)| *amt).sum(); - if still_need_orders == 0 && remaining_fee == 0 { - // All orders and fee satisfied, stop processing notes - break; - } - - let available = note.assets.value; - let mut consumed = 0u64; - - // Build seeds for this note - let mut seeds_list = Vec::new(); - - // Fill orders sequentially - let mut updated_orders = Vec::new(); - for (recipient, remaining) in remaining_orders { - if remaining == 0 { - continue; // Order already filled - } - - let to_allocate = core::cmp::min(remaining, available - consumed); - if to_allocate > 0 { - // Create output lock for recipient - let mut recipient_set = ZSet::new(); - recipient_set.put(recipient.clone()); - let output_lock = SpendCondition { - p: vec![LockPrimitive { - header: "pkh".to_string(), - body: LockPrimitiveBody::Pkh(Pkh { - m: 1, - h: recipient_set, - }), - }], - }; - - // Build note-data - let lock_data = LockData::V0(output_lock.clone()); - let mut note_data_map = ZMap::new(); - let lock_data_noun = lock_data_to_untyped_noun(&lock_data); - note_data_map.put("lock".to_string(), lock_data_noun); - let note_data = NoteData { map: note_data_map }; - - // Create seed - let seed = SeedV1 { - output_source: None, - lock_root: lock_hash(&output_lock), - note_data, - gift: Coins { value: to_allocate }, - parent_hash: note_hash_v1(¬e), - }; - - seeds_list.push(seed); - consumed += to_allocate; - updated_orders.push((recipient, remaining - to_allocate)); - } else { - updated_orders.push((recipient, remaining)); - } - } - remaining_orders = updated_orders; - - // Handle fee - if remaining_fee > 0 { - let to_fee = core::cmp::min(remaining_fee, available - consumed); - consumed += to_fee; - remaining_fee -= to_fee; - } - - // Handle refund if any leftover - if consumed < available { - let refund_amount = available - consumed; - - // Create refund seed - let mut refund_set = ZSet::new(); - refund_set.put(refund_pkh.clone()); - let refund_lock = SpendCondition { - p: vec![LockPrimitive { - header: "pkh".to_string(), - body: LockPrimitiveBody::Pkh(Pkh { - m: 1, - h: refund_set, - }), - }], - }; - - let lock_data = LockData::V0(refund_lock.clone()); - let mut note_data_map = ZMap::new(); - let lock_data_noun = lock_data_to_untyped_noun(&lock_data); - note_data_map.put("lock".to_string(), lock_data_noun); - let note_data = NoteData { map: note_data_map }; - - let refund_seed = SeedV1 { - output_source: None, - lock_root: lock_hash(&refund_lock), - note_data, - gift: Coins { - value: refund_amount, - }, - parent_hash: note_hash_v1(¬e), - }; - - seeds_list.push(refund_seed); - } - - // Only create spend if we actually consumed from this note - if consumed > 0 { - // === Extract lock from note-data === - // Note: This is the unsigned version, so we don't validate ownership - - // Get note-data from the note - let nd = ¬e.note_data; - - // Build the simple PKH lock for the refund_pkh - let simple_pkh = LockPrimitive { - header: "pkh".to_string(), - body: LockPrimitiveBody::Pkh(Pkh { - m: 1, - h: { - let mut set = ZSet::new(); - set.put(refund_pkh.clone()); - set - }, - }), - }; - - // Build the coinbase lock (used as fallback) - let coinbase_lock = SpendCondition { - p: vec![simple_pkh.clone()], - }; - - // Try to extract lock from note-data, or use coinbase lock as default - let input_lock: Result = { - match nd.map.get(&"lock".to_string()) { - None => { - // No lock noun found, use coinbase lock - Ok(coinbase_lock) - } - Some(lock_noun) => { - // Found a lock noun in note-data, deserialize it - match lock_noun.to_typed::() { - Ok(lock_data) => { - // Successfully deserialized the lock data - let spend_condition = match lock_data { - LockData::V0(sc) => sc, - }; - Ok(spend_condition) - } - Err(e) => { - Err(format!("Failed to deserialize lock from note-data: {}", e)) - } - } - } - } - }; - - // If input-lock extraction failed, error out - let input_lock = match input_lock { - Ok(lock) => lock, - Err(reason) => { - return Err(format!("Error processing note: {}", reason)); - } - }; - - // Create seeds set - let mut seeds_set = ZSet::new(); - for seed in seeds_list { - seeds_set.put(seed); - } - let seeds = SeedsV1 { set: seeds_set }; - - // Calculate fee portion for this note - let fee_portion = fee.value - remaining_fee; - - // === Build lock merkle proof === - // Build a merkle proof showing that the spend-condition is in the lock tree - let lmp = - build_lock_merkle_proof(input_lock.clone(), 1, crate::fee_calculator::BYTHOS_PHASE); - - // Create witness with proper lock merkle proof - let witness = Witness { - lmp, - pkh: PkhSignature { map: ZMap::new() }, - hax: ZMap::new(), - tim: 0, - }; - - // Create V1 spend - let spend_body = SpendV1 { - seeds, - fee: Coins { value: fee_portion }, - witness, - }; - - spends.put( - note.name.clone(), - Spend { - version: 1, - body: SpendBody::V1(spend_body), - }, - ); - } - } - - // Check if we satisfied all requirements - let still_need_orders: u64 = remaining_orders.iter().map(|(_, amt)| *amt).sum(); - if still_need_orders > 0 || remaining_fee > 0 { - return Err(format!( - "Insufficient funds. Still need: {} for orders, {} for fee", - still_need_orders, remaining_fee - )); - } - - Ok(spends) -} diff --git a/tx-types/src/tx_builder_v1.rs b/tx-types/src/tx_builder_v1.rs deleted file mode 100644 index a1d1e4c..0000000 --- a/tx-types/src/tx_builder_v1.rs +++ /dev/null @@ -1,1226 +0,0 @@ -//! Transaction Builder for V0 and V1 Transactions -//! -//! This module provides functionality to build transactions from a list of notes. -//! It implements the complete `tx-builder-v1.hoon` module including: -//! - Main dispatcher that handles both V0 and V1 notes -//! - `create_spends_0` for V0 notes -//! - `create_spends_1` for V1 notes -//! - Refund handling and fee validation -//! -//! The builder handles: -//! - Fan-in transactions (multiple inputs to single output) -//! - Automatic fee and gift distribution across notes -//! - Version detection and validation -//! - Lock validation and witness generation -//! - Refund handling -//! - Signing of spends with secret keys - -#![cfg_attr(not(test), no_std)] - -extern crate alloc; -use alloc::string::{String, ToString}; -use alloc::vec::Vec; - -use crate::block_types::build_lock_merkle_proof; -use crate::collections::{ZMap, ZSet}; -use crate::generic_noun::UntypedNoun; -use crate::hashing::hashable::Hashable; -use crate::hashing::hasher::hash_hashable; -use crate::hashing::tip5::Tip5Error; -use crate::signer::schnorr_sign_digest; -use crate::transaction_types::*; -use crate::transaction_types_v0::*; -use crate::transaction_types_v1::*; -use nockapp::noun::slab::NounSlab; -use nockvm::noun::{Noun, NounAllocator, NounSpace, D, T}; -use noun_serde::{NounDecode, NounDecodeError, NounEncode}; - -/// Order structure representing a payment to a recipient -/// Matches the Hoon type: +$ order [recipient=hash:transact gift=coins:transact] -#[derive(Debug, Clone)] -pub struct Order { - /// Recipient's pubkey hash - pub recipient: Hash, - /// Amount to send to recipient - pub gift: Coins, -} - -/// Lock data that gets embedded in note-data field of seeds -/// Matches the Hoon type: +$ lock-data $% [%0 =lock:transact] == -#[derive(Debug, Clone)] -pub enum LockData { - V0(SpendCondition), -} - -impl NounDecode for LockData { - fn from_noun(noun: &Noun, space: &NounSpace) -> Result { - // lock-data is a cell: [version lock:transact] - // For V0: [%0 =lock:transact] where lock is a spend-condition - let cell = match noun.in_space(space).as_cell() { - Ok(c) => c, - Err(_) => return Err(NounDecodeError::ExpectedCell), - }; - - let version = cell.head(); - let lock_noun = cell.tail(); - - // Check version tag - let version_atom = match version.as_atom() { - Ok(a) => a, - Err(_) => { - return Err(NounDecodeError::Custom( - "LockData version must be an atom".to_string(), - )) - } - }; - - let version_num = match version_atom.as_u64() { - Ok(v) => v, - Err(_) => { - return Err(NounDecodeError::Custom( - "LockData version too large".to_string(), - )) - } - }; - - if version_num != 0 { - return Err(NounDecodeError::Custom(format!( - "Unknown LockData version: {}", - version_num - ))); - } - - // Decode the spend-condition - let spend_condition = SpendCondition::from_noun(&lock_noun.noun(), space)?; - - Ok(LockData::V0(spend_condition)) - } -} - -/// Result type for operations that can fail with a reason -/// Matches Hoon's ++ reason |$ object (each object term) -pub type Reason = Result; - -/// Helper function to serialize LockData to UntypedNoun -/// -/// This serializes lock-data in the Hoon format: [%0 =lock:transact] -/// where lock is a spend-condition (list of lock-primitives). -/// -/// # Arguments -/// * `lock_data` - The LockData to serialize -/// -/// # Returns -/// An UntypedNoun containing the jammed lock-data -pub fn lock_data_to_untyped_noun(lock_data: &LockData) -> UntypedNoun { - let mut slab = NounSlab::new(); - - match lock_data { - LockData::V0(spend_condition) => { - // Create the version tag %0 - let version_tag = D(0); - - // Serialize the spend-condition (list of lock-primitives) - let lock_noun = spend_condition_to_noun(spend_condition, &mut slab); - - // Create the cell [%0 lock] - let lock_data_noun = T(&mut slab, &[version_tag, lock_noun]); - - // Copy the noun into the slab and jam it - let space = slab.noun_space(); - slab.copy_into(lock_data_noun, &space); - let jammed = slab.jam(); - - UntypedNoun { p: jammed } - } - } -} - -/// Helper function to serialize a SpendCondition to a Noun -/// -/// A spend-condition is a list of lock-primitives in Hoon. -/// This creates a proper list noun structure. -/// -/// # Arguments -/// * `spend_condition` - The SpendCondition to serialize -/// * `slab` - The NounSlab allocator to use -/// -/// # Returns -/// A Noun representing the spend-condition list -fn spend_condition_to_noun(spend_condition: &SpendCondition, slab: &mut NounSlab) -> Noun { - // Build a Hoon-style list by folding right-to-left - // List structure: [item1 [item2 [item3 [... ~]]]] - // where ~ is the null terminator (atom 0) - spend_condition.p.iter().rev().fold(D(0), |acc, primitive| { - let primitive_noun = lock_primitive_to_noun(primitive, slab); - T(slab, &[primitive_noun, acc]) - }) -} - -/// Helper function to serialize a LockPrimitive to a Noun -/// -/// A lock-primitive is a tagged union in Hoon: [header body] -/// Examples: [%pkh [m=1 h=(set hash)]], [%tim [...]], etc. -/// -/// # Arguments -/// * `primitive` - The LockPrimitive to serialize -/// * `slab` - The NounSlab allocator to use -/// -/// # Returns -/// A Noun representing the lock-primitive -fn lock_primitive_to_noun(primitive: &LockPrimitive, slab: &mut NounSlab) -> Noun { - use nockapp::utils::make_tas; - - // Create the header (tag) as a tas (term/symbol) - let header_noun = make_tas(slab, &primitive.header).as_noun(); - - // Serialize the body based on the type - let body_noun = match &primitive.body { - LockPrimitiveBody::Pkh(pkh) => { - // Serialize as [m h] where h is a z-set of hashes - let m_noun = D(pkh.m); - let h_noun = pkh.h.to_noun(slab); - T(slab, &[m_noun, h_noun]) - } - LockPrimitiveBody::Tim(tim) => { - // Serialize as [rel abs] where rel and abs are timelock ranges - let rel_noun = tim.rel.to_noun(slab); - let abs_noun = tim.abs.to_noun(slab); - T(slab, &[rel_noun, abs_noun]) - } - LockPrimitiveBody::Hax(hax) => { - // Serialize as a z-set of hashes - hax.set.to_noun(slab) - } - LockPrimitiveBody::Brn(brn) => { - // Serialize as just the value (which is always 0) - D(brn.value) - } - }; - - // Return [header body] - T(slab, &[header_noun, body_noun]) -} - -/// Main transaction builder function (dispatcher) -/// -/// This is the Rust implementation of the main gate in `tx-builder-v1.hoon` (lines 7-54). -/// It takes a list of notes, determines their version, and routes to the appropriate -/// builder function (V0 or V1). -/// -/// # Arguments -/// * `notes` - List of notes to spend -/// * `order` - Payment order specifying recipient and amount -/// * `fee` - Total transaction fee -/// * `sign_key` - Secret key for signing (T8 format) -/// * `pubkey` - Public key corresponding to sign_key -/// * `refund_pkh` - Optional pubkey hash for refund outputs (required for V0 notes) -/// -/// # Returns -/// A ZMap of note names to signed spends, or an error message -/// -/// # Errors -/// - If gift amount is <= 0 -/// - If notes are mixed versions (some V0, some V1) -/// - If fee is below minimum required fee -/// - If insufficient funds to cover gift and fee -/// - If V0 notes are used without specifying refund_pkh -/// -/// # Algorithm (matches Hoon lines 7-54) -/// 1. Validate gift amount is positive -/// 2. Check if all notes are V0 or all V1 (no mixing allowed) -/// 3. Route to appropriate builder (create_spends_0 or create_spends_1) -/// 4. Sort notes by assets (descending) for optimal distribution -/// 5. Validate minimum fee requirement (TODO) -/// 6. Return spends map -/// -/// # Example -/// ```ignore -/// use tx_types::{build_spends, Order, Coins}; -/// -/// let notes = vec![note1, note2, note3]; -/// let spends = build_spends( -/// notes, -/// Order { -/// recipient: recipient_pkh, -/// gift: Coins { value: 1000 }, -/// }, -/// Coins { value: 10 }, // fee -/// secret_key, -/// public_key, -/// Some(refund_address), // Required for V0 notes, optional for V1 -/// )?; -/// ``` -pub fn build_spends( - notes: Vec, - order: Order, - fee: Coins, - sign_key: T8, - pubkey: SchnorrPubkey, - refund_pkh: Option, -) -> Result, String> { - // Validate gift amount (lines 19-20) - // ?: (lte gift.order 0) - // ~|("Cannot create a transaction with zero gift" !!) - if order.gift.value == 0 { - return Err("Cannot create a transaction with zero gift".to_string()); - } - - // Build spends based on note version (lines 21-48) - let spends = { - // Check if all notes are V0 (lines 22-34) - // ?: (levy notes |=(=nnote:transact ?=(^ -.nnote))) - let all_v0 = notes.iter().all(|note| matches!(note, NNote::V0(_))); - - if all_v0 { - // Require refund_pkh for V0 notes (lines 23-24) - // ?~ refund-pkh - // ~|('Need to specify a refund address...' !!) - if refund_pkh.is_none() { - return Err( - "Need to specify a refund address if spending from v0 notes. \ - Use the `--refund-pkh` flag in the create-tx command" - .to_string(), - ); - } - - // Extract and sort V0 notes (lines 25-32) - // =/ notes=(list nnote:v0:transact) - // %+ turn notes - // |= =nnote:transact - // ?> ?=(^ -.nnote) - // nnote - let mut v0_notes: Vec = Vec::new(); - for note in notes { - match note { - NNote::V0(v0_note) => v0_notes.push(v0_note), - _ => return Err("Mixed note versions detected".to_string()), - } - } - - // Sort by assets descending (lines 33-35) - // =. notes - // %+ sort notes - // |= [a=nnote:v0:transact b=nnote:v0:transact] - // (gth assets.a assets.b) - v0_notes.sort_by(|a, b| b.assets.value.cmp(&a.assets.value)); - - // Call create_spends_0 (line 36) - // (create-spends-0 notes) - create_spends_0( - v0_notes, - order.clone(), - fee, - sign_key.clone(), - pubkey.clone(), - refund_pkh, - )? - } else { - // Check if all notes are V1 (lines 38-44) - // ?: (levy notes |=(=nnote:transact ?=(@ -.nnote))) - let all_v1 = notes.iter().all(|note| matches!(note, NNote::V1(_))); - - if all_v1 { - // Extract and sort V1 notes (lines 39-47) - let mut v1_notes: Vec = Vec::new(); - for note in notes { - match note { - NNote::V1(v1_note) => v1_notes.push(v1_note), - _ => return Err("Mixed note versions detected".to_string()), - } - } - - // Sort by assets descending - v1_notes.sort_by(|a, b| b.assets.value.cmp(&a.assets.value)); - - // Call create_spends_1 (line 48) - // (create-spends-1 notes) - create_spends_1(v1_notes, order.clone(), fee, sign_key, pubkey, refund_pkh)? - } else { - // Mixed versions - error (lines 49-52) - // ~> %slog.[0 'Notes must all be the same version!!!'] !! - return Err("Notes must all be the same version!!!".to_string()); - } - } - }; - - // TODO: Validate minimum fee (lines 53-54) - // =+ min-fee=(calculate-min-fee:spends:transact spends) - // ?: (lth fee min-fee) - // ~|("Min fee not met..." !!) - // This requires implementing calculate-min-fee function - - Ok(spends) -} - -/// Build V0 spends from a list of V0 notes -/// -/// This is the Rust implementation of `++ create-spends-0` from tx-builder-v1.hoon (lines 56-123). -/// It creates a fan-in transaction where multiple V0 notes are spent to a single recipient. -/// -/// # Arguments -/// * `notes` - List of V0 notes to spend (will be consumed) -/// * `order` - Payment order specifying recipient and amount -/// * `fee` - Total transaction fee -/// * `sign_key` - Secret key for signing (T8 format) -/// * `pubkey` - Public key corresponding to sign_key -/// * `refund_pkh` - Pubkey hash for refund outputs (required for V0) -/// -/// # Returns -/// A ZMap of note names to signed spends, or an error message -/// -/// # Algorithm (matches Hoon lines 56-123) -/// Similar to create_spends_1 but: -/// - Uses V0 note structure -/// - Validates via sig.note instead of lock primitives -/// - Creates Spend0 instead of Spend1 -pub fn create_spends_0( - notes: Vec, - order: Order, - fee: Coins, - sign_key: T8, - pubkey: SchnorrPubkey, - refund_pkh: Option, -) -> Result, String> { - // Clone refund_pkh at the start so it can be used multiple times in the loop - let refund_pkh_clone = refund_pkh.clone(); - // Initialize the output lock (lines 72-73) - // =/ output-lock=lock:transact - // [%pkh [m=1 (z-silt:zo ~[recipient.order])]]~ - let mut recipient_set = ZSet::new(); - recipient_set.put(order.recipient.clone()); - let pkh_primitive = LockPrimitive { - header: "pkh".to_string(), - body: LockPrimitiveBody::Pkh(Pkh { - m: 1, - h: recipient_set, - }), - }; - let output_lock = SpendCondition { - p: vec![pkh_primitive], - }; - - // Build note-data (lines 74-76) - // =/ =note-data:v1:transact - // %- ~(put z-by:zo *note-data:v1:transact) - // =/ =lock-data:wt [%0 output-lock] - // [%lock ^-(* lock-data)] - let lock_data = LockData::V0(output_lock.clone()); - let mut note_data_map = ZMap::new(); - let lock_data_noun = lock_data_to_untyped_noun(&lock_data); - note_data_map.put("lock".to_string(), lock_data_noun); - - // NOTE: Due to type mismatch (see comment in seed building below), - // this note_data is not currently used in V0 seeds - #[allow(unused_variables)] - let note_data = NoteData { map: note_data_map }; - - // Initialize accumulator state (lines 57-62, 70-71) - let mut spends = ZMap::new(); - let mut remaining_gift = order.gift.value; - let mut remaining_fee = fee.value; - - // Process each note (lines 70-122) - // %+ roll notes - for note in notes { - // === Validate note is spendable === (lines 77-80) - // ?. ?& =(1 m.sig.note) - // (~(has z-in:zo pubkeys.sig.note) pubkey) - // == - // ~> %slog.[0 'Note not spendable by signing key'] !! - - // V0 notes have a Lock field (not sig), so validate that instead - // Check if pubkey is in the lock's pubkeys set - let has_pubkey = note.lock.pubkeys.iter().any(|pk| pk == &pubkey); - if note.lock.m != 1 || !has_pubkey { - return Err(format!( - "Note {} not spendable by signing key", - note_name_to_string(¬e.name) - )); - } - - // === Calculate portions === (lines 81-91) - let gift_portion = if remaining_gift == 0 { - 0 - } else { - core::cmp::min(remaining_gift, note.assets.value) - }; - - let available_for_fee = note.assets.value - gift_portion; - let fee_portion = if remaining_fee == 0 { - 0 - } else { - core::cmp::min(remaining_fee, available_for_fee) - }; - - // Skip if no contribution (lines 88-89) - if gift_portion == 0 && fee_portion == 0 { - continue; - } - - // Update remaining amounts (lines 90-91) - let new_gift_remaining = remaining_gift - gift_portion; - let new_fee_remaining = remaining_fee - fee_portion; - - // Calculate refund (line 92) - let refund = note.assets.value - (gift_portion + fee_portion); - - // Skip if no gift and no refund (lines 93-95) - if gift_portion == 0 && refund == 0 { - continue; - } - - // === Build seeds === (lines 96-108) - // Create V1 seeds (with note-data) even though we're spending V0 notes - // because spend-0:v1:transact creates V1 outputs - let mut seed_list: Vec = Vec::new(); - - // Add gift seed (lines 99-106) - if gift_portion > 0 { - let lock_root = hash_hashable(&output_lock.to_hashable()).map_err(|e| e.to_string())?; - let parent_hash = note.to_hash(); - - let gift_seed = SeedV1 { - output_source: None, - lock_root, - note_data: note_data.clone(), - gift: Coins { - value: gift_portion, - }, - parent_hash, - }; - seed_list.push(gift_seed); - } - - // Add refund seed (lines 107-108) - if refund > 0 { - let refund_seed = - create_refund_v0_to_v1(¬e, refund, refund_pkh_clone.clone(), &pubkey); - seed_list.push(refund_seed); - } - - // Convert to ZSet (line 96: %- z-silt:zo) - let mut seeds_set = ZSet::new(); - for seed in seed_list { - seeds_set.put(seed); - } - let seeds = SeedsV1 { set: seeds_set }; - - // Error if no seeds (lines 109-110) - if seeds.set.is_empty() { - return Err("No seeds were provided".to_string()); - } - - // === Create spend === (lines 111-114) - // =/ spend=spend-0:v1:transact - // %* . *spend-0:v1:transact - // seeds seeds - // fee fee-portion - // == - // Note: spend-0 uses V1 seeds, not V0 seeds! - let spend_body = SpendV0ToV1 { - signature: ZMap::new(), // Will be filled by sign function - seeds, - fee: Coins { value: fee_portion }, - }; - - // === Sign the spend === (line 118) - // (sign:spend-v1:transact [%0 spend] sign-key) - let signed_spend = sign_spend_v0_to_v1(spend_body, sign_key.clone(), pubkey.clone())?; - - // === Add to spends map === (lines 117-118) - spends.put(note.name.clone(), signed_spend); - - // Update remaining amounts - remaining_gift = new_gift_remaining; - remaining_fee = new_fee_remaining; - } - - // === Final validation === (lines 58-62) - // ?. ?& =(0 gift.remaining) =(0 fee.remaining) == - // ~> %slog.[0 'Insufficient funds to pay fee and gift'] !! - if remaining_gift != 0 || remaining_fee != 0 { - return Err(format!( - "Insufficient funds to pay fee and gift. Still need: gift={}, fee={}", - remaining_gift, remaining_fee - )); - } - - Ok(spends) -} - -/// Build V1 spends from a list of V1 notes -/// -/// This is the Rust implementation of `++ create-spends-1` from tx-builder-v1.hoon. -/// It creates a fan-in transaction where multiple notes are spent to a single recipient, -/// with automatic distribution of gift and fee across the input notes. -/// -/// # Arguments -/// * `notes` - List of V1 notes to spend (will be consumed) -/// * `order` - Payment order specifying recipient and amount -/// * `fee` - Total transaction fee -/// * `sign_key` - Secret key for signing (T8 format) -/// * `pubkey` - Public key corresponding to sign_key -/// * `refund_pkh` - Optional pubkey hash for refund outputs (if None, uses pubkey's hash) -/// -/// # Returns -/// A ZMap of note names to signed spends, or an error message -/// -/// # Algorithm -/// The function processes notes in order, distributing the gift and fee across them: -/// 1. For each note, calculate how much of the gift and fee it can cover -/// 2. Create output seeds for the gift portion (to recipient) -/// 3. Create refund seed for any leftover amount (back to spender) -/// 4. Build lock merkle proof for the input note's lock -/// 5. Sign the spend with the secret key -/// 6. Continue until all gift and fee are covered, or error if insufficient funds -pub fn create_spends_1( - mut notes: Vec, - order: Order, - fee: Coins, - sign_key: T8, - pubkey: SchnorrPubkey, - refund_pkh: Option, -) -> Result, String> { - // Validate that gift is non-zero (line 19-20 in Hoon) - if order.gift.value == 0 { - return Err("Cannot create a transaction with zero gift".to_string()); - } - - // Sort notes by assets in descending order (lines 42-45 in Hoon) - // This processes larger notes first for efficiency - notes.sort_by(|a, b| b.assets.value.cmp(&a.assets.value)); - - // Initialize the output lock: simple 1-of-1 PKH lock with recipient (lines 131-132) - // [%pkh [m=1 (z-silt:zo ~[recipient.order])]]~ - let mut recipient_set = ZSet::new(); - recipient_set.put(order.recipient.clone()); - let pkh_primitive = LockPrimitive { - header: "pkh".to_string(), - body: LockPrimitiveBody::Pkh(Pkh { - m: 1, - h: recipient_set, - }), - }; - let output_lock = SpendCondition { - p: vec![pkh_primitive], - }; - - // Build note-data that will be attached to output seeds (lines 133-136) - // This stores the lock information in the note for future reference - let lock_data = LockData::V0(output_lock.clone()); - let mut note_data_map = ZMap::new(); - - // Convert lock_data to UntypedNoun - // In Hoon this is: [%lock ^-(* lock-data)] - // We serialize the lock_data as an untyped noun to store it - let lock_data_noun = lock_data_to_untyped_noun(&lock_data); - note_data_map.put("lock".to_string(), lock_data_noun); - let note_data = NoteData { map: note_data_map }; - - // Calculate our pubkey hash for lock validation (line 136) - let pkh = pubkey.to_hash(); - - // Initialize the accumulator state for the fold/roll operation (lines 125-130, 137-140) - let mut spends = ZMap::new(); - let mut remaining_gift = order.gift.value; - let mut remaining_fee = fee.value; - - // Process each note in sequence (lines 137-212: %+ roll notes) - // This is a left fold that accumulates spends and tracks remaining amounts - for note in notes { - // === Parse and validate note-data === (lines 143-145) - // Try to extract note-data from the note - // ?~ nd ~> %slog.[0 'error: note-data malformed in note!'] !! - let nd = ¬e.note_data; - - // === Build and validate input-lock === (lines 146-171) - // Create a simple PKH lock-primitive using our pubkey hash - // This will be used as the default if no lock is found in note-data - let simple_pkh = LockPrimitive { - header: "pkh".to_string(), - body: LockPrimitiveBody::Pkh(Pkh { - m: 1, - h: { - let mut set = ZSet::new(); - set.put(pkh.clone()); - set - }, - }), - }; - - // Build the coinbase lock (used as fallback) - // =/ coinbase-lock=spend-condition:transact ~[simple-pkh tim-lp:coinbase:transact] - // For simplicity, we'll just use the simple-pkh without timelock for now - let coinbase_lock = SpendCondition { - p: vec![simple_pkh.clone()], - }; - - // Try to extract lock from note-data, or use coinbase lock as default - // This complex logic validates that the lock is spendable by our key (lines 148-170) - let input_lock: Reason = { - // Look for 'lock' key in note-data map - match nd.map.get(&"lock".to_string()) { - None => { - // No lock noun found, use coinbase lock (line 150) - Ok(coinbase_lock) - } - Some(lock_noun) => { - // Found a lock noun in note-data, deserialize it - // The lock is stored as jammed bytes in the UntypedNoun - match lock_noun.to_typed::() { - Ok(lock_data) => { - // Successfully deserialized the lock data - let spend_condition = match lock_data { - LockData::V0(sc) => sc, - }; - - // Validate it's spendable: must be a single PKH with m=1 - // and our pkh must be in the set - if spend_condition.p.len() != 1 { - return Err("Lock has multiple primitives, unsupported".to_string()); - } - - let primitive = &spend_condition.p[0]; - if primitive.header != "pkh" { - return Err("Lock is not a PKH lock, unsupported".to_string()); - } - - match &primitive.body { - LockPrimitiveBody::Pkh(pkh_lock) => { - if pkh_lock.m != 1 { - return Err("Lock requires m != 1 signatures, unsupported" - .to_string()); - } - - // Check if our public key hash is in the lock's hash set - let our_pkh = pubkey.to_hash(); - if !pkh_lock.h.has(&our_pkh) { - return Err( - "Our PKH is not in the lock's hash set".to_string() - ); - } - - Ok(spend_condition) - } - _ => { - // Other lock primitive types (Tim, Hax, Brn) are not supported - Err("Unsupported lock primitive type".to_string()) - } - } - } - Err(e) => { - // Failed to deserialize lock, this shouldn't happen if the note was valid - Err(format!("Failed to deserialize lock from note-data: {}", e)) - } - } - } - } - }; - - // If input-lock validation failed, error out (lines 171-172) - let input_lock = match input_lock { - Ok(lock) => lock, - Err(reason) => { - return Err(format!( - "Error processing note {}: {}", - note_name_to_string(¬e.name), - reason - )); - } - }; - - // === Calculate portions === (lines 173-185) - // Determine how much of this note goes to gift, fee, and refund - - // Gift portion: take what's needed from gift.remaining, up to note's assets - // =/ gift-portion=@ - // ?: =(0 gift.remaining) 0 - // (min gift.remaining assets.note) - let gift_portion = if remaining_gift == 0 { - 0 - } else { - core::cmp::min(remaining_gift, note.assets.value) - }; - - // What's left after gift goes toward fee - // =/ available-for-fee=@ (sub assets.note gift-portion) - let available_for_fee = note.assets.value - gift_portion; - - // Fee portion: take what's needed from fee.remaining, up to available amount - // =/ fee-portion=@ - // ?: =(0 fee.remaining) 0 - // (min fee.remaining available-for-fee) - let fee_portion = if remaining_fee == 0 { - 0 - } else { - core::cmp::min(remaining_fee, available_for_fee) - }; - - // If this note contributes nothing, skip it (lines 182-183) - // ?: &(=(0 gift-portion) =(0 fee-portion)) - // [spends remaining] - if gift_portion == 0 && fee_portion == 0 { - continue; - } - - // Update remaining amounts (lines 184-185) - // =/ [new-gift-remaining=@ new-fee-remaining=@] - // :- (sub gift.remaining gift-portion) - // (sub fee.remaining fee-portion) - let new_gift_remaining = remaining_gift - gift_portion; - let new_fee_remaining = remaining_fee - fee_portion; - - // Calculate refund amount (line 186) - // =/ refund=@ (sub assets.note (add gift-portion fee-portion)) - let refund = note.assets.value - (gift_portion + fee_portion); - - // Skip if no gift and no refund (edge case, lines 187-189) - // ?: ?& =(0 gift-portion) =(0 refund) == - // [spends remaining] - if gift_portion == 0 && refund == 0 { - continue; - } - - // === Build seeds === (lines 190-203) - // Create output seeds for this spend - let mut seed_list: Vec = Vec::new(); - - // Add gift seed if there's a gift portion (lines 193-200) - // =? seeds (gth gift-portion 0) - if gift_portion > 0 { - // Compute lock root hash for the output lock - let lock_root = hash_hashable(&output_lock.to_hashable()).map_err(|e| e.to_string())?; - - // Get parent hash (hash of the note being spent) - let parent_hash = note.name.to_hash(); - - let gift_seed = SeedV1 { - output_source: None, - lock_root, - note_data: note_data.clone(), - gift: Coins { - value: gift_portion, - }, - parent_hash, - }; - seed_list.push(gift_seed); - } - - // Add refund seed if there's a refund (lines 201-203) - // =? seeds (gth refund 0) - if refund > 0 { - let refund_seed = create_refund(¬e, refund, refund_pkh.clone(), &pubkey); - seed_list.push(refund_seed); - } - - // Convert seed list to ZSet (line 190: %- z-silt:zo) - let mut seeds_set = ZSet::new(); - for seed in seed_list { - seeds_set.put(seed); - } - let seeds = SeedsV1 { set: seeds_set }; - - // Error if no seeds created (lines 204-205) - // ?~ seeds ~|('No seeds were provided' !!) - if seeds.set.is_empty() { - return Err("No seeds were provided".to_string()); - } - - // === Build lock merkle proof === (lines 206-207) - // Build a merkle proof showing that the spend-condition is in the lock tree - // =/ lmp=lock-merkle-proof:transact - // (build-lock-merkle-proof:lock:transact p.input-lock 1) - let lmp = - build_lock_merkle_proof(input_lock.clone(), 1, crate::fee_calculator::BYTHOS_PHASE); - - // === Create spend and witness === (lines 208-215) - // Build the Spend1 structure with witness - // =/ spend=spend-1:v1:transact - // %* . *spend-1:v1:transact - // seeds seeds - // fee fee-portion - // == - let witness = Witness { - lmp, - pkh: PkhSignature { map: ZMap::new() }, - hax: ZMap::new(), - tim: 0, - }; - - // Note: In the Hoon version, witness fields are set after creation: - // =. witness.spend - // %* . *witness:transact - // lmp lmp - // == - // The witness already has lmp set above, so we're good - - let spend_body = SpendV1 { - witness, - seeds, - fee: Coins { value: fee_portion }, - }; - - // === Sign the spend === (line 218) - // Sign using the spend-v1 signing function - // (sign:spend-v1:transact [%1 spend] sign-key) - let signed_spend = sign_spend_v1(spend_body, sign_key.clone(), pubkey.clone())?; - - // === Add to spends map === (lines 217-218) - // Add the signed spend to our accumulator - // %- (~(put z-by:zo spends)) - // [name.note (sign:spend-v1:transact [%1 spend] sign-key)] - spends.put(note.name.clone(), signed_spend); - - // Update remaining amounts for next iteration - remaining_gift = new_gift_remaining; - remaining_fee = new_fee_remaining; - } - - // === Final validation === (lines 127-130) - // Check that we covered the entire gift and fee - // ?. ?& =(0 gift.remaining) =(0 fee.remaining) == - // ~> %slog.[0 'Insufficient funds to pay fee and gift'] !! - if remaining_gift != 0 || remaining_fee != 0 { - return Err(format!( - "Insufficient funds to pay fee and gift. Still need: gift={}, fee={}", - remaining_gift, remaining_fee - )); - } - - Ok(spends) -} - -/// Create a refund seed that returns leftover funds to the spender -/// -/// Matches the Hoon function `++ create-refund` (lines 214-229) -/// -/// # Arguments -/// * `note` - The note being spent (used for parent hash) -/// * `refund` - Amount to refund -/// * `refund_pkh` - Optional explicit refund address (pubkey hash) -/// * `pubkey` - Default pubkey to refund to (if refund_pkh is None) -/// -/// # Returns -/// A SeedV1 that will create a refund output -fn create_refund( - note: &NNoteV1, - refund: u64, - refund_pkh: Option, - pubkey: &SchnorrPubkey, -) -> SeedV1 { - // Build the refund lock-primitive (lines 224-228) - // =/ refund-lp=lock-primitive:transact - // ?^ refund-pkh - // [%pkh [m=1 (z-silt:zo ~[u.refund-pkh])]] - // =/ pkh=hash:transact (hash:schnorr-pubkey:transact pubkey) - // [%pkh [m=1 (z-silt:zo ~[pkh])]] - let refund_pkh_hash = match refund_pkh { - Some(pkh) => pkh, - None => pubkey.to_hash(), - }; - - let mut pkh_set = ZSet::new(); - pkh_set.put(refund_pkh_hash); - - let refund_lp = LockPrimitive { - header: "pkh".to_string(), - body: LockPrimitiveBody::Pkh(Pkh { m: 1, h: pkh_set }), - }; - - // Build the lock (just the single primitive) (line 229) - // =/ lok=lock:transact ~[refund-lp] - let lok = SpendCondition { p: vec![refund_lp] }; - - // Build note-data with the lock (lines 230-232) - // =/ =note-data:v1:transact - // %- ~(put z-by:zo *note-data:v1:transact) - // [%lock ^-(lock-data:wt [%0 lok])] - let lock_data = LockData::V0(lok.clone()); - let mut note_data_map = ZMap::new(); - - // Serialize lock_data to UntypedNoun - let lock_data_noun = lock_data_to_untyped_noun(&lock_data); - note_data_map.put("lock".to_string(), lock_data_noun); - let note_data = NoteData { map: note_data_map }; - - // Calculate lock root hash - let lock_root = hash_hashable(&lok.to_hashable()).expect("refund lock hashable is u64-based"); - - // Get parent hash - let parent_hash = note.name.to_hash(); - - // Build and return the seed (lines 225-229) - // :* output-source=~ - // lock-root=(hash:lock:transact lok) - // note-data - // gift=refund - // parent-hash=(hash:nnote:transact note) - // == - SeedV1 { - output_source: None, - lock_root, - note_data, - gift: Coins { value: refund }, - parent_hash, - } -} - -/// Sign a V1 spend with a secret key -/// -/// This creates a proper signature for the spend and wraps it in the Spend enum. -/// Matches the Hoon signature: (sign:spend-v1:transact [%1 spend] sign-key) -/// -/// # Arguments -/// * `spend_body` - The SpendV1 to sign -/// * `sign_key` - Secret key (T8 format) -/// * `pubkey` - Public key corresponding to the secret key -/// -/// # Returns -/// A signed Spend (wrapped in the versioned enum) -/// Sign a V1 spend with the secret key -/// -/// # Arguments -/// * `spend_body` - The SpendV1 body to sign -/// * `sign_key` - Secret key for signing (T8 format) -/// * `pubkey` - Public key corresponding to sign_key -/// -/// # Returns -/// A fully signed Spend with version and signature -pub fn sign_spend_v1( - spend_body: SpendV1, - sign_key: T8, - pubkey: SchnorrPubkey, -) -> Result { - // Calculate the signature hash for the spend - // In Hoon: (sig-hash:spend:v1 sp) - let sig_hash = compute_spend_v1_sig_hash(&spend_body).map_err(|e| e.to_string())?; - - // Sign the hash using Schnorr signature - let (chal_t8, sig_t8) = schnorr_sign_digest(sign_key, pubkey.clone(), sig_hash); - - // Create the SchnorrSignature - let schnorr_sig = SchnorrSignature { - chal: Chal { values: chal_t8 }, - sig: Sig { values: sig_t8 }, - }; - - // Calculate pubkey hash - let pk_hash = pubkey.to_hash(); - - // Create PKH signature map entry - let mut pkh_map = ZMap::new(); - let pkh_sig_value = PkhSignatureValue { - pk: pubkey, - sig: schnorr_sig, - }; - pkh_map.put(pk_hash, pkh_sig_value); - - // Update the witness with the signature - let mut updated_spend = spend_body; - updated_spend.witness.pkh = PkhSignature { map: pkh_map }; - - // Wrap in the versioned Spend enum with version tag %1 - Ok(Spend { - version: 1, - body: SpendBody::V1(updated_spend), - }) -} - -/// Compute the signature hash for a V1 spend -/// -/// This is what gets signed when creating a transaction. -/// The signature commits to the seeds and fee. -/// -/// From Hoon (tx-engine-1.hoon lines 738-742): -/// ```hoon -/// ++ sig-hash -/// |= sen=form -/// ^- ^hash -/// %- hash-hashable:tip5 -/// [(sig-hashable:seeds seeds.sen) leaf+fee.sen] -/// ``` -fn compute_spend_v1_sig_hash(spend: &SpendV1) -> Result { - use crate::hashing::hasher::hash_hashable; - - // Get the sig-hashable for seeds - let seeds_hashable = spend.seeds.to_sig_hashable()?; - - // Create the fee leaf - let fee_hashable = spend.fee.to_hashable(); - - // Combine them into a cell [seeds_hashable fee_hashable] - let combined_hashable = Hashable::cell(seeds_hashable, fee_hashable); - - // Hash it - hash_hashable(&combined_hashable) -} - -/// Helper function to convert note name to string for error messages -fn note_name_to_string(name: &NName) -> String { - // Convert the first hash to base58 if available - if let Some(first_hash) = name.p.first() { - first_hash.to_b58() - } else { - "unknown".to_string() - } -} - -/// Create a refund seed for V0 notes that returns leftover funds to the spender -/// Returns a V1 seed since spend-0 creates V1 outputs -/// -/// Matches the Hoon function `++ create-refund` (lines 214-229) -/// -/// # Arguments -/// * `note` - The V0 note being spent (used for parent hash) -/// * `refund` - Amount to refund -/// * `refund_pkh` - Optional explicit refund address (pubkey hash) -/// * `pubkey` - Default pubkey to refund to (if refund_pkh is None) -/// -/// # Returns -/// A SeedV1 that will create a refund output -fn create_refund_v0_to_v1( - note: &NNoteV0, - refund: u64, - refund_pkh: Option, - pubkey: &SchnorrPubkey, -) -> SeedV1 { - // Build the refund lock-primitive (lines 217-220) - let refund_pkh_hash = match refund_pkh { - Some(pkh) => pkh, - None => pubkey.to_hash(), - }; - - let mut pkh_set = ZSet::new(); - pkh_set.put(refund_pkh_hash); - - let refund_lp = LockPrimitive { - header: "pkh".to_string(), - body: LockPrimitiveBody::Pkh(Pkh { m: 1, h: pkh_set }), - }; - - // Build the lock (just the single primitive) (line 221) - let lok = SpendCondition { p: vec![refund_lp] }; - - // Build note-data with the lock (lines 222-224) - let lock_data = LockData::V0(lok.clone()); - let mut note_data_map = ZMap::new(); - - // Serialize lock_data to UntypedNoun - let lock_data_noun = lock_data_to_untyped_noun(&lock_data); - note_data_map.put("lock".to_string(), lock_data_noun); - let note_data = NoteData { map: note_data_map }; - - // Calculate lock root hash - let lock_root = hash_hashable(&lok.to_hashable()).expect("refund lock hashable is u64-based"); - - // Get parent hash - let parent_hash = note.to_hash(); - - // Build and return the V1 seed (lines 225-229) - SeedV1 { - output_source: None, - lock_root, - note_data, - gift: Coins { value: refund }, - parent_hash, - } -} - -/// Sign a V0-to-V1 spend (spend-0:v1:transact) -/// -/// This function signs a spend that consumes a V0 note and creates V1 outputs. -/// It corresponds to the `++sign` arm in `spend-0:v1:transact` from tx-engine-1.hoon. -/// -/// From Hoon (tx-engine-1.hoon lines 573-586): -/// ```hoon -/// ++ sign -/// |= [sen=form sk=schnorr-seckey] -/// ^+ sen -/// =/ pk=schnorr-pubkey -/// %- ch-scal:affine:curve:cheetah -/// :* (t8-to-atom:belt-schnorr:cheetah sk) -/// a-gen:curve:cheetah -/// == -/// =/ sig=schnorr-signature -/// %+ sign:affine:belt-schnorr:cheetah -/// sk -/// (sig-hash sen) -/// %_ sen -/// signature (~(put z-by signature.sen) pk sig) -/// == -/// ``` -pub fn sign_spend_v0_to_v1( - mut spend_body: SpendV0ToV1, - sign_key: T8, - pubkey: SchnorrPubkey, -) -> Result { - // Calculate the signature hash using the SpendV0ToV1's method - let sig_hash = spend_body.compute_sig_hash().map_err(|e| e.to_string())?; - - // Sign the hash using Schnorr signature - let (chal_t8, sig_t8) = schnorr_sign_digest(sign_key, pubkey.clone(), sig_hash); - - // Create the SchnorrSignature - let schnorr_sig = SchnorrSignature { - chal: Chal { values: chal_t8 }, - sig: Sig { values: sig_t8 }, - }; - - // Add signature to the signature map (V0 uses pubkey as key, not hash) - spend_body.signature.put(pubkey, schnorr_sig); - - // Wrap in the versioned Spend enum with version tag %0 - // Note: Even though this creates V1 outputs, it's still tagged as %0 - // because it spends a V0 note - Ok(Spend { - version: 0, - body: SpendBody::V0ToV1(spend_body), - }) -} - -/// Helper trait to extract PKH from LockPrimitiveBody -#[allow(dead_code)] -trait AsPkh { - fn as_pkh(&self) -> Option<&Pkh>; -} - -impl AsPkh for LockPrimitiveBody { - fn as_pkh(&self) -> Option<&Pkh> { - match self { - LockPrimitiveBody::Pkh(pkh) => Some(pkh), - _ => None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_create_refund() { - // Test refund seed creation - let pubkey = SchnorrPubkey { - x: F6LT { - values: [1, 2, 3, 4, 5, 6], - }, - y: F6LT { - values: [7, 8, 9, 10, 11, 12], - }, - inf: false, - }; - - let note = NNoteV1 { - version: 1, - origin_page: PageNumber { value: 100 }, - name: NName { p: vec![] }, - note_data: NoteData { map: ZMap::new() }, - assets: Coins { value: 1000 }, - }; - - let refund_seed = create_refund(¬e, 500, None, &pubkey); - - assert_eq!(refund_seed.gift.value, 500); - assert_eq!(refund_seed.output_source, None); - } -} diff --git a/tx-types/src/word_count.rs b/tx-types/src/word_count.rs new file mode 100644 index 0000000..3130682 --- /dev/null +++ b/tx-types/src/word_count.rs @@ -0,0 +1,123 @@ +//! Exact noun-leaf accounting for transaction fees. + +use crate::{Hash, NName, NoteData, SeedV1, Spend, SpendBody, ZMap}; +use alloc::{collections::BTreeMap, vec, vec::Vec}; +use pokenoun::codec::{Encode, Error}; +use pokenoun::{Arena, Noun}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WordCountBreakdown { + pub seed_words: u64, + pub witness_words: u64, +} + +pub fn noun_words(noun: Noun, arena: &Arena) -> Result { + let mut pending = vec![noun]; + let mut words = 0u64; + let mut visited = 0usize; + while let Some(noun) = pending.pop() { + visited += 1; + if visited > 1 << 20 { + return Err(Error("noun word count exceeds traversal budget")); + } + match noun { + Noun::Atom(_) => words += 1, + Noun::Cell(id) => { + let cell = arena.cell(id); + pending.extend([cell.tail, cell.head]); + } + } + } + Ok(words) +} + +pub fn encoded_words(value: &impl Encode) -> Result { + let mut arena = Arena::new(); + let noun = value.encode(&mut arena)?; + noun_words(noun, &arena) +} + +/// Hoon charges the flattened [key value ... ~] reduction, not the map tree. +pub fn note_data_words(note_data: &NoteData) -> Result { + note_data + .map + .tap() + .iter() + .try_fold(1u64, |total, (_, value)| { + total + .checked_add(1 + encoded_words(value)?) + .ok_or(Error("note-data word count overflow")) + }) +} + +pub fn spend_witness_words(spend: &Spend) -> Result { + match &spend.body { + SpendBody::V0(spend) => encoded_words(&spend.signature), + SpendBody::V0ToV1(spend) => encoded_words(&spend.signature), + SpendBody::V1(spend) => encoded_words(&spend.witness), + } +} + +pub fn witness_words(spends: &ZMap) -> Result { + spends.tap().iter().try_fold(0u64, |total, (_, spend)| { + total + .checked_add(spend_witness_words(spend)?) + .ok_or(Error("witness word count overflow")) + }) +} + +fn seeds(spend: &Spend) -> Vec { + match &spend.body { + SpendBody::V0(_) => Vec::new(), + SpendBody::V0ToV1(spend) => spend.seeds.set.tap(), + SpendBody::V1(spend) => spend.seeds.set.tap(), + } +} + +pub fn seed_words(spends: &ZMap, merge_by_lock_root: bool) -> Result { + let mut total = 0u64; + let mut merged = BTreeMap::::new(); + for (_, spend) in spends.tap() { + for seed in seeds(&spend) { + if merge_by_lock_root { + // z-by uni prefers the incoming map's value for an existing key. + let data = merged + .entry(seed.lock_root) + .or_insert_with(|| NoteData { map: ZMap::new() }); + for (key, value) in seed.note_data.map.tap() { + data.map + .try_put(key, value) + .map_err(|_| Error("unbased note-data key"))?; + } + } else { + total = total + .checked_add(note_data_words(&seed.note_data)?) + .ok_or(Error("seed word count overflow"))?; + } + } + } + for data in merged.values() { + total = total + .checked_add(note_data_words(data)?) + .ok_or(Error("seed word count overflow"))?; + } + Ok(total) +} + +pub fn transaction_words( + spends: &ZMap, + merge_by_lock_root: bool, +) -> Result { + Ok(WordCountBreakdown { + seed_words: seed_words(spends, merge_by_lock_root)?, + witness_words: witness_words(spends)?, + }) +} + +/// A canonical signature map has one empty branch more than entries. +pub fn signature_map_words(signatures: u64, key_words: u64) -> Result { + signatures + .checked_mul(key_words + 16 + 1) + .and_then(|count| count.checked_add(1)) + .ok_or(Error("signature word count overflow")) +} diff --git a/tx-types/tests/reference/README.md b/tx-types/tests/reference/README.md new file mode 100644 index 0000000..684ad08 --- /dev/null +++ b/tx-types/tests/reference/README.md @@ -0,0 +1,36 @@ +# Transaction reference fixtures + +The reference is the official Nockchain checkout at +`9bcc26c6c57019dfde7f83aad9a9dd4ed5cd7902`. The `.jam` files contain public test +values. The tests decode them using the same portable codecs in hosted and +`no_std` builds. Portable JAM output matches the official bytes, including +canonical backreferences; comparisons remove only the generator files’ terminal +eight-byte-word padding. + +| Fixture | Oracle and assertions | +| --- | --- | +| `lock-proofs.jam` | `lock-proofs.hoon` invokes `common/tx-engine-1.hoon` lock hashing, stub/full proof builders, proof hashing, and verification. It covers every leaf of 1/2/4/8/16-condition locks using the official `tests/dumb/mod/unit/lock-merkle-proof.hoon` cases. Tests compare exact JAMs, hashes, axes, sibling order, and validation verdicts. | +| `note-data-oracle.jam` | `note-data-oracle.hoon` uses payloads from `tests/wallet/mod/note-data-fixtures.hoon`. Official note-data hashing and `num-of-leaves` produce the expected hashes and word counts for tagged locks, bridge deposits/withdrawals, and opaque versions/networks. | +| `fee-oracle.jam` | `fee-oracle.hoon` uses the official raw V1 transaction plus duplicate-input and V0 migration variants. The note-data aggregation/count gates come from `common/tx-engine.hoon`; fees use its formula with Bythos 10, base fee 257, divisor 3, and minimum 100. Twelve cases cover heights 9/10/11 and single/multiple V0/V1 witness shapes. These synthetic fee cases do not assert spend authorization. | +| `raw-tx-v0.jam`, `raw-tx-v1.jam` | Copied from `crates/nockchain-types/jams/v{0,1}/raw-tx.jam`; compare transaction IDs embedded by the reference. | +| `spend1-sig-hash.jam` | Copied from the official V1 JAM directory; compares per-spend signature hashes. | +| `withdrawal-transactions.jam` | Copied from `crates/wallet-tx-builder/tests/fixtures`; compares unsigned withdrawal fee estimates, including the required signature-map words. These fixtures use their own Bythos/base/minimum configuration. | + +Seed fees count the flattened note-data key/value reduction, including its +terminal zero. They do not count the branching map representation. Full witness +fees include the arity-tag sibling in each Merkle proof. The Hoon oracles pin +these details independently of Rust round-trip tests. + +Install the official `hoon` and `hoonc` binaries from the reference checkout, +then reproduce and compare every fixture: + +```sh +python3 tx-types/tests/reference/regenerate.py /path/to/nockchain --check +(cd tx-types/tests/reference && sha256sum --check SHA256SUMS) +cargo test -p tx-types --lib +cargo test -p tx-types --no-default-features --lib +``` + +Omit `--check` to replace fixtures and checksums. The generator runs in an +isolated temporary directory and requires successful compilation before copying +output. This also isolates the compiler's `out.jam` intermediate. diff --git a/tx-types/tests/reference/SHA256SUMS b/tx-types/tests/reference/SHA256SUMS new file mode 100644 index 0000000..b8c8e25 --- /dev/null +++ b/tx-types/tests/reference/SHA256SUMS @@ -0,0 +1,7 @@ +d0d8352740a0788f1c8dc59a46f56a7d355613c89f334456d9bcd7eb7d92d48e fee-oracle.jam +b570cfbb6df7b89d2d095afa3d1416542e2b35404cc9be24f59ddb878d360335 lock-proofs.jam +c459cd09d5fd27ec23a6e569a8b955c12c6000e2cc54a9f75f29fc17f2901581 note-data-oracle.jam +e9d28570bfdbe501aa937425c281ad695e57709d44e3f81d2e74bb3717e93e16 raw-tx-v0.jam +4fdd8eae76ea3382d1a5920c1a1213e9939d02312a65d1346635caaed7d8487b raw-tx-v1.jam +6790ce3c900989899f817dca8bd8769ecf6b330b1fdac3ff20a338f633b584d9 spend1-sig-hash.jam +819eab12f7ec313e781511ff3976be9000a44892499cedf6a1642866dd8b44fc withdrawal-transactions.jam diff --git a/tx-types/tests/reference/fee-oracle.hoon b/tx-types/tests/reference/fee-oracle.hoon new file mode 100644 index 0000000..808bcb0 --- /dev/null +++ b/tx-types/tests/reference/fee-oracle.hoon @@ -0,0 +1,104 @@ +:: Fee/count gates from common/tx-engine.hoon at the documented reference pin. +/= t /common/tx-engine-1 +/= zo /common/zoon +/= z /common/zeke +=< oracle +|% +++ oracle + =/ raw=raw-tx:t + ;;(raw-tx:t (cue 0x1.0452.1c00.7041.1487.2b89.e9f8.73f7.0338.0403.08ba.6849.408c.4b6e.01d6.4e8d.2246.3198.3002.00e9.bdf7.0552.b2ec.6e81.0060.d1eb.928f.5b06.fb00.803e.7480.e034.1148.7643.831b.2b97.c056.dcc2.d0c7.f015.82a6.5bb9.cd93.b92c.0201.1cd9.cf4f.75a4.b6e7.a01d.5124.4e3a.a4e3.4b5f.8063.1afa.40b6.94a4.6080.8038.9f32.4905.2551.12e0.4016.5682.290e.fbbc.2055.41dc.cae0.dff0.1d6c.6ded.8dce.af80.62aa.4356.8229.0f76.879c.1b03.f9bf.aba8.100f.c298.bc98.2018.267e.3770.1e03.0d79.3a03.f964.4c47.406e.2c2d.03fc.0731.1a87.c010.0f4e.ff28.b606.917b.255f.80c1.5ccb.adf8.0f25.0cca.7820.1c25.4b70.b040.3ba5.6310.8080.75bc.e40f.203e.07da.24e0.805c.9d3a.fcd4.c559.186f.d015.5d86.b985.d91c.df70.0d97.a67c.07b1.c2fb.a806.2fac.d263.d824.ee5f.e01f.3cc9.283d.4aad.4cf6.01a3.eac8.efe4.a2bb.f3d0.0b71.34b3.f2df.5b7b.f804.027d.0ad9.6320.20f7.bd80.7cde.9bfd.4be6.4ed2.0080.3ffc.1d1a.69a7.d0c9.7a01.bfb9.7722.69a5.236f.0200.f094.9947.5860.2350.fc02.ba5d.c356.e6cd.4467.c08d.1e85.7804.03d2.f4e0.d208.0692.0d07.6010.0c6a.42c8.fd01.b8ec.e9bd.0403.972b.cab4.0807.ec1b.202e.80c5.2549.65d0.2b44.82b8.0100.ce7e.323b.8201.d53a.f833.0403.1035.50bd.80df.e379.6082.018b.68f7.bfa0.3308.278b.0080.5cd6.9af1.e5ec.623c.cbf8.0941.d46b.633f.6d5a.6020.0d66.0fe3.459a.9ad9.c810.0717.d9b2.4fd0.80e2.d808.0381.809b.45d3.311f.ffa0.1be7.5a11.cd51.6aa9.ff80.5c63.146b.0ce2.0f60.7603.9f9d.f6ad.00f3.729f.e01c.0f33.bacb.5282.1400.200d.f2d6.a7b7.9441.9118.1007.ebe4.cb54.5233.f068.0803.f979.07a4.9f72.d3b4.0401.5a1a.e1ab.4dcc.1f96.e081.19de.0100.d666.376d.3946.a6e7.f806.926d.8bf9.17dc.fb37.e018.c88e.4d57.79e6.8c7f.807f.40df.adc7.b997.f1c0.802e.d00d.80a9.b1f1.58b8.100d.08fd.e669.8cba.a550.200d.1aca.e694.b738.751f.c03c.87d3.5db6.a513.3a3f.00c3.9f07.99dc.d931.e981.005d.65d6.4514.9912.9780.e4ac.342e.1ae5.133f.d00c.7cbb.b9b5.06ba.988f.c03d.4eb3.3896.2de4.e0ff.00d8.105f.5032.fbd5.a401.005d.8682.290f.c3a9.963a.9740.53d7.e033.0ef7.0b57.71a3.a7fe.0193.fb5d.0189.6859.3c02.00f8.fe08.c7ac.e222.a3b0.0f47.8d2e.84fa.8be0.e810.05f0.c2d0.f016.a309.6884.e895.dad2.e8f0.16a7.24bf.85a4.a753.d40f.406a.2bec.5f52.0939.d301.0075.726d.0d70.d88a.c6f4.03d5.d84b.2963.ce83.7404.01b0.3168.66f1.4a87.8902.00ae.2077.cd72.6024.56e0.100d.bb49.56b2.24b1.ee10.200f.3169.a6ac.28f8.7c10.1006.8072.2d90.32c0.6c39.00f8.9a7d.af6e.e812.adc0.2c87.435b.83c0.5763.63ab.37c0.571b.6fd8.a6b7.3d8b.e00f.e03f.8bce.7d73.d275.b4fe.01fb.200d.6bb8.a2af.d602.00e8.4ed8.3b7e.d9c0.8ffc.0346.6445.ed37.01e7.4fa0.16ce.6e71.02e3.30ae.ec08.0699.c460.a31a.6a7a.bfc0.3217.3422.dd73.c6ed.7a01.8b58.52a3.67d4.eded.f807.6f18.dfbe.7edf.f6ff.402a.ece3.b877.ce82.0dd2.fc05.e1e9.25ea.e999.dd3f.c030.3836.c5ec.92cf.3d60.401a.95df.3250.8ec0.785f.806b.609b.840f.a2e4.bbfe.0171)) + =/ row=[nam=nname:t sp=spend:t] (snag 0 ~(tap z-by:zo spends.raw)) + =/ duplicate=spends:t + (~(put z-by:zo spends.raw) ;;(nname:t [[1 2 3 4 5] [6 7 8 9 10] ~]) sp.row) + =/ migration=spends:t + (~(put z-by:zo *spends:t) nam.row [%0 [~ seeds.+.sp.row fee.+.sp.row]]) + =/ mixed=spends:t + %+ ~(put z-by:zo duplicate) + ;;(nname:t [[11 12 13 14 15] [16 17 18 19 20] ~]) + [%0 [~ seeds.+.sp.row fee.+.sp.row]] + =/ cases=(list [@tas spends:t]) + ~[[%raw spends.raw] [%duplicate duplicate] [%migration migration] [%mixed mixed]] + %- zing + %+ turn cases + |= [case=@tas sps=spends:t] + =/ heights=(list @) ~[9 10 11] + %+ turn heights + |= height=@ + =/ seed-words=@ + ?: (gte height 10) + (count-seed-words-merged sps) + (count-seed-words-legacy sps) + =/ witness-words=@ + %+ roll ~(tap z-by:zo sps) + |= [[nam=nname:t sp=spend:t] acc=@] + %+ add acc + %- num-of-leaves:shape:z + ?- -.sp + %0 `*`signature.+.sp + %1 `*`witness.+.sp + == + =/ base=@ 257 + =/ divisor=@ 3 + =/ minimum=@ 100 + =/ effective=@ ?:((gte height 10) base (mul 2 base)) + =/ discount=@ ?:((gte height 10) divisor 1) + =/ fee=@ + %+ max minimum + (add (mul seed-words effective) (div (mul witness-words effective) discount)) + [case sps height base divisor minimum seed-words witness-words fee] +++ note-data-by-lock-root + |= sps=spends:t + ^- (z-mip:zo hash:t @tas *) + =/ all-seeds=(list seed:t) + %- zing + %+ turn ~(tap z-by:zo sps) + |= [nam=nname:t sp=spend:t] + ?- -.sp + %0 ~(tap z-in:zo seeds.+.sp) + %1 ~(tap z-in:zo seeds.+.sp) + == + =/ by-lock-root=(z-mip:zo hash:t @tas *) + %+ roll all-seeds + |= [sed=seed:t acc=(z-mip:zo hash:t @tas *)] + =/ key=hash:t lock-root.sed + =/ existing=(unit (z-map:zo @tas *)) + (~(get z-by:zo acc) key) + ?~ existing + (~(put z-by:zo acc) key note-data.sed) + =/ merged=(z-map:zo @tas *) + (~(uni z-by:zo u.existing) note-data.sed) + (~(put z-by:zo acc) key merged) + by-lock-root +:: pre-bythos fee accounting charged note-data per spend, without lock-root +:: aggregation across the transaction. +++ count-seed-words-legacy + |= sps=spends:t + ^- @ + %+ roll ~(tap z-by:zo sps) + |= [[nam=nname:t sp=spend:t] acc=@] + =/ seds=seeds:t + ?- -.sp + %0 seeds.+.sp + %1 seeds.+.sp + == + %+ add acc + %+ roll ~(tap z-in:zo seds) + |= [sed=seed:t acc=@] + %+ add acc + %- num-of-leaves:shape:z + %- ~(rep z-by:zo note-data.sed) + |= [[k=@tas v=*] tree=*] + [k v tree] +++ count-seed-words-merged + |= sps=spends:t + ^- @ + =/ merged-by-lock-root=(z-mip:zo hash:t @tas *) + (note-data-by-lock-root sps) + %+ roll ~(tap z-by:zo merged-by-lock-root) + |= [[key=hash:t note-data=(z-map:zo @tas *)] acc=@] + %+ add acc + %- num-of-leaves:shape:z + %- ~(rep z-by:zo note-data) + |= [[k=@tas v=*] tree=*] + [k v tree] +-- diff --git a/tx-types/tests/reference/fee-oracle.jam b/tx-types/tests/reference/fee-oracle.jam new file mode 100644 index 0000000000000000000000000000000000000000..dbeb42fdc8bf3e5054a75558e658691a98dc0685 GIT binary patch literal 1280 zcmV+b1^@a5Jh`xYRe=8Ozx{l_G2RaF+uNznMy0S81N7RB=ek8`E@Xgz_0%**!H6>r z5ZGN|#s%WsoGn0~&I4}Ri^z-y_#c6@{dTaO&;bOjUqx}(8bI~~{Auf?=lYx%f50EW z7kh3^vEMeXfNxgE#;$+|w}uW`fLkTd-Cp}N@d0w!fMpt^3)%!9~Ul7;;|KQ{;mN>Ic zJ;0Bcx(2nmyL=4LKNIB|E;OvydUG1=Ui2cN?LKROeowq4VQJis4y zIJcDM${GzIP^G$zY3BV14G_3k@v*6Z4bU!t!10&4$F1K$e}I3C=6P35j>s6`H~ZWd z`HO9m2KeWuMmcRaX4U`!-Wh@5mLJSbtD#J<00P=+xlhuAy!-(KH24`Qs%7%^0|<=B zAj;cQYxxEc0182=W!;$v4)9O8d4N{?&d&imum^F5HHtAV!2eXWsOAW*^9|7dkC^GA zO~Akb1Z;6Y=qK6iivtMcY)#Fg@dvX85HPK_pRqOSpalG79x?Xvcui|AK!C*)gc;D^ zd$h#>L9o2>Z@^xt8i)ac8GN1U0Kpv@KWBgdTtT80=(41eVn7#=4fX>BTIH3?0fOFi zcHDq|WFa*M2(W_&Ndp9FaC+$h0=zT~YFPWcASi zd!|;U9+YIB5AZK{66`T)))xcRec{*xKGVwvxNnZ^!d%0y3g|bCtYT_@olY)5a1!kU zACL!f-a0@)h_TWgAXvDi5)KeNWrmUm{MKczV8E}Ey+{LFlz*oO2;f7Fm;?TS78W<4 zBPO!^0iC>t0S~wvKNAcPOkPak2ME;F-~53M&O^J0+qKnjLl5xPxv{;CtpolWT$wwl z3WRsx;dX6h0R%1)RZ@^7^N9lp2u4b23#6|x2Kco!PO9TX64e2EZcAGC^qkERAi$D4 z*_^v&Dp&!33@yxU1$?=(F*sz0+qD4)r1Jp;*f-dElw6Mn0|<0umz65G?@b2~fWeG0 z5@~d@4scsy1WH2FMHnE!8MvRrPfuJq+^)d^aJXFu$-o2*9h@9)APRv&QL$z=iU!fZ z1PmRV9Bv>AK^w+3vr#n24*dfbz=Ny~jxB90jILaxYF2T`&|%^hcKAUsNJ7-1dLC}q zK{7A_LkA}Z(ZDDK3W|y~v^m(&K4B0IOu*2=$>9b@A$SCD4Q&oKv`-jhhaMFR;L($d(b}tOg}XMp+d?8AW!y?XuGn#VM(z$Xeb+xsCPus2zg=lLaTAg@-R3uXiR09<9QJywOx!GqdKUP`F;pk%zF`hiJr- z!Pi*cWfWY`K*XuhTqeZ2qT1iGQsK54f}g*N0(AnfClipcdRceH6< z^{$cq*Fu*$%_?sYmidEsiBOXdS*@l>L~((rI(Ml@_2aicepFSNi_vaI;mO%qf~VT} z&f!Q*^xh$xdleSM?yS2U7OKPJm0;-ar`HmAcfS;;&anS5lpA|B#!mKTIWJuQt==t4 z7kwT$e(s*QGM3q?^VewWQbrEA_3p(P(~-o-subA&D7or8&Zk!(jknon5t_)l0O5fiSP;AY-bx7Z~Ig=kJNZrfR}K z_oI;~R#xFRfpCB}wD3w;3Uaff+@Io+ff0O5PCvrTcAm_zyl-UMR(zU0>zqlk}b zs$7#zJxwT>Ueg8o1b)wiWXvN^u_>9;am`tJ6EWF`z zb9`6|+&bK@72xv)6mV21b>4V)o;w4)ku7|&-Zs^Rz!020E8%0gQ4Ff{G)=fa89p7m z8Dp77kYu={r%`zCLvx}T8Tvyw5>0~EYN_N|A zyX-Y*&d3pz$a%gxU>jJ;GwBhmQ-dkwxRct=6pRNv!M&>J&t!ZMq|lqWvlUKm zv%ns@kxz)f>O%4y5*tkkKM#b*Pra8PT`Qgh>hJ$L%hMsHs07nSZ(rb)prv^L;4eJ^ z>xG}!&0s*1-8}zJ{ugHueLo|(JXeNm4C?4@{?31A?j?kmh|w|ds6Rl!lIUN|9S?SB zSYz={`Nv6sa8LdPgWWoFe)1IJaS@sUV8w%DRyVvkEEqw30dB1ehJ6~U%e@Ng*xg6( zS?~UV#@qT}oU4}1a6A;ph}jcGy9z#P!*J$z3r-WiCOPz~-gtudK@02M#0c}pjE`Kb4-as9tbiQSF_Ug8}hQA6;MmZF2%?-Vwn7YGaAf0U#@{c zm7%PsgL!uqDWNNyjoI$eV_;|m>oR)o6;+YqsQxRhN=J6J6hK`MGNvq=5Ucn0pS-zS1R~Uu%>}(-+o2igo-d6-l_7fA-6l(nDc7&&Yy8guy@Y{dTod$VWz>Y zg5qJ?nXxYyVUb%aBc{8Zj0t4QA>aP&Ge2Y5NdUO#xnte%yd2HJJnh_ib@X#0*>QB< zBz*IxtS|-06mu#IgiDlYSvR5vm?tjGVOZ0h5@ALcwmUA?U%WU_8m88&er>lrEz8GO ze8~DAi$DgrePeoG`CAH6fZu^OiF~S}x-c#C`M#$QwC_q$C{^fx5h!sWa8~S1j97^4 z%eR289J}|^m<2a=TG(!}KUn)w4gy0(+;+W+w>AXA1I$T3?9bU12)*@GHXs^I`oK=6 zB#Fjm>mHC0r8_)1eA6)`yBy1et{t;{oqO*Mi2kt9XL8N15(H64yVyz_7TM~XLJbb$ z2fR^UL$2qWIEPWJ^Wn>JNoPOx0=2ShOM+Q=4q?ZMs4fgc zJDaZ*j4t-V6GC@_%QLUjJ+;|~BzB*I$k!%3YxNDY=B z$x}5{YN3@bXSS-lnzK68N1R5U_|9{t>fO2QwdgXgwhF{%xg`pyO;pV zWorSN8pW3Y;VRuXMEn>VdOI_)FvLz)JUMx|$+i57AiYvQD7Mn{Pdje5e!a_;XMaK? zfMP$ZWXSVMX$96n--{UbQIMCXZ7XQ)R(lx2;3TJ1Nj_qJgsVg;^tQ>zmf|2wkrvib zB^Sy#qld+h77f1j{a2{S`H#SE)1<0x?K#^}g8)ln#h_Uf(Mw$xhMSrUPc1w`1p(o! zOD7GIp9)O_w<;1IT4ih73e$C`F6-C+L7jFwiNy0GAf*U{=ZZ=$xh#EwW9MkQrlY-%@3V18itZaf)04LK z#o(A{cZcFp~KJP^PCgwnF zP=W-T){IG^leAp8gGP#frk#tco000L)tcR+|4;aS8|%jon{;da9Xi(g^s?NiF<662v>X*zi+G}e9{!ZSZc_3hnqDaVmWQQKpjrWTDz!lsGm zhrMrIT4Ib)q41($GljH-O@Dn66$t$0`8P6}%(HQvW2DAUysEUro_2 zdkGZUJ}pD*G}^K>wr+W+WqG4{=%+WEpgJR)VC<}oMdaFgU&l8cGy3Cmk)dCBK|P5Uot>$C~y04=kyb(_h+oK>0P1 z4FAKKw62HUW@pZ4gv<5p+MV1~cw#5e?G_}^#FXDC(RD8zN#hmQ=jgLGDI5$a&4pBa zQ|cmwuMu|W%uUd1M6G!kr(U?}0rjc=(91AYjDXn{59a!C*hekaW;sGg0SCQ6w2Vk_fg`AngYfsq!C`kFoi^-H zv~C$@V7fK{03YT8VfP+BGKFbxh9*mnf7WpUZ-|S_PkUQt6Ug2P(~Wos>WTne!T0z@ zEK z#a$XS&cI8o_ov1;5S3=3D^4i!;D@$=gD2iEq`Pb|B(-8G{ltIfKE*@r5KJMZ zi(MxD9dU%SJjcUlxcyoL=vG2x_s2FKAE2#!*}rG(y##N7TVmfnL_fP=83=&v&4EAf zC4XuHP&H1W*Y1DFs|V5LPBBk+A9!s69_bEmI_@BJMR!iUJfgu}BV7I3q2h};4=^-% zz`0mGQ!Eq?d(yWDcc$boKLxIFFR>po@KvRKX6Lwjtl?uO7GJ)sZf(IJCL$#Ga_2p? zEGl6s)Lruv#2T_(JB16){0W2^&q)<>D)%YDltCl@uux*Y})|_ip7sR?L`j#W(jt*j!meOC|4Yc-e20)KA{XehCm-`W_{*MF| zj}QNn>Zy@y=9Q=!zqZr|G z!~i-ip4EOcqrgda^sAxea>eqB(0MZ3160nAIs;(h*DTEKN{TZKWR!i(7(VU6QmiU= zAJV%3*L5FI$;E~^5y{@6x*?luy`lURdgiVb{kilziZCpvCHBSiqsI}dF7X`Q)x3P- ze`L*I7j)rh(&F-k@ZYTJ^Cq#QRNUoWI_O3nc&oV6!^w29Rgi0&@TKG^>9O)`WZauZTM-uJW|g$`{cB;@Yo7fUUfT_o?^kX5M*Uw0#M z?kFZ0K56{8VxaY@ClJo)9s^K+KWqcRH$QiVB-ywT$%-qZ>`_(lCZ0LZ_w24x<=0iD z4F5?c+U>x}VxBaY!!(D-wfADTrmPoIh@CBW_3`gnauKtS20Jy_17Y z9q$&tqkV>2{CEa66XSy5@i}KLVU<7q9AvYfW9xlwqWvP&8Rhtyx!XrFKvd~Ayha5$ z$1zMTs;`P)TFMlNrXvl zWpN7teH;so508g70H|%F{db(+_hcij@;v7Jkoxi8pw_OBn!3zrUlPd+*HD716oanN~&RW@qsA_y$ZU<1cUot}*qulR;==ZB? z$Adjbw7@U}3-?0L0h2OBFX_Ptin8&dQCC_~tPaz_Z!JY8qX0^ewKaKjA{PlGQ|1rX zvW3leXocCndv4){r55?&mNY1dTSVmG^DbXDq4^%fzBRbkQO2e-X8>L3_I%D--DfSB zLOjEk6zpLqLxD$R#8RGkoeDuTOPVUHM00kOoP$yG{jQ0Bnz~acvXc!EKEFKN#$K;E z2%x%+6&z(>12nN;RH$MD z7F;*yVDPrY@3VOru>j#)_a2U1Wc0dVNojRXa(T%Q>=ChNW&WX$AnM6O^%~wwb61rw zK*py+rmxmv?JH2r$M7hv?dC*I7M-2O=x*EwK#+l3(c^FVhhmLCn+Vh^$!-I}6j~sS zNnt9Il$h?tm%s~w>#)OmA9w7`_zyhE)zTv+>;PFw2rbuyP@o>=i!3cE-&A_Ok+L7c z3pqJw@U%wFV@afSd^TBB@ja1DHnG*H|Nh02NK%}X)z+&Dla{A>y*D+H;1M(@bnC`a z?pfnb+y~K9PB=b8)7KKvsM~o`x7)9;xRs5ZT|ps3`zK2$N=vT>2>W-xN4i#);z{f* zRj`X$0?`{&v6WnjH4h*>eQInun$5-o%LGMj?WWH1zXQPbP1G&}0s4j>iT+>Poce;o z%f6_XrSd^w>;2J~xJJGkJV&`U{$clmnpWVDDJCµlur&a78a>~ zktsX~7I-yIJu7qTM7)`ei6!d7b*?|)+TYyKrGM*9 z+7a@2Rz8N=CRkG-IHH~oqP;KvS&!cEFM?QgI?bO#o2UN)s8d4W0cZQWR^Y8FD~(qB zPcP+ZU1Fn3BcD}OAUw4!)zR!LY6i<3{CxRRme?qw4fVV8jV{~N)_|ytXXM*K(^q<% zLYpES2@?Xup#K`P^#NyAdg0YzEg04i)lw?wzU2%%Oea6$*>|>HfI`gE6crqO?NopvnQhj)j&@I25y&J(jhjPjciTlMM+WhhdOboJ`f#J`%93FA@C)!9b{*da-+gKEcIVr~H8KLue; zHe9PaOpClhXuOqS><<3PW&`uo|8aA4S4vE2_jBsM1JQhWGMu)*r!GK!2Op(%)cmuD zwoHjO4W8nhp&**2VDv6{eVYv7PactG`(c-fm)$E2T0=N#f7!;KF^5(=*82+{}NXnc@u36Ow# zVz|#|>&5JVwc*u(M?u{+J2OY~Fgr5GF|^eL=*GQ&pO%@^t48}=$~x1bD(j5+0flJ! zJwZ30aFS9aQZ@Qwpl~5HVPpp$hL#Ws@-Vm1)Ce594hzgGy#0vBA>dQFqX)I`FpjZ%0^r6`tM<6X5@eAaD`m1NH2owTM2kfG zxD7~E7l5eb!}AgS8&OYpy7C&~l_&A#e_hG^osx0lTA)fDh)x@1lYVS|vKE>zUa&wNfD&uv4lVRMjSx@Vc2P-UrB~Yu zxkgCSsQpdzs3kDs`?!NRqjKsXJVigfi@$nh4i(zso2FJ^wB`jaOg(NCOqGnrwJug3 zRoqk3c8DUH<%t=bHpsw|q%^*av)_|j#p1O`Ib=xVotPbW+oM{Ot|K*puqF>(SD($n z#Z3J?jzsn~Nq;^;_(UL6A3a<=B^DeaK%JO5I>OOxUI%XP4K6l4Y1LT;Y_s}k8*y)% z579(ZlGuu!IU9&%N~SGy{&MSE5sD*GV#Zo7=`B@B^I85YiX z&y2%<@CeN>mte`Dg6JUW3o@Aw(P4=7Oc ztzvC=T&2waWF*GkMF*9tH~cxg6A-H<}}J9ot!FMF{zU z=+I>4)9Dx8cp?0g)vu{oWn&*KiFrEZe%ynpn*=iL?4`q>_oq=fhI;vQHnP}nxq@ia zsJJCvmlrY79ZqQM3zPg;xLNV_gZ`B)zCS?NvhA4zZMLPtWT13xV6eMwzZT+&TL&34 zo7B+%r!K`SbLNb8r2zny*FFMO5&e-0t8&Gd|f#!C7)U&n?W9KHPM2c%B-4SuL zhQo@l-Qs5iO9@cOvGpcqGjTLcnD!|9Lcso%X5?0AL)gCl8SkbA%`0LgWujgySrq;9 z=T0f83rH!1PedJG6F!Zsp8Ca=^;FKUbOET5y#Y_lYb#Q~ji_HYA|-5#xVy95*+WsQ;_JQs#Ac^XBHvSn}R@u z5EQMOGPWYO2>Rz8!Hs>z>sQ&b$f^`#7*_e$J1HUD?TmJL)n#Qj2=CkQQ2A9Gzy+bJ zP6obfz|>{n!dI(zsOw1^JDDV;vrC3xd{Ts-Uww7o9XISWj^v6RRB%#T;vOEc#tAdZCJ^^P(IMpW`OB9zb#lAFqQpae$`~~vLm%K8^rZLb2J}sg5r(spE|?|^nm5dQ#yc*ROP@=JrK8e2 z(z{YkfE1pBXJI}3C;SF}1;-2L!g2_7`m~eB?cbR=isb3>py{WB-g9^@^6gGh=EHkU zNNl(u3GWudGehKQsx|5aySLum5r9j3N81g)$fIL(JC{=S-fD+jsNVzu6tNLWF?liu z%Jbp7Zhv9<6;6Ahwj#%?lsAaP{_(u)3IiP_+yr=q_)cD7!{{a z7-m&+AF*gxS>s>TpqT^mYhMTLPQN$fDcN}`yW!H}8V>GM(|g_JV64oMZM(F1yMF;z z8avbPE>PPa#crdOy+g>(mY%-bl+p^K@YVYW)rXTa=XWePT+>$E2%Nd~D7 z6(@xW(08aTt)TH-4;rg&Y#vlB43m2@!R%E>2x4^e>I@}KA36Wg$Fg|xq(;?6 z>_~v7%keECeudiA*#&j{aC~LzRtnvB*Xc9G`HeZy)AH_186wh%Eu9S`u0*n0ifu^B z+|jisJcLQ*(SV^bKUFC~Cygb}RSH!LvxCuX#dh`q{ZVWfQ zE$A=crcK-29Ru2|Hv~2FuXA;|9eGfQFSni0PV2bFcNe?UvMHz> z!dBiO+iwFg9IwF8m}v*o_NVO&v)O1i$WwcDRpH*%jEL%kLr8Dgp33NfUHPluiQt z0L^x{|BX@)m;`lo{jj~{wH=2a-3ks@m3+|8!IA2Wmg@ocrUF6}m8Mcrv6y0n=HF7H zi%(q}A~a=1WmHhU*h_E%bc@3Ft?!-`c_$k6V>{zia3BPp@`ICC*mt!-i~iMC8J88U z_1XIljXhXDYoCape_9)@&M6_BQLAlh50A>AOqZAANXN5AOm|VgekIa4T(9)(_?O*O zL`&)Nx>NgdM`TfG9X=!ePcH>Sw%N!>aB#xdj5y9#sFUtm*H8{E+I6PgTT}Ad09`b5 z&y3OsFp*YJ4^mT(6>Ewi%wj#gs0MnA49YHfAJWj3eOX+PaIWs%t0BGIbLEZ)AkcD2 z9UNFMJq-OS%p2TB?I@8$xypQ!NVo^4!_IS?u^7odmI;g{g7Dd_sOnW((9%&qQ6410 z(RQ31gaF(*aTG%@pOx5z3Ko-ZOCu371S(b77TECuYjcp(AbwX!OQ+JdMB17YNSR3r zW-z{4FS!dyc13Xk?rdK}Jw|2lAu3JXP1heM-7;=|HOj-|w1_Zu2fH9zXFYFeP&=&N zT1@(f!ZWv1_r29M5{5z`J-H>}r@`|d(1oe3XhYHJpLmjB_DR)zuUG41_b})n_y9O8){Os|sWos+zurD0fq%(q?tP z5ADsI+T0?yWuCRk#khIAcu>~;li^bk&as=dJCcw+2k=75ipYa`V{`C)htSI5z`b-g z_Mr1;P+;xqc&15Zvd5^ua~l%;M-2eB0#rS8|OcRcts3(%54~$L0MkzBY3J7cCo%>?6g0=2=Tz`bYDg}-B zI7;NZ9_Al1+!WME;N-Nco*te#w#(7Vx;Q0!kM^0KqK@bLYHCj5r zgS)>l3QK(MG>HiJu^xPy&RsnSLXLzc_U`|&#sdpg%X%R2)2NY0kw4}F{F{>Eqy&wr zdOK>%6a#a~mwxjg2L0wS6IK%#oc%1_9|H5sElO!_W?fKqdwO}fa&oZ_#`YQ^3A9IU ztD)$%TXXt`^C|s`i6xJD15W5{z~&10e6aZj1%$Q@%MlZEf0}U`< zvVzgZwtTTKok05hK6wGg2fh>v0q#!G;15hgqdbO4IzoYGVf>!vr+je9Qd@2Z_hzP6 z{_m|cS30AAEQTQT%6)ahU51|u@Q)?8K4n^no4_QCv(5>bf9B!{G!A<0g6j@DapHKV zkMVhsm*Vv7U4f%Is?#UZd5e8}tnMiosO@|7dn;HaXGIj13Wah9-cTjHKsm&xd()72MrSg zUG3*ogryg}GJ&e6x_q?%DssszcdzYL-}a*L3tr%oL$}ESmgLo51r`rwewRnMNRBC) zR_WyPk?3sa=0)hU4zht0dh~KY%HY?Xbiss5_!PJ_mYLrS0cPC9NsL9G&^2^b^;z}? z%hz#bi<}o5wJ>v5SCbVlELKd^WmjjNozSPB-A_dXDqB^@W>w$Ip_na?d|3Mf!33SU zt6o(m)ab@7SSr)QD$UQ2GYWNo!$1+e3)b0>cIO(f%?O59Q$AW<6GPB7k9vQ$wbSU_ zL5Af8)?-JmDcSnF@YVl44nDid&cL zv0AaGh^O6Xin7gJw}=NfC=C2#4~+Gk*<^q+zdku9B>;Q6ngG4e!(0YIZ1PV~h5LyT zBN&;J--F&%{N@DX%`!~$LBD6AIE98j+v7qA9+Ela#-1HDQYLnMXv%}DW;I1tkFE*; z^NhmBzB}t)hx@iexMuyKm7p>^8u%Aqvo!qK_HpTf&Pk;rl|{Scxi^`TSGIf%-45eD zf#+WWfAX2DQ5p8N78>lld-OAfyMKtlq?VgmwPm&mGDMm%sTV#T8}ghY;7^D?bomyL z49wIQIugBXe7c*NOETs4M^S=aFGs9{Rzc@-;kmntXmyJN|6h0f)cPYD+IW%8-DK~p!9X+QH*P|sneKiPu@)>r+xQk;iv{A;ur+rd_ml+qM(Am8H zrvJEo(b2fPLqu$WDE|L;ImC3PqqR?`cV@E-{+DmLP`s#?eccZ$I zv-*-ie)kr!R)u08{@k;EX)ksjtl&J4c&~$D)-}1N-erPw5IC{%2w&UoS|~eL>PqjC zgi;5!%cD#FIJ?d3cVNbCea2)o;XGsZ)Ju2xHd^5k=z+2BXmL!ruNG}iguS9g ze8={Yb$ZF8>%XxBG1l3mS;iqb2@0}Z=Kv$+aO0{@=ynLuw z(&$)Byk4KNsF2S8#D=zR&QUv$u~vT$G(ffTqSweFkklth3XOGeahym>tIjJ6yFbQK zCXybe&a>-!=%clnWXXR+eHLnhWfCx36D_?@RMJV`s(Q%zom}r#BC^*lx;C}3Fpqfv literal 0 HcmV?d00001 diff --git a/tx-types/tests/reference/raw-tx-v1.jam b/tx-types/tests/reference/raw-tx-v1.jam new file mode 100644 index 0000000000000000000000000000000000000000..b5f2f0013882bca67334c840ec01521b2da46197 GIT binary patch literal 1064 zcmV+@1lRj<0sgz>q7Q_dU~7P1c)*TOGT)UNKwv%3lI+DcI55CJ-I?j?CF$V>{L&4A z&Ud)u>?%P2_TPTK-xzNP`0ee~XQNVBivfD=#&g{wG#4_!zj|sKqhQ3D1_pXbGbh@X*5W1rX@qi~58vjYki#qaxz0#|ZxT00KN&Xo&$` z`;!6wr=xLK3-=B);MY??mpYcI!w)HfhFt&wq}BT}P+t((0RQ0REtWX5PCdYnn7RhF zxx0J}&_5I98ZI=fdnFpW406#htrM6wuhdjU^bvU<_=E@ok zAW)^cjA`cm2n`UpSn;u`fDOjw}RkwKKVr`GZf zAOIACQp>tC4;8$l02jMdJ@<4Ip4zZ9ijc)IkaO%RFN2Fl@x{m4RU z3=m)k4Uz^3(%|&c0|a=59*w|fM9t<_!(F-p{7@rcSVx(Z@BjjDBc*8~ce%d-ddbkI zX&N2;KY#$zPUcJfo8EkYz4ss>W7!IQ0tEPbTi^1tG;s^i^Sh$t@5t(-0rpI-NX`uNiv@}fPi46l(sxs%PgTc&~) z@W;@?+*ZJsD;tADc1RI4;DB^KfB^djTaS|K(O>|9ZtSvB1^2z_00JBCXu1dk1ULitbNK0rD~A+8Z~z=q1OWg50000qe(fRv literal 0 HcmV?d00001 diff --git a/tx-types/tests/reference/regenerate.py b/tx-types/tests/reference/regenerate.py new file mode 100644 index 0000000..7be8f1a --- /dev/null +++ b/tx-types/tests/reference/regenerate.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Reproduce the reference fixtures with the pinned official Hoon tools.""" +import argparse +import hashlib +from pathlib import Path +import shutil +import subprocess +import tempfile + +PIN = "9bcc26c6c57019dfde7f83aad9a9dd4ed5cd7902" +HERE = Path(__file__).resolve().parent +COPIES = { + "raw-tx-v0.jam": "crates/nockchain-types/jams/v0/raw-tx.jam", + "raw-tx-v1.jam": "crates/nockchain-types/jams/v1/raw-tx.jam", + "spend1-sig-hash.jam": "crates/nockchain-types/jams/v1/raw-tx-spend1-sig-hash-oracle.jam", + "withdrawal-transactions.jam": "crates/wallet-tx-builder/tests/fixtures/withdrawal_tx_fixtures.jam", +} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("reference", type=Path, help="official nockchain checkout at the documented pin") + parser.add_argument("--check", action="store_true", help="compare without replacing fixtures") + args = parser.parse_args() + reference = args.reference.resolve() + head = subprocess.check_output(["git", "-C", str(reference), "rev-parse", "HEAD"], text=True).strip() + if head != PIN: + raise SystemExit(f"reference HEAD must be {PIN}, got {head}") + if not shutil.which("hoon"): + raise SystemExit("install the official hoon and hoonc binaries from the pinned checkout") + generated = {} + for name, source in COPIES.items(): + generated[name] = (reference / source).read_bytes() + for source in sorted(HERE.glob("*.hoon")): + # hoon uses out.jam in its working directory. An isolated directory makes + # a failed compilation unable to reuse another generator's output. + with tempfile.TemporaryDirectory(prefix="tx-types-reference-") as directory: + root = Path(directory) + result = subprocess.run( + ["hoon", str(source), str(reference / "hoon"), "--out-dir", str(root)], + cwd=root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + target = root / source.with_suffix(".jam").name + if result.returncode or not target.is_file() or "build failed" in result.stdout: + print(result.stdout) + raise SystemExit(f"reference generation failed: {source.name}") + generated[target.name] = target.read_bytes() + for name, data in sorted(generated.items()): + target = HERE / name + if args.check: + if target.read_bytes() != data: + raise SystemExit(f"fixture differs: {name}") + else: + target.write_bytes(data) + print(f"{name}: {len(data)} bytes") + checksums = "".join(f"{hashlib.sha256(data).hexdigest()} {name}\n" for name, data in sorted(generated.items())) + if args.check: + if (HERE / "SHA256SUMS").read_text() != checksums: + raise SystemExit("SHA256SUMS differs") + else: + (HERE / "SHA256SUMS").write_text(checksums) + + +if __name__ == "__main__": + main() diff --git a/tx-types/tests/reference/spend1-sig-hash.jam b/tx-types/tests/reference/spend1-sig-hash.jam new file mode 100644 index 0000000000000000000000000000000000000000..26b6be099d8b17b2a1a2da1eb3738545900ffb8d GIT binary patch literal 152 zcmV;J0B8SIfd1{j{d~VM-VX5F+o{h+rLYzQ^xBQ*x)SCEkK{n18&-j$czT~AAz&|cCek$0R*gHMRC^}K=uRtY3rot`kWVkz#qUDdu~m! z-!_1Nk&AVR%_SjjfPXWVnBSE4=o&zP%w=F7L8faodfbhj_0t9Ru>ci G0001%YEAM0 literal 0 HcmV?d00001 diff --git a/tx-types/tests/reference/withdrawal-transactions.jam b/tx-types/tests/reference/withdrawal-transactions.jam new file mode 100644 index 0000000000000000000000000000000000000000..e2a84af948db923ff972f917f11556d3ee5de622 GIT binary patch literal 2688 zcmZ{ldpOgJAIHDnea9BttWq??rgA8xnA=>wLMc&^N-mL#ST5ZQwG~?~(^jo0%Jo#@ zM3PI&G#q#1xNRWpggq4%CbDrn?e$VrJp5K3;KR$oFKd<-ud4H5-HEy|1t8b{j zPz?@Ws(RN=O9RFCy-PI8(=^D=F0g(?F{;YY%OMcV%dE2RXlhZ(gF61N< zT|DMtF%gljaz`MqehMI5CnvUeZQ9ZUBJFFDXL|FDd!U(+HIO&^`P^~(n+v~ajk?6Y zM^DNxTkm$83!hVwB{$`BGkIl$rNDV+gYs5JEvSq?7vN$4cCfV?L*iUGU=#Mix0RU1 z$`y=$kj(?exK%bg2AK<6NkF{X#55gQH2@+l(A(2XE6Vhs1&^?nn*mVhIDqhY+-F(6 zt~(F>zI(p#&U8O>SurFTvTd!pGA511VRi`=f=38RZBlh=M#p;on-~*Lo3i{RmIQ>2 z*!m@Ho79BqC%ZN$U90;{;cr-es@41mu~P|6rztsS=6_0T0MFrXxUHtH53l@BBrL}q zEXK+$4x`lM^#Zx=Z`u7ctGc}c>2m}+^uf6OHnp!`QFx&wB=6y^ram>2sooWX$el&w zS}NrDKjPhOK3m|qVzcZWLlrWS~QZ}d<%Tlrv zDl>V>kfaF1_T{DN@B&)d82`QPgLk~zIKw+Sd%CUns;@ z)BRI9{hnUKRSIV+q!?dOl*bc#$E?Nk_J4fRy50nJgI`9K_0CP9@bJqm*DmZL4yXzH zTp~S-?V@jk-wm*tse<%fnj}*v_*F&fG0y`c&flDQsRhMQLparzSgTRkIaba?8d zg{@?t^wb^oY4LAE?Hr;8sVqJyY~Z4~$zo$np`%m&7vIQN;rS-vyC1`hlU_hS{Q z@pon@W|F#gA4yn*>lI65?K3)9)93M6^C&I#_+cf-slvkiqExoYag&dQ<0fnz zdxy!CNlKB65zXYd9}$Q={ysjXJ>vby>zqTwQ3b=Yu~g0^CnG+O85LT0D5>#smyFXG z75cV$GHEJH$egJgndzoQOnveOk+x?NJ00V^@z9LzyEKi_Z{%2jaX;E5v9!SHJ%ETX zW;JjV@8trFfoc<1Z#@r5_TaVklDj=6C6A>nML!qnpiWpvq669V0WK_Ml)Dcs!Xq`MX3RRMS^A&huch+~h zx>+2B4$F4B(AS}xLJRq$z7tGv5%3TRIND#Q0u**$D?OCzTgKy-djQSZfmz6xGEGgoR5;k_puVBF}<+IBuXb{KG& zKQQKJUCzdn0{a|Xp#jEWWN%(PodYD>b0M^_ zYpaj^({(CpULX!3Be#Z+&|`kh4JI+PM+EX%SA9ifSM&)ZCWLP9qU?1yiAD+HkF?3u zX9V%VA^>46vR8Up@TCw$IzN{Kx$AmcAoSGjmu^e2vQMbVmxq)6ljwd^K#X(;r_WD5 zibS8%;AHt!`mSFI!^kJfyfwM`=KLDJ)?SI61w?_Cs+ zCB4sUUv`ZdS+YSzz+yk%H+KTOtkRIbNRO!5X^%obOE+FPq7) zhL9ag?mgqHwViZFoeH^)H$GysDD@$kDKVVCl-yvAmeioVnHXaG#w zob_wLn5Y{-WSyIQa@W1&0e!JqDYw&N5`FK8)6ZZ5VUC$HChcJS>mJ42-QP)&87*k| z#0=Q3{aq;~JhZ$to7xo?dZ{e?!w1Cd)9M8*KFwxu3o2G~ZX%GLb#IE``YfFehX*i2up4CILjeb3P{p9Cgr2$LWE$&Bjb6Za@9L zFlt`+og=nSC`4G@M;$Vi(TB3E{>J(E|Kn86@bgLDucIv<@M|IZXYT(Wyy7|^>z+VE zN+y{nN*m{Orq%PBVC^6&x0ABOdIfi>w~LZUGTzuO`bK&=}#w~3KbbRu)8aiWdh zRX!r&>1cr(Lh)Q!tYf%|ldQvErLh4{e{^F%o3#?2ibk0$u3+WYu(~gZd)F$=u3(;_ zYS|bTF=zK_RlPC^)GDA30t{I(=M3DJ+xitk-s$Y>0_~8AZPlgv