diff --git a/README.md b/README.md index 2d0e869b..1193525f 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,10 @@ starforge new contract my-dex --template uniswap-v2 --from marketplace starforge template publish ./my-template ``` -### ?? Contract Deployment -Validate, size-check, and deploy compiled Soroban `.wasm` files to Testnet or Mainnet. Verifies account balance on-chain, calculates WASM hash, and generates the exact `stellar contract deploy` command to complete the deployment. +### 🚀 Contract Deployment +Validate, size-check, and deploy compiled Soroban `.wasm` files to Testnet or Mainnet. Verifies account balance on-chain, calculates the Soroban WASM hash as a SHA-256 digest of the raw file bytes, and generates the exact `stellar contract deploy` command to complete the deployment. + +The local hash shown by `starforge deploy` is intended to match the value reported by `stellar contract inspect --wasm ` for the same bytecode. --- @@ -300,6 +302,17 @@ starforge wallet show mykey --reveal Unencrypted keys (without `--encrypt`) are stored in plaintext and are suitable only for testnet or throwaway accounts. **Do not use plaintext keys on mainnet with real funds.** +### Test Environment Secret + +Some tests validate secret-key parsing without embedding a secret in the repository. Set the value at runtime before running the test suite: + +```powershell +$env:STARFORGE_TEST_SECRET_KEY = "S..." # 56-character Stellar secret key +cargo test +``` + +Generate this value outside the codebase using your preferred secure workflow, such as a local Stellar key generation command or an existing throwaway test wallet. The key should live only in your shell environment or secret manager, not in source control. + --- ## Contract Templates diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs index b1deaf03..40d61013 100644 --- a/src/commands/deploy.rs +++ b/src/commands/deploy.rs @@ -2,6 +2,7 @@ use crate::utils::{config, horizon, optimizer, print as p}; use anyhow::Result; use clap::Args; use colored::*; +use sha2::{Digest, Sha256}; use std::fs; use std::path::PathBuf; use std::process::Command; @@ -34,11 +35,14 @@ fn is_wasm_above_size_limit(wasm_size_kb: f64) -> bool { wasm_size_kb > SOROBAN_WASM_LIMIT_KB } +/// Compute the Soroban WASM hash (SHA-256 over raw `.wasm` file bytes) +/// and return it as a 64-character lowercase hex string. +/// +/// This matches the hash that `stellar contract inspect --wasm ` reports +/// and that Soroban uses to identify uploaded contract bytecode on-chain. fn compute_local_wasm_hash(wasm_bytes: &[u8]) -> String { - let hash_val = wasm_bytes.iter().enumerate().fold(0u64, |acc, (i, &b)| { - acc.wrapping_add((b as u64).wrapping_mul(i as u64 + 1)) - }); - format!("{:016x}", hash_val) + let digest = Sha256::digest(wasm_bytes); + hex::encode(digest) } fn build_stellar_deploy_command(wasm: &std::path::Path, source: &str, network: &str) -> String { @@ -197,14 +201,17 @@ pub fn handle(args: DeployArgs) -> Result<()> { .unwrap_or("0"); pb.inc(1); - pb.set_message("Calculating WASM hash..."); + pb.set_message("Calculating WASM SHA-256 hash..."); + + let wasm_hash = compute_local_wasm_hash(&wasm_bytes); + pb.inc(1); pb.set_message("Generating stellar CLI command..."); pb.finish_with_message("Deployment preparation complete!"); println!(); p::kv_accent("XLM Balance", &format!("{} XLM", xlm)); - p::kv("WASM hash (local)", &wasm_hash); + p::kv("WASM Hash (local SHA-256)", &wasm_hash); println!(); p::separator(); @@ -261,21 +268,100 @@ mod tests { use std::fs; use tempfile::tempdir; + // --------------------------------------------------------------------------- + // SHA-256 hash tests + // --------------------------------------------------------------------------- + + /// The output must always be a 64-character lowercase hex string (256 bits). #[test] - fn computes_stable_hash_for_same_input() { + fn sha256_output_is_64_hex_chars() { + let hash = compute_local_wasm_hash(b"hello-starforge"); + assert_eq!(hash.len(), 64, "SHA-256 hex digest must be 64 characters"); + assert!( + hash.chars().all(|c| c.is_ascii_hexdigit()), + "digest must be lowercase hex" + ); + } + + /// Same bytes → same digest (deterministic). + #[test] + fn sha256_is_deterministic() { let bytes = b"hello-starforge"; - let first = compute_local_wasm_hash(bytes); - let second = compute_local_wasm_hash(bytes); - assert_eq!(first, second); + assert_eq!(compute_local_wasm_hash(bytes), compute_local_wasm_hash(bytes)); } + /// Different bytes → different digest (collision-resistance sanity check). #[test] - fn hash_changes_when_input_changes() { - let first = compute_local_wasm_hash(b"abc"); - let second = compute_local_wasm_hash(b"abd"); - assert_ne!(first, second); + fn sha256_differs_for_different_inputs() { + assert_ne!(compute_local_wasm_hash(b"abc"), compute_local_wasm_hash(b"abd")); } + /// Known-answer test: SHA-256("abc") == the FIPS 180-4 test vector. + /// + /// Expected value verified against `echo -n abc | sha256sum` and the + /// NIST FIPS 180-4 published test vector. + #[test] + fn sha256_known_answer_abc() { + let hash = compute_local_wasm_hash(b"abc"); + assert_eq!( + hash, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + // SHA-256("abc") as computed by sha2 0.10 / FIPS 180-4. + ); + } + + /// Known-answer test against `tests/fixtures/minimal.wasm`. + /// + /// Expected value: `sha256sum tests/fixtures/minimal.wasm` + /// → 93a44bbb96c751218e4c00d479e4c14358122a389acca16205b1e4d0dc5f9476 + #[test] + fn sha256_minimal_wasm_fixture() { + let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("minimal.wasm"); + let wasm_bytes = fs::read(&fixture_path).expect("failed to read minimal.wasm fixture"); + let hash = compute_local_wasm_hash(&wasm_bytes); + assert_eq!( + hash, + "93a44bbb96c751218e4c00d479e4c14358122a389acca16205b1e4d0dc5f9476" + ); + assert_eq!(hash.len(), 64); + } + + /// Hashing an empty slice must not panic and must equal the well-known + /// SHA-256 digest of the empty string. + #[test] + fn sha256_empty_input() { + let hash = compute_local_wasm_hash(b""); + assert_eq!( + hash, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + /// Round-trip via a real (temporary) file to confirm fs::read → SHA-256 + /// produces a 64-char hex string. + #[test] + fn sha256_real_file_round_trip() { + let dir = tempdir().expect("failed to create temp dir"); + let wasm_path = dir.path().join("token.wasm"); + let wasm_magic: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; + fs::write(&wasm_path, wasm_magic).expect("failed to write wasm"); + let bytes = fs::read(&wasm_path).expect("failed to read wasm"); + + let hash = compute_local_wasm_hash(&bytes); + assert_eq!(hash.len(), 64); + assert_eq!( + hash, + "93a44bbb96c751218e4c00d479e4c14358122a389acca16205b1e4d0dc5f9476" + ); + } + + // --------------------------------------------------------------------------- + // Unchanged helper tests + // --------------------------------------------------------------------------- + #[test] fn builds_expected_deploy_command() { let command = build_stellar_deploy_command( @@ -318,15 +404,4 @@ mod tests { assert!(!is_wasm_above_size_limit(128.0)); assert!(is_wasm_above_size_limit(128.1)); } - - #[test] - fn can_hash_real_wasm_file_contents() { - let dir = tempdir().expect("failed to create temp dir"); - let wasm_path = dir.path().join("token.wasm"); - fs::write(&wasm_path, [0, 97, 115, 109, 1, 0, 0, 0]).expect("failed to write wasm"); - let bytes = fs::read(&wasm_path).expect("failed to read wasm"); - - let hash = compute_local_wasm_hash(&bytes); - assert_eq!(hash.len(), 16); - } } diff --git a/src/commands/new.rs b/src/commands/new.rs index 80c17162..77addf1e 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -62,8 +62,15 @@ pub fn handle(cmd: NewCommands) -> Result<()> { } } -fn search_templates(query: &str) -> Result<()> { - let results = templates::search_templates(query, None)?; +fn search_templates(query: &str, tags: Option<&str>) -> Result<()> { + let tag_list: Option> = tags.map(|t| { + t.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }); + + let results = templates::search_templates(query, tag_list.as_deref())?; p::header(&format!("Template search results for '{}'", query)); if let Some(ref tags) = tag_list { diff --git a/src/commands/template.rs b/src/commands/template.rs index 852dad5f..90985783 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -52,13 +52,13 @@ pub enum TemplateCommands { pub fn handle(cmd: TemplateCommands) -> Result<()> { match cmd { - TemplateCommands::Publish { - path, - name, - description, - author, - tags, - version + TemplateCommands::Publish { + path, + name, + description, + author, + tags, + version, } => publish(path, name, description, author, tags, version), TemplateCommands::List => list(), TemplateCommands::Search { query, tags } => search(query, tags), @@ -69,55 +69,55 @@ pub fn handle(cmd: TemplateCommands) -> Result<()> { } fn publish( - path: PathBuf, - name: Option, - description: Option, - author: Option, - tags: Option, - version: String + path: PathBuf, + name: Option, + description: Option, + author: Option, + tags: Option, + version: String, ) -> Result<()> { - // Prompt for missing information - let name = name.unwrap_or_else(|| { - Input::new() + // Resolve name interactively if not provided + let name = match name { + Some(n) => n, + None => Input::new() .with_prompt("Template name") - .interact_text() - .unwrap() - }); - - let description = description.unwrap_or_else(|| { - Input::new() - .with_prompt("Template description") - .interact_text() - .unwrap() - }); - - let author = author.unwrap_or_else(|| { - Input::new() - .with_prompt("Author name") - .interact_text() - .unwrap() - }); - - let tags_str = tags.unwrap_or_else(|| { - Input::new() - .with_prompt("Tags (comma-separated)") - .interact_text() - .unwrap_or_default() - }); - - let tags: Vec = tags_str.split(',') + .interact_text()?, + }; + let description = match description { + Some(d) => d, + None => Input::new() + .with_prompt("Description") + .interact_text()?, + }; + let author = match author { + Some(a) => a, + None => Input::new() + .with_prompt("Author") + .default("unknown".to_string()) + .interact_text()?, + }; + let tag_list: Vec = tags + .unwrap_or_default() + .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); - - let version_clone = version.clone(); - templates::publish_template(&path, name.clone(), description, author, tags, version)?; + + let template = + templates::publish_template(&path, name, description, author, tag_list, version)?; p::header("Template Publish"); p::success("Template registered successfully"); - p::kv_accent("Name", &name); - p::kv("Version", &version_clone); - + p::kv_accent("Name", &template.name); + p::kv("Version", &template.version); + p::kv("Source", &template.source.to_string()); + if !template.tags.is_empty() { + p::kv("Tags", &template.tags.join(", ")); + } + if let Some(ref path) = template.path { + p::kv("Path", path); + } + Ok(()) } @@ -136,7 +136,7 @@ fn list() -> Result<()> { if !template.tags.is_empty() { p::kv("Tags", &template.tags.join(", ")); } - if let Some(path) = template.path.as_ref() { + if let Some(ref path) = template.path { p::kv("Path", path); } if i + 1 < registry.templates.len() { @@ -217,4 +217,4 @@ fn init() -> Result<()> { // This would initialize with default templates p::success("Template registry initialized"); Ok(()) -} \ No newline at end of file +} diff --git a/src/utils/config.rs b/src/utils/config.rs index 803683bf..2d9619e1 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -431,8 +431,13 @@ mod tests { #[test] fn test_valid_plain_secret_key() { - let secret = "SAW46Z7TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNT"; - assert!(validate_secret_key(secret).is_ok()); + let Ok(secret) = std::env::var("STARFORGE_TEST_SECRET_KEY") else { + eprintln!( + "skipping test_valid_plain_secret_key: STARFORGE_TEST_SECRET_KEY is not set" + ); + return; + }; + assert!(validate_secret_key(&secret).is_ok()); } #[test] diff --git a/src/utils/templates.rs b/src/utils/templates.rs index cf8a7e10..f2d59f49 100644 --- a/src/utils/templates.rs +++ b/src/utils/templates.rs @@ -5,10 +5,40 @@ use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct TemplateRegistry { + #[serde(default)] + pub version: String, #[serde(default)] pub templates: Vec, } +/// Describes where a template's source files live. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum TemplateSource { + /// Clone from a remote git repository. + Git { + url: String, + #[serde(default)] + branch: Option, + }, + /// Copy from a local directory on disk. + Local { path: String }, + /// A built-in template bundled with StarForge. + Builtin { id: String }, +} + +impl std::fmt::Display for TemplateSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TemplateSource::Git { url, branch } => match branch { + Some(b) => write!(f, "git:{} (branch: {})", url, b), + None => write!(f, "git:{}", url), + }, + TemplateSource::Local { path } => write!(f, "local:{}", path), + TemplateSource::Builtin { id } => write!(f, "builtin:{}", id), + } + } +} #[derive(Debug, Clone, Serialize, Deserialize)] pub enum TemplateSource { Git { url: String, branch: Option }, @@ -92,6 +122,16 @@ fn template_storage_dir() -> Result { Ok(dir) } +/// Returns the user-level templates directory where published templates are stored. +fn templates_dir() -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let dir = home.join(".starforge").join("templates").join("local"); + if !dir.exists() { + fs::create_dir_all(&dir).with_context(|| format!("Failed to create {}", dir.display()))?; + } + Ok(dir) +} + pub fn load_registry() -> Result { let path = registry_path()?; if !path.exists() { @@ -164,6 +204,42 @@ pub fn get_template(name: &str) -> Result { .ok_or_else(|| anyhow::anyhow!("Template '{}' not found in registry", name)) } +pub fn template_source_content(name: &str) -> Result> { + let entry = match get_template(name) { + Ok(entry) => entry, + Err(_) => return Ok(None), + }; + + let content = match &entry.source { + TemplateSource::Builtin { id } => { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("templates") + .join("examples") + .join(id) + .join("src") + .join("lib.rs"); + if path.exists() { + Some(fs::read_to_string(&path) + .with_context(|| format!("Failed to read built-in template at {}", path.display()))?) + } else { + None + } + } + TemplateSource::Local { path } => { + let lib_rs = Path::new(path).join("src").join("lib.rs"); + if lib_rs.exists() { + Some(fs::read_to_string(&lib_rs) + .with_context(|| format!("Failed to read template source at {}", lib_rs.display()))?) + } else { + None + } + } + TemplateSource::Git { .. } => None, + }; + + Ok(content) +} + pub fn add_template(entry: TemplateEntry) -> Result<()> { let mut registry = load_registry()?; @@ -286,7 +362,7 @@ pub fn publish_template( author: String, tags: Vec, version: String, -) -> Result<()> { +) -> Result { if !template_path.exists() { anyhow::bail!("Template path does not exist: {}", template_path.display()); } @@ -311,6 +387,7 @@ pub fn publish_template( source: TemplateSource::Local { path: dest.to_string_lossy().to_string(), }, + path: None, created_at: chrono::Utc::now().to_rfc3339(), updated_at: chrono::Utc::now().to_rfc3339(), downloads: 0, @@ -318,9 +395,9 @@ pub fn publish_template( path: None, }; - add_template(entry)?; + add_template(entry.clone())?; - Ok(()) + Ok(entry) } #[allow(dead_code)] @@ -358,6 +435,7 @@ mod tests { author: "DeFi Team".to_string(), tags: vec!["defi".to_string(), "dex".to_string(), "amm".to_string()], source: TemplateSource::Builtin { id: "uniswap-v2".to_string() }, + path: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), downloads: 100, diff --git a/tests/deploy_wasm_hash_test.rs b/tests/deploy_wasm_hash_test.rs new file mode 100644 index 00000000..f53de44c --- /dev/null +++ b/tests/deploy_wasm_hash_test.rs @@ -0,0 +1,136 @@ +//! Integration tests for the WASM SHA-256 hash used in `starforge deploy`. +//! +//! These tests exercise the hash function from outside the crate (via the +//! public fixture file) so they complement the unit tests inside +//! `src/commands/deploy.rs`. +//! +//! # Relationship to `stellar contract` +//! +//! The SHA-256 of a `.wasm` file is the same value that +//! `stellar contract inspect --wasm ` prints as the *WASM hash*. +//! Soroban uses this digest to deduplicate uploaded contract code on-chain. +//! +//! # Fixture +//! +//! `tests/fixtures/minimal.wasm` is a structurally minimal WASM binary: +//! 4-byte magic (`\0asm`) + 4-byte version (`\x01\x00\x00\x00`), 8 bytes total. +//! Its SHA-256 is `93a44bbb96c751218e4c00d479e4c14358122a389acca16205b1e4d0dc5f9476`. + +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::PathBuf; + +/// Helper that mirrors `compute_wasm_sha256` in `src/commands/deploy.rs`. +/// Kept local so these integration tests have zero coupling to internal APIs. +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// Path to the minimal WASM fixture relative to the workspace root. +fn fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/minimal.wasm") +} + +// --------------------------------------------------------------------------- +// Fixture integrity +// --------------------------------------------------------------------------- + +#[test] +fn fixture_file_exists() { + assert!( + fixture_path().exists(), + "tests/fixtures/minimal.wasm must exist — re-run the fixture generator" + ); +} + +#[test] +fn fixture_file_has_correct_size() { + let bytes = fs::read(fixture_path()).expect("should be able to read fixture"); + assert_eq!(bytes.len(), 8, "minimal.wasm must be exactly 8 bytes (magic + version)"); +} + +#[test] +fn fixture_file_starts_with_wasm_magic() { + let bytes = fs::read(fixture_path()).expect("should be able to read fixture"); + // WASM magic: \0asm + assert_eq!(&bytes[0..4], &[0x00, 0x61, 0x73, 0x6d], "first 4 bytes must be WASM magic"); + // WASM version 1 + assert_eq!(&bytes[4..8], &[0x01, 0x00, 0x00, 0x00], "bytes 4-7 must be WASM version 1"); +} + +// --------------------------------------------------------------------------- +// Known-answer tests — fixture SHA-256 +// --------------------------------------------------------------------------- + +/// The SHA-256 of `tests/fixtures/minimal.wasm` must match the digest +/// documented in `tests/fixtures/README.md`. +/// +/// If this test fails after touching the fixture, regenerate it and update +/// both this constant and the README. +#[test] +fn fixture_sha256_matches_known_digest() { + const EXPECTED: &str = "93a44bbb96c751218e4c00d479e4c14358122a389acca16205b1e4d0dc5f9476"; + + let bytes = fs::read(fixture_path()).expect("should be able to read fixture"); + let got = sha256_hex(&bytes); + + assert_eq!( + got, EXPECTED, + "SHA-256 of minimal.wasm changed — update the fixture or the expected digest" + ); +} + +/// The digest must always be a 64-character lowercase hex string. +#[test] +fn fixture_sha256_is_64_hex_chars() { + let bytes = fs::read(fixture_path()).expect("should be able to read fixture"); + let hash = sha256_hex(&bytes); + + assert_eq!(hash.len(), 64, "SHA-256 hex output must be exactly 64 characters"); + assert!( + hash.chars().all(|c| c.is_ascii_hexdigit()), + "SHA-256 hex output must only contain 0-9 a-f characters, got: {hash}" + ); +} + +// --------------------------------------------------------------------------- +// Property tests +// --------------------------------------------------------------------------- + +/// Hashing the same bytes twice must produce the same digest (determinism). +#[test] +fn sha256_is_deterministic_for_fixture() { + let bytes = fs::read(fixture_path()).expect("should be able to read fixture"); + assert_eq!(sha256_hex(&bytes), sha256_hex(&bytes)); +} + +/// Two different inputs must produce different digests. +#[test] +fn sha256_distinguishes_different_wasm_bytes() { + let bytes = fs::read(fixture_path()).expect("should be able to read fixture"); + let mut modified = bytes.clone(); + // Flip the last byte of the version field (0x00 → 0xFF). + *modified.last_mut().unwrap() = 0xFF; + + assert_ne!( + sha256_hex(&bytes), + sha256_hex(&modified), + "modifying a single byte must change the SHA-256 digest" + ); +} + +/// Changing only the WASM version field must change the hash — verifies the +/// hash covers the entire file, not just the magic prefix. +#[test] +fn sha256_covers_version_field() { + // Version 1 (canonical) + let v1: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; + // Hypothetical version 2 + let v2: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x02, 0x00, 0x00, 0x00]; + + assert_ne!( + sha256_hex(v1), + sha256_hex(v2), + "different WASM versions must produce different hashes" + ); +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 00000000..a7fafda2 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,53 @@ +# Test Fixtures + +This directory contains binary fixtures used by StarForge's test suite. + +## minimal.wasm + +A **structurally minimal** WebAssembly module consisting of only the WASM +magic number and version field — the smallest valid (parseable) WASM header: + +| Offset | Bytes | Description | +|--------|-----------------------------|------------------------| +| 0–3 | `00 61 73 6d` | WASM magic `\0asm` | +| 4–7 | `01 00 00 00` | WASM version 1 | + +### How it was generated + +```python +data = bytes([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]) +with open("tests/fixtures/minimal.wasm", "wb") as f: + f.write(data) +``` + +### Known SHA-256 digest + +``` +93a44bbb96c751218e4c00d479e4c14358122a389acca16205b1e4d0dc5f9476 +``` + +Verified with: + +```sh +# PowerShell +Get-FileHash tests\fixtures\minimal.wasm -Algorithm SHA256 + +# Unix +sha256sum tests/fixtures/minimal.wasm + +# Python +python -c "import hashlib; print(hashlib.sha256(open('tests/fixtures/minimal.wasm','rb').read()).hexdigest())" +``` + +### Relationship to Soroban / `stellar contract` + +The hash produced by `starforge deploy` for a given `.wasm` file is the +**raw SHA-256 of the file bytes**, which is the same value that +`stellar contract inspect --wasm ` reports as the contract hash before +upload. After upload, the Soroban ledger stores contracts keyed by this same +digest. + +> **Note**: `stellar contract deploy` derives the on-chain contract ID from +> the deployer's address and a salt, not from the WASM hash directly. The +> WASM hash is used to deduplicate uploaded code — uploading the same bytes +> twice is a no-op on Soroban.