From 1c00a727c78b154e10af0114619bc1c17d38c021 Mon Sep 17 00:00:00 2001 From: afarukcali Date: Wed, 8 Apr 2026 15:22:38 +0300 Subject: [PATCH] feat : nexus version 0.8.x for sdk-wasm --- Cargo.lock | 31 +- sdk-wasm/Cargo.toml | 23 +- sdk-wasm/crypto_helpers.ts | 305 +++------- sdk-wasm/package.json | 13 +- sdk-wasm/src/crypto.rs | 803 +++++++++----------------- sdk-wasm/src/dag_execute.rs | 274 +++++---- sdk-wasm/src/dag_publish.rs | 171 +++--- sdk-wasm/src/lib.rs | 24 +- sdk-wasm/src/scheduler.rs | 791 +++++++++++++++++++++++++ sdk-wasm/src/walrus.rs | 112 ++++ sdk-wasm/test.html | 2 +- sdk-wasm/tests/dag_validation.test.js | 11 +- 12 files changed, 1523 insertions(+), 1037 deletions(-) create mode 100644 sdk-wasm/src/scheduler.rs create mode 100644 sdk-wasm/src/walrus.rs diff --git a/Cargo.lock b/Cargo.lock index 8cd4997a..3d1d40fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -323,18 +323,6 @@ dependencies = [ "object 0.32.2", ] -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures", - "password-hash", -] - [[package]] name = "ark-bn254" version = "0.4.0" @@ -2269,6 +2257,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", + "rand_core 0.6.4", "serde", "sha2 0.10.9", "subtle", @@ -5536,12 +5525,10 @@ dependencies = [ name = "nexus-sdk-wasm" version = "0.8.2" dependencies = [ - "aes-gcm", "anyhow", - "argon2", "base64 0.21.7", - "bincode", "console_error_panic_hook", + "ed25519-dalek", "getrandom 0.2.16", "hex", "js-sys", @@ -5550,12 +5537,11 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", + "sha2 0.10.9", "thiserror 2.0.17", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "x25519-dalek", - "zeroize", ] [[package]] @@ -6111,17 +6097,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "pasta_curves" version = "0.5.1" diff --git a/sdk-wasm/Cargo.toml b/sdk-wasm/Cargo.toml index a5b49ba8..1b83529e 100644 --- a/sdk-wasm/Cargo.toml +++ b/sdk-wasm/Cargo.toml @@ -16,58 +16,43 @@ categories.workspace = true crate-type = ["cdylib"] [dependencies] -# WASM bindings wasm-bindgen = "0.2" js-sys = "0.3" console_error_panic_hook = "0.1" -# Serde for JSON handling serde.workspace = true serde_json.workspace = true serde-wasm-bindgen = "0.4" -# Error handling anyhow.workspace = true thiserror.workspace = true -# Crypto dependencies (from workspace) hex.workspace = true -x25519-dalek = "2.0" rand.workspace = true base64.workspace = true -bincode.workspace = true -argon2.workspace = true -aes-gcm.workspace = true -zeroize.workspace = true +ed25519-dalek = { version = "2", features = ["rand_core"] } +sha2 = "0.10" -# Web API bindings [dependencies.web-sys] version = "0.3" features = [ "console", "Window", - "Location", - "Navigator", "Storage", "Request", "RequestInit", + "RequestMode", "Response", "Headers", - "Crypto", - "SubtleCrypto", ] -# === Nexus deps === [dependencies.nexus-sdk] workspace = true features = ["wasm_types", "dag"] -# Re-open dependencies table for local crate deps -# (moved crypto deps to main [dependencies] above) -# Fix getrandom for WASM [dependencies.getrandom] version = "0.2" features = ["js"] [dependencies.wasm-bindgen-futures] -version = "0.4" \ No newline at end of file +version = "0.4" diff --git a/sdk-wasm/crypto_helpers.ts b/sdk-wasm/crypto_helpers.ts index 9de645e4..78a9c95f 100644 --- a/sdk-wasm/crypto_helpers.ts +++ b/sdk-wasm/crypto_helpers.ts @@ -1,259 +1,104 @@ -// Enhanced crypto helpers for WASM integration -// Provides CLI-compatible functionality for browser environment +// Crypto helpers for Nexus WASM integration. +// +// Thin convenience layer over the WASM-exported functions for Sui private key +// and Ed25519 tool signing key management via localStorage. + +interface WasmModule { + set_sui_private_key(raw: string): string; + get_sui_private_key_b64(): string | undefined; + sui_key_status(): string; + remove_sui_private_key(): string; + tool_auth_keygen(force: boolean): string; + tool_auth_import_key(raw: string, force: boolean): string; + tool_key_status(): string; + remove_tool_signing_key(): string; + sign_http_request( + method: string, + path: string, + query: string, + body: Uint8Array, + toolId: string, + keyId: string, + ttlMs: number + ): string; + crypto_clear_all(): string; +} class NexusCryptoHelpers { - private masterKey: string | null; - private sessions: Map; + private wasm: WasmModule; - constructor() { - this.masterKey = null; - this.sessions = new Map(); + constructor(wasmModule: WasmModule) { + this.wasm = wasmModule; } - // Securely store master key in localStorage with encryption - async storeMasterKeySecurely(masterKeyHex: string) { - try { - // Use Web Crypto API to encrypt the master key - const keyData = new TextEncoder().encode(masterKeyHex); - const iv = crypto.getRandomValues(new Uint8Array(12)); - - // Generate a storage key from browser-specific data - const storageKeyMaterial = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(navigator.userAgent + location.origin), - { name: "PBKDF2" }, - false, - ["deriveKey"] - ); - - const storageKey = await crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt: new TextEncoder().encode("nexus-wasm-salt"), - iterations: 100000, - hash: "SHA-256", - }, - storageKeyMaterial, - { name: "AES-GCM", length: 256 }, - false, - ["encrypt", "decrypt"] - ); + // ---- Sui private key ---- - const encrypted = await crypto.subtle.encrypt( - { name: "AES-GCM", iv: iv }, - storageKey, - keyData - ); - - // Store IV + encrypted data - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv, 0); - combined.set(new Uint8Array(encrypted), iv.length); - - localStorage.setItem( - "nexus-master-key", - btoa(String.fromCharCode(...combined)) - ); - return { success: true, message: "Master key stored securely" }; - } catch (error) { - return { success: false, error: error.message }; - } + setSuiPrivateKey(raw: string) { + return JSON.parse(this.wasm.set_sui_private_key(raw)); } - // Load master key from secure localStorage - async loadMasterKeySecurely() { - try { - const storedData = localStorage.getItem("nexus-master-key"); - if (!storedData) { - return { success: false, error: "No master key found" }; - } - - // Validate stored data format - if (typeof storedData !== "string" || storedData.length === 0) { - return { success: false, error: "Invalid stored data format" }; - } - - let combined; - try { - const decoded = atob(storedData); - combined = new Uint8Array( - decoded.split("").map((c) => c.charCodeAt(0)) - ); - } catch (decodeError) { - return { success: false, error: "Failed to decode stored data" }; - } - - // Validate combined data length - if (combined.length < 12) { - return { success: false, error: "Stored data too short" }; - } - - const iv = combined.slice(0, 12); - const encrypted = combined.slice(12); - - if (encrypted.length === 0) { - return { success: false, error: "No encrypted data found" }; - } - - // Check if Web Crypto API is available - if (!crypto || !crypto.subtle) { - return { success: false, error: "Web Crypto API not available" }; - } - - // Recreate storage key with better error handling - let storageKeyMaterial; - try { - const keyMaterial = new TextEncoder().encode( - navigator.userAgent + location.origin - ); - - storageKeyMaterial = await crypto.subtle.importKey( - "raw", - keyMaterial, - { name: "PBKDF2" }, - false, - ["deriveKey"] - ); - } catch (importError) { - return { success: false, error: "Failed to import key material" }; - } - - let storageKey; - try { - storageKey = await crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt: new TextEncoder().encode("nexus-wasm-salt"), - iterations: 100000, - hash: "SHA-256", - }, - storageKeyMaterial, - { name: "AES-GCM", length: 256 }, - false, - ["encrypt", "decrypt"] - ); - } catch (deriveError) { - return { success: false, error: "Failed to derive storage key" }; - } - - let decrypted; - try { - decrypted = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: iv }, - storageKey, - encrypted - ); - } catch (decryptError) { - // Provide more specific error information - if (decryptError.name === "OperationError") { - return { - success: false, - error: - "Decryption failed - this usually means the browser context has changed or the stored key is corrupted. Please clear storage and regenerate the master key.", - }; - } - - return { - success: false, - error: `Decryption failed: ${decryptError.message}`, - }; - } - - const masterKeyHex = new TextDecoder().decode(decrypted); - - // Validate the decrypted master key - if (!masterKeyHex || masterKeyHex.length === 0) { - return { success: false, error: "Decrypted master key is empty" }; - } + getSuiPrivateKeyB64(): string | undefined { + return this.wasm.get_sui_private_key_b64(); + } - return { success: true, masterKey: masterKeyHex }; - } catch (error) { - return { success: false, error: error.message }; - } + suiKeyStatus() { + return JSON.parse(this.wasm.sui_key_status()); } - // CLI-compatible crypto init function - async cryptoInitKey(wasmModule: any, force = false) { - try { - // CLI-parity: Check for existing keys first - const existingKeys = await this.checkExistingKeys(); + removeSuiPrivateKey() { + return JSON.parse(this.wasm.remove_sui_private_key()); + } - if (existingKeys.hasAnyKey && !force) { - return { - success: false, - error: "KeyAlreadyExists", - message: - "A different persistent key already exists; re-run with --force if you really want to replace it", - requires_force: true, - }; - } + // ---- Tool signing key ---- - // Call WASM key_init to check status and get instructions - const initResult = wasmModule.key_init(force); - const parsedResult = JSON.parse(initResult); + toolAuthKeygen(force = false) { + return JSON.parse(this.wasm.tool_auth_keygen(force)); + } - if (!parsedResult.success) { - return parsedResult; - } + toolAuthImportKey(raw: string, force = false) { + return JSON.parse(this.wasm.tool_auth_import_key(raw, force)); + } - // If we got a master key to store, store it securely - if (parsedResult.action === "store_key" && parsedResult.master_key) { - const storeResult = await this.storeMasterKeySecurely( - parsedResult.master_key - ); - if (!storeResult.success) { - return { success: false, error: storeResult.error }; - } + toolKeyStatus() { + return JSON.parse(this.wasm.tool_key_status()); + } - return { - success: true, - message: "32-byte master key saved to secure storage", - master_key_preview: parsedResult.master_key.substring(0, 16) + "...", - cli_compatible: true, - }; - } + removeToolSigningKey() { + return JSON.parse(this.wasm.remove_tool_signing_key()); + } - return parsedResult; - } catch (error) { - return { success: false, error: error.message }; - } + // ---- Signed HTTP ---- + + signHttpRequest( + method: string, + path: string, + query: string, + body: Uint8Array, + toolId: string, + keyId: string, + ttlMs = 30_000 + ) { + return JSON.parse( + this.wasm.sign_http_request(method, path, query, body, toolId, keyId, ttlMs) + ); } - // CLI-parity: Check for existing keys (like CLI's keyring check) - async checkExistingKeys() { - try { - const masterKeyExists = localStorage.getItem("nexus-master-key") !== null; - const passphraseExists = - localStorage.getItem("nexus-passphrase") !== null; + // ---- Wipe ---- - return { - hasAnyKey: masterKeyExists || passphraseExists, - masterKeyExists, - passphraseExists, - }; - } catch (error) { - return { - hasAnyKey: false, - masterKeyExists: false, - passphraseExists: false, - }; - } + clearAll() { + return JSON.parse(this.wasm.crypto_clear_all()); } - // Status check (internal) - async getStatus() { - const masterKeyExists = localStorage.getItem("nexus-master-key") !== null; - const sessionsExist = localStorage.getItem("nexus-sessions") !== null; + // ---- Status ---- + getStatus() { + const sui = this.suiKeyStatus(); + const tool = this.toolKeyStatus(); return { - masterKeyExists, - sessionsExist, - cryptoApiAvailable: !!(crypto && crypto.subtle), - userAgent: navigator.userAgent, - origin: location.origin, + suiKeyExists: sui.exists ?? false, + toolKeyExists: tool.exists ?? false, }; } } -// Export for use (window as any).NexusCryptoHelpers = NexusCryptoHelpers; diff --git a/sdk-wasm/package.json b/sdk-wasm/package.json index a8127ce5..c7b67d31 100644 --- a/sdk-wasm/package.json +++ b/sdk-wasm/package.json @@ -1,9 +1,9 @@ { - "name": "nexus-cli-wasm", - "version": "0.2.0-rc.7", - "description": "Nexus CLI WASM bindings for browser/web usage", - "main": "pkg/nexus_cli_wasm.js", - "types": "pkg/nexus_cli_wasm.d.ts", + "name": "nexus-sdk-wasm", + "version": "0.8.2", + "description": "Nexus SDK WASM bindings for browser/web usage", + "main": "pkg/nexus_sdk_wasm.js", + "types": "pkg/nexus_sdk_wasm.d.ts", "files": [ "pkg" ], @@ -30,8 +30,9 @@ "author": "Talus Engineers ", "license": "Apache-2.0", "devDependencies": { + "@jest/globals": "^30.0.2", "jest": "^30.0.2", - "@jest/globals": "^30.0.2" + "wasm-pack": "^0.13.1" }, "jest": { "testEnvironment": "node", diff --git a/sdk-wasm/src/crypto.rs b/sdk-wasm/src/crypto.rs index bc1c8213..4ca3dfdc 100644 --- a/sdk-wasm/src/crypto.rs +++ b/sdk-wasm/src/crypto.rs @@ -1,61 +1,28 @@ -// CLI v0.3.0 Crypto Implementation for WASM -// Direct port of CLI crypto with localStorage instead of OS keyring +//! Ed25519 key management for browser/WASM. +//! +//! Mirrors the CLI's `nexus tool auth keygen` / key-status flow using +//! localStorage instead of the file system. The Sui private key is stored +//! as base64 in localStorage and used by the JS-side Sui SDK to sign +//! transactions. +//! +//! This replaces the old X3DH + Double Ratchet session crypto that was +//! removed from the SDK. use { - aes_gcm::{ - aead::{Aead, KeyInit}, - Aes256Gcm, - Nonce, - }, - argon2::{Algorithm, Argon2, Params, Version}, - base64::{self, Engine as _}, - nexus_sdk::crypto::{ - session::{Message, Session}, - x3dh::{IdentityKey, PreKeyBundle}, - }, - rand::{self, RngCore}, + base64::{engine::general_purpose::STANDARD as B64, Engine as _}, + ed25519_dalek::{SigningKey, VerifyingKey}, + rand::RngCore, serde::{Deserialize, Serialize}, - std::collections::HashMap, + sha2::{Digest, Sha256}, wasm_bindgen::prelude::*, - zeroize::Zeroizing, }; -// === Constants (matching CLI) === +const LS_SUI_PK: &str = "nexus-sui-pk"; +const LS_TOOL_SK: &str = "nexus-tool-signing-key"; -const SERVICE: &str = "nexus-cli-store"; -const USER: &str = "master-key"; -const PASSPHRASE_USER: &str = "passphrase"; - -const KEY_LEN: usize = 32; // 256-bit master key -const SALT_LEN: usize = 16; // 128-bit salt - -// Argon2id parameters (matching CLI exactly) -const ARGON2_MEMORY_KIB: u32 = 64 * 1024; // 64 MiB -const ARGON2_ITERATIONS: u32 = 4; - -// LocalStorage keys (simulating keyring) -fn get_storage_key(user: &str) -> String { - format!("nexus-{}-{}", SERVICE, user) -} - -const SALT_KEY: &str = "nexus-argon2-salt"; -const CRYPTO_CONF_KEY: &str = "nexus-crypto-conf"; - -// === CryptoConf Structure (matching CLI) === - -#[derive(Serialize, Deserialize, Default)] -struct CryptoConf { - identity_key: Option, - sessions: Option, -} - -#[derive(Serialize, Deserialize, Clone)] -struct EncryptedData { - nonce: Vec, - ciphertext: Vec, -} - -// === LocalStorage Helper (simulating keyring) === +// --------------------------------------------------------------------------- +// localStorage helpers +// --------------------------------------------------------------------------- fn local_storage() -> Option { web_sys::window()?.local_storage().ok()? @@ -67,554 +34,330 @@ fn storage_get(key: &str) -> Option { fn storage_set(key: &str, value: &str) -> Result<(), String> { local_storage() - .ok_or("LocalStorage not available")? + .ok_or("localStorage not available")? .set_item(key, value) - .map_err(|_| "Failed to set item".to_string()) + .map_err(|_| "failed to write to localStorage".to_string()) } fn storage_remove(key: &str) { - if let Some(storage) = local_storage() { - let _ = storage.remove_item(key); + if let Some(s) = local_storage() { + let _ = s.remove_item(key); } } -// === Master Key Management (CLI v0.3.0 parity) === - -/// Get master key with 3-tier resolution (matching CLI exactly) -fn get_master_key() -> Result, String> { - // 1. Check passphrase in storage (simulating keyring) - if let Some(passphrase) = storage_get(&get_storage_key(PASSPHRASE_USER)) { - return derive_from_passphrase(&passphrase); - } - - // 2. Check raw key in storage - if let Some(hex_key) = storage_get(&get_storage_key(USER)) { - let bytes = hex::decode(&hex_key).map_err(|e| format!("Hex decode: {}", e))?; - if bytes.len() == KEY_LEN { - let mut key_array = [0u8; KEY_LEN]; - key_array.copy_from_slice(&bytes); - return Ok(Zeroizing::new(key_array)); - } - // Corrupt entry, clean up - storage_remove(&get_storage_key(USER)); - } +// --------------------------------------------------------------------------- +// JSON response helpers +// --------------------------------------------------------------------------- - Err("No persistent master key found; run crypto_init_key() or crypto_set_passphrase()".into()) +fn ok_json(value: serde_json::Value) -> String { + let mut v = value; + v["success"] = serde_json::Value::Bool(true); + v.to_string() } -/// Derive master key from passphrase using Argon2id (CLI parity) -fn derive_from_passphrase(passphrase: &str) -> Result, String> { - let salt = get_or_create_salt()?; - - let params = Params::new(ARGON2_MEMORY_KIB, ARGON2_ITERATIONS, 1, Some(KEY_LEN)) - .map_err(|e| format!("Argon2 params: {}", e))?; - - let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); +fn err_json(msg: &str) -> String { + serde_json::json!({ "success": false, "error": msg }).to_string() +} - let mut key = Zeroizing::new([0u8; KEY_LEN]); - argon2 - .hash_password_into(passphrase.as_bytes(), &salt, &mut *key) - .map_err(|e| format!("Argon2 hash: {}", e))?; +// --------------------------------------------------------------------------- +// Sui private key management (replaces old master-key / passphrase) +// --------------------------------------------------------------------------- - Ok(key) -} +/// Store a Sui Ed25519 private key (base64, hex, or Sui keytool format). +/// +/// This is the web equivalent of `nexus conf set --sui.pk `. +#[wasm_bindgen] +pub fn set_sui_private_key(raw_key: &str) -> String { + let raw = raw_key.trim(); + if raw.is_empty() { + return err_json("private key must not be empty"); + } -/// Get or create salt (matching CLI salt.bin logic) -fn get_or_create_salt() -> Result<[u8; SALT_LEN], String> { - if let Some(salt_b64) = storage_get(SALT_KEY) { - let bytes = base64::engine::general_purpose::STANDARD - .decode(&salt_b64) - .map_err(|e| format!("Salt decode: {}", e))?; - - if bytes.len() == SALT_LEN { - let mut salt = [0u8; SALT_LEN]; - salt.copy_from_slice(&bytes); - return Ok(salt); + match parse_ed25519_private_key(raw) { + Ok(sk) => { + let b64 = B64.encode(sk.to_bytes()); + match storage_set(LS_SUI_PK, &b64) { + Ok(()) => ok_json(serde_json::json!({ + "message": "Sui private key saved to localStorage", + "public_key_hex": hex::encode(VerifyingKey::from(&sk).to_bytes()), + })), + Err(e) => err_json(&e), + } } - // Invalid salt, will recreate - storage_remove(SALT_KEY); + Err(e) => err_json(&format!("invalid key: {e}")), } - - // Generate new salt - let mut salt = [0u8; SALT_LEN]; - rand::rngs::OsRng.fill_bytes(&mut salt); - - // Store in localStorage - let salt_b64 = base64::engine::general_purpose::STANDARD.encode(salt); - storage_set(SALT_KEY, &salt_b64)?; - - Ok(salt) -} - -// === Secret Encryption (CLI parity) === - -/// Encrypt data with master key using AES-256-GCM -fn encrypt_with_master_key( - plaintext: &[u8], - master_key: &[u8; KEY_LEN], -) -> Result { - // Generate random nonce - let mut nonce_bytes = [0u8; 12]; - rand::rngs::OsRng.fill_bytes(&mut nonce_bytes); - - let cipher = Aes256Gcm::new(master_key.into()); - let nonce = Nonce::from_slice(&nonce_bytes); - - let ciphertext = cipher - .encrypt(nonce, plaintext) - .map_err(|e| format!("Encryption failed: {}", e))?; - - Ok(EncryptedData { - nonce: nonce_bytes.to_vec(), - ciphertext, - }) } -/// Decrypt data with master key -fn decrypt_with_master_key( - encrypted: &EncryptedData, - master_key: &[u8; KEY_LEN], -) -> Result, String> { - let cipher = Aes256Gcm::new(master_key.into()); - let nonce = Nonce::from_slice(&encrypted.nonce); - - cipher - .decrypt(nonce, encrypted.ciphertext.as_ref()) - .map_err(|e| format!("Decryption failed: {}", e)) +/// Get the stored Sui private key (base64). +/// JS-side Sui SDK can use this to sign transactions. +#[wasm_bindgen] +pub fn get_sui_private_key_b64() -> Option { + storage_get(LS_SUI_PK) } -// === CryptoConf Management === - -fn load_crypto_conf() -> CryptoConf { - storage_get(CRYPTO_CONF_KEY) - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default() +/// Return status of stored Sui key. +#[wasm_bindgen] +pub fn sui_key_status() -> String { + match storage_get(LS_SUI_PK) { + Some(b64) => { + let pk_hex = B64 + .decode(&b64) + .ok() + .and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok()) + .map(|sk| { + let signing = SigningKey::from_bytes(&sk); + hex::encode(signing.verifying_key().to_bytes()) + }) + .unwrap_or_else(|| "corrupt".to_string()); + ok_json(serde_json::json!({ + "exists": true, + "public_key_hex": pk_hex, + })) + } + None => ok_json(serde_json::json!({ "exists": false })), + } } -fn save_crypto_conf(conf: &CryptoConf) -> Result<(), String> { - let json = serde_json::to_string(conf).map_err(|e| format!("Serialize: {}", e))?; - storage_set(CRYPTO_CONF_KEY, &json) +/// Remove stored Sui private key. +#[wasm_bindgen] +pub fn remove_sui_private_key() -> String { + storage_remove(LS_SUI_PK); + ok_json(serde_json::json!({ "message": "Sui private key removed" })) } -// === CLI Commands (WASM exports) === +// --------------------------------------------------------------------------- +// Tool signing key management (mirrors `nexus tool auth keygen`) +// --------------------------------------------------------------------------- -/// CLI: nexus crypto init-key [--force] +/// Generate a new Ed25519 tool signing key and store it. +/// +/// Web equivalent of `nexus tool auth keygen`. #[wasm_bindgen] -pub fn crypto_init_key(force: bool) -> String { - let result = (|| -> Result { - // Check for existing keys (unless --force) - if !force { - if storage_get(&get_storage_key(PASSPHRASE_USER)).is_some() - || storage_get(&get_storage_key(USER)).is_some() - { - return Err("KeyAlreadyExists: A different persistent key already exists; re-run with force=true".into()); - } - } - - // Generate random 32-byte key - let mut key = [0u8; KEY_LEN]; - rand::rngs::OsRng.fill_bytes(&mut key); - let key_hex = hex::encode(key); - - // Store in localStorage (simulating keyring) - storage_set(&get_storage_key(USER), &key_hex)?; - - // Remove any stale passphrase - storage_remove(&get_storage_key(PASSPHRASE_USER)); - - // Clear crypto conf (sessions invalidated) - save_crypto_conf(&CryptoConf::default())?; - - Ok(serde_json::json!({ - "success": true, - "message": "32-byte master key saved to localStorage", - "key_preview": format!("{}...", &key_hex[..16]), - "cli_compatible": true - }) - .to_string()) - })(); +pub fn tool_auth_keygen(force: bool) -> String { + if !force && storage_get(LS_TOOL_SK).is_some() { + return err_json("tool signing key already exists; call with force=true to overwrite"); + } - match result { - Ok(json) => json, - Err(e) => serde_json::json!({ - "success": false, - "error": e - }) - .to_string(), + let mut sk_bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut sk_bytes); + let signing = SigningKey::from_bytes(&sk_bytes); + let pk_hex = hex::encode(signing.verifying_key().to_bytes()); + let sk_hex = hex::encode(sk_bytes); + + match storage_set(LS_TOOL_SK, &sk_hex) { + Ok(()) => ok_json(serde_json::json!({ + "message": "Ed25519 tool signing key generated", + "private_key_hex": sk_hex, + "public_key_hex": pk_hex, + })), + Err(e) => err_json(&e), } } -/// CLI: nexus crypto set-passphrase [--force] +/// Import an existing tool signing key (hex or base64). #[wasm_bindgen] -pub fn crypto_set_passphrase(passphrase: String, force: bool) -> String { - let result = (|| -> Result { - if passphrase.trim().is_empty() { - return Err("Passphrase cannot be empty".into()); - } +pub fn tool_auth_import_key(raw_key: &str, force: bool) -> String { + if !force && storage_get(LS_TOOL_SK).is_some() { + return err_json("tool signing key already exists; call with force=true to overwrite"); + } - // Check for existing keys (unless --force) - if !force { - if storage_get(&get_storage_key(USER)).is_some() - || storage_get(&get_storage_key(PASSPHRASE_USER)).is_some() - { - return Err("KeyAlreadyExists: A different persistent key already exists; re-run with force=true".into()); + match parse_ed25519_private_key(raw_key.trim()) { + Ok(sk) => { + let sk_hex = hex::encode(sk.to_bytes()); + let pk_hex = hex::encode(sk.verifying_key().to_bytes()); + match storage_set(LS_TOOL_SK, &sk_hex) { + Ok(()) => ok_json(serde_json::json!({ + "message": "tool signing key imported", + "public_key_hex": pk_hex, + })), + Err(e) => err_json(&e), } } - - // Store passphrase - storage_set(&get_storage_key(PASSPHRASE_USER), &passphrase)?; - - // Remove any stale raw key - storage_remove(&get_storage_key(USER)); - - // Clear crypto conf (sessions invalidated) - save_crypto_conf(&CryptoConf::default())?; - - Ok(serde_json::json!({ - "success": true, - "message": "Passphrase stored in localStorage", - "cli_compatible": true - }) - .to_string()) - })(); - - match result { - Ok(json) => json, - Err(e) => serde_json::json!({ - "success": false, - "error": e - }) - .to_string(), + Err(e) => err_json(&format!("invalid key: {e}")), } } -/// CLI: nexus crypto key-status +/// Return status of stored tool signing key. #[wasm_bindgen] -pub fn crypto_key_status() -> String { - if storage_get(&get_storage_key(PASSPHRASE_USER)).is_some() { - serde_json::json!({ - "exists": true, - "source": "passphrase (localStorage)", - "cli_compatible": true - }) - .to_string() - } else if let Some(hex) = storage_get(&get_storage_key(USER)) { - serde_json::json!({ - "exists": true, - "source": "raw-key (localStorage)", - "preview": format!("{}...", &hex[..std::cmp::min(16, hex.len())]), - "cli_compatible": true - }) - .to_string() - } else { - serde_json::json!({ - "exists": false, - "cli_compatible": true - }) - .to_string() +pub fn tool_key_status() -> String { + match storage_get(LS_TOOL_SK) { + Some(sk_hex) => { + let pk_hex = hex::decode(&sk_hex) + .ok() + .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok()) + .map(|sk| hex::encode(SigningKey::from_bytes(&sk).verifying_key().to_bytes())) + .unwrap_or_else(|| "corrupt".to_string()); + ok_json(serde_json::json!({ + "exists": true, + "public_key_hex": pk_hex, + })) + } + None => ok_json(serde_json::json!({ "exists": false })), } } -/// CLI: nexus crypto generate-identity-key +/// Remove stored tool signing key. #[wasm_bindgen] -pub fn crypto_generate_identity_key() -> String { - let result = (|| -> Result { - let master_key = get_master_key()?; - - // Generate fresh identity key - let identity_key = IdentityKey::generate(); - - // Serialize - let bytes = bincode::serialize(&identity_key).map_err(|e| format!("Serialize: {}", e))?; - - // Encrypt with master key - let encrypted = encrypt_with_master_key(&bytes, &master_key)?; - - // Save to CryptoConf - let mut conf = load_crypto_conf(); - conf.identity_key = Some(encrypted); - conf.sessions = None; // Invalidate sessions - save_crypto_conf(&conf)?; - - Ok(serde_json::json!({ - "success": true, - "message": "Identity key generated and stored (encrypted)", - "warning": "All existing sessions have been invalidated", - "cli_compatible": true - }) - .to_string()) - })(); - - match result { - Ok(json) => json, - Err(e) => serde_json::json!({ - "success": false, - "error": e - }) - .to_string(), - } +pub fn remove_tool_signing_key() -> String { + storage_remove(LS_TOOL_SK); + ok_json(serde_json::json!({ "message": "tool signing key removed" })) } -/// Get identity key from CryptoConf -fn get_identity_key() -> Result { - let master_key = get_master_key()?; - let conf = load_crypto_conf(); - - let encrypted = conf - .identity_key - .ok_or("No identity key found; run crypto_generate_identity_key() first")?; - - let bytes = decrypt_with_master_key(&encrypted, &master_key)?; - - bincode::deserialize(&bytes).map_err(|e| format!("Deserialize identity key: {}", e)) +// --------------------------------------------------------------------------- +// Signed HTTP claims (mirrors `sdk/src/signed_http/v1`) +// --------------------------------------------------------------------------- + +/// Minimal signed-HTTP claims structure. +/// This is a simplified version of the SDK's `V1Claims` designed for +/// browser-side use. +#[derive(Serialize, Deserialize)] +struct SignedHttpClaims { + version: u8, + method: String, + path: String, + query: String, + body_sha256: String, + iat_ms: u64, + exp_ms: u64, + nonce: String, + #[serde(skip_serializing_if = "Option::is_none")] + leader_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tool_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + key_id: Option, } -// === X3DH Session (with persistent identity key) === - +/// Sign an HTTP request's claims blob with the stored tool signing key. +/// +/// Returns JSON with `sig_input` (base64url) and `signature` (base64url) +/// that should be placed into `X-Nexus-Sig-Input` and `X-Nexus-Sig` headers. #[wasm_bindgen] -pub fn crypto_auth(peer_bundle_bytes: &[u8]) -> String { - let result = (|| -> Result { - // Load persistent identity key - let identity_key = get_identity_key()?; - - // Deserialize peer bundle - let peer_bundle: PreKeyBundle = bincode::deserialize(peer_bundle_bytes) - .map_err(|e| format!("Deserialize bundle: {}", e))?; - - // X3DH handshake - let first_message = b"nexus auth"; - let (initial_msg, session) = Session::initiate(&identity_key, &peer_bundle, first_message) - .map_err(|e| format!("X3DH failed: {}", e))?; - - let initial_message = match initial_msg { - Message::Initial(msg) => msg, - _ => return Err("Expected Initial message".into()), +pub fn sign_http_request( + method: &str, + path: &str, + query: &str, + body: &[u8], + tool_id: &str, + key_id: &str, + ttl_ms: u64, +) -> String { + let result = (|| -> Result { + let sk_hex = + storage_get(LS_TOOL_SK).ok_or("no tool signing key; call tool_auth_keygen() first")?; + let sk_bytes = hex::decode(&sk_hex).map_err(|e| format!("hex decode: {e}"))?; + let sk_arr: [u8; 32] = sk_bytes + .try_into() + .map_err(|_| "corrupt tool signing key")?; + let signing = SigningKey::from_bytes(&sk_arr); + + let now_ms = js_sys::Date::now() as u64; + let mut nonce_bytes = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut nonce_bytes); + + let body_hash = Sha256::digest(body); + + let claims = SignedHttpClaims { + version: 1, + method: method.to_uppercase(), + path: path.to_string(), + query: query.to_string(), + body_sha256: hex::encode(body_hash), + iat_ms: now_ms, + exp_ms: now_ms + ttl_ms, + nonce: hex::encode(nonce_bytes), + leader_id: None, + tool_id: Some(tool_id.to_string()), + key_id: Some(key_id.to_string()), }; - // Serialize initial message - let initial_message_bytes = bincode::serialize(&initial_message) - .map_err(|e| format!("Serialize message: {}", e))?; + let claims_bytes = + serde_json::to_vec(&claims).map_err(|e| format!("serialize claims: {e}"))?; - // Store session - let session_id = *session.id(); - save_session(session_id, session)?; + use ed25519_dalek::Signer; + let signature = signing.sign(&claims_bytes); + + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + let sig_input_b64 = URL_SAFE_NO_PAD.encode(&claims_bytes); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); Ok(serde_json::json!({ - "success": true, - "session_id": hex::encode(session_id), - "initial_message_b64": base64::engine::general_purpose::STANDARD.encode(&initial_message_bytes), - "cli_compatible": true - }) - .to_string()) + "sig_input": sig_input_b64, + "signature": sig_b64, + "headers": { + "X-Nexus-Sig-Version": "1", + "X-Nexus-Sig-Input": sig_input_b64, + "X-Nexus-Sig": sig_b64, + } + })) })(); match result { - Ok(json) => json, - Err(e) => serde_json::json!({ - "success": false, - "error": e - }) - .to_string(), + Ok(v) => ok_json(v), + Err(e) => err_json(&e), } } -// === Session Management === - -thread_local! { - pub(crate) static SESSIONS: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); -} - -fn save_session(session_id: [u8; 32], session: Session) -> Result<(), String> { - SESSIONS.with(|sessions| { - sessions.borrow_mut().insert(session_id, session); - }); - - // Persist to CryptoConf - persist_sessions() -} - -pub(crate) fn persist_sessions() -> Result<(), String> { - let master_key = get_master_key()?; - let mut conf = load_crypto_conf(); - - // Serialize all sessions - let sessions_bytes = SESSIONS.with(|sessions| { - let sessions = sessions.borrow(); - bincode::serialize(&*sessions).map_err(|e| format!("Serialize sessions: {}", e)) - })?; - - // Encrypt - let encrypted = encrypt_with_master_key(&sessions_bytes, &master_key)?; - conf.sessions = Some(encrypted); - - save_crypto_conf(&conf) -} - -pub(crate) fn load_sessions() -> Result<(), String> { - let master_key = get_master_key()?; - let conf = load_crypto_conf(); - - if let Some(encrypted) = conf.sessions { - let bytes = decrypt_with_master_key(&encrypted, &master_key)?; - let sessions_map: HashMap<[u8; 32], Session> = - bincode::deserialize(&bytes).map_err(|e| format!("Deserialize sessions: {}", e))?; - - SESSIONS.with(|sessions| { - *sessions.borrow_mut() = sessions_map; - }); - } - - Ok(()) -} +// --------------------------------------------------------------------------- +// Clear all stored crypto material +// --------------------------------------------------------------------------- +/// Wipe all Nexus crypto data from localStorage. #[wasm_bindgen] -pub fn get_session_count() -> usize { - SESSIONS.with(|sessions| sessions.borrow().len()) +pub fn crypto_clear_all() -> String { + storage_remove(LS_SUI_PK); + storage_remove(LS_TOOL_SK); + ok_json(serde_json::json!({ "message": "all crypto data cleared" })) } -/// Encrypt input ports with active session (CLI parity) -#[wasm_bindgen] -pub fn encrypt_entry_ports(input_json: &str, encrypted_ports_json: &str) -> String { - let result = (|| -> Result { - // Load sessions if not already loaded - let _ = load_sessions(); - - let mut input_data: serde_json::Value = - serde_json::from_str(input_json).map_err(|e| e.to_string())?; - let encrypted_ports: HashMap> = - serde_json::from_str(encrypted_ports_json).map_err(|e| e.to_string())?; - - if encrypted_ports.is_empty() { - return Ok(serde_json::json!({ - "success": true, - "input_data": input_data, - "encrypted_count": 0 - }) - .to_string()); - } - - let mut encrypted_count = 0; +// --------------------------------------------------------------------------- +// Private key parsing (mirrors sdk/src/signed_http/keys.rs logic) +// --------------------------------------------------------------------------- - SESSIONS.with(|sessions| { - let mut sessions = sessions.borrow_mut(); +fn parse_ed25519_private_key(raw: &str) -> Result { + let raw = raw.trim(); + let raw_no_0x = raw.strip_prefix("0x").unwrap_or(raw); - if sessions.is_empty() { - return Err("No active sessions; run crypto_auth() first".to_string()); - } - - let (_session_id, session) = sessions.iter_mut().next().ok_or("No sessions")?; - - for (vertex, ports) in &encrypted_ports { - for port in ports { - if let Some(slot) = input_data.get_mut(vertex).and_then(|v| v.get_mut(port)) { - let plaintext = slot.take(); - let bytes = serde_json::to_vec(&plaintext).map_err(|e| e.to_string())?; - - let msg = session - .encrypt(&bytes) - .map_err(|e| format!("Encrypt: {}", e))?; - - let Message::Standard(pkt) = msg else { - return Err("Expected StandardMessage".into()); - }; - - // CLI parity: serialize StandardMessage directly as JSON object - *slot = serde_json::to_value(&pkt).map_err(|e| e.to_string())?; - encrypted_count += 1; - } - } - } - - session.commit_sender(None); - Ok(()) - })?; + let looks_hex = raw.starts_with("0x") + || ((raw_no_0x.len() == 64 || raw_no_0x.len() == 66) + && raw_no_0x.chars().all(|c| c.is_ascii_hexdigit())); - // Persist updated sessions - persist_sessions()?; - - Ok(serde_json::json!({ - "success": true, - "input_data": input_data, - "encrypted_count": encrypted_count - }) - .to_string()) - })(); - - match result { - Ok(json) => json, - Err(e) => serde_json::json!({ - "success": false, - "error": e - }) - .to_string(), + if looks_hex { + let bytes = hex::decode(raw_no_0x).map_err(|e| format!("hex: {e}"))?; + return signing_key_from_bytes(&bytes); } -} -/// Decrypt output data with active session (CLI parity) -#[wasm_bindgen] -pub fn decrypt_output_data(encrypted_json: &str) -> String { - let result = (|| -> Result { - // Load sessions if not already loaded - let _ = load_sessions(); + // base64 / base64url + let try_b64 = |engine: &base64::engine::general_purpose::GeneralPurpose| -> Option> { + engine.decode(raw.as_bytes()).ok() + }; - // CLI parity: StandardMessage is serialized as JSON object, not bincode - let standard_msg: nexus_sdk::crypto::session::StandardMessage = - serde_json::from_str(encrypted_json) - .map_err(|e| format!("Parse StandardMessage: {}", e))?; + use base64::engine::general_purpose::*; + let bytes = try_b64(&STANDARD) + .or_else(|| try_b64(&STANDARD_NO_PAD)) + .or_else(|| try_b64(&URL_SAFE)) + .or_else(|| try_b64(&URL_SAFE_NO_PAD)) + .ok_or("expected hex or base64 encoded key")?; - let decrypted_data = SESSIONS.with(|sessions| { - let mut sessions = sessions.borrow_mut(); + signing_key_from_bytes(&bytes) +} - if sessions.is_empty() { - return Err("No active sessions".to_string()); +fn signing_key_from_bytes(bytes: &[u8]) -> Result { + match bytes.len() { + 32 => { + let arr: [u8; 32] = bytes.try_into().unwrap(); + Ok(SigningKey::from_bytes(&arr)) + } + 33 => { + if bytes[0] != 0x00 { + return Err(format!( + "unsupported Sui key scheme flag 0x{:02x} (expected 0x00 for Ed25519)", + bytes[0] + )); } - - let (_session_id, session) = sessions.iter_mut().next().ok_or("No sessions")?; - - let decrypted_bytes = session - .decrypt(&Message::Standard(standard_msg)) - .map_err(|e| format!("Decrypt: {}", e))?; - - let data: serde_json::Value = serde_json::from_slice(&decrypted_bytes) - .map_err(|e| format!("Parse JSON: {}", e))?; - - session.commit_receiver(None, None); - Ok(data) - })?; - - // Persist updated sessions - persist_sessions()?; - - Ok(serde_json::json!({ - "success": true, - "data": decrypted_data - }) - .to_string()) - })(); - - match result { - Ok(json) => json, - Err(e) => serde_json::json!({ - "success": false, - "error": e - }) - .to_string(), + let arr: [u8; 32] = bytes[1..].try_into().unwrap(); + Ok(SigningKey::from_bytes(&arr)) + } + len => Err(format!("invalid key length {len}, expected 32 or 33 bytes")), } } - -/// Clear all crypto data (for testing) -#[wasm_bindgen] -pub fn crypto_clear_all() -> String { - storage_remove(&get_storage_key(USER)); - storage_remove(&get_storage_key(PASSPHRASE_USER)); - storage_remove(SALT_KEY); - storage_remove(CRYPTO_CONF_KEY); - - SESSIONS.with(|sessions| sessions.borrow_mut().clear()); - - serde_json::json!({ - "success": true, - "message": "All crypto data cleared" - }) - .to_string() -} diff --git a/sdk-wasm/src/dag_execute.rs b/sdk-wasm/src/dag_execute.rs index 2b539f99..ac4c4a11 100644 --- a/sdk-wasm/src/dag_execute.rs +++ b/sdk-wasm/src/dag_execute.rs @@ -1,19 +1,17 @@ use { + nexus_sdk::types::DEFAULT_ENTRY_GROUP, serde::{Deserialize, Serialize}, - std::collections::HashMap, + std::collections::{HashMap, HashSet}, wasm_bindgen::prelude::*, }; -/// DAG execution operation sequence for JS-side transaction building #[derive(Serialize, Deserialize)] pub struct DagExecutionSequence { pub operation_type: String, pub steps: Vec, pub execution_params: ExecutionParams, - pub encryption_info: EncryptionInfo, } -/// Individual DAG execution operation #[derive(Serialize, Deserialize)] pub struct DagExecutionOperation { pub operation: String, @@ -22,7 +20,6 @@ pub struct DagExecutionOperation { pub parameters: Option, } -/// Execution parameters #[derive(Serialize, Deserialize)] pub struct ExecutionParams { pub dag_id: String, @@ -32,15 +29,6 @@ pub struct ExecutionParams { pub gas_coin_id: Option, } -/// Encryption information for entry ports -#[derive(Serialize, Deserialize)] -pub struct EncryptionInfo { - pub has_encrypted_ports: bool, - pub encrypted_ports: HashMap>, // vertex -> [port_names] - pub requires_session: bool, -} - -/// WASM-exported execution result #[wasm_bindgen] pub struct ExecutionResult { is_success: bool, @@ -66,85 +54,70 @@ impl ExecutionResult { } } -/// ✅ Build DAG execution transaction using SDK (CLI-compatible with auto-encryption) +/// Build a DAG execution transaction matching the SDK's `prepare_dag_execution` flow. +/// +/// Input data is plain JSON. The Sui transaction is signed JS-side with the +/// Sui SDK using the stored private key. +/// +/// # Walrus (remote storage) parameters +/// +/// When using remote storage, call `upload_json_to_walrus` for each remote +/// port first, then pass the blob IDs here. +/// +/// * `remote_ports_json` – JSON array of `"vertex.port"` strings, +/// e.g. `["add-vertex.a"]` +/// * `remote_ports_blob_ids_json` – JSON object mapping `"vertex.port"` to +/// Walrus blob IDs #[wasm_bindgen] pub fn build_dag_execution_transaction( dag_id: &str, entry_group: &str, input_json: &str, - encrypted_ports_json: &str, gas_budget: &str, + priority_fee_per_gas_unit: Option, + remote_ports_json: Option, + remote_ports_blob_ids_json: Option, ) -> ExecutionResult { - use web_sys::console; - let result = (|| -> Result> { - // Parse inputs - let mut input_data: serde_json::Value = serde_json::from_str(input_json)?; - let encrypted_ports: std::collections::HashMap> = - serde_json::from_str(encrypted_ports_json)?; + let input_data: serde_json::Value = serde_json::from_str(input_json)?; + // Match `nexus_sdk` / CLI: `execute(..., entry_group.unwrap_or(DEFAULT_ENTRY_GROUP), ...)` + let entry_group = if entry_group.trim().is_empty() { + DEFAULT_ENTRY_GROUP + } else { + entry_group + }; let gas_budget_u64: u64 = gas_budget.parse()?; - // 🔐 CLI-parity: If there are encrypted ports, encrypt input data first - // BUT: Check if data is already encrypted (array = already encrypted) - if !encrypted_ports.is_empty() { - // Check if any encrypted port is already in encrypted form (byte array) - let mut needs_encryption = false; - for (vertex_name, port_names) in &encrypted_ports { - if let Some(vertex_obj) = input_data.get(vertex_name) { - for port_name in port_names { - if let Some(port_value) = vertex_obj.get(port_name) { - // If port_value is NOT an array, it needs encryption - // If it IS an array, it's already encrypted - if !port_value.is_array() { - needs_encryption = true; - break; - } - } - } - } - if needs_encryption { - break; - } - } - - if needs_encryption { - console::log_1( - &"🔐 Encrypted ports detected, encrypting input data (CLI-parity)...".into(), - ); - - // Call the encryption function from crypto.rs (master key loaded internally) - let encrypt_result = crate::encrypt_entry_ports(input_json, encrypted_ports_json); - - // Parse the encryption result - let encrypt_response: serde_json::Value = serde_json::from_str(&encrypt_result)?; - - if !encrypt_response["success"].as_bool().unwrap_or(false) { - let error_msg = encrypt_response["error"] - .as_str() - .unwrap_or("Encryption failed"); - return Err(format!("Input encryption failed: {}", error_msg).into()); - } - - // Use the encrypted input data - input_data = encrypt_response["input_data"].clone(); - console::log_1( - &format!( - "✅ Successfully encrypted {} ports (CLI-parity)", - encrypt_response["encrypted_count"].as_u64().unwrap_or(0) + let priority_fee: u64 = priority_fee_per_gas_unit + .as_ref() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let remote_ports: HashSet = remote_ports_json + .as_ref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + + let remote_blob_ids: HashMap = remote_ports_blob_ids_json + .as_ref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + + if !remote_ports.is_empty() { + for rp in &remote_ports { + if !remote_blob_ids.contains_key(rp) { + return Err(format!( + "Missing blob ID for remote port '{}'. Call upload_json_to_walrus first.", + rp ) - .into(), - ); - } else { - console::log_1(&"Input data already encrypted, skipping encryption (prevent double encryption)".into()); + .into()); + } } - } else { - console::log_1(&"No encrypted ports, using plaintext input (CLI-parity)".into()); } - // Build transaction commands that mirror CLI's dag::execute function let mut commands = Vec::new(); - // Step 1: Create empty VecMap for vertex inputs (like CLI) + // Step 1: outer VecMap> commands.push(serde_json::json!({ "type": "moveCall", "target": "0x2::vec_map::empty", @@ -158,14 +131,14 @@ pub fn build_dag_execution_transaction( let mut command_index = 1; - // Step 2: Process each vertex like CLI - for (vertex_name, vertex_data) in input_data.as_object().unwrap_or(&serde_json::Map::new()) + // Step 2: build per-vertex input maps + for (vertex_name, vertex_data) in + input_data.as_object().unwrap_or(&serde_json::Map::new()) { if !vertex_data.is_object() { continue; } - // Create vertex commands.push(serde_json::json!({ "type": "moveCall", "target": "{{workflow_pkg_id}}::dag::vertex_from_string", @@ -175,7 +148,6 @@ pub fn build_dag_execution_transaction( let vertex_result_index = command_index; command_index += 1; - // Create empty inner VecMap for ports commands.push(serde_json::json!({ "type": "moveCall", "target": "0x2::vec_map::empty", @@ -189,52 +161,46 @@ pub fn build_dag_execution_transaction( let inner_vecmap_result_index = command_index; command_index += 1; - // Process each port for (port_name, port_value) in vertex_data.as_object().unwrap_or(&serde_json::Map::new()) { - let is_encrypted = encrypted_ports - .get(vertex_name) - .map_or(false, |ports| ports.contains(port_name)); - - // Create input port (encrypted or normal like CLI) - let port_target = if is_encrypted { - "{{workflow_pkg_id}}::dag::encrypted_input_port_from_string" - } else { - "{{workflow_pkg_id}}::dag::input_port_from_string" - }; - commands.push(serde_json::json!({ "type": "moveCall", - "target": port_target, + "target": "{{workflow_pkg_id}}::dag::input_port_from_string", "arguments": [{"type": "pure", "pure_type": "string", "value": port_name}], "result_index": command_index })); let port_result_index = command_index; command_index += 1; - let json_string = serde_json::to_string(port_value)?; - let json_bytes = json_string.as_bytes().to_vec(); + let remote_handle = format!("{}.{}", vertex_name, port_name); + let is_remote = remote_ports.contains(&remote_handle); - // Use appropriate NexusData function based on encryption status (like CLI) - let data_target = if is_encrypted { - "{{primitives_pkg_id}}::data::inline_one_encrypted" // Use inline_one_encrypted for encrypted data + let (data_target, data_bytes) = if is_remote { + let blob_id = remote_blob_ids.get(&remote_handle).ok_or_else(|| { + format!("Missing blob ID for remote port '{}'", remote_handle) + })?; + let blob_id_bytes = + serde_json::to_vec(&serde_json::Value::String(blob_id.clone()))?; + ("{{primitives_pkg_id}}::data::walrus_one", blob_id_bytes) } else { - "{{primitives_pkg_id}}::data::inline_one" // Use inline_one for regular data + // Same as `primitives::Data::nexus_data_inline_from_json` for non-array values: + // `pure_arg(&serde_json::to_vec(json)?)`. + let bytes = serde_json::to_vec(port_value)?; + ("{{primitives_pkg_id}}::data::inline_one", bytes) }; commands.push(serde_json::json!({ "type": "moveCall", "target": data_target, "arguments": [ - {"type": "pure", "pure_type": "vector_u8", "value": json_bytes} + {"type": "pure", "pure_type": "vector_u8", "value": data_bytes} ], "result_index": command_index })); let nexus_data_result_index = command_index; command_index += 1; - // Insert port and data into inner VecMap commands.push(serde_json::json!({ "type": "moveCall", "target": "0x2::vec_map::insert", @@ -252,7 +218,6 @@ pub fn build_dag_execution_transaction( command_index += 1; } - // Insert vertex and inner VecMap into outer VecMap commands.push(serde_json::json!({ "type": "moveCall", "target": "0x2::vec_map::insert", @@ -270,7 +235,7 @@ pub fn build_dag_execution_transaction( command_index += 1; } - // Step 3: Create entry group + // Step 3: entry group commands.push(serde_json::json!({ "type": "moveCall", "target": "{{workflow_pkg_id}}::dag::entry_group_from_string", @@ -280,35 +245,101 @@ pub fn build_dag_execution_transaction( let entry_group_result_index = command_index; command_index += 1; - // Step 4: Final DAG execution call (exactly like CLI) + // Step 4: prepare_dag_execution + // Returns (RequestWalkExecution, DAGExecution, ExecutionGas). + // JS side must access nested results [0], [1], [2] from this call. commands.push(serde_json::json!({ "type": "moveCall", - "target": "{{workflow_pkg_id}}::default_tap::begin_dag_execution", + "target": "{{workflow_pkg_id}}::default_tap::prepare_dag_execution", "arguments": [ {"type": "shared_object_by_id", "id": "{{default_tap_object_id}}", "mutable": true}, {"type": "shared_object_by_id", "id": dag_id, "mutable": false}, {"type": "shared_object_by_id", "id": "{{gas_service_object_id}}", "mutable": true}, + {"type": "shared_object_by_id", "id": "{{tool_registry_id}}", "mutable": false}, {"type": "pure", "pure_type": "id", "value": "{{network_id}}"}, {"type": "result", "index": entry_group_result_index}, {"type": "result", "index": 0}, + {"type": "pure", "pure_type": "u64", "value": priority_fee}, {"type": "clock_object"} ], - "result_index": command_index + "result_index": command_index, + "returns": { + "ticket": {"nested_index": 0}, + "execution": {"nested_index": 1}, + "execution_gas": {"nested_index": 2} + } })); + let prepare_result_index = command_index; + let _ = command_index + 1; + + // Step 5: lock_gas_state_for_tool (one per tool) + // JS side must iterate over tool gas objects and add one command per tool. + // Template for a single tool: + let gas_lock_template = serde_json::json!({ + "type": "moveCall", + "target": "{{workflow_pkg_id}}::gas::lock_gas_state_for_tool", + "arguments_template": [ + {"type": "nested_result", "index": prepare_result_index, "nested_index": 2, "description": "execution_gas"}, + {"type": "shared_object_by_id", "id": "{{tool_gas_object_id}}", "mutable": true, "description": "tool_gas (JS must provide per tool)"}, + {"type": "shared_object_by_id", "id": "{{invoker_gas_object_id}}", "mutable": true, "description": "invoker_gas (JS must provide)"}, + {"type": "shared_object_by_id", "id": dag_id, "mutable": false, "description": "dag"}, + {"type": "nested_result", "index": prepare_result_index, "nested_index": 1, "description": "execution"}, + {"type": "nested_result", "index": prepare_result_index, "nested_index": 0, "description": "ticket"} + ], + "note": "Repeat this command for each tool in the DAG. JS must supply actual tool_gas and invoker_gas object IDs." + }); + + // Step 6: request_network_to_execute_walks + let walk_request_template = serde_json::json!({ + "type": "moveCall", + "target": "{{workflow_pkg_id}}::dag::request_network_to_execute_walks", + "arguments_template": [ + {"type": "shared_object_by_id", "id": dag_id, "mutable": false, "description": "dag"}, + {"type": "nested_result", "index": prepare_result_index, "nested_index": 1, "description": "execution"}, + {"type": "nested_result", "index": prepare_result_index, "nested_index": 0, "description": "ticket"}, + {"type": "shared_object_by_id", "id": "{{leader_registry_id}}", "mutable": false, "description": "leader_registry"}, + {"type": "clock_object"} + ] + }); + + // Step 7: share DAGExecution and ExecutionGas + let share_execution_template = serde_json::json!({ + "type": "moveCall", + "target": "0x2::transfer::public_share_object", + "typeArguments": ["{{workflow_pkg_id}}::dag::DAGExecution"], + "arguments_template": [ + {"type": "nested_result", "index": prepare_result_index, "nested_index": 1, "description": "execution"} + ] + }); + + let share_gas_template = serde_json::json!({ + "type": "moveCall", + "target": "0x2::transfer::public_share_object", + "typeArguments": ["{{workflow_pkg_id}}::gas::ExecutionGas"], + "arguments_template": [ + {"type": "nested_result", "index": prepare_result_index, "nested_index": 2, "description": "execution_gas"} + ] + }); let transaction_data = serde_json::json!({ "commands": commands, "gas_budget": gas_budget_u64, - "encrypted_ports_count": encrypted_ports.len(), + "priority_fee_per_gas_unit": priority_fee, "vertices_count": input_data.as_object().map_or(0, |obj| obj.len()), - "auto_encrypted": !encrypted_ports.is_empty() + "remote_ports_count": remote_ports.len(), + "prepare_result_index": prepare_result_index, + "post_prepare_templates": { + "lock_gas_state_for_tool": gas_lock_template, + "request_network_to_execute_walks": walk_request_template, + "share_execution": share_execution_template, + "share_execution_gas": share_gas_template + } }); Ok(serde_json::json!({ "success": true, "transaction_data": transaction_data.to_string(), - "message": "CLI-compatible transaction built successfully with auto-encryption", - "encrypted": !encrypted_ports.is_empty() + "message": "Transaction built (prepare_dag_execution + post-prepare templates)", }) .to_string()) })(); @@ -327,13 +358,13 @@ pub fn build_dag_execution_transaction( } } +/// Validate that all required parameters are present before execution. #[wasm_bindgen] pub fn validate_dag_execution_readiness( dag_id: &str, entry_group: &str, input_json: &str, ) -> ExecutionResult { - // Parse input JSON to validate structure let input_data: serde_json::Value = match serde_json::from_str(input_json) { Ok(data) => data, Err(e) => { @@ -345,7 +376,6 @@ pub fn validate_dag_execution_readiness( } }; - // Basic validation checks if dag_id.is_empty() { return ExecutionResult { is_success: false, @@ -354,15 +384,12 @@ pub fn validate_dag_execution_readiness( }; } - if entry_group.is_empty() { - return ExecutionResult { - is_success: false, - error_message: Some("Entry group is required".to_string()), - transaction_data: None, - }; - } + let resolved_entry_group = if entry_group.trim().is_empty() { + DEFAULT_ENTRY_GROUP + } else { + entry_group + }; - // Check if input data is an object (required for vertex-port mapping) if !input_data.is_object() { return ExecutionResult { is_success: false, @@ -375,10 +402,11 @@ pub fn validate_dag_execution_readiness( let readiness_info = serde_json::json!({ "dag_id": dag_id, - "entry_group": entry_group, + "entry_group": resolved_entry_group, + "entry_group_was_defaulted": entry_group.trim().is_empty(), "input_vertices": input_data.as_object().unwrap().keys().collect::>(), "ready_for_execution": true, - "validation_timestamp": js_sys::Date::now() as u64 / 1000 // Unix timestamp + "validation_timestamp": js_sys::Date::now() as u64 / 1000 }); match serde_json::to_string(&readiness_info) { diff --git a/sdk-wasm/src/dag_publish.rs b/sdk-wasm/src/dag_publish.rs index 713240d6..a7865577 100644 --- a/sdk-wasm/src/dag_publish.rs +++ b/sdk-wasm/src/dag_publish.rs @@ -1,7 +1,7 @@ use { nexus_sdk::{ dag::validator, - types::{Dag, EdgeKind, VertexKind}, + types::{Dag, EdgeKind, StorageKind, VertexKind}, }, serde::{Deserialize, Serialize}, std::collections::HashMap, @@ -174,30 +174,57 @@ pub fn build_dag_publish_transaction(dag_json: &str, nexus_objects_json: &str) - let vertex_name_index = result_index; result_index += 1; - // Create vertex kind (off-chain) - Use pattern matching for VertexKind - let tool_fqn = match &vertex.kind { - VertexKind::OffChain { tool_fqn } => tool_fqn.to_string(), - VertexKind::OnChain { .. } => "on_chain_tool".to_string(), + let vertex_kind_index = match &vertex.kind { + VertexKind::OffChain { tool_fqn } => { + commands.push(TransactionCommand { + command_type: "moveCall".to_string(), + target: format!( + "{}::dag::vertex_off_chain", + nexus_objects + .get("workflow_pkg_id") + .unwrap_or(&"{{workflow_pkg_id}}".to_string()) + ), + arguments: vec![CommandArgument::Pure { + pure_type: "string".to_string(), + value: serde_json::Value::String(tool_fqn.to_string()), + }], + type_arguments: vec![], + result_index, + }); + let idx = result_index; + result_index += 1; + idx + } + VertexKind::OnChain { tool_fqn } => { + commands.push(TransactionCommand { + command_type: "moveCall".to_string(), + target: format!( + "{}::dag::vertex_on_chain", + nexus_objects + .get("workflow_pkg_id") + .unwrap_or(&"{{workflow_pkg_id}}".to_string()) + ), + arguments: vec![ + CommandArgument::Object { + value: nexus_objects + .get("tool_registry_id") + .cloned() + .unwrap_or_else(|| "{{tool_registry_id}}".to_string()), + }, + CommandArgument::Pure { + pure_type: "string".to_string(), + value: serde_json::Value::String(tool_fqn.to_string()), + }, + ], + type_arguments: vec![], + result_index, + }); + let idx = result_index; + result_index += 1; + idx + } }; - commands.push(TransactionCommand { - command_type: "moveCall".to_string(), - target: format!( - "{}::dag::vertex_off_chain", - nexus_objects - .get("workflow_pkg_id") - .unwrap_or(&"{{workflow_pkg_id}}".to_string()) - ), - arguments: vec![CommandArgument::Pure { - pure_type: "string".to_string(), - value: serde_json::Value::String(tool_fqn), - }], - type_arguments: vec![], - result_index, - }); - let vertex_kind_index = result_index; - result_index += 1; - // Add vertex to DAG commands.push(TransactionCommand { command_type: "moveCall".to_string(), @@ -267,18 +294,27 @@ pub fn build_dag_publish_transaction(dag_json: &str, nexus_objects_json: &str) - let input_port_index = result_index; result_index += 1; - // Create nexus data - Access data field from Data struct let json_string = serde_json::to_string(&default_value.value.data).unwrap_or_default(); let json_bytes: Vec = json_string.into_bytes(); - commands.push(TransactionCommand { - command_type: "moveCall".to_string(), - target: format!( + let data_target = match default_value.value.storage { + StorageKind::Walrus => format!( + "{}::data::walrus_one", + nexus_objects + .get("primitives_pkg_id") + .unwrap_or(&"{{primitives_pkg_id}}".to_string()) + ), + StorageKind::Inline => format!( "{}::data::inline_one", nexus_objects .get("primitives_pkg_id") .unwrap_or(&"{{primitives_pkg_id}}".to_string()) ), + }; + + commands.push(TransactionCommand { + command_type: "moveCall".to_string(), + target: data_target, arguments: vec![CommandArgument::Pure { pure_type: "vector_u8".to_string(), value: serde_json::Value::Array( @@ -429,6 +465,7 @@ pub fn build_dag_publish_transaction(dag_json: &str, nexus_objects_json: &str) - EdgeKind::Collect => "collect", EdgeKind::DoWhile => "do_while", EdgeKind::Break => "break", + EdgeKind::Static => "static", }; commands.push(TransactionCommand { command_type: "moveCall".to_string(), @@ -446,26 +483,14 @@ pub fn build_dag_publish_transaction(dag_json: &str, nexus_objects_json: &str) - let edge_kind_index = result_index; result_index += 1; - // Add edge to DAG (with encrypted edge support) - let edge_target = if edge.from.encrypted { - format!( - "{}::dag::with_encrypted_edge", - nexus_objects - .get("workflow_pkg_id") - .unwrap_or(&"{{workflow_pkg_id}}".to_string()) - ) - } else { - format!( + commands.push(TransactionCommand { + command_type: "moveCall".to_string(), + target: format!( "{}::dag::with_edge", nexus_objects .get("workflow_pkg_id") .unwrap_or(&"{{workflow_pkg_id}}".to_string()) - ) - }; - - commands.push(TransactionCommand { - command_type: "moveCall".to_string(), - target: edge_target, + ), arguments: vec![ CommandArgument::Result { index: current_dag_index, @@ -556,26 +581,14 @@ pub fn build_dag_publish_transaction(dag_json: &str, nexus_objects_json: &str) - let output_port_index = result_index; result_index += 1; - // Add output to DAG (with encrypted support like SDK) - let output_target = if output.encrypted { - format!( - "{}::dag::with_encrypted_output", - nexus_objects - .get("workflow_pkg_id") - .unwrap_or(&"{{workflow_pkg_id}}".to_string()) - ) - } else { - format!( + commands.push(TransactionCommand { + command_type: "moveCall".to_string(), + target: format!( "{}::dag::with_output", nexus_objects .get("workflow_pkg_id") .unwrap_or(&"{{workflow_pkg_id}}".to_string()) - ) - }; - - commands.push(TransactionCommand { - command_type: "moveCall".to_string(), - target: output_target, + ), arguments: vec![ CommandArgument::Result { index: current_dag_index, @@ -629,26 +642,14 @@ pub fn build_dag_publish_transaction(dag_json: &str, nexus_objects_json: &str) - let vertex_index = result_index; result_index += 1; - // Create entry port argument - let entry_port_target = if entry_port.encrypted { - format!( - "{}::dag::encrypted_input_port_from_string", - nexus_objects - .get("workflow_pkg_id") - .unwrap_or(&"{{workflow_pkg_id}}".to_string()) - ) - } else { - format!( + commands.push(TransactionCommand { + command_type: "moveCall".to_string(), + target: format!( "{}::dag::input_port_from_string", nexus_objects .get("workflow_pkg_id") .unwrap_or(&"{{workflow_pkg_id}}".to_string()) - ) - }; - - commands.push(TransactionCommand { - command_type: "moveCall".to_string(), - target: entry_port_target, + ), arguments: vec![CommandArgument::Pure { pure_type: "string".to_string(), value: serde_json::Value::String(entry_port.name.clone()), @@ -799,26 +800,14 @@ pub fn build_dag_publish_transaction(dag_json: &str, nexus_objects_json: &str) - let vertex_index = result_index; result_index += 1; - // Create entry port argument - let entry_port_target = if entry_port.encrypted { - format!( - "{}::dag::encrypted_input_port_from_string", - nexus_objects - .get("workflow_pkg_id") - .unwrap_or(&"{{workflow_pkg_id}}".to_string()) - ) - } else { - format!( + commands.push(TransactionCommand { + command_type: "moveCall".to_string(), + target: format!( "{}::dag::input_port_from_string", nexus_objects .get("workflow_pkg_id") .unwrap_or(&"{{workflow_pkg_id}}".to_string()) - ) - }; - - commands.push(TransactionCommand { - command_type: "moveCall".to_string(), - target: entry_port_target, + ), arguments: vec![CommandArgument::Pure { pure_type: "string".to_string(), value: serde_json::Value::String(entry_port.name.clone()), diff --git a/sdk-wasm/src/lib.rs b/sdk-wasm/src/lib.rs index 177f2338..2c267e9b 100644 --- a/sdk-wasm/src/lib.rs +++ b/sdk-wasm/src/lib.rs @@ -4,8 +4,12 @@ mod crypto; mod dag_execute; mod dag_publish; mod dag_validate; +mod scheduler; +mod walrus; -pub use {crypto::*, dag_execute::*, dag_publish::*, dag_validate::*}; +pub use { + crypto::*, dag_execute::*, dag_publish::*, dag_validate::*, scheduler::*, walrus::*, +}; // Called when the wasm module is instantiated #[wasm_bindgen(start)] @@ -13,6 +17,24 @@ pub fn main() { console_error_panic_hook::set_once(); } +/// JSON metadata about this WASM build (for JS feature detection). +#[wasm_bindgen] +pub fn get_sdk_version() -> String { + serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), + "features": [ + "dag_validation", + "dag_publish", + "dag_execute", + "sui_and_tool_keys", + "signed_http_helpers", + "scheduler", + "walrus_upload" + ], + }) + .to_string() +} + // A macro to provide `println!(..)`-style syntax for `console.log` logging. #[allow(unused_macros)] macro_rules! console_log { diff --git a/sdk-wasm/src/scheduler.rs b/sdk-wasm/src/scheduler.rs new file mode 100644 index 00000000..c18fa19b --- /dev/null +++ b/sdk-wasm/src/scheduler.rs @@ -0,0 +1,791 @@ +// Scheduler transaction builders for WASM +// Added in v0.5.0 to support on-chain task management +// +// Note: SDK's native transaction builders (nexus_sdk::transactions::scheduler) +// cannot be used in WASM due to sui-crypto and sui-transaction-builder dependencies. +// This module produces JSON command structures that JavaScript can convert to Sui transactions. +// +// IMPORTANT: This implementation mirrors sdk/src/transactions/scheduler.rs exactly. + +use { + serde::{Deserialize, Serialize}, + std::collections::HashMap, + wasm_bindgen::prelude::*, +}; + +// ============================================================================= +// Constants +// ============================================================================= + +const SUI_FRAMEWORK: &str = "0x2"; +const MOVE_STDLIB: &str = "0x1"; + +// ============================================================================= +// Public Types (WASM-exported) +// ============================================================================= + +/// Generator type for scheduler tasks +#[wasm_bindgen] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GeneratorKind { + Queue = 0, + Periodic = 1, +} + +/// Task state actions +#[wasm_bindgen] +#[derive(Clone, Copy, Debug)] +pub enum TaskStateAction { + Pause = 0, + Resume = 1, + Cancel = 2, +} + +// ============================================================================= +// Input Parameter Types +// ============================================================================= + +/// Scheduler task creation parameters +#[derive(Serialize, Deserialize)] +pub struct CreateTaskParams { + pub dag_id: String, + pub entry_group: String, + pub input_data: serde_json::Value, + pub metadata: Vec<(String, String)>, + pub execution_priority_fee_per_gas_unit: u64, + pub generator: String, // "queue" or "periodic" + pub network_id: String, // Required for execution policy +} + +/// Occurrence request parameters +#[derive(Serialize, Deserialize)] +pub struct OccurrenceParams { + pub start_ms: Option, + pub deadline_ms: Option, + pub start_offset_ms: Option, + pub deadline_offset_ms: Option, + pub priority_fee_per_gas_unit: u64, +} + +/// Periodic schedule configuration +#[derive(Serialize, Deserialize)] +pub struct PeriodicConfig { + pub first_start_ms: u64, + pub period_ms: u64, + pub deadline_offset_ms: Option, + pub max_iterations: Option, + pub priority_fee_per_gas_unit: u64, +} + +// ============================================================================= +// Result Types (WASM-exported) +// ============================================================================= + +#[wasm_bindgen] +pub struct SchedulerResult { + is_success: bool, + error_message: Option, + transaction_data: Option, +} + +#[wasm_bindgen] +impl SchedulerResult { + #[wasm_bindgen(getter)] + pub fn is_success(&self) -> bool { + self.is_success + } + + #[wasm_bindgen(getter)] + pub fn error_message(&self) -> Option { + self.error_message.clone() + } + + #[wasm_bindgen(getter)] + pub fn transaction_data(&self) -> Option { + self.transaction_data.clone() + } +} + +impl SchedulerResult { + fn ok(data: String) -> Self { + Self { + is_success: true, + error_message: None, + transaction_data: Some(data), + } + } + + fn err(msg: impl Into) -> Self { + Self { + is_success: false, + error_message: Some(msg.into()), + transaction_data: None, + } + } +} + +// ============================================================================= +// Internal: Command Builder +// ============================================================================= + +/// Helper for building PTB commands in JSON format +struct CommandBuilder { + commands: Vec, + result_index: usize, + workflow_pkg: String, + primitives_pkg: String, +} + +impl CommandBuilder { + fn new(nexus_objects: &HashMap) -> Self { + Self { + commands: Vec::new(), + result_index: 0, + workflow_pkg: nexus_objects + .get("workflow_pkg_id") + .cloned() + .unwrap_or_else(|| "{{workflow_pkg_id}}".into()), + primitives_pkg: nexus_objects + .get("primitives_pkg_id") + .cloned() + .unwrap_or_else(|| "{{primitives_pkg_id}}".into()), + } + } + + /// Add a move call command and return its result index + fn move_call( + &mut self, + target: impl Into, + type_args: Vec, + arguments: Vec, + ) -> usize { + let idx = self.result_index; + self.commands.push(serde_json::json!({ + "type": "moveCall", + "target": target.into(), + "typeArguments": type_args, + "arguments": arguments, + "result_index": idx + })); + self.result_index += 1; + idx + } + + /// Reference a previous result + fn result(index: usize) -> serde_json::Value { + serde_json::json!({"type": "result", "index": index}) + } + + /// Pure value argument + fn pure(pure_type: &str, value: impl Serialize) -> serde_json::Value { + serde_json::json!({"type": "pure", "pure_type": pure_type, "value": value}) + } + + /// Shared object argument + fn shared_object(id: &str, mutable: bool) -> serde_json::Value { + serde_json::json!({"type": "shared_object_by_id", "id": id, "mutable": mutable}) + } + + /// Clock object argument + fn clock() -> serde_json::Value { + serde_json::json!({"type": "clock_object"}) + } + + // ========================================================================= + // Sui Framework Helpers + // ========================================================================= + + /// Create empty VecMap + fn vec_map_empty(&mut self, key_type: &str, value_type: &str) -> usize { + self.move_call( + format!("{SUI_FRAMEWORK}::vec_map::empty"), + vec![key_type.into(), value_type.into()], + vec![], + ) + } + + /// Insert into VecMap + fn vec_map_insert( + &mut self, + key_type: &str, + value_type: &str, + map_idx: usize, + key_idx: usize, + value_idx: usize, + ) { + self.move_call( + format!("{SUI_FRAMEWORK}::vec_map::insert"), + vec![key_type.into(), value_type.into()], + vec![ + Self::result(map_idx), + Self::result(key_idx), + Self::result(value_idx), + ], + ); + } + + /// Create empty TableVec + fn table_vec_empty(&mut self, elem_type: &str) -> usize { + self.move_call( + format!("{SUI_FRAMEWORK}::table_vec::empty"), + vec![elem_type.into()], + vec![], + ) + } + + /// Push back to TableVec + fn table_vec_push_back(&mut self, elem_type: &str, vec_idx: usize, elem_idx: usize) { + self.move_call( + format!("{SUI_FRAMEWORK}::table_vec::push_back"), + vec![elem_type.into()], + vec![Self::result(vec_idx), Self::result(elem_idx)], + ); + } + + /// Drop TableVec + fn table_vec_drop(&mut self, elem_type: &str, vec_idx: usize) { + self.move_call( + format!("{SUI_FRAMEWORK}::table_vec::drop"), + vec![elem_type.into()], + vec![Self::result(vec_idx)], + ); + } + + /// Create string from bytes + fn string_utf8(&mut self, value: &str) -> usize { + self.move_call( + format!("{MOVE_STDLIB}::string::utf8"), + vec![], + vec![Self::pure("vector_u8", value.as_bytes().to_vec())], + ) + } + + /// Share object publicly + fn public_share_object(&mut self, type_tag: &str, obj_idx: usize) { + self.move_call( + format!("{SUI_FRAMEWORK}::transfer::public_share_object"), + vec![type_tag.into()], + vec![Self::result(obj_idx)], + ); + } + + // ========================================================================= + // Workflow Package Helpers + // ========================================================================= + + fn workflow_target(&self, module: &str, function: &str) -> String { + format!("{}::{}::{}", self.workflow_pkg, module, function) + } + + fn primitives_target(&self, module: &str, function: &str) -> String { + format!("{}::{}::{}", self.primitives_pkg, module, function) + } + + /// Create witness symbol from type + fn witness_symbol(&mut self, witness_type: &str) -> usize { + self.move_call( + self.primitives_target("policy", "witness_symbol"), + vec![witness_type.into()], + vec![], + ) + } + + /// dag::vertex_from_string + fn vertex_from_string(&mut self, name: &str) -> usize { + self.move_call( + self.workflow_target("dag", "vertex_from_string"), + vec![], + vec![Self::pure("string", name)], + ) + } + + /// dag::input_port_from_string + fn input_port_from_string(&mut self, name: &str) -> usize { + self.move_call( + self.workflow_target("dag", "input_port_from_string"), + vec![], + vec![Self::pure("string", name)], + ) + } + + /// dag::entry_group_from_string + fn entry_group_from_string(&mut self, name: &str) -> usize { + self.move_call( + self.workflow_target("dag", "entry_group_from_string"), + vec![], + vec![Self::pure("string", name)], + ) + } + + /// data::inline_one (create NexusData) + fn nexus_data_inline(&mut self, json_value: &serde_json::Value) -> anyhow::Result { + let json_bytes = serde_json::to_string(json_value)?.into_bytes(); + Ok(self.move_call( + self.primitives_target("data", "inline_one"), + vec![], + vec![Self::pure("vector_u8", json_bytes)], + )) + } + + // ========================================================================= + // Build Final Output + // ========================================================================= + + fn build(self) -> Vec { + self.commands + } +} + +// ============================================================================= +// Input Data Processing +// ============================================================================= + +/// Build the nested VecMap structure for input data +/// VecMap> +fn build_input_data_commands( + cb: &mut CommandBuilder, + input_data: &serde_json::Value, +) -> anyhow::Result { + let vertex_type = format!("{}::dag::Vertex", cb.workflow_pkg); + let port_type = format!("{}::dag::InputPort", cb.workflow_pkg); + let data_type = format!("{}::data::NexusData", cb.primitives_pkg); + let inner_map_type = format!("{SUI_FRAMEWORK}::vec_map::VecMap<{port_type}, {data_type}>"); + + // Outer VecMap> + let outer_map = cb.vec_map_empty(&vertex_type, &inner_map_type); + + if let Some(vertices) = input_data.as_object() { + for (vertex_name, ports) in vertices { + let vertex_idx = cb.vertex_from_string(vertex_name); + let inner_map = cb.vec_map_empty(&port_type, &data_type); + + if let Some(port_obj) = ports.as_object() { + for (port_name, port_value) in port_obj { + let port_idx = cb.input_port_from_string(port_name); + let data_idx = cb.nexus_data_inline(port_value)?; + cb.vec_map_insert(&port_type, &data_type, inner_map, port_idx, data_idx); + } + } + + cb.vec_map_insert( + &vertex_type, + &inner_map_type, + outer_map, + vertex_idx, + inner_map, + ); + } + } + + Ok(outer_map) +} + +/// Build metadata VecMap +fn build_metadata_commands(cb: &mut CommandBuilder, metadata: &[(String, String)]) -> usize { + let string_type = format!("{MOVE_STDLIB}::string::String"); + let metadata_map = cb.vec_map_empty(&string_type, &string_type); + + for (key, value) in metadata { + let key_idx = cb.string_utf8(key); + let value_idx = cb.string_utf8(value); + cb.vec_map_insert(&string_type, &string_type, metadata_map, key_idx, value_idx); + } + + metadata_map +} + +/// Build constraints policy with proper witness/TableVec pattern +/// Mirrors SDK: new_constraints_policy() +fn build_constraints_policy( + cb: &mut CommandBuilder, + generator: &str, +) -> usize { + let symbol_type = format!("{}::policy::Symbol", cb.primitives_pkg); + + // 1. Create witness symbol based on generator type + let witness_type = if generator == "periodic" { + format!("{}::scheduler::PeriodicGeneratorWitness", cb.workflow_pkg) + } else { + format!("{}::scheduler::QueueGeneratorWitness", cb.workflow_pkg) + }; + let constraint_symbol = cb.witness_symbol(&witness_type); + + // 2. Create TableVec and push the symbol + let constraint_sequence = cb.table_vec_empty(&symbol_type); + cb.table_vec_push_back(&symbol_type, constraint_sequence, constraint_symbol); + + // 3. Create constraints policy from sequence + let constraints = cb.move_call( + cb.workflow_target("scheduler", "new_constraints_policy"), + vec![], + vec![CommandBuilder::result(constraint_sequence)], + ); + + // 4. Drop the TableVec (it's consumed) + cb.table_vec_drop(&symbol_type, constraint_sequence); + + // 5. Create and register generator state + if generator == "periodic" { + // Create periodic generator state + let periodic_state = cb.move_call( + cb.workflow_target("scheduler", "new_periodic_generator_state"), + vec![], + vec![], + ); + // Register it + cb.move_call( + cb.workflow_target("scheduler", "register_periodic_generator"), + vec![], + vec![CommandBuilder::result(constraints), CommandBuilder::result(periodic_state)], + ); + } else { + // Create queue generator state + let queue_state = cb.move_call( + cb.workflow_target("scheduler", "new_queue_generator_state"), + vec![], + vec![], + ); + // Register it + cb.move_call( + cb.workflow_target("scheduler", "register_queue_generator"), + vec![], + vec![CommandBuilder::result(constraints), CommandBuilder::result(queue_state)], + ); + } + + constraints +} + +/// Build execution policy with proper witness/TableVec pattern +/// Mirrors SDK: new_execution_policy() +fn build_execution_policy( + cb: &mut CommandBuilder, + dag_id: &str, + network_id: &str, + entry_group: &str, + input_data: &serde_json::Value, + priority_fee_per_gas_unit: u64, +) -> anyhow::Result { + let symbol_type = format!("{}::policy::Symbol", cb.primitives_pkg); + + // 1. Create witness symbol for BeginDagExecutionWitness + let witness_type = format!("{}::default_tap::BeginDagExecutionWitness", cb.workflow_pkg); + let execution_symbol = cb.witness_symbol(&witness_type); + + // 2. Create TableVec and push the symbol + let execution_sequence = cb.table_vec_empty(&symbol_type); + cb.table_vec_push_back(&symbol_type, execution_sequence, execution_symbol); + + // 3. Create execution policy from sequence + let execution = cb.move_call( + cb.workflow_target("scheduler", "new_execution_policy"), + vec![], + vec![CommandBuilder::result(execution_sequence)], + ); + + // 4. Drop the TableVec (it's consumed) + cb.table_vec_drop(&symbol_type, execution_sequence); + + // 5. Create dag_id argument (ID from object_id) + let dag_id_arg = cb.move_call( + format!("{SUI_FRAMEWORK}::object::id_from_address"), + vec![], + vec![CommandBuilder::pure("address", dag_id)], + ); + + // 6. Create network_id argument (ID from object_id) + let network_id_arg = cb.move_call( + format!("{SUI_FRAMEWORK}::object::id_from_address"), + vec![], + vec![CommandBuilder::pure("address", network_id)], + ); + + // 7. Create entry_group argument + let entry_group_arg = cb.entry_group_from_string(entry_group); + + // 8. Build input data VecMap + let with_vertex_inputs = build_input_data_commands(cb, input_data)?; + + // 9. Create DAG execution config + let config = cb.move_call( + cb.workflow_target("dag", "new_dag_execution_config"), + vec![], + vec![ + CommandBuilder::result(dag_id_arg), + CommandBuilder::result(network_id_arg), + CommandBuilder::result(entry_group_arg), + CommandBuilder::result(with_vertex_inputs), + CommandBuilder::pure("u64", priority_fee_per_gas_unit), + ], + ); + + // 10. Register the config on execution policy + cb.move_call( + cb.workflow_target("default_tap", "register_begin_execution"), + vec![], + vec![CommandBuilder::result(execution), CommandBuilder::result(config)], + ); + + Ok(execution) +} + +// ============================================================================= +// WASM-Exported Functions +// ============================================================================= + +/// Build transaction for creating a scheduler task +/// CLI: `nexus scheduler task create` +#[wasm_bindgen] +pub fn build_scheduler_task_create_transaction( + params_json: &str, + nexus_objects_json: &str, +) -> SchedulerResult { + match build_task_create_impl(params_json, nexus_objects_json) { + Ok(data) => SchedulerResult::ok(data), + Err(e) => SchedulerResult::err(format!("Failed to build task create transaction: {e}")), + } +} + +fn build_task_create_impl(params_json: &str, nexus_objects_json: &str) -> anyhow::Result { + let params: CreateTaskParams = serde_json::from_str(params_json)?; + let nexus_objects: HashMap = serde_json::from_str(nexus_objects_json)?; + + let mut cb = CommandBuilder::new(&nexus_objects); + + // 1. Build metadata + let metadata_map = build_metadata_commands(&mut cb, ¶ms.metadata); + let metadata_arg = cb.move_call( + cb.workflow_target("scheduler", "new_metadata"), + vec![], + vec![CommandBuilder::result(metadata_map)], + ); + + // 2. Build constraints policy (with witness/TableVec pattern) + let constraints_arg = build_constraints_policy(&mut cb, ¶ms.generator); + + // 3. Build execution policy (with witness/TableVec pattern + config + register) + let execution_arg = build_execution_policy( + &mut cb, + ¶ms.dag_id, + ¶ms.network_id, + ¶ms.entry_group, + ¶ms.input_data, + params.execution_priority_fee_per_gas_unit, + )?; + + // 4. Create task + let task_idx = cb.move_call( + cb.workflow_target("scheduler", "new"), + vec![], + vec![ + CommandBuilder::result(metadata_arg), + CommandBuilder::result(constraints_arg), + CommandBuilder::result(execution_arg), + ], + ); + + // 5. Share task object + let task_type = format!("{}::scheduler::Task", cb.workflow_pkg); + cb.public_share_object(&task_type, task_idx); + + Ok(serde_json::to_string(&serde_json::json!({ + "operation": "scheduler_task_create", + "commands": cb.build(), + "dag_id": params.dag_id, + "network_id": params.network_id, + "entry_group": params.entry_group, + "generator": params.generator, + "priority_fee_per_gas_unit": params.execution_priority_fee_per_gas_unit + }))?) +} + +/// Build transaction for adding an occurrence to a task +/// CLI: `nexus scheduler occurrence add` +#[wasm_bindgen] +pub fn build_scheduler_occurrence_add_transaction( + task_id: &str, + params_json: &str, + nexus_objects_json: &str, +) -> SchedulerResult { + match build_occurrence_add_impl(task_id, params_json, nexus_objects_json) { + Ok(data) => SchedulerResult::ok(data), + Err(e) => SchedulerResult::err(format!("Failed to build occurrence add transaction: {e}")), + } +} + +fn build_occurrence_add_impl( + task_id: &str, + params_json: &str, + nexus_objects_json: &str, +) -> anyhow::Result { + let params: OccurrenceParams = serde_json::from_str(params_json)?; + let nexus_objects: HashMap = serde_json::from_str(nexus_objects_json)?; + + let mut cb = CommandBuilder::new(&nexus_objects); + let is_absolute = params.start_ms.is_some(); + + if is_absolute { + // Absolute time occurrence + let start_ms = params.start_ms.unwrap(); + let deadline_offset = params + .deadline_offset_ms + .or_else(|| params.deadline_ms.map(|d| d.saturating_sub(start_ms))); + + cb.move_call( + cb.workflow_target("scheduler", "add_occurrence_absolute_for_task"), + vec![], + vec![ + CommandBuilder::shared_object(task_id, true), + CommandBuilder::pure("u64", start_ms), + CommandBuilder::pure("option_u64", deadline_offset), + CommandBuilder::pure("u64", params.priority_fee_per_gas_unit), + CommandBuilder::clock(), + ], + ); + } else { + // Relative time occurrence + let start_offset = params.start_offset_ms.unwrap_or(0); + + cb.move_call( + cb.workflow_target("scheduler", "add_occurrence_relative_for_task"), + vec![], + vec![ + CommandBuilder::shared_object(task_id, true), + CommandBuilder::pure("u64", start_offset), + CommandBuilder::pure("option_u64", params.deadline_offset_ms), + CommandBuilder::pure("u64", params.priority_fee_per_gas_unit), + CommandBuilder::clock(), + ], + ); + } + + Ok(serde_json::to_string(&serde_json::json!({ + "operation": "scheduler_occurrence_add", + "commands": cb.build(), + "task_id": task_id, + "is_absolute": is_absolute, + "priority_fee_per_gas_unit": params.priority_fee_per_gas_unit + }))?) +} + +/// Build transaction for configuring periodic scheduling +/// CLI: `nexus scheduler periodic set` +#[wasm_bindgen] +pub fn build_scheduler_periodic_set_transaction( + task_id: &str, + config_json: &str, + nexus_objects_json: &str, +) -> SchedulerResult { + match build_periodic_set_impl(task_id, config_json, nexus_objects_json) { + Ok(data) => SchedulerResult::ok(data), + Err(e) => SchedulerResult::err(format!("Failed to build periodic set transaction: {e}")), + } +} + +fn build_periodic_set_impl( + task_id: &str, + config_json: &str, + nexus_objects_json: &str, +) -> anyhow::Result { + let config: PeriodicConfig = serde_json::from_str(config_json)?; + let nexus_objects: HashMap = serde_json::from_str(nexus_objects_json)?; + + let mut cb = CommandBuilder::new(&nexus_objects); + + // SDK does NOT use clock for this function + // Arguments: task, first_start_ms, period_ms, deadline_offset_ms, max_iterations, priority_fee + cb.move_call( + cb.workflow_target("scheduler", "new_or_modify_periodic_for_task"), + vec![], + vec![ + CommandBuilder::shared_object(task_id, true), + CommandBuilder::pure("u64", config.first_start_ms), + CommandBuilder::pure("u64", config.period_ms), + CommandBuilder::pure("option_u64", config.deadline_offset_ms), + CommandBuilder::pure("option_u64", config.max_iterations), + CommandBuilder::pure("u64", config.priority_fee_per_gas_unit), + ], + ); + + Ok(serde_json::to_string(&serde_json::json!({ + "operation": "scheduler_periodic_set", + "commands": cb.build(), + "task_id": task_id, + "first_start_ms": config.first_start_ms, + "period_ms": config.period_ms + }))?) +} + +/// Build transaction for disabling periodic scheduling +/// CLI: `nexus scheduler periodic disable` +#[wasm_bindgen] +pub fn build_scheduler_periodic_disable_transaction( + task_id: &str, + nexus_objects_json: &str, +) -> SchedulerResult { + match build_periodic_disable_impl(task_id, nexus_objects_json) { + Ok(data) => SchedulerResult::ok(data), + Err(e) => { + SchedulerResult::err(format!("Failed to build periodic disable transaction: {e}")) + } + } +} + +fn build_periodic_disable_impl(task_id: &str, nexus_objects_json: &str) -> anyhow::Result { + let nexus_objects: HashMap = serde_json::from_str(nexus_objects_json)?; + let mut cb = CommandBuilder::new(&nexus_objects); + + cb.move_call( + cb.workflow_target("scheduler", "disable_periodic_for_task"), + vec![], + vec![CommandBuilder::shared_object(task_id, true)], + ); + + Ok(serde_json::to_string(&serde_json::json!({ + "operation": "scheduler_periodic_disable", + "commands": cb.build(), + "task_id": task_id + }))?) +} + +/// Build transaction for changing task state (pause/resume/cancel) +/// CLI: `nexus scheduler task pause/resume/cancel` +#[wasm_bindgen] +pub fn build_scheduler_task_state_transaction( + task_id: &str, + action: TaskStateAction, + nexus_objects_json: &str, +) -> SchedulerResult { + match build_task_state_impl(task_id, action, nexus_objects_json) { + Ok(data) => SchedulerResult::ok(data), + Err(e) => SchedulerResult::err(format!("Failed to build task state transaction: {e}")), + } +} + +fn build_task_state_impl( + task_id: &str, + action: TaskStateAction, + nexus_objects_json: &str, +) -> anyhow::Result { + let nexus_objects: HashMap = serde_json::from_str(nexus_objects_json)?; + let mut cb = CommandBuilder::new(&nexus_objects); + + let action_name = match action { + TaskStateAction::Pause => "pause", + TaskStateAction::Resume => "resume", + TaskStateAction::Cancel => "cancel", + }; + + cb.move_call( + cb.workflow_target("scheduler", action_name), + vec![], + vec![CommandBuilder::shared_object(task_id, true)], + ); + + Ok(serde_json::to_string(&serde_json::json!({ + "operation": format!("scheduler_task_{action_name}"), + "commands": cb.build(), + "task_id": task_id, + "action": action_name + }))?) +} diff --git a/sdk-wasm/src/walrus.rs b/sdk-wasm/src/walrus.rs new file mode 100644 index 00000000..2803143e --- /dev/null +++ b/sdk-wasm/src/walrus.rs @@ -0,0 +1,112 @@ +//! Walrus blob storage integration for WASM. +//! +//! Provides HTTP-based upload to Walrus publisher for remote port data storage. + +use { + js_sys::Promise, + serde::Deserialize, + wasm_bindgen::prelude::*, + wasm_bindgen_futures::JsFuture, +}; + +/// Response from Walrus publisher PUT /v1/blobs +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StorageInfo { + newly_created: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NewlyCreated { + blob_object: BlobObject, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BlobObject { + blob_id: String, +} + +/// Upload JSON data to Walrus and return the blob ID. +/// +/// Uses the Walrus publisher API: PUT {publisher_url}/v1/blobs?epochs={epochs} +/// with the JSON content as body. +/// +/// # Arguments +/// * `publisher_url` - Walrus publisher URL (e.g. https://publisher.walrus-testnet.walrus.space) +/// * `data_json` - JSON string of the data to upload +/// * `save_for_epochs` - Number of epochs to store the data (1-53) +/// +/// # Returns +/// A Promise that resolves to the blob ID string, or rejects with an error message. +#[wasm_bindgen] +pub fn upload_json_to_walrus(publisher_url: &str, data_json: &str, save_for_epochs: u8) -> Promise { + let url = format!("{}/v1/blobs?epochs={}", publisher_url, save_for_epochs); + + let init = web_sys::RequestInit::new(); + init.set_method("PUT"); + init.set_mode(web_sys::RequestMode::Cors); + let body = JsValue::from_str(data_json); + init.set_body(&body); + + let request = + web_sys::Request::new_with_str_and_init(&url, &init).expect("Failed to create request"); + + request + .headers() + .set("Content-Type", "application/json") + .expect("Failed to set Content-Type header"); + + let window = web_sys::window().expect("No window"); + let fetch_promise = window.fetch_with_request(&request); + + wasm_bindgen_futures::future_to_promise(async move { + let resp_value = JsFuture::from(fetch_promise).await.map_err(|e| { + JsValue::from(format!( + "Walrus upload request failed: {}", + js_sys::Reflect::get(&e, &JsValue::from_str("message")) + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_else(|| "Unknown error".to_string()) + )) + })?; + + let resp: web_sys::Response = resp_value + .dyn_into() + .map_err(|_| JsValue::from("Fetch returned non-Response"))?; + + if !resp.ok() { + let status = resp.status(); + let text = JsFuture::from( + resp.text() + .map_err(|_| JsValue::from("Failed to get response text"))?, + ) + .await + .map_err(|_| JsValue::from("Failed to read response body"))?; + let text_str = text.as_string().unwrap_or_default(); + return Err(JsValue::from(format!( + "Walrus API error {}: {}", + status, text_str + ))); + } + + let json = JsFuture::from( + resp.json() + .map_err(|_| JsValue::from("Failed to get JSON"))?, + ) + .await + .map_err(|_| JsValue::from("Failed to parse JSON response"))?; + + let info: StorageInfo = serde_wasm_bindgen::from_value(json) + .map_err(|e| JsValue::from(format!("Failed to parse Walrus response: {}", e)))?; + + let blob_id = info + .newly_created + .ok_or_else(|| JsValue::from("Walrus response missing newlyCreated"))? + .blob_object + .blob_id; + + Ok(JsValue::from(blob_id)) + }) +} diff --git a/sdk-wasm/test.html b/sdk-wasm/test.html index fd9e0c63..674aa369 100644 --- a/sdk-wasm/test.html +++ b/sdk-wasm/test.html @@ -225,7 +225,7 @@

📊 Validation Result