lib.rs (21231B)
1 use ed25519_dalek::{Signer, SigningKey}; 2 use ml_dsa::{Keypair, MlDsa44, Seed, SigningKey as MlDsaSigningKey}; 3 use serde::{Deserialize, Serialize}; 4 use sha2::{Digest, Sha256}; 5 use wasm_bindgen::prelude::*; 6 7 const TX_TAG: &[u8] = b"IUNA-TX-V2"; 8 const ADDRESS_TAG: &[u8] = b"IUNA-ADDRESS-V1"; 9 const ML_SEED_DOMAIN: &str = "iuna-wallet-ml-dsa44-seed-v1"; 10 const ML_CHILD_DOMAIN: &str = "iuna-wallet-ml-dsa44-child-seed-v1"; 11 const ED_SEED_DOMAIN: &str = "iuna-wallet-seed"; 12 const BECH32M: u32 = 0x2bc8_30a3; 13 const CHARSET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l"; 14 const HYBRID_PUBLIC_KEY_BYTES: usize = 1_344; 15 const HYBRID_SIGNATURE_BYTES: usize = 2_484; 16 const MAX_BLOCK_BYTES: usize = 1_000_000; 17 18 #[derive(Clone, Copy)] 19 struct Address { 20 version: u8, 21 payload: [u8; 32], 22 } 23 24 #[derive(Serialize)] 25 #[serde(rename_all = "camelCase")] 26 struct DerivedAddress { 27 index: u32, 28 address: String, 29 } 30 31 #[derive(Deserialize)] 32 #[serde(rename_all = "camelCase")] 33 struct TransferRequest { 34 seed: String, 35 chain_id: String, 36 genesis_hash: String, 37 recipient_address: String, 38 amount: String, 39 fee_rate: String, 40 change_index: u32, 41 utxos: Vec<HybridUtxo>, 42 } 43 44 #[derive(Deserialize, Clone)] 45 #[serde(rename_all = "camelCase")] 46 struct HybridUtxo { 47 address_index: u32, 48 #[serde(default)] 49 address_branch: HybridAddressBranch, 50 outpoint: Outpoint, 51 output: Output, 52 } 53 54 #[derive(Clone, Copy, Default, Deserialize)] 55 #[serde(rename_all = "lowercase")] 56 enum HybridAddressBranch { 57 #[default] 58 External, 59 Reward, 60 } 61 62 impl HybridAddressBranch { 63 fn domain_label(self) -> &'static str { 64 match self { 65 Self::External => "external", 66 Self::Reward => "reward", 67 } 68 } 69 } 70 71 #[derive(Deserialize, Clone)] 72 struct Outpoint { 73 txid: String, 74 index: u32, 75 } 76 77 #[derive(Deserialize, Clone)] 78 struct Output { 79 amount: u64, 80 } 81 82 #[derive(Deserialize)] 83 #[serde(rename_all = "camelCase")] 84 struct MigrationRequest { 85 seed: String, 86 chain_id: String, 87 genesis_hash: String, 88 destination_index: u32, 89 fee_rate: String, 90 utxos: Vec<LegacyUtxo>, 91 } 92 93 #[derive(Deserialize, Clone)] 94 struct LegacyUtxo { 95 outpoint: Outpoint, 96 output: Output, 97 } 98 99 #[derive(Serialize)] 100 #[serde(rename_all = "camelCase")] 101 struct BuiltTransaction { 102 envelope: String, 103 transaction_id: String, 104 fee: String, 105 input_count: usize, 106 } 107 108 #[wasm_bindgen] 109 pub fn derive_external_addresses( 110 seed: &str, 111 count: u32, 112 network_id: &str, 113 ) -> Result<String, JsValue> { 114 derive_addresses(seed, count, network_id, HybridAddressBranch::External) 115 } 116 117 #[wasm_bindgen] 118 pub fn derive_reward_addresses( 119 seed: &str, 120 count: u32, 121 network_id: &str, 122 ) -> Result<String, JsValue> { 123 derive_addresses(seed, count, network_id, HybridAddressBranch::Reward) 124 } 125 126 fn derive_addresses( 127 seed: &str, 128 count: u32, 129 network_id: &str, 130 branch: HybridAddressBranch, 131 ) -> Result<String, JsValue> { 132 if count == 0 || count > 10_000 { 133 return Err(js_error("address count must be between 1 and 10000")); 134 } 135 let addresses = (0..count) 136 .map(|index| { 137 let (_, _, address) = hybrid_key(seed, branch, index); 138 DerivedAddress { 139 index, 140 address: encode_address(address, network_id), 141 } 142 }) 143 .collect::<Vec<_>>(); 144 serde_json::to_string(&addresses).map_err(js_error) 145 } 146 147 #[wasm_bindgen] 148 pub fn build_transfer(request_json: &str) -> Result<String, JsValue> { 149 let request: TransferRequest = serde_json::from_str(request_json).map_err(js_error)?; 150 let amount = parse_u64(&request.amount, "amount")?; 151 let fee_rate = parse_positive_u64(&request.fee_rate, "fee rate")?; 152 if amount == 0 { 153 return Err(js_error("amount must be greater than zero")); 154 } 155 let recipient = decode_address(&request.recipient_address, &request.chain_id)?; 156 if recipient.version != 1 { 157 return Err(js_error("hybrid transfers require an address-v1 recipient")); 158 } 159 let genesis_hash = decode_array::<32>(&request.genesis_hash, "genesis hash")?; 160 let mut available = request.utxos; 161 available.sort_by(|left, right| { 162 right 163 .output 164 .amount 165 .cmp(&left.output.amount) 166 .then_with(|| left.outpoint.txid.cmp(&right.outpoint.txid)) 167 .then_with(|| left.outpoint.index.cmp(&right.outpoint.index)) 168 }); 169 170 let mut fee = 1_u64; 171 let (selected, total, outputs) = loop { 172 let required = amount 173 .checked_add(fee) 174 .ok_or_else(|| js_error("amount plus fee overflows"))?; 175 let mut total = 0_u64; 176 let mut selected = Vec::new(); 177 for utxo in &available { 178 total = total 179 .checked_add(utxo.output.amount) 180 .ok_or_else(|| js_error("input total overflows"))?; 181 selected.push(utxo.clone()); 182 if total >= required { 183 break; 184 } 185 } 186 if total < required { 187 return Err(js_error( 188 "insufficient hybrid funds for the amount and network fee", 189 )); 190 } 191 let mut outputs = vec![(recipient, amount)]; 192 let change = total - required; 193 if change > 0 { 194 let (_, _, change_address) = hybrid_key( 195 &request.seed, 196 HybridAddressBranch::External, 197 request.change_index, 198 ); 199 outputs.push((change_address, change)); 200 } 201 let size = transfer_encoded_size(&request.chain_id, selected.len(), outputs.len()); 202 if size > MAX_BLOCK_BYTES { 203 return Err(js_error( 204 "transaction exceeds the block budget; send a smaller amount or consolidate first", 205 )); 206 } 207 let required_fee = fee_rate 208 .checked_mul(size as u64) 209 .ok_or_else(|| js_error("fee overflows"))? 210 .max(1); 211 if fee >= required_fee { 212 break (selected, total, outputs); 213 } 214 fee = required_fee; 215 }; 216 if total < amount + fee { 217 return Err(js_error("insufficient hybrid funds after fee calculation")); 218 } 219 220 let mut signing = encode_prefix(&request.chain_id, genesis_hash)?; 221 signing.push(1); 222 push_u32(&mut signing, selected.len())?; 223 for input in &selected { 224 signing.extend_from_slice(&decode_array::<32>( 225 &input.outpoint.txid, 226 "v2 transaction ID", 227 )?); 228 signing.extend_from_slice(&input.outpoint.index.to_be_bytes()); 229 let (_, _, owner) = hybrid_key( 230 &request.seed, 231 input.address_branch, 232 input.address_index, 233 ); 234 encode_address_bytes(&mut signing, owner); 235 } 236 encode_outputs(&mut signing, &outputs)?; 237 signing.extend_from_slice(&fee.to_be_bytes()); 238 239 let mut envelope = signing.clone(); 240 push_u32(&mut envelope, selected.len())?; 241 for input in &selected { 242 let (public_key, ml_seed, _) = hybrid_key( 243 &request.seed, 244 input.address_branch, 245 input.address_index, 246 ); 247 let signature = hybrid_signature(&request.seed, ml_seed, &signing)?; 248 encode_authorization(&mut envelope, 2, &public_key, &signature)?; 249 } 250 built_json(envelope, fee, selected.len()) 251 } 252 253 #[wasm_bindgen] 254 pub fn build_migration(request_json: &str) -> Result<String, JsValue> { 255 let request: MigrationRequest = serde_json::from_str(request_json).map_err(js_error)?; 256 let fee_rate = parse_positive_u64(&request.fee_rate, "fee rate")?; 257 if request.utxos.is_empty() { 258 return Err(js_error("no legacy outputs are available for migration")); 259 } 260 if request.utxos.len() > 1_000 { 261 return Err(js_error("a migration can contain at most 1000 inputs")); 262 } 263 let genesis_hash = decode_array::<32>(&request.genesis_hash, "genesis hash")?; 264 let total = request.utxos.iter().try_fold(0_u64, |sum, utxo| { 265 sum.checked_add(utxo.output.amount) 266 .ok_or_else(|| js_error("input total overflows")) 267 })?; 268 let encoded_size = migration_encoded_size(&request.chain_id, &request.utxos); 269 if encoded_size > MAX_BLOCK_BYTES { 270 return Err(js_error( 271 "migration exceeds the block budget; migrate fewer legacy outputs at a time", 272 )); 273 } 274 let fee = fee_rate 275 .checked_mul(encoded_size as u64) 276 .ok_or_else(|| js_error("fee overflows"))? 277 .max(1); 278 let migrated = total 279 .checked_sub(fee) 280 .filter(|amount| *amount > 0) 281 .ok_or_else(|| js_error("migration fee exceeds the available legacy balance"))?; 282 let ed_seed = derive_seed(ED_SEED_DOMAIN, &request.seed); 283 let ed_public = SigningKey::from_bytes(&ed_seed).verifying_key().to_bytes(); 284 let (_, _, destination) = hybrid_key( 285 &request.seed, 286 HybridAddressBranch::External, 287 request.destination_index, 288 ); 289 290 let mut signing = encode_prefix(&request.chain_id, genesis_hash)?; 291 signing.push(4); 292 push_u32(&mut signing, request.utxos.len())?; 293 for input in &request.utxos { 294 let id = decode_hex(&input.outpoint.txid, "legacy transaction ID")?; 295 match id.len() { 296 32 => signing.push(0), 297 64 => signing.push(1), 298 _ => { 299 return Err(js_error( 300 "legacy transaction ID must contain 32 or 64 bytes", 301 )); 302 } 303 } 304 signing.extend_from_slice(&id); 305 signing.extend_from_slice(&input.outpoint.index.to_be_bytes()); 306 encode_address_bytes( 307 &mut signing, 308 Address { 309 version: 0, 310 payload: ed_public, 311 }, 312 ); 313 } 314 encode_outputs(&mut signing, &[(destination, migrated)])?; 315 signing.extend_from_slice(&fee.to_be_bytes()); 316 317 let signature = SigningKey::from_bytes(&ed_seed).sign(&signing).to_bytes(); 318 let mut envelope = signing; 319 push_u32(&mut envelope, request.utxos.len())?; 320 for _ in &request.utxos { 321 encode_authorization(&mut envelope, 0, &ed_public, &signature)?; 322 } 323 built_json(envelope, fee, request.utxos.len()) 324 } 325 326 fn hybrid_key( 327 seed: &str, 328 branch: HybridAddressBranch, 329 index: u32, 330 ) -> ([u8; HYBRID_PUBLIC_KEY_BYTES], [u8; 32], Address) { 331 let ed_seed = derive_seed(ED_SEED_DOMAIN, seed); 332 let parent_ml_seed = derive_seed(ML_SEED_DOMAIN, seed); 333 let ml_seed = if matches!(branch, HybridAddressBranch::External) && index == 0 { 334 parent_ml_seed 335 } else { 336 derive_child_seed(parent_ml_seed, branch, index) 337 }; 338 let ed_public = SigningKey::from_bytes(&ed_seed).verifying_key().to_bytes(); 339 let ml_public = ml_public_key(ml_seed); 340 let mut public_key = [0_u8; HYBRID_PUBLIC_KEY_BYTES]; 341 public_key[..32].copy_from_slice(&ed_public); 342 public_key[32..].copy_from_slice(&ml_public); 343 let mut hasher = Sha256::new(); 344 hasher.update(ADDRESS_TAG); 345 hasher.update([2]); 346 hasher.update(32_u32.to_be_bytes()); 347 hasher.update(ed_public); 348 hasher.update(1_312_u32.to_be_bytes()); 349 hasher.update(ml_public); 350 let address = Address { 351 version: 1, 352 payload: hasher.finalize().into(), 353 }; 354 (public_key, ml_seed, address) 355 } 356 357 fn hybrid_signature( 358 seed: &str, 359 ml_seed: [u8; 32], 360 payload: &[u8], 361 ) -> Result<[u8; HYBRID_SIGNATURE_BYTES], JsValue> { 362 let ed_seed = derive_seed(ED_SEED_DOMAIN, seed); 363 let ed = SigningKey::from_bytes(&ed_seed).sign(payload).to_bytes(); 364 let ml_key = MlDsaSigningKey::<MlDsa44>::from_seed(&Seed::from(ml_seed)); 365 let ml = ml_key 366 .expanded_key() 367 .sign_deterministic(payload, &[]) 368 .map_err(|_| js_error("failed to create ML-DSA-44 signature"))? 369 .encode(); 370 let mut signature = [0_u8; HYBRID_SIGNATURE_BYTES]; 371 signature[..64].copy_from_slice(&ed); 372 signature[64..].copy_from_slice(ml.as_slice()); 373 Ok(signature) 374 } 375 376 fn ml_public_key(seed: [u8; 32]) -> [u8; 1_312] { 377 MlDsaSigningKey::<MlDsa44>::from_seed(&Seed::from(seed)) 378 .verifying_key() 379 .encode() 380 .as_slice() 381 .try_into() 382 .expect("fixed ML-DSA public key") 383 } 384 385 fn derive_seed(domain: &str, seed: &str) -> [u8; 32] { 386 let mut hasher = Sha256::new(); 387 hasher.update(domain.as_bytes()); 388 hasher.update(b":"); 389 hasher.update(seed.as_bytes()); 390 hasher.finalize().into() 391 } 392 393 fn derive_child_seed( 394 parent: [u8; 32], 395 branch: HybridAddressBranch, 396 index: u32, 397 ) -> [u8; 32] { 398 let mut hasher = Sha256::new(); 399 hasher.update(ML_CHILD_DOMAIN.as_bytes()); 400 hasher.update(b":"); 401 hasher.update(branch.domain_label().as_bytes()); 402 hasher.update(b":"); 403 hasher.update(index.to_be_bytes()); 404 hasher.update(b":"); 405 hasher.update(parent); 406 hasher.finalize().into() 407 } 408 409 fn encode_prefix(chain_id: &str, genesis_hash: [u8; 32]) -> Result<Vec<u8>, JsValue> { 410 if chain_id.is_empty() || chain_id.len() > 64 || !chain_id.is_ascii() { 411 return Err(js_error("invalid transaction v2 chain ID")); 412 } 413 let mut bytes = Vec::new(); 414 bytes.extend_from_slice(TX_TAG); 415 bytes.extend_from_slice(&2_u16.to_be_bytes()); 416 push_bytes(&mut bytes, chain_id.as_bytes())?; 417 bytes.extend_from_slice(&genesis_hash); 418 Ok(bytes) 419 } 420 421 fn encode_outputs(bytes: &mut Vec<u8>, outputs: &[(Address, u64)]) -> Result<(), JsValue> { 422 push_u32(bytes, outputs.len())?; 423 for (address, amount) in outputs { 424 if *amount == 0 { 425 return Err(js_error("transaction outputs must be greater than zero")); 426 } 427 encode_address_bytes(bytes, *address); 428 bytes.extend_from_slice(&amount.to_be_bytes()); 429 } 430 Ok(()) 431 } 432 433 fn encode_address_bytes(bytes: &mut Vec<u8>, address: Address) { 434 bytes.push(address.version); 435 bytes.extend_from_slice(&address.payload); 436 } 437 438 fn encode_authorization( 439 bytes: &mut Vec<u8>, 440 scheme: u8, 441 public_key: &[u8], 442 signature: &[u8], 443 ) -> Result<(), JsValue> { 444 bytes.push(scheme); 445 push_bytes(bytes, public_key)?; 446 push_bytes(bytes, signature) 447 } 448 449 fn transfer_encoded_size(chain_id: &str, inputs: usize, outputs: usize) -> usize { 450 TX_TAG.len() 451 + 2 452 + 4 453 + chain_id.len() 454 + 32 455 + 1 456 + 4 457 + inputs * 69 458 + 4 459 + outputs * 41 460 + 8 461 + 4 462 + inputs * (1 + 4 + HYBRID_PUBLIC_KEY_BYTES + 4 + HYBRID_SIGNATURE_BYTES) 463 } 464 465 fn migration_encoded_size(chain_id: &str, inputs: &[LegacyUtxo]) -> usize { 466 let input_bytes = inputs 467 .iter() 468 .map(|input| 1 + input.outpoint.txid.len() / 2 + 4 + 33) 469 .sum::<usize>(); 470 TX_TAG.len() 471 + 2 472 + 4 473 + chain_id.len() 474 + 32 475 + 1 476 + 4 477 + input_bytes 478 + 4 479 + 41 480 + 8 481 + 4 482 + inputs.len() * (1 + 4 + 32 + 4 + 64) 483 } 484 485 fn built_json(envelope: Vec<u8>, fee: u64, input_count: usize) -> Result<String, JsValue> { 486 let transaction_id = hex(&Sha256::digest(&envelope)); 487 serde_json::to_string(&BuiltTransaction { 488 envelope: hex(&envelope), 489 transaction_id, 490 fee: fee.to_string(), 491 input_count, 492 }) 493 .map_err(js_error) 494 } 495 496 fn encode_address(address: Address, network_id: &str) -> String { 497 let hrp = if network_id.contains("testnet") || network_id.contains("e2e") { 498 "tiuna" 499 } else { 500 "iuna" 501 }; 502 let mut data = vec![address.version]; 503 data.extend(convert_bits(&address.payload, 8, 5, true).expect("fixed payload converts")); 504 let mut values = hrp_expand(hrp); 505 values.extend_from_slice(&data); 506 values.extend_from_slice(&[0; 6]); 507 let checksum = polymod(&values) ^ BECH32M; 508 let encoded = data 509 .into_iter() 510 .chain((0..6).map(|index| ((checksum >> (5 * (5 - index))) & 31) as u8)) 511 .map(|value| CHARSET[value as usize] as char) 512 .collect::<String>(); 513 format!("{hrp}1{encoded}") 514 } 515 516 fn decode_address(value: &str, network_id: &str) -> Result<Address, JsValue> { 517 let value = value.trim(); 518 if value.is_empty() || value.len() > 90 || !value.is_ascii() { 519 return Err(js_error("address length or encoding is invalid")); 520 } 521 let has_lower = value.bytes().any(|byte| byte.is_ascii_lowercase()); 522 let has_upper = value.bytes().any(|byte| byte.is_ascii_uppercase()); 523 if has_lower && has_upper { 524 return Err(js_error( 525 "address must not mix uppercase and lowercase characters", 526 )); 527 } 528 let expected = if network_id.contains("testnet") || network_id.contains("e2e") { 529 "tiuna" 530 } else { 531 "iuna" 532 }; 533 let normalized = value.to_ascii_lowercase(); 534 let separator = normalized 535 .rfind('1') 536 .ok_or_else(|| js_error("address is missing its separator"))?; 537 if &normalized[..separator] != expected { 538 return Err(js_error("address belongs to another network")); 539 } 540 let data = normalized[separator + 1..] 541 .bytes() 542 .map(|byte| { 543 CHARSET 544 .iter() 545 .position(|candidate| *candidate == byte) 546 .map(|index| index as u8) 547 .ok_or_else(|| js_error("address contains an invalid character")) 548 }) 549 .collect::<Result<Vec<_>, _>>()?; 550 let mut checksum_values = hrp_expand(expected); 551 checksum_values.extend_from_slice(&data); 552 if polymod(&checksum_values) != BECH32M || data.len() < 7 { 553 return Err(js_error("address checksum is invalid")); 554 } 555 let payload = &data[..data.len() - 6]; 556 let version = *payload 557 .first() 558 .ok_or_else(|| js_error("address payload is empty"))?; 559 if version > 1 { 560 return Err(js_error("unsupported address version")); 561 } 562 let bytes = convert_bits(&payload[1..], 5, 8, false)?; 563 if bytes.len() != 32 { 564 return Err(js_error("address payload must contain 32 bytes")); 565 } 566 Ok(Address { 567 version, 568 payload: bytes.try_into().expect("checked address length"), 569 }) 570 } 571 572 fn hrp_expand(hrp: &str) -> Vec<u8> { 573 hrp.bytes() 574 .map(|byte| byte >> 5) 575 .chain(std::iter::once(0)) 576 .chain(hrp.bytes().map(|byte| byte & 31)) 577 .collect() 578 } 579 580 fn polymod(values: &[u8]) -> u32 { 581 let generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; 582 let mut checksum = 1_u32; 583 for value in values { 584 let top = checksum >> 25; 585 checksum = ((checksum & 0x1ff_ffff) << 5) ^ u32::from(*value); 586 for (index, generator) in generators.iter().enumerate() { 587 if (top >> index) & 1 == 1 { 588 checksum ^= generator; 589 } 590 } 591 } 592 checksum 593 } 594 595 fn convert_bits(data: &[u8], from: u32, to: u32, pad: bool) -> Result<Vec<u8>, JsValue> { 596 let mut accumulator = 0_u32; 597 let mut bits = 0_u32; 598 let mut result = Vec::new(); 599 let max = (1_u32 << to) - 1; 600 for value in data { 601 if u32::from(*value) >> from != 0 { 602 return Err(js_error("invalid address data")); 603 } 604 accumulator = 605 ((accumulator << from) | u32::from(*value)) & ((1_u32 << (from + to - 1)) - 1); 606 bits += from; 607 while bits >= to { 608 bits -= to; 609 result.push(((accumulator >> bits) & max) as u8); 610 } 611 } 612 if pad && bits > 0 { 613 result.push(((accumulator << (to - bits)) & max) as u8); 614 } else if !pad && (bits >= from || ((accumulator << (to - bits)) & max) != 0) { 615 return Err(js_error("invalid address padding")); 616 } 617 Ok(result) 618 } 619 620 fn push_u32(bytes: &mut Vec<u8>, value: usize) -> Result<(), JsValue> { 621 let value = u32::try_from(value).map_err(|_| js_error("value exceeds the wire limit"))?; 622 bytes.extend_from_slice(&value.to_be_bytes()); 623 Ok(()) 624 } 625 626 fn push_bytes(bytes: &mut Vec<u8>, value: &[u8]) -> Result<(), JsValue> { 627 push_u32(bytes, value.len())?; 628 bytes.extend_from_slice(value); 629 Ok(()) 630 } 631 632 fn parse_u64(value: &str, label: &str) -> Result<u64, JsValue> { 633 value 634 .parse::<u64>() 635 .map_err(|_| js_error(format!("{label} is invalid"))) 636 } 637 638 fn parse_positive_u64(value: &str, label: &str) -> Result<u64, JsValue> { 639 parse_u64(value, label).and_then(|value| { 640 if value == 0 { 641 Err(js_error(format!("{label} must be greater than zero"))) 642 } else { 643 Ok(value) 644 } 645 }) 646 } 647 648 fn decode_array<const N: usize>(value: &str, label: &str) -> Result<[u8; N], JsValue> { 649 decode_hex(value, label)? 650 .try_into() 651 .map_err(|_| js_error(format!("{label} must contain {N} bytes"))) 652 } 653 654 fn decode_hex(value: &str, label: &str) -> Result<Vec<u8>, JsValue> { 655 if value.len() % 2 != 0 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { 656 return Err(js_error(format!("{label} is not hexadecimal"))); 657 } 658 (0..value.len()) 659 .step_by(2) 660 .map(|index| u8::from_str_radix(&value[index..index + 2], 16).map_err(js_error)) 661 .collect() 662 } 663 664 fn hex(bytes: &[u8]) -> String { 665 bytes.iter().map(|byte| format!("{byte:02x}")).collect() 666 } 667 668 fn js_error(error: impl std::fmt::Display) -> JsValue { 669 JsValue::from_str(&error.to_string()) 670 }