Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
cef22d4
Add Keeta block and operation types for DER parsing
mamonet Jan 10, 2026
788a2a4
Store DER integers as `&'a [u8]`
mamonet Jan 10, 2026
c7e1ce0
Remove address validation exports from public API
mamonet Jan 10, 2026
147ab9c
Remove `#[repr(u8)]` from enums in types.rs
mamonet Jan 11, 2026
a17c01b
Fix formatting for block crate
mamonet Jan 11, 2026
8e550f2
Use typed integers and fallible enum parsing in block types
mamonet Jan 13, 2026
ce141db
Add KeetaBlockBuilder and replace U128 with byte slices
mamonet Jan 13, 2026
0f2914b
Add signatures to KeetaBlock
mamonet Jan 14, 2026
82e515f
Add multisig signer types for V2 blocks
mamonet Jan 14, 2026
880d5d2
Add vote types and separate rate/value swap types
mamonet Jan 15, 2026
711bbf7
Add DER encoding/decoding support with derive macros
mamonet Jan 15, 2026
27650cf
Add test coverage
mamonet Jan 15, 2026
032f569
Export vote and certificate types from lib.rs
mamonet Jan 15, 2026
4135c36
Add KeetaBlock DER encoding/decoding with SDK compatibility tests
mamonet Jan 18, 2026
2bdcd25
Fix clippy expect_fun_call warnings in SDK compat tests
mamonet Jan 18, 2026
d3e8baf
Add multisig signer iteration support with SignersIter
mamonet Jan 18, 2026
0ca1272
Fix clippy vec_init_then_push warning in multisig test
mamonet Jan 18, 2026
c78763f
Embed test samples and add malformed input tests
mamonet Jan 19, 2026
883d2cc
Fix clippy useless_vec warning in x509 utils test
mamonet Jan 21, 2026
8822860
Fix rustdoc warnings for bracket notation in docs
mamonet Jan 21, 2026
2881b80
Replace manual DER parsing with SliceReader in SignersIter
mamonet Jan 22, 2026
3a34af0
Add extract_operations_slice for block operations parsing
mamonet Feb 4, 2026
10a5c8c
Add keetanetwork-permissions crate with permission bit definitions
mamonet Feb 5, 2026
374bba1
Add keetanetwork-permissions to workspace members
mamonet Feb 5, 2026
9d76887
Add metadata module and consolidate permissions into keetanetwork-block
mamonet Feb 5, 2026
c0ad09e
Remove Vote types
mamonet Feb 18, 2026
cebf208
Remove dead code from keetanetwork-block
mamonet Mar 8, 2026
ece7984
DRY metadata field copying with closure
mamonet Mar 8, 2026
6571b29
Data-drive metadata error case tests
mamonet Mar 8, 2026
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
13 changes: 11 additions & 2 deletions keetanetwork-block/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,20 @@ repository.workspace = true
homepage.workspace = true
description = "Block structure and operations for Keetanetwork blockchain"

[features]
default = ["std"]
std = ["alloc"]
alloc = ["der/alloc"]

[dependencies]
keetanetwork-error = { version = "0.1.0", path = "../keetanetwork-error" }
snafu = { workspace = true }
der = { version = "0.7.10", default-features = false, features = ["derive"] }
serde = { version = "1.0", default-features = false, features = ["derive"] }
serde-json-core = { version = "0.6", default-features = false }
base64ct = { version = "1.8", default-features = false }

[dev-dependencies]
der = "0.7.10"
hex-literal = "0.4"

[lib]
name = "keetanetwork_block"
Expand Down
49 changes: 49 additions & 0 deletions keetanetwork-block/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,52 @@
//! # Keetanetwork Block
//!
//! This crate provides block structure and operations for the Keetanetwork blockchain.

#![cfg_attr(not(feature = "std"), no_std)]

pub mod metadata;
mod parse;
pub mod permissions;
mod types;

pub use parse::extract_operations_slice;

pub use types::{
// Enums
AdjustMethod,
AdjustMethodRelative,
// Block types
BlockHeader,
BlockPurpose,
BlockVersion,
// Type aliases (maps to der types or raw bytes)
Bytes,
// Operation structs
CancelSwapOp,
CreateIdentifierArgs,
CreateIdentifierOp,
// Supporting types
FeeRate,
FeeValue,
FeeValueWithRecipient,
Int,
ManageCertificateOp,
MatchSwapOp,
ModifyPermissionsOp,
MultisigArgs,
// Value-or-none wrapper (like Option but NULL has meaning)
NullOr,
// Operation enum
Operation,
Permission,
ReceiveOp,
SendOp,
SetInfoOp,
SetRepOp,
Str,
SwapArgs,
TokenAdminModifyBalanceOp,
TokenAdminSupplyOp,
TokenRate,
TokenValue,
};
232 changes: 232 additions & 0 deletions keetanetwork-block/src/metadata.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
//! Metadata parsing.
//!
//! Decodes base64 JSON metadata and extracts asset_id/authority/symbol.
//! This module is no_std compatible and does not allocate.

