From 0b6df8c8c00dc5c2bb31298b2033825767f50b1c Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Tue, 7 Jul 2026 14:03:54 -0700 Subject: [PATCH 1/3] feat: improve typing --- Cargo.lock | 6 +- keetanetwork-account/src/account.rs | 502 ++++++++------- keetanetwork-account/src/doc_utils.rs | 2 +- keetanetwork-account/src/lib.rs | 5 +- .../tests/identifier_accounts.rs | 2 +- keetanetwork-bindings/src/parse.rs | 18 +- keetanetwork-block/src/time.rs | 67 +- .../host-tests/Cargo.lock | 10 +- .../host-tests/tests/java_account.rs | 6 +- .../host-tests/tests/java_client_ops.rs | 9 +- .../host-tests/tests/java_p1_net.rs | 5 +- .../host-tests/tests/java_token_supply.rs | 5 +- .../host-tests/tests/p2_net.rs | 361 ++++++++--- .../host-tests/tests/smoke.rs | 28 +- keetanetwork-client-wasi/src/p2/mod.rs | 600 +++++++++++++----- keetanetwork-client-wasi/src/pure.rs | 7 + keetanetwork-client-wasi/wit/world.wit | 457 ++++++++----- keetanetwork-client-wasm/src/client.rs | 96 ++- keetanetwork-client-wasm/src/convert.rs | 19 +- keetanetwork-client-wasm/src/dto.rs | 35 +- keetanetwork-client-wasm/src/user.rs | 102 ++- keetanetwork-client/Cargo.toml | 7 + .../openapi/keetanet-node.yaml | 10 + keetanetwork-client/src/builder.rs | 4 +- keetanetwork-client/src/client.rs | 395 ++++++++---- keetanetwork-client/src/codec.rs | 101 ++- keetanetwork-client/src/error.rs | 18 + keetanetwork-client/src/genesis.rs | 4 +- keetanetwork-client/src/lib.rs | 14 +- keetanetwork-client/src/math.rs | 15 +- keetanetwork-client/src/model.rs | 65 +- keetanetwork-client/src/transport.rs | 210 ++++-- keetanetwork-client/src/user.rs | 236 +++++-- keetanetwork-client/tests/e2e.rs | 368 +++++++---- keetanetwork-vote/src/hash.rs | 8 +- 35 files changed, 2676 insertions(+), 1121 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe277fc..97748bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -348,9 +348,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -1308,6 +1308,7 @@ version = "0.3.2" dependencies = [ "async-trait", "base64", + "chrono", "futures", "getrandom 0.2.16", "getrandom 0.3.3", @@ -1316,6 +1317,7 @@ dependencies = [ "js-sys", "keetanetwork-account", "keetanetwork-block", + "keetanetwork-crypto", "keetanetwork-error", "keetanetwork-utils", "keetanetwork-vote", diff --git a/keetanetwork-account/src/account.rs b/keetanetwork-account/src/account.rs index 042d4e2..b456f3f 100644 --- a/keetanetwork-account/src/account.rs +++ b/keetanetwork-account/src/account.rs @@ -358,6 +358,78 @@ pub trait AccountPublicKey { /// The key pair type (algorithm) backing this account. fn to_keypair_type(&self) -> KeyPairType; + + /// The raw public key bytes, without the key type prefix byte. + /// + /// Together with [`to_keypair_type`](Self::to_keypair_type) this is the + /// account's identity, borrowed without allocating. + fn as_public_key_bytes(&self) -> &[u8]; + + /// The account's `keeta_...` network address. + /// + /// # Errors + /// + /// Returns [`AccountError::InvalidConstruction`] when the key material is + /// empty, or any encoding failure from address formatting. + /// + /// # Examples + /// + /// ```rust + /// # use keetanetwork_account::doc_utils; + /// use keetanetwork_account::AccountPublicKey; + /// + /// # let (_, _, account) = doc_utils::create_ed25519_test_keys(None); + /// let address = account.to_public_key_string()?; + /// assert!(address.starts_with("keeta_")); + /// # Ok::<(), Box>(()) + /// ``` + fn to_public_key_string(&self) -> Result { + let key_with_type = self.to_public_key_with_type(); + let (key_type, raw_key) = key_with_type + .split_first() + .ok_or(AccountError::InvalidConstruction)?; + + format_public_key_string(raw_key, *key_type) + } + + /// Encrypt `plaintext` to this account's public key. + /// + /// # Errors + /// + /// Returns an error when encryption fails or the key type does not + /// support encryption (see [`supports_encryption`](Self::supports_encryption)). + fn encrypt>(&self, plaintext: T) -> Result, AccountError>; + + /// Whether this account's key type supports encryption operations. + fn supports_encryption(&self) -> bool; +} + +/// The raw public key bytes prefixed with the key type byte, the canonical +/// [`AccountPublicKey::to_public_key_with_type`] layout. +fn public_key_with_type(raw_key: &[u8], key_type: KeyPairType) -> Vec { + let mut bytes = Vec::with_capacity(1 + raw_key.len()); + bytes.push(key_type as u8); + bytes.extend_from_slice(raw_key); + bytes +} + +/// Exposes an account's private-key custody polymorphically over the +/// statically typed [`Account`] and the runtime-typed [`GenericAccount`]: +/// decrypting with and extracting the held private key. +pub trait AccountPrivateKey { + /// Decrypt `ciphertext` with the private key. + /// + /// # Errors + /// + /// Returns an error when decryption fails, no private key is held, or + /// the key type does not support encryption. + fn decrypt>(&self, ciphertext: T) -> Result, AccountError>; + + /// Extract the private key, consuming the holder. + /// + /// Returns `None` when only public key material is held (public-only or + /// identifier accounts). + fn take_private_key(self) -> Option; } /// Trait for types that can provide raw public key bytes. @@ -464,7 +536,15 @@ impl FromHex for IdentifierKey { /// /// All methods return `Result` types with [`AccountError`] for consistent /// error handling across different key types and operations. -pub trait KeyPair: AccountSigner + AccountVerifier + Send + Sync + TryFrom { +pub trait KeyPair: + AccountSigner + + AccountVerifier + + AccountPublicKey + + AccountPrivateKey + + Send + + Sync + + TryFrom +{ /// The public key storage type for this key pair. type PublicKey: PublicKeyStorage; @@ -559,95 +639,6 @@ pub trait KeyPair: AccountSigner + AccountVerifier + Send + Sync + TryFrom Result; - /// Encrypt data using the public key. - /// - /// This method performs public key encryption using the appropriate scheme - /// for the key type. The encrypted data can only be decrypted using the - /// corresponding private key. - /// - /// # Parameters - /// - /// - `plaintext`: The data to encrypt (any type that can convert to bytes) - /// - /// # Returns - /// - /// The encrypted ciphertext as a byte vector, or an error if encryption - /// fails or is not supported by this key type. - /// - /// # Examples - /// - /// ```rust - /// # use keetanetwork_account::doc_utils; - /// use keetanetwork_account::{KeyED25519, KeyPair}; - /// - /// // Create test account with private key - /// # let (_, _, account) = doc_utils::create_ed25519_test_keys(None); - /// // Encrypt a message - /// let message = b"Secret message for the Keeta Network"; - /// let ciphertext = account.keypair.encrypt(message)?; - /// # Ok::<(), Box>(()) - /// ``` - fn encrypt>(&self, plaintext: T) -> Result, AccountError> { - let public_key = self.to_public_key(); - let ciphertext = public_key.encrypt(plaintext.as_ref())?; - Ok(ciphertext) - } - - /// Decrypt data using the private key. - /// - /// This method performs private key decryption to recover the original - /// plaintext from ciphertext that was encrypted using the corresponding - /// public key. This operation requires access to the private key. - /// - /// # Parameters - /// - /// - `ciphertext`: The encrypted data to decrypt - /// - /// # Returns - /// - /// The decrypted plaintext as a byte vector, or an error if decryption - /// fails, the private key is not available, or the key type does not - /// support encryption. - /// - /// # Examples - /// - /// ```rust - /// # use keetanetwork_account::doc_utils; - /// use keetanetwork_account::{KeyED25519, KeyPair}; - /// - /// // Create test account with private key - /// # let (_, _, account) = doc_utils::create_ed25519_test_keys(None); - /// // Encrypt and then decrypt a message - /// let original_message = b"Secret message for the Keeta Network"; - /// let ciphertext = account.keypair.encrypt(original_message)?; - /// let plaintext = account.keypair.decrypt(&ciphertext)?; - /// assert_eq!(original_message, plaintext.as_slice()); - /// # Ok::<(), Box>(()) - /// ``` - fn decrypt>(&self, ciphertext: T) -> Result, AccountError>; - - /// Check if this key pair supports encryption operations. - /// - /// Returns `true` if both [`encrypt`](Self::encrypt) and [`decrypt`](Self::decrypt) - /// operations are supported by this key type, `false` otherwise. - /// - /// # Examples - /// - /// ```rust - /// # use keetanetwork_account::doc_utils; - /// use keetanetwork_account::{KeyED25519, KeyNETWORK, KeyPair}; - /// - /// // Create test accounts - /// # let (_, _, ed25519_account) = doc_utils::create_ed25519_test_keys(None); - /// # let network_account = doc_utils::create_network_test_account(Some(5)); - /// // Cryptographic keys support encryption - /// assert!(ed25519_account.keypair.supports_encryption()); - /// // Identifier keys do not - /// assert!(!network_account.keypair.supports_encryption()); - /// # Ok::<(), Box>(()) - /// ``` - fn supports_encryption(&self) -> bool; - /// Convert the key pair to a public key. /// /// This method extracts the public key component from the key pair. @@ -680,111 +671,6 @@ pub trait KeyPair: AccountSigner + AccountVerifier + Send + Sync + TryFrom>(()) /// ``` fn to_public_key(&self) -> Self::PublicKey; - - /// Extract the private key if available. - /// - /// This method consumes the account and returns the private key component - /// if it exists. After calling this method, the account is no longer - /// usable. This is useful for securely transferring private keys or - /// converting between different key representations. - /// - /// # Returns - /// - /// `Some(private_key)` if a private key is available, `None` if this is - /// a public-key-only account. - /// - /// # Examples - /// - /// ```rust - /// # use keetanetwork_account::doc_utils; - /// use keetanetwork_account::{KeyED25519, KeyPair}; - /// use keetanetwork_crypto::prelude::AnyPrivateKey; - /// - /// // Create test account with private key - /// # let (_, _, account) = doc_utils::create_ed25519_test_keys(None); - /// // Extract the private key (consumes the account) - /// let private_key = account.keypair.take_private_key(); - /// match private_key { - /// Some(AnyPrivateKey::Ed25519(_key)) => { - /// // Private key was successfully extracted - /// assert!(true); - /// } - /// _ => { - /// panic!("Expected Ed25519 private key"); - /// } - /// } - /// # Ok::<(), Box>(()) - /// ``` - fn take_private_key(self) -> Option; - - /// Convert the key pair to a keeta network address string. - /// - /// This method generates a human-readable Keeta Network address string - /// that uniquely identifies this key pair. The address includes the - /// algorithm type and can be used for receiving transactions or messages. - /// - /// # Returns - /// - /// A formatted address string with `keeta_` prefix. - /// - /// # Format - /// - /// The address format varies by key type: - /// - Ed25519: `keeta_ae...` or `keeta_ah...` - /// - ECDSA secp256k1: `keeta_aa...` or `keeta_ab...` - /// - ECDSA secp256r1: `keeta_ay...` or `keeta_az...` - /// - Network: `keeta_ai...` through `keeta_al...` - /// - Token: `keeta_am...` through `keeta_ap...` - /// - Storage: `keeta_aq...` through `keeta_at...` - /// - Multisig: `keeta_a4...` through `keeta_a7...` - /// - /// # Examples - /// - /// ```rust - /// # use keetanetwork_account::doc_utils; - /// use keetanetwork_account::{KeyED25519, KeyPair}; - /// - /// // Create test account - /// # let (_, _, account) = doc_utils::create_ed25519_test_keys(None); - /// // Get the network address - /// let address = account.keypair.to_public_key_string()?; - /// assert!(address.starts_with("keeta_ae") || address.starts_with("keeta_ah")); - /// # Ok::<(), Box>(()) - /// ``` - fn to_public_key_string(&self) -> Result { - let key_type_value = self.to_keypair_type() as u8; - format_public_key_string(self.to_public_key(), key_type_value) - } - - /// Returns the key pair type for this instance. - /// - /// This method returns the runtime key pair type identifier, which is - /// useful for type checking and serialization. It returns the same value - /// as the `KEY_PAIR_TYPE` constant but is available on instances. - /// - /// # Examples - /// - /// ```rust - /// # use keetanetwork_account::doc_utils; - /// use keetanetwork_account::{KeyED25519, KeyPair, KeyPairType}; - /// - /// # let (_, _, account) = doc_utils::create_ed25519_test_keys(None); - /// // Get the runtime key type - /// let key_type = account.keypair.to_keypair_type(); - /// assert_eq!(key_type, KeyPairType::ED25519); - /// - /// // Useful for pattern matching - /// match key_type { - /// KeyPairType::ED25519 => assert!(true), // Expected - /// KeyPairType::ECDSASECP256K1 => panic!("Unexpected ECDSA key"), - /// KeyPairType::NETWORK => panic!("Unexpected Network identifier"), - /// _ => panic!("Other unexpected key type"), - /// } - /// # Ok::<(), Box>(()) - /// ``` - fn to_keypair_type(&self) -> KeyPairType { - Self::KEY_PAIR_TYPE - } } /// Different types of key material that can be used to create key pairs. @@ -863,7 +749,7 @@ impl From<(Vec, Index)> for Keyable { /// # Examples /// /// ```rust -/// use keetanetwork_account::{Account, KeyECDSASECP256K1, KeyPair}; +/// use keetanetwork_account::{Account, AccountPublicKey, KeyECDSASECP256K1}; /// use keetanetwork_crypto::algorithms::secp256k1::Secp256k1Derivation; /// use keetanetwork_crypto::prelude::{KeyDerivation, IntoSecret}; /// @@ -916,6 +802,12 @@ macro_rules! impl_crypto_keypair { } } + fn to_public_key(&self) -> Self::PublicKey { + self.public_key.clone() + } + } + + impl AccountPrivateKey for $key_type { fn decrypt>(&self, ciphertext: T) -> Result, AccountError> { let private_key = self .private_key @@ -925,16 +817,30 @@ macro_rules! impl_crypto_keypair { Ok(private_key.decrypt(ciphertext.as_ref())?) } - fn supports_encryption(&self) -> bool { - true // ECIES encryption is implemented for this algorithm + fn take_private_key(self) -> Option { + self.private_key.map(AnyPrivateKey::$any_variant) } + } - fn to_public_key(&self) -> Self::PublicKey { - self.public_key.clone() + impl AccountPublicKey for $key_type { + fn to_public_key_with_type(&self) -> Vec { + public_key_with_type(self.public_key.as_ref(), $keypair_type) } - fn take_private_key(self) -> Option { - self.private_key.map(AnyPrivateKey::$any_variant) + fn to_keypair_type(&self) -> KeyPairType { + $keypair_type + } + + fn as_public_key_bytes(&self) -> &[u8] { + self.public_key.as_ref() + } + + fn encrypt>(&self, plaintext: T) -> Result, AccountError> { + Ok(self.public_key.encrypt(plaintext.as_ref())?) + } + + fn supports_encryption(&self) -> bool { + true // ECIES encryption is implemented for this algorithm } } }; @@ -957,7 +863,7 @@ impl_crypto_keypair!( /// # Examples /// /// ```rust -/// use keetanetwork_account::{Account, KeyECDSASECP256R1, KeyPair}; +/// use keetanetwork_account::{Account, AccountPublicKey, KeyECDSASECP256R1}; /// use keetanetwork_crypto::algorithms::secp256r1::Secp256r1Derivation; /// use keetanetwork_crypto::prelude::{KeyDerivation, IntoSecret}; /// @@ -1000,7 +906,7 @@ impl_crypto_keypair!( /// # Examples /// /// ```rust -/// use keetanetwork_account::{Account, KeyED25519, KeyPair}; +/// use keetanetwork_account::{Account, AccountPublicKey, KeyED25519}; /// use keetanetwork_crypto::algorithms::ed25519::Ed25519Derivation; /// use keetanetwork_crypto::prelude::{KeyDerivation, IntoSecret}; /// @@ -1044,7 +950,7 @@ impl_crypto_keypair!( /// # Examples /// /// ```rust -/// use keetanetwork_account::{Account, KeyNETWORK, KeyPair}; +/// use keetanetwork_account::{Account, AccountPublicKey, KeyNETWORK}; /// /// // Create a network identifier from a network ID /// let account = Account::::generate_network_address(12345)?; @@ -1068,7 +974,7 @@ pub struct KeyNETWORK { /// # Examples /// /// ```rust -/// use keetanetwork_account::{Account, KeyTOKEN, KeyPair, Keyable, Accountable}; +/// use keetanetwork_account::{Account, AccountPublicKey, KeyTOKEN, Keyable, Accountable}; /// /// // Create a token identifier directly from an identifier string /// let keyable = Keyable::Identifier("test-token-id".to_string()); @@ -1090,7 +996,7 @@ pub struct KeyTOKEN { /// # Examples /// /// ```rust -/// use keetanetwork_account::{Account, KeySTORAGE, KeyPair, Keyable, Accountable}; +/// use keetanetwork_account::{Account, AccountPublicKey, KeySTORAGE, Keyable, Accountable}; /// /// // Create a storage identifier directly from an identifier string /// let keyable = Keyable::Identifier("test-storage-id".to_string()); @@ -1114,7 +1020,7 @@ pub struct KeySTORAGE { /// # Examples /// /// ```rust -/// use keetanetwork_account::{Account, KeyMULTISIG, KeyPair, Keyable, Accountable}; +/// use keetanetwork_account::{Account, AccountPublicKey, KeyMULTISIG, Keyable, Accountable}; /// /// // Create a multisig identifier directly from an identifier string /// let keyable = Keyable::Identifier("test-multisig-id".to_string()); @@ -1329,7 +1235,7 @@ where /// ## Creating Cryptographic Accounts /// /// ```rust -/// use keetanetwork_account::{Account, KeyED25519, KeyPair}; +/// use keetanetwork_account::{Account, AccountPublicKey, KeyED25519}; /// use keetanetwork_crypto::algorithms::ed25519::Ed25519Derivation; /// use keetanetwork_crypto::prelude::{KeyDerivation, IntoSecret}; /// @@ -1346,7 +1252,7 @@ where /// ## Creating Identifier Accounts /// /// ```rust -/// use keetanetwork_account::{Account, KeyNETWORK, KeyPair}; +/// use keetanetwork_account::{Account, AccountPublicKey, KeyNETWORK}; /// /// // Create a network identifier account from a network ID /// let account = Account::::generate_network_address(5)?; @@ -1409,18 +1315,60 @@ where KEYTYPE: KeyPair, { fn to_public_key_with_type(&self) -> Vec { - let public_key = self.keypair.to_public_key(); - let raw_key = public_key.as_ref(); - - let mut bytes = Vec::with_capacity(1 + raw_key.len()); - bytes.push(self.to_keypair_type() as u8); - bytes.extend_from_slice(raw_key); - bytes + self.keypair.to_public_key_with_type() } fn to_keypair_type(&self) -> KeyPairType { self.keypair.to_keypair_type() } + + fn as_public_key_bytes(&self) -> &[u8] { + self.keypair.as_public_key_bytes() + } + + fn encrypt>(&self, plaintext: T) -> Result, AccountError> { + self.keypair.encrypt(plaintext) + } + + fn supports_encryption(&self) -> bool { + self.keypair.supports_encryption() + } +} + +// A borrowed account exposes the same public identity as the account itself. +impl AccountPublicKey for &T { + fn to_public_key_with_type(&self) -> Vec { + (**self).to_public_key_with_type() + } + + fn to_keypair_type(&self) -> KeyPairType { + (**self).to_keypair_type() + } + + fn as_public_key_bytes(&self) -> &[u8] { + (**self).as_public_key_bytes() + } + + fn encrypt>(&self, plaintext: U) -> Result, AccountError> { + (**self).encrypt(plaintext) + } + + fn supports_encryption(&self) -> bool { + (**self).supports_encryption() + } +} + +impl AccountPrivateKey for Account +where + KEYTYPE: KeyPair, +{ + fn decrypt>(&self, ciphertext: T) -> Result, AccountError> { + self.keypair.decrypt(ciphertext) + } + + fn take_private_key(self) -> Option { + self.keypair.take_private_key() + } } impl TryFrom> for Account @@ -2620,24 +2568,40 @@ macro_rules! impl_identifier_keypair { Err(AccountError::InvalidConstruction) } - fn encrypt>(&self, _plaintext: T) -> Result, AccountError> { - Err(AccountError::EncryptionNotSupported) + fn to_public_key(&self) -> Self::PublicKey { + self.public_key.clone() } + } + impl AccountPrivateKey for $key_type { fn decrypt>(&self, _ciphertext: T) -> Result, AccountError> { Err(AccountError::EncryptionNotSupported) } - fn supports_encryption(&self) -> bool { - false + fn take_private_key(self) -> Option { + None // Identifier types do not have private keys } + } - fn to_public_key(&self) -> Self::PublicKey { - self.public_key.clone() + impl AccountPublicKey for $key_type { + fn to_public_key_with_type(&self) -> Vec { + public_key_with_type(self.public_key.as_ref(), $pair_type) } - fn take_private_key(self) -> Option { - None // Identifier types do not have private keys + fn to_keypair_type(&self) -> KeyPairType { + $pair_type + } + + fn as_public_key_bytes(&self) -> &[u8] { + self.public_key.as_ref() + } + + fn encrypt>(&self, _plaintext: T) -> Result, AccountError> { + Err(AccountError::EncryptionNotSupported) + } + + fn supports_encryption(&self) -> bool { + false } } @@ -2751,8 +2715,29 @@ impl AccountPublicKey for GenericAccount { fn to_keypair_type(&self) -> KeyPairType { delegate_to_variants!(self, to_keypair_type) } + + fn as_public_key_bytes(&self) -> &[u8] { + delegate_to_variants!(self, as_public_key_bytes) + } + + fn encrypt>(&self, plaintext: T) -> Result, AccountError> { + delegate_to_variants!(self, encrypt, plaintext) + } + + fn supports_encryption(&self) -> bool { + delegate_to_variants!(self, supports_encryption) + } } +// Equality is account identity: the key algorithm plus the public key. +impl PartialEq for GenericAccount { + fn eq(&self, other: &Self) -> bool { + self.to_keypair_type() == other.to_keypair_type() && self.as_public_key_bytes() == other.as_public_key_bytes() + } +} + +impl Eq for GenericAccount {} + // Macro to apply the same expression to all GenericAccount variants macro_rules! map_all_variants { ($self:ident, $var:ident, $expr:expr) => { @@ -2768,6 +2753,16 @@ macro_rules! map_all_variants { }; } +impl AccountPrivateKey for GenericAccount { + fn decrypt>(&self, ciphertext: T) -> Result, AccountError> { + delegate_to_variants!(self, decrypt, ciphertext) + } + + fn take_private_key(self) -> Option { + map_all_variants!(self, account, account.take_private_key()) + } +} + /// Macro to implement converters for AnyPrivateKey macro_rules! impl_try_from_keypair { ($($key_type:ty),+ $(,)?) => { @@ -5767,4 +5762,61 @@ mod tests { Ok(()) } + + #[test] + fn test_public_key_bytes_match_typed_key() -> Result<(), AccountError> { + macro_rules! test_bytes_match { + ($($key_type:ty),*) => { + $( + let account = create_test_account::<$key_type>(None)?; + let with_type = account.to_public_key_with_type(); + assert_eq!(with_type[0], account.to_keypair_type() as u8); + assert_eq!(&with_type[1..], account.keypair.as_public_key_bytes()); + )* + }; + } + + test_bytes_match!(KeyECDSASECP256K1, KeyECDSASECP256R1, KeyED25519); + + let identifier = create_test_account_from_identifier::("tok")?; + let with_type = identifier.to_public_key_with_type(); + assert_eq!(&with_type[1..], identifier.keypair.as_public_key_bytes()); + + Ok(()) + } + + #[test] + fn test_generic_account_eq_ignores_private_key() -> Result<(), AccountError> { + let signing = create_test_account::(None)?; + let address = signing.keypair.to_public_key_string()?; + let read_only = GenericAccount::from_str(&address)?; + let signing = GenericAccount::from(signing); + + assert_eq!(signing, read_only); + assert_eq!(read_only, signing); + + Ok(()) + } + + #[test] + fn test_generic_account_eq_different_keys() -> Result<(), AccountError> { + let first = GenericAccount::from(create_test_account::(None)?); + let second = GenericAccount::from(create_test_account::(None)?); + + assert_ne!(first, second); + + Ok(()) + } + + #[test] + fn test_generic_account_eq_same_bytes_different_type() -> Result<(), AccountError> { + let raw_bytes = vec![7u8; 32]; + let token = GenericAccount::from(Account::::from(IdentifierKey::new(raw_bytes.clone())?)); + let storage = GenericAccount::from(Account::::from(IdentifierKey::new(raw_bytes)?)); + + assert_eq!(token.as_public_key_bytes(), storage.as_public_key_bytes()); + assert_ne!(token, storage); + + Ok(()) + } } diff --git a/keetanetwork-account/src/doc_utils.rs b/keetanetwork-account/src/doc_utils.rs index 63959a2..737a26c 100644 --- a/keetanetwork-account/src/doc_utils.rs +++ b/keetanetwork-account/src/doc_utils.rs @@ -113,7 +113,7 @@ pub fn create_test_passphrase() -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::{KeyPair, KeyPairType}; + use crate::{AccountPublicKey, KeyPair, KeyPairType}; #[test] fn test_create_ed25519_test_keys() { diff --git a/keetanetwork-account/src/lib.rs b/keetanetwork-account/src/lib.rs index a60c185..9476991 100644 --- a/keetanetwork-account/src/lib.rs +++ b/keetanetwork-account/src/lib.rs @@ -17,8 +17,9 @@ pub mod utils; // Re-export the main types for easier use pub use account::{ - Account, AccountPublicKey, AccountVerifier, Accountable, GenericAccount, KeyECDSASECP256K1, KeyECDSASECP256R1, - KeyED25519, KeyMULTISIG, KeyNETWORK, KeyPair, KeyPairType, KeySTORAGE, KeyTOKEN, Keyable, PublicKeyStorage, + Account, AccountPrivateKey, AccountPublicKey, AccountVerifier, Accountable, GenericAccount, KeyECDSASECP256K1, + KeyECDSASECP256R1, KeyED25519, KeyMULTISIG, KeyNETWORK, KeyPair, KeyPairType, KeySTORAGE, KeyTOKEN, Keyable, + PublicKeyStorage, }; pub use cert::{CertSigner, CertVerifier}; pub use error::AccountError; diff --git a/keetanetwork-account/tests/identifier_accounts.rs b/keetanetwork-account/tests/identifier_accounts.rs index 0e59f3e..fad833b 100644 --- a/keetanetwork-account/tests/identifier_accounts.rs +++ b/keetanetwork-account/tests/identifier_accounts.rs @@ -1,6 +1,6 @@ //! Integration tests for identifier account generation functionality. -use keetanetwork_account::{Account, AccountError, AccountPublicKey, GenericAccount, KeyPair, KeyPairType, KeyTOKEN}; +use keetanetwork_account::{Account, AccountError, AccountPublicKey, GenericAccount, KeyPairType, KeyTOKEN}; use keetanetwork_account::{Accountable, KeyECDSASECP256K1, KeyNETWORK, KeySTORAGE, Keyable}; use keetanetwork_crypto::hash::BlockHash; use keetanetwork_crypto::IntoSecret; diff --git a/keetanetwork-bindings/src/parse.rs b/keetanetwork-bindings/src/parse.rs index ec02156..0e6d9a7 100644 --- a/keetanetwork-bindings/src/parse.rs +++ b/keetanetwork-bindings/src/parse.rs @@ -7,7 +7,7 @@ use alloc::string::{String, ToString}; use core::str::FromStr; use keetanetwork_account::KeyPairType; -use keetanetwork_block::{AdjustMethod, Amount, BaseFlag, BlockPurpose}; +use keetanetwork_block::{AdjustMethod, Amount, BaseFlag, BlockPurpose, BlockTime}; use num_bigint::BigInt; use crate::error::CodedError; @@ -144,10 +144,26 @@ pub fn bigint_hex(value: &str, label: &str) -> Result { .ok_or_else(|| CodedError::new("INVALID_INTEGER", format!("{label} must be 0x-hex"))) } +/// Parse an ISO 8601 moment into a [`BlockTime`]. +pub fn moment(value: &str) -> Result { + BlockTime::from_str(value).map_err(|_| CodedError::new("INVALID_MOMENT", "moment must be ISO 8601")) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn moment_parses_iso_and_round_trips() { + let parsed = moment("2025-01-02T03:04:05.123Z").expect("an ISO moment must parse"); + assert_eq!(parsed.to_string(), "2025-01-02T03:04:05.123Z"); + } + + #[test] + fn moment_rejects_garbage() { + assert!(matches!(moment("nope"), Err(error) if error.code == "INVALID_MOMENT")); + } + #[test] fn amount_round_trips_decimal_strings() { let cases = ["0", "1", "1000", "1000000000"]; diff --git a/keetanetwork-block/src/time.rs b/keetanetwork-block/src/time.rs index bf55595..d309802 100644 --- a/keetanetwork-block/src/time.rs +++ b/keetanetwork-block/src/time.rs @@ -8,7 +8,10 @@ //! [`keetanetwork_asn1::Asn1Time`]; this type adds the millisecond-precision //! convenience surface (`now`, `from_unix_millis`, `unix_millis`). -use chrono::{DateTime, Utc}; +use core::fmt; +use core::str::FromStr; + +use chrono::{DateTime, Datelike, Timelike, Utc}; use keetanetwork_asn1::Asn1Time; #[cfg(feature = "std")] @@ -62,6 +65,35 @@ impl From for BlockTime { } } +/// ISO 8601 with millisecond precision and a `Z` designator +/// (`YYYY-MM-DDTHH:MM:SS.mmmZ`), matching TypeScript's `Date.toISOString`. +impl fmt::Display for BlockTime { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let datetime = self.0.as_datetime(); + write!( + formatter, + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", + datetime.year(), + datetime.month(), + datetime.day(), + datetime.hour(), + datetime.minute(), + datetime.second(), + datetime.timestamp_subsec_millis() + ) + } +} + +/// Parse an ISO 8601 / RFC 3339 moment, normalizing to UTC. +impl FromStr for BlockTime { + type Err = chrono::ParseError; + + fn from_str(value: &str) -> Result { + let parsed = DateTime::parse_from_rfc3339(value)?; + Ok(Self::from(parsed.with_timezone(&Utc))) + } +} + impl From for Asn1Time { fn from(value: BlockTime) -> Self { value.0 @@ -111,4 +143,37 @@ mod tests { let restored: BlockTime = asn1.into(); assert_eq!(time, restored); } + + #[test] + fn test_display_matches_iso_millis() { + let time = time_from_str("2025-01-02T03:04:05.123Z"); + assert_eq!(time.to_string(), "2025-01-02T03:04:05.123Z"); + } + + #[test] + fn test_display_pads_zero_millis() { + let time = time_from_str("2025-01-02T03:04:05Z"); + assert_eq!(time.to_string(), "2025-01-02T03:04:05.000Z"); + } + + #[test] + fn test_from_str_round_trip() { + let time = "2025-01-02T03:04:05.123Z" + .parse::() + .expect("test moment must parse"); + assert_eq!(time.to_string(), "2025-01-02T03:04:05.123Z"); + } + + #[test] + fn test_from_str_normalizes_offsets_to_utc() { + let time = "2025-01-02T04:04:05.123+01:00" + .parse::() + .expect("test moment must parse"); + assert_eq!(time.to_string(), "2025-01-02T03:04:05.123Z"); + } + + #[test] + fn test_from_str_rejects_garbage() { + assert!("not-a-moment".parse::().is_err()); + } } diff --git a/keetanetwork-client-wasi/host-tests/Cargo.lock b/keetanetwork-client-wasi/host-tests/Cargo.lock index ecf9ff5..ebfe914 100644 --- a/keetanetwork-client-wasi/host-tests/Cargo.lock +++ b/keetanetwork-client-wasi/host-tests/Cargo.lock @@ -1453,7 +1453,7 @@ dependencies = [ [[package]] name = "keetanetwork-account" -version = "0.2.0" +version = "0.3.1" dependencies = [ "base32", "bip39-dict", @@ -1471,7 +1471,7 @@ dependencies = [ [[package]] name = "keetanetwork-asn1" -version = "0.2.2" +version = "0.2.4" dependencies = [ "chrono", "const-oid", @@ -1504,7 +1504,7 @@ dependencies = [ [[package]] name = "keetanetwork-crypto" -version = "0.2.0" +version = "0.3.0" dependencies = [ "aead", "aes", @@ -1543,7 +1543,7 @@ dependencies = [ [[package]] name = "keetanetwork-error" -version = "0.2.0" +version = "0.2.1" dependencies = [ "snafu", ] @@ -1559,7 +1559,7 @@ dependencies = [ [[package]] name = "keetanetwork-x509" -version = "0.2.1" +version = "0.3.0" dependencies = [ "base64", "chrono", diff --git a/keetanetwork-client-wasi/host-tests/tests/java_account.rs b/keetanetwork-client-wasi/host-tests/tests/java_account.rs index 52bcfd5..1b3659d 100644 --- a/keetanetwork-client-wasi/host-tests/tests/java_account.rs +++ b/keetanetwork-client-wasi/host-tests/tests/java_account.rs @@ -43,7 +43,6 @@ fn maven() -> String { std::env::var("MAVEN_BIN").unwrap_or_else(|_| String::from("mvn")) } - /// Reconstruct accounts in Java and return its emitted signatures/ciphertexts. fn java_parity(module: &PathBuf, reference: &Value) -> Result> { let output = Command::new(maven()) @@ -61,7 +60,10 @@ fn java_parity(module: &PathBuf, reference: &Value) -> Result Result<(), Box Result<(), Box Result<(), Box Result<(), Box, + bindings: &KeetaClient, + address: &str, +) -> wasmtime::Result { + bindings + .keeta_client_crypto() + .account() + .call_from_address(&mut *store, address) .await? .map_err(coded) } @@ -128,6 +146,10 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { let (mut store, bindings) = instantiate().await?; let node = bindings.keeta_client_node(); + // Typed account resources for the harness's textual addresses. + let trusted_account = account_from_address(&mut store, &bindings, &trusted).await?; + let base_token_account = account_from_address(&mut store, &bindings, &base_token).await?; + // `node` resource: anonymous client bound to the rep URL. let client = node.client().call_constructor(&mut store, &api).await?; let version = node @@ -139,7 +161,7 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { let balance = node .client() - .call_account_balance(&mut store, client, &trusted, &base_token) + .call_account_balance(&mut store, client, trusted_account, base_token_account) .await? .map_err(coded)?; assert_eq!(balance, SUPPLY, "the trusted balance must equal the minted supply"); @@ -147,7 +169,7 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { // `account-state` must round-trip and agree with the scalar balance read. let state = node .client() - .call_account_state(&mut store, client, &trusted) + .call_account_state(&mut store, client, trusted_account) .await? .map_err(coded)?; let state_balance = state @@ -169,7 +191,7 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { // The minted supply gave the trusted account a chain (most recent first). let chain = node .client() - .call_chain(&mut store, client, &trusted) + .call_chain(&mut store, client, trusted_account) .await? .map_err(coded)?; assert!(!chain.is_empty(), "the seeded trusted account must have a chain"); @@ -179,7 +201,7 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { let query = ChainQuery { start: None, end: None, limit: Some(page_limit) }; let page = node .client() - .call_chain_page(&mut store, client, &trusted, &query) + .call_chain_page(&mut store, client, trusted_account, &query) .await? .map_err(coded)?; @@ -193,7 +215,7 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { // projection, so they are not directly comparable.) let head_info = node .client() - .call_account_head_info(&mut store, client, &trusted) + .call_account_head_info(&mut store, client, trusted_account) .await? .map_err(coded)? .expect("a funded account must have a head block"); @@ -207,7 +229,7 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { // `history` records the supply staple, each entry carrying a hex staple. let history = node .client() - .call_history(&mut store, client, &trusted) + .call_history(&mut store, client, trusted_account) .await? .map_err(coded)?; assert!(!history.is_empty(), "the seeded account must have history"); @@ -216,7 +238,7 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { // A settled account has no half-published successor. let pending = node .client() - .call_pending_block(&mut store, client, &trusted) + .call_pending_block(&mut store, client, trusted_account) .await? .map_err(coded)?; assert!(pending.is_none(), "a settled account must have no pending block"); @@ -224,12 +246,12 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { // `user-client` resource: account-scoped reads without repeating the address. let user = node .user_client() - .call_read_only(&mut store, &api, &trusted) + .call_read_only(&mut store, &api, trusted_account) .await? .map_err(coded)?; let user_balance = node .user_client() - .call_balance(&mut store, user, &base_token) + .call_balance(&mut store, user, base_token_account) .await? .map_err(coded)?; assert_eq!(user_balance, balance, "the user-client must read the same balance as the node client"); @@ -268,7 +290,7 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { // Batch state read returns one entry per requested account. let states = node .client() - .call_account_states(&mut store, client, core::slice::from_ref(&trusted)) + .call_account_states(&mut store, client, core::slice::from_ref(&trusted_account)) .await? .map_err(coded)?; assert_eq!(states.len(), 1, "account-states must return one state per requested account"); @@ -296,7 +318,7 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { // An unknown idempotency key resolves to no block. let idempotent = node .client() - .call_block_by_idempotent(&mut store, client, &trusted, "no-such-key") + .call_block_by_idempotent(&mut store, client, trusted_account, "no-such-key", None) .await? .map_err(coded)?; assert!(idempotent.is_none(), "an unknown idempotency key must resolve to no block"); @@ -305,7 +327,7 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { let history_query = HistoryQuery { start: None, limit: Some(1) }; let history_first = node .client() - .call_history_page(&mut store, client, &trusted, &history_query) + .call_history_page(&mut store, client, trusted_account, &history_query) .await? .map_err(coded)?; assert!(history_first.len() <= 1, "history-page must honor the limit"); @@ -344,6 +366,174 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { let first_staple = history_first.first().map(|entry| &entry.staple); assert_eq!(first_staple, history.first().map(|entry| &entry.staple), "the first history page must match the head"); + // `chain-all` / `history-all` walk the node's cursor to exhaustion: even + // with a page limit of 1 they must cover the full chain/history. + let chain_all = node + .client() + .call_chain_all(&mut store, client, trusted_account, 1) + .await? + .map_err(coded)?; + assert_eq!(chain_all.first(), chain.first(), "chain-all must start at the chain head"); + assert!(chain_all.len() >= chain.len(), "chain-all must cover at least the unpaged chain"); + + let history_all = node + .client() + .call_history_all(&mut store, client, trusted_account, 1) + .await? + .map_err(coded)?; + assert!(history_all.len() >= history.len(), "history-all must cover at least the unpaged history"); + + // Single-rep topology: heads trivially agree, so there is nothing to sync; + // a settled account has nothing pending to recover. + let synced = node + .client() + .call_sync_account(&mut store, client, trusted_account, false) + .await? + .map_err(coded)?; + assert!(synced.is_none(), "a single-rep client must have nothing to sync"); + let recovered = node + .client() + .call_recover_account(&mut store, client, trusted_account, false) + .await? + .map_err(coded)?; + assert!(recovered.is_none(), "a settled account must have nothing to recover"); + + // The user-client mirrors of the new reads, scoped to the operating account. + let user_chain_all = node + .user_client() + .call_chain_all(&mut store, user, 1) + .await? + .map_err(coded)?; + assert_eq!(user_chain_all, chain_all, "the user-client chain-all must match the node-client chain-all"); + + let user_history_page = node + .user_client() + .call_history_page(&mut store, user, &history_query) + .await? + .map_err(coded)?; + assert!(user_history_page.len() <= 1, "the user-client history-page must honor the limit"); + + let user_history_all = node + .user_client() + .call_history_all(&mut store, user, 1) + .await? + .map_err(coded)?; + assert_eq!( + user_history_all.len(), + history_all.len(), + "the user-client history-all must match the node-client history-all" + ); + + let user_head_block = node + .user_client() + .call_block(&mut store, user, &head_hash, None) + .await? + .map_err(coded)?; + assert_eq!(user_head_block.as_ref(), chain.first(), "the user-client block lookup must return the head block"); + + let user_idempotent = node + .user_client() + .call_block_from_idempotent(&mut store, user, "no-such-key", None) + .await? + .map_err(coded)?; + assert!(user_idempotent.is_none(), "an unknown idempotency key must resolve to no block"); + + let user_idempotent_both = node + .user_client() + .call_block_from_idempotent(&mut store, user, "no-such-key", Some(LedgerSide::Both)) + .await? + .map_err(coded)?; + assert!(user_idempotent_both.is_none(), "an unknown idempotency key must resolve to no block on both ledgers"); + + // The operating account's own history, filtered for itself, must name it + // in at least one operation (the genesis supply moves through it). + let history_staples: Vec = history.iter().map(|entry| entry.staple.clone()).collect(); + let staple_effects = node + .user_client() + .call_staple_effects(&mut store, user, &history_staples) + .await? + .map_err(coded)?; + assert_eq!(staple_effects.len(), history_staples.len(), "every staple must be keyed in the effects list"); + + let named_operations: usize = staple_effects + .iter() + .flat_map(|effects| &effects.blocks) + .map(|block| block.operation_indexes.len()) + .sum(); + assert!(named_operations > 0, "the operating account's history must carry operations involving it"); + + // Genesis grants the trusted account ACLs as principal on the base token + // and network address, so the principal view must be non-empty, scoped + // to the operating account, and agree with the node-client read. + let user_acls = node + .user_client() + .call_acls(&mut store, user) + .await? + .map_err(coded)?; + assert!(!user_acls.is_empty(), "the genesis account must hold ACLs as principal after network init"); + let scoped_as_principal = user_acls + .iter() + .all(|acl| acl.principal.as_deref() == Some(trusted.as_str())); + assert!(scoped_as_principal, "every principal-scoped ACL must name the operating account as principal"); + let grants_base_token = user_acls + .iter() + .any(|acl| acl.entity.as_deref() == Some(base_token.as_str())); + assert!(grants_base_token, "the genesis grants must include the base token as entity"); + let node_acls = node + .client() + .call_acls_by_principal(&mut store, client, trusted_account) + .await? + .map_err(coded)?; + assert_eq!(user_acls.len(), node_acls.len(), "the user-client ACLs must match the node-client ACLs"); + + // The entity view keys rows under the operating account. It must be scoped + // to it and agree with the node-client read of the same entity. + let user_entity_acls = node + .user_client() + .call_acls_by_entity(&mut store, user) + .await? + .map_err(coded)?; + let scoped_as_entity = user_entity_acls + .iter() + .all(|acl| acl.entity.as_deref() == Some(trusted.as_str())); + assert!(scoped_as_entity, "every entity-scoped ACL must name the operating account as entity"); + let node_entity_acls = node + .client() + .call_acls_by_entity(&mut store, client, trusted_account) + .await? + .map_err(coded)?; + assert_eq!( + user_entity_acls.len(), + node_entity_acls.len(), + "the user-client entity ACLs must match the node-client entity ACLs" + ); + + let user_certificates = node + .user_client() + .call_certificates(&mut store, user) + .await? + .map_err(coded)?; + assert!(user_certificates.is_empty(), "a fresh account must hold no certificates"); + let user_certificate = node + .user_client() + .call_certificate(&mut store, user, &"00".repeat(32)) + .await? + .map_err(coded)?; + assert!(user_certificate.is_none(), "an unknown certificate hash must resolve to none"); + + let user_synced = node + .user_client() + .call_sync(&mut store, user, false) + .await? + .map_err(coded)?; + assert!(user_synced.is_none(), "a single-rep user-client must have nothing to sync"); + let user_recovered = node + .user_client() + .call_recover(&mut store, user, false) + .await? + .map_err(coded)?; + assert!(user_recovered.is_none(), "a settled operating account must have nothing to recover"); + harness.shutdown()?; Ok(()) } @@ -352,7 +542,6 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { let mut harness = E2eNode::start()?; let api = ready_field(&harness, "api"); - let trusted = ready_field(&harness, "trusted"); let representative = ready_field(&harness, "representative"); let base_token = ready_field(&harness, "baseToken"); let network = ready_field(&harness, "network"); @@ -366,6 +555,10 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { let (mut store, bindings) = instantiate().await?; let node = bindings.keeta_client_node(); + // Typed account resources for the harness's textual addresses. + let representative_account = account_from_address(&mut store, &bindings, &representative).await?; + let base_token_account = account_from_address(&mut store, &bindings, &base_token).await?; + // Bind a signing client to the trusted (genesis) seed; it is its own // operating account. let trusted_account = account_from_seed(&mut store, &bindings, &trusted_seed(), 0).await?; @@ -376,7 +569,7 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { .map_err(coded)?; let before = node .user_client() - .call_balance(&mut store, user, &base_token) + .call_balance(&mut store, user, base_token_account) .await? .map_err(coded)?; assert_eq!(before, SUPPLY.to_string(), "the signer must start with the minted supply"); @@ -385,14 +578,14 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { // sender's settled balance drops by exactly the amount (no fee configured). let sent = node .user_client() - .call_send(&mut store, user, &representative, &base_token, &SEND.to_string()) + .call_send(&mut store, user, representative_account, base_token_account, &SEND.to_string()) .await? .map_err(coded)?; assert!(sent, "the send must be accepted"); let after = node .user_client() - .call_balance(&mut store, user, &base_token) + .call_balance(&mut store, user, base_token_account) .await? .map_err(coded)?; assert_eq!(after, (SUPPLY - SEND).to_string(), "the sender balance must drop by the sent amount"); @@ -401,14 +594,21 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { // aggregated; it must move value just like a plain send. let sent_external = node .user_client() - .call_send_external(&mut store, user, &representative, &base_token, &SEND.to_string(), "invoice-42") + .call_send_external( + &mut store, + user, + representative_account, + base_token_account, + &SEND.to_string(), + "invoice-42", + ) .await? .map_err(coded)?; assert!(sent_external, "the external send must be accepted"); let after_external = node .user_client() - .call_balance(&mut store, user, &base_token) + .call_balance(&mut store, user, base_token_account) .await? .map_err(coded)?; assert_eq!(after_external, (SUPPLY - SEND - SEND).to_string(), "the external send must drop the balance again"); @@ -416,7 +616,7 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { // `set-rep` must take effect in the operating account's state. let set_rep = node .user_client() - .call_set_rep(&mut store, user, &representative) + .call_set_rep(&mut store, user, representative_account) .await? .map_err(coded)?; assert!(set_rep, "the set-rep must be accepted"); @@ -453,19 +653,19 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { let client = node.client().call_constructor(&mut store, &api).await?; let supply_before = node .client() - .call_token_supply(&mut store, client, &base_token) + .call_token_supply(&mut store, client, base_token_account) .await? .map_err(coded)?; let minted = node .user_client() - .call_modify_token(&mut store, user, &base_token, None, &MINT.to_string(), AdjustMethod::Add) + .call_modify_token(&mut store, user, base_token_account, None, &MINT.to_string(), AdjustMethod::Add) .await? .map_err(coded)?; assert!(minted, "the supply mint must be accepted"); let supply_after = node .client() - .call_token_supply(&mut store, client, &base_token) + .call_token_supply(&mut store, client, base_token_account) .await? .map_err(coded)?; let supply_after_value = supply_after @@ -486,7 +686,7 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { .map_err(coded)?; let balance_before_tx = node .user_client() - .call_balance(&mut store, user, &base_token) + .call_balance(&mut store, user, base_token_account) .await? .map_err(coded)?; @@ -500,7 +700,7 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { .await? .map_err(coded)?; node.transaction() - .call_send(&mut store, tx, &representative, &base_token, &SEND.to_string()) + .call_send(&mut store, tx, representative_account, base_token_account, &SEND.to_string()) .await? .map_err(coded)?; let published = node @@ -528,7 +728,7 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { let balance_after_tx = node .user_client() - .call_balance(&mut store, user, &base_token) + .call_balance(&mut store, user, base_token_account) .await? .map_err(coded)?; let expected = balance_before_tx.parse::().unwrap_or_default() - SEND; @@ -538,13 +738,20 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { // via the ACL surface. let granted = node .user_client() - .call_update_permissions(&mut store, user, &representative, AdjustMethod::Add, &["access".to_string()], None) + .call_update_permissions( + &mut store, + user, + representative_account, + AdjustMethod::Add, + BasePermission::ACCESS, + None, + ) .await? .map_err(coded)?; assert!(granted, "the permission grant must be accepted"); let acls = node .client() - .call_acls_by_principal(&mut store, client, &representative) + .call_acls_by_principal(&mut store, client, representative_account) .await? .map_err(coded)?; let lists_representative = acls @@ -555,7 +762,7 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { // `generate-multisig`: create a 1-of-2 multisig identifier on-chain. let multisig = node .user_client() - .call_generate_multisig(&mut store, user, &[trusted.clone(), representative.clone()], 1) + .call_generate_multisig(&mut store, user, &[trusted_account, representative_account], 1) .await? .map_err(coded)?; assert!(!multisig.is_empty(), "the multisig identifier address must be returned"); @@ -567,10 +774,10 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { .call_create_swap( &mut store, user, - &representative, - &base_token, + representative_account, + base_token_account, &SEND.to_string(), - &base_token, + base_token_account, &SEND.to_string(), false, ) @@ -585,10 +792,10 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { .call_create_swap( &mut store, user, - &trusted, - &base_token, + trusted_account, + base_token_account, &SEND.to_string(), - &base_token, + base_token_account, &SEND.to_string(), true, ) @@ -608,9 +815,10 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { .call_add_certificate(&mut store, user, "zz", &[]) .await?; assert!(bad_add.is_err(), "a non-hex certificate must be rejected with a coded error"); + let bad_remove = node .user_client() - .call_remove_certificate(&mut store, user, "abcd") + .call_remove_certificate(&mut store, user, &"abcd".to_string()) .await?; assert!(bad_remove.is_err(), "a malformed certificate hash must be rejected with a coded error"); @@ -626,24 +834,19 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { .call_with_account(&mut store, &api, taker_account, &network) .await? .map_err(coded)?; - let taker_address = node - .user_client() - .call_address(&mut store, taker) - .await? - .map_err(coded)?; node.user_client() - .call_send(&mut store, user, &taker_address, &base_token, &FUND.to_string()) + .call_send(&mut store, user, taker_account, base_token_account, &FUND.to_string()) .await? .map_err(coded)?; let maker_before = node .user_client() - .call_balance(&mut store, user, &base_token) + .call_balance(&mut store, user, base_token_account) .await? .map_err(coded)?; let taker_before = node .user_client() - .call_balance(&mut store, taker, &base_token) + .call_balance(&mut store, taker, base_token_account) .await? .map_err(coded)?; @@ -652,10 +855,10 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { .call_create_swap( &mut store, user, - &taker_address, - &base_token, + taker_account, + base_token_account, &SWAP_SEND.to_string(), - &base_token, + base_token_account, &SWAP_RECV.to_string(), true, ) @@ -676,12 +879,12 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { let maker_after = node .user_client() - .call_balance(&mut store, user, &base_token) + .call_balance(&mut store, user, base_token_account) .await? .map_err(coded)?; let taker_after = node .user_client() - .call_balance(&mut store, taker, &base_token) + .call_balance(&mut store, taker, base_token_account) .await? .map_err(coded)?; let maker_before_value = maker_before.parse::().unwrap_or_default(); @@ -711,9 +914,6 @@ async fn p2_multisig_signer_against_e2e_node() -> wasmtime::Result<()> { let mut harness = E2eNode::start()?; let api = ready_field(&harness, "api"); let network = ready_field(&harness, "network"); - let network_id: u64 = network - .parse() - .map_err(|_| wasmtime::Error::msg("the harness must advertise a numeric network id"))?; // Give the node live ledger state to read; the harness charges no fee. harness.request("init_supply", serde_json::json!({ "amount": "1000000" }))?; @@ -741,9 +941,11 @@ async fn p2_multisig_signer_against_e2e_node() -> wasmtime::Result<()> { .call_address(&mut store, user) .await? .map_err(coded)?; + let user_account = account_from_address(&mut store, &bindings, &user_address).await?; // Fund the user so it has a settled balance and a head to chain onto. let base_token = ready_field(&harness, "baseToken"); + let base_token_account = account_from_address(&mut store, &bindings, &base_token).await?; let trusted_account = account_from_seed(&mut store, &bindings, &trusted_seed(), 0).await?; let funder = node .user_client() @@ -752,42 +954,36 @@ async fn p2_multisig_signer_against_e2e_node() -> wasmtime::Result<()> { .map_err(coded)?; let funded = node .user_client() - .call_send(&mut store, funder, &user_address, &base_token, "1000") + .call_send(&mut store, funder, user_account, base_token_account, &"1000".to_string()) .await? .map_err(coded)?; assert!(funded, "the user funding send must settle"); let mut signer_accounts = Vec::new(); - let mut signers = Vec::new(); for index in 1..=3u32 { let account = account_from_seed(&mut store, &bindings, &seed, index).await?; - let address = bindings - .keeta_client_crypto() - .account() - .call_address(&mut store, account) - .await?; signer_accounts.push(account); - signers.push(address); } // Local, deterministic multisig address (kind MULTISIG, op index 0). let multisig = node - .call_derive_identifier(&mut store, account0, "multisig", None, 0) + .call_derive_identifier(&mut store, account0, IdentifierKind::Multisig, None, 0) .await? .map_err(coded)?; assert!(!multisig.is_empty(), "the multisig identifier address must derive"); + let multisig_account = account_from_address(&mut store, &bindings, &multisig).await?; // User block: create the 2-of-3 multisig and grant it ADMIN over the user. let user_head = node .client() - .call_account_state(&mut store, client, &user_address) + .call_account_state(&mut store, client, user_account) .await? .map_err(coded)? .head; let identifier_block = { let builder = node .block_builder() - .call_new(&mut store, network_id, &user_address) + .call_new(&mut store, &network, user_account) .await? .map_err(coded)?; match &user_head { @@ -807,11 +1003,18 @@ async fn p2_multisig_signer_against_e2e_node() -> wasmtime::Result<()> { .await? .map_err(coded)?; node.block_builder() - .call_op_create_multisig(&mut store, builder, &multisig, &signers, 2) + .call_op_create_multisig(&mut store, builder, multisig_account, &signer_accounts, 2) .await? .map_err(coded)?; node.block_builder() - .call_op_modify_permissions(&mut store, builder, &multisig, &["admin".to_string()], AdjustMethod::Set, None) + .call_op_modify_permissions( + &mut store, + builder, + multisig_account, + BasePermission::ADMIN, + AdjustMethod::Set, + None, + ) .await? .map_err(coded)?; node.block_builder() @@ -833,17 +1036,18 @@ async fn p2_multisig_signer_against_e2e_node() -> wasmtime::Result<()> { // Generic identifier creation; the trusted client owns the token (see fn doc). let custom_token = node .user_client() - .call_generate_identifier(&mut store, funder, "token") + .call_generate_identifier(&mut store, funder, IdentifierKind::Token) .await? .map_err(coded)?; assert!(!custom_token.is_empty(), "the custom token address must be returned"); + let custom_token_account = account_from_address(&mut store, &bindings, &custom_token).await?; // Grant the multisig ADMIN on the token's own chain (the entity whose ACL // changes), signed by the trusted creator. let grant_block = { let builder = node .block_builder() - .call_new(&mut store, network_id, &custom_token) + .call_new(&mut store, &network, custom_token_account) .await? .map_err(coded)?; node.block_builder() @@ -855,7 +1059,14 @@ async fn p2_multisig_signer_against_e2e_node() -> wasmtime::Result<()> { .await? .map_err(coded)?; node.block_builder() - .call_op_modify_permissions(&mut store, builder, &multisig, &["admin".to_string()], AdjustMethod::Set, None) + .call_op_modify_permissions( + &mut store, + builder, + multisig_account, + BasePermission::ADMIN, + AdjustMethod::Set, + None, + ) .await? .map_err(coded)?; node.block_builder() @@ -877,14 +1088,14 @@ async fn p2_multisig_signer_against_e2e_node() -> wasmtime::Result<()> { // The proof: SET_INFO on the token signed by a 2-of-3 quorum subset. let token_head = node .client() - .call_account_state(&mut store, client, &custom_token) + .call_account_state(&mut store, client, custom_token_account) .await? .map_err(coded)? .head; let multisig_block = { let builder = node .block_builder() - .call_new(&mut store, network_id, &custom_token) + .call_new(&mut store, &network, custom_token_account) .await? .map_err(coded)?; node.block_builder() @@ -914,12 +1125,12 @@ async fn p2_multisig_signer_against_e2e_node() -> wasmtime::Result<()> { "TKNM", "Test Multisig Token Example", "eyJkZWNpbWFsUGxhY2VzIjo2fQ==", - &["access".to_string()], + Some(BasePermission::ACCESS), ) .await? .map_err(coded)?; node.block_builder() - .call_signer_multisig(&mut store, builder, &multisig, &[signer_accounts[0], signer_accounts[1]]) + .call_signer_multisig(&mut store, builder, multisig_account, &[signer_accounts[0], signer_accounts[1]]) .await? .map_err(coded)?; node.block_builder() @@ -937,7 +1148,7 @@ async fn p2_multisig_signer_against_e2e_node() -> wasmtime::Result<()> { // The node accepted a multisig-signed block: it set the info and is the head. let token_state = node .client() - .call_account_state(&mut store, client, &custom_token) + .call_account_state(&mut store, client, custom_token_account) .await? .map_err(coded)?; let token_name = token_state.info.and_then(|info| info.name); @@ -945,7 +1156,7 @@ async fn p2_multisig_signer_against_e2e_node() -> wasmtime::Result<()> { let token_head_block = node .client() - .call_head_block(&mut store, client, &custom_token) + .call_head_block(&mut store, client, custom_token_account) .await? .map_err(coded)?; assert_eq!(token_head_block.as_ref(), Some(&multisig_block), "the multisig-signed block must be the token's head"); diff --git a/keetanetwork-client-wasi/host-tests/tests/smoke.rs b/keetanetwork-client-wasi/host-tests/tests/smoke.rs index 33ef5f2..8786932 100644 --- a/keetanetwork-client-wasi/host-tests/tests/smoke.rs +++ b/keetanetwork-client-wasi/host-tests/tests/smoke.rs @@ -249,7 +249,9 @@ fn p1_account_signs_verifies_and_encrypts_round_trip() -> wasmtime::Result<()> { let message: &[u8] = b"keeta account binding parity"; let (msg_ptr, msg_len) = abi.write(&mut store, message)?; - let raw = abi.account_sign.call(&mut store, (account, msg_ptr, msg_len))?; + let raw = abi + .account_sign + .call(&mut store, (account, msg_ptr, msg_len))?; let signature = abi.take(&mut store, raw)?; assert!(!signature.is_empty(), "signing must produce a signature"); @@ -261,12 +263,16 @@ fn p1_account_signs_verifies_and_encrypts_round_trip() -> wasmtime::Result<()> { assert_eq!(valid, 1, "the account must verify its own signature"); let (plain_ptr, plain_len) = abi.write(&mut store, message)?; - let raw = abi.account_encrypt.call(&mut store, (account, plain_ptr, plain_len))?; + let raw = abi + .account_encrypt + .call(&mut store, (account, plain_ptr, plain_len))?; let ciphertext = abi.take(&mut store, raw)?; assert_ne!(ciphertext.as_slice(), message, "ciphertext must differ from plaintext"); let (cipher_ptr, cipher_len) = abi.write(&mut store, &ciphertext)?; - let raw = abi.account_decrypt.call(&mut store, (account, cipher_ptr, cipher_len))?; + let raw = abi + .account_decrypt + .call(&mut store, (account, cipher_ptr, cipher_len))?; let recovered = abi.take(&mut store, raw)?; assert_eq!(recovered.as_slice(), message, "decrypt must recover the plaintext"); @@ -280,8 +286,12 @@ fn p1_parses_a_certificate_and_round_trips_pem_der_and_validity() -> wasmtime::R use keetanetwork_x509::doc_utils::create_test_certificate; let fixture = create_test_certificate("Host Smoke CA", None); - let fixture_pem = fixture.to_pem().map_err(|error| wasmtime::Error::msg(error.to_string()))?; - let fixture_der = fixture.to_der().map_err(|error| wasmtime::Error::msg(error.to_string()))?; + let fixture_pem = fixture + .to_pem() + .map_err(|error| wasmtime::Error::msg(error.to_string()))?; + let fixture_der = fixture + .to_der() + .map_err(|error| wasmtime::Error::msg(error.to_string()))?; let (mut store, abi) = instantiate()?; @@ -300,11 +310,15 @@ fn p1_parses_a_certificate_and_round_trips_pem_der_and_validity() -> wasmtime::R .duration_since(UNIX_EPOCH) .expect("system clock must be after the unix epoch") .as_millis() as i64; - let valid = abi.certificate_valid_at.call(&mut store, (certificate, now_millis))?; + let valid = abi + .certificate_valid_at + .call(&mut store, (certificate, now_millis))?; assert_eq!(valid, 1, "a freshly built certificate must be valid now"); abi.certificate_free.call(&mut store, certificate)?; - let after_free = abi.certificate_valid_at.call(&mut store, (certificate, now_millis))?; + let after_free = abi + .certificate_valid_at + .call(&mut store, (certificate, now_millis))?; assert_eq!(after_free, -1, "a freed certificate handle must report an error"); Ok(()) diff --git a/keetanetwork-client-wasi/src/p2/mod.rs b/keetanetwork-client-wasi/src/p2/mod.rs index 78a238d..96497f3 100644 --- a/keetanetwork-client-wasi/src/p2/mod.rs +++ b/keetanetwork-client-wasi/src/p2/mod.rs @@ -5,20 +5,20 @@ use core::future::Future; use core::str::FromStr; use std::sync::Arc; -use keetanetwork_account::KeyPairType; -use keetanetwork_bindings::client::ledger_side; -use keetanetwork_bindings::parse::{amount as parse_amount, amount_to_string}; +use keetanetwork_account::{AccountPublicKey, GenericAccount, KeyPairType}; +use keetanetwork_bindings::parse::{self, amount as parse_amount, amount_to_string}; +use keetanetwork_bindings::permissions as bindings_permissions; use keetanetwork_block::{ - AccountRef, AdjustMethod, BlockBuilder, BlockHash, CertificateDer, CertificateOrHash, IdentifierCreateArguments, - IntermediateCertificates, ManageCertificate, ModifyPermissions, ModifyPermissionsPrincipal, - MultisigCreateArguments, SetInfo, + AccountRef, AdjustMethod, BaseFlag, BlockBuilder, BlockHash, CertificateDer, CertificateOrHash, + IdentifierCreateArguments, IntermediateCertificates, ManageCertificate, ModifyPermissions, + ModifyPermissionsPrincipal, MultisigCreateArguments, Permissions, SetInfo, }; use keetanetwork_client::{ AcceptSwapRequest, AccountInfo as CoreInfo, AccountState as CoreState, Acl as CoreAcl, - Certificate as CoreCertificate, ChainQuery, ClientConfig, ClientError, CreateSwapRequest, - HistoryEntry as CoreHistory, HistoryQuery, KeetaClient, LedgerChecksum as CoreChecksum, RepPart, - Representative as CoreRep, SwapExpectation, SwapTokenAmount, TransactionBuilder, TransmitOptions, UserClient, - WasiRuntime, WasiTransportFactory, + BlockEffects as CoreBlockEffects, Certificate as CoreCertificate, ChainQuery, ClientConfig, ClientError, + CreateSwapRequest, HistoryEntry as CoreHistory, HistoryQuery, KeetaClient, LedgerChecksum as CoreChecksum, + LedgerSide, RepPart, Representative as CoreRep, Runtime, SwapExpectation, SwapTokenAmount, TransactionBuilder, + TransmitOptions, UserClient, VoteBlockHash, WasiRuntime, WasiTransportFactory, }; use keetanetwork_x509::certificates::Certificate as X509Certificate; use num_bigint::BigInt; @@ -32,17 +32,34 @@ wit_bindgen::generate!({ }); use exports::keeta::client::crypto::{ - Account as WitAccount, AccountBorrow, Certificate as WitCertificate, Guest as CryptoGuest, GuestAccount, - GuestCertificate, + Account as WitAccount, AccountBorrow, AccountKind as WitAccountKind, Certificate as WitCertificate, + Guest as CryptoGuest, GuestAccount, GuestCertificate, KeyAlgorithm as WitKeyAlgorithm, }; use exports::keeta::client::node::{ - AccountInfo, AccountState, Acl, AdjustMethod as WitAdjustMethod, BlockBuilder as BlockBuilderResource, Certificate, - ChainPage, ChainQuery as WitChainQuery, CodedError, Guest, GuestBlockBuilder, GuestClient, GuestTransaction, - GuestUserClient, HeadInfo, HistoryEntry, HistoryQuery as WitHistoryQuery, LedgerChecksum, Representative, + AccountInfo, AccountState, Acl, AdjustMethod as WitAdjustMethod, BasePermission as WitBasePermission, + BlockBuilder as BlockBuilderResource, BlockEffects as WitBlockEffects, Certificate, ChainPage, + ChainQuery as WitChainQuery, CodedError, Guest, GuestBlockBuilder, GuestClient, GuestTransaction, GuestUserClient, + HeadInfo, HistoryEntry, HistoryQuery as WitHistoryQuery, IdentifierKind as WitIdentifierKind, LedgerChecksum, + LedgerSide as WitLedgerSide, Representative, StapleEffects as WitStapleEffects, SwapExpectation as WitSwapExpectation, SwapTokenAmount as WitSwapTokenAmount, TokenBalance, Transaction as TransactionResource, UserClient as UserClientResource, }; +impl From for LedgerSide { + fn from(side: WitLedgerSide) -> Self { + match side { + WitLedgerSide::Main => LedgerSide::Main, + WitLedgerSide::Side => LedgerSide::Side, + WitLedgerSide::Both => LedgerSide::Both, + } + } +} + +/// The typed account behind a borrowed WIT account resource. +fn account_of(resource: AccountBorrow<'_>) -> AccountRef { + Arc::clone(&resource.get::().account) +} + /// Drive an async client call to completion on the `wstd` reactor, projecting /// its error to the WIT boundary type. fn run(future: impl Future>) -> Result { @@ -64,6 +81,16 @@ fn decode_hash(value: &str) -> Result<[u8; 32], CodedError> { .map_err(|_| CodedError { code: "INVALID_HASH".into(), message: "hash must be 32 bytes".into() }) } +/// Parse a hex block hash crossing the WIT boundary. +fn parse_block_hash(value: &str) -> Result { + Ok(BlockHash::from(decode_hash(value)?)) +} + +/// Parse a hex history cursor (staple id) crossing the WIT boundary. +fn parse_history_cursor(value: &str) -> Result { + Ok(VoteBlockHash::from(decode_hash(value)?)) +} + struct Component; impl Guest for Component { @@ -74,14 +101,13 @@ impl Guest for Component { fn derive_identifier( signer: AccountBorrow<'_>, - kind: String, + kind: WitIdentifierKind, previous: Option, op_index: u32, ) -> Result { let account = &signer.get::().account; - let kind = derivable_identifier_kind(&kind)?; let previous = previous.map(|hash| decode_hash(&hash)).transpose()?; - let identifier = pure::generate_identifier(account, kind, previous, op_index)?; + let identifier = pure::generate_identifier(account, kind.into(), previous, op_index)?; Ok(pure::account_address(&identifier)) } } @@ -109,23 +135,23 @@ struct AccountResource { } impl GuestAccount for AccountResource { - fn from_seed(seed: String, index: u32, algorithm: String) -> Result { - let account = pure::account_from_seed(&seed, index, &algorithm)?; + fn from_seed(seed: String, index: u32, algorithm: WitKeyAlgorithm) -> Result { + let account = pure::account_from_seed(&seed, index, algorithm_name(algorithm))?; Ok(WitAccount::new(Self { account })) } - fn from_private_key(key: String, algorithm: String) -> Result { - let account = pure::account_from_private_key(&key, &algorithm)?; + fn from_private_key(key: String, algorithm: WitKeyAlgorithm) -> Result { + let account = pure::account_from_private_key(&key, algorithm_name(algorithm))?; Ok(WitAccount::new(Self { account })) } - fn from_passphrase(words: Vec, index: u32, algorithm: String) -> Result { - let account = pure::account_from_passphrase(words, index, &algorithm)?; + fn from_passphrase(words: Vec, index: u32, algorithm: WitKeyAlgorithm) -> Result { + let account = pure::account_from_passphrase(words, index, algorithm_name(algorithm))?; Ok(WitAccount::new(Self { account })) } - fn from_public_key(key: String, algorithm: String) -> Result { - let account = pure::account_from_public_key(&key, &algorithm)?; + fn from_public_key(key: String, algorithm: WitKeyAlgorithm) -> Result { + let account = pure::account_from_public_key(&key, algorithm_name(algorithm))?; Ok(WitAccount::new(Self { account })) } @@ -146,8 +172,16 @@ impl GuestAccount for AccountResource { pure::account_address(&self.account) } - fn algorithm(&self) -> String { - pure::account_algorithm(&self.account) + fn kind(&self) -> WitAccountKind { + match self.account.to_keypair_type() { + KeyPairType::ED25519 => WitAccountKind::Signing(WitKeyAlgorithm::Ed25519), + KeyPairType::ECDSASECP256K1 => WitAccountKind::Signing(WitKeyAlgorithm::EcdsaSecp256k1), + KeyPairType::ECDSASECP256R1 => WitAccountKind::Signing(WitKeyAlgorithm::EcdsaSecp256r1), + KeyPairType::NETWORK => WitAccountKind::Identifier(WitIdentifierKind::Network), + KeyPairType::TOKEN => WitAccountKind::Identifier(WitIdentifierKind::Token), + KeyPairType::STORAGE => WitAccountKind::Identifier(WitIdentifierKind::Storage), + KeyPairType::MULTISIG => WitAccountKind::Identifier(WitIdentifierKind::Multisig), + } } fn public_key(&self) -> String { @@ -218,16 +252,58 @@ impl GuestCertificate for CertificateResource { } } -/// Parse an identifier kind for local derivation. Unlike the shared parser -/// (which reserves multisig for the publishing path that supplies its create -/// arguments), local address derivation accepts every identifier type. -fn derivable_identifier_kind(kind: &str) -> Result { - match kind { - "multisig" => Ok(KeyPairType::MULTISIG), - other => Ok(keetanetwork_bindings::parse::identifier_type(other)?), +/// The canonical name of a signing algorithm, as understood by the shared +/// account constructors. +fn algorithm_name(algorithm: WitKeyAlgorithm) -> &'static str { + match algorithm { + WitKeyAlgorithm::Ed25519 => "ed25519", + WitKeyAlgorithm::EcdsaSecp256k1 => "ecdsa_secp256k1", + WitKeyAlgorithm::EcdsaSecp256r1 => "ecdsa_secp256r1", + } +} + +impl From for KeyPairType { + fn from(kind: WitIdentifierKind) -> Self { + match kind { + WitIdentifierKind::Network => KeyPairType::NETWORK, + WitIdentifierKind::Token => KeyPairType::TOKEN, + WitIdentifierKind::Storage => KeyPairType::STORAGE, + WitIdentifierKind::Multisig => KeyPairType::MULTISIG, + } } } +/// The per-flag mapping between the WIT flags type and the domain base +/// flags, in on-chain bit order. +const PERMISSION_FLAGS: [(WitBasePermission, BaseFlag); 15] = [ + (WitBasePermission::ACCESS, BaseFlag::Access), + (WitBasePermission::OWNER, BaseFlag::Owner), + (WitBasePermission::ADMIN, BaseFlag::Admin), + (WitBasePermission::UPDATE_INFO, BaseFlag::UpdateInfo), + (WitBasePermission::SEND_ON_BEHALF, BaseFlag::SendOnBehalf), + (WitBasePermission::TOKEN_ADMIN_CREATE, BaseFlag::TokenAdminCreate), + (WitBasePermission::TOKEN_ADMIN_SUPPLY, BaseFlag::TokenAdminSupply), + (WitBasePermission::TOKEN_ADMIN_MODIFY_BALANCE, BaseFlag::TokenAdminModifyBalance), + (WitBasePermission::STORAGE_CREATE, BaseFlag::StorageCreate), + (WitBasePermission::STORAGE_CAN_HOLD, BaseFlag::StorageCanHold), + (WitBasePermission::STORAGE_DEPOSIT, BaseFlag::StorageDeposit), + (WitBasePermission::PERMISSION_DELEGATE_ADD, BaseFlag::PermissionDelegateAdd), + (WitBasePermission::PERMISSION_DELEGATE_REMOVE, BaseFlag::PermissionDelegateRemove), + (WitBasePermission::MANAGE_CERTIFICATE, BaseFlag::ManageCertificate), + (WitBasePermission::MULTISIG_SIGNER, BaseFlag::MultisigSigner), +]; + +/// Build a domain permission set from the WIT base-permission flags. +fn permissions_of(flags: WitBasePermission) -> Result { + let flags: Vec = PERMISSION_FLAGS + .iter() + .filter(|(wit, _)| flags.contains(*wit)) + .map(|&(_, base)| base) + .collect(); + + Ok(bindings_permissions::from_flags(&flags, &[])?) +} + /// A single-representative KeetaNet client backed by the WASI transport. struct NodeClient { inner: KeetaClient, @@ -267,7 +343,7 @@ impl From for AccountState { fn from(state: CoreState) -> Self { Self { representative: state.representative, - head: state.head, + head: state.head.map(|head| head.to_string()), height: state.height.map(amount_to_string), info: state.info.map(AccountInfo::from), supply: state.supply.map(amount_to_string), @@ -292,19 +368,49 @@ impl From for LedgerChecksum { impl From for HistoryEntry { fn from(entry: CoreHistory) -> Self { - Self { staple: pure::staple_to_hex(&entry.staple), id: entry.id, timestamp: entry.timestamp } + Self { + staple: pure::staple_to_hex(&entry.staple), + id: entry.id.map(|id| id.to_string()), + timestamp: entry.timestamp.map(|moment| moment.to_string()), + } } } -impl From for ChainQuery { - fn from(query: WitChainQuery) -> Self { - Self { start: query.start, end: query.end, limit: query.limit } +impl From<&CoreBlockEffects> for WitBlockEffects { + fn from(effects: &CoreBlockEffects) -> Self { + Self { + block: pure::block_to_hex(&effects.block), + operation_indexes: effects + .operation_indexes + .iter() + .map(|&index| index as u32) + .collect(), + } } } -impl From for HistoryQuery { - fn from(query: WitHistoryQuery) -> Self { - Self { start: query.start, limit: query.limit } +impl TryFrom for ChainQuery { + type Error = CodedError; + + fn try_from(query: WitChainQuery) -> Result { + let start = query.start.as_deref().map(parse_block_hash).transpose()?; + let end = query.end.as_deref().map(parse_block_hash).transpose()?; + + Ok(Self { start, end, limit: query.limit }) + } +} + +impl TryFrom for HistoryQuery { + type Error = CodedError; + + fn try_from(query: WitHistoryQuery) -> Result { + let start = query + .start + .as_deref() + .map(parse_history_cursor) + .transpose()?; + + Ok(Self { start, limit: query.limit }) } } @@ -329,6 +435,7 @@ impl TryFrom for SwapTokenAmount { .map(|token| pure::account_from_address(&token)) .transpose()?; let amount = leg.amount.map(|amount| parse_amount(&amount)).transpose()?; + Ok(Self { token, amount }) } } @@ -345,6 +452,7 @@ impl TryFrom for SwapExpectation { .send .map(SwapTokenAmount::try_from) .transpose()?; + Ok(Self { receive, send }) } } @@ -380,40 +488,47 @@ impl GuestClient for NodeClient { run(self.inner.node_version()) } - fn account_balance(&self, account: String, token: String) -> Result { - Ok(amount_to_string(run(self.inner.balance(account, token))?)) + fn account_balance(&self, account: AccountBorrow<'_>, token: AccountBorrow<'_>) -> Result { + let (account, token) = (account_of(account), account_of(token)); + Ok(amount_to_string(run(self.inner.balance(&*account, &*token))?)) } - fn account_balances(&self, account: String) -> Result, CodedError> { - Ok(run(self.inner.balances(account))? + fn account_balances(&self, account: AccountBorrow<'_>) -> Result, CodedError> { + let account = account_of(account); + Ok(run(self.inner.balances(&*account))? .into_iter() .map(|balance| TokenBalance { token: balance.token, amount: amount_to_string(balance.balance) }) .collect()) } - fn token_supply(&self, token: String) -> Result, CodedError> { - Ok(run(self.inner.token_supply(token))?.map(amount_to_string)) + fn token_supply(&self, token: AccountBorrow<'_>) -> Result, CodedError> { + let token = account_of(token); + Ok(run(self.inner.token_supply(&*token))?.map(amount_to_string)) } - fn account_state(&self, account: String) -> Result { - Ok(AccountState::from(run(self.inner.state(account))?)) + fn account_state(&self, account: AccountBorrow<'_>) -> Result { + let account = account_of(account); + Ok(AccountState::from(run(self.inner.state(&*account))?)) } - fn head_block(&self, account: String) -> Result, CodedError> { - Ok(run(self.inner.head_block(account))?.map(|block| pure::block_to_hex(&block))) + fn head_block(&self, account: AccountBorrow<'_>) -> Result, CodedError> { + let account = account_of(account); + Ok(run(self.inner.head_block(&*account))?.map(|block| pure::block_to_hex(&block))) } - fn block(&self, blockhash: String, side: Option) -> Result, CodedError> { - let side = ledger_side(side.as_deref())?; - Ok(run(self.inner.block(blockhash, side))?.map(|block| pure::block_to_hex(&block))) + fn block(&self, blockhash: String, side: Option) -> Result, CodedError> { + let blockhash = parse_block_hash(&blockhash)?; + Ok(run(self.inner.block(blockhash, side.map(Into::into)))?.map(|block| pure::block_to_hex(&block))) } fn vote_staple(&self, blockhash: String) -> Result, CodedError> { + let blockhash = parse_block_hash(&blockhash)?; Ok(run(self.inner.vote_staple(blockhash))?.map(|staple| pure::staple_to_hex(&staple))) } - fn representative(&self, rep: String) -> Result { - Ok(Representative::from(run(self.inner.representative(rep))?)) + fn representative(&self, rep: AccountBorrow<'_>) -> Result { + let rep = account_of(rep); + Ok(Representative::from(run(self.inner.representative(&*rep))?)) } fn representatives(&self) -> Result, CodedError> { @@ -427,38 +542,55 @@ impl GuestClient for NodeClient { Ok(LedgerChecksum::from(run(self.inner.ledger_checksum())?)) } - fn chain(&self, account: String) -> Result, CodedError> { - Ok(run(self.inner.chain(account))? + fn chain(&self, account: AccountBorrow<'_>) -> Result, CodedError> { + let account = account_of(account); + Ok(run(self.inner.chain(&*account))? .iter() .map(pure::block_to_hex) .collect()) } - fn chain_page(&self, account: String, query: WitChainQuery) -> Result { + fn chain_page(&self, account: AccountBorrow<'_>, query: WitChainQuery) -> Result { + let account = account_of(account); let page = run(self .inner - .chain_page_cursor(account, ChainQuery::from(query)))?; - Ok(ChainPage { blocks: page.blocks.iter().map(pure::block_to_hex).collect(), next_key: page.next_key }) + .chain_page_cursor(&*account, ChainQuery::try_from(query)?))?; + Ok(ChainPage { + blocks: page.blocks.iter().map(pure::block_to_hex).collect(), + next_key: page.next_key.map(|key| key.to_string()), + }) + } + + fn chain_all(&self, account: AccountBorrow<'_>, page_limit: u32) -> Result, CodedError> { + let account = account_of(account); + Ok(run(self.inner.chain_all(&*account, page_limit))? + .iter() + .map(pure::block_to_hex) + .collect()) } - fn history(&self, account: String) -> Result, CodedError> { - Ok(run(self.inner.history(account))? + fn history(&self, account: AccountBorrow<'_>) -> Result, CodedError> { + let account = account_of(account); + Ok(run(self.inner.history(&*account))? .into_iter() .map(HistoryEntry::from) .collect()) } - fn pending_block(&self, account: String) -> Result, CodedError> { - Ok(run(self.inner.pending_block(account))?.map(|block| pure::block_to_hex(&block))) + fn pending_block(&self, account: AccountBorrow<'_>) -> Result, CodedError> { + let account = account_of(account); + Ok(run(self.inner.pending_block(&*account))?.map(|block| pure::block_to_hex(&block))) } - fn account_head_info(&self, account: String) -> Result, CodedError> { - Ok(run(self.inner.account_head_info(account))? + fn account_head_info(&self, account: AccountBorrow<'_>) -> Result, CodedError> { + let account = account_of(account); + Ok(run(self.inner.account_head_info(&*account))? .map(|(block, height)| HeadInfo { block: pure::block_to_hex(&block), height: amount_to_string(height) })) } - fn account_states(&self, accounts: Vec) -> Result, CodedError> { - let refs: Vec<&str> = accounts.iter().map(String::as_str).collect(); + fn account_states(&self, accounts: Vec>) -> Result, CodedError> { + let accounts: Vec = accounts.into_iter().map(account_of).collect(); + let refs: Vec<&GenericAccount> = accounts.iter().map(|account| &**account).collect(); Ok(run(self.inner.states(&refs))? .into_iter() .map(AccountState::from) @@ -466,45 +598,73 @@ impl GuestClient for NodeClient { } fn successor_block(&self, blockhash: String) -> Result, CodedError> { + let blockhash = parse_block_hash(&blockhash)?; Ok(run(self.inner.successor_block(blockhash))?.map(|block| pure::block_to_hex(&block))) } - fn block_by_idempotent(&self, account: String, key: String) -> Result, CodedError> { - Ok(run(self.inner.block_by_idempotent(account, key))?.map(|block| pure::block_to_hex(&block))) + fn block_by_idempotent( + &self, + account: AccountBorrow<'_>, + key: String, + side: Option, + ) -> Result, CodedError> { + let account = account_of(account); + let side = side.map(Into::into); + Ok(run(self.inner.block_by_idempotent(&*account, key, side))?.map(|block| pure::block_to_hex(&block))) } - fn history_page(&self, account: String, query: WitHistoryQuery) -> Result, CodedError> { - let entries = run(self.inner.history_page(account, HistoryQuery::from(query)))?; + fn history_page( + &self, + account: AccountBorrow<'_>, + query: WitHistoryQuery, + ) -> Result, CodedError> { + let account = account_of(account); + let entries = run(self + .inner + .history_page(&*account, HistoryQuery::try_from(query)?))?; Ok(entries.into_iter().map(HistoryEntry::from).collect()) } + fn history_all(&self, account: AccountBorrow<'_>, page_limit: u32) -> Result, CodedError> { + let account = account_of(account); + Ok(run(self.inner.history_all(&*account, page_limit))? + .into_iter() + .map(HistoryEntry::from) + .collect()) + } + fn node_representative(&self) -> Result { Ok(Representative::from(run(self.inner.node_representative())?)) } - fn acls_by_principal(&self, account: String) -> Result, CodedError> { - Ok(run(self.inner.acls_by_principal(account))? + fn acls_by_principal(&self, account: AccountBorrow<'_>) -> Result, CodedError> { + let account = account_of(account); + Ok(run(self.inner.acls_by_principal(&*account))? .into_iter() .map(Acl::from) .collect()) } - fn acls_by_entity(&self, account: String) -> Result, CodedError> { - Ok(run(self.inner.acls_by_entity(account))? + fn acls_by_entity(&self, account: AccountBorrow<'_>) -> Result, CodedError> { + let account = account_of(account); + Ok(run(self.inner.acls_by_entity(&*account))? .into_iter() .map(Acl::from) .collect()) } - fn certificates(&self, account: String) -> Result, CodedError> { - Ok(run(self.inner.certificates(account))? + fn certificates(&self, account: AccountBorrow<'_>) -> Result, CodedError> { + let account = account_of(account); + Ok(run(self.inner.certificates(&*account))? .into_iter() .map(Certificate::from) .collect()) } - fn certificate(&self, account: String, hash: String) -> Result, CodedError> { - Ok(run(self.inner.certificate(account, hash))?.map(Certificate::from)) + fn certificate(&self, account: AccountBorrow<'_>, hash: String) -> Result, CodedError> { + let account = account_of(account); + let hash = decode_hash(&hash)?; + Ok(run(self.inner.certificate(&*account, hash))?.map(Certificate::from)) } fn global_history(&self) -> Result, CodedError> { @@ -515,11 +675,14 @@ impl GuestClient for NodeClient { } fn global_history_page(&self, query: WitHistoryQuery) -> Result, CodedError> { - let entries = run(self.inner.global_history_page(HistoryQuery::from(query)))?; + let entries = run(self + .inner + .global_history_page(HistoryQuery::try_from(query)?))?; Ok(entries.into_iter().map(HistoryEntry::from).collect()) } fn vote_staples_after(&self, start: String) -> Result, CodedError> { + let start = parse::moment(&start)?; Ok(run(self.inner.vote_staples_after(start))? .iter() .map(pure::staple_to_hex) @@ -527,11 +690,25 @@ impl GuestClient for NodeClient { } fn vote_staples_after_page(&self, start: String, limit: Option) -> Result, CodedError> { + let start = parse::moment(&start)?; Ok(run(self.inner.vote_staples_after_page(start, limit))? .iter() .map(pure::staple_to_hex) .collect()) } + + fn sync_account(&self, account: AccountBorrow<'_>, publish: bool) -> Result, CodedError> { + let account = account_of(account); + Ok(run(self.inner.sync_account(&account, publish))?.map(|staple| pure::staple_to_hex(&staple))) + } + + fn recover_account(&self, account: AccountBorrow<'_>, publish: bool) -> Result, CodedError> { + let account = account_of(account); + Ok(run(self + .inner + .recover_account(&account, publish, TransmitOptions::default()))? + .map(|staple| pure::staple_to_hex(&staple))) + } } /// A read-only [`UserClient`] scoped to one operating account. @@ -540,8 +717,8 @@ struct AccountClient { } impl GuestUserClient for AccountClient { - fn read_only(base_url: String, address: String) -> Result { - let account = pure::account_from_address(&address)?; + fn read_only(base_url: String, address: AccountBorrow<'_>) -> Result { + let account = account_of(address); let inner = UserClient::from_parts(single_rep_client(base_url), None).with_account(account); Ok(UserClientResource::new(Self { inner })) } @@ -551,13 +728,13 @@ impl GuestUserClient for AccountClient { signer: AccountBorrow<'_>, network: String, ) -> Result { - let signer = Arc::clone(&signer.get::().account); + let signer = account_of(signer); let network = BigInt::from_str(&network).map_err(|_| CodedError { code: "INVALID_INTEGER".into(), message: "network must be a decimal integer".into(), })?; - let client = single_rep_client(base_url).with_network(network); + let client = single_rep_client(base_url).with_network(network); let inner = UserClient::from_parts(client, Some(signer)); Ok(UserClientResource::new(Self { inner })) } @@ -566,8 +743,9 @@ impl GuestUserClient for AccountClient { Ok(pure::account_address(&self.inner.account()?)) } - fn balance(&self, token: String) -> Result { - Ok(amount_to_string(run(self.inner.balance(token))?)) + fn balance(&self, token: AccountBorrow<'_>) -> Result { + let token = account_of(token); + Ok(amount_to_string(run(self.inner.balance(&*token))?)) } fn all_balances(&self) -> Result, CodedError> { @@ -593,10 +771,17 @@ impl GuestUserClient for AccountClient { } fn chain_page(&self, query: WitChainQuery) -> Result, CodedError> { - let blocks = run(self.inner.chain_page(ChainQuery::from(query)))?; + let blocks = run(self.inner.chain_page(ChainQuery::try_from(query)?))?; Ok(blocks.iter().map(pure::block_to_hex).collect()) } + fn chain_all(&self, page_limit: u32) -> Result, CodedError> { + Ok(run(self.inner.chain_all(page_limit))? + .iter() + .map(pure::block_to_hex) + .collect()) + } + fn history(&self) -> Result, CodedError> { Ok(run(self.inner.history())? .into_iter() @@ -604,26 +789,105 @@ impl GuestUserClient for AccountClient { .collect()) } + fn history_page(&self, query: WitHistoryQuery) -> Result, CodedError> { + let entries = run(self.inner.history_page(HistoryQuery::try_from(query)?))?; + Ok(entries.into_iter().map(HistoryEntry::from).collect()) + } + + fn history_all(&self, page_limit: u32) -> Result, CodedError> { + Ok(run(self.inner.history_all(page_limit))? + .into_iter() + .map(HistoryEntry::from) + .collect()) + } + fn pending_block(&self) -> Result, CodedError> { Ok(run(self.inner.pending_block())?.map(|block| pure::block_to_hex(&block))) } - fn send(&self, to: String, token: String, amount: String) -> Result { - let to = pure::account_from_address(&to)?; - let token = pure::account_from_address(&token)?; + fn block(&self, blockhash: String, side: Option) -> Result, CodedError> { + let blockhash = parse_block_hash(&blockhash)?; + Ok(run(self.inner.block(blockhash, side.map(Into::into)))?.map(|block| pure::block_to_hex(&block))) + } + + fn block_from_idempotent(&self, key: String, side: Option) -> Result, CodedError> { + let side = side.map(Into::into); + Ok(run(self.inner.block_from_idempotent(key, side))?.map(|block| pure::block_to_hex(&block))) + } + + fn staple_effects(&self, staples: Vec) -> Result, CodedError> { + let moment = WasiRuntime.unix_millis(); + let staples = staples + .iter() + .map(|staple| pure::staple_from_hex(staple, moment)) + .collect::, _>>()?; + + let effects = self + .inner + .staple_effects(&staples) + .map_err(CodedError::from)?; + Ok(effects + .into_iter() + .map(|(id, blocks)| WitStapleEffects { + id: id.to_string(), + blocks: blocks.iter().map(WitBlockEffects::from).collect(), + }) + .collect()) + } + + fn acls(&self) -> Result, CodedError> { + Ok(run(self.inner.acls())?.into_iter().map(Acl::from).collect()) + } + + fn acls_by_entity(&self) -> Result, CodedError> { + Ok(run(self.inner.acls_by_entity())? + .into_iter() + .map(Acl::from) + .collect()) + } + + fn certificates(&self) -> Result, CodedError> { + Ok(run(self.inner.certificates())? + .into_iter() + .map(Certificate::from) + .collect()) + } + + fn certificate(&self, hash: String) -> Result, CodedError> { + let hash = decode_hash(&hash)?; + Ok(run(self.inner.certificate(hash))?.map(Certificate::from)) + } + + fn sync(&self, publish: bool) -> Result, CodedError> { + Ok(run(self.inner.sync(publish))?.map(|staple| pure::staple_to_hex(&staple))) + } + + fn recover(&self, publish: bool) -> Result, CodedError> { + Ok(run(self.inner.recover(publish))?.map(|staple| pure::staple_to_hex(&staple))) + } + + fn send(&self, to: AccountBorrow<'_>, token: AccountBorrow<'_>, amount: String) -> Result { + let to = account_of(to); + let token = account_of(token); let amount = parse_amount(&amount)?; run(self.inner.send(&to, &token, amount)) } - fn send_external(&self, to: String, token: String, amount: String, external: String) -> Result { - let to = pure::account_from_address(&to)?; - let token = pure::account_from_address(&token)?; + fn send_external( + &self, + to: AccountBorrow<'_>, + token: AccountBorrow<'_>, + amount: String, + external: String, + ) -> Result { + let to = account_of(to); + let token = account_of(token); let amount = parse_amount(&amount)?; run(self.inner.send_external(&to, &token, amount, external)) } - fn set_rep(&self, rep: String) -> Result { - let rep = pure::account_from_address(&rep)?; + fn set_rep(&self, rep: AccountBorrow<'_>) -> Result { + let rep = account_of(rep); run(self.inner.set_rep(&rep)) } @@ -644,15 +908,13 @@ impl GuestUserClient for AccountClient { fn modify_token( &self, - token: String, - holder: Option, + token: AccountBorrow<'_>, + holder: Option>, amount: String, method: WitAdjustMethod, ) -> Result { - let token = pure::account_from_address(&token)?; - let holder = holder - .map(|holder| pure::account_from_address(&holder)) - .transpose()?; + let token = account_of(token); + let holder = holder.map(account_of); let amount = parse_amount(&amount)?; run(self @@ -662,28 +924,23 @@ impl GuestUserClient for AccountClient { fn update_permissions( &self, - principal: String, + principal: AccountBorrow<'_>, method: WitAdjustMethod, - permissions: Vec, - target: Option, + permissions: WitBasePermission, + target: Option>, ) -> Result { - let principal = ModifyPermissionsPrincipal::Account(pure::account_from_address(&principal)?); + let principal = ModifyPermissionsPrincipal::Account(account_of(principal)); let permissions = match permissions.is_empty() { true => None, - false => Some(pure::permissions_from_flags(&permissions, &[])?), + false => Some(permissions_of(permissions)?), }; - let target = target - .map(|target| pure::account_from_address(&target)) - .transpose()?; + let target = target.map(account_of); let change = ModifyPermissions { principal, method: AdjustMethod::from(method), permissions, target }; run(self.inner.update_permissions(change)) } - fn generate_multisig(&self, signers: Vec, quorum: u32) -> Result { - let signers = signers - .iter() - .map(|signer| pure::account_from_address(signer)) - .collect::, _>>()?; + fn generate_multisig(&self, signers: Vec>, quorum: u32) -> Result { + let signers = signers.into_iter().map(account_of).collect(); let arguments = IdentifierCreateArguments::Multisig(MultisigCreateArguments { signers, quorum: quorum.into() }); let identifier = run(self .inner @@ -691,26 +948,34 @@ impl GuestUserClient for AccountClient { Ok(pure::account_address(&identifier)) } - fn generate_identifier(&self, kind: String) -> Result { - let kind = keetanetwork_bindings::parse::identifier_type(&kind)?; - let identifier = run(self.inner.generate_identifier(kind, None))?; + fn generate_identifier(&self, kind: WitIdentifierKind) -> Result { + // Multisig identifiers require create arguments, supplied only by the + // dedicated generate-multisig path. + if kind == WitIdentifierKind::Multisig { + return Err(CodedError { + code: "INVALID_IDENTIFIER_TYPE".into(), + message: "multisig identifiers are created through generate-multisig".into(), + }); + } + + let identifier = run(self.inner.generate_identifier(kind.into(), None))?; Ok(pure::account_address(&identifier)) } fn create_swap( &self, - counterparty: String, - send_token: String, + counterparty: AccountBorrow<'_>, + send_token: AccountBorrow<'_>, send_amount: String, - receive_token: String, + receive_token: AccountBorrow<'_>, receive_amount: String, receive_exact: bool, ) -> Result { let request = CreateSwapRequest { - counterparty: pure::account_from_address(&counterparty)?, - send_token: pure::account_from_address(&send_token)?, + counterparty: account_of(counterparty), + send_token: account_of(send_token), send_amount: parse_amount(&send_amount)?, - receive_token: pure::account_from_address(&receive_token)?, + receive_token: account_of(receive_token), receive_amount: parse_amount(&receive_amount)?, receive_exact, }; @@ -746,6 +1011,7 @@ impl GuestUserClient for AccountClient { certificate_or_hash: CertificateOrHash::Certificate(certificate), intermediate_certificates: Some(IntermediateCertificates::Bundle(intermediates)), }; + run(self.inner.modify_certificate(manage)) } @@ -785,9 +1051,9 @@ struct TransactionState { } impl GuestTransaction for TransactionState { - fn send(&self, to: String, token: String, amount: String) -> Result<(), CodedError> { - let to = pure::account_from_address(&to)?; - let token = pure::account_from_address(&token)?; + fn send(&self, to: AccountBorrow<'_>, token: AccountBorrow<'_>, amount: String) -> Result<(), CodedError> { + let to = account_of(to); + let token = account_of(token); let amount = parse_amount(&amount)?; self.builder.borrow_mut().send(&to, &token, amount); @@ -795,9 +1061,15 @@ impl GuestTransaction for TransactionState { Ok(()) } - fn send_external(&self, to: String, token: String, amount: String, external: String) -> Result<(), CodedError> { - let to = pure::account_from_address(&to)?; - let token = pure::account_from_address(&token)?; + fn send_external( + &self, + to: AccountBorrow<'_>, + token: AccountBorrow<'_>, + amount: String, + external: String, + ) -> Result<(), CodedError> { + let to = account_of(to); + let token = account_of(token); let amount = parse_amount(&amount)?; self.builder @@ -807,8 +1079,8 @@ impl GuestTransaction for TransactionState { Ok(()) } - fn set_rep(&self, rep: String) -> Result<(), CodedError> { - let rep = pure::account_from_address(&rep)?; + fn set_rep(&self, rep: AccountBorrow<'_>) -> Result<(), CodedError> { + let rep = account_of(rep); self.builder.borrow_mut().set_rep(&rep); @@ -869,8 +1141,12 @@ fn builder_consumed() -> CodedError { } impl GuestBlockBuilder for BuilderState { - fn new(network: u64, account: String) -> Result { - let account = pure::account_from_address(&account)?; + fn new(network: String, account: AccountBorrow<'_>) -> Result { + let account = account_of(account); + let network = BigInt::from_str(&network).map_err(|_| CodedError { + code: "INVALID_INTEGER".into(), + message: "network must be a decimal integer".into(), + })?; let builder = BlockBuilder::default() .with_network(network) .with_account(account); @@ -897,43 +1173,40 @@ impl GuestBlockBuilder for BuilderState { } fn signer_single(&self, signer: AccountBorrow<'_>) -> Result<(), CodedError> { - let account = Arc::clone(&signer.get::().account); + let account = account_of(signer); let signer = pure::signer_single(account); self.stage(|builder| builder.with_signer(signer)) } - fn signer_multisig(&self, multisig: String, members: Vec>) -> Result<(), CodedError> { - let multisig = pure::account_from_address(&multisig)?; - let members = members - .iter() - .map(|member| Arc::clone(&member.get::().account)) - .collect(); + fn signer_multisig(&self, multisig: AccountBorrow<'_>, members: Vec>) -> Result<(), CodedError> { + let multisig = account_of(multisig); + let members = members.into_iter().map(account_of).collect(); let signer = pure::signer_multisig(multisig, members); self.stage(|builder| builder.with_signer(signer)) } - fn op_create_multisig(&self, multisig: String, signers: Vec, quorum: u32) -> Result<(), CodedError> { - let multisig = pure::account_from_address(&multisig)?; - let signers = signers - .iter() - .map(|signer| pure::account_from_address(signer)) - .collect::, _>>()?; + fn op_create_multisig( + &self, + multisig: AccountBorrow<'_>, + signers: Vec>, + quorum: u32, + ) -> Result<(), CodedError> { + let multisig = account_of(multisig); + let signers = signers.into_iter().map(account_of).collect(); let operation = pure::op_create_multisig(multisig, signers, quorum); self.stage(|builder| builder.with_operation(operation)) } fn op_modify_permissions( &self, - principal: String, - permissions: Vec, + principal: AccountBorrow<'_>, + permissions: WitBasePermission, method: WitAdjustMethod, - target: Option, + target: Option>, ) -> Result<(), CodedError> { - let principal = pure::account_from_address(&principal)?; - let permissions = pure::permissions_from_flags(&permissions, &[])?; - let target = target - .map(|target| pure::account_from_address(&target)) - .transpose()?; + let principal = account_of(principal); + let permissions = permissions_of(permissions)?; + let target = target.map(account_of); let operation = pure::op_modify_permissions(principal, permissions, AdjustMethod::from(method), target); self.stage(|builder| builder.with_operation(operation)) } @@ -943,18 +1216,15 @@ impl GuestBlockBuilder for BuilderState { name: String, description: String, metadata: String, - default_permission: Vec, + default_permission: Option, ) -> Result<(), CodedError> { - let default_permission = match default_permission.is_empty() { - true => None, - false => Some(pure::permissions_from_flags(&default_permission, &[])?), - }; + let default_permission = default_permission.map(permissions_of).transpose()?; let operation = pure::op_set_info(name, description, metadata, default_permission); self.stage(|builder| builder.with_operation(operation)) } - fn op_set_rep(&self, rep: String) -> Result<(), CodedError> { - let rep = pure::account_from_address(&rep)?; + fn op_set_rep(&self, rep: AccountBorrow<'_>) -> Result<(), CodedError> { + let rep = account_of(rep); let operation = pure::op_set_rep(rep); self.stage(|builder| builder.with_operation(operation)) } diff --git a/keetanetwork-client-wasi/src/pure.rs b/keetanetwork-client-wasi/src/pure.rs index cc7102a..f6dd865 100644 --- a/keetanetwork-client-wasi/src/pure.rs +++ b/keetanetwork-client-wasi/src/pure.rs @@ -154,6 +154,13 @@ pub fn vote_from_bytes(bytes: impl Into>) -> Result { Vote::verify(bytes).map_err(CodedError::from) } +/// Decode and verify a staple from its compressed hex transport encoding, +/// enforcing the staple invariants at `moment_millis`. +pub fn staple_from_hex(value: &str, moment_millis: i64) -> Result { + let bytes = hex::decode(value).map_err(|_| CodedError::new("INVALID_STAPLE", "staple must be hex"))?; + VoteStaple::verify(bytes, ValidationConfig::default(), block_time(moment_millis)?).map_err(CodedError::from) +} + /// Assemble a publishable [`VoteStaple`] from signed `blocks` and the `votes` /// endorsing them, enforcing the staple invariants at `moment_millis`. pub fn vote_staple_build(blocks: Vec, votes: Vec, moment_millis: i64) -> Result, CodedError> { diff --git a/keetanetwork-client-wasi/wit/world.wit b/keetanetwork-client-wasi/wit/world.wit index 352426f..1420c33 100644 --- a/keetanetwork-client-wasi/wit/world.wit +++ b/keetanetwork-client-wasi/wit/world.wit @@ -2,6 +2,21 @@ package keeta:client@0.1.0; /// Shared value types crossing the component boundary. interface types { + /// A 32-byte block hash, hex-encoded. + type block-hash = string; + /// A staple id: the hash of the block hashes a staple covers, hex-encoded. + type staple-id = string; + /// A signed block in its transport encoding (hex). + type block-hex = string; + /// A verified vote staple in its compressed transport encoding (hex). + type staple-hex = string; + /// An arbitrary-precision unsigned integer as a decimal string. + type amount = string; + /// A textual `keeta_…` account address. + type address = string; + /// An ISO 8601 moment. + type moment = string; + /// A stable, machine-readable error: a programmatic `code` plus a /// human-readable `message`. record coded-error { @@ -9,25 +24,79 @@ interface types { message: string, } - /// One token balance held by an account. `amount` is a decimal integer - /// string in the token's base units. + /// The ledger storage a lookup targets. `both` searches the main ledger + /// and the side ledger (pending, not-yet-promoted staples). + enum ledger-side { + main, + side, + both, + } + + /// A signing key algorithm. + enum key-algorithm { + ed25519, + ecdsa-secp256k1, + ecdsa-secp256r1, + } + + /// An identifier account kind: an on-chain account derived from a signer + /// rather than a key pair. + enum identifier-kind { + network, + token, + storage, + multisig, + } + + /// What an account is: a signing account under a key algorithm, or a + /// derived identifier account. + variant account-kind { + signing(key-algorithm), + identifier(identifier-kind), + } + + /// The base permission flags (the on-chain base bitmap, low bit first). + flags base-permission { + access, + owner, + admin, + update-info, + send-on-behalf, + token-admin-create, + token-admin-supply, + token-admin-modify-balance, + storage-create, + storage-can-hold, + storage-deposit, + permission-delegate-add, + permission-delegate-remove, + manage-certificate, + multisig-signer, + } + + /// A 32-byte certificate hash, hex-encoded. + type certificate-hash = string; + /// A network identifier as a decimal integer string. + type network-id = string; + + /// One token balance held by an account, in the token's base units. record token-balance { - token: string, - amount: string, + token: address, + amount: amount, } - /// A representative and its voting weight. `weight` is a decimal integer - /// string; `api-url` is present only on the plural lookup. + /// A representative and its voting weight; `api-url` is present only on + /// the plural lookup. record representative { - account: string, - weight: string, + account: address, + weight: amount, api-url: option, } - /// A point-in-time ledger checksum. `checksum` is a decimal integer string. + /// A point-in-time ledger checksum. record ledger-checksum { - checksum: string, - moment: option, + checksum: amount, + moment: option, moment-range: option, } @@ -38,45 +107,42 @@ interface types { metadata: option, } - /// A snapshot of an account's ledger state. `height`/`supply` are decimal - /// integer strings; `head` is a block hash (hex). + /// A snapshot of an account's ledger state. record account-state { - representative: option, - head: option, - height: option, + representative: option
, + head: option, + height: option, info: option, - supply: option, + supply: option, balances: list, } - /// One verified entry of an account's transaction history. `staple` is the - /// vote staple (hex); `timestamp` is ISO 8601. + /// One verified entry of an account's transaction history. record history-entry { - staple: string, - id: option, - timestamp: option, + staple: staple-hex, + id: option, + timestamp: option, } /// Pagination/range bounds for a chain page. `start`/`end` are block-hash /// cursors; `limit` caps the page size. record chain-query { - start: option, - end: option, + start: option, + end: option, limit: option, } - /// A page of an account's chain (each block as hex, most recent first) plus - /// the cursor to pass as the next page's `start`, or none when exhausted. + /// A page of an account's chain (most recent first) plus the cursor to + /// pass as the next page's `start`, or none when exhausted. record chain-page { - blocks: list, - next-key: option, + blocks: list, + next-key: option, } - /// An account's head block (hex) together with its chain height (decimal - /// integer string). + /// An account's head block together with its chain height. record head-info { - block: string, - height: string, + block: block-hex, + height: amount, } /// How a supply/balance adjustment combines with the current value. @@ -86,18 +152,33 @@ interface types { set, } - /// Pagination/range bounds for a history page. `start` is a vote-staple-id + /// Pagination/range bounds for a history page. `start` is a staple-id /// cursor; `limit` caps the page size. record history-query { - start: option, + start: option, limit: option, } - /// An access-control entry. `permissions` are `0x`-prefixed hex bitmaps. + /// One staple block with the indexes of its operations that involve the + /// filtered account, in block order. + record block-effects { + block: block-hex, + operation-indexes: list, + } + + /// The effects of one staple for a filtered account: the staple id with + /// the matching operations of each of its blocks. + record staple-effects { + id: staple-id, + blocks: list, + } + + /// An access-control entry. `permissions` are the on-chain + /// `[base, external]` `0x`-prefixed hex bitmaps. record acl { - principal: option, - entity: option, - target: option, + principal: option
, + entity: option
, + target: option
, permissions: list, } @@ -107,11 +188,10 @@ interface types { intermediates: list, } - /// An optional token/amount assertion for one leg of a swap. `amount` is a - /// decimal integer string. + /// An optional token/amount assertion for one leg of a swap. record swap-token-amount { - token: option, - amount: option, + token: option
, + amount: option, } /// Optional validation of a swap's legs when accepting an offer. @@ -124,22 +204,22 @@ interface types { /// The Keeta cryptographic primitives every binding boundary reuses: signing /// accounts and base X.509 certificates. interface crypto { - use types.{coded-error}; + use types.{coded-error, key-algorithm, account-kind}; /// A Keeta account: a signer derived from a seed, or a read-only account /// parsed from its textual address. resource account { /// Derive an account from a 32-byte hex `seed` at `index` under - /// `algorithm` (`ed25519`, `ecdsa_secp256k1`, or `ecdsa_secp256r1`). - from-seed: static func(seed: string, index: u32, algorithm: string) -> result; + /// `algorithm`. + from-seed: static func(seed: string, index: u32, algorithm: key-algorithm) -> result; /// Build an account from a hex-encoded private `key` under `algorithm`. - from-private-key: static func(key: string, algorithm: string) -> result; + from-private-key: static func(key: string, algorithm: key-algorithm) -> result; /// Derive an account from a BIP39 mnemonic `words` at `index` under /// `algorithm`. - from-passphrase: static func(words: list, index: u32, algorithm: string) -> result; + from-passphrase: static func(words: list, index: u32, algorithm: key-algorithm) -> result; /// Build a read-only account from a hex-encoded public `key` under /// `algorithm`. - from-public-key: static func(key: string, algorithm: string) -> result; + from-public-key: static func(key: string, algorithm: key-algorithm) -> result; /// Parse a read-only account from its textual `keeta_…` address. from-address: static func(address: string) -> result; /// A fresh random 32-byte seed, hex-encoded. @@ -148,8 +228,9 @@ interface crypto { generate-passphrase: static func() -> list; /// The textual `keeta_…` address. address: func() -> string; - /// The signing algorithm name, or `other` for identifier accounts. - algorithm: func() -> string; + /// What the account is: a signing account under a key algorithm, or a + /// derived identifier account. + kind: func() -> account-kind; /// The type-prefixed public key, hex-encoded. public-key: func() -> string; /// Sign `message`, returning the raw signature bytes. @@ -193,15 +274,16 @@ interface node { use types.{ coded-error, token-balance, representative, ledger-checksum, account-info, account-state, history-entry, chain-query, chain-page, head-info, adjust-method, history-query, acl, certificate, - swap-token-amount, swap-expectation, + block-effects, staple-effects, swap-token-amount, swap-expectation, + ledger-side, identifier-kind, base-permission, + block-hash, staple-id, block-hex, staple-hex, amount, address, moment, certificate-hash, network-id, }; use crypto.{account}; - /// Locally derive an identifier address relative to `signer`, an optional - /// `previous` block hash (hex; the opening hash when absent), and operation - /// `op-index`. `kind` is an identifier key type - /// (`network`/`token`/`storage`/`multisig`). - derive-identifier: func(signer: borrow, kind: string, previous: option, op-index: u32) -> result; + /// Locally derive an identifier address of `kind` relative to `signer`, + /// an optional `previous` block hash (the opening hash when absent), and + /// operation `op-index`. + derive-identifier: func(signer: borrow, kind: identifier-kind, previous: option, op-index: u32) -> result; /// A client bound to a single representative endpoint. resource client { @@ -209,65 +291,82 @@ interface node { constructor(base-url: string); /// The node software version string. node-version: func() -> result; - /// The settled balance of `token` held by `account`, as a decimal - /// integer string in the token's base units. - account-balance: func(account: string, token: string) -> result; + /// The settled balance of `token` held by `account`, in the token's + /// base units. + account-balance: func(account: borrow, token: borrow) -> result; /// Every token balance held by `account`. - account-balances: func(account: string) -> result, coded-error>; + account-balances: func(account: borrow) -> result, coded-error>; /// The total supply of `token`, or none for a non-token account. - token-supply: func(token: string) -> result, coded-error>; + token-supply: func(token: borrow) -> result, coded-error>; /// A snapshot of `account`'s ledger state. - account-state: func(account: string) -> result; - /// The head block of `account` (hex), or none when it has no blocks. - head-block: func(account: string) -> result, coded-error>; - /// The block identified by `blockhash` (hex), if held. `side` selects - /// the ledger (`main`, `side`, or `both`; defaults to main). - block: func(blockhash: string, side: option) -> result, coded-error>; - /// The vote staple (hex) covering `blockhash`, or none. - vote-staple: func(blockhash: string) -> result, coded-error>; + account-state: func(account: borrow) -> result; + /// The head block of `account`, or none when it has no blocks. + head-block: func(account: borrow) -> result, coded-error>; + /// The block identified by `blockhash`, if held. `side` selects the + /// ledger to read (defaults to main). + block: func(blockhash: block-hash, side: option) -> result, coded-error>; + /// The vote staple covering `blockhash`, or none. + vote-staple: func(blockhash: block-hash) -> result, coded-error>; /// A single representative and its voting weight. - representative: func(rep: string) -> result; + representative: func(rep: borrow) -> result; /// Every known representative and its voting weight. representatives: func() -> result, coded-error>; /// A point-in-time ledger checksum. ledger-checksum: func() -> result; - /// A prefix of `account`'s chain (most recent first), each block as hex. - chain: func(account: string) -> result, coded-error>; + /// A prefix of `account`'s chain (most recent first). + chain: func(account: borrow) -> result, coded-error>; /// A single page of `account`'s chain plus the next-page cursor. - chain-page: func(account: string, query: chain-query) -> result; + chain-page: func(account: borrow, query: chain-query) -> result; + /// Every block in `account`'s chain (most recent first), fetched by + /// following the node's cursor with `page-limit` per request. + chain-all: func(account: borrow, page-limit: u32) -> result, coded-error>; /// `account`'s transaction history as verified vote staples. - history: func(account: string) -> result, coded-error>; - /// `account`'s half-published successor block (hex), if any. - pending-block: func(account: string) -> result, coded-error>; - /// `account`'s head block (hex) plus its chain height. - account-head-info: func(account: string) -> result, coded-error>; + history: func(account: borrow) -> result, coded-error>; + /// `account`'s half-published successor block, if any. + pending-block: func(account: borrow) -> result, coded-error>; + /// `account`'s head block plus its chain height. + account-head-info: func(account: borrow) -> result, coded-error>; /// A snapshot of several accounts' ledger states, in input order. - account-states: func(accounts: list) -> result, coded-error>; - /// The block (hex) that chains directly after `blockhash`, if any. - successor-block: func(blockhash: string) -> result, coded-error>; - /// The block (hex) `account` published under idempotency `key`, if any. - block-by-idempotent: func(account: string, key: string) -> result, coded-error>; + account-states: func(accounts: list>) -> result, coded-error>; + /// The block that chains directly after `blockhash`, if any. + successor-block: func(blockhash: block-hash) -> result, coded-error>; + /// The block `account` published under idempotency `key`, if any. + /// `side` selects the ledger to search (defaults to main). + block-by-idempotent: func(account: borrow, key: string, side: option) -> result, coded-error>; /// A single page of `account`'s history, bounded by `query`. - history-page: func(account: string, query: history-query) -> result, coded-error>; + history-page: func(account: borrow, query: history-query) -> result, coded-error>; + /// Every entry in `account`'s history, fetched by following the node's + /// cursor with `page-limit` per request. + history-all: func(account: borrow, page-limit: u32) -> result, coded-error>; /// The node's own representative and its voting weight. node-representative: func() -> result; /// The access-control entries `account` grants as principal. - acls-by-principal: func(account: string) -> result, coded-error>; + acls-by-principal: func(account: borrow) -> result, coded-error>; /// The access-control entries keyed under `account` as entity. - acls-by-entity: func(account: string) -> result, coded-error>; + acls-by-entity: func(account: borrow) -> result, coded-error>; /// Every certificate held by `account`. - certificates: func(account: string) -> result, coded-error>; + certificates: func(account: borrow) -> result, coded-error>; /// The certificate on `account` identified by `hash`, if held. - certificate: func(account: string, hash: string) -> result, coded-error>; + certificate: func(account: borrow, hash: certificate-hash) -> result, coded-error>; /// The node's global transaction history. global-history: func() -> result, coded-error>; /// A single page of the node's global history, bounded by `query`. global-history-page: func(query: history-query) -> result, coded-error>; - /// Vote staples (hex) committed at or after the ISO 8601 `start` moment. - vote-staples-after: func(start: string) -> result, coded-error>; - /// A single page of vote staples (hex) at or after the ISO 8601 `start` - /// moment, capped at `limit`. - vote-staples-after-page: func(start: string, limit: option) -> result, coded-error>; + /// Vote staples committed at or after the `start` moment. + vote-staples-after: func(start: moment) -> result, coded-error>; + /// A single page of vote staples at or after the `start` moment, + /// capped at `limit`. + vote-staples-after-page: func(start: moment, limit: option) -> result, coded-error>; + /// Synchronize `account` across representatives: pull the missing + /// successor staple from the highest rep and, when `publish` is set, + /// publish it to the lagging reps. Returns the reconciling staple, + /// or none when the reps already agree. + sync-account: func(account: borrow, publish: bool) -> result, coded-error>; + /// Rebuild the staple for `account`'s half-published successor block + /// from the votes scattered across reps; when `publish` is set, + /// republish it. Returns the recovered staple, or none when nothing + /// is pending. + recover-account: func(account: borrow, publish: bool) -> result, coded-error>; } /// A client scoped to a single operating account: account-relative reads, @@ -275,73 +374,109 @@ interface node { /// and transmits over `wasi:http`; a read-only client rejects writes. resource user-client { /// Bind to the node at `base-url`, scoped to read `address`. - read-only: static func(base-url: string, address: string) -> result; + read-only: static func(base-url: string, address: borrow) -> result; /// Bind to the node at `base-url`, signing (and operating) as `signer`. - /// `network` is the decimal network identifier stamped onto originated - /// blocks. - with-account: static func(base-url: string, signer: borrow, network: string) -> result; + /// `network` is the network identifier stamped onto originated blocks. + with-account: static func(base-url: string, signer: borrow, network: network-id) -> result; /// The operating account's address. - address: func() -> result; + address: func() -> result; /// The settled balance of `token` held by the operating account. - balance: func(token: string) -> result; + balance: func(token: borrow) -> result; /// Every token balance held by the operating account. all-balances: func() -> result, coded-error>; /// The full state of the operating account. state: func() -> result; - /// The operating account's head block (hex), if any. - head: func() -> result, coded-error>; - /// A prefix of the operating account's chain (most recent first), hex. - chain: func() -> result, coded-error>; - /// A single page of the operating account's chain (hex), bounded by - /// `query`. - chain-page: func(query: chain-query) -> result, coded-error>; + /// The operating account's head block, if any. + head: func() -> result, coded-error>; + /// A prefix of the operating account's chain (most recent first). + chain: func() -> result, coded-error>; + /// A single page of the operating account's chain, bounded by `query`. + chain-page: func(query: chain-query) -> result, coded-error>; + /// Every block in the operating account's chain, fetched by following + /// the node's cursor with `page-limit` per request. + chain-all: func(page-limit: u32) -> result, coded-error>; /// The operating account's transaction history as verified vote staples. history: func() -> result, coded-error>; - /// The operating account's half-published successor block (hex), if any. - pending-block: func() -> result, coded-error>; - /// Send `amount` (decimal) of `token` to `to`; builds, signs, and - /// transmits. Requires a signer. - send: func(to: string, token: string, amount: string) -> result; - /// Send `amount` (decimal) of `token` to `to` with attached `external` - /// reference data; never aggregated. Requires a signer. - send-external: func(to: string, token: string, amount: string, external: string) -> result; + /// A single page of the operating account's history, bounded by `query`. + history-page: func(query: history-query) -> result, coded-error>; + /// Every entry in the operating account's history, fetched by following + /// the node's cursor with `page-limit` per request. + history-all: func(page-limit: u32) -> result, coded-error>; + /// The operating account's half-published successor block, if any. + pending-block: func() -> result, coded-error>; + /// The block identified by `blockhash`, if held. `side` selects the + /// ledger to read (defaults to main). + block: func(blockhash: block-hash, side: option) -> result, coded-error>; + /// The block the operating account published under idempotency `key`, + /// if any. `side` selects the ledger to search (defaults to main). + block-from-idempotent: func(key: string, side: option) -> result, coded-error>; + /// The operations in `staples` that involve the operating account, + /// keyed by staple id and ordered as published: every operation of a + /// block the account produced, plus operations on other accounts' + /// blocks that name it. + staple-effects: func(staples: list) -> result, coded-error>; + /// The access-control entries the operating account grants as principal. + acls: func() -> result, coded-error>; + /// The access-control entries keyed under the operating account as + /// entity. + acls-by-entity: func() -> result, coded-error>; + /// Every certificate held by the operating account. + certificates: func() -> result, coded-error>; + /// The certificate on the operating account identified by `hash`, if + /// held. + certificate: func(hash: certificate-hash) -> result, coded-error>; + /// Synchronize the operating account across representatives; when + /// `publish` is set, publish the reconciling staple to the lagging + /// reps. Returns the staple, or none when the reps already agree. + sync: func(publish: bool) -> result, coded-error>; + /// Rebuild the staple for the operating account's half-published + /// successor block; when `publish` is set, republish it. Returns the + /// recovered staple, or none when nothing is pending. + recover: func(publish: bool) -> result, coded-error>; + /// Send `amount` of `token` to `to`; builds, signs, and transmits. + /// Requires a signer. + send: func(to: borrow, token: borrow, amount: amount) -> result; + /// Send `amount` of `token` to `to` with attached `external` reference + /// data; never aggregated. Requires a signer. + send-external: func(to: borrow, token: borrow, amount: amount, external: string) -> result; /// Set the operating account's representative to `rep`. Requires a signer. - set-rep: func(rep: string) -> result; + set-rep: func(rep: borrow) -> result; /// Set the operating account's info fields. Requires a signer. set-info: func(name: option, description: option, metadata: option) -> result; /// Adjust `token`'s supply (and optionally `holder`'s balance) by - /// `amount` (decimal) via `method`. Requires a signer with token admin. - modify-token: func(token: string, holder: option, amount: string, method: adjust-method) -> result; - /// Grant or revoke `permissions` (flag names) for `principal`, optionally - /// scoped to `target`, via `method`. An empty `permissions` list with - /// `set` clears them. Requires a signer. - update-permissions: func(principal: string, method: adjust-method, permissions: list, target: option) -> result; + /// `amount` via `method`. Requires a signer with token admin. + modify-token: func(token: borrow, holder: option>, amount: amount, method: adjust-method) -> result; + /// Grant or revoke `permissions` for `principal`, optionally scoped to + /// `target`, via `method`. Empty `permissions` with `set` clears them. + /// Requires a signer. + update-permissions: func(principal: borrow, method: adjust-method, permissions: base-permission, target: option>) -> result; /// Create an on-chain multisig identifier requiring `quorum` of /// `signers`; returns the new identifier address. Requires a signer. - generate-multisig: func(signers: list, quorum: u32) -> result; - /// Create an on-chain identifier of `kind` - /// (`network`/`token`/`storage`/`multisig`); builds and publishes, - /// returning the new identifier address. Requires a signer. - generate-identifier: func(kind: string) -> result; + generate-multisig: func(signers: list>, quorum: u32) -> result; + /// Create an on-chain identifier of `kind`; builds and publishes, + /// returning the new identifier address. Multisig identifiers are + /// created through `generate-multisig`, which supplies the required + /// arguments. Requires a signer. + generate-identifier: func(kind: identifier-kind) -> result; /// Build (without publishing) a swap offer sending `send-amount` of /// `send-token` to `counterparty` against `receive-amount` of - /// `receive-token`; returns the offer block (hex) for the counterparty - /// to accept. Requires a signer. - create-swap: func(counterparty: string, send-token: string, send-amount: string, receive-token: string, receive-amount: string, receive-exact: bool) -> result; - /// Accept a maker's swap `offer` (block hex), optionally validating its - /// legs against `expected`; returns the taker's and maker's blocks (hex) - /// to transmit together. Requires a signer. - accept-swap: func(offer: string, expected: option) -> result, coded-error>; - /// Transmit pre-signed `blocks` (hex) together as one staple so they - /// settle atomically; the bound signer pays the fee. Used by a swap - /// taker to settle both the taker's and maker's blocks at once. - transmit: func(blocks: list) -> result; + /// `receive-token`; returns the offer block for the counterparty to + /// accept. Requires a signer. + create-swap: func(counterparty: borrow, send-token: borrow, send-amount: amount, receive-token: borrow, receive-amount: amount, receive-exact: bool) -> result; + /// Accept a maker's swap `offer`, optionally validating its legs + /// against `expected`; returns the taker's and maker's blocks to + /// transmit together. Requires a signer. + accept-swap: func(offer: block-hex, expected: option) -> result, coded-error>; + /// Transmit pre-signed `blocks` together as one staple so they settle + /// atomically; the bound signer pays the fee. Used by a swap taker to + /// settle both the taker's and maker's blocks at once. + transmit: func(blocks: list) -> result; /// Add the certificate `certificate` (DER as hex) with `intermediates` /// (DER hex) to the operating account. Requires a signer. add-certificate: func(certificate: string, intermediates: list) -> result; - /// Remove the certificate identified by `hash` (hex) from the operating + /// Remove the certificate identified by `hash` from the operating /// account. Requires a signer. - remove-certificate: func(hash: string) -> result; + remove-certificate: func(hash: certificate-hash) -> result; /// Begin a multi-operation transaction on the operating account: stage /// several operations, then seal them into one atomic block. Requires a /// signer. @@ -351,17 +486,17 @@ interface node { /// A staged, signer-bound transaction: accumulate operations on the /// operating account, then seal them into one atomic block via `commit`. resource transaction { - /// Stage a send of `amount` (decimal) of `token` to `to`. - send: func(to: string, token: string, amount: string) -> result<_, coded-error>; + /// Stage a send of `amount` of `token` to `to`. + send: func(to: borrow, token: borrow, amount: amount) -> result<_, coded-error>; /// Stage a send carrying `external` reference data (never aggregated). - send-external: func(to: string, token: string, amount: string, external: string) -> result<_, coded-error>; + send-external: func(to: borrow, token: borrow, amount: amount, external: string) -> result<_, coded-error>; /// Stage a representative change. - set-rep: func(rep: string) -> result<_, coded-error>; + set-rep: func(rep: borrow) -> result<_, coded-error>; /// Stage an account-info change. set-info: func(name: option, description: option, metadata: option) -> result<_, coded-error>; /// Build, sign, and publish all staged operations as one atomic - /// transaction over `wasi:http`; returns the published block(s) as hex. - commit: func() -> result, coded-error>; + /// transaction over `wasi:http`; returns the published block(s). + commit: func() -> result, coded-error>; } /// A low-level, block assembler: stage operations on an operating @@ -369,12 +504,12 @@ interface node { /// transport block (hex). The signed block settles via /// `user-client.transmit`. resource block-builder { - /// Start a block for operating `account` (address) on `network`. - new: static func(network: u64, account: string) -> result; + /// Start a block for operating `account` on `network`. + new: static func(network: network-id, account: borrow) -> result; /// Set the block version (1 or 2). version: func(version: u32) -> result<_, coded-error>; - /// Chain onto `previous` (block hash hex). - previous: func(previous: string) -> result<_, coded-error>; + /// Chain onto `previous`. + previous: func(previous: block-hash) -> result<_, coded-error>; /// Mark this as the account's opening block (no previous). opening: func() -> result<_, coded-error>; /// Set the block timestamp (Unix milliseconds). Required: the component @@ -382,22 +517,22 @@ interface node { date: func(unix-millis: s64) -> result<_, coded-error>; /// Sign with the single account `signer`. signer-single: func(signer: borrow) -> result<_, coded-error>; - /// Sign with `multisig` (address) using the member accounts `members` + /// Sign with `multisig` using the member accounts `members` /// (a quorum subset is allowed). - signer-multisig: func(multisig: string, members: list>) -> result<_, coded-error>; + signer-multisig: func(multisig: borrow, members: list>) -> result<_, coded-error>; /// Stage a CREATE_IDENTIFIER for a multisig requiring `quorum` of - /// `signers` (addresses). - op-create-multisig: func(multisig: string, signers: list, quorum: u32) -> result<_, coded-error>; + /// `signers`. + op-create-multisig: func(multisig: borrow, signers: list>, quorum: u32) -> result<_, coded-error>; /// Stage a MODIFY_PERMISSIONS for `principal` granting/clearing - /// `permissions` (flag names), optionally scoped to `target`. - op-modify-permissions: func(principal: string, permissions: list, method: adjust-method, target: option) -> result<_, coded-error>; - /// Stage a SET_INFO; an empty `default-permission` leaves it unset. - op-set-info: func(name: string, description: string, metadata: string, default-permission: list) -> result<_, coded-error>; - /// Stage a SET_REP delegating voting weight to `rep` (address). - op-set-rep: func(rep: string) -> result<_, coded-error>; - /// Build and sign the staged block, returning it as transport hex. + /// `permissions`, optionally scoped to `target`. + op-modify-permissions: func(principal: borrow, permissions: base-permission, method: adjust-method, target: option>) -> result<_, coded-error>; + /// Stage a SET_INFO; an absent `default-permission` leaves it unset. + op-set-info: func(name: string, description: string, metadata: string, default-permission: option) -> result<_, coded-error>; + /// Stage a SET_REP delegating voting weight to `rep`. + op-set-rep: func(rep: borrow) -> result<_, coded-error>; + /// Build and sign the staged block, returning its transport encoding. /// Consumes the builder. - build-and-sign: func() -> result; + build-and-sign: func() -> result; } } diff --git a/keetanetwork-client-wasm/src/client.rs b/keetanetwork-client-wasm/src/client.rs index 278ecb3..1027abe 100644 --- a/keetanetwork-client-wasm/src/client.rs +++ b/keetanetwork-client-wasm/src/client.rs @@ -4,6 +4,8 @@ use alloc::string::String; use alloc::vec::Vec; use core::str::FromStr; +use keetanetwork_account::GenericAccount; +use keetanetwork_block::AccountRef; use keetanetwork_client::{ChainQuery, HistoryQuery, KeetaClient as Core, Network}; use num_bigint::BigInt; use wasm_bindgen::prelude::wasm_bindgen; @@ -11,7 +13,10 @@ use wasm_bindgen::prelude::wasm_bindgen; use crate::account::Account; use crate::block::{Block, VoteStaple}; use crate::builder::Builder; -use crate::convert::{amount_to_string, client_error, coded_error, parse_amount, parse_ledger_side, JsResult}; +use crate::convert::{ + amount_to_string, client_error, coded_error, parse_amount, parse_block_hash, parse_hash32, parse_history_cursor, + parse_ledger_side, parse_moment, JsResult, +}; use crate::dto::{ AccountStateView, AclView, CertificateView, HistoryEntryView, LedgerChecksumView, RepresentativeView, TokenBalanceView, @@ -77,7 +82,7 @@ impl KeetaClient { pub async fn balance(&self, account: &Account, token: &Account) -> JsResult { let amount = self .inner - .balance(account.address(), token.address()) + .balance(&*account.inner(), &*token.inner()) .await .map_err(client_error)?; Ok(amount_to_string(amount)) @@ -87,7 +92,7 @@ impl KeetaClient { pub async fn balances(&self, account: &Account) -> JsResult> { let balances = self .inner - .balances(account.address()) + .balances(&*account.inner()) .await .map_err(client_error)?; Ok(balances.iter().map(TokenBalanceView::from).collect()) @@ -97,15 +102,16 @@ impl KeetaClient { pub async fn state(&self, account: &Account) -> JsResult { let state = self .inner - .state(account.address()) + .state(&*account.inner()) .await .map_err(client_error)?; Ok(AccountStateView::from(&state)) } /// Snapshots of several accounts' ledger state, in input order. - pub async fn states(&self, accounts: Vec) -> JsResult> { - let refs: Vec<&str> = accounts.iter().map(String::as_str).collect(); + pub async fn states(&self, accounts: Vec) -> JsResult> { + let inners: Vec = accounts.iter().map(Account::inner).collect(); + let refs: Vec<&GenericAccount> = inners.iter().map(|account| &**account).collect(); let states = self.inner.states(&refs).await.map_err(client_error)?; Ok(states.iter().map(AccountStateView::from).collect()) } @@ -115,7 +121,7 @@ impl KeetaClient { pub async fn token_supply(&self, token: &Account) -> JsResult> { let supply = self .inner - .token_supply(token.address()) + .token_supply(&*token.inner()) .await .map_err(client_error)?; Ok(supply.map(amount_to_string)) @@ -126,7 +132,7 @@ impl KeetaClient { pub async fn head_block(&self, account: &Account) -> JsResult> { let head = self .inner - .head_block(account.address()) + .head_block(&*account.inner()) .await .map_err(client_error)?; Ok(head.map(Block::from)) @@ -137,7 +143,7 @@ impl KeetaClient { pub async fn account_head_info(&self, account: &Account) -> JsResult> { let info = self .inner - .account_head_info(account.address()) + .account_head_info(&*account.inner()) .await .map_err(client_error)?; Ok(info.map(|(block, balance)| AccountHead { block: Block::from(block), balance: amount_to_string(balance) })) @@ -148,7 +154,7 @@ impl KeetaClient { pub async fn pending_block(&self, account: &Account) -> JsResult> { let pending = self .inner - .pending_block(account.address()) + .pending_block(&*account.inner()) .await .map_err(client_error)?; Ok(pending.map(Block::from)) @@ -159,6 +165,7 @@ impl KeetaClient { /// used when omitted. pub async fn block(&self, block_hash: String, side: Option) -> JsResult> { let side = parse_ledger_side(side)?; + let block_hash = parse_block_hash(&block_hash)?; let block = self .inner .block(block_hash, side) @@ -170,6 +177,7 @@ impl KeetaClient { /// The block that chains directly after `block_hash`, if any. #[wasm_bindgen(js_name = successorBlock)] pub async fn successor_block(&self, block_hash: String) -> JsResult> { + let block_hash = parse_block_hash(&block_hash)?; let block = self .inner .successor_block(block_hash) @@ -178,12 +186,19 @@ impl KeetaClient { Ok(block.map(Block::from)) } - /// The block carrying idempotency `key` on `account`, if any. + /// The block carrying idempotency `key` on `account`, if any. `side` + /// selects the ledger to search (`"main"`, `"side"`, or `"both"`). #[wasm_bindgen(js_name = blockByIdempotent)] - pub async fn block_by_idempotent(&self, account: &Account, key: String) -> JsResult> { + pub async fn block_by_idempotent( + &self, + account: &Account, + key: String, + side: Option, + ) -> JsResult> { + let side = parse_ledger_side(side)?; let block = self .inner - .block_by_idempotent(account.address(), key) + .block_by_idempotent(&*account.inner(), key, side) .await .map_err(client_error)?; Ok(block.map(Block::from)) @@ -192,6 +207,7 @@ impl KeetaClient { /// The verified vote staple committing the block with hash `block_hash`. #[wasm_bindgen(js_name = voteStaple)] pub async fn vote_staple(&self, block_hash: String) -> JsResult> { + let block_hash = parse_block_hash(&block_hash)?; let staple = self .inner .vote_staple(block_hash) @@ -204,7 +220,7 @@ impl KeetaClient { pub async fn chain(&self, account: &Account) -> JsResult> { let blocks = self .inner - .chain(account.address()) + .chain(&*account.inner()) .await .map_err(client_error)?; Ok(blocks.into_iter().map(Block::from).collect()) @@ -220,12 +236,15 @@ impl KeetaClient { end: Option, limit: Option, ) -> JsResult> { + let start = start.as_deref().map(parse_block_hash).transpose()?; + let end = end.as_deref().map(parse_block_hash).transpose()?; let query = ChainQuery { start, end, limit: limit.map(i64::from) }; let blocks = self .inner - .chain_page(account.address(), query) + .chain_page(&*account.inner(), query) .await .map_err(client_error)?; + Ok(blocks.into_iter().map(Block::from).collect()) } @@ -235,7 +254,7 @@ impl KeetaClient { pub async fn chain_all(&self, account: &Account, page_limit: u32) -> JsResult> { let blocks = self .inner - .chain_all(account.address(), page_limit) + .chain_all(&*account.inner(), page_limit) .await .map_err(client_error)?; Ok(blocks.into_iter().map(Block::from).collect()) @@ -245,7 +264,7 @@ impl KeetaClient { pub async fn history(&self, account: &Account) -> JsResult> { let entries = self .inner - .history(account.address()) + .history(&*account.inner()) .await .map_err(client_error)?; Ok(entries.iter().map(HistoryEntryView::from).collect()) @@ -259,10 +278,24 @@ impl KeetaClient { start: Option, limit: Option, ) -> JsResult> { + let start = start.as_deref().map(parse_history_cursor).transpose()?; let query = HistoryQuery { start, limit: limit.map(i64::from) }; let entries = self .inner - .history_page(account.address(), query) + .history_page(&*account.inner(), query) + .await + .map_err(client_error)?; + + Ok(entries.iter().map(HistoryEntryView::from).collect()) + } + + /// Every entry in `account`'s history, fetched by following the node's + /// cursor with `pageLimit` per request. + #[wasm_bindgen(js_name = historyAll)] + pub async fn history_all(&self, account: &Account, page_limit: u32) -> JsResult> { + let entries = self + .inner + .history_all(&*account.inner(), page_limit) .await .map_err(client_error)?; Ok(entries.iter().map(HistoryEntryView::from).collect()) @@ -282,18 +315,33 @@ impl KeetaClient { start: Option, limit: Option, ) -> JsResult> { + let start = start.as_deref().map(parse_history_cursor).transpose()?; let query = HistoryQuery { start, limit: limit.map(i64::from) }; let entries = self .inner .global_history_page(query) .await .map_err(client_error)?; + + Ok(entries.iter().map(HistoryEntryView::from).collect()) + } + + /// Every entry in the node's global history, fetched by following the + /// node's cursor with `pageLimit` per request. + #[wasm_bindgen(js_name = globalHistoryAll)] + pub async fn global_history_all(&self, page_limit: u32) -> JsResult> { + let entries = self + .inner + .global_history_all(page_limit) + .await + .map_err(client_error)?; Ok(entries.iter().map(HistoryEntryView::from).collect()) } /// Vote staples committed at or after the ISO 8601 `start` moment. #[wasm_bindgen(js_name = voteStaplesAfter)] pub async fn vote_staples_after(&self, start: String) -> JsResult> { + let start = parse_moment(&start)?; let staples = self .inner .vote_staples_after(start) @@ -306,6 +354,7 @@ impl KeetaClient { /// `limit`. #[wasm_bindgen(js_name = voteStaplesAfterPage)] pub async fn vote_staples_after_page(&self, start: String, limit: Option) -> JsResult> { + let start = parse_moment(&start)?; let staples = self .inner .vote_staples_after_page(start, limit.map(i64::from)) @@ -329,7 +378,7 @@ impl KeetaClient { pub async fn representative(&self, rep: &Account) -> JsResult { let rep = self .inner - .representative(rep.address()) + .representative(&*rep.inner()) .await .map_err(client_error)?; Ok(RepresentativeView::from(&rep)) @@ -353,7 +402,7 @@ impl KeetaClient { pub async fn acls_by_principal(&self, account: &Account) -> JsResult> { let acls = self .inner - .acls_by_principal(account.address()) + .acls_by_principal(&*account.inner()) .await .map_err(client_error)?; Ok(acls.iter().map(AclView::from).collect()) @@ -364,7 +413,7 @@ impl KeetaClient { pub async fn acls_by_entity(&self, account: &Account) -> JsResult> { let acls = self .inner - .acls_by_entity(account.address()) + .acls_by_entity(&*account.inner()) .await .map_err(client_error)?; Ok(acls.iter().map(AclView::from).collect()) @@ -374,7 +423,7 @@ impl KeetaClient { pub async fn certificates(&self, account: &Account) -> JsResult> { let certificates = self .inner - .certificates(account.address()) + .certificates(&*account.inner()) .await .map_err(client_error)?; Ok(certificates.iter().map(CertificateView::from).collect()) @@ -382,9 +431,10 @@ impl KeetaClient { /// The certificate of `account` identified by `hash`, if present. pub async fn certificate(&self, account: &Account, hash: String) -> JsResult> { + let hash = parse_hash32(&hash, "certificate hash")?; let certificate = self .inner - .certificate(account.address(), hash) + .certificate(&*account.inner(), hash) .await .map_err(client_error)?; Ok(certificate.as_ref().map(CertificateView::from)) diff --git a/keetanetwork-client-wasm/src/convert.rs b/keetanetwork-client-wasm/src/convert.rs index a49adc3..f791df3 100644 --- a/keetanetwork-client-wasm/src/convert.rs +++ b/keetanetwork-client-wasm/src/convert.rs @@ -6,8 +6,8 @@ use keetanetwork_account::KeyPairType; use keetanetwork_bindings::client as bindings_client; use keetanetwork_bindings::error::CodedError; use keetanetwork_bindings::parse; -use keetanetwork_block::{AdjustMethod, Amount, BaseFlag, BlockPurpose}; -use keetanetwork_client::{ClientError, LedgerSide}; +use keetanetwork_block::{AdjustMethod, Amount, BaseFlag, BlockHash, BlockPurpose, BlockTime}; +use keetanetwork_client::{ClientError, LedgerSide, VoteBlockHash}; use num_bigint::BigInt; use wasm_bindgen::JsValue; @@ -26,6 +26,21 @@ pub fn parse_hash32(value: &str, label: &str) -> JsResult<[u8; 32]> { parse::hash32(value, label).map_err(coded) } +/// Parse a hex block hash into a [`BlockHash`]. +pub fn parse_block_hash(value: &str) -> JsResult { + Ok(BlockHash::from(parse_hash32(value, "block hash")?)) +} + +/// Parse a hex history cursor (staple id) into a [`VoteBlockHash`]. +pub fn parse_history_cursor(value: &str) -> JsResult { + Ok(VoteBlockHash::from(parse_hash32(value, "history cursor")?)) +} + +/// Parse an ISO 8601 moment into a [`BlockTime`]. +pub fn parse_moment(value: &str) -> JsResult { + parse::moment(value).map_err(coded) +} + /// Parse a supply/balance/permission adjustment method. pub fn parse_adjust_method(method: &str) -> JsResult { parse::adjust_method(method).map_err(rejected) diff --git a/keetanetwork-client-wasm/src/dto.rs b/keetanetwork-client-wasm/src/dto.rs index 63a3164..e7feb92 100644 --- a/keetanetwork-client-wasm/src/dto.rs +++ b/keetanetwork-client-wasm/src/dto.rs @@ -5,7 +5,8 @@ use alloc::string::String; use alloc::vec::Vec; use keetanetwork_client::{ - AccountInfo, AccountState, Acl, Certificate, HistoryEntry, LedgerChecksum, Representative, TokenBalance, + AccountInfo, AccountState, Acl, BlockEffects, Certificate, HistoryEntry, LedgerChecksum, Representative, + TokenBalance, }; use serde::Serialize; use tsify::Tsify; @@ -57,7 +58,7 @@ impl From<&AccountState> for AccountStateView { fn from(state: &AccountState) -> Self { Self { representative: state.representative.clone(), - head: state.head.clone(), + head: state.head.map(|head| head.to_string()), height: state.height.clone().map(amount_to_string), info: state.info.as_ref().map(AccountInfoView::from), supply: state.supply.clone().map(amount_to_string), @@ -66,6 +67,30 @@ impl From<&AccountState> for AccountStateView { } } +/// One staple block (hex) with the indexes of its operations that involve +/// the filtered account, in block order. +#[derive(Serialize, Tsify)] +#[tsify(into_wasm_abi)] +pub struct BlockEffectsView { + pub block: String, + pub operation_indexes: Vec, +} + +impl From<&BlockEffects> for BlockEffectsView { + fn from(effects: &BlockEffects) -> Self { + Self { block: hex::encode(effects.block.to_bytes()), operation_indexes: effects.operation_indexes.clone() } + } +} + +/// The per-staple effects of a filter: the staple id with the matching +/// operations of each of its blocks. +#[derive(Serialize, Tsify)] +#[tsify(into_wasm_abi)] +pub struct StapleEffectsView { + pub id: String, + pub blocks: Vec, +} + /// A history entry: the staple as hex plus its id and timestamp. #[derive(Serialize, Tsify)] #[tsify(into_wasm_abi)] @@ -77,7 +102,11 @@ pub struct HistoryEntryView { impl From<&HistoryEntry> for HistoryEntryView { fn from(entry: &HistoryEntry) -> Self { - Self { staple: hex::encode(entry.staple.as_bytes()), id: entry.id.clone(), timestamp: entry.timestamp.clone() } + Self { + staple: hex::encode(entry.staple.as_bytes()), + id: entry.id.map(|id| id.to_string()), + timestamp: entry.timestamp.map(|moment| moment.to_string()), + } } } diff --git a/keetanetwork-client-wasm/src/user.rs b/keetanetwork-client-wasm/src/user.rs index 6087a45..fbea9b5 100644 --- a/keetanetwork-client-wasm/src/user.rs +++ b/keetanetwork-client-wasm/src/user.rs @@ -1,14 +1,15 @@ //! JS `UserClient`: a signer-bound facade over [`KeetaClient`](crate::client). -use alloc::string::String; +use alloc::collections::BTreeMap; +use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::str::FromStr; use keetanetwork_account::KeyPairType; use keetanetwork_block::{IdentifierCreateArguments, MultisigCreateArguments, SetInfo}; use keetanetwork_client::{ - AcceptSwapRequest, ChainQuery, CreateSwapRequest, HistoryQuery, KeetaClient as Core, Network, - UserClient as CoreUser, + AcceptSwapRequest, BlockEffects, ChainQuery, CreateSwapRequest, HistoryQuery, InitializeNetwork, + KeetaClient as Core, Network, UserClient as CoreUser, VoteBlockHash, }; use num_bigint::BigInt; use wasm_bindgen::prelude::wasm_bindgen; @@ -19,10 +20,12 @@ use crate::builder::Builder; use crate::certificate::ManageCertificate; use crate::client::KeetaClient; use crate::convert::{ - amount_to_string, client_error, parse_adjust_method, parse_amount, parse_identifier_type, parse_ledger_side, - JsResult, + amount_to_string, client_error, parse_adjust_method, parse_amount, parse_block_hash, parse_hash32, + parse_history_cursor, parse_identifier_type, parse_ledger_side, JsResult, +}; +use crate::dto::{ + AccountStateView, AclView, BlockEffectsView, CertificateView, HistoryEntryView, StapleEffectsView, TokenBalanceView, }; -use crate::dto::{AccountStateView, AclView, CertificateView, HistoryEntryView, TokenBalanceView}; use crate::options::TransmitOptions; use crate::permissions::{PermissionChange, Permissions}; use crate::swap::SwapExpectation; @@ -116,7 +119,7 @@ impl UserClient { pub async fn balance(&self, token: &Account) -> JsResult { let amount = self .inner - .balance(token.address()) + .balance(&*token.inner()) .await .map_err(client_error)?; Ok(amount_to_string(amount)) @@ -156,6 +159,8 @@ impl UserClient { end: Option, limit: Option, ) -> JsResult> { + let start = start.as_deref().map(parse_block_hash).transpose()?; + let end = end.as_deref().map(parse_block_hash).transpose()?; let query = ChainQuery { start, end, limit: limit.map(i64::from) }; let blocks = self.inner.chain_page(query).await.map_err(client_error)?; Ok(blocks.into_iter().map(Block::from).collect()) @@ -183,11 +188,25 @@ impl UserClient { /// and `limit`. #[wasm_bindgen(js_name = historyPage)] pub async fn history_page(&self, start: Option, limit: Option) -> JsResult> { + let start = start.as_deref().map(parse_history_cursor).transpose()?; let query = HistoryQuery { start, limit: limit.map(i64::from) }; + let entries = self.inner.history_page(query).await.map_err(client_error)?; Ok(entries.iter().map(HistoryEntryView::from).collect()) } + /// Every entry in the operating account's history, fetched by following + /// the node's cursor with `pageLimit` per request. + #[wasm_bindgen(js_name = historyAll)] + pub async fn history_all(&self, page_limit: u32) -> JsResult> { + let entries = self + .inner + .history_all(page_limit) + .await + .map_err(client_error)?; + Ok(entries.iter().map(HistoryEntryView::from).collect()) + } + /// The next pending block for the operating account, if any. #[wasm_bindgen(js_name = pendingBlock)] pub async fn pending_block(&self) -> JsResult> { @@ -216,31 +235,36 @@ impl UserClient { /// A single certificate on the operating account by its `hash`, if present. pub async fn certificate(&self, hash: String) -> JsResult> { + let hash = parse_hash32(&hash, "certificate hash")?; let certificate = self.inner.certificate(hash).await.map_err(client_error)?; Ok(certificate.as_ref().map(CertificateView::from)) } /// The block with hash `block_hash`, if the node has it. `side` selects the - /// ledger to read (`"main"`, `"side"`, or `"both"`); the main ledger is - /// used when omitted. + /// ledger to read. pub async fn block(&self, block_hash: String, side: Option) -> JsResult> { let side = parse_ledger_side(side)?; + let block_hash = parse_block_hash(&block_hash)?; let block = self .inner .block(block_hash, side) .await .map_err(client_error)?; + Ok(block.map(Block::from)) } /// The block carrying idempotency `key` on the operating account, if any. + /// `side` selects the ledger to search. #[wasm_bindgen(js_name = blockFromIdempotent)] - pub async fn block_from_idempotent(&self, key: String) -> JsResult> { + pub async fn block_from_idempotent(&self, key: String, side: Option) -> JsResult> { + let side = parse_ledger_side(side)?; let block = self .inner - .block_from_idempotent(key) + .block_from_idempotent(key, side) .await .map_err(client_error)?; + Ok(block.map(Block::from)) } @@ -251,11 +275,33 @@ impl UserClient { Ok(quotes.into_iter().map(VoteQuote::from).collect()) } + /// The operations in `staples` that involve `account`, keyed by staple id + /// and ordered as published: every operation of a block the account + /// produced, plus operations on other accounts' blocks that name it. + #[wasm_bindgen(js_name = filterStapleOperations)] + pub fn filter_staple_operations(staples: Vec, account: &Account) -> Vec { + let staples: Vec<_> = staples + .iter() + .map(|staple| staple.inner().clone()) + .collect(); + staple_effects_views(CoreUser::filter_staple_operations(&staples, &*account.inner())) + } + + /// [`filterStapleOperations`](Self::filter_staple_operations) over the + /// operating account. + #[wasm_bindgen(js_name = stapleEffects)] + pub fn staple_effects(&self, staples: Vec) -> JsResult> { + let staples: Vec<_> = staples + .iter() + .map(|staple| staple.inner().clone()) + .collect(); + let effects = self.inner.staple_effects(&staples).map_err(client_error)?; + Ok(staple_effects_views(effects)) + } + /// Recover the operating account's pending side block, optionally /// republishing. Returns the resulting staple, if any. `options` tunes the - /// fee paid for the recovery block (fee-token preference, pre-fetched - /// quotes); the bound signer pays the fee when none is set. Omit `options` - /// to recover with the bound signer as fee signer. + /// fee paid for the recovery block. pub async fn recover(&self, publish: bool, options: Option) -> JsResult> { let staple = match options { None => self.inner.recover(publish).await, @@ -392,6 +438,21 @@ impl UserClient { .map_err(client_error) } + /// Bootstrap a fresh network: mint `addSupplyAmount` of the base token to + /// the operating account and delegate its weight to `delegateTo`. + #[wasm_bindgen(js_name = initializeNetwork)] + pub async fn initialize_network(&self, add_supply_amount: String, delegate_to: Option) -> JsResult { + let options = InitializeNetwork { + add_supply_amount: parse_amount(&add_supply_amount)?, + delegate_to: delegate_to.map(|account| account.inner()), + ..InitializeNetwork::default() + }; + self.inner + .initialize_network(options) + .await + .map_err(client_error) + } + /// Create an identifier of `kind` (`"network"`, `"token"`, or `"storage"`) /// under the operating account, publish the creating block, and return the /// derived [`Account`]. @@ -446,6 +507,7 @@ impl UserClient { .create_swap_request(request) .await .map_err(client_error)?; + Ok(Block::from(block)) } @@ -460,6 +522,18 @@ impl UserClient { .accept_swap_request(request) .await .map_err(client_error)?; + Ok(blocks.into_iter().map(Block::from).collect()) } } + +/// Render a core effects map as serializable views, keyed order preserved. +fn staple_effects_views(effects: BTreeMap>) -> Vec { + effects + .into_iter() + .map(|(id, blocks)| StapleEffectsView { + id: id.to_string(), + blocks: blocks.iter().map(BlockEffectsView::from).collect(), + }) + .collect() +} diff --git a/keetanetwork-client/Cargo.toml b/keetanetwork-client/Cargo.toml index d470092..2409749 100644 --- a/keetanetwork-client/Cargo.toml +++ b/keetanetwork-client/Cargo.toml @@ -30,6 +30,7 @@ std = [ "keetanetwork-vote/std", "keetanetwork-error/std", "keetanetwork-account/std", + "keetanetwork-crypto/std", ] wasm = ["http"] wasi = ["codec"] @@ -38,9 +39,11 @@ testing = [] [dependencies] keetanetwork-block = { workspace = true, features = ["alloc", "rasn"] } keetanetwork-vote = { workspace = true, features = ["alloc", "rasn"] } +keetanetwork-crypto = { workspace = true, features = ["alloc"] } keetanetwork-error = { workspace = true, features = ["alloc"] } keetanetwork-account = { workspace = true, features = ["alloc", "rasn"] } +chrono = { workspace = true } progenitor-client = { workspace = true, optional = true } num-bigint = { workspace = true } rand_core = { workspace = true } @@ -69,6 +72,10 @@ optional = true workspace = true features = ["alloc"] +[dependencies.hex] +workspace = true +features = ["alloc"] + [dependencies.snafu] workspace = true diff --git a/keetanetwork-client/openapi/keetanet-node.yaml b/keetanetwork-client/openapi/keetanet-node.yaml index 2dd1131..f7268ed 100644 --- a/keetanetwork-client/openapi/keetanet-node.yaml +++ b/keetanetwork-client/openapi/keetanet-node.yaml @@ -1037,6 +1037,16 @@ paths: required: true schema: type: string + - name: side + in: query + required: false + schema: + type: string + enum: + - main + - side + - both + description: Which ledger storage to search (defaults to main). responses: '200': description: Block diff --git a/keetanetwork-client/src/builder.rs b/keetanetwork-client/src/builder.rs index 88b576d..19238bc 100644 --- a/keetanetwork-client/src/builder.rs +++ b/keetanetwork-client/src/builder.rs @@ -326,12 +326,12 @@ impl TransactionBuilder { /// seed (primary only), then the ledger head, then `None` (opening). async fn first_previous(&self, account: &AccountRef) -> Result, ClientError> { if let Some(previous) = self.initial_previous { - if account.to_string() == self.primary.to_string() { + if account == &self.primary { return Ok(Some(previous)); } } - match self.client.head_block(account.to_string()).await? { + match self.client.head_block(&**account).await? { Some(head) => Ok(Some(head.hash())), None => Ok(None), } diff --git a/keetanetwork-client/src/client.rs b/keetanetwork-client/src/client.rs index eaa3000..bc69346 100644 --- a/keetanetwork-client/src/client.rs +++ b/keetanetwork-client/src/client.rs @@ -7,16 +7,14 @@ use alloc::string::{String, ToString}; use alloc::sync::Arc; use alloc::vec::Vec; use core::future::Future; +use core::str::FromStr; use core::sync::atomic::{AtomicU64, Ordering}; use core::time::Duration; -#[cfg(feature = "std")] -use core::str::FromStr; - use futures::future::{select, Either}; use futures::pin_mut; use futures::stream::{FuturesUnordered, StreamExt}; -use keetanetwork_account::{Account, GenericAccount, KeyNETWORK, KeyPairType}; +use keetanetwork_account::{Account, AccountPublicKey, GenericAccount, KeyNETWORK, KeyPairType}; use keetanetwork_block::{ AccountRef, Amount, Block, BlockBuilder, BlockHash, BlockPurpose, BlockTime, Hashable, Operation, Send, }; @@ -30,8 +28,8 @@ use crate::config::ClientConfig; use crate::error::{AccountSnafu, BlockSnafu, ClientError, VoteSnafu}; use crate::math::{meets_quorum, most_common_hash, next_backoff, overlapping_moment}; use crate::model::{ - AccountState, Acl, Certificate, ChainPage, ChainQuery, HistoryEntry, HistoryQuery, LedgerChecksum, Representative, - TokenBalance, TransmitOptions, + AccountState, Acl, Certificate, ChainPage, ChainQuery, HistoryEntry, HistoryPage, HistoryQuery, LedgerChecksum, + Representative, TokenBalance, TransmitOptions, }; use crate::rep::{RepBook, RepPart, RepRecord, RepRef, SmallRng}; use crate::runtime::{Runtime, TaskHandle}; @@ -67,7 +65,7 @@ struct RepPick { /// an account, used to detect and reconcile divergence during a sync. struct RepHead { pick: RepPick, - head: Option<(String, Amount)>, + head: Option<(BlockHash, Amount)>, } /// Representatives' own votes for a pending successor block, sorted into @@ -84,18 +82,12 @@ struct RecoveredVotes { impl RecoveredVotes { /// The hashes of the blocks covered by a sample vote (permanent first, /// then temporary), or `None` when nothing was recovered. - fn block_hashes(&self) -> Option> { + fn block_hashes(&self) -> Option> { let sample = self .perm_votes .first() .or_else(|| self.temp_votes.first())?; - Some( - sample - .blocks() - .iter() - .map(|hash| hash.to_string()) - .collect(), - ) + Some(sample.blocks().to_vec()) } } @@ -323,7 +315,7 @@ impl KeetaClient { /// The current clock moment from the runtime, falling back to the /// epoch if the clock is out of [`BlockTime`]'s representable range. - fn now_moment(&self) -> BlockTime { + pub(crate) fn now_moment(&self) -> BlockTime { let millis = self.inner.runtime.unix_millis(); BlockTime::from_unix_millis(millis).unwrap_or_default() } @@ -898,18 +890,17 @@ impl KeetaClient { } let lowest_head = match &heads[0].head { - Some((hash, _)) => hash.clone(), - None => account.to_opening_hash().to_string(), + Some((hash, _)) => *hash, + None => account.to_opening_hash(), }; let highest_transport = Arc::clone(&heads[highest_index].pick.transport); - let successor = match highest_transport.successor_block(&lowest_head).await? { + let successor = match highest_transport.successor_block(lowest_head).await? { Some(block) => block, None => return Ok(None), }; - let successor_hash = successor.hash().to_string(); let staple = match self - .compose_staple_on(&highest_transport, &successor_hash) + .compose_staple_on(&highest_transport, successor.hash()) .await? { Some(staple) => staple, @@ -956,7 +947,7 @@ impl KeetaClient { ) -> Result, ClientError> { self.ensure_refresh(); - let successor = match self.pending_block(account.to_string()).await? { + let successor = match self.pending_block(&**account).await? { Some(block) => block, None => return Ok(None), }; @@ -966,11 +957,11 @@ impl KeetaClient { return Err(ClientError::NoRepresentatives); } - let successor_hash = successor.hash().to_string(); + let successor_hash = successor.hash(); let moment = self.now_moment(); let config = ValidationConfig::default(); let mut votes = self - .collect_recover_votes(&picks, &successor_hash, moment, config) + .collect_recover_votes(&picks, successor_hash, moment, config) .await; let block_hashes = match votes.block_hashes() { @@ -1002,7 +993,7 @@ impl KeetaClient { async fn collect_recover_votes( &self, picks: &[RepPick], - successor_hash: &str, + successor_hash: BlockHash, moment: BlockTime, config: ValidationConfig, ) -> RecoveredVotes { @@ -1026,12 +1017,12 @@ impl KeetaClient { async fn fetch_recover_blocks( &self, picks: &[RepPick], - block_hashes: &[String], + block_hashes: &[BlockHash], ) -> Result, ClientError> { let mut blocks = Vec::with_capacity(block_hashes.len()); for hash in block_hashes { let block = self - .first_block_on(picks, hash) + .first_block_on(picks, *hash) .await? .ok_or(ClientError::RecoverFailed)?; blocks.push(block); @@ -1041,7 +1032,7 @@ impl KeetaClient { } /// The first representative that holds `hash` on either ledger side. - async fn first_block_on(&self, picks: &[RepPick], hash: &str) -> Result, ClientError> { + async fn first_block_on(&self, picks: &[RepPick], hash: BlockHash) -> Result, ClientError> { for pick in picks { if let Some(block) = pick.transport.block(hash, Some(LedgerSide::Both)).await? { return Ok(Some(block)); @@ -1109,7 +1100,7 @@ impl KeetaClient { /// Fetch a representative's own vote for `hash`, preferring the main /// ledger (the rep already promoted the staple) and falling back to the /// side ledger (the staple is still pending). - async fn rep_recover_vote(&self, pick: &RepPick, hash: &str) -> Option { + async fn rep_recover_vote(&self, pick: &RepPick, hash: BlockHash) -> Option { for side in [LedgerSide::Main, LedgerSide::Side] { let list = match pick.transport.block_votes(hash, side).await { Ok(Some(list)) if !list.is_empty() => list, @@ -1169,7 +1160,7 @@ impl KeetaClient { async fn compose_staple_on( &self, transport: &Arc, - block_hash: &str, + block_hash: BlockHash, ) -> Result, ClientError> { let votes = match transport.block_votes(block_hash, LedgerSide::Main).await? { Some(list) if !list.is_empty() => list, @@ -1178,10 +1169,7 @@ impl KeetaClient { let mut blocks = Vec::new(); for hash in votes[0].blocks() { - match transport - .block(&hash.to_string(), Some(LedgerSide::Main)) - .await? - { + match transport.block(*hash, Some(LedgerSide::Main)).await? { Some(block) => blocks.push(block), None => return Ok(None), } @@ -1203,11 +1191,10 @@ impl KeetaClient { moment: BlockTime, priority: &[AccountRef], ) -> Result { - let signer_key = signer.to_string(); let previous = blocks .iter() .rev() - .find(|block| block.data().account().to_string() == signer_key) + .find(|block| block.data().account() == signer) .map(|block| block.hash()) .ok_or(ClientError::FeeRequired)?; @@ -1311,9 +1298,13 @@ impl KeetaClient { } /// The settled balance of `token` held by `account`. - pub async fn balance(&self, account: impl AsRef, token: impl AsRef) -> Result { - let account = account.as_ref().to_owned(); - let token = token.as_ref().to_owned(); + pub async fn balance( + &self, + account: impl AccountPublicKey, + token: impl AccountPublicKey, + ) -> Result { + let account = account.to_public_key_string().context(AccountSnafu)?; + let token = token.to_public_key_string().context(AccountSnafu)?; self.dispatch_any(move |t| { let account = account.clone(); @@ -1324,8 +1315,8 @@ impl KeetaClient { } /// Every token balance held by `account`. - pub async fn balances(&self, account: impl AsRef) -> Result, ClientError> { - let account = account.as_ref().to_owned(); + pub async fn balances(&self, account: impl AccountPublicKey) -> Result, ClientError> { + let account = account.to_public_key_string().context(AccountSnafu)?; self.dispatch_any(move |t| { let account = account.clone(); async move { t.balances(&account).await } @@ -1335,8 +1326,8 @@ impl KeetaClient { /// The full ledger state of `account`: representative, head, height, and /// balances. - pub async fn state(&self, account: impl AsRef) -> Result { - let account = account.as_ref().to_owned(); + pub async fn state(&self, account: impl AccountPublicKey) -> Result { + let account = account.to_public_key_string().context(AccountSnafu)?; self.dispatch_any(move |t| { let account = account.clone(); async move { t.account_state(&account).await } @@ -1346,14 +1337,14 @@ impl KeetaClient { /// The total supply of `token`, or `None` when the account reports no /// supply (it is not a token account). - pub async fn token_supply(&self, token: impl AsRef) -> Result, ClientError> { + pub async fn token_supply(&self, token: impl AccountPublicKey) -> Result, ClientError> { let state = self.state(token).await?; Ok(state.supply) } /// The head block of `account`, or `None` when the account has no blocks. - pub async fn head_block(&self, account: impl AsRef) -> Result, ClientError> { - let account = account.as_ref().to_owned(); + pub async fn head_block(&self, account: impl AccountPublicKey) -> Result, ClientError> { + let account = account.to_public_key_string().context(AccountSnafu)?; self.dispatch_any(move |t| { let account = account.clone(); async move { t.head_block(&account).await } @@ -1363,8 +1354,11 @@ impl KeetaClient { /// The head block of `account` paired with its height, or `None` when the /// account has no blocks. - pub async fn account_head_info(&self, account: impl AsRef) -> Result, ClientError> { - let account = account.as_ref().to_owned(); + pub async fn account_head_info( + &self, + account: impl AccountPublicKey, + ) -> Result, ClientError> { + let account = account.to_public_key_string().context(AccountSnafu)?; self.dispatch_any(move |t| { let account = account.clone(); async move { t.account_head_info(&account).await } @@ -1373,10 +1367,10 @@ impl KeetaClient { } /// The next pending (unreceived) block for `account`, if any. - pub async fn pending_block(&self, account: impl AsRef) -> Result, ClientError> { + pub async fn pending_block(&self, account: impl AccountPublicKey) -> Result, ClientError> { self.ensure_refresh(); - let account = account.as_ref().to_owned(); + let account = account.to_public_key_string().context(AccountSnafu)?; let picks = self.snapshot_picks(); if picks.is_empty() { return Err(ClientError::NoRepresentatives); @@ -1393,8 +1387,8 @@ impl KeetaClient { // Tally candidate blocks by hash so the block seen on the most reps // wins: reps may briefly disagree on the pending head, so majority // agreement is the safest single answer to return. - let mut blocks_by_hash: BTreeMap = BTreeMap::new(); - let mut observed: Vec = Vec::new(); + let mut blocks_by_hash: BTreeMap = BTreeMap::new(); + let mut observed: Vec = Vec::new(); let mut any_success = false; let mut last_error: Option = None; while let Some((key, result)) = requests.next().await { @@ -1404,8 +1398,8 @@ impl KeetaClient { any_success = true; - let hash = block.hash().to_string(); - blocks_by_hash.entry(hash.clone()).or_insert(block); + let hash = block.hash(); + blocks_by_hash.entry(hash).or_insert(block); observed.push(hash); } Ok(None) => { @@ -1435,10 +1429,9 @@ impl KeetaClient { /// The vote staple covering `blockhash` on the main ledger, assembled from /// the first representative that holds votes for it, or `None` when no /// representative has the staple. - pub async fn vote_staple(&self, blockhash: impl AsRef) -> Result, ClientError> { + pub async fn vote_staple(&self, blockhash: BlockHash) -> Result, ClientError> { self.ensure_refresh(); - let blockhash = blockhash.as_ref(); let picks = self.snapshot_picks(); if picks.is_empty() { return Err(ClientError::NoRepresentatives); @@ -1467,53 +1460,47 @@ impl KeetaClient { /// The block identified by `blockhash`, if the node has it. `side` selects /// the ledger to read (`None` defaults to the main ledger). - pub async fn block( - &self, - blockhash: impl AsRef, - side: Option, - ) -> Result, ClientError> { - let blockhash = blockhash.as_ref().to_owned(); - self.dispatch_any(move |t| { - let blockhash = blockhash.clone(); - async move { t.block(&blockhash, side).await } - }) - .await + pub async fn block(&self, blockhash: BlockHash, side: Option) -> Result, ClientError> { + self.dispatch_any(move |t| async move { t.block(blockhash, side).await }) + .await } /// The block following `blockhash`, if one exists. - pub async fn successor_block(&self, blockhash: impl AsRef) -> Result, ClientError> { - let blockhash = blockhash.as_ref().to_owned(); - self.dispatch_any(move |t| { - let blockhash = blockhash.clone(); - async move { t.successor_block(&blockhash).await } - }) - .await + pub async fn successor_block(&self, blockhash: BlockHash) -> Result, ClientError> { + self.dispatch_any(move |t| async move { t.successor_block(blockhash).await }) + .await } - /// The block produced by `account` for the given idempotent `key`, if any. + /// The block produced by `account` for the given idempotent `key`, if + /// any, searching the given `side` (`None` defaults to the main ledger). pub async fn block_by_idempotent( &self, - account: impl AsRef, + account: impl AccountPublicKey, key: impl AsRef, + side: Option, ) -> Result, ClientError> { - let account = account.as_ref().to_owned(); + let account = account.to_public_key_string().context(AccountSnafu)?; let key = key.as_ref().to_owned(); self.dispatch_any(move |t| { let account = account.clone(); let key = key.clone(); - async move { t.block_by_idempotent(&account, &key).await } + async move { t.block_by_idempotent(&account, &key, side).await } }) .await } /// A prefix of `account`'s block chain, most recent first. - pub async fn chain(&self, account: impl AsRef) -> Result, ClientError> { + pub async fn chain(&self, account: impl AccountPublicKey) -> Result, ClientError> { self.chain_page(account, ChainQuery::default()).await } /// A single page of `account`'s block chain (most recent first), bounded /// by `query`. - pub async fn chain_page(&self, account: impl AsRef, query: ChainQuery) -> Result, ClientError> { + pub async fn chain_page( + &self, + account: impl AccountPublicKey, + query: ChainQuery, + ) -> Result, ClientError> { Ok(self.chain_page_cursor(account, query).await?.blocks) } @@ -1521,13 +1508,18 @@ impl KeetaClient { /// cursor for the following page. pub async fn chain_page_cursor( &self, - account: impl AsRef, + account: impl AccountPublicKey, query: ChainQuery, ) -> Result { - let account = account.as_ref().to_owned(); + let account = account.to_public_key_string().context(AccountSnafu)?; + self.chain_page_cursor_raw(account, query).await + } + + /// [`Self::chain_page_cursor`] over a pre-rendered account address, so + /// cursor loops render the address once. + async fn chain_page_cursor_raw(&self, account: String, query: ChainQuery) -> Result { self.dispatch_any(move |t| { let account = account.clone(); - let query = query.clone(); async move { t.chain_page(&account, &query).await } }) .await @@ -1536,48 +1528,78 @@ impl KeetaClient { /// Every block in `account`'s chain (most recent first), fetched by /// following the node's `next_key` cursor with `page_limit` per request /// until the cursor is exhausted. - pub async fn chain_all(&self, account: impl AsRef, page_limit: u32) -> Result, ClientError> { - let account = account.as_ref(); + pub async fn chain_all(&self, account: impl AccountPublicKey, page_limit: u32) -> Result, ClientError> { + let account = account.to_public_key_string().context(AccountSnafu)?; let limit = i64::from(page_limit.max(1)); - let mut blocks = Vec::new(); - let mut cursor: Option = None; - - loop { - let query = ChainQuery { start: cursor.clone(), end: None, limit: Some(limit) }; - let page = self.chain_page_cursor(account, query).await?; - - blocks.extend(page.blocks); - match page.next_key { - Some(next) => cursor = Some(next), - None => break, + drain_cursor_pages(|cursor| { + let account = account.clone(); + async move { + let query = ChainQuery { start: cursor, end: None, limit: Some(limit) }; + let page = self.chain_page_cursor_raw(account, query).await?; + Ok((page.blocks, page.next_key)) } - } - - Ok(blocks) + }) + .await } /// `account`'s transaction history as verified vote staples. - pub async fn history(&self, account: impl AsRef) -> Result, ClientError> { + pub async fn history(&self, account: impl AccountPublicKey) -> Result, ClientError> { self.history_page(account, HistoryQuery::default()).await } /// A single page of `account`'s history, bounded by `query`. pub async fn history_page( &self, - account: impl AsRef, + account: impl AccountPublicKey, query: HistoryQuery, ) -> Result, ClientError> { - let account = account.as_ref().to_owned(); + Ok(self.history_page_cursor(account, query).await?.entries) + } + + /// A single page of `account`'s history together with the node's + /// `next_key` cursor for the following page. + pub async fn history_page_cursor( + &self, + account: impl AccountPublicKey, + query: HistoryQuery, + ) -> Result { + let account = account.to_public_key_string().context(AccountSnafu)?; + self.history_page_cursor_raw(account, query).await + } + + /// [`Self::history_page_cursor`] over a pre-rendered account address, so + /// cursor loops render the address once. + async fn history_page_cursor_raw(&self, account: String, query: HistoryQuery) -> Result { self.dispatch_any(move |t| { let account = account.clone(); - let query = query.clone(); - async move { t.history_page(&account, &query).await } }) .await } + /// Every entry in `account`'s history, fetched by following the node's + /// `next_key` cursor with `page_limit` per request until the cursor is + /// exhausted. + pub async fn history_all( + &self, + account: impl AccountPublicKey, + page_limit: u32, + ) -> Result, ClientError> { + let account = account.to_public_key_string().context(AccountSnafu)?; + let limit = i64::from(page_limit.max(1)); + + drain_cursor_pages(|cursor| { + let account = account.clone(); + async move { + let query = HistoryQuery { start: cursor, limit: Some(limit) }; + let page = self.history_page_cursor_raw(account, query).await?; + Ok((page.entries, page.next_key)) + } + }) + .await + } + /// The node's global transaction history as verified vote staples. pub async fn global_history(&self) -> Result, ClientError> { self.global_history_page(HistoryQuery::default()).await @@ -1585,15 +1607,32 @@ impl KeetaClient { /// A single page of the node's global history, bounded by `query`. pub async fn global_history_page(&self, query: HistoryQuery) -> Result, ClientError> { - self.dispatch_any(move |t| { - let query = query.clone(); - async move { t.global_history_page(&query).await } + Ok(self.global_history_page_cursor(query).await?.entries) + } + + /// A single page of the node's global history together with the node's + /// `next_key` cursor for the following page. + pub async fn global_history_page_cursor(&self, query: HistoryQuery) -> Result { + self.dispatch_any(move |t| async move { t.global_history_page(&query).await }) + .await + } + + /// Every entry in the node's global history, fetched by following the + /// node's `next_key` cursor with `page_limit` per request until the + /// cursor is exhausted. + pub async fn global_history_all(&self, page_limit: u32) -> Result, ClientError> { + let limit = i64::from(page_limit.max(1)); + + drain_cursor_pages(|cursor| async move { + let query = HistoryQuery { start: cursor, limit: Some(limit) }; + let page = self.global_history_page_cursor(query).await?; + Ok((page.entries, page.next_key)) }) .await } - /// Vote staples committed at or after the ISO 8601 `start` moment. - pub async fn vote_staples_after(&self, start: impl AsRef) -> Result, ClientError> { + /// Vote staples committed at or after the `start` moment. + pub async fn vote_staples_after(&self, start: BlockTime) -> Result, ClientError> { self.vote_staples_after_page(start, None).await } @@ -1601,15 +1640,11 @@ impl KeetaClient { /// `limit`. pub async fn vote_staples_after_page( &self, - start: impl AsRef, + start: BlockTime, limit: Option, ) -> Result, ClientError> { - let start = start.as_ref().to_owned(); - self.dispatch_any(move |t| { - let start = start.clone(); - async move { t.vote_staples_after(&start, limit).await } - }) - .await + self.dispatch_any(move |t| async move { t.vote_staples_after(start, limit).await }) + .await } /// The node's own representative and its weight. @@ -1619,8 +1654,8 @@ impl KeetaClient { } /// The weight of representative `rep`. - pub async fn representative(&self, rep: impl AsRef) -> Result { - let rep = rep.as_ref().to_owned(); + pub async fn representative(&self, rep: impl AccountPublicKey) -> Result { + let rep = rep.to_public_key_string().context(AccountSnafu)?; self.dispatch_any(move |t| { let rep = rep.clone(); async move { t.representative(&rep).await } @@ -1641,8 +1676,8 @@ impl KeetaClient { } /// ACL entries where `account` is the principal (grantee). - pub async fn acls_by_principal(&self, account: impl AsRef) -> Result, ClientError> { - let account = account.as_ref().to_owned(); + pub async fn acls_by_principal(&self, account: impl AccountPublicKey) -> Result, ClientError> { + let account = account.to_public_key_string().context(AccountSnafu)?; self.dispatch_any(move |t| { let account = account.clone(); async move { t.acls_by_principal(&account).await } @@ -1651,8 +1686,8 @@ impl KeetaClient { } /// ACL entries granted to `account` as an entity. - pub async fn acls_by_entity(&self, account: impl AsRef) -> Result, ClientError> { - let account = account.as_ref().to_owned(); + pub async fn acls_by_entity(&self, account: impl AccountPublicKey) -> Result, ClientError> { + let account = account.to_public_key_string().context(AccountSnafu)?; self.dispatch_any(move |t| { let account = account.clone(); async move { t.acls_by_entity(&account).await } @@ -1665,9 +1700,9 @@ impl KeetaClient { #[cfg(feature = "std")] pub async fn acls_by_principal_with_info( &self, - account: impl AsRef, + account: impl AccountPublicKey, ) -> Result { - let account = account.as_ref().to_owned(); + let account = account.to_public_key_string().context(AccountSnafu)?; self.dispatch_any(move |t| { let account = account.clone(); async move { t.acls_by_principal_with_info(&account).await } @@ -1676,8 +1711,8 @@ impl KeetaClient { } /// Every certificate held by `account`. - pub async fn certificates(&self, account: impl AsRef) -> Result, ClientError> { - let account = account.as_ref().to_owned(); + pub async fn certificates(&self, account: impl AccountPublicKey) -> Result, ClientError> { + let account = account.to_public_key_string().context(AccountSnafu)?; self.dispatch_any(move |t| { let account = account.clone(); async move { t.certificates(&account).await } @@ -1688,15 +1723,13 @@ impl KeetaClient { /// The certificate of `account` identified by `hash`, if present. pub async fn certificate( &self, - account: impl AsRef, - hash: impl AsRef, + account: impl AccountPublicKey, + hash: [u8; 32], ) -> Result, ClientError> { - let account = account.as_ref().to_owned(); - let hash = hash.as_ref().to_owned(); + let account = account.to_public_key_string().context(AccountSnafu)?; self.dispatch_any(move |t| { let account = account.clone(); - let hash = hash.clone(); - async move { t.certificate(&account, &hash).await } + async move { t.certificate(&account, hash).await } }) .await } @@ -1742,11 +1775,15 @@ impl KeetaClient { } /// Ledger state for several `accounts` in one call. - pub async fn states(&self, accounts: &[&str]) -> Result, ClientError> { - let accounts = accounts.join(","); + pub async fn states(&self, accounts: &[T]) -> Result, ClientError> { + let addresses: Vec = accounts + .iter() + .map(AccountPublicKey::to_public_key_string) + .collect::>() + .context(AccountSnafu)?; self.dispatch_any(move |t| { - let accounts = accounts.clone(); - async move { t.account_states(&accounts).await } + let addresses = addresses.clone(); + async move { t.account_states(&addresses).await } }) .await } @@ -1768,7 +1805,6 @@ impl KeetaClient { /// The account of the client's first representative, parsed from its /// published key; used as the default delegate at genesis. - #[cfg(feature = "std")] pub(crate) fn first_rep_account(&self) -> Result, ClientError> { match self.inner.reps.snapshot().into_iter().next() { Some(rep) => { @@ -1809,7 +1845,7 @@ impl KeetaClient { .with_account(Arc::clone(account)) .with_operations(operations); - if signer.to_string() != account.to_string() { + if signer != account { builder = builder.with_signer(Arc::clone(signer)); } if let Some(purpose) = purpose { @@ -1880,6 +1916,35 @@ fn store_representatives(runtime: &Arc, signature: &str, reps: &[Re #[cfg(not(feature = "std"))] fn store_representatives(_runtime: &Arc, _signature: &str, _reps: &[RepEntry]) {} +/// Drain a cursor-paged read: call `fetch` with no cursor, then with each +/// page's `next_key`, until the node reports the end of the sequence. +async fn drain_cursor_pages(fetch: FETCH) -> Result, ClientError> +where + CURSOR: Copy, + FETCH: Fn(Option) -> PAGE, + PAGE: Future, Option), ClientError>>, +{ + let mut items = Vec::new(); + let mut cursor = None; + + loop { + let (page, next_key) = fetch(cursor).await?; + + if page.is_empty() { + break; + } + + items.extend(page); + + match next_key { + Some(next) => cursor = Some(next), + None => break, + } + } + + Ok(items) +} + /// Choose a moment that lies within every vote's validity window so a /// reconstructed staple validates without rejecting near-expired votes. fn staple_moment(votes: &[Vote], fallback: BlockTime) -> BlockTime { @@ -1925,7 +1990,7 @@ pub(crate) fn is_ledger_code(error: &ClientError, code: &str) -> bool { /// A specific rep's head block hash and height for `account`, treating any /// error or absent head as "no head" so divergence detection can sort it low. -async fn head_info(transport: &Arc, account: &str) -> Option<(String, Amount)> { +async fn head_info(transport: &Arc, account: &str) -> Option<(BlockHash, Amount)> { let Ok(state) = transport.account_state(account).await else { return None; }; @@ -1937,7 +2002,7 @@ async fn head_info(transport: &Arc, account: &str) -> Option< /// The head height carried by a rep's account info, treating a missing head as /// `-1` so unopened reps sort below opened ones. -fn height_value(info: &Option<(String, Amount)>) -> BigInt { +fn height_value(info: &Option<(BlockHash, Amount)>) -> BigInt { match info { Some((_, height)) => height.as_bigint().clone(), None => BigInt::from(-1), @@ -1967,6 +2032,7 @@ fn pick_best_vote(mut votes: Vec) -> Option { .unix_millis() .cmp(&left.validity().to.unix_millis()) }); + votes.into_iter().next() } @@ -2016,6 +2082,55 @@ mod tests { Ok(builder.build_signed(issuer.as_ref())?) } + /// Resolve a future that never awaits anything pending (the paging tests + /// drive `drain_cursor_pages` over `core::future::ready` fetches). + fn resolve(future: impl Future) -> Option { + let mut future = core::pin::pin!(future); + let waker = core::task::Waker::noop(); + let mut context = core::task::Context::from_waker(waker); + + match future.as_mut().poll(&mut context) { + core::task::Poll::Ready(value) => Some(value), + core::task::Poll::Pending => None, + } + } + + #[test] + fn drain_cursor_pages_stops_on_an_empty_page_echoing_the_cursor() -> TestResult { + let calls = core::cell::Cell::new(0u32); + let fetch = |cursor: Option| { + let call = calls.get(); + calls.set(call + 1); + core::future::ready(match call { + 0 => Ok((alloc::vec![1, 2], Some(7u8))), + _ => Ok((Vec::new(), cursor)), + }) + }; + + let items = resolve(drain_cursor_pages(fetch)).ok_or("ready future")??; + assert_eq!(items, alloc::vec![1, 2]); + assert_eq!(calls.get(), 2); + Ok(()) + } + + #[test] + fn drain_cursor_pages_stops_when_the_cursor_runs_out() -> TestResult { + let calls = core::cell::Cell::new(0u32); + let fetch = |_: Option| { + let call = calls.get(); + calls.set(call + 1); + core::future::ready(match call { + 0 => Ok((alloc::vec![1], Some(9u8))), + _ => Ok((alloc::vec![2], None)), + }) + }; + + let items = resolve(drain_cursor_pages(fetch)).ok_or("ready future")??; + assert_eq!(items, alloc::vec![1, 2]); + assert_eq!(calls.get(), 2); + Ok(()) + } + #[test] fn absent_fees_are_not_required() -> TestResult { assert!(!fees_required(&[signed_vote(None)?])); diff --git a/keetanetwork-client/src/codec.rs b/keetanetwork-client/src/codec.rs index c843d56..62c014d 100644 --- a/keetanetwork-client/src/codec.rs +++ b/keetanetwork-client/src/codec.rs @@ -10,11 +10,12 @@ use core::str::FromStr; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; use keetanetwork_block::{Amount, Block, BlockTime}; +use keetanetwork_crypto::error::CryptoError; use keetanetwork_error::{KeetaNetError, NodeErrorParts, NodeErrorType}; use keetanetwork_vote::{ValidationConfig, Vote, VoteQuote, VoteStaple}; use snafu::ResultExt; -use crate::error::{AmountSnafu, BlockSnafu, ClientError, DecodeSnafu, VoteSnafu}; +use crate::error::{AmountSnafu, BlockSnafu, ClientError, DecodeSnafu, HashSnafu, MomentSnafu, VoteSnafu}; use crate::generated::types; use crate::model::{AccountInfo, AccountState, Acl, Certificate, HistoryEntry, Representative, TokenBalance}; use crate::transport::LedgerSide; @@ -29,6 +30,16 @@ impl From for types::GetBlockSide { } } +impl From for types::GetBlockFromIdempotentSide { + fn from(side: LedgerSide) -> Self { + match side { + LedgerSide::Main => types::GetBlockFromIdempotentSide::Main, + LedgerSide::Side => types::GetBlockFromIdempotentSide::Side, + LedgerSide::Both => types::GetBlockFromIdempotentSide::Both, + } + } +} + impl From for types::GetBlockVotesSide { fn from(side: LedgerSide) -> Self { match side { @@ -145,12 +156,30 @@ pub(crate) fn decode_history( .into_iter() .filter_map(|entry| match decode_staple(entry.vote_staple, moment) { Ok(None) => None, - Ok(Some(staple)) => Some(Ok(HistoryEntry { staple, id: entry.id, timestamp: entry.timestamp })), + Ok(Some(staple)) => Some(decode_history_entry(staple, entry.id, entry.timestamp)), Err(error) => Some(Err(error)), }) .collect() } +/// Assemble a verified [`HistoryEntry`] from its decoded staple and the +/// transport id/timestamp fields. +fn decode_history_entry( + staple: VoteStaple, + id: Option, + timestamp: Option, +) -> Result { + Ok(HistoryEntry { staple, id: decode_hash(id)?, timestamp: decode_moment(timestamp)? }) +} + +/// Parse an optional ISO 8601 timestamp field into a [`BlockTime`], treating +/// an absent field as `None`. +pub(crate) fn decode_moment(timestamp: Option) -> Result, ClientError> { + timestamp + .map(|value| BlockTime::from_str(&value).context(MomentSnafu)) + .transpose() +} + /// Decode a transport representative entry. pub(crate) fn decode_representative(rep: types::Representative) -> Result { Ok(Representative { @@ -204,7 +233,7 @@ pub(crate) fn decode_account_state( Ok(AccountState { representative, - head, + head: decode_hash(head)?, height: height .map(|height| decode_amount(Some(height))) .transpose()?, @@ -223,6 +252,16 @@ pub(crate) fn decode_amount(balance: Option) -> Result(hash: Option) -> Result, ClientError> +where + T: FromStr, +{ + hash.map(|value| T::from_str(&value).context(HashSnafu)) + .transpose() +} + #[cfg(test)] mod tests { use super::*; @@ -242,6 +281,46 @@ mod tests { assert!(matches!(decode_amount(Some(String::from("nope"))), Err(ClientError::Amount { .. }))); } + #[test] + fn decodes_absent_hash_as_none() -> Result<(), ClientError> { + let decoded: Option = decode_hash(None)?; + assert_eq!(decoded, None); + Ok(()) + } + + #[test] + fn decodes_a_hex_hash() -> Result<(), ClientError> { + let hash = keetanetwork_block::BlockHash::from([0xABu8; 32]); + let decoded: Option = decode_hash(Some(hash.to_string()))?; + assert_eq!(decoded, Some(hash)); + Ok(()) + } + + #[test] + fn rejects_a_malformed_hash() { + let decoded: Result, ClientError> = + decode_hash(Some(String::from("nope"))); + assert!(matches!(decoded, Err(ClientError::Hash { .. }))); + } + + #[test] + fn decodes_absent_moment_as_none() -> Result<(), ClientError> { + assert_eq!(decode_moment(None)?, None); + Ok(()) + } + + #[test] + fn decodes_an_iso_moment() -> Result<(), ClientError> { + let decoded = decode_moment(Some(String::from("2025-01-02T03:04:05.123Z")))?; + assert_eq!(decoded.map(|moment| moment.to_string()).as_deref(), Some("2025-01-02T03:04:05.123Z")); + Ok(()) + } + + #[test] + fn rejects_a_malformed_moment() { + assert!(matches!(decode_moment(Some(String::from("nope"))), Err(ClientError::Moment { .. }))); + } + #[test] fn maps_block_side_to_the_wire_variant() { assert!(matches!(types::GetBlockSide::from(LedgerSide::Main), types::GetBlockSide::Main)); @@ -249,6 +328,22 @@ mod tests { assert!(matches!(types::GetBlockSide::from(LedgerSide::Both), types::GetBlockSide::Both)); } + #[test] + fn maps_idempotent_side_to_the_wire_variant() { + assert!(matches!( + types::GetBlockFromIdempotentSide::from(LedgerSide::Main), + types::GetBlockFromIdempotentSide::Main + )); + assert!(matches!( + types::GetBlockFromIdempotentSide::from(LedgerSide::Side), + types::GetBlockFromIdempotentSide::Side + )); + assert!(matches!( + types::GetBlockFromIdempotentSide::from(LedgerSide::Both), + types::GetBlockFromIdempotentSide::Both + )); + } + #[test] fn collapses_vote_side_both_to_main() { assert!(matches!(types::GetBlockVotesSide::from(LedgerSide::Side), types::GetBlockVotesSide::Side)); diff --git a/keetanetwork-client/src/error.rs b/keetanetwork-client/src/error.rs index 2557469..000413b 100644 --- a/keetanetwork-client/src/error.rs +++ b/keetanetwork-client/src/error.rs @@ -4,6 +4,7 @@ use alloc::boxed::Box; use keetanetwork_account::AccountError; use keetanetwork_block::BlockError; +use keetanetwork_crypto::error::CryptoError; use keetanetwork_error::KeetaNetError; use keetanetwork_vote::VoteError; use num_bigint::ParseBigIntError; @@ -62,6 +63,21 @@ pub enum ClientError { source: ParseBigIntError, }, + /// A hash field (block hash or history cursor) in the response could not + /// be parsed. + #[snafu(display("malformed hash in node response"))] + Hash { + /// Underlying hash decoding error. + source: CryptoError, + }, + + /// An ISO 8601 timestamp field in the response could not be parsed. + #[snafu(display("malformed timestamp in node response"))] + Moment { + /// Underlying datetime parse error. + source: chrono::ParseError, + }, + /// The `/vote` response omitted the vote field. #[snafu(display("node response omitted the vote"))] MissingVote, @@ -171,6 +187,8 @@ impl ClientError { Self::Vote { .. } => "VOTE", Self::Block { .. } => "BLOCK", Self::Amount { .. } => "AMOUNT", + Self::Hash { .. } => "HASH", + Self::Moment { .. } => "MOMENT", Self::MissingVote => "MISSING_VOTE", Self::MissingQuote => "MISSING_QUOTE", Self::MissingPublish => "MISSING_PUBLISH", diff --git a/keetanetwork-client/src/genesis.rs b/keetanetwork-client/src/genesis.rs index 2d3704b..3137808 100644 --- a/keetanetwork-client/src/genesis.rs +++ b/keetanetwork-client/src/genesis.rs @@ -101,7 +101,7 @@ pub(crate) fn generate_initial_vote_staple( let blocks = alloc::vec![network_block, base_token_block, balance_block, set_rep_block]; let hashes: Vec = blocks.iter().map(Block::hash).collect(); - let vote = permanent_vote(trusted, hashes, options)?; + let vote = permanent_vote(client.now_moment(), trusted, hashes, options)?; VoteStapleBuilder::new() .add_blocks(blocks) @@ -123,6 +123,7 @@ fn seal( /// Self-issue the permanent vote binding the genesis `hashes` into a staple. fn permanent_vote( + from: BlockTime, trusted: &AccountRef, hashes: Vec, options: &InitializeNetwork, @@ -132,7 +133,6 @@ fn permanent_vote( .clone() .unwrap_or_else(|| num_bigint::BigInt::from(0u8)); - let from = BlockTime::now(); let span_end = from.unix_millis().saturating_add(PERMANENT_SPAN_MS); let to = BlockTime::from_unix_millis(span_end).unwrap_or(from); diff --git a/keetanetwork-client/src/lib.rs b/keetanetwork-client/src/lib.rs index 2b44901..4348d3e 100644 --- a/keetanetwork-client/src/lib.rs +++ b/keetanetwork-client/src/lib.rs @@ -81,7 +81,6 @@ mod user; #[cfg(feature = "codec")] mod codec; -#[cfg(feature = "std")] mod genesis; #[cfg(feature = "http")] mod network; @@ -100,12 +99,13 @@ pub use builder::TransactionBuilder; pub use client::KeetaClient; pub use config::ClientConfig; pub use error::ClientError; +pub use genesis::{BaseNetworkInfo, BaseTokenInfo, InitializeNetwork}; pub use keetanetwork_error::{KeetaNetError, NodeErrorType}; -pub use keetanetwork_vote::{Vote, VoteQuote, VoteStaple}; +pub use keetanetwork_vote::{Vote, VoteBlockHash, VoteQuote, VoteStaple}; pub use marker::{MaybeSend, MaybeSync}; pub use model::{ - AccountInfo, AccountOrPending, AccountState, Acl, Certificate, ChainPage, ChainQuery, HistoryEntry, HistoryQuery, - LedgerChecksum, PendingAccount, Representative, TokenBalance, TransmitOptions, + AccountInfo, AccountOrPending, AccountState, Acl, BlockEffects, Certificate, ChainPage, ChainQuery, HistoryEntry, + HistoryPage, HistoryQuery, LedgerChecksum, PendingAccount, Representative, TokenBalance, TransmitOptions, }; pub use rep::RepPart; pub use runtime::{BoxFuture, Runtime, TaskHandle}; @@ -122,11 +122,7 @@ pub use { }; #[cfg(feature = "std")] -pub use { - genesis::{BaseNetworkInfo, BaseTokenInfo, InitializeNetwork}, - model::RepStatus, - runtime::TokioRuntime, -}; +pub use {model::RepStatus, runtime::TokioRuntime}; #[cfg(all(feature = "wasm", target_family = "wasm", target_os = "unknown"))] pub use runtime::WasmRuntime; diff --git a/keetanetwork-client/src/math.rs b/keetanetwork-client/src/math.rs index ee21cd7..dc67384 100644 --- a/keetanetwork-client/src/math.rs +++ b/keetanetwork-client/src/math.rs @@ -3,7 +3,7 @@ //! transport. use alloc::collections::BTreeMap; -use alloc::string::{String, ToString}; +use alloc::string::ToString; use num_bigint::BigInt; @@ -64,19 +64,18 @@ pub fn selection_score(weight_fraction: f64, reliability: f64) -> f64 { } /// The hash observed on the most representatives. Ties break to the -/// lexicographically smallest hash for determinism. `None` for no -/// observations. +/// smallest hash (by `Ord`) for determinism. `None` for no observations. #[must_use] -pub fn most_common_hash(hashes: &[String]) -> Option { - let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); +pub fn most_common_hash(hashes: &[T]) -> Option { + let mut counts: BTreeMap<&T, usize> = BTreeMap::new(); for hash in hashes { - *counts.entry(hash.as_str()).or_insert(0) += 1; + *counts.entry(hash).or_insert(0) += 1; } counts .into_iter() .max_by(|left, right| left.1.cmp(&right.1).then_with(|| right.0.cmp(left.0))) - .map(|(hash, _)| hash.to_string()) + .map(|(hash, _)| hash.clone()) } /// The latest `from` shared by all `(from, to)` validity windows, when their @@ -165,7 +164,7 @@ mod tests { #[test] fn most_common_hash_is_none_without_observations() { - assert_eq!(most_common_hash(&[]), None); + assert_eq!(most_common_hash::(&[]), None); } #[test] diff --git a/keetanetwork-client/src/model.rs b/keetanetwork-client/src/model.rs index c1e034e..3594fe4 100644 --- a/keetanetwork-client/src/model.rs +++ b/keetanetwork-client/src/model.rs @@ -5,8 +5,8 @@ use alloc::string::String; use alloc::sync::Arc; use alloc::vec::Vec; -use keetanetwork_block::{AccountRef, Amount, Block}; -use keetanetwork_vote::{VoteQuote, VoteStaple}; +use keetanetwork_block::{AccountRef, Amount, Block, BlockHash, BlockTime, Operation}; +use keetanetwork_vote::{VoteBlockHash, VoteQuote, VoteStaple}; use crate::error::ClientError; use crate::sync::Once; @@ -123,10 +123,35 @@ pub struct LedgerChecksum { pub struct HistoryEntry { /// The verified vote staple. pub staple: VoteStaple, - /// Hexadecimal vote staple id. - pub id: Option, - /// ISO 8601 timestamp. - pub timestamp: Option, + /// The staple's id: the hash of the block hashes it covers. + pub id: Option, + /// The moment the staple was committed. + pub timestamp: Option, +} + +/// One staple block together with the subset of its operations that involve +/// a filtered account (see +/// [`UserClient::filter_staple_operations`](crate::UserClient::filter_staple_operations)). +/// +/// Operations are carried as indexes into the block's operation list, so the +/// selection survives any boundary the block itself crosses. +#[derive(Debug, Clone)] +pub struct BlockEffects { + /// The block the operations came from. + pub block: Block, + /// Indexes into the block's operation list for the operations involving + /// the filtered account, in block order. + pub operation_indexes: Vec, +} + +impl BlockEffects { + /// The operations involving the filtered account, resolved against the block. + pub fn operations(&self) -> impl Iterator { + let operations = self.block.data().operations(); + self.operation_indexes + .iter() + .filter_map(|&index| operations.get(index)) + } } /// An access-control entry granting a principal permissions over a target. @@ -155,12 +180,12 @@ pub struct Certificate { /// /// `start`/`end` are block-hash cursors; `limit` caps the page size (the node /// enforces its own maximum). -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Copy, Default)] pub struct ChainQuery { /// Start cursor (block hash) to page from. - pub start: Option, + pub start: Option, /// End cursor (block hash) to stop at. - pub end: Option, + pub end: Option, /// Maximum entries to return in the page. pub limit: Option, } @@ -172,20 +197,30 @@ pub struct ChainPage { pub blocks: Vec, /// Cursor to pass as the next page's [`ChainQuery::start`], or `None` once /// the chain is exhausted. - pub next_key: Option, + pub next_key: Option, } /// Pagination/range bounds for /// [`KeetaClient::history_page`](crate::KeetaClient::history_page) and /// [`KeetaClient::global_history_page`](crate::KeetaClient::global_history_page). -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Copy, Default)] pub struct HistoryQuery { - /// Start cursor (block hash) to page from. - pub start: Option, + /// Start cursor (the previous page's last staple id) to page from. + pub start: Option, /// Maximum entries to return in the page. pub limit: Option, } +/// A single page of history together with the cursor for the next page. +#[derive(Debug, Clone, Default)] +pub struct HistoryPage { + /// The verified history entries in this page. + pub entries: Vec, + /// Cursor to pass as the next page's [`HistoryQuery::start`], or `None` + /// once the history is exhausted. + pub next_key: Option, +} + /// Account metadata as set via [`UserClient::set_info`](crate::UserClient::set_info). #[derive(Debug, Clone, Default)] pub struct AccountInfo { @@ -202,8 +237,8 @@ pub struct AccountInfo { pub struct AccountState { /// Representative account address, if one is set. pub representative: Option, - /// Head block hash (hex), if the account has any blocks. - pub head: Option, + /// Head block hash, if the account has any blocks. + pub head: Option, /// Head block height, if known. pub height: Option, /// Account metadata, if the account reports any. diff --git a/keetanetwork-client/src/transport.rs b/keetanetwork-client/src/transport.rs index e7f7fd5..d103398 100644 --- a/keetanetwork-client/src/transport.rs +++ b/keetanetwork-client/src/transport.rs @@ -10,13 +10,13 @@ use alloc::sync::Arc; use alloc::vec::Vec; use async_trait::async_trait; -use keetanetwork_block::{Amount, Block}; +use keetanetwork_block::{Amount, Block, BlockHash, BlockTime}; use keetanetwork_vote::{Vote, VoteQuote, VoteStaple}; use crate::error::ClientError; use crate::marker::{MaybeSend, MaybeSync}; use crate::model::{ - AccountState, Acl, Certificate, ChainPage, ChainQuery, HistoryEntry, HistoryQuery, LedgerChecksum, Representative, + AccountState, Acl, Certificate, ChainPage, ChainQuery, HistoryPage, HistoryQuery, LedgerChecksum, Representative, TokenBalance, }; @@ -47,8 +47,8 @@ pub trait NodeTransport: core::fmt::Debug + MaybeSend + MaybeSync { async fn balances(&self, account: &str) -> Result, ClientError>; /// The full ledger state of `account`. async fn account_state(&self, account: &str) -> Result; - /// Ledger state for several comma-joined `accounts` in one call. - async fn account_states(&self, accounts: &str) -> Result, ClientError>; + /// Ledger state for several `accounts` (pre-rendered addresses) in one call. + async fn account_states(&self, accounts: &[String]) -> Result, ClientError>; /// The head block of `account`, if any. async fn head_block(&self, account: &str) -> Result, ClientError>; /// The head block of `account` paired with its height, if any. @@ -56,22 +56,30 @@ pub trait NodeTransport: core::fmt::Debug + MaybeSend + MaybeSync { /// The next pending (unreceived) block for `account`, if any. async fn pending_block(&self, account: &str) -> Result, ClientError>; /// The block identified by `hash` on the given `side`, if present. - async fn block(&self, hash: &str, side: Option) -> Result, ClientError>; + async fn block(&self, hash: BlockHash, side: Option) -> Result, ClientError>; /// The block following `hash`, if one exists. - async fn successor_block(&self, hash: &str) -> Result, ClientError>; - /// The block produced by `account` for the idempotent `key`, if any. - async fn block_by_idempotent(&self, account: &str, key: &str) -> Result, ClientError>; + async fn successor_block(&self, hash: BlockHash) -> Result, ClientError>; + /// The block produced by `account` for the idempotent `key`, if any, + /// searching the given `side` (`None` defaults to the main ledger). + async fn block_by_idempotent( + &self, + account: &str, + key: &str, + side: Option, + ) -> Result, ClientError>; /// The verified votes a rep holds for `hash` on `side`. `None` when none. - async fn block_votes(&self, hash: &str, side: LedgerSide) -> Result>, ClientError>; + async fn block_votes(&self, hash: BlockHash, side: LedgerSide) -> Result>, ClientError>; /// A single page of `account`'s block chain, bounded by `query`, including /// the cursor for the next page. async fn chain_page(&self, account: &str, query: &ChainQuery) -> Result; - /// A single page of `account`'s staple history, bounded by `query`. - async fn history_page(&self, account: &str, query: &HistoryQuery) -> Result, ClientError>; - /// A single page of global staple history, bounded by `query`. - async fn global_history_page(&self, query: &HistoryQuery) -> Result, ClientError>; - /// Verified vote staples published after the `start` cursor. - async fn vote_staples_after(&self, start: &str, limit: Option) -> Result, ClientError>; + /// A single page of `account`'s staple history, bounded by `query`, + /// including the cursor for the next page. + async fn history_page(&self, account: &str, query: &HistoryQuery) -> Result; + /// A single page of global staple history, bounded by `query`, including + /// the cursor for the next page. + async fn global_history_page(&self, query: &HistoryQuery) -> Result; + /// Verified vote staples committed at or after the `start` moment. + async fn vote_staples_after(&self, start: BlockTime, limit: Option) -> Result, ClientError>; /// The node's own representative. async fn node_representative(&self) -> Result; /// The named representative `rep`. @@ -87,7 +95,7 @@ pub trait NodeTransport: core::fmt::Debug + MaybeSend + MaybeSync { /// Every certificate held by `account`. async fn certificates(&self, account: &str) -> Result, ClientError>; /// The certificate of `account` identified by `hash`, if present. - async fn certificate(&self, account: &str, hash: &str) -> Result, ClientError>; + async fn certificate(&self, account: &str, hash: [u8; 32]) -> Result, ClientError>; /// Request a vote for `blocks`, attaching `prior` votes and an optional /// `quote` issued by this representative. async fn create_vote( @@ -132,26 +140,26 @@ pub use wasi_backend::{WasiTransport, WasiTransportFactory}; #[cfg(feature = "http")] mod backend { use alloc::boxed::Box; - use alloc::string::String; + use alloc::string::{String, ToString}; use alloc::sync::Arc; use alloc::vec::Vec; use async_trait::async_trait; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; - use keetanetwork_block::{Amount, Block, BlockTime}; + use keetanetwork_block::{Amount, Block, BlockHash, BlockTime}; use keetanetwork_vote::{Vote, VoteQuote, VoteStaple}; use super::{LedgerSide, NodeTransport, TransportFactory}; use crate::codec::{ decode_account_state, decode_acl, decode_amount, decode_balances, decode_block, decode_certificate, - decode_history, decode_node_error, decode_quote_binary, decode_representative, decode_staples, + decode_hash, decode_history, decode_node_error, decode_quote_binary, decode_representative, decode_staples, decode_vote_binary, encode_blocks, encode_votes, }; use crate::error::ClientError; use crate::generated::{types, Client as Transport, Error as GeneratedError}; use crate::model::{ - AccountState, Acl, Certificate, ChainPage, ChainQuery, HistoryEntry, HistoryQuery, LedgerChecksum, + AccountState, Acl, Certificate, ChainPage, ChainQuery, HistoryPage, HistoryQuery, LedgerChecksum, Representative, TokenBalance, }; @@ -236,9 +244,9 @@ mod backend { ) } - async fn account_states(&self, accounts: &str) -> Result, ClientError> { + async fn account_states(&self, accounts: &[String]) -> Result, ClientError> { self.client - .get_account_states(accounts) + .get_account_states(&accounts.join(",")) .await? .into_inner() .into_iter() @@ -276,23 +284,37 @@ mod backend { decode_block(response.into_inner().block) } - async fn block(&self, hash: &str, side: Option) -> Result, ClientError> { - let response = self.client.get_block(hash, side.map(Into::into)).await?; + async fn block(&self, hash: BlockHash, side: Option) -> Result, ClientError> { + let response = self + .client + .get_block(&hash.to_string(), side.map(Into::into)) + .await?; decode_block(response.into_inner().block) } - async fn successor_block(&self, hash: &str) -> Result, ClientError> { - let response = self.client.get_successor_block(hash).await?; + async fn successor_block(&self, hash: BlockHash) -> Result, ClientError> { + let response = self.client.get_successor_block(&hash.to_string()).await?; decode_block(response.into_inner().successor_block) } - async fn block_by_idempotent(&self, account: &str, key: &str) -> Result, ClientError> { - let response = self.client.get_block_from_idempotent(account, key).await?; + async fn block_by_idempotent( + &self, + account: &str, + key: &str, + side: Option, + ) -> Result, ClientError> { + let response = self + .client + .get_block_from_idempotent(account, key, side.map(Into::into)) + .await?; decode_block(response.into_inner().block) } - async fn block_votes(&self, hash: &str, side: LedgerSide) -> Result>, ClientError> { - let response = self.client.get_block_votes(hash, Some(side.into())).await?; + async fn block_votes(&self, hash: BlockHash, side: LedgerSide) -> Result>, ClientError> { + let response = self + .client + .get_block_votes(&hash.to_string(), Some(side.into())) + .await?; let Some(list) = response.into_inner().votes else { return Ok(None); }; @@ -306,9 +328,11 @@ mod backend { } async fn chain_page(&self, account: &str, query: &ChainQuery) -> Result { + let end = query.end.map(|hash| hash.to_string()); + let start = query.start.map(|hash| hash.to_string()); let response = self .client - .get_account_chain(account, query.end.as_deref(), query.limit, query.start.as_deref()) + .get_account_chain(account, end.as_deref(), query.limit, start.as_deref()) .await? .into_inner(); @@ -318,27 +342,45 @@ mod backend { .filter_map(|entry| decode_block(entry.block).transpose()) .collect::, ClientError>>()?; - Ok(ChainPage { blocks, next_key: response.next_key }) + Ok(ChainPage { blocks, next_key: decode_hash(response.next_key)? }) } - async fn history_page(&self, account: &str, query: &HistoryQuery) -> Result, ClientError> { + async fn history_page(&self, account: &str, query: &HistoryQuery) -> Result { + let start = query.start.map(|hash| hash.to_string()); let response = self .client - .get_account_history(account, query.limit, query.start.as_deref()) - .await?; - decode_history(response.into_inner().history, verify_moment()) + .get_account_history(account, query.limit, start.as_deref()) + .await? + .into_inner(); + + Ok(HistoryPage { + entries: decode_history(response.history, verify_moment())?, + next_key: decode_hash(response.next_key)?, + }) } - async fn global_history_page(&self, query: &HistoryQuery) -> Result, ClientError> { + async fn global_history_page(&self, query: &HistoryQuery) -> Result { + let start = query.start.map(|hash| hash.to_string()); let response = self .client - .get_global_history(query.limit, query.start.as_deref()) - .await?; - decode_history(response.into_inner().history, verify_moment()) + .get_global_history(query.limit, start.as_deref()) + .await? + .into_inner(); + Ok(HistoryPage { + entries: decode_history(response.history, verify_moment())?, + next_key: decode_hash(response.next_key)?, + }) } - async fn vote_staples_after(&self, start: &str, limit: Option) -> Result, ClientError> { - let response = self.client.get_vote_staples_after(limit, start).await?; + async fn vote_staples_after( + &self, + start: BlockTime, + limit: Option, + ) -> Result, ClientError> { + let response = self + .client + .get_vote_staples_after(limit, &start.to_string()) + .await?; decode_staples(response.into_inner().vote_staples, verify_moment()) } @@ -408,10 +450,10 @@ mod backend { .collect()) } - async fn certificate(&self, account: &str, hash: &str) -> Result, ClientError> { + async fn certificate(&self, account: &str, hash: [u8; 32]) -> Result, ClientError> { let found = self .client - .get_certificate_by_hash(account, hash) + .get_certificate_by_hash(account, &hex::encode_upper(hash)) .await? .into_inner(); Ok(decode_certificate(types::Certificate { @@ -486,7 +528,7 @@ mod wasi_backend { use async_trait::async_trait; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; - use keetanetwork_block::{Amount, Block, BlockTime}; + use keetanetwork_block::{Amount, Block, BlockHash, BlockTime}; use keetanetwork_vote::{Vote, VoteQuote, VoteStaple}; use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use serde::de::DeserializeOwned; @@ -496,13 +538,13 @@ mod wasi_backend { use super::{LedgerSide, NodeTransport, TransportFactory}; use crate::codec::{ decode_account_state, decode_acl, decode_amount, decode_balances, decode_block, decode_certificate, - decode_history, decode_node_error, decode_quote_binary, decode_representative, decode_staples, + decode_hash, decode_history, decode_node_error, decode_quote_binary, decode_representative, decode_staples, decode_vote_binary, encode_blocks, encode_votes, }; use crate::error::ClientError; use crate::generated::types; use crate::model::{ - AccountState, Acl, Certificate, ChainPage, ChainQuery, HistoryEntry, HistoryQuery, LedgerChecksum, + AccountState, Acl, Certificate, ChainPage, ChainQuery, HistoryPage, HistoryQuery, LedgerChecksum, Representative, TokenBalance, }; @@ -695,8 +737,8 @@ mod wasi_backend { ) } - async fn account_states(&self, accounts: &str) -> Result, ClientError> { - let path = format!("/node/ledger/accounts/{}", segment(accounts)); + async fn account_states(&self, accounts: &[String]) -> Result, ClientError> { + let path = format!("/node/ledger/accounts/{}", segment(&accounts.join(","))); let response: Vec = self.get_json(&path).await?; response .into_iter() @@ -737,32 +779,41 @@ mod wasi_backend { decode_block(response.block) } - async fn block(&self, hash: &str, side: Option) -> Result, ClientError> { + async fn block(&self, hash: BlockHash, side: Option) -> Result, ClientError> { let mut query = Query::default(); query.push_opt("side", side.map(block_side)); - let path = format!("/node/ledger/block/{}{}", segment(hash), query.finish()); + let path = format!("/node/ledger/block/{}{}", segment(&hash.to_string()), query.finish()); let response: types::GetBlockResponse = self.get_json(&path).await?; decode_block(response.block) } - async fn successor_block(&self, hash: &str) -> Result, ClientError> { - let path = format!("/node/ledger/block/{}/successor", segment(hash)); + async fn successor_block(&self, hash: BlockHash) -> Result, ClientError> { + let path = format!("/node/ledger/block/{}/successor", segment(&hash.to_string())); let response: types::GetSuccessorBlockResponse = self.get_json(&path).await?; decode_block(response.successor_block) } - async fn block_by_idempotent(&self, account: &str, key: &str) -> Result, ClientError> { - let path = format!("/node/ledger/account/{}/idempotent/{}", segment(account), segment(key)); + async fn block_by_idempotent( + &self, + account: &str, + key: &str, + side: Option, + ) -> Result, ClientError> { + let mut query = Query::default(); + query.push_opt("side", side.map(block_side)); + + let path = + format!("/node/ledger/account/{}/idempotent/{}{}", segment(account), segment(key), query.finish()); let response: types::GetBlockFromIdempotentResponse = self.get_json(&path).await?; decode_block(response.block) } - async fn block_votes(&self, hash: &str, side: LedgerSide) -> Result>, ClientError> { + async fn block_votes(&self, hash: BlockHash, side: LedgerSide) -> Result>, ClientError> { let mut query = Query::default(); query.push("side", block_votes_side(side)); - let path = format!("/vote/{}{}", segment(hash), query.finish()); + let path = format!("/vote/{}{}", segment(&hash.to_string()), query.finish()); let response: types::GetBlockVotesResponse = self.get_json(&path).await?; let Some(list) = response.votes else { return Ok(None); @@ -777,10 +828,12 @@ mod wasi_backend { } async fn chain_page(&self, account: &str, query: &ChainQuery) -> Result { + let end = query.end.map(|hash| hash.to_string()); + let start = query.start.map(|hash| hash.to_string()); let mut params = Query::default(); - params.push_opt("end", query.end.as_deref()); + params.push_opt("end", end.as_deref()); params.push_limit(query.limit); - params.push_opt("start", query.start.as_deref()); + params.push_opt("start", start.as_deref()); let path = format!("/node/ledger/account/{}/chain{}", segment(account), params.finish()); let response: types::GetAccountChainResponse = self.get_json(&path).await?; @@ -790,33 +843,49 @@ mod wasi_backend { .filter_map(|entry| decode_block(entry.block).transpose()) .collect::, ClientError>>()?; - Ok(ChainPage { blocks, next_key: response.next_key }) + Ok(ChainPage { blocks, next_key: decode_hash(response.next_key)? }) } - async fn history_page(&self, account: &str, query: &HistoryQuery) -> Result, ClientError> { + async fn history_page(&self, account: &str, query: &HistoryQuery) -> Result { + let start = query.start.map(|hash| hash.to_string()); + let mut params = Query::default(); params.push_limit(query.limit); - params.push_opt("start", query.start.as_deref()); + params.push_opt("start", start.as_deref()); let path = format!("/node/ledger/account/{}/history{}", segment(account), params.finish()); let response: types::GetAccountHistoryResponse = self.get_json(&path).await?; - decode_history(response.history, now_moment()) + + Ok(HistoryPage { + entries: decode_history(response.history, now_moment())?, + next_key: decode_hash(response.next_key)?, + }) } - async fn global_history_page(&self, query: &HistoryQuery) -> Result, ClientError> { + async fn global_history_page(&self, query: &HistoryQuery) -> Result { + let start = query.start.map(|hash| hash.to_string()); + let mut params = Query::default(); params.push_limit(query.limit); - params.push_opt("start", query.start.as_deref()); + params.push_opt("start", start.as_deref()); let path = format!("/node/ledger/history{}", params.finish()); let response: types::GetGlobalHistoryResponse = self.get_json(&path).await?; - decode_history(response.history, now_moment()) + + Ok(HistoryPage { + entries: decode_history(response.history, now_moment())?, + next_key: decode_hash(response.next_key)?, + }) } - async fn vote_staples_after(&self, start: &str, limit: Option) -> Result, ClientError> { + async fn vote_staples_after( + &self, + start: BlockTime, + limit: Option, + ) -> Result, ClientError> { let mut params = Query::default(); params.push_limit(limit); - params.push("start", start); + params.push("start", &start.to_string()); let path = format!("/node/bootstrap/votes{}", params.finish()); let response: types::GetVoteStaplesAfterResponse = self.get_json(&path).await?; @@ -874,8 +943,9 @@ mod wasi_backend { .collect()) } - async fn certificate(&self, account: &str, hash: &str) -> Result, ClientError> { - let path = format!("/node/ledger/account/{}/certificates/{}", segment(account), segment(hash)); + async fn certificate(&self, account: &str, hash: [u8; 32]) -> Result, ClientError> { + let path = + format!("/node/ledger/account/{}/certificates/{}", segment(account), segment(&hex::encode_upper(hash))); let found: types::GetCertificateByHashResponse = self.get_json(&path).await?; Ok(decode_certificate(types::Certificate { certificate: found.certificate, diff --git a/keetanetwork-client/src/user.rs b/keetanetwork-client/src/user.rs index b69ff65..60fe4cb 100644 --- a/keetanetwork-client/src/user.rs +++ b/keetanetwork-client/src/user.rs @@ -1,21 +1,22 @@ //! Signer-bound high-level facade over [`KeetaClient`]. +use alloc::collections::BTreeMap; use alloc::string::{String, ToString}; use alloc::sync::Arc; use alloc::vec::Vec; -use keetanetwork_account::KeyPairType; +use keetanetwork_account::{AccountPublicKey, KeyPairType}; use keetanetwork_block::{ - AccountRef, AdjustMethod, Amount, Block, IdentifierCreateArguments, ManageCertificate, ModifyPermissions, - Operation, Receive, Send, SetInfo, + AccountRef, AdjustMethod, Amount, Block, BlockHash, IdentifierCreateArguments, ManageCertificate, + ModifyPermissions, ModifyPermissionsPrincipal, Operation, Receive, Send, SetInfo, }; -use keetanetwork_vote::{VoteQuote, VoteStaple}; +use keetanetwork_vote::{VoteBlockHash, VoteQuote, VoteStaple}; use crate::builder::TransactionBuilder; use crate::client::{is_ledger_code, KeetaClient}; use crate::error::ClientError; use crate::model::{ - AccountState, Acl, Certificate, ChainQuery, HistoryEntry, HistoryQuery, TokenBalance, TransmitOptions, + AccountState, Acl, BlockEffects, Certificate, ChainQuery, HistoryEntry, HistoryQuery, TokenBalance, TransmitOptions, }; use crate::swap::{AcceptSwapRequest, CreateSwapRequest, SwapTokenAmount}; use crate::transport::LedgerSide; @@ -23,7 +24,6 @@ use crate::transport::LedgerSide; #[cfg(feature = "http")] use {crate::config::ClientConfig, crate::network::Network, crate::rep::RepEndpoint, num_bigint::BigInt}; -#[cfg(feature = "std")] use crate::genesis::{generate_initial_vote_staple, InitializeNetwork}; /// A [`KeetaClient`] bound to a signer (and optionally a distinct operating @@ -176,65 +176,72 @@ impl UserClient { } /// The settled balance of `token` held by the operating account. - pub async fn balance(&self, token: impl AsRef) -> Result { + pub async fn balance(&self, token: impl AccountPublicKey) -> Result { let account = self.account_or(None)?; - self.client.balance(account.to_string(), token).await + self.client.balance(&*account, token).await } /// Every token balance held by the operating account. pub async fn all_balances(&self) -> Result, ClientError> { let account = self.account_or(None)?; - self.client.balances(account.to_string()).await + self.client.balances(&*account).await } /// The full state of the operating account. pub async fn state(&self) -> Result { let account = self.account_or(None)?; - self.client.state(account.to_string()).await + self.client.state(&*account).await } /// The operating account's head block, if any. pub async fn head(&self) -> Result, ClientError> { let account = self.account_or(None)?; - self.client.head_block(account.to_string()).await + self.client.head_block(&*account).await } /// The operating account's settled chain (first/default page). pub async fn chain(&self) -> Result, ClientError> { let account = self.account_or(None)?; - self.client.chain(account.to_string()).await + self.client.chain(&*account).await } /// A single page of the operating account's chain, bounded by `query`. pub async fn chain_page(&self, query: ChainQuery) -> Result, ClientError> { let account = self.account_or(None)?; - self.client.chain_page(account.to_string(), query).await + self.client.chain_page(&*account, query).await } /// Every block in the operating account's chain, following the node's /// pagination cursor with `page_limit` blocks per request. pub async fn chain_all(&self, page_limit: u32) -> Result, ClientError> { let account = self.account_or(None)?; - self.client.chain_all(account.to_string(), page_limit).await + self.client.chain_all(&*account, page_limit).await } /// The operating account's transaction history (first/default page). pub async fn history(&self) -> Result, ClientError> { let account = self.account_or(None)?; - self.client.history(account.to_string()).await + self.client.history(&*account).await + } + + /// Every entry in the operating account's history, fetched by following + /// the node's cursor with `page_limit` per request. + pub async fn history_all(&self, page_limit: u32) -> Result, ClientError> { + let account = self.account_or(None)?; + self.client.history_all(&*account, page_limit).await } /// A single page of the operating account's history, bounded by `query`. pub async fn history_page(&self, query: HistoryQuery) -> Result, ClientError> { let account = self.account_or(None)?; - self.client.history_page(account.to_string(), query).await + self.client.history_page(&*account, query).await } /// The operating account's half-published successor, if any reps agree on /// one. pub async fn pending_block(&self) -> Result, ClientError> { let account = self.account_or(None)?; - self.client.pending_block(account.to_string()).await + self.client.pending_block(&*account).await } /// Recover the operating account's half-published staple, optionally @@ -270,13 +277,13 @@ impl UserClient { /// The access-control entries the operating account grants as principal. pub async fn acls(&self) -> Result, ClientError> { let account = self.account_or(None)?; - self.client.acls_by_principal(account.to_string()).await + self.client.acls_by_principal(&*account).await } /// The access-control entries naming the operating account as entity. pub async fn acls_by_entity(&self) -> Result, ClientError> { let account = self.account_or(None)?; - self.client.acls_by_entity(account.to_string()).await + self.client.acls_by_entity(&*account).await } /// The access-control entries the operating account grants as principal, @@ -284,27 +291,24 @@ impl UserClient { #[cfg(feature = "std")] pub async fn acls_with_info(&self) -> Result { let account = self.account_or(None)?; - self.client - .acls_by_principal_with_info(account.to_string()) - .await + self.client.acls_by_principal_with_info(&*account).await } /// A specific block by hash, regardless of account. `side` selects the /// ledger to read (`None` defaults to the main ledger). - pub async fn block( - &self, - blockhash: impl AsRef, - side: Option, - ) -> Result, ClientError> { + pub async fn block(&self, blockhash: BlockHash, side: Option) -> Result, ClientError> { self.client.block(blockhash, side).await } - /// The operating account's block carrying the idempotency `key`, if any. - pub async fn block_from_idempotent(&self, key: impl AsRef) -> Result, ClientError> { + /// The operating account's block carrying the idempotency `key`, if any, + /// searching the given `side` (`None` defaults to the main ledger). + pub async fn block_from_idempotent( + &self, + key: impl AsRef, + side: Option, + ) -> Result, ClientError> { let account = self.account_or(None)?; - self.client - .block_by_idempotent(account.to_string(), key) - .await + self.client.block_by_idempotent(&*account, key, side).await } /// Vote quotes for `blocks` from every responding representative. @@ -312,16 +316,44 @@ impl UserClient { self.client.quotes(blocks).await } + /// The operations in `staples` that involve `account`, keyed by staple id + /// and ordered as published: every operation of a block the account + /// produced, plus operations on other accounts' blocks that name it. + pub fn filter_staple_operations( + staples: &[VoteStaple], + account: impl AccountPublicKey, + ) -> BTreeMap> { + staples + .iter() + .map(|staple| { + let blocks = staple + .blocks() + .iter() + .map(|block| block_effects(block, &account)); + (staple.block_hash(), blocks.collect()) + }) + .collect() + } + + /// [`Self::filter_staple_operations`] over the operating account. + pub fn staple_effects( + &self, + staples: &[VoteStaple], + ) -> Result>, ClientError> { + let account = self.account_or(None)?; + Ok(Self::filter_staple_operations(staples, &*account)) + } + /// The certificates attached to the operating account. pub async fn certificates(&self) -> Result, ClientError> { let account = self.account_or(None)?; - self.client.certificates(account.to_string()).await + self.client.certificates(&*account).await } /// A single certificate on the operating account by its `hash`, if present. - pub async fn certificate(&self, hash: impl AsRef) -> Result, ClientError> { + pub async fn certificate(&self, hash: [u8; 32]) -> Result, ClientError> { let account = self.account_or(None)?; - self.client.certificate(account.to_string(), hash).await + self.client.certificate(&*account, hash).await } /// Start a transaction originated by the operating account and signed by @@ -530,7 +562,6 @@ impl UserClient { /// client has no representative to default to. /// - [`ClientError::Block`] / [`ClientError::Vote`] -- the genesis staple /// cannot be built. - #[cfg(feature = "std")] pub async fn initialize_network(&self, options: InitializeNetwork) -> Result { let trusted = self.signer()?; let recipient = self.account_or(None)?; @@ -669,6 +700,55 @@ impl UserClient { } } +/// The [`BlockEffects`] of one staple block for `account`: every operation +/// when the account produced the block, otherwise only the operations that +/// name it. +fn block_effects(block: &Block, account: &impl AccountPublicKey) -> BlockEffects { + let operations = block.data().operations().iter().enumerate(); + let operation_indexes = match same_account(block.data().account(), account) { + true => operations.map(|(index, _)| index).collect(), + false => operations + .filter(|(_, operation)| operation_involves(operation, account)) + .map(|(index, _)| index) + .collect(), + }; + + BlockEffects { block: block.clone(), operation_indexes } +} + +/// Whether `operation` names `account` as a participant: send/set-rep +/// recipient, modify-permissions account principal, create-identifier +/// identifier, or receive source/forward. Info, token-admin, and certificate +/// operations reference no account directly. +fn operation_involves(operation: &Operation, account: &impl AccountPublicKey) -> bool { + match operation { + Operation::Send(send) => same_account(&send.to, account), + Operation::SetRep(set_rep) => same_account(&set_rep.to, account), + Operation::ModifyPermissions(change) => { + matches!(&change.principal, ModifyPermissionsPrincipal::Account(principal) if same_account(principal, account)) + } + Operation::CreateIdentifier(create) => same_account(&create.identifier, account), + Operation::Receive(receive) => { + same_account(&receive.from, account) + || receive + .forward + .as_ref() + .is_some_and(|forward| same_account(forward, account)) + } + Operation::SetInfo(_) + | Operation::TokenAdminSupply(_) + | Operation::TokenAdminModifyBalance(_) + | Operation::ManageCertificate(_) => false, + } +} + +/// Whether two accounts share the same public-key identity (algorithm plus +/// raw key bytes), mirroring the TS `comparePublicKey`. +fn same_account(candidate: &AccountRef, account: &impl AccountPublicKey) -> bool { + candidate.to_keypair_type() == account.to_keypair_type() + && candidate.as_public_key_bytes() == account.as_public_key_bytes() +} + /// Extract the SEND and RECEIVE operations from a swap-request block. fn swap_legs(block: &Block) -> Result<(&Send, &Receive), ClientError> { let mut send = None; @@ -744,7 +824,7 @@ fn assert_swap_amount(amount: &Amount, expected: &SwapTokenAmount) -> Result<(), #[cfg(test)] mod tests { - use keetanetwork_block::testing::generate_ed25519_ref; + use keetanetwork_block::testing::{generate_ed25519_ref, generate_identifier_ref}; use core::mem::discriminant; @@ -831,4 +911,86 @@ mod tests { }; assert_rejects(expectation, false, ClientError::SwapAmountMismatch); } + + type BlockResult = Result<(), Box>; + + /// A send from the producing account 1 to `recipient`. + fn send_to(recipient: u8) -> Operation { + Operation::Send(Send { + to: generate_ed25519_ref(recipient), + amount: Amount::from(10u64), + token: generate_identifier_ref(1, KeyPairType::TOKEN, 0), + external: None, + }) + } + + /// A SET_INFO carrying no account references, so it never matches a + /// foreign filter. + fn set_info() -> Operation { + Operation::SetInfo(SetInfo { + name: String::new(), + description: String::new(), + metadata: String::new(), + default_permission: None, + }) + } + + /// A signed opening block for account 1 carrying `operations`. + fn effects_block(operations: Vec) -> Result> { + let mut builder = keetanetwork_block::BlockBuilder::default() + .with_network(0u8) + .with_account(generate_ed25519_ref(1)) + .as_opening(); + for operation in operations { + builder = builder.with_operation(operation); + } + + Ok(builder.build()?.sign()?) + } + + #[test] + fn producer_blocks_keep_every_operation() -> BlockResult { + let block = effects_block(vec![send_to(2), set_info()])?; + + let effects = block_effects(&block, &*generate_ed25519_ref(1)); + assert_eq!(effects.operation_indexes, vec![0, 1]); + Ok(()) + } + + #[test] + fn foreign_blocks_drop_operations_not_naming_the_account() -> BlockResult { + let block = effects_block(vec![send_to(2), set_info()])?; + + let effects = block_effects(&block, &*generate_ed25519_ref(9)); + assert!(effects.operation_indexes.is_empty()); + Ok(()) + } + + #[test] + fn send_recipients_see_only_the_send_operation() -> BlockResult { + let block = effects_block(vec![set_info(), send_to(2)])?; + + let effects = block_effects(&block, &*generate_ed25519_ref(2)); + assert_eq!(effects.operation_indexes, vec![1]); + assert!(matches!(effects.operations().next(), Some(Operation::Send(_)))); + Ok(()) + } + + #[test] + fn receive_forwards_see_the_receive_operation() -> BlockResult { + let forward = generate_ed25519_ref(7); + let receive = Receive { + amount: Amount::from(5u64), + token: generate_identifier_ref(1, KeyPairType::TOKEN, 0), + from: generate_ed25519_ref(2), + exact: true, + forward: Some(Arc::clone(&forward)), + }; + let block = effects_block(vec![Operation::Receive(receive)])?; + + let effects = block_effects(&block, &*forward); + assert_eq!(effects.operation_indexes, vec![0]); + assert!(matches!(effects.operations().next(), Some(Operation::Receive(_)))); + Ok(()) + } } diff --git a/keetanetwork-client/tests/e2e.rs b/keetanetwork-client/tests/e2e.rs index d60861f..e6f7108 100644 --- a/keetanetwork-client/tests/e2e.rs +++ b/keetanetwork-client/tests/e2e.rs @@ -11,10 +11,11 @@ use std::sync::Arc; use keetanetwork_account::{AccountPublicKey, GenericAccount, KeyPairType}; use keetanetwork_block::testing::generate_ed25519_ref; -use keetanetwork_block::{AccountRef, AdjustMethod, Amount, Block, Hashable, Operation, SetInfo}; +use keetanetwork_block::{AccountRef, AdjustMethod, Amount, Block, BlockHash, BlockTime, Hashable, Operation, SetInfo}; use keetanetwork_client::{ AcceptSwapRequest, ChainQuery, ClientConfig, ClientError, CreateSwapRequest, HistoryQuery, InitializeNetwork, - KeetaClient, KeetaNetError, Network, NodeErrorType, RepEndpoint, TransactionBuilder, TransmitOptions, UserClient, + KeetaClient, KeetaNetError, LedgerSide, Network, NodeErrorType, RepEndpoint, TransactionBuilder, TransmitOptions, + UserClient, }; use keetanetwork_utils::node_harness::E2eNode; use num_bigint::BigInt; @@ -39,8 +40,7 @@ const REP_SEED_BYTE: u8 = 0x5a; struct Fixture { node: E2eNode, client: KeetaClient, - trusted: String, - base_token: String, + accounts: SigningAccounts, blocks: Vec, } @@ -54,7 +54,7 @@ impl Fixture { fn head_hash(&mut self) -> String { let head = self .node - .request("head", json!({ "account": self.trusted })) + .request("head", json!({ "account": self.accounts.trusted.to_string() })) .expect("the head query must succeed"); head["head"] @@ -76,7 +76,7 @@ impl Drop for Fixture { struct Published { fixture: Fixture, head: Block, - head_hash: String, + head_hash: BlockHash, } /// The future a probe produces @@ -174,7 +174,7 @@ async fn fixture() -> Fixture { .await .expect("the client must build and sign a send block"); - Fixture { node, client, trusted, base_token, blocks: vec![block] } + Fixture { node, client, accounts, blocks: vec![block] } } /// Boot a fixture and publish its send block, capturing the resulting head. @@ -190,11 +190,14 @@ async fn published() -> Published { let head = fixture .client - .head_block(&fixture.trusted) + .head_block(&*fixture.accounts.trusted) .await .expect("the head query must succeed") .expect("the trusted account head must advance once the send is published"); - let head_hash = fixture.head_hash(); + let head_hash = fixture + .head_hash() + .parse() + .expect("the harness head hash must parse"); Published { fixture, head, head_hash } } @@ -209,36 +212,41 @@ fn read_only_cases() -> Vec> { require(!version.is_empty(), "version is empty") }), case!("balance reflects minted supply", |fx| { - let balance = fx.client.balance(&fx.trusted, &fx.base_token).await?; + let balance = fx + .client + .balance(&*fx.accounts.trusted, &*fx.accounts.token) + .await?; require(balance == Amount::from(MINTED_SUPPLY), format!("got {balance:?}")) }), case!("balances include the base token", |fx| { - let balances = fx.client.balances(&fx.trusted).await?; - let base = balances.iter().find(|entry| entry.token == fx.base_token); + let balances = fx.client.balances(&*fx.accounts.trusted).await?; + let base_token = fx.accounts.token.to_string(); + let base = balances.iter().find(|entry| entry.token == base_token); require( base.is_some_and(|entry| entry.balance == Amount::from(MINTED_SUPPLY)), "base token balance mismatch", ) }), case!("account state reports representative, head and balances", |fx| { - let state = fx.client.state(&fx.trusted).await?; + let state = fx.client.state(&*fx.accounts.trusted).await?; let recipient = fx.recipient(); let representative_matches = state.representative.as_deref() == Some(recipient.as_str()); require(representative_matches, "representative mismatch")?; require(state.head.is_some(), "missing head block")?; + let base_token = fx.accounts.token.to_string(); let base = state .balances .iter() - .find(|entry| entry.token == fx.base_token); + .find(|entry| entry.token == base_token); let base_matches = base.is_some_and(|entry| entry.balance == Amount::from(MINTED_SUPPLY)); require(base_matches, "base balance mismatch") }), case!("base token reports its minted supply", |fx| { - let supply = fx.client.token_supply(&fx.base_token).await?; + let supply = fx.client.token_supply(&*fx.accounts.token).await?; require(supply == Some(Amount::from(MINTED_SUPPLY)), format!("got {supply:?}")) }), case!("non-token account reports no supply", |fx| { - let supply = fx.client.token_supply(&fx.trusted).await?; + let supply = fx.client.token_supply(&*fx.accounts.trusted).await?; require(supply.is_none(), format!("unexpected supply {supply:?} for a non-token account")) }), case!("node stats is an object", |fx| { @@ -258,7 +266,7 @@ fn read_only_cases() -> Vec> { require(rep.account == fx.recipient(), "representative mismatch") }), case!("representative lookup echoes the account", |fx| { - let rep = fx.client.representative(fx.recipient()).await?; + let rep = fx.client.representative(&*fx.accounts.recipient).await?; require(rep.account == fx.recipient(), "representative mismatch") }), case!("representative set includes the node rep", |fx| { @@ -266,16 +274,19 @@ fn read_only_cases() -> Vec> { require(all.iter().any(|rep| rep.account == fx.recipient()), "node rep absent") }), case!("principal ACLs carry permission bitmaps", |fx| { - let acls = fx.client.acls_by_principal(&fx.trusted).await?; + let acls = fx.client.acls_by_principal(&*fx.accounts.trusted).await?; require(!acls.is_empty(), "no principal ACLs")?; require(acls.iter().all(|acl| !acl.permissions.is_empty()), "empty permissions") }), case!("granted ACL query succeeds", |fx| { - fx.client.acls_by_entity(&fx.trusted).await?; + fx.client.acls_by_entity(&*fx.accounts.trusted).await?; Ok(()) }), case!("additional ACL aggregate is an object", |fx| { - let additional = fx.client.acls_by_principal_with_info(&fx.trusted).await?; + let additional = fx + .client + .acls_by_principal_with_info(&*fx.accounts.trusted) + .await?; require(additional.is_object(), "aggregate not an object") }), case!("vote covers at least one block", |fx| { @@ -287,24 +298,29 @@ fn read_only_cases() -> Vec> { Ok(()) }), case!("batch account states return one entry per account", |fx| { - let recipient = fx.recipient(); - let states = fx.client.states(&[&fx.trusted, &recipient]).await?; + let states = fx + .client + .states(&[&*fx.accounts.trusted, &*fx.accounts.recipient]) + .await?; require(states.len() == 2, format!("got {} states", states.len()))?; require(states[0].representative.is_some(), "missing representative") }), case!("unknown block hash resolves to none", |fx| { - let block = fx.client.block("0".repeat(64), None).await?; + let block = fx.client.block(BlockHash::from([0u8; 32]), None).await?; require(block.is_none(), "unexpected block") }), case!("unknown idempotent key resolves to none", |fx| { let block = fx .client - .block_by_idempotent(&fx.trusted, "unknown-idempotent-key") + .block_by_idempotent(&*fx.accounts.trusted, "unknown-idempotent-key", None) .await?; require(block.is_none(), "unexpected block") }), case!("unknown certificate hash resolves to none", |fx| { - let certificate = fx.client.certificate(&fx.trusted, "0".repeat(64)).await?; + let certificate = fx + .client + .certificate(&*fx.accounts.trusted, [0u8; 32]) + .await?; require(certificate.is_none(), "unexpected certificate") }), ] @@ -325,7 +341,10 @@ async fn test_read_only_queries() { async fn test_pending_block_absent_is_none() -> Result<(), Box> { let fixture = fixture().await; - let pending = fixture.client.pending_block(&fixture.trusted).await?; + let pending = fixture + .client + .pending_block(&*fixture.accounts.trusted) + .await?; assert!(pending.is_none(), "an account with no staged side block must have no pending block"); Ok(()) @@ -340,16 +359,24 @@ fn post_transmit_cases() -> Vec> { let balance = ctx .fixture .client - .balance(&ctx.fixture.trusted, &ctx.fixture.base_token) + .balance(&*ctx.fixture.accounts.trusted, &*ctx.fixture.accounts.token) .await?; require(balance == Amount::from(MINTED_SUPPLY - SEND_AMOUNT), format!("got {balance:?}")) }), case!("chain contains blocks after the send", |ctx| { - let chain = ctx.fixture.client.chain(&ctx.fixture.trusted).await?; + let chain = ctx + .fixture + .client + .chain(&*ctx.fixture.accounts.trusted) + .await?; require(!chain.is_empty(), "empty chain") }), case!("account history contains a staple after the send", |ctx| { - let history = ctx.fixture.client.history(&ctx.fixture.trusted).await?; + let history = ctx + .fixture + .client + .history(&*ctx.fixture.accounts.trusted) + .await?; require(!history.is_empty(), "empty history") }), case!("global history contains a staple after the send", |ctx| { @@ -360,7 +387,7 @@ fn post_transmit_cases() -> Vec> { let staples = ctx .fixture .client - .vote_staples_after("1970-01-01T00:00:00.000Z") + .vote_staples_after(BlockTime::default()) .await?; require(!staples.is_empty(), "no staples") }), @@ -368,7 +395,7 @@ fn post_transmit_cases() -> Vec> { let fetched = ctx .fixture .client - .block(&ctx.head_hash, None) + .block(ctx.head_hash, None) .await? .ok_or("head block not retrievable by hash")?; let bytes_match = fetched.to_bytes() == ctx.head.to_bytes(); @@ -378,10 +405,10 @@ fn post_transmit_cases() -> Vec> { let (block, height) = ctx .fixture .client - .account_head_info(&ctx.fixture.trusted) + .account_head_info(&*ctx.fixture.accounts.trusted) .await? .ok_or("account head info must be present once the send is published")?; - let head_matches = block.hash().to_string() == ctx.head_hash; + let head_matches = block.hash() == ctx.head_hash; require(head_matches, "head info block mismatch")?; require(*height.as_bigint() > BigInt::from(0u8), format!("unexpected height {height:?}")) }), @@ -389,17 +416,17 @@ fn post_transmit_cases() -> Vec> { let staple = ctx .fixture .client - .vote_staple(&ctx.head_hash) + .vote_staple(ctx.head_hash) .await? .ok_or("a vote staple must be retrievable for the published head")?; let contains_head = staple .blocks() .iter() - .any(|block| block.hash().to_string() == ctx.head_hash); + .any(|block| block.hash() == ctx.head_hash); require(contains_head, "the vote staple must contain the head block") }), case!("head block has no successor", |ctx| { - let successor = ctx.fixture.client.successor_block(&ctx.head_hash).await?; + let successor = ctx.fixture.client.successor_block(ctx.head_hash).await?; require(successor.is_none(), "unexpected successor") }), case!("block votes endpoint returns the head's votes", |ctx| { @@ -407,7 +434,7 @@ fn post_transmit_cases() -> Vec> { .fixture .client .transport() - .get_block_votes(&ctx.head_hash, None) + .get_block_votes(&ctx.head_hash.to_string(), None) .await?; let votes = response .into_inner() @@ -420,13 +447,34 @@ fn post_transmit_cases() -> Vec> { let chain = ctx .fixture .client - .chain_all(&ctx.fixture.trusted, 1) + .chain_all(&*ctx.fixture.accounts.trusted, 1) .await?; require(!chain.is_empty(), "empty auto-paged chain") }), + case!("auto-paged history covers the unpaged history", |ctx| { + let history = ctx + .fixture + .client + .history(&*ctx.fixture.accounts.trusted) + .await?; + let paged = ctx + .fixture + .client + .history_all(&*ctx.fixture.accounts.trusted, 1) + .await?; + require(paged.len() >= history.len(), "auto-paged history must cover the unpaged history") + }), + case!("auto-paged global history covers the unpaged global history", |ctx| { + let history = ctx.fixture.client.global_history().await?; + let paged = ctx.fixture.client.global_history_all(1).await?; + require(paged.len() >= history.len(), "auto-paged global history must cover the unpaged global history") + }), case!("sync is a ignored when the only rep is in sync", |ctx| { - let account: AccountRef = Arc::new(GenericAccount::from_str(&ctx.fixture.trusted)?); - let synced = ctx.fixture.client.sync_account(&account, false).await?; + let synced = ctx + .fixture + .client + .sync_account(&ctx.fixture.accounts.trusted, false) + .await?; require(synced.is_none(), "single in-sync rep should not produce a sync staple") }), ] @@ -463,8 +511,8 @@ async fn test_conflicting_vote_request_is_typed_node_error() -> Result<(), Box (E2eNode, KeetaClient, SigningAccounts, String) { +/// a network-configured client, and the derived signing accounts. +fn fee_fixture() -> (E2eNode, KeetaClient, SigningAccounts) { let mut node = E2eNode::start_with_fee(FEE_AMOUNT).expect("the fee-enforcing harness must start"); let network = BigInt::from_str(&ready_field(&node, "network")).expect("the network id must parse"); @@ -476,17 +524,15 @@ fn fee_fixture() -> (E2eNode, KeetaClient, SigningAccounts, String) { let accounts = signing_accounts(&base_token).expect("the signing accounts must derive"); - (node, client, accounts, base_token) + (node, client, accounts) } /// A native send against a fee-enforcing node must originate the required fee /// block, be accepted, and debit the sender for the amount plus the fee. #[tokio::test(flavor = "multi_thread")] async fn test_send_with_required_fee_is_accepted() -> Result<(), Box> { - let (_node, client, accounts, base_token) = fee_fixture(); - let before = client - .balance(accounts.trusted.to_string(), &base_token) - .await?; + let (_node, client, accounts) = fee_fixture(); + let before = client.balance(&*accounts.trusted, &*accounts.token).await?; let quote_block = send_block(&client, &accounts, &accounts.recipient, SEND_AMOUNT).await?; let quotes = client.quotes(&[quote_block]).await?; @@ -497,9 +543,7 @@ async fn test_send_with_required_fee_is_accepted() -> Result<(), Box Result<(), Box Result<(), Box> { - let (_node, client, accounts, _base_token) = fee_fixture(); + let (_node, client, accounts) = fee_fixture(); let block = send_block(&client, &accounts, &accounts.recipient, SEND_AMOUNT).await?; let result = client.transmit(&[block], TransmitOptions::default()).await; @@ -768,21 +812,23 @@ fn assert_heads_diverged(node: &mut E2eNode, account: &str, block: &Block) -> Re async fn assert_converged_send( fixture: &mut ClusterFixture, block: &Block, - trusted: &str, - base_token: &str, + accounts: &SigningAccounts, ) -> Result<(), Box> { fixture.converge().await?; let head = fixture .client - .head_block(trusted) + .head_block(&*accounts.trusted) .await? .ok_or("the trusted account must have a head once the staple publishes")?; let head_hash = head.hash().to_string(); let block_hash = block.hash().to_string(); assert_eq!(head_hash, block_hash, "the published block must become the cluster-wide head"); - let balance = fixture.client.balance(trusted, base_token).await?; + let balance = fixture + .client + .balance(&*accounts.trusted, &*accounts.token) + .await?; assert_eq!( balance, Amount::from(MINTED_SUPPLY - SEND_AMOUNT), @@ -797,18 +843,19 @@ async fn assert_converged_send( #[tokio::test(flavor = "multi_thread")] async fn test_multi_rep_quorum_publish_and_convergence() -> Result<(), Box> { let mut fixture = ClusterFixture::start(CLUSTER_REPS).await?; - let trusted = fixture.trusted.clone(); - let base_token = fixture.base_token.clone(); + let accounts = fixture.accounts()?; // Reads dispatch across the cluster and endpoints decode from discovery. - let balance = fixture.client.balance(&trusted, &base_token).await?; + let balance = fixture + .client + .balance(&*accounts.trusted, &*accounts.token) + .await?; assert_eq!(balance, Amount::from(MINTED_SUPPLY), "every representative must report the minted supply"); let all = fixture.client.representatives().await?; assert!(all.iter().any(|rep| rep.api_url.is_some()), "the representative set must advertise endpoints"); // A client-built send must vote to quorum and publish across the cluster. - let accounts = fixture.accounts()?; let block = send_block(&fixture.client, &accounts, &accounts.recipient, SEND_AMOUNT).await?; let accepted = fixture @@ -818,7 +865,10 @@ async fn test_multi_rep_quorum_publish_and_convergence() -> Result<(), Box Result<(), Box> { // A send now requires aggregating the primary with at least one secondary @@ -932,7 +980,10 @@ async fn send_and_assert_debit( assert!(accepted, "the cluster must accept a staple that needed votes from more than one rep"); fixture.converge().await?; - let after_send = fixture.client.balance(trusted, base_token).await?; + let after_send = fixture + .client + .balance(&*accounts.trusted, &*accounts.token) + .await?; assert_eq!( after_send, Amount::from(primary - SEND_AMOUNT), @@ -955,8 +1006,6 @@ async fn send_after_rep_failure( fixture: &mut ClusterFixture, accounts: &SigningAccounts, account2: &AccountRef, - trusted: &str, - base_token: &str, primary: u64, ) -> Result<(), Box> { // The primary plus the surviving secondary still hold 0.8 of the weight, @@ -974,7 +1023,10 @@ async fn send_after_rep_failure( fixture.converge().await?; // Reads must still resolve, dispatching past the failed rep. - let after_failure = fixture.client.balance(trusted, base_token).await?; + let after_failure = fixture + .client + .balance(&*accounts.trusted, &*accounts.token) + .await?; assert_eq!( after_failure, Amount::from(primary - 2 * SEND_AMOUNT), @@ -988,8 +1040,6 @@ async fn send_after_rep_failure( #[tokio::test(flavor = "multi_thread")] async fn test_multi_rep_weighted_quorum_and_rep_failure() -> Result<(), Box> { let mut fixture = ClusterFixture::start(WEIGHTED_REPS).await?; - let trusted = fixture.trusted.clone(); - let base_token = fixture.base_token.clone(); let rep_accounts = fixture.rep_accounts.clone(); let accounts = fixture.accounts()?; @@ -1002,9 +1052,9 @@ async fn test_multi_rep_weighted_quorum_and_rep_failure() -> Result<(), Box Result<(), Box Result<(), Box> { let mut fixture = ClusterFixture::start(WEIGHTED_REPS).await?; - let trusted = fixture.trusted.clone(); - let base_token = fixture.base_token.clone(); let accounts = fixture.accounts()?; // Build a send block but never transmit it; instead stage it on every @@ -1029,7 +1077,7 @@ async fn test_multi_rep_recover_publishes_pending_side_block() -> Result<(), Box // The client must see the half-published block as the pending successor. let pending = fixture .client - .pending_block(&trusted) + .pending_block(&*accounts.trusted) .await? .ok_or("the staged side block must surface as the pending successor")?; let pending_hash = pending.hash().to_string(); @@ -1043,7 +1091,7 @@ async fn test_multi_rep_recover_publishes_pending_side_block() -> Result<(), Box .await?; assert!(recovered.is_some(), "recovery must produce a staple for the pending block"); - assert_converged_send(&mut fixture, &block, &trusted, &base_token).await?; + assert_converged_send(&mut fixture, &block, &accounts).await?; Ok(()) } @@ -1056,7 +1104,6 @@ async fn test_multi_rep_recover_publishes_pending_side_block() -> Result<(), Box async fn test_multi_rep_recover_reads_main_promoted_vote() -> Result<(), Box> { let mut fixture = ClusterFixture::start(WEIGHTED_REPS).await?; let trusted = fixture.trusted.clone(); - let base_token = fixture.base_token.clone(); let accounts = fixture.accounts()?; let block = send_block(&fixture.client, &accounts, &accounts.recipient, SEND_AMOUNT).await?; @@ -1076,7 +1123,7 @@ async fn test_multi_rep_recover_reads_main_promoted_vote() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { let mut fixture = ClusterFixture::start(WEIGHTED_REPS).await?; - let trusted = fixture.trusted.clone(); let accounts = fixture.accounts()?; // Stage a half-published successor on every rep's side ledger @@ -1119,7 +1165,7 @@ async fn test_send_rebuilds_after_recovering_successor_conflict() -> Result<(), fixture.converge().await?; let head = fixture .client - .head_block(&trusted) + .head_block(&*accounts.trusted) .await? .ok_or("the trusted account must have a head once the rebuilt send publishes")?; let head_hash = head.hash().to_string(); @@ -1140,15 +1186,15 @@ async fn test_send_rebuilds_after_recovering_successor_conflict() -> Result<(), #[tokio::test(flavor = "multi_thread")] async fn test_mod_token_supply_and_balance_admins_a_distinct_token() -> Result<(), Box> { let fixture = fixture().await; - let accounts = signing_accounts(&fixture.base_token)?; - let user = trusted_user(&fixture.client, &accounts); + let accounts = &fixture.accounts; + let user = trusted_user(&fixture.client, accounts); let accepted = user .modify_token_supply_and_balance(&accounts.token, None, Amount::from(SEND_AMOUNT), AdjustMethod::Add) .await?; assert!(accepted, "the node must accept the combined supply-and-balance staple"); - let supply = fixture.client.token_supply(&fixture.base_token).await?; + let supply = fixture.client.token_supply(&*accounts.token).await?; assert_eq!( supply, Some(Amount::from(MINTED_SUPPLY + SEND_AMOUNT)), @@ -1156,7 +1202,7 @@ async fn test_mod_token_supply_and_balance_admins_a_distinct_token() -> Result<( ); let balance = fixture .client - .balance(&fixture.trusted, &fixture.base_token) + .balance(&*fixture.accounts.trusted, &*fixture.accounts.token) .await?; assert_eq!( balance, @@ -1174,15 +1220,15 @@ async fn test_mod_token_supply_and_balance_admins_a_distinct_token() -> Result<( #[tokio::test(flavor = "multi_thread")] async fn test_mod_token_supply_and_balance_burns_supply_first() -> Result<(), Box> { let fixture = fixture().await; - let accounts = signing_accounts(&fixture.base_token)?; - let user = trusted_user(&fixture.client, &accounts); + let accounts = &fixture.accounts; + let user = trusted_user(&fixture.client, accounts); let accepted = user .modify_token_supply_and_balance(&accounts.token, None, Amount::from(SEND_AMOUNT), AdjustMethod::Subtract) .await?; assert!(accepted, "the node must accept a supply-first burn across the token/holder split"); - let supply = fixture.client.token_supply(&fixture.base_token).await?; + let supply = fixture.client.token_supply(&*accounts.token).await?; assert_eq!( supply, Some(Amount::from(MINTED_SUPPLY - SEND_AMOUNT)), @@ -1190,7 +1236,7 @@ async fn test_mod_token_supply_and_balance_burns_supply_first() -> Result<(), Bo ); let balance = fixture .client - .balance(&fixture.trusted, &fixture.base_token) + .balance(&*fixture.accounts.trusted, &*fixture.accounts.token) .await?; assert_eq!( balance, @@ -1206,8 +1252,8 @@ async fn test_mod_token_supply_and_balance_burns_supply_first() -> Result<(), Bo #[tokio::test(flavor = "multi_thread")] async fn test_account_state_surfaces_set_info_metadata() -> Result<(), Box> { let fixture = fixture().await; - let accounts = signing_accounts(&fixture.base_token)?; - let user = trusted_user(&fixture.client, &accounts); + let accounts = &fixture.accounts; + let user = trusted_user(&fixture.client, accounts); let accepted = user .set_info(SetInfo { @@ -1219,7 +1265,7 @@ async fn test_account_state_surfaces_set_info_metadata() -> Result<(), Box Result<(), Box Result<(), Box> { let fixture = fixture().await; - let accounts = signing_accounts(&fixture.base_token)?; - let user = trusted_user(&fixture.client, &accounts); + let accounts = &fixture.accounts; + let user = trusted_user(&fixture.client, accounts); let accepted = user .send_external(&accounts.recipient, &accounts.token, Amount::from(SEND_AMOUNT), "invoice-42") @@ -1249,7 +1295,7 @@ async fn test_user_client_send_external_attaches_reference() -> Result<(), Box Result<(), Box Result<(), Box> { let fixture = fixture().await; - let accounts = signing_accounts(&fixture.base_token)?; - let user = trusted_user(&fixture.client, &accounts); + let accounts = &fixture.accounts; + let user = trusted_user(&fixture.client, accounts); for _ in 0..3 { let accepted = user @@ -1289,15 +1335,51 @@ async fn test_user_client_chain_pagination_follows_cursor() -> Result<(), Box Result<(), Box> { + let fixture = fixture().await; + let accounts = &fixture.accounts; + let user = trusted_user(&fixture.client, accounts); + + for _ in 0..3 { + let accepted = user + .send(&accounts.recipient, &accounts.token, Amount::from(SEND_AMOUNT)) + .await?; + assert!(accepted, "each history-extending send must be accepted"); + } + + let paged = user.history_all(1).await?; + let single = user + .history_page(HistoryQuery { start: None, limit: Some(200) }) + .await?; + assert!(paged.len() >= 3, "the history must contain at least the three send staples"); + assert_eq!(paged.len(), single.len(), "cursor pagination must return the full history"); + + let paged_ids: Vec> = paged + .iter() + .map(|entry| entry.id.map(|id| id.to_string())) + .collect(); + let single_ids: Vec> = single + .iter() + .map(|entry| entry.id.map(|id| id.to_string())) + .collect(); + assert_eq!(paged_ids, single_ids, "paged order must match the single-page order"); + + Ok(()) +} + /// A by-hash certificate lookup for an account with no such certificate must /// resolve to `None`, exercising the `UserClient` by-hash surface. #[tokio::test(flavor = "multi_thread")] async fn test_user_client_certificate_by_hash_absent_is_none() -> Result<(), Box> { let fixture = fixture().await; - let accounts = signing_accounts(&fixture.base_token)?; - let user = trusted_user(&fixture.client, &accounts); + let accounts = &fixture.accounts; + let user = trusted_user(&fixture.client, accounts); - let certificate = user.certificate("0".repeat(64)).await?; + let certificate = user.certificate([0u8; 32]).await?; assert!(certificate.is_none(), "an unknown certificate hash must resolve to none"); Ok(()) @@ -1310,7 +1392,6 @@ async fn test_user_client_certificate_by_hash_absent_is_none() -> Result<(), Box async fn test_multi_rep_sync_repairs_lagging_rep() -> Result<(), Box> { let mut fixture = ClusterFixture::start(WEIGHTED_REPS).await?; let trusted = fixture.trusted.clone(); - let base_token = fixture.base_token.clone(); let accounts = fixture.accounts()?; // Build the successor and assemble a permanent staple from side votes. @@ -1328,7 +1409,7 @@ async fn test_multi_rep_sync_repairs_lagging_rep() -> Result<(), Box Result<(), Box fixture.node.request("manage_cert_add", json!({}))?; - let certificates = fixture.client.certificates(&fixture.trusted).await?; + let certificates = fixture + .client + .certificates(&*fixture.accounts.trusted) + .await?; assert!(!certificates.is_empty(), "the trusted account must hold a certificate after adding one"); assert!(!certificates[0].certificate.is_empty(), "the returned certificate must carry a PEM body"); @@ -1353,9 +1437,8 @@ async fn test_certificates_after_add() -> Result<(), Box #[tokio::test(flavor = "multi_thread")] async fn test_builder_creates_pending_identifier() -> Result<(), Box> { let mut fixture = fixture().await; - let accounts = signing_accounts(&fixture.base_token)?; - let mut builder = fixture.client.builder(&accounts.trusted); + let mut builder = fixture.client.builder(&fixture.accounts.trusted); let storage = builder.generate_identifier(KeyPairType::STORAGE, None); let blocks = builder.build().await?; assert_eq!(blocks.len(), 1, "a single originator must render exactly one block"); @@ -1381,7 +1464,7 @@ async fn test_builder_creates_pending_identifier() -> Result<(), Box Result<(), Box> { let fixture = fixture().await; - let accounts = signing_accounts(&fixture.base_token)?; + let accounts = &fixture.accounts; let account2 = generate_ed25519_ref(ACCOUNT2_SEED_BYTE); let mut builder = fixture.client.builder(&accounts.trusted); @@ -1396,12 +1479,12 @@ async fn test_multi_account_builder_staple() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { let fixture = fixture().await; - let accounts = signing_accounts(&fixture.base_token)?; - let user = trusted_user(&fixture.client, &accounts); + let accounts = &fixture.accounts; + let user = trusted_user(&fixture.client, accounts); let reported_signer = user.signer_account().map(|signer| signer.to_string()); assert_eq!(reported_signer, Some(accounts.trusted.to_string()), "the bound signer must be reported"); assert!(!user.is_read_only(), "a signer-bound client must be writable"); let operating_account = user.account()?.to_string(); assert_eq!(operating_account, accounts.trusted.to_string(), "reads must default to the signer's account"); - let balance = user.balance(&fixture.base_token).await?; + let balance = user.balance(&*accounts.token).await?; assert_eq!(balance, Amount::from(MINTED_SUPPLY), "the balance wrapper must delegate"); + let base_token = accounts.token.to_string(); let balances = user.all_balances().await?; - assert!( - balances - .iter() - .any(|entry| entry.token == fixture.base_token), - "all_balances must include the base token" - ); + assert!(balances.iter().any(|entry| entry.token == base_token), "all_balances must include the base token"); let state = user.state().await?; - assert!( - state - .balances - .iter() - .any(|entry| entry.token == fixture.base_token), - "state must carry the base balance" - ); + assert!(state.balances.iter().any(|entry| entry.token == base_token), "state must carry the base balance"); assert!(user.head().await?.is_some(), "the funded account must have a head"); assert!(!user.chain().await?.is_empty(), "the chain wrapper must return blocks"); @@ -1565,12 +1639,32 @@ async fn test_user_client_read_surface() -> Result<(), Box = history.iter().map(|entry| entry.staple.clone()).collect(); + let effects = user.staple_effects(&staples)?; + assert_eq!(effects.len(), staples.len(), "every staple must be keyed in the effects map"); + + let named_operations = effects + .values() + .flatten() + .flat_map(|block| block.operations()) + .count(); + assert!(named_operations > 0, "the operating account's history must carry operations involving it"); let _ = user.certificates().await?; - assert!(user.certificate("0".repeat(64)).await?.is_none(), "an unknown certificate hash must resolve to none"); + assert!(user.certificate([0u8; 32]).await?.is_none(), "an unknown certificate hash must resolve to none"); let quotes = user.quotes(&fixture.blocks).await?; assert!(!quotes.is_empty(), "every responding representative must return a quote"); @@ -1584,8 +1678,8 @@ async fn test_user_client_read_surface() -> Result<(), Box Result<(), Box> { let fixture = fixture().await; - let accounts = signing_accounts(&fixture.base_token)?; - let user = trusted_user(&fixture.client, &accounts); + let accounts = &fixture.accounts; + let user = trusted_user(&fixture.client, accounts); assert!(user.set_rep(&accounts.recipient).await?, "set_rep must publish"); let state = user.state().await?; @@ -1615,8 +1709,8 @@ async fn test_user_client_write_surface() -> Result<(), Box Result<(), Box> { let fixture = fixture().await; - let accounts = signing_accounts(&fixture.base_token)?; - let maker = trusted_user(&fixture.client, &accounts); + let accounts = &fixture.accounts; + let maker = trusted_user(&fixture.client, accounts); let request = maker .create_swap_request(CreateSwapRequest { @@ -1637,7 +1731,7 @@ async fn test_user_client_swap_request_round_trip_builds() -> Result<(), Box Result<(), Box Result<(), Box> { let fixture = fixture().await; - let accounts = signing_accounts(&fixture.base_token)?; + let accounts = &fixture.accounts; - let split = trusted_user(&fixture.client, &accounts).with_account(Arc::clone(&accounts.recipient)); + let split = trusted_user(&fixture.client, accounts).with_account(Arc::clone(&accounts.recipient)); let split_account = split.account()?.to_string(); assert_eq!(split_account, accounts.recipient.to_string(), "with_account must override the read/originator account"); let split_signer = split.signer_account().map(|signer| signer.to_string()); @@ -1679,7 +1773,7 @@ async fn test_user_client_account_split_and_transmit() -> Result<(), Box { $(#[$meta])* #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -76,17 +76,17 @@ macro_rules! digest_newtype { }; } -digest_newtype!( +digest_new_type!( /// Hash of an entire serialized vote certificate. VoteHash ); -digest_newtype!( +digest_new_type!( /// Hash of a canonical, uncompressed vote staple body. VoteStapleHash ); -digest_newtype!( +digest_new_type!( /// Hash of the block hashes covered by a vote or staple, independent of /// which vote subset is included. VoteBlockHash From 78ee94e8ca054bf9d38b10c8d786dfeb28b76608 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Tue, 7 Jul 2026 15:11:21 -0700 Subject: [PATCH 2/3] chore: cleanup --- .../host-tests/tests/p2_net.rs | 17 +- keetanetwork-client-wasi/src/p2/mod.rs | 82 ++++++-- keetanetwork-client-wasi/wit/world.wit | 33 ++- keetanetwork-client-wasm/src/client.rs | 23 ++- keetanetwork-client-wasm/src/dto.rs | 63 ++++-- .../openapi/keetanet-node.yaml | 11 +- keetanetwork-client/src/client.rs | 33 ++- keetanetwork-client/src/codec.rs | 188 +++++++++++++++++- keetanetwork-client/src/error.rs | 19 +- keetanetwork-client/src/lib.rs | 5 +- keetanetwork-client/src/model.rs | 44 ++-- keetanetwork-client/src/transport.rs | 33 ++- keetanetwork-client/tests/e2e.rs | 97 ++++++--- 13 files changed, 504 insertions(+), 144 deletions(-) diff --git a/keetanetwork-client-wasi/host-tests/tests/p2_net.rs b/keetanetwork-client-wasi/host-tests/tests/p2_net.rs index 8c3993c..a66d29e 100644 --- a/keetanetwork-client-wasi/host-tests/tests/p2_net.rs +++ b/keetanetwork-client-wasi/host-tests/tests/p2_net.rs @@ -22,7 +22,7 @@ mod bindings { use bindings::exports::keeta::client::crypto::KeyAlgorithm; use bindings::exports::keeta::client::node::{ - AdjustMethod, BasePermission, ChainQuery, CodedError, HistoryQuery, IdentifierKind, LedgerSide, + AclPrincipal, AdjustMethod, BasePermission, ChainQuery, CodedError, HistoryQuery, IdentifierKind, LedgerSide, }; use bindings::KeetaClient; @@ -471,14 +471,22 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { .await? .map_err(coded)?; assert!(!user_acls.is_empty(), "the genesis account must hold ACLs as principal after network init"); + let scoped_as_principal = user_acls .iter() - .all(|acl| acl.principal.as_deref() == Some(trusted.as_str())); + .all(|acl| matches!(&acl.principal, Some(AclPrincipal::Account(account)) if account == &trusted)); assert!(scoped_as_principal, "every principal-scoped ACL must name the operating account as principal"); + let grants_base_token = user_acls .iter() .any(|acl| acl.entity.as_deref() == Some(base_token.as_str())); assert!(grants_base_token, "the genesis grants must include the base token as entity"); + + let carries_flags = user_acls + .iter() + .all(|acl| acl.permissions != BasePermission::empty()); + assert!(carries_flags, "every genesis ACL must decode to non-empty base permission flags"); + let node_acls = node .client() .call_acls_by_principal(&mut store, client, trusted_account) @@ -497,6 +505,7 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { .iter() .all(|acl| acl.entity.as_deref() == Some(trusted.as_str())); assert!(scoped_as_entity, "every entity-scoped ACL must name the operating account as entity"); + let node_entity_acls = node .client() .call_acls_by_entity(&mut store, client, trusted_account) @@ -514,6 +523,7 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { .await? .map_err(coded)?; assert!(user_certificates.is_empty(), "a fresh account must hold no certificates"); + let user_certificate = node .user_client() .call_certificate(&mut store, user, &"00".repeat(32)) @@ -527,6 +537,7 @@ async fn p2_reads_against_e2e_node() -> wasmtime::Result<()> { .await? .map_err(coded)?; assert!(user_synced.is_none(), "a single-rep user-client must have nothing to sync"); + let user_recovered = node .user_client() .call_recover(&mut store, user, false) @@ -756,7 +767,7 @@ async fn p2_writes_against_e2e_node() -> wasmtime::Result<()> { .map_err(coded)?; let lists_representative = acls .iter() - .any(|acl| acl.principal.as_deref() == Some(representative.as_str())); + .any(|acl| matches!(&acl.principal, Some(AclPrincipal::Account(account)) if account == &representative)); assert!(lists_representative, "the granted ACL must list the representative as principal"); // `generate-multisig`: create a 1-of-2 multisig identifier on-chain. diff --git a/keetanetwork-client-wasi/src/p2/mod.rs b/keetanetwork-client-wasi/src/p2/mod.rs index 96497f3..a1ddbf1 100644 --- a/keetanetwork-client-wasi/src/p2/mod.rs +++ b/keetanetwork-client-wasi/src/p2/mod.rs @@ -15,10 +15,11 @@ use keetanetwork_block::{ }; use keetanetwork_client::{ AcceptSwapRequest, AccountInfo as CoreInfo, AccountState as CoreState, Acl as CoreAcl, - BlockEffects as CoreBlockEffects, Certificate as CoreCertificate, ChainQuery, ClientConfig, ClientError, - CreateSwapRequest, HistoryEntry as CoreHistory, HistoryQuery, KeetaClient, LedgerChecksum as CoreChecksum, - LedgerSide, RepPart, Representative as CoreRep, Runtime, SwapExpectation, SwapTokenAmount, TransactionBuilder, - TransmitOptions, UserClient, VoteBlockHash, WasiRuntime, WasiTransportFactory, + AclPrincipal as CoreAclPrincipal, BlockEffects as CoreBlockEffects, Certificate as CoreCertificate, ChainQuery, + ClientConfig, ClientError, CreateSwapRequest, HistoryEntry as CoreHistory, HistoryQuery, KeetaClient, + LedgerChecksum as CoreChecksum, LedgerSide, RepPart, Representative as CoreRep, Runtime, SwapExpectation, + SwapTokenAmount, TokenBalance as CoreTokenBalance, TransactionBuilder, TransmitOptions, UserClient, VoteBlockHash, + WasiRuntime, WasiTransportFactory, }; use keetanetwork_x509::certificates::Certificate as X509Certificate; use num_bigint::BigInt; @@ -36,7 +37,8 @@ use exports::keeta::client::crypto::{ Guest as CryptoGuest, GuestAccount, GuestCertificate, KeyAlgorithm as WitKeyAlgorithm, }; use exports::keeta::client::node::{ - AccountInfo, AccountState, Acl, AdjustMethod as WitAdjustMethod, BasePermission as WitBasePermission, + AccountInfo, AccountState, Acl, AclCertificatePrincipal as WitAclCertificatePrincipal, + AclPrincipal as WitAclPrincipal, AdjustMethod as WitAdjustMethod, BasePermission as WitBasePermission, BlockBuilder as BlockBuilderResource, BlockEffects as WitBlockEffects, Certificate, ChainPage, ChainQuery as WitChainQuery, CodedError, Guest, GuestBlockBuilder, GuestClient, GuestTransaction, GuestUserClient, HeadInfo, HistoryEntry, HistoryQuery as WitHistoryQuery, IdentifierKind as WitIdentifierKind, LedgerChecksum, @@ -304,6 +306,15 @@ fn permissions_of(flags: WitBasePermission) -> Result { Ok(bindings_permissions::from_flags(&flags, &[])?) } +/// Project a domain permission set onto the WIT base-permission flags. +fn wit_permissions_of(permissions: &Permissions) -> WitBasePermission { + let flags = permissions.base().flags(); + PERMISSION_FLAGS + .iter() + .filter(|(_, base)| flags.contains(base)) + .fold(WitBasePermission::empty(), |set, &(wit, _)| set | wit) +} + /// A single-representative KeetaNet client backed by the WASI transport. struct NodeClient { inner: KeetaClient, @@ -329,7 +340,10 @@ impl From for CodedError { impl From for Representative { fn from(rep: CoreRep) -> Self { - Self { account: rep.account, weight: amount_to_string(rep.weight), api_url: rep.api_url } + let account = rep.account.to_string(); + let weight = amount_to_string(rep.weight); + + Self { account, weight, api_url: rep.api_url } } } @@ -342,16 +356,14 @@ impl From for AccountInfo { impl From for AccountState { fn from(state: CoreState) -> Self { Self { - representative: state.representative, + representative: state + .representative + .map(|representative| representative.to_string()), head: state.head.map(|head| head.to_string()), height: state.height.map(amount_to_string), info: state.info.map(AccountInfo::from), supply: state.supply.map(amount_to_string), - balances: state - .balances - .into_iter() - .map(|balance| TokenBalance { token: balance.token, amount: amount_to_string(balance.balance) }) - .collect(), + balances: state.balances.into_iter().map(TokenBalance::from).collect(), } } } @@ -360,12 +372,21 @@ impl From for LedgerChecksum { fn from(checksum: CoreChecksum) -> Self { Self { checksum: amount_to_string(checksum.checksum), - moment: checksum.moment, + moment: checksum.moment.map(|moment| moment.to_string()), moment_range: checksum.moment_range, } } } +impl From for TokenBalance { + fn from(balance: CoreTokenBalance) -> Self { + let amount = amount_to_string(balance.balance); + let token = balance.token.to_string(); + + Self { token, amount } + } +} + impl From for HistoryEntry { fn from(entry: CoreHistory) -> Self { Self { @@ -414,9 +435,27 @@ impl TryFrom for HistoryQuery { } } +impl From<&CoreAclPrincipal> for WitAclPrincipal { + fn from(principal: &CoreAclPrincipal) -> Self { + match principal { + CoreAclPrincipal::Account(account) => Self::Account(account.to_string()), + CoreAclPrincipal::Certificate { hash, account } => Self::Certificate(WitAclCertificatePrincipal { + certificate: hex::encode(hash), + account: account.to_string(), + }), + } + } +} + impl From for Acl { fn from(acl: CoreAcl) -> Self { - Self { principal: acl.principal, entity: acl.entity, target: acl.target, permissions: acl.permissions } + Self { + principal: acl.principal.as_ref().map(WitAclPrincipal::from), + entity: acl.entity.map(|entity| entity.to_string()), + target: acl.target.map(|target| target.to_string()), + permissions: wit_permissions_of(&acl.permissions), + external_permissions: bindings_permissions::offsets(&acl.permissions), + } } } @@ -497,7 +536,7 @@ impl GuestClient for NodeClient { let account = account_of(account); Ok(run(self.inner.balances(&*account))? .into_iter() - .map(|balance| TokenBalance { token: balance.token, amount: amount_to_string(balance.balance) }) + .map(TokenBalance::from) .collect()) } @@ -521,9 +560,9 @@ impl GuestClient for NodeClient { Ok(run(self.inner.block(blockhash, side.map(Into::into)))?.map(|block| pure::block_to_hex(&block))) } - fn vote_staple(&self, blockhash: String) -> Result, CodedError> { + fn vote_staple(&self, blockhash: String, side: Option) -> Result, CodedError> { let blockhash = parse_block_hash(&blockhash)?; - Ok(run(self.inner.vote_staple(blockhash))?.map(|staple| pure::staple_to_hex(&staple))) + Ok(run(self.inner.vote_staple(blockhash, side.map(Into::into)))?.map(|staple| pure::staple_to_hex(&staple))) } fn representative(&self, rep: AccountBorrow<'_>) -> Result { @@ -736,6 +775,7 @@ impl GuestUserClient for AccountClient { let client = single_rep_client(base_url).with_network(network); let inner = UserClient::from_parts(client, Some(signer)); + Ok(UserClientResource::new(Self { inner })) } @@ -751,7 +791,7 @@ impl GuestUserClient for AccountClient { fn all_balances(&self) -> Result, CodedError> { Ok(run(self.inner.all_balances())? .into_iter() - .map(|balance| TokenBalance { token: balance.token, amount: amount_to_string(balance.balance) }) + .map(TokenBalance::from) .collect()) } @@ -870,6 +910,7 @@ impl GuestUserClient for AccountClient { let to = account_of(to); let token = account_of(token); let amount = parse_amount(&amount)?; + run(self.inner.send(&to, &token, amount)) } @@ -883,6 +924,7 @@ impl GuestUserClient for AccountClient { let to = account_of(to); let token = account_of(token); let amount = parse_amount(&amount)?; + run(self.inner.send_external(&to, &token, amount, external)) } @@ -934,6 +976,7 @@ impl GuestUserClient for AccountClient { true => None, false => Some(permissions_of(permissions)?), }; + let target = target.map(account_of); let change = ModifyPermissions { principal, method: AdjustMethod::from(method), permissions, target }; run(self.inner.update_permissions(change)) @@ -1130,6 +1173,7 @@ impl BuilderState { fn stage(&self, change: impl FnOnce(BlockBuilder) -> BlockBuilder) -> Result<(), CodedError> { let mut slot = self.builder.borrow_mut(); let builder = slot.take().ok_or_else(builder_consumed)?; + *slot = Some(change(builder)); Ok(()) } @@ -1207,6 +1251,7 @@ impl GuestBlockBuilder for BuilderState { let principal = account_of(principal); let permissions = permissions_of(permissions)?; let target = target.map(account_of); + let operation = pure::op_modify_permissions(principal, permissions, AdjustMethod::from(method), target); self.stage(|builder| builder.with_operation(operation)) } @@ -1236,6 +1281,7 @@ impl GuestBlockBuilder for BuilderState { .take() .ok_or_else(builder_consumed)?; let unsigned = pure::build_unsigned(builder)?; + let signed = pure::sign_unsigned(unsigned)?; Ok(pure::block_to_hex(&signed)) } diff --git a/keetanetwork-client-wasi/wit/world.wit b/keetanetwork-client-wasi/wit/world.wit index 1420c33..63062fc 100644 --- a/keetanetwork-client-wasi/wit/world.wit +++ b/keetanetwork-client-wasi/wit/world.wit @@ -173,13 +173,29 @@ interface types { blocks: list, } - /// An access-control entry. `permissions` are the on-chain - /// `[base, external]` `0x`-prefixed hex bitmaps. + /// A certificate-issuer principal: any account presenting a certificate + /// issued by the certificate with hash `certificate`, anchored at + /// `account`. + record acl-certificate-principal { + certificate: certificate-hash, + account: address, + } + + /// The principal of an access-control entry: the party the permissions + /// are granted to. + variant acl-principal { + account(address), + certificate(acl-certificate-principal), + } + + /// An access-control entry. `permissions` are the decoded base flags; + /// `external-permissions` are the external bit offsets. record acl { - principal: option
, + principal: option, entity: option
, target: option
, - permissions: list, + permissions: base-permission, + external-permissions: list, } /// A certificate bundle: a PEM-encoded leaf plus PEM intermediates. @@ -273,8 +289,8 @@ interface crypto { interface node { use types.{ coded-error, token-balance, representative, ledger-checksum, account-info, account-state, history-entry, - chain-query, chain-page, head-info, adjust-method, history-query, acl, certificate, - block-effects, staple-effects, swap-token-amount, swap-expectation, + chain-query, chain-page, head-info, adjust-method, history-query, acl, acl-principal, + acl-certificate-principal, certificate, block-effects, staple-effects, swap-token-amount, swap-expectation, ledger-side, identifier-kind, base-permission, block-hash, staple-id, block-hex, staple-hex, amount, address, moment, certificate-hash, network-id, }; @@ -305,8 +321,9 @@ interface node { /// The block identified by `blockhash`, if held. `side` selects the /// ledger to read (defaults to main). block: func(blockhash: block-hash, side: option) -> result, coded-error>; - /// The vote staple covering `blockhash`, or none. - vote-staple: func(blockhash: block-hash) -> result, coded-error>; + /// The vote staple covering `blockhash`, or none. `side` selects the + /// ledger to read. + vote-staple: func(blockhash: block-hash, side: option) -> result, coded-error>; /// A single representative and its voting weight. representative: func(rep: borrow) -> result; /// Every known representative and its voting weight. diff --git a/keetanetwork-client-wasm/src/client.rs b/keetanetwork-client-wasm/src/client.rs index 1027abe..30c7c65 100644 --- a/keetanetwork-client-wasm/src/client.rs +++ b/keetanetwork-client-wasm/src/client.rs @@ -138,7 +138,7 @@ impl KeetaClient { Ok(head.map(Block::from)) } - /// The head block of `account` paired with its settled base-token balance. + /// The head block of `account` paired with its chain height. #[wasm_bindgen(js_name = accountHeadInfo)] pub async fn account_head_info(&self, account: &Account) -> JsResult> { let info = self @@ -146,7 +146,7 @@ impl KeetaClient { .account_head_info(&*account.inner()) .await .map_err(client_error)?; - Ok(info.map(|(block, balance)| AccountHead { block: Block::from(block), balance: amount_to_string(balance) })) + Ok(info.map(|(block, height)| AccountHead { block: Block::from(block), height: amount_to_string(height) })) } /// The next pending (unreceived-driven) block for `account`, if any. @@ -201,18 +201,23 @@ impl KeetaClient { .block_by_idempotent(&*account.inner(), key, side) .await .map_err(client_error)?; + Ok(block.map(Block::from)) } /// The verified vote staple committing the block with hash `block_hash`. + /// `side` selects the ledger to read (`"main"` or `"side"`); the main + /// ledger is used when omitted. #[wasm_bindgen(js_name = voteStaple)] - pub async fn vote_staple(&self, block_hash: String) -> JsResult> { + pub async fn vote_staple(&self, block_hash: String, side: Option) -> JsResult> { + let side = parse_ledger_side(side)?; let block_hash = parse_block_hash(&block_hash)?; let staple = self .inner - .vote_staple(block_hash) + .vote_staple(block_hash, side) .await .map_err(client_error)?; + Ok(staple.map(VoteStaple::from)) } @@ -564,11 +569,11 @@ impl From for KeetaClient { } } -/// An account's head block paired with its settled base-token balance. +/// An account's head block paired with its chain height. #[wasm_bindgen] pub struct AccountHead { block: Block, - balance: String, + height: String, } #[wasm_bindgen] @@ -579,9 +584,9 @@ impl AccountHead { self.block.clone() } - /// The settled base-token balance as a decimal string. + /// The chain height of the head block as a decimal string. #[wasm_bindgen(getter)] - pub fn balance(&self) -> String { - self.balance.clone() + pub fn height(&self) -> String { + self.height.clone() } } diff --git a/keetanetwork-client-wasm/src/dto.rs b/keetanetwork-client-wasm/src/dto.rs index e7feb92..6931747 100644 --- a/keetanetwork-client-wasm/src/dto.rs +++ b/keetanetwork-client-wasm/src/dto.rs @@ -4,9 +4,10 @@ use alloc::string::String; use alloc::vec::Vec; +use keetanetwork_bindings::permissions::{flag_names, offsets}; use keetanetwork_client::{ - AccountInfo, AccountState, Acl, BlockEffects, Certificate, HistoryEntry, LedgerChecksum, Representative, - TokenBalance, + AccountInfo, AccountState, Acl, AclPrincipal, BlockEffects, Certificate, HistoryEntry, LedgerChecksum, + Representative, TokenBalance, }; use serde::Serialize; use tsify::Tsify; @@ -23,7 +24,7 @@ pub struct TokenBalanceView { impl From<&TokenBalance> for TokenBalanceView { fn from(balance: &TokenBalance) -> Self { - Self { token: balance.token.clone(), balance: amount_to_string(balance.balance.clone()) } + Self { token: balance.token.to_string(), balance: amount_to_string(balance.balance.clone()) } } } @@ -38,7 +39,11 @@ pub struct AccountInfoView { impl From<&AccountInfo> for AccountInfoView { fn from(info: &AccountInfo) -> Self { - Self { name: info.name.clone(), description: info.description.clone(), metadata: info.metadata.clone() } + let name = info.name.clone(); + let description = info.description.clone(); + let metadata = info.metadata.clone(); + + Self { name, description, metadata } } } @@ -57,7 +62,10 @@ pub struct AccountStateView { impl From<&AccountState> for AccountStateView { fn from(state: &AccountState) -> Self { Self { - representative: state.representative.clone(), + representative: state + .representative + .as_ref() + .map(|representative| representative.to_string()), head: state.head.map(|head| head.to_string()), height: state.height.clone().map(amount_to_string), info: state.info.as_ref().map(AccountInfoView::from), @@ -122,7 +130,7 @@ pub struct RepresentativeView { impl From<&Representative> for RepresentativeView { fn from(rep: &Representative) -> Self { Self { - account: rep.account.clone(), + account: rep.account.to_string(), weight: amount_to_string(rep.weight.clone()), api_url: rep.api_url.clone(), } @@ -142,29 +150,58 @@ impl From<&LedgerChecksum> for LedgerChecksumView { fn from(checksum: &LedgerChecksum) -> Self { Self { checksum: amount_to_string(checksum.checksum.clone()), - moment: checksum.moment.clone(), + moment: checksum.moment.map(|moment| moment.to_string()), moment_range: checksum.moment_range, } } } -/// An access-control entry. +/// The principal of an access-control entry: `kind` is `"account"` or +/// `"certificate"`; `certificate` carries the issuing certificate hash (hex) +/// for certificate principals. +#[derive(Serialize, Tsify)] +#[tsify(into_wasm_abi)] +pub struct AclPrincipalView { + pub kind: String, + pub account: String, + pub certificate: Option, +} + +impl From<&AclPrincipal> for AclPrincipalView { + fn from(principal: &AclPrincipal) -> Self { + match principal { + AclPrincipal::Account(account) => { + Self { kind: String::from("account"), account: account.to_string(), certificate: None } + } + AclPrincipal::Certificate { hash, account } => Self { + kind: String::from("certificate"), + account: account.to_string(), + certificate: Some(hex::encode(hash)), + }, + } + } +} + +/// An access-control entry. `permissions` are the normalized base flag +/// names; `external_permissions` are the external bit offsets. #[derive(Serialize, Tsify)] #[tsify(into_wasm_abi)] pub struct AclView { - pub principal: Option, + pub principal: Option, pub entity: Option, pub target: Option, pub permissions: Vec, + pub external_permissions: Vec, } impl From<&Acl> for AclView { fn from(acl: &Acl) -> Self { Self { - principal: acl.principal.clone(), - entity: acl.entity.clone(), - target: acl.target.clone(), - permissions: acl.permissions.clone(), + principal: acl.principal.as_ref().map(AclPrincipalView::from), + entity: acl.entity.as_ref().map(|entity| entity.to_string()), + target: acl.target.as_ref().map(|target| target.to_string()), + permissions: flag_names(&acl.permissions), + external_permissions: offsets(&acl.permissions), } } } diff --git a/keetanetwork-client/openapi/keetanet-node.yaml b/keetanetwork-client/openapi/keetanet-node.yaml index f7268ed..6445f66 100644 --- a/keetanetwork-client/openapi/keetanet-node.yaml +++ b/keetanetwork-client/openapi/keetanet-node.yaml @@ -112,8 +112,15 @@ components: ACLRow: type: object properties: - principal: + principalType: type: string + enum: [ACCOUNT, CERTIFICATE] + description: Whether the principal is an account or a certificate issuer + principal: + description: >- + The principal account address when principalType is ACCOUNT, or an + object carrying the issuing certificate hash and its anchor account + when principalType is CERTIFICATE entity: type: string target: @@ -122,7 +129,7 @@ components: type: array items: type: string - description: Permission bitmaps as 0x-prefixed hexadecimal values + description: Permission bitmaps as 0x-prefixed hexadecimal values ([base, external]) HistoryEntry: type: object diff --git a/keetanetwork-client/src/client.rs b/keetanetwork-client/src/client.rs index bc69346..92887bb 100644 --- a/keetanetwork-client/src/client.rs +++ b/keetanetwork-client/src/client.rs @@ -409,7 +409,7 @@ impl KeetaClient { let representatives = self.representatives().await?; let entries = representatives .into_iter() - .map(|rep| (rep.account, rep.weight.as_bigint().clone(), rep.api_url)) + .map(|rep| (rep.account.to_string(), rep.weight.as_bigint().clone(), rep.api_url)) .collect(); Ok(entries) } @@ -900,7 +900,7 @@ impl KeetaClient { None => return Ok(None), }; let staple = match self - .compose_staple_on(&highest_transport, successor.hash()) + .compose_staple_on(&highest_transport, successor.hash(), LedgerSide::Main) .await? { Some(staple) => staple, @@ -1155,21 +1155,22 @@ impl KeetaClient { Ok(votes) } - /// Assemble the staple covering `block_hash` from one rep's main-ledger - /// votes and the blocks they cover. + /// Assemble the staple covering `block_hash` from one rep's votes on the + /// given ledger `side` and the blocks they cover. async fn compose_staple_on( &self, transport: &Arc, block_hash: BlockHash, + side: LedgerSide, ) -> Result, ClientError> { - let votes = match transport.block_votes(block_hash, LedgerSide::Main).await? { + let votes = match transport.block_votes(block_hash, side).await? { Some(list) if !list.is_empty() => list, _ => return Ok(None), }; let mut blocks = Vec::new(); for hash in votes[0].blocks() { - match transport.block(*hash, Some(LedgerSide::Main)).await? { + match transport.block(*hash, Some(side)).await? { Some(block) => blocks.push(block), None => return Ok(None), } @@ -1426,10 +1427,16 @@ impl KeetaClient { } } - /// The vote staple covering `blockhash` on the main ledger, assembled from - /// the first representative that holds votes for it, or `None` when no - /// representative has the staple. - pub async fn vote_staple(&self, blockhash: BlockHash) -> Result, ClientError> { + /// The vote staple covering `blockhash`, assembled from the first + /// representative that holds votes for it, or `None` when no + /// representative has the staple. `side` selects the ledger to read + /// (`None` defaults to the main ledger; vote lookups have no "both", so + /// [`LedgerSide::Both`] reads the main ledger). + pub async fn vote_staple( + &self, + blockhash: BlockHash, + side: Option, + ) -> Result, ClientError> { self.ensure_refresh(); let picks = self.snapshot_picks(); @@ -1437,9 +1444,13 @@ impl KeetaClient { return Err(ClientError::NoRepresentatives); } + let side = side.unwrap_or(LedgerSide::Main); let mut last_error: Option = None; for pick in &picks { - match self.compose_staple_on(&pick.transport, blockhash).await { + match self + .compose_staple_on(&pick.transport, blockhash, side) + .await + { Ok(Some(staple)) => { self.boost(&pick.key); return Ok(Some(staple)); diff --git a/keetanetwork-client/src/codec.rs b/keetanetwork-client/src/codec.rs index 62c014d..abc2166 100644 --- a/keetanetwork-client/src/codec.rs +++ b/keetanetwork-client/src/codec.rs @@ -7,17 +7,25 @@ use alloc::string::String; use alloc::vec::Vec; use core::str::FromStr; +use alloc::sync::Arc; + use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; -use keetanetwork_block::{Amount, Block, BlockTime}; +use keetanetwork_account::GenericAccount; +use keetanetwork_block::{AccountRef, Amount, Block, BlockTime, Permissions}; use keetanetwork_crypto::error::CryptoError; use keetanetwork_error::{KeetaNetError, NodeErrorParts, NodeErrorType}; use keetanetwork_vote::{ValidationConfig, Vote, VoteQuote, VoteStaple}; use snafu::ResultExt; -use crate::error::{AmountSnafu, BlockSnafu, ClientError, DecodeSnafu, HashSnafu, MomentSnafu, VoteSnafu}; +use crate::error::{ + AccountSnafu, AmountSnafu, BlockSnafu, ClientError, DecodeSnafu, HashSnafu, MomentSnafu, PermissionSnafu, VoteSnafu, +}; use crate::generated::types; -use crate::model::{AccountInfo, AccountState, Acl, Certificate, HistoryEntry, Representative, TokenBalance}; +use crate::model::{ + AccountInfo, AccountState, Acl, AclPrincipal, Certificate, HistoryEntry, LedgerChecksum, Representative, + TokenBalance, +}; use crate::transport::LedgerSide; impl From for types::GetBlockSide { @@ -180,18 +188,97 @@ pub(crate) fn decode_moment(timestamp: Option) -> Result) -> Result { + let value = address.unwrap_or_default(); + let account = GenericAccount::from_str(&value).context(AccountSnafu)?; + Ok(Arc::new(account)) +} + +/// Parse an optional account address field into an [`AccountRef`], treating an +/// absent field as `None`. +pub(crate) fn decode_account_opt(address: Option) -> Result, ClientError> { + address.map(|value| decode_account(Some(value))).transpose() +} + /// Decode a transport representative entry. pub(crate) fn decode_representative(rep: types::Representative) -> Result { Ok(Representative { - account: rep.representative.unwrap_or_default(), + account: decode_account(rep.representative)?, weight: decode_amount(rep.weight)?, api_url: rep.endpoints.and_then(|endpoints| endpoints.api), }) } +/// Decode the ledger checksum response into a domain [`LedgerChecksum`]. +pub(crate) fn decode_checksum(checksum: types::GetLedgerChecksumResponse) -> Result { + Ok(LedgerChecksum { + checksum: decode_amount(checksum.checksum)?, + moment: decode_moment(checksum.moment)?, + moment_range: checksum.moment_range, + }) +} + /// Map a transport ACL row into a domain [`Acl`]. -pub(crate) fn decode_acl(row: types::AclRow) -> Acl { - Acl { principal: row.principal, entity: row.entity, target: row.target, permissions: row.permissions } +pub(crate) fn decode_acl(row: types::AclRow) -> Result { + Ok(Acl { + principal: decode_acl_principal(row.principal_type, row.principal)?, + entity: decode_account_opt(row.entity)?, + target: decode_account_opt(row.target)?, + permissions: decode_permissions(row.permissions)?, + }) +} + +/// Decode the `[base, external]` permission bitmaps into a [`Permissions`] +/// set, treating absent entries as empty bitmaps. +pub(crate) fn decode_permissions(bitmaps: Vec) -> Result { + let mut parts = bitmaps.into_iter(); + let base = decode_amount(parts.next())?; + let external = decode_amount(parts.next())?; + + Permissions::from_bigints(base.as_bigint().clone(), external.as_bigint().clone()).context(PermissionSnafu) +} + +/// Decode an ACL principal from its wire shape: an account address string +/// when `kind` is `ACCOUNT` (or absent), or a certificate object when +/// `CERTIFICATE`. +fn decode_acl_principal( + kind: Option, + principal: Option, +) -> Result, ClientError> { + let Some(value) = principal else { + return Ok(None); + }; + + match kind { + Some(types::AclRowPrincipalType::Certificate) => Ok(Some(decode_certificate_principal(&value)?)), + Some(types::AclRowPrincipalType::Account) | None => { + let address = value.as_str().ok_or(ClientError::AclPrincipal)?; + let decoded = decode_account(Some(address.into()))?; + + Ok(Some(AclPrincipal::Account(decoded))) + } + } +} + +/// Decode a certificate principal object carrying the issuing certificate +/// hash and its anchor account. +fn decode_certificate_principal(value: &serde_json::Value) -> Result { + let hash_hex = value + .get("certificate") + .and_then(serde_json::Value::as_str) + .ok_or(ClientError::AclPrincipal)?; + let account = value + .get("certificateAccount") + .and_then(serde_json::Value::as_str) + .ok_or(ClientError::AclPrincipal)?; + + let bytes = hex::decode(hash_hex).map_err(|_| ClientError::AclPrincipal)?; + let hash: [u8; 32] = bytes.try_into().map_err(|_| ClientError::AclPrincipal)?; + let account = decode_account(Some(account.into()))?; + + Ok(AclPrincipal::Certificate { hash, account }) } /// Map a transport certificate into a domain [`Certificate`], dropping entries @@ -205,9 +292,7 @@ pub(crate) fn decode_certificate(cert: types::Certificate) -> Option) -> Result, ClientError> { entries .into_iter() - .map(|entry| { - Ok(TokenBalance { token: entry.token.unwrap_or_default(), balance: decode_amount(entry.balance)? }) - }) + .map(|entry| Ok(TokenBalance { token: decode_account(entry.token)?, balance: decode_amount(entry.balance)? })) .collect() } @@ -232,7 +317,7 @@ pub(crate) fn decode_account_state( .transpose()?; Ok(AccountState { - representative, + representative: decode_account_opt(representative)?, head: decode_hash(head)?, height: height .map(|height| decode_amount(Some(height))) @@ -266,11 +351,94 @@ where mod tests { use super::*; + use alloc::vec; + + use keetanetwork_block::testing::generate_ed25519_ref; + use keetanetwork_block::BaseFlag; + #[test] fn decodes_absent_amount_as_zero() { assert_eq!(decode_amount(None).unwrap(), Amount::default()); } + #[test] + fn decodes_a_required_account() -> Result<(), ClientError> { + let account = generate_ed25519_ref(0x05); + let decoded = decode_account(Some(account.to_string()))?; + assert_eq!(decoded, account); + Ok(()) + } + + #[test] + fn rejects_an_absent_required_account() { + assert!(matches!(decode_account(None), Err(ClientError::Account { .. }))); + } + + #[test] + fn decodes_an_absent_optional_account_as_none() -> Result<(), ClientError> { + assert_eq!(decode_account_opt(None)?, None); + Ok(()) + } + + #[test] + fn decodes_permission_bitmaps() -> Result<(), ClientError> { + let permissions = decode_permissions(vec![String::from("0x1"), String::from("0x0")])?; + assert!(permissions.has(&[BaseFlag::Access], &[])); + Ok(()) + } + + #[test] + fn decodes_absent_bitmaps_as_an_empty_set() -> Result<(), ClientError> { + let permissions = decode_permissions(vec![])?; + assert!(permissions.base().flags().is_empty()); + Ok(()) + } + + #[test] + fn rejects_a_malformed_bitmap() { + let decoded = decode_permissions(vec![String::from("nope")]); + assert!(matches!(decoded, Err(ClientError::Amount { .. }))); + } + + #[test] + fn decodes_an_account_principal() -> Result<(), ClientError> { + let account = generate_ed25519_ref(0x06); + let decoded = decode_acl_principal( + Some(types::AclRowPrincipalType::Account), + Some(serde_json::Value::String(account.to_string())), + )?; + assert!(matches!(decoded, Some(AclPrincipal::Account(principal)) if principal == account)); + Ok(()) + } + + #[test] + fn decodes_a_certificate_principal() -> Result<(), ClientError> { + let account = generate_ed25519_ref(0x07); + let value = serde_json::json!({ + "certificate": "ab".repeat(32), + "certificateAccount": account.to_string(), + }); + + let decoded = decode_acl_principal(Some(types::AclRowPrincipalType::Certificate), Some(value))?; + assert!(matches!( + decoded, + Some(AclPrincipal::Certificate { hash, account: anchor }) if hash == [0xABu8; 32] && anchor == account + )); + Ok(()) + } + + #[test] + fn decodes_an_absent_principal_as_none() -> Result<(), ClientError> { + assert_eq!(decode_acl_principal(None, None)?, None); + Ok(()) + } + + #[test] + fn rejects_a_malformed_principal_shape() { + let decoded = decode_acl_principal(None, Some(serde_json::Value::from(7))); + assert!(matches!(decoded, Err(ClientError::AclPrincipal))); + } + #[test] fn decodes_a_hex_amount() { assert_eq!(decode_amount(Some(String::from("0x10"))).unwrap(), Amount::from(16u64)); diff --git a/keetanetwork-client/src/error.rs b/keetanetwork-client/src/error.rs index 000413b..8e1c2bc 100644 --- a/keetanetwork-client/src/error.rs +++ b/keetanetwork-client/src/error.rs @@ -78,6 +78,18 @@ pub enum ClientError { source: chrono::ParseError, }, + /// A permission bitmap in the response failed to decode into a + /// permission set. + #[snafu(display("malformed permissions in node response"))] + Permission { + /// Underlying permission decoding error. + source: BlockError, + }, + + /// An ACL principal field in the response had an unrecognized shape. + #[snafu(display("malformed ACL principal in node response"))] + AclPrincipal, + /// The `/vote` response omitted the vote field. #[snafu(display("node response omitted the vote"))] MissingVote, @@ -100,8 +112,9 @@ pub enum ClientError { #[snafu(display("node votes require a fee block but no signer was supplied"))] FeeRequired, - /// Deriving the network base token (the implicit fee currency) failed. - #[snafu(display("network base token derivation failed"))] + /// An account address could not be parsed or derived: a malformed address + /// in a node response, or a failed network base-token derivation. + #[snafu(display("account parsing or derivation failed"))] Account { /// Underlying account error. source: AccountError, @@ -189,6 +202,8 @@ impl ClientError { Self::Amount { .. } => "AMOUNT", Self::Hash { .. } => "HASH", Self::Moment { .. } => "MOMENT", + Self::Permission { .. } => "PERMISSION", + Self::AclPrincipal => "ACL_PRINCIPAL", Self::MissingVote => "MISSING_VOTE", Self::MissingQuote => "MISSING_QUOTE", Self::MissingPublish => "MISSING_PUBLISH", diff --git a/keetanetwork-client/src/lib.rs b/keetanetwork-client/src/lib.rs index 4348d3e..a5b44a6 100644 --- a/keetanetwork-client/src/lib.rs +++ b/keetanetwork-client/src/lib.rs @@ -104,8 +104,9 @@ pub use keetanetwork_error::{KeetaNetError, NodeErrorType}; pub use keetanetwork_vote::{Vote, VoteBlockHash, VoteQuote, VoteStaple}; pub use marker::{MaybeSend, MaybeSync}; pub use model::{ - AccountInfo, AccountOrPending, AccountState, Acl, BlockEffects, Certificate, ChainPage, ChainQuery, HistoryEntry, - HistoryPage, HistoryQuery, LedgerChecksum, PendingAccount, Representative, TokenBalance, TransmitOptions, + AccountInfo, AccountOrPending, AccountState, Acl, AclPrincipal, BlockEffects, Certificate, ChainPage, ChainQuery, + HistoryEntry, HistoryPage, HistoryQuery, LedgerChecksum, PendingAccount, Representative, TokenBalance, + TransmitOptions, }; pub use rep::RepPart; pub use runtime::{BoxFuture, Runtime, TaskHandle}; diff --git a/keetanetwork-client/src/model.rs b/keetanetwork-client/src/model.rs index 3594fe4..fd4b8c4 100644 --- a/keetanetwork-client/src/model.rs +++ b/keetanetwork-client/src/model.rs @@ -5,7 +5,7 @@ use alloc::string::String; use alloc::sync::Arc; use alloc::vec::Vec; -use keetanetwork_block::{AccountRef, Amount, Block, BlockHash, BlockTime, Operation}; +use keetanetwork_block::{AccountRef, Amount, Block, BlockHash, BlockTime, Operation, Permissions}; use keetanetwork_vote::{VoteBlockHash, VoteQuote, VoteStaple}; use crate::error::ClientError; @@ -88,8 +88,8 @@ impl From<&PendingAccount> for AccountOrPending { /// A token balance entry for an account. #[derive(Debug, Clone)] pub struct TokenBalance { - /// Token account address. - pub token: String, + /// Token account. + pub token: AccountRef, /// Settled balance. pub balance: Amount, } @@ -97,8 +97,8 @@ pub struct TokenBalance { /// A representative and its voting weight. #[derive(Debug, Clone)] pub struct Representative { - /// Representative account address. - pub account: String, + /// Representative account. + pub account: AccountRef, /// Voting weight. pub weight: Amount, /// REST API base URL the representative can be reached at, when the node @@ -112,8 +112,8 @@ pub struct Representative { pub struct LedgerChecksum { /// XOR checksum of the ledger. pub checksum: Amount, - /// Approximate moment the checksum was taken (ISO 8601). - pub moment: Option, + /// Approximate moment the checksum was taken. + pub moment: Option, /// Half the measurement window, in milliseconds. pub moment_range: Option, } @@ -154,17 +154,33 @@ impl BlockEffects { } } +/// The principal of an access-control entry: the party the permissions are +/// granted to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AclPrincipal { + /// A concrete account. + Account(AccountRef), + /// Any account presenting a certificate issued by the referenced + /// certificate. + Certificate { + /// Hash of the issuing certificate. + hash: [u8; 32], + /// The account the certificate grant is anchored to. + account: AccountRef, + }, +} + /// An access-control entry granting a principal permissions over a target. #[derive(Debug, Clone)] pub struct Acl { /// Principal the permissions are granted to. - pub principal: Option, + pub principal: Option, /// Entity the ACL is keyed under. - pub entity: Option, + pub entity: Option, /// Target the permissions apply to. - pub target: Option, - /// Permission bitmaps as `0x`-prefixed hexadecimal values. - pub permissions: Vec, + pub target: Option, + /// The granted permission set. + pub permissions: Permissions, } /// A certificate and its intermediate chain. @@ -235,8 +251,8 @@ pub struct AccountInfo { /// A snapshot of an account's ledger state. #[derive(Debug, Clone)] pub struct AccountState { - /// Representative account address, if one is set. - pub representative: Option, + /// Representative account, if one is set. + pub representative: Option, /// Head block hash, if the account has any blocks. pub head: Option, /// Head block height, if known. diff --git a/keetanetwork-client/src/transport.rs b/keetanetwork-client/src/transport.rs index d103398..ca12ade 100644 --- a/keetanetwork-client/src/transport.rs +++ b/keetanetwork-client/src/transport.rs @@ -153,8 +153,8 @@ mod backend { use super::{LedgerSide, NodeTransport, TransportFactory}; use crate::codec::{ decode_account_state, decode_acl, decode_amount, decode_balances, decode_block, decode_certificate, - decode_hash, decode_history, decode_node_error, decode_quote_binary, decode_representative, decode_staples, - decode_vote_binary, encode_blocks, encode_votes, + decode_checksum, decode_hash, decode_history, decode_node_error, decode_quote_binary, decode_representative, + decode_staples, decode_vote_binary, encode_blocks, encode_votes, }; use crate::error::ClientError; use crate::generated::{types, Client as Transport, Error as GeneratedError}; @@ -406,32 +406,27 @@ mod backend { } async fn ledger_checksum(&self) -> Result { - let checksum = self.client.get_ledger_checksum().await?.into_inner(); - Ok(LedgerChecksum { - checksum: decode_amount(checksum.checksum)?, - moment: checksum.moment, - moment_range: checksum.moment_range, - }) + decode_checksum(self.client.get_ledger_checksum().await?.into_inner()) } async fn acls_by_principal(&self, account: &str) -> Result, ClientError> { let response = self.client.list_acls_by_principal(account).await?; - Ok(response + response .into_inner() .permissions .into_iter() .map(decode_acl) - .collect()) + .collect() } async fn acls_by_entity(&self, account: &str) -> Result, ClientError> { let response = self.client.list_acls_by_entity(account).await?; - Ok(response + response .into_inner() .permissions .into_iter() .map(decode_acl) - .collect()) + .collect() } #[cfg(feature = "std")] @@ -538,8 +533,8 @@ mod wasi_backend { use super::{LedgerSide, NodeTransport, TransportFactory}; use crate::codec::{ decode_account_state, decode_acl, decode_amount, decode_balances, decode_block, decode_certificate, - decode_hash, decode_history, decode_node_error, decode_quote_binary, decode_representative, decode_staples, - decode_vote_binary, encode_blocks, encode_votes, + decode_checksum, decode_hash, decode_history, decode_node_error, decode_quote_binary, decode_representative, + decode_staples, decode_vote_binary, encode_blocks, encode_votes, }; use crate::error::ClientError; use crate::generated::types; @@ -914,23 +909,19 @@ mod wasi_backend { async fn ledger_checksum(&self) -> Result { let checksum: types::GetLedgerChecksumResponse = self.get_json("/node/ledger/checksum").await?; - Ok(LedgerChecksum { - checksum: decode_amount(checksum.checksum)?, - moment: checksum.moment, - moment_range: checksum.moment_range, - }) + decode_checksum(checksum) } async fn acls_by_principal(&self, account: &str) -> Result, ClientError> { let path = format!("/node/ledger/account/{}/acl", segment(account)); let response: types::ListAclsByPrincipalResponse = self.get_json(&path).await?; - Ok(response.permissions.into_iter().map(decode_acl).collect()) + response.permissions.into_iter().map(decode_acl).collect() } async fn acls_by_entity(&self, account: &str) -> Result, ClientError> { let path = format!("/node/ledger/account/{}/acl/granted", segment(account)); let response: types::ListAclsByEntityResponse = self.get_json(&path).await?; - Ok(response.permissions.into_iter().map(decode_acl).collect()) + response.permissions.into_iter().map(decode_acl).collect() } async fn certificates(&self, account: &str) -> Result, ClientError> { diff --git a/keetanetwork-client/tests/e2e.rs b/keetanetwork-client/tests/e2e.rs index e6f7108..4bed766 100644 --- a/keetanetwork-client/tests/e2e.rs +++ b/keetanetwork-client/tests/e2e.rs @@ -220,8 +220,9 @@ fn read_only_cases() -> Vec> { }), case!("balances include the base token", |fx| { let balances = fx.client.balances(&*fx.accounts.trusted).await?; - let base_token = fx.accounts.token.to_string(); - let base = balances.iter().find(|entry| entry.token == base_token); + let base = balances + .iter() + .find(|entry| entry.token == fx.accounts.token); require( base.is_some_and(|entry| entry.balance == Amount::from(MINTED_SUPPLY)), "base token balance mismatch", @@ -230,14 +231,17 @@ fn read_only_cases() -> Vec> { case!("account state reports representative, head and balances", |fx| { let state = fx.client.state(&*fx.accounts.trusted).await?; let recipient = fx.recipient(); - let representative_matches = state.representative.as_deref() == Some(recipient.as_str()); + let representative_matches = state + .representative + .as_ref() + .is_some_and(|rep| rep.to_string() == recipient); require(representative_matches, "representative mismatch")?; require(state.head.is_some(), "missing head block")?; - let base_token = fx.accounts.token.to_string(); + let base = state .balances .iter() - .find(|entry| entry.token == base_token); + .find(|entry| entry.token == fx.accounts.token); let base_matches = base.is_some_and(|entry| entry.balance == Amount::from(MINTED_SUPPLY)); require(base_matches, "base balance mismatch") }), @@ -263,20 +267,29 @@ fn read_only_cases() -> Vec> { }), case!("node representative matches ready payload", |fx| { let rep = fx.client.node_representative().await?; - require(rep.account == fx.recipient(), "representative mismatch") + require(rep.account.to_string() == fx.recipient(), "representative mismatch") }), case!("representative lookup echoes the account", |fx| { let rep = fx.client.representative(&*fx.accounts.recipient).await?; - require(rep.account == fx.recipient(), "representative mismatch") + require(rep.account.to_string() == fx.recipient(), "representative mismatch") }), case!("representative set includes the node rep", |fx| { let all = fx.client.representatives().await?; - require(all.iter().any(|rep| rep.account == fx.recipient()), "node rep absent") + require( + all.iter() + .any(|rep| rep.account.to_string() == fx.recipient()), + "node rep absent", + ) }), - case!("principal ACLs carry permission bitmaps", |fx| { + case!("principal ACLs carry decoded permission flags", |fx| { let acls = fx.client.acls_by_principal(&*fx.accounts.trusted).await?; require(!acls.is_empty(), "no principal ACLs")?; - require(acls.iter().all(|acl| !acl.permissions.is_empty()), "empty permissions") + require( + acls.iter() + .all(|acl| !acl.permissions.base().flags().is_empty()), + "empty permissions", + )?; + require(acls.iter().all(|acl| acl.principal.is_some()), "every ACL row must carry a decoded principal") }), case!("granted ACL query succeeds", |fx| { fx.client.acls_by_entity(&*fx.accounts.trusted).await?; @@ -416,7 +429,7 @@ fn post_transmit_cases() -> Vec> { let staple = ctx .fixture .client - .vote_staple(ctx.head_hash) + .vote_staple(ctx.head_hash, None) .await? .ok_or("a vote staple must be retrievable for the published head")?; let contains_head = staple @@ -594,6 +607,7 @@ fn cluster_reps(node: &E2eNode) -> Result, Box Result { let mut builder = client.builder(account); + ops(&mut builder); - Ok(one_block(builder.build().await?)) + + let blocks = builder.build().await?; + Ok(one_block(blocks)) } /// A signer-bound [`UserClient`] over `client`, signing as the trusted account. @@ -747,6 +764,7 @@ impl ClusterFixture { let endpoints = cluster_reps(&node)?; assert_eq!(endpoints.len(), reps, "the cluster must report one endpoint per representative"); + let rep_accounts = endpoints .iter() .map(|rep| rep.account().to_string()) @@ -801,6 +819,7 @@ fn assert_heads_diverged(node: &mut E2eNode, account: &str, block: &Block) -> Re let heads = head_hashes(node, account)?; let block_hash = block.hash().to_string(); assert_eq!(heads[0], block_hash, "the primary rep must hold the advanced head"); + let peers_lag = heads[1..].iter().all(|head| *head != heads[0]); assert!(peers_lag, "the peer reps must lag behind the primary before the repair"); @@ -899,13 +918,13 @@ const ACCOUNT3_SEED_BYTE: u8 = 0x43; /// The voting weight a representative reports in `representatives()`, by account. fn rep_weight(reps: &[keetanetwork_client::Representative], account: &str) -> Option { reps.iter() - .find(|rep| rep.account == account) + .find(|rep| rep.account.to_string() == account) .map(|rep| rep.weight.clone()) } /// Distribute base token to two fresh accounts and delegate each to a /// secondary rep, in one staple: a two-SEND block from the trusted account -/// plus an opening SET_REP block for each recipient. Converges the cluster. +/// plus an opening SET_REP block for each recipient. async fn distribute_and_delegate( fixture: &mut ClusterFixture, accounts: &SigningAccounts, @@ -934,6 +953,7 @@ async fn distribute_and_delegate( .transmit(&[distribute, set_rep2, set_rep3], TransmitOptions::default()) .await?; assert!(accepted, "the cluster must accept the weight-distribution staple"); + fixture.converge().await?; Ok(()) @@ -952,10 +972,13 @@ async fn assert_rep_weights( let primary = MINTED_SUPPLY - 2 * DISTRIBUTE_AMOUNT; let primary_weight = rep_weight(&all, &rep_accounts[0]); assert_eq!(primary_weight, Some(Amount::from(primary)), "primary rep weight mismatch"); + let secondary1_weight = rep_weight(&all, &rep_accounts[1]); assert_eq!(secondary1_weight, Some(Amount::from(DISTRIBUTE_AMOUNT)), "first secondary rep weight mismatch"); + let secondary2_weight = rep_weight(&all, &rep_accounts[2]); assert_eq!(secondary2_weight, Some(Amount::from(DISTRIBUTE_AMOUNT)), "second secondary rep weight mismatch"); + let all_advertise = all.iter().all(|rep| rep.api_url.is_some()); assert!(all_advertise, "every discovered rep must advertise an endpoint"); @@ -978,6 +1001,7 @@ async fn send_and_assert_debit( .transmit(&[send], TransmitOptions::default()) .await?; assert!(accepted, "the cluster must accept a staple that needed votes from more than one rep"); + fixture.converge().await?; let after_send = fixture @@ -1020,6 +1044,7 @@ async fn send_after_rep_failure( .transmit(&[degraded], TransmitOptions::default()) .await?; assert!(accepted, "the cluster must reach a degraded quorum with one rep down"); + fixture.converge().await?; // Reads must still resolve, dispatching past the failed rep. @@ -1051,9 +1076,7 @@ async fn test_multi_rep_weighted_quorum_and_rep_failure() -> Result<(), Box Result<(), Box Result<(), assert!(accepted, "the send must succeed by recovering the conflict and rebuilding on the advanced head"); fixture.converge().await?; + let head = fixture .client .head_block(&*accounts.trusted) @@ -1171,6 +1194,7 @@ async fn test_send_rebuilds_after_recovering_successor_conflict() -> Result<(), let head_hash = head.hash().to_string(); let staged_hash = staged.hash().to_string(); assert_ne!(head_hash, staged_hash, "the head must advance past the recovered successor to the rebuilt send"); + let head_previous = head.data().previous().to_string(); assert_eq!( head_previous, staged_hash, @@ -1200,6 +1224,7 @@ async fn test_mod_token_supply_and_balance_admins_a_distinct_token() -> Result<( Some(Amount::from(MINTED_SUPPLY + SEND_AMOUNT)), "minting must raise the named token's total supply" ); + let balance = fixture .client .balance(&*fixture.accounts.trusted, &*fixture.accounts.token) @@ -1234,6 +1259,7 @@ async fn test_mod_token_supply_and_balance_burns_supply_first() -> Result<(), Bo Some(Amount::from(MINTED_SUPPLY - SEND_AMOUNT)), "burning must lower the named token's total supply" ); + let balance = fixture .client .balance(&*fixture.accounts.trusted, &*fixture.accounts.token) @@ -1322,9 +1348,9 @@ async fn test_user_client_chain_pagination_follows_cursor() -> Result<(), Box= 3, "the chain must contain at least the three sends"); assert_eq!(paged.len(), single.len(), "cursor pagination must return the full chain"); + let paged_hashes: Vec = paged.iter().map(|block| block.hash().to_string()).collect(); let single_hashes: Vec = single .iter() @@ -1470,6 +1496,7 @@ async fn test_multi_account_builder_staple() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { let fixture = fixture().await; let accounts = &fixture.accounts; - let user = trusted_user(&fixture.client, accounts); + let user = trusted_user(&fixture.client, accounts); assert!(user.set_rep(&accounts.recipient).await?, "set_rep must publish"); + let state = user.state().await?; - let recipient = accounts.recipient.to_string(); assert_eq!( - state.representative.as_deref(), - Some(recipient.as_str()), + state.representative, + Some(Arc::clone(&accounts.recipient)), "the representative wrapper must delegate weight" ); @@ -1758,6 +1792,7 @@ async fn test_user_client_account_split_and_transmit() -> Result<(), Box Result<(), Box Date: Tue, 7 Jul 2026 15:35:38 -0700 Subject: [PATCH 3/3] chore: reduce duplication --- keetanetwork-client/src/codec.rs | 27 +++++++++++++++++++------- keetanetwork-client/src/transport.rs | 29 +++++++++------------------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/keetanetwork-client/src/codec.rs b/keetanetwork-client/src/codec.rs index abc2166..188d177 100644 --- a/keetanetwork-client/src/codec.rs +++ b/keetanetwork-client/src/codec.rs @@ -23,8 +23,8 @@ use crate::error::{ }; use crate::generated::types; use crate::model::{ - AccountInfo, AccountState, Acl, AclPrincipal, Certificate, HistoryEntry, LedgerChecksum, Representative, - TokenBalance, + AccountInfo, AccountState, Acl, AclPrincipal, Certificate, HistoryEntry, HistoryPage, LedgerChecksum, + Representative, TokenBalance, }; use crate::transport::LedgerSide; @@ -156,10 +156,7 @@ pub(crate) fn decode_staples( /// Decode transport history entries into verified domain entries against /// `moment`. -pub(crate) fn decode_history( - entries: Vec, - moment: BlockTime, -) -> Result, ClientError> { +fn decode_history(entries: Vec, moment: BlockTime) -> Result, ClientError> { entries .into_iter() .filter_map(|entry| match decode_staple(entry.vote_staple, moment) { @@ -170,6 +167,19 @@ pub(crate) fn decode_history( .collect() } +/// Assemble a [`HistoryPage`] from a transport history list and next-page +/// cursor, verifying each staple against `moment`. +pub(crate) fn decode_history_page( + history: Vec, + next_key: Option, + moment: BlockTime, +) -> Result { + let entries = decode_history(history, moment)?; + let next_key = decode_hash(next_key)?; + + Ok(HistoryPage { entries, next_key }) +} + /// Assemble a verified [`HistoryEntry`] from its decoded staple and the /// transport id/timestamp fields. fn decode_history_entry( @@ -177,7 +187,10 @@ fn decode_history_entry( id: Option, timestamp: Option, ) -> Result { - Ok(HistoryEntry { staple, id: decode_hash(id)?, timestamp: decode_moment(timestamp)? }) + let id = decode_hash(id)?; + let timestamp = decode_moment(timestamp)?; + + Ok(HistoryEntry { staple, id, timestamp }) } /// Parse an optional ISO 8601 timestamp field into a [`BlockTime`], treating diff --git a/keetanetwork-client/src/transport.rs b/keetanetwork-client/src/transport.rs index ca12ade..90b8594 100644 --- a/keetanetwork-client/src/transport.rs +++ b/keetanetwork-client/src/transport.rs @@ -153,8 +153,8 @@ mod backend { use super::{LedgerSide, NodeTransport, TransportFactory}; use crate::codec::{ decode_account_state, decode_acl, decode_amount, decode_balances, decode_block, decode_certificate, - decode_checksum, decode_hash, decode_history, decode_node_error, decode_quote_binary, decode_representative, - decode_staples, decode_vote_binary, encode_blocks, encode_votes, + decode_checksum, decode_hash, decode_history_page, decode_node_error, decode_quote_binary, + decode_representative, decode_staples, decode_vote_binary, encode_blocks, encode_votes, }; use crate::error::ClientError; use crate::generated::{types, Client as Transport, Error as GeneratedError}; @@ -353,10 +353,7 @@ mod backend { .await? .into_inner(); - Ok(HistoryPage { - entries: decode_history(response.history, verify_moment())?, - next_key: decode_hash(response.next_key)?, - }) + decode_history_page(response.history, response.next_key, verify_moment()) } async fn global_history_page(&self, query: &HistoryQuery) -> Result { @@ -366,10 +363,8 @@ mod backend { .get_global_history(query.limit, start.as_deref()) .await? .into_inner(); - Ok(HistoryPage { - entries: decode_history(response.history, verify_moment())?, - next_key: decode_hash(response.next_key)?, - }) + + decode_history_page(response.history, response.next_key, verify_moment()) } async fn vote_staples_after( @@ -533,8 +528,8 @@ mod wasi_backend { use super::{LedgerSide, NodeTransport, TransportFactory}; use crate::codec::{ decode_account_state, decode_acl, decode_amount, decode_balances, decode_block, decode_certificate, - decode_checksum, decode_hash, decode_history, decode_node_error, decode_quote_binary, decode_representative, - decode_staples, decode_vote_binary, encode_blocks, encode_votes, + decode_checksum, decode_hash, decode_history_page, decode_node_error, decode_quote_binary, + decode_representative, decode_staples, decode_vote_binary, encode_blocks, encode_votes, }; use crate::error::ClientError; use crate::generated::types; @@ -851,10 +846,7 @@ mod wasi_backend { let path = format!("/node/ledger/account/{}/history{}", segment(account), params.finish()); let response: types::GetAccountHistoryResponse = self.get_json(&path).await?; - Ok(HistoryPage { - entries: decode_history(response.history, now_moment())?, - next_key: decode_hash(response.next_key)?, - }) + decode_history_page(response.history, response.next_key, now_moment()) } async fn global_history_page(&self, query: &HistoryQuery) -> Result { @@ -867,10 +859,7 @@ mod wasi_backend { let path = format!("/node/ledger/history{}", params.finish()); let response: types::GetGlobalHistoryResponse = self.get_json(&path).await?; - Ok(HistoryPage { - entries: decode_history(response.history, now_moment())?, - next_key: decode_hash(response.next_key)?, - }) + decode_history_page(response.history, response.next_key, now_moment()) } async fn vote_staples_after(