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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"] }
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,44 @@ Add this to your `Cargo.toml`:
tx-types = { git = "https://github.com/SWPSCO/tx-types" }
```

## 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:

```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

[`vanity`](vanity/) provides allocation-free
Expand Down
14 changes: 10 additions & 4 deletions pokenoun/src/arena.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -26,13 +26,19 @@ struct AtomSlice {
len: u32,
}

#[derive(Debug, Default)]
#[derive(Debug)]
pub struct Arena {
atoms: Vec<AtomSlice>,
pub(crate) atom_data: Vec<u8>,
cells: Vec<Cell>,
}

impl Default for Arena {
fn default() -> Self {
Self::new()
}
}

#[inline]
fn trim_trailing_zeros(bytes: &[u8]) -> &[u8] {
let mut len = bytes.len();
Expand Down
264 changes: 264 additions & 0 deletions pokenoun/src/codec.rs
Original file line number Diff line number Diff line change
@@ -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<Noun, Error>;

fn to_jam(&self) -> Result<Vec<u8>, 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<Self, Error>;

fn from_jam(bytes: &[u8]) -> Result<Self, Error> {
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<Noun, Error> {
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<Noun, Error> {
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<Noun, Error> {
Ok(arena.alloc_atom_u64(*self))
}
}

impl Decode for u64 {
fn decode(noun: Noun, arena: &Arena) -> Result<Self, Error> {
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<Noun, Error> {
u64::from(!self).encode(arena)
}
}

impl Decode for bool {
fn decode(noun: Noun, arena: &Arena) -> Result<Self, Error> {
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<Noun, Error> {
Ok(arena.alloc_atom_bytes(self.as_bytes()))
}
}

impl Encode for String {
fn encode(&self, arena: &mut Arena) -> Result<Noun, Error> {
self.as_str().encode(arena)
}
}

impl Decode for String {
fn decode(noun: Noun, arena: &Arena) -> Result<Self, Error> {
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<T: Encode + ?Sized> Encode for &T {
fn encode(&self, arena: &mut Arena) -> Result<Noun, Error> {
(*self).encode(arena)
}
}

impl<T: Encode> Encode for Option<T> {
fn encode(&self, arena: &mut Arena) -> Result<Noun, Error> {
match self {
None => Ok(arena.atom0()),
Some(value) => {
let value = value.encode(arena)?;
Ok(arena.alloc_cell(arena.atom0(), value))
}
}
}
}

impl<T: Decode> Decode for Option<T> {
fn decode(noun: Noun, arena: &Arena) -> Result<Self, Error> {
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<T: Encode> Encode for Vec<T> {
fn encode(&self, arena: &mut Arena) -> Result<Noun, Error> {
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<T: Decode> Decode for Vec<T> {
fn decode(mut noun: Noun, arena: &Arena) -> Result<Self, Error> {
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<T: Encode, const N: usize> Encode for [T; N] {
fn encode(&self, arena: &mut Arena) -> Result<Noun, Error> {
let items: Result<Vec<_>, _> = self.iter().map(|value| value.encode(arena)).collect();
tuple(arena, &items?)
}
}

impl<T: Decode, const N: usize> Decode for [T; N] {
fn decode(noun: Noun, arena: &Arena) -> Result<Self, Error> {
let mut fields = Fields::new(noun, N);
(0..N)
.map(|_| T::decode(fields.next(arena)?, arena))
.collect::<Result<Vec<_>, _>>()?
.try_into()
.map_err(|_| Error("wrong array length"))
}
}

impl<A: Encode, B: Encode> Encode for (A, B) {
fn encode(&self, arena: &mut Arena) -> Result<Noun, Error> {
let left = self.0.encode(arena)?;
let right = self.1.encode(arena)?;
Ok(arena.alloc_cell(left, right))
}
}

impl<A: Decode, B: Decode> Decode for (A, B) {
fn decode(noun: Noun, arena: &Arena) -> Result<Self, Error> {
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)]
}
Loading
Loading