use base64ct::{Base64, Encoding};
use serde::Deserialize;

const MAX_FIELD_LEN: usize = 64;

/// Raw metadata structure for JSON deserialization.
#[derive(Deserialize)]
struct RawMetadata<'a> {
#[serde(default)]
asset_id: Option<&'a str>,
#[serde(default)]
authority: Option<&'a str>,
#[serde(default)]
symbol: Option<&'a str>,
}

/// Decoded metadata with fixed-size buffers.
pub struct DecodedMetadata {
pub asset_id: [u8; MAX_FIELD_LEN],
pub asset_id_len: usize,
pub authority: [u8; MAX_FIELD_LEN],
pub authority_len: usize,
pub symbol: [u8; MAX_FIELD_LEN],
pub symbol_len: usize,
}

impl DecodedMetadata {
/// Create a new empty metadata instance.
pub fn new() -> Self {
Self {
asset_id: [0u8; MAX_FIELD_LEN],
asset_id_len: 0,
authority: [0u8; MAX_FIELD_LEN],
authority_len: 0,
symbol: [0u8; MAX_FIELD_LEN],
symbol_len: 0,
}
}

/// Get the asset_id as a string slice, if present.
pub fn asset_id_str(&self) -> Option<&str> {
if self.asset_id_len > 0 {
core::str::from_utf8(&self.asset_id[..self.asset_id_len]).ok()
} else {
None
}
}

/// Get the authority as a string slice, if present.
pub fn authority_str(&self) -> Option<&str> {
if self.authority_len > 0 {
core::str::from_utf8(&self.authority[..self.authority_len]).ok()
} else {
None
}
}

/// Get the symbol as a string slice, if present.
pub fn symbol_str(&self) -> Option<&str> {
if self.symbol_len > 0 {
core::str::from_utf8(&self.symbol[..self.symbol_len]).ok()
} else {
None
}
}
}

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

/// Result of metadata decoding.
pub enum MetadataDisplay {
/// Successfully decoded metadata with at least one known field.
Decoded(DecodedMetadata),
/// Valid JSON but no known fields (asset_id, authority, symbol).
Unknown,
/// Invalid base64 encoding or malformed JSON.
Invalid,
/// Empty input string.
Empty,
}

/// Decode base64 metadata and extract known fields.
///
/// # Arguments
/// * `base64_input` - Base64-encoded JSON string
/// * `decode_buf` - Buffer for base64 decoding, must be >= input.len() * 3/4
///
/// # Returns
/// A `MetadataDisplay` variant indicating the result.
pub fn decode_metadata(base64_input: &str, decode_buf: &mut [u8]) -> MetadataDisplay {
if base64_input.is_empty() {
return MetadataDisplay::Empty;
}

let decoded = match Base64::decode(base64_input.as_bytes(), decode_buf) {
Ok(bytes) => bytes,
Err(_) => return MetadataDisplay::Invalid,
};

let raw: RawMetadata = match serde_json_core::from_slice(decoded) {
Ok((meta, _)) => meta,
Err(_) => return MetadataDisplay::Invalid,
};

let mut result = DecodedMetadata::new();

let copy_field = |value: &str, buf: &mut [u8; MAX_FIELD_LEN]| -> usize {
let len = value.len().min(MAX_FIELD_LEN);
buf[..len].copy_from_slice(&value.as_bytes()[..len]);
len
};

if let Some(v) = raw.asset_id {
result.asset_id_len = copy_field(v, &mut result.asset_id);
}
if let Some(v) = raw.authority {
result.authority_len = copy_field(v, &mut result.authority);
}
if let Some(v) = raw.symbol {
result.symbol_len = copy_field(v, &mut result.symbol);
}

if result.asset_id_len > 0 || result.authority_len > 0 || result.symbol_len > 0 {
MetadataDisplay::Decoded(result)
} else {
MetadataDisplay::Unknown
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_decode_metadata_full() {
let mut buf = [0u8; 512];

let json =
r#"{"asset_id":"asset://1f0ccae9-5666/1","authority":"keeta_abc123def456","signature":"c2lnbmF0dXJl"}"#;
let b64 = base64_encode_for_test(json.as_bytes());

match decode_metadata(&b64, &mut buf) {
MetadataDisplay::Decoded(meta) => {
assert_eq!(meta.asset_id_str(), Some("asset://1f0ccae9-5666/1"));
assert_eq!(meta.authority_str(), Some("keeta_abc123def456"));
}
_ => panic!("Expected Decoded variant"),
}
}

#[test]
fn test_decode_metadata_with_symbol() {
let mut buf = [0u8; 512];

let json = r#"{"asset_id":"asset://123","symbol":"KEETA"}"#;
let b64 = base64_encode_for_test(json.as_bytes());

match decode_metadata(&b64, &mut buf) {
MetadataDisplay::Decoded(meta) => {
assert_eq!(meta.asset_id_str(), Some("asset://123"));
assert_eq!(meta.symbol_str(), Some("KEETA"));
assert_eq!(meta.authority_str(), None);
}
_ => panic!("Expected Decoded variant"),
}
}

#[test]
fn test_decode_metadata_non_decoded_cases() {
let unknown_json = r#"{"foo":"bar","baz":123}"#;
let unknown_b64 = base64_encode_for_test(unknown_json.as_bytes());
let invalid_json_b64 = base64_encode_for_test(b"not json at all");

let cases: &[(&str, &str)] = &[
("empty", ""),
("unknown_fields", &unknown_b64),
("invalid_base64", "not-valid-base64!!!"),
("invalid_json", &invalid_json_b64),
];

for (name, input) in cases {
let mut buf = [0u8; 512];
let result = decode_metadata(input, &mut buf);
match (name, &result) {
(&"empty", MetadataDisplay::Empty)
| (&"unknown_fields", MetadataDisplay::Unknown)
| (&"invalid_base64", MetadataDisplay::Invalid)
| (&"invalid_json", MetadataDisplay::Invalid) => {}
_ => panic!("Unexpected result for case: {name}"),
}
}
}

fn base64_encode_for_test(input: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut result = String::new();

for chunk in input.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;

let n = (b0 << 16) | (b1 << 8) | b2;

result.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
result.push(CHARS[((n >> 12) & 0x3F) as usize] as char);

if chunk.len() > 1 {
result.push(CHARS[((n >> 6) & 0x3F) as usize] as char);
} else {
result.push('=');
}

if chunk.len() > 2 {
result.push(CHARS[(n & 0x3F) as usize] as char);
} else {
result.push('=');
}
}

result
}
}
83 changes: 83 additions & 0 deletions keetanetwork-block/src/parse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//! DER utility functions for zero-copy parsing.

use der::{Decode, Header, Reader, SliceReader, Tag, TagNumber};

/// Extracts the operations SEQUENCE content from raw block bytes.
///
/// This function skips block header fields and returns a slice containing
/// the raw DER content of the operations SEQUENCE.
pub fn extract_operations_slice(data: &[u8]) -> Option<&[u8]> {
if data.is_empty() {
return None;
}

let mut reader = SliceReader::new(data).ok()?;
let tag = reader.peek_tag().ok()?;

if tag == Tag::Sequence {
// V1 block: plain SEQUENCE
extract_operations_v1(&mut reader)
} else if tag.is_context_specific() && tag.number() == TagNumber::new(1) {
// V2 block: context tag [1]
extract_operations_v2(&mut reader)
} else {
None
}
}

/// Extract operations from V1 block.
fn extract_operations_v1<'a>(reader: &mut SliceReader<'a>) -> Option<&'a [u8]> {
let header = Header::decode(reader).ok()?;
if header.tag != Tag::Sequence {
return None;
}
let content = reader.read_slice(header.length).ok()?;
let mut inner = SliceReader::new(content).ok()?;

// Skip V1 header fields: version, network, subnet, date, account, signer, previous
for _ in 0..7 {
skip_any(&mut inner).ok()?;
}

read_sequence_content(&mut inner)
}

/// Extract operations from V2 block.
fn extract_operations_v2<'a>(reader: &mut SliceReader<'a>) -> Option<&'a [u8]> {
let ctx_header = Header::decode(reader).ok()?;
if !ctx_header.tag.is_context_specific() || ctx_header.tag.number() != TagNumber::new(1) {
return None;
}
let inner_content = reader.read_slice(ctx_header.length).ok()?;
let mut inner = SliceReader::new(inner_content).ok()?;

let seq_header = Header::decode(&mut inner).ok()?;
if seq_header.tag != Tag::Sequence {
return None;
}
let seq_content = inner.read_slice(seq_header.length).ok()?;
let mut seq_reader = SliceReader::new(seq_content).ok()?;

// Skip V2 header fields: network, date, purpose, account, signer, previous
for _ in 0..6 {
skip_any(&mut seq_reader).ok()?;
}

read_sequence_content(&mut seq_reader)
}

/// Skip any DER element (tag + length + content).
pub(crate) fn skip_any<'a>(reader: &mut SliceReader<'a>) -> der::Result<()> {
let header = Header::decode(reader)?;
reader.read_slice(header.length)?;
Ok(())
}

/// Read a SEQUENCE and return its content bytes.
fn read_sequence_content<'a>(reader: &mut SliceReader<'a>) -> Option<&'a [u8]> {
let header = Header::decode(reader).ok()?;
if header.tag != Tag::Sequence {
return None;
}
reader.read_slice(header.length).ok()
}
Loading
Loading