From ca9139cb0210ffa5f8748c9b1e835398c927802e Mon Sep 17 00:00:00 2001 From: olatechy0 <302475381+olatechy0@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:28:39 +0100 Subject: [PATCH] feat(xdr): define a versioned JSON schema for decoded values --- CHANGELOG.md | 9 ++ README.md | 7 +- crates/devkit-cli/src/main.rs | 22 ++- crates/devkit-cli/tests/xdr_schema.rs | 39 +++++ crates/devkit-xdr/src/lib.rs | 132 +++++++++++++++-- crates/devkit-xdr/tests/schema_snapshots.rs | 133 +++++++++++++++++ .../devkit-xdr/tests/snapshots/envelope.json | 23 +++ crates/devkit-xdr/tests/snapshots/event.json | 21 +++ crates/devkit-xdr/tests/snapshots/scval.json | 7 + docs/ARCHITECTURE.md | 9 +- docs/CLI.md | 14 +- docs/PROJECT_STATUS.md | 3 +- docs/SCVAL.md | 115 +++++++++++++++ docs/XDR.md | 134 ++++++++++++++++++ 14 files changed, 637 insertions(+), 31 deletions(-) create mode 100644 crates/devkit-cli/tests/xdr_schema.rs create mode 100644 crates/devkit-xdr/tests/schema_snapshots.rs create mode 100644 crates/devkit-xdr/tests/snapshots/envelope.json create mode 100644 crates/devkit-xdr/tests/snapshots/event.json create mode 100644 crates/devkit-xdr/tests/snapshots/scval.json create mode 100644 docs/SCVAL.md create mode 100644 docs/XDR.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bc7e492..39bf6cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `devkit-xdr`: versioned decoded-XDR JSON schema (`schema_version` `1.0`) for + `ScVal`, `TransactionEnvelope`, and `ContractEvent`, an opt-in `raw_xdr` + echo (`--raw`), golden snapshots, and the `docs/XDR.md` and `docs/SCVAL.md` + schema references. + ## [0.1.0] - 2026-10-28 First tagged release. Prebuilt binaries for Linux x86_64, macOS arm64, and diff --git a/README.md b/README.md index 668be93..f58a82b 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ The core works fully **offline** and never requires AI. - `devkit-core` — shared errors, network profiles, output format. - `devkit-xdr` — decode `ScVal`, `TransactionEnvelope`, and `ContractEvent` from - base64 XDR into JSON; classify Stellar strkeys; bounded malformed-input - handling. + base64 XDR into a versioned JSON document (`schema_version` 1.0) with an + opt-in `raw_xdr` echo; classify Stellar strkeys; bounded malformed-input + handling. See [docs/XDR.md](docs/XDR.md) and [docs/SCVAL.md](docs/SCVAL.md). - `devkit-rpc` — endpoint validation, typed protocol layer, deterministic mock transport, retry policy, and a bounded HTTP JSON-RPC transport. - `devkit-contract` — non-executing WASM validation, import/export/custom-section @@ -106,6 +107,8 @@ Decode commands accept `-` to read one bounded value from stdin. Exit codes: - [Architecture](docs/ARCHITECTURE.md) - [Getting started](docs/GETTING_STARTED.md) - [CLI reference](docs/CLI.md) +- [XDR JSON schema](docs/XDR.md) +- [SCVal JSON schema](docs/SCVAL.md) - [Configuration](docs/CONFIGURATION.md) - [Implemented capabilities](docs/IMPLEMENTED_CAPABILITIES.md) - [Memory measurement](docs/MEMORY.md) diff --git a/crates/devkit-cli/src/main.rs b/crates/devkit-cli/src/main.rs index fd9e51f..b2eb619 100644 --- a/crates/devkit-cli/src/main.rs +++ b/crates/devkit-cli/src/main.rs @@ -49,6 +49,10 @@ struct Cli { #[arg(long, value_enum, default_value_t = FormatArg::Terminal, global = true)] format: FormatArg, + /// Include the raw base64 XDR input in decoded JSON output. + #[arg(long, global = true)] + raw: bool, + #[command(subcommand)] command: Command, } @@ -173,6 +177,9 @@ fn print_json_error(category: &str, message: &str) { fn run(cli: Cli) -> Result { let format: OutputFormat = cli.format.into(); + let options = xdr::DecodeOptions { + include_raw: cli.raw, + }; let config_path = cli.config.clone(); match cli.command { Command::Version => { @@ -192,20 +199,23 @@ fn run(cli: Cli) -> Result { } Command::Scval { value } => { let value = read_value(value)?; - let decoded = xdr::inspect_scval_base64(&value).map_err(runtime)?; - print_value(format, &xdr::summary("scval", &decoded)); + let decoded = + xdr::inspect_scval_base64_with_options(&value, options).map_err(runtime)?; + print_value(format, &decoded); Ok(EXIT_SUCCESS) } Command::Envelope { value } => { let value = read_value(value)?; - let decoded = xdr::inspect_envelope_base64(&value).map_err(runtime)?; - print_value(format, &xdr::summary("transaction_envelope", &decoded)); + let decoded = + xdr::inspect_envelope_base64_with_options(&value, options).map_err(runtime)?; + print_value(format, &decoded); Ok(EXIT_SUCCESS) } Command::Event { value } => { let value = read_value(value)?; - let decoded = xdr::inspect_event_base64(&value).map_err(runtime)?; - print_value(format, &xdr::summary("contract_event", &decoded)); + let decoded = + xdr::inspect_event_base64_with_options(&value, options).map_err(runtime)?; + print_value(format, &decoded); Ok(EXIT_SUCCESS) } Command::InspectWasm { path } => { diff --git a/crates/devkit-cli/tests/xdr_schema.rs b/crates/devkit-cli/tests/xdr_schema.rs new file mode 100644 index 0000000..52c1af9 --- /dev/null +++ b/crates/devkit-cli/tests/xdr_schema.rs @@ -0,0 +1,39 @@ +use std::process::{Command, Output}; + +fn run_cli(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_stellar-foundry")) + .args(args) + .output() + .expect("run CLI") +} + +#[test] +fn scval_json_output_is_versioned() { + let output = run_cli(&["--format", "json", "scval", "AAAAAwAAACo="]); + assert!( + output.status.success(), + "CLI failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("valid JSON output"); + assert_eq!(value["schema_version"], "1.0"); + assert_eq!(value["kind"], "scval"); + assert_eq!(value["value"]["u32"], 42); + assert!(value.get("raw_xdr").is_none()); +} + +#[test] +fn raw_flag_echoes_the_base64_input() { + let output = run_cli(&["--format", "json", "--raw", "scval", "AAAAAwAAACo="]); + assert!( + output.status.success(), + "CLI failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("valid JSON output"); + assert_eq!(value["schema_version"], "1.0"); + assert_eq!(value["kind"], "scval"); + assert_eq!(value["raw_xdr"], "AAAAAwAAACo="); +} diff --git a/crates/devkit-xdr/src/lib.rs b/crates/devkit-xdr/src/lib.rs index d1ea258..94554a2 100644 --- a/crates/devkit-xdr/src/lib.rs +++ b/crates/devkit-xdr/src/lib.rs @@ -10,9 +10,25 @@ use devkit_core::DevkitError; use serde::Serialize; -use serde_json::{json, Value}; +use serde_json::Value; use stellar_xdr::{ContractEvent, Limits, ReadXdr, ScVal, TransactionEnvelope}; +/// Version of the decoded-XDR JSON schema emitted by this crate. +/// +/// The schema covers the document envelope (`schema_version`, `kind`, `value`, +/// and optional `raw_xdr`). See `docs/XDR.md` and `docs/SCVAL.md`. +pub const XDR_JSON_SCHEMA_VERSION: &str = "1.0"; + +/// Options controlling decoded-XDR output. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DecodeOptions { + /// Include the canonical base64 XDR input as `raw_xdr`. + /// + /// Off by default so machine-readable output stays compact; the raw + /// encoding is always available behind this flag. + pub include_raw: bool, +} + /// Maximum recursion depth applied when decoding untrusted XDR. /// /// Bounds stack usage for deeply nested values. @@ -46,24 +62,81 @@ pub struct StrkeyInfo { pub value: String, } -/// Decodes a base64 `ScVal` and returns developer-friendly JSON. +/// Build a versioned decoded-XDR document. +/// +/// The envelope is the stable part of the schema: +/// +/// ```json +/// { +/// "schema_version": "1.0", +/// "kind": "scval", +/// "value": { "...": "XDR-JSON payload" }, +/// "raw_xdr": "AAAAAwAAACo=" +/// } +/// ``` +/// +/// `raw_xdr` is only present when [`DecodeOptions::include_raw`] is set. +fn document(kind: &str, value: Value, input: &str, options: DecodeOptions) -> Value { + let mut object = serde_json::Map::new(); + object.insert( + "schema_version".to_string(), + Value::String(XDR_JSON_SCHEMA_VERSION.to_string()), + ); + object.insert("kind".to_string(), Value::String(kind.to_string())); + object.insert("value".to_string(), value); + if options.include_raw { + object.insert("raw_xdr".to_string(), Value::String(input.to_string())); + } + Value::Object(object) +} + +/// Decodes a base64 `ScVal` and returns a versioned JSON document. pub fn inspect_scval_base64(input: &str) -> Result { - let value = ScVal::from_xdr_base64(input.trim(), limits()).map_err(DevkitError::decode)?; - serde_json::to_value(&value).map_err(DevkitError::decode) + inspect_scval_base64_with_options(input, DecodeOptions::default()) } -/// Decodes a base64 `TransactionEnvelope` and returns developer-friendly JSON. +/// Decodes a base64 `ScVal` with explicit output options. +pub fn inspect_scval_base64_with_options( + input: &str, + options: DecodeOptions, +) -> Result { + let input = input.trim(); + let value = ScVal::from_xdr_base64(input, limits()).map_err(DevkitError::decode)?; + let value = serde_json::to_value(&value).map_err(DevkitError::decode)?; + Ok(document("scval", value, input, options)) +} + +/// Decodes a base64 `TransactionEnvelope` and returns a versioned JSON document. pub fn inspect_envelope_base64(input: &str) -> Result { - let value = TransactionEnvelope::from_xdr_base64(input.trim(), limits()) - .map_err(DevkitError::decode)?; - serde_json::to_value(&value).map_err(DevkitError::decode) + inspect_envelope_base64_with_options(input, DecodeOptions::default()) } -/// Decodes a base64 `ContractEvent` and returns developer-friendly JSON. -pub fn inspect_event_base64(input: &str) -> Result { +/// Decodes a base64 `TransactionEnvelope` with explicit output options. +pub fn inspect_envelope_base64_with_options( + input: &str, + options: DecodeOptions, +) -> Result { + let input = input.trim(); let value = - ContractEvent::from_xdr_base64(input.trim(), limits()).map_err(DevkitError::decode)?; - serde_json::to_value(&value).map_err(DevkitError::decode) + TransactionEnvelope::from_xdr_base64(input, limits()).map_err(DevkitError::decode)?; + let value = serde_json::to_value(&value).map_err(DevkitError::decode)?; + Ok(document("transaction_envelope", value, input, options)) +} + +/// Decodes a base64 `ContractEvent` and returns a versioned JSON document. +pub fn inspect_event_base64(input: &str) -> Result { + inspect_event_base64_with_options(input, DecodeOptions::default()) +} + +/// Decodes a base64 `ContractEvent` with explicit output options. +pub fn inspect_event_base64_with_options( + input: &str, + options: DecodeOptions, +) -> Result { + let input = input.trim(); + let value = ContractEvent::from_xdr_base64(input, limits()).map_err(DevkitError::decode)?; + let value = serde_json::to_value(&value).map_err(DevkitError::decode)?; + Ok(document("contract_event", value, input, options)) } /// Classifies a Stellar strkey. @@ -85,9 +158,12 @@ pub fn describe_strkey(input: &str) -> Result { }) } -/// Builds a compact summary object for a decoded value, used by the CLI. +/// Builds a versioned document for an already-decoded value. +/// +/// Prefer the `inspect_*` functions, which decode under bounded limits. This +/// helper remains for callers that already hold a decoded value. pub fn summary(kind: &str, value: &Value) -> Value { - json!({ "kind": kind, "value": value }) + document(kind, value.clone(), "", DecodeOptions::default()) } #[cfg(test)] @@ -103,10 +179,38 @@ mod tests { fn decodes_scval_u32_roundtrip() { let encoded = roundtrip_scval(&ScVal::U32(42)); let decoded = inspect_scval_base64(&encoded).unwrap(); + assert_eq!(decoded["schema_version"], XDR_JSON_SCHEMA_VERSION); + assert_eq!(decoded["kind"], "scval"); + assert!(decoded.get("value").is_some(), "missing value: {decoded}"); let text = serde_json::to_string(&decoded).unwrap(); assert!(text.contains("42"), "unexpected json: {text}"); } + #[test] + fn raw_xdr_is_opt_in_and_preserved_verbatim() { + let encoded = roundtrip_scval(&ScVal::U32(7)); + let default = inspect_scval_base64(&encoded).unwrap(); + assert!(default.get("raw_xdr").is_none()); + + let with_raw = inspect_scval_base64_with_options( + &format!(" {encoded}\n"), + DecodeOptions { include_raw: true }, + ) + .unwrap(); + assert_eq!(with_raw["raw_xdr"], encoded); + assert_eq!(with_raw["schema_version"], XDR_JSON_SCHEMA_VERSION); + } + + #[test] + fn summary_uses_the_versioned_envelope() { + let value = serde_json::json!({ "u32": 1 }); + let summary = summary("scval", &value); + assert_eq!(summary["schema_version"], XDR_JSON_SCHEMA_VERSION); + assert_eq!(summary["kind"], "scval"); + assert_eq!(summary["value"], value); + assert!(summary.get("raw_xdr").is_none()); + } + #[test] fn decodes_scval_bool_roundtrip() { let encoded = roundtrip_scval(&ScVal::Bool(true)); diff --git a/crates/devkit-xdr/tests/schema_snapshots.rs b/crates/devkit-xdr/tests/schema_snapshots.rs new file mode 100644 index 0000000..b9434ea --- /dev/null +++ b/crates/devkit-xdr/tests/schema_snapshots.rs @@ -0,0 +1,133 @@ +//! Golden snapshots and structural validation for the versioned XDR schema. +//! +//! The snapshot files under `tests/snapshots/` are the reviewable contract for +//! schema version 1.0. If a change intentionally alters them, bump +//! `XDR_JSON_SCHEMA_VERSION` and document the migration in `docs/XDR.md`. + +use std::path::{Path, PathBuf}; + +use devkit_xdr::{ + inspect_envelope_base64_with_options, inspect_event_base64_with_options, + inspect_scval_base64_with_options, DecodeOptions, XDR_JSON_SCHEMA_VERSION, +}; +use stellar_xdr::{ + BytesM, ContractEvent, ContractEventBody, ContractEventType, ContractEventV0, ContractId, + DecoratedSignature, ExtensionPoint, Hash, Limits, Memo, MuxedAccount, Preconditions, ScVal, + SequenceNumber, Signature, SignatureHint, Transaction, TransactionEnvelope, TransactionExt, + TransactionV1Envelope, Uint256, VecM, WriteXdr, +}; + +fn snapshot_path(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("snapshots") + .join(format!("{name}.json")) +} + +fn assert_snapshot(name: &str, value: &serde_json::Value) { + let actual = format!("{}\n", serde_json::to_string_pretty(value).unwrap()); + let path = snapshot_path(name); + if !path.exists() { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, &actual).unwrap(); + panic!( + "created missing snapshot {}; re-run the test to compare", + path.display() + ); + } + let expected = std::fs::read_to_string(&path).unwrap(); + assert_eq!(actual, expected, "snapshot {} drifted", path.display()); +} + +fn assert_document(document: &serde_json::Value, kind: &str, raw_xdr: Option<&str>) { + let object = document.as_object().expect("document is an object"); + assert_eq!(object["schema_version"], XDR_JSON_SCHEMA_VERSION); + assert_eq!(object["kind"], kind); + assert!(object.contains_key("value"), "document has no value"); + + let allowed = if raw_xdr.is_some() { + vec!["schema_version", "kind", "value", "raw_xdr"] + } else { + vec!["schema_version", "kind", "value"] + }; + for key in object.keys() { + assert!( + allowed.contains(&key.as_str()), + "unexpected document field `{key}`" + ); + } + assert_eq!(object.get("raw_xdr").and_then(|v| v.as_str()), raw_xdr); +} + +fn sample_envelope() -> TransactionEnvelope { + TransactionEnvelope::Tx(TransactionV1Envelope { + tx: Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0x22; 32])), + fee: 100, + seq_num: SequenceNumber(42), + cond: Preconditions::None, + memo: Memo::None, + operations: VecM::try_from(Vec::new()).unwrap(), + ext: TransactionExt::V0, + }, + signatures: VecM::try_from(vec![DecoratedSignature { + hint: SignatureHint([0xab; 4]), + signature: Signature(BytesM::try_from(vec![0xcd; 64]).unwrap()), + }]) + .unwrap(), + }) +} + +fn sample_event() -> ContractEvent { + ContractEvent { + ext: ExtensionPoint::V0, + contract_id: Some(ContractId(Hash([0x11; 32]))), + type_: ContractEventType::Contract, + body: ContractEventBody::V0(ContractEventV0 { + topics: VecM::try_from(vec![ScVal::Symbol( + "transfer".try_into().expect("valid symbol"), + )]) + .unwrap(), + data: ScVal::U32(7), + }), + } +} + +#[test] +fn scval_document_matches_golden_snapshot_and_schema() { + let encoded = ScVal::U32(42).to_xdr_base64(Limits::none()).unwrap(); + let document = inspect_scval_base64_with_options(&encoded, DecodeOptions::default()).unwrap(); + assert_document(&document, "scval", None); + assert_snapshot("scval", &document); +} + +#[test] +fn envelope_document_matches_golden_snapshot_and_schema() { + let encoded = sample_envelope().to_xdr_base64(Limits::none()).unwrap(); + let document = + inspect_envelope_base64_with_options(&encoded, DecodeOptions::default()).unwrap(); + assert_document(&document, "transaction_envelope", None); + assert_snapshot("envelope", &document); +} + +#[test] +fn event_document_matches_golden_snapshot_and_schema() { + let encoded = sample_event().to_xdr_base64(Limits::none()).unwrap(); + let document = inspect_event_base64_with_options(&encoded, DecodeOptions::default()).unwrap(); + assert_document(&document, "contract_event", None); + assert_snapshot("event", &document); +} + +#[test] +fn raw_xdr_is_only_present_when_requested() { + let encoded = ScVal::Bool(true).to_xdr_base64(Limits::none()).unwrap(); + let plain = inspect_scval_base64_with_options(&encoded, DecodeOptions::default()).unwrap(); + assert_document(&plain, "scval", None); + + let raw = inspect_scval_base64_with_options( + &format!("{encoded}\n"), + DecodeOptions { include_raw: true }, + ) + .unwrap(); + assert_document(&raw, "scval", Some(&encoded)); +} diff --git a/crates/devkit-xdr/tests/snapshots/envelope.json b/crates/devkit-xdr/tests/snapshots/envelope.json new file mode 100644 index 0000000..e2371a7 --- /dev/null +++ b/crates/devkit-xdr/tests/snapshots/envelope.json @@ -0,0 +1,23 @@ +{ + "kind": "transaction_envelope", + "schema_version": "1.0", + "value": { + "tx": { + "signatures": [ + { + "hint": "abababab", + "signature": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + } + ], + "tx": { + "cond": "none", + "ext": "v0", + "fee": 100, + "memo": "none", + "operations": [], + "seq_num": "42", + "source_account": "GARCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCFRVX" + } + } + } +} diff --git a/crates/devkit-xdr/tests/snapshots/event.json b/crates/devkit-xdr/tests/snapshots/event.json new file mode 100644 index 0000000..b530def --- /dev/null +++ b/crates/devkit-xdr/tests/snapshots/event.json @@ -0,0 +1,21 @@ +{ + "kind": "contract_event", + "schema_version": "1.0", + "value": { + "body": { + "v0": { + "data": { + "u32": 7 + }, + "topics": [ + { + "symbol": "transfer" + } + ] + } + }, + "contract_id": "CAIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRDB3V", + "ext": "v0", + "type": "contract" + } +} diff --git a/crates/devkit-xdr/tests/snapshots/scval.json b/crates/devkit-xdr/tests/snapshots/scval.json new file mode 100644 index 0000000..cd25e70 --- /dev/null +++ b/crates/devkit-xdr/tests/snapshots/scval.json @@ -0,0 +1,7 @@ +{ + "kind": "scval", + "schema_version": "1.0", + "value": { + "u32": 42 + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bfef842..3aab909 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,9 +67,12 @@ Design rules: - Failures are structured, never panics. Malformed input is a first-class test case; `fixtures/malformed/` and `crates/devkit-xdr/tests/malformed_corpus.rs` cover the regression corpus. -- JSON is produced by `stellar-xdr`'s `serde` representation today. A curated, - versioned schema is future work; until then the JSON shape is documented as - **unstable**. +- JSON is emitted as a versioned document: `schema_version`, `kind`, `value`, + and an opt-in `raw_xdr`. The envelope and the frozen XDR-JSON payload rules + are specified in [`XDR.md`](XDR.md), with SCVal details in + [`SCVAL.md`](SCVAL.md). Additive changes bump the schema minor version; + breaking changes require a major bump and a migration note. +- Golden snapshots in `crates/devkit-xdr/tests/snapshots/` pin the schema. ## RPC architecture diff --git a/docs/CLI.md b/docs/CLI.md index f907175..6b8e2f7 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -27,15 +27,17 @@ Validates a Stellar strkey and reports its kind (`account`, `contract`, or ### `scval ` -Decodes a base64 XDR `ScVal` into JSON. Pass `-` to read one value from stdin, for example `printf 'AAAAAwAAACo=' | stellar-foundry --format json scval -`. +Decodes a base64 XDR `ScVal` into a versioned JSON document. Pass `-` to read one value from stdin, for example `printf 'AAAAAwAAACo=' | stellar-foundry --format json scval -`. ### `envelope ` -Decodes a base64 XDR `TransactionEnvelope` into JSON. Pass `-` to read one value from stdin. +Decodes a base64 XDR `TransactionEnvelope` into a versioned JSON document. Pass `-` to read one value from stdin. ### `event ` -Decodes a base64 XDR `ContractEvent` into JSON. Pass `-` to read one value from stdin. +Decodes a base64 XDR `ContractEvent` into a versioned JSON document. Pass `-` to read one value from stdin. + +Decoded output includes `schema_version`, `kind`, and `value`. Add the global `--raw` flag to also echo the canonical base64 input as `raw_xdr`. See [XDR.md](XDR.md) and [SCVAL.md](SCVAL.md) for the schema and migration policy. When `-` is supplied, stdin must be piped or redirected; the CLI never waits for interactive terminal input. Input is limited to 16 MiB and must be valid UTF-8. Only one value is read; streaming multiple values is not supported. @@ -86,5 +88,7 @@ The category is one of `usage`, `decode`, `encode`, `invalid_input`, `configurat ## Output stability - `terminal` output is human-oriented and may change. -- `json` output is machine-readable. Until a versioned schema is published - (tracked as an issue), treat the shape as unstable. +- `json` output for `scval`, `envelope`, and `event` is machine-readable and + versioned. Consumers must check `schema_version` (currently `1.0`) before + using the payload; see [XDR.md](XDR.md). Other JSON shapes remain + human-oriented until they gain their own versioned envelope. diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index b69f82c..3b1e75d 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -14,7 +14,8 @@ The merge commit is `aec759c`. CI and the RustSec audit are green on `main`. - `devkit-core`: structured errors, network profiles, output formats. - `devkit-xdr`: bounded decoding of base64 `ScVal`, `TransactionEnvelope`, and - `ContractEvent`; strkey classification; malformed-input regression fixtures. + `ContractEvent` into a schema-versioned JSON document (1.0) with an opt-in raw + XDR echo; strkey classification; malformed-input regression fixtures. - `stellar-foundry` CLI: `version`, `doctor`, `strkey`, `scval`, `envelope`, `event`, `endpoint`, `inspect-wasm`, `analyze-wasm`, and `rpc-health`; terminal/JSON output, SARIF output for analysis, and exit codes 0/2/3. diff --git a/docs/SCVAL.md b/docs/SCVAL.md new file mode 100644 index 0000000..bf811fb --- /dev/null +++ b/docs/SCVAL.md @@ -0,0 +1,115 @@ +# SCVal JSON schema + +This page defines the `scval` document emitted by `devkit-xdr` and the +`stellar-foundry scval` command. The shared envelope is specified in +[`XDR.md`](XDR.md); this page covers the `value` payload. + +Current schema version: **1.0**. + +## Schema + +```json +{ + "schema_version": "1.0", + "kind": "scval", + "value": { "u32": 42 } +} +``` + +- `schema_version` — `1.0`. +- `kind` — always `scval`. +- `value` — exactly one tagged `ScVal` object or unit string. +- `raw_xdr` — optional base64 input, enabled with `--raw`. + +A decoder must reject a document whose `kind` is not `scval` or whose major +schema version is not `1`. + +## Variant mapping + +| SCVal variant | JSON shape | Example | +| ------------- | ---------- | ------- | +| `Bool` | `{"bool": true}` | `{"bool":false}` | +| `Void` | `"void"` | `"void"` | +| `Error` | `{"error": {...}}` | `{"error":{"contract":1}}` | +| `U32` | `{"u32": 42}` | number | +| `I32` | `{"i32": -1}` | number | +| `U64` | `{"u64": "42"}` | string, no precision loss | +| `I64` | `{"i64": "-1"}` | string | +| `Timepoint` | `{"timepoint": "1700000000"}` | string | +| `Duration` | `{"duration": "60"}` | string | +| `U128` | `{"u128": {"hi": "...", "lo": "..."}}` | parts | +| `I128` | `{"i128": {"hi": "...", "lo": "..."}}` | parts | +| `U256` | `{"u256": {"hi_hi": "...", "hi_lo": "...", "lo_hi": "...", "lo_lo": "..."}}` | parts | +| `I256` | `{"i256": {...}}` | parts | +| `Bytes` | `{"bytes": ""}` | lowercase hex | +| `String` | `{"string": "..."}` | UTF-8 | +| `Symbol` | `{"symbol": "transfer"}` | UTF-8 | +| `Vec` | `{"vec": [ ... ]}` or `{"vec": null}` | recursive `ScVal` | +| `Map` | `{"map": [{"key": ..., "value": ...}]}` or `null` | recursive, ordered | +| `Address` | `{"address": "G..."}` / `"C..."` | strkey | +| `ContractInstance` | `{"contract_instance": {...}}` | XDR-JSON | +| `LedgerKeyContractInstance` | `"ledger_key_contract_instance"` | unit string | +| `LedgerKeyNonce` | `{"ledger_key_nonce": {...}}` | XDR-JSON | +| `ExecutableTag` | `{"executable_tag": "..."}` | string | + +The table reflects the `stellar-xdr` 28 serde mapping frozen by schema 1.0. If a +future `stellar-xdr` release adds a variant, the decoder reports a decode error +until the variant is documented here and the schema minor version is bumped. + +## Examples + +### Integer + +```console +$ stellar-foundry --format json scval AAAAAwAAACo= +{"kind":"scval","schema_version":"1.0","value":{"u32":42}} +``` + +### Symbol and vector + +```json +{ + "schema_version": "1.0", + "kind": "scval", + "value": { + "vec": [ + { "symbol": "transfer" }, + { "u32": 7 } + ] + } +} +``` + +### Map + +Maps are arrays of `key`/`value` pairs so arbitrary SCVal keys survive JSON +round-trips without string coercion: + +```json +{ + "schema_version": "1.0", + "kind": "scval", + "value": { + "map": [ + { "key": { "symbol": "admin" }, "value": { "address": "G..." } } + ] + } +} +``` + +## Recursion and limits + +- Nested `Vec`, `Map`, and contract-instance values use the same schema + recursively. +- Decoding is bounded to depth 128 and 16 MiB of XDR. Exceeding a limit returns + a structured decode error; no partial document is emitted. +- The `value` payload never contains raw binary; bytes and hashes are hex and + ids are strkeys. + +## Stability + +Within schema 1.x, existing variant tags and shapes do not change. Additive +changes (new documented variants or optional fields) bump the minor version. +Removing or renaming a tag requires schema 2.0 and a migration note in +`XDR.md`. Golden SCVal output is covered by +`crates/devkit-xdr/tests/snapshots/scval.json`. diff --git a/docs/XDR.md b/docs/XDR.md new file mode 100644 index 0000000..d05c509 --- /dev/null +++ b/docs/XDR.md @@ -0,0 +1,134 @@ +# XDR JSON schema + +`devkit-xdr` emits a versioned document for every decoded value. The envelope is +the stable contract; the `value` member is the XDR-JSON mapping defined below. + +Current schema version: **1.0** (`devkit_xdr::XDR_JSON_SCHEMA_VERSION`). + +## Document envelope + +```json +{ + "schema_version": "1.0", + "kind": "scval", + "value": { "u32": 42 } +} +``` + +| Field | Type | Presence | Meaning | +| ----- | ---- | -------- | ------- | +| `schema_version` | string | always | Schema version in `MAJOR.MINOR` form. | +| `kind` | string | always | `scval`, `transaction_envelope`, or `contract_event`. | +| `value` | any JSON | always | Decoded XDR-JSON payload (see below). | +| `raw_xdr` | string | opt-in | Canonical, trimmed base64 XDR input. | + +`raw_xdr` is omitted by default so reports stay compact. Request it with +`--raw` on the CLI or `DecodeOptions { include_raw: true }` in the library. + +```console +$ stellar-foundry --format json --raw scval AAAAAwAAACo= +{"kind":"scval","raw_xdr":"AAAAAwAAACo=","schema_version":"1.0","value":{"u32":42}} +``` + +The raw encoding is always recoverable: the input is base64 XDR and the decoder +never rewrites it. The flag only echoes it into the document. + +## Kinds + +### `scval` + +`value` is the XDR-JSON mapping of a single `ScVal`. See +[`SCVAL.md`](SCVAL.md) for the variant table and examples. + +### `transaction_envelope` + +`value` is the XDR-JSON mapping of a `TransactionEnvelope`. The top-level key +identifies the envelope variant: + +- `tx_v0` — legacy `TransactionV0Envelope` +- `tx` — `TransactionV1Envelope` (contains `tx` and `signatures`) +- `tx_fee_bump` — `FeeBumpTransactionEnvelope` + +Example (`tx` variant, abbreviated): + +```json +{ + "schema_version": "1.0", + "kind": "transaction_envelope", + "value": { + "tx": { + "tx": { + "source_account": "G...", + "fee": 100, + "seq_num": "42", + "cond": "none", + "memo": "none", + "operations": [] + }, + "signatures": [ + { "hint": "abababab", "signature": "cdcd..." } + ] + } + } +} +``` + +### `contract_event` + +`value` is the XDR-JSON mapping of a `ContractEvent`: + +| Field | Meaning | +| ----- | ------- | +| `type` | `system`, `contract`, or `diagnostic` | +| `contract_id` | strkey contract id, or `null` for system events | +| `body.v0.topics` | array of `ScVal` values, each in the `scval` schema | +| `body.v0.data` | `ScVal` payload, in the `scval` schema | +| `ext` | extension point (`v0` in schema 1.0) | + +## XDR-JSON payload rules + +The `value` payload is the deterministic `stellar-xdr` serde mapping, frozen as +part of schema 1.0. Rules that consumers may rely on: + +- Enums and union variants are tagged objects or strings using `snake_case` + names, for example `{"u32":42}`, `{"symbol":"transfer"}`, `"none"`. +- 64-bit integers use JSON strings (`"42"`), matching `stellar-xdr`'s + `NumberOrString` mapping, so no precision is lost. +- Byte arrays are lowercase hex strings; hashes and signature hints are hex. +- Account, muxed-account, and contract ids are strkeys. +- Field names use `snake_case` (`seq_num`, `source_account`). +- Maps and vectors preserve XDR order; the decoder does not reorder them. +- Undecoded or unknown future XDR variants are a decode error, not silent data + loss. + +## Versioning and migration + +- `MAJOR` changes when an existing field is removed, renamed, retyped, or + reordered in a way that breaks consumers. A major bump requires a migration + section in this file and a new snapshot set. +- `MINOR` changes are additive: new optional fields, new `kind` values, or new + documented XDR variants. Existing fields keep their names and meanings. +- Every successfully decoded document carries `schema_version`. Decode errors + are returned through the existing structured error type, not as documents. +- Unknown fields must be ignored by consumers. + +### Migrating from the unversioned output + +Before schema 1.0, CLI output was `{ "kind": ..., "value": ... }` with no +version. Migration is additive: + +1. Read `schema_version`; reject documents whose major version is not `1`. +2. Keep reading `kind` and `value` as before. +3. Treat `raw_xdr` as optional and only present when requested. + +No field was removed or renamed, so existing consumers that ignored unknown +fields continue to work. + +## Tests + +- `crates/devkit-xdr/tests/schema_snapshots.rs` validates the envelope and + compares all three kinds against committed golden files in + `crates/devkit-xdr/tests/snapshots/`. +- Unit tests assert that `raw_xdr` is opt-in and preserved verbatim. +- Changing a snapshot requires updating this document and bumping the schema + version when the change is not additive.