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,185 @@ 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| 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) + .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 +553,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 +566,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 +580,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 +589,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 +605,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 +627,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 +664,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 +697,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 +711,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 +739,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,24 +749,31 @@ 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 .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. 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 +785,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 +803,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 +826,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 +845,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 +866,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 +890,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 +925,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 +952,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 +965,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 +1014,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 +1047,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 +1070,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 +1099,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 +1136,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 +1159,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 +1167,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..a1ddbf1 100644 --- a/keetanetwork-client-wasi/src/p2/mod.rs +++ b/keetanetwork-client-wasi/src/p2/mod.rs @@ -5,19 +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, + 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; @@ -32,17 +33,35 @@ 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, 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, + 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 +83,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 +103,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 +137,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 +174,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 +254,67 @@ 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, &[])?) +} + +/// 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, @@ -253,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 } } } @@ -266,16 +356,14 @@ impl From for AccountInfo { impl From for AccountState { fn from(state: CoreState) -> Self { Self { - representative: state.representative, - head: state.head, + 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(), } } } @@ -284,33 +372,90 @@ 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 { 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 }) + } +} + +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), + } } } @@ -329,6 +474,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 +491,7 @@ impl TryFrom for SwapExpectation { .send .map(SwapTokenAmount::try_from) .transpose()?; + Ok(Self { receive, send }) } } @@ -380,40 +527,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) }) + .map(TokenBalance::from) .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> { - Ok(run(self.inner.vote_staple(blockhash))?.map(|staple| pure::staple_to_hex(&staple))) + fn vote_staple(&self, blockhash: String, side: Option) -> Result, CodedError> { + let blockhash = parse_block_hash(&blockhash)?; + Ok(run(self.inner.vote_staple(blockhash, side.map(Into::into)))?.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 +581,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 history(&self, account: String) -> Result, CodedError> { - Ok(run(self.inner.history(account))? + 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: 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 +637,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 +714,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 +729,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 +756,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,14 +767,15 @@ 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,14 +783,15 @@ 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> { Ok(run(self.inner.all_balances())? .into_iter() - .map(|balance| TokenBalance { token: balance.token, amount: amount_to_string(balance.balance) }) + .map(TokenBalance::from) .collect()) } @@ -593,10 +811,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 +829,107 @@ 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 +950,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 +966,24 @@ 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 +991,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 +1054,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 +1094,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 +1104,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 +1122,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); @@ -858,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(()) } @@ -869,8 +1185,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 +1217,41 @@ 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 +1261,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)) } @@ -966,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/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..63062fc 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,19 +152,50 @@ 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, + } + + /// 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, - entity: option, - target: option, - permissions: list, + principal: option, + entity: option
, + target: option
, + permissions: base-permission, + external-permissions: list, } /// A certificate bundle: a PEM-encoded leaf plus PEM intermediates. @@ -107,11 +204,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 +220,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 +244,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. @@ -192,16 +289,17 @@ 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, - 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, }; 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 +307,83 @@ 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. `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: 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 +391,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 +503,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 +521,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 +534,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..30c7c65 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,21 +132,21 @@ 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)) } - /// 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 .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) })) + 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. @@ -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,25 +186,38 @@ 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)) } /// 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)) } @@ -204,7 +225,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 +241,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 +259,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 +269,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 +283,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 +320,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 +359,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 +383,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 +407,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 +418,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 +428,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 +436,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)) @@ -514,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] @@ -529,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/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..6931747 100644 --- a/keetanetwork-client-wasm/src/dto.rs +++ b/keetanetwork-client-wasm/src/dto.rs @@ -4,8 +4,10 @@ use alloc::string::String; use alloc::vec::Vec; +use keetanetwork_bindings::permissions::{flag_names, offsets}; use keetanetwork_client::{ - AccountInfo, AccountState, Acl, Certificate, HistoryEntry, LedgerChecksum, Representative, TokenBalance, + AccountInfo, AccountState, Acl, AclPrincipal, BlockEffects, Certificate, HistoryEntry, LedgerChecksum, + Representative, TokenBalance, }; use serde::Serialize; use tsify::Tsify; @@ -22,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()) } } } @@ -37,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 } } } @@ -56,8 +62,11 @@ pub struct AccountStateView { impl From<&AccountState> for AccountStateView { fn from(state: &AccountState) -> Self { Self { - representative: state.representative.clone(), - head: state.head.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), supply: state.supply.clone().map(amount_to_string), @@ -66,6 +75,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 +110,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()), + } } } @@ -93,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(), } @@ -113,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-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..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 @@ -1037,6 +1044,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..92887bb 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() } @@ -417,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) } @@ -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(), LedgerSide::Main) .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, @@ -1164,24 +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: &str, + 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.to_string(), Some(LedgerSide::Main)) - .await? - { + match transport.block(*hash, Some(side)).await? { Some(block) => blocks.push(block), None => return Ok(None), } @@ -1203,11 +1192,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 +1299,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 +1316,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 +1327,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 +1338,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 +1355,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 +1368,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 +1388,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 +1399,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) => { @@ -1432,21 +1427,30 @@ 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> { + /// 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 blockhash = blockhash.as_ref(); let picks = self.snapshot_picks(); if picks.is_empty() { 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)); @@ -1467,53 +1471,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 +1519,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 +1539,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 +1618,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 +1651,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 +1665,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 +1687,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 +1697,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 +1711,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 +1722,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 +1734,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 +1786,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 +1816,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 +1856,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 +1927,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 +2001,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 +2013,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 +2043,7 @@ fn pick_best_vote(mut votes: Vec) -> Option { .unix_millis() .cmp(&left.validity().to.unix_millis()) }); + votes.into_iter().next() } @@ -2016,6 +2093,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..188d177 100644 --- a/keetanetwork-client/src/codec.rs +++ b/keetanetwork-client/src/codec.rs @@ -7,16 +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, 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, HistoryPage, LedgerChecksum, + Representative, TokenBalance, +}; use crate::transport::LedgerSide; impl From for types::GetBlockSide { @@ -29,6 +38,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 { @@ -137,32 +156,142 @@ 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) { 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 [`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( + staple: VoteStaple, + id: Option, + timestamp: Option, +) -> Result { + 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 +/// an absent field as `None`. +pub(crate) fn decode_moment(timestamp: Option) -> Result, ClientError> { + timestamp + .map(|value| BlockTime::from_str(&value).context(MomentSnafu)) + .transpose() +} + +/// Parse a required account address field into an [`AccountRef`], treating an +/// absent field as malformed. +pub(crate) fn decode_account(address: Option) -> 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 @@ -176,9 +305,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() } @@ -203,8 +330,8 @@ pub(crate) fn decode_account_state( .transpose()?; Ok(AccountState { - representative, - head, + representative: decode_account_opt(representative)?, + head: decode_hash(head)?, height: height .map(|height| decode_amount(Some(height))) .transpose()?, @@ -223,15 +350,108 @@ 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::*; + 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)); @@ -242,6 +462,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 +509,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..8e1c2bc 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,33 @@ 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, + }, + + /// 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, @@ -84,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, @@ -171,6 +200,10 @@ impl ClientError { Self::Vote { .. } => "VOTE", Self::Block { .. } => "BLOCK", 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/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..a5b44a6 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,14 @@ 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, AclPrincipal, BlockEffects, Certificate, ChainPage, ChainQuery, + HistoryEntry, HistoryPage, HistoryQuery, LedgerChecksum, PendingAccount, Representative, TokenBalance, + TransmitOptions, }; pub use rep::RepPart; pub use runtime::{BoxFuture, Runtime, TaskHandle}; @@ -122,11 +123,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..fd4b8c4 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, Permissions}; +use keetanetwork_vote::{VoteBlockHash, VoteQuote, VoteStaple}; use crate::error::ClientError; use crate::sync::Once; @@ -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, } @@ -123,23 +123,64 @@ 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)) + } +} + +/// 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. @@ -155,12 +196,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 +213,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 { @@ -200,10 +251,10 @@ 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, - /// Head block hash (hex), if the account has any blocks. - pub head: 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. 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..90b8594 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_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}; 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,40 @@ 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(); + + decode_history_page(response.history, response.next_key, verify_moment()) } - 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(); + + decode_history_page(response.history, response.next_key, verify_moment()) } - 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()) } @@ -364,32 +401,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")] @@ -408,10 +440,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 +518,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 +528,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_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; use crate::model::{ - AccountState, Acl, Certificate, ChainPage, ChainQuery, HistoryEntry, HistoryQuery, LedgerChecksum, + AccountState, Acl, Certificate, ChainPage, ChainQuery, HistoryPage, HistoryQuery, LedgerChecksum, Representative, TokenBalance, }; @@ -695,8 +727,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 +769,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 +818,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 +833,43 @@ 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()) + + decode_history_page(response.history, response.next_key, now_moment()) } - 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()) + + decode_history_page(response.history, response.next_key, now_moment()) } - 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?; @@ -845,23 +898,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> { @@ -874,8 +923,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..4bed766 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,45 @@ 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 = 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", ) }), 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()); + 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 = state .balances .iter() - .find(|entry| entry.token == fx.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") }), 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| { @@ -255,27 +267,39 @@ 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.recipient()).await?; - require(rep.account == fx.recipient(), "representative mismatch") + let rep = fx.client.representative(&*fx.accounts.recipient).await?; + 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| { - let acls = fx.client.acls_by_principal(&fx.trusted).await?; + 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.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 +311,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 +354,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 +372,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 +400,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 +408,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 +418,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 +429,17 @@ 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 .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 +447,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 +460,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 +524,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 +537,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 +556,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; @@ -550,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. @@ -703,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()) @@ -757,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"); @@ -768,21 +831,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 +862,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 +884,10 @@ async fn test_multi_rep_quorum_publish_and_convergence() -> Result<(), Box 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, @@ -884,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(()) @@ -902,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"); @@ -918,8 +991,6 @@ async fn send_and_assert_debit( fixture: &mut ClusterFixture, accounts: &SigningAccounts, account2: &AccountRef, - trusted: &str, - base_token: &str, primary: u64, ) -> Result<(), Box> { // A send now requires aggregating the primary with at least one secondary @@ -930,9 +1001,13 @@ 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.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 +1030,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, @@ -971,10 +1044,14 @@ 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. - 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 +1065,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()?; @@ -1001,10 +1076,8 @@ 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 +1100,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 +1114,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 +1127,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?; @@ -1069,14 +1139,13 @@ 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 @@ -1117,14 +1185,16 @@ async fn test_send_rebuilds_after_recovering_successor_conflict() -> 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(&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(); 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, @@ -1140,23 +1210,24 @@ 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)), "minting must raise the named token's total supply" ); + let balance = fixture .client - .balance(&fixture.trusted, &fixture.base_token) + .balance(&*fixture.accounts.trusted, &*fixture.accounts.token) .await?; assert_eq!( balance, @@ -1174,23 +1245,24 @@ 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)), "burning must lower the named token's total supply" ); + let balance = fixture .client - .balance(&fixture.trusted, &fixture.base_token) + .balance(&*fixture.accounts.trusted, &*fixture.accounts.token) .await?; assert_eq!( balance, @@ -1206,8 +1278,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 +1291,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 +1321,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 @@ -1276,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() @@ -1289,15 +1361,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 +1418,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 +1435,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 +1463,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,12 +1490,13 @@ 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); builder.send(&account2, &accounts.token, Amount::from(SEND_AMOUNT)); builder.for_account(&account2).set_rep(&accounts.recipient); + let blocks = builder.build().await?; assert_eq!(blocks.len(), 2, "two distinct originators must render two blocks"); @@ -1396,12 +1506,12 @@ async fn test_multi_account_builder_staple() -> Result<(), Box 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 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 == accounts.token), "all_balances must include the base token"); let state = user.state().await?; assert!( state .balances .iter() - .any(|entry| entry.token == fixture.base_token), + .any(|entry| entry.token == accounts.token), "state must carry the base balance" ); @@ -1565,12 +1673,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,15 +1712,15 @@ 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?; - 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" ); @@ -1615,8 +1743,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 +1765,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()); assert_eq!(split_signer, Some(accounts.trusted.to_string()), "the bound signer must remain the trusted account"); @@ -1677,9 +1806,9 @@ 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