iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

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 }