diff --git a/android/features/recipient/viewmodels/build.gradle.kts b/android/features/recipient/viewmodels/build.gradle.kts index 61c910c23b..148f94dd06 100644 --- a/android/features/recipient/viewmodels/build.gradle.kts +++ b/android/features/recipient/viewmodels/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { ksp(libs.hilt.compiler) testImplementation(libs.junit) + testImplementation(testFixtures(project(":gemcore"))) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientValidation.kt b/android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientValidation.kt new file mode 100644 index 0000000000..b05d8ea088 --- /dev/null +++ b/android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientValidation.kt @@ -0,0 +1,15 @@ +package com.gemwallet.android.features.recipient.viewmodel + +import com.gemwallet.android.blockchain.operators.ValidateAddressOperator +import com.gemwallet.android.ext.matchesRecipient +import com.gemwallet.android.model.DestinationAddress +import com.wallet.core.primitives.Chain +import com.wallet.core.primitives.NameRecord + +internal fun DestinationAddress.isValidRecipient( + inputAddress: String, + chain: Chain, + nameRecord: NameRecord?, + validateAddress: ValidateAddressOperator, +): Boolean = validateAddress(address, chain).getOrNull() == true && + (nameRecord == null || nameRecord.matchesRecipient(inputAddress, address, chain)) diff --git a/android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientViewModel.kt b/android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientViewModel.kt index eb3466bb42..2126eb1113 100644 --- a/android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientViewModel.kt +++ b/android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientViewModel.kt @@ -169,14 +169,16 @@ class RecipientViewModel @Inject constructor( amountAction: AmountTransactionAction, confirmAction: ConfirmTransactionAction, ) { + val resolvedNameRecord = nameRecord.value submit( type = type, destination = DestinationAddress( - address = nameRecord.value?.address ?: address.value, - name = nameRecord.value?.name, + address = resolvedNameRecord?.address ?: address.value, + name = resolvedNameRecord?.name, ), amountAction = amountAction, confirmAction = confirmAction, + nameRecord = resolvedNameRecord, ) } @@ -194,10 +196,11 @@ class RecipientViewModel @Inject constructor( destination: DestinationAddress, amountAction: AmountTransactionAction, confirmAction: ConfirmTransactionAction, + nameRecord: NameRecord? = null, ) { val asset = type.assetInfo.asset destination.copy(address = asset.chain.checksumAddress(destination.address)).let { destination -> - val validation = validateDestination(asset, destination) + val validation = validateDestination(asset, destination, nameRecord) if (validation != RecipientError.None) { if (!resolveName.canResolveName(destination.address)) { addressError.update { validation } @@ -268,8 +271,8 @@ class RecipientViewModel @Inject constructor( confirmAction(params) } - private fun validateDestination(asset: Asset, destination: DestinationAddress): RecipientError = - if (validateAddressOperator(destination.address, asset.chain).getOrNull() == true) { + private fun validateDestination(asset: Asset, destination: DestinationAddress, nameRecord: NameRecord? = null): RecipientError = + if (destination.isValidRecipient(address.value, asset.chain, nameRecord, validateAddressOperator)) { RecipientError.None } else { RecipientError.IncorrectAddress(asset.name) diff --git a/android/features/recipient/viewmodels/src/test/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientValidationTest.kt b/android/features/recipient/viewmodels/src/test/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientValidationTest.kt new file mode 100644 index 0000000000..67449eccb8 --- /dev/null +++ b/android/features/recipient/viewmodels/src/test/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientValidationTest.kt @@ -0,0 +1,55 @@ +package com.gemwallet.android.features.recipient.viewmodel + +import com.gemwallet.android.blockchain.operators.ValidateAddressOperator +import com.gemwallet.android.model.DestinationAddress +import com.gemwallet.android.testkit.mockNameRecord +import com.wallet.core.primitives.Chain +import com.wallet.core.primitives.NameProvider +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecipientValidationTest { + + @Test + fun validRecipientRequiresValidAddressAndMatchingRecord() { + val accountId = "wrap.near" + val otherAccountId = "other.near" + val chain = Chain.Near + val destination = DestinationAddress(address = accountId, name = accountId) + val record = mockNameRecord(name = accountId, chain = chain, address = accountId, provider = NameProvider.Near) + val validAddress = addressValidator(true) + val invalidAddress = addressValidator(false) + + assertTrue(destination.isValidRecipient(accountId, chain, record, validAddress)) + assertFalse(destination.isValidRecipient(otherAccountId, chain, record, validAddress)) + assertFalse(destination.isValidRecipient(accountId, Chain.Ethereum, record, validAddress)) + assertFalse(destination.isValidRecipient(accountId, chain, record, invalidAddress)) + assertFalse(destination.isValidRecipient(accountId, chain, null, invalidAddress)) + assertTrue(destination.isValidRecipient(accountId, chain, null, validAddress)) + + val ethereumName = "example.eth" + val ethereumAddress = "0x5615E8AB93b9d695b6d4d6545f7792aA59e1069a" + val ethereumDestination = DestinationAddress(address = ethereumAddress, name = ethereumName) + val ethereumRecord = mockNameRecord( + name = ethereumName, + address = ethereumAddress, + ) + val unresolvedEthereumRecord = ethereumRecord.copy(address = ethereumName) + val unresolvedEthereumDestination = DestinationAddress(address = ethereumName, name = ethereumName) + + assertTrue(ethereumDestination.isValidRecipient(ethereumName, Chain.Ethereum, ethereumRecord, validAddress)) + assertFalse( + unresolvedEthereumDestination.isValidRecipient( + ethereumName, + Chain.Ethereum, + unresolvedEthereumRecord, + invalidAddress, + ), + ) + } + + private fun addressValidator(result: Boolean) = object : ValidateAddressOperator { + override fun invoke(address: String, chain: Chain): Result = Result.success(result) + } +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/NameRecord.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/NameRecord.kt new file mode 100644 index 0000000000..a915fef5f2 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/NameRecord.kt @@ -0,0 +1,7 @@ +package com.gemwallet.android.ext + +import com.wallet.core.primitives.Chain +import com.wallet.core.primitives.NameRecord + +fun NameRecord.matchesRecipient(name: String, address: String, chain: Chain): Boolean = + this.name == name && this.address == address && this.chain == chain diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Name.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Name.kt index 8257066bfc..d1bff19b73 100644 --- a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Name.kt +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Name.kt @@ -41,6 +41,8 @@ enum class NameProvider(val string: String) { Hyperliquid("hyperliquid"), @SerialName("alldomains") AllDomains("alldomains"), + @SerialName("near") + Near("near"), } @Serializable diff --git a/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/NameRecordMock.kt b/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/NameRecordMock.kt new file mode 100644 index 0000000000..c4f7332e5c --- /dev/null +++ b/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/NameRecordMock.kt @@ -0,0 +1,17 @@ +package com.gemwallet.android.testkit + +import com.wallet.core.primitives.Chain +import com.wallet.core.primitives.NameProvider +import com.wallet.core.primitives.NameRecord + +fun mockNameRecord( + name: String = "example.eth", + chain: Chain = Chain.Ethereum, + address: String = "0x5615E8AB93b9d695b6d4d6545f7792aA59e1069a", + provider: NameProvider = NameProvider.Ens, +) = NameRecord( + name = name, + chain = chain, + address = address, + provider = provider, +) diff --git a/core/Cargo.lock b/core/Cargo.lock index 22c721041c..193063fc93 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -5643,6 +5643,7 @@ dependencies = [ "gem_evm", "gem_hash", "gem_jsonrpc", + "gem_near", "gem_solana", "gem_ton", "hex", diff --git a/core/crates/gem_near/src/address.rs b/core/crates/gem_near/src/address.rs index 44b14d4533..a73a1432cc 100644 --- a/core/crates/gem_near/src/address.rs +++ b/core/crates/gem_near/src/address.rs @@ -16,8 +16,24 @@ impl AddressTrait for NearAddress { } } -pub fn validate_address(address: &str) -> bool { - is_implicit_address(address) +pub fn is_valid_address(address: &str) -> bool { + is_implicit_address(address) || is_valid_account_id(address) +} + +pub fn is_valid_account_id(account_id: &str) -> bool { + if !(2..=64).contains(&account_id.len()) { + return false; + } + + let mut previous_is_separator = true; + for character in account_id.bytes() { + match character { + b'a'..=b'z' | b'0'..=b'9' => previous_is_separator = false, + b'-' | b'_' | b'.' if !previous_is_separator => previous_is_separator = true, + _ => return false, + } + } + !previous_is_separator } pub(crate) fn is_implicit_address(address: &str) -> bool { @@ -28,15 +44,30 @@ pub(crate) fn is_implicit_address(address: &str) -> bool { mod tests { use super::*; + #[test] + fn test_near_account() { + let implicit_address = "e3ac115fd911eb985ffd884ee60302c84dc94df52127ccde8d6fb97ad6d22945"; + let eth_implicit_address = "0x85f17cf997934a597031b2e18a9ab6ebd4b9f6a4"; + let deterministic_address = "0s85f17cf997934a597031b2e18a9ab6ebd4b9f6a4"; + + for address in ["aa", "alice-near_1.testnet", "h3rman.near", implicit_address, eth_implicit_address, deterministic_address] { + assert!(is_valid_account_id(address)); + } + for address in ["a", "Alice.near", "Ć’elicia.near", ".near", "alice..near", "alice.near-"] { + assert!(!is_valid_account_id(address)); + } + assert!(!is_valid_account_id(&"a".repeat(65))); + } + #[test] fn test_near_address() { - let address = "e3ac115fd911eb985ffd884ee60302c84dc94df52127ccde8d6fb97ad6d22945"; - let parsed = NearAddress::try_parse(address).unwrap(); + let implicit_address = "e3ac115fd911eb985ffd884ee60302c84dc94df52127ccde8d6fb97ad6d22945"; + let parsed = NearAddress::try_parse(implicit_address).unwrap(); - assert!(validate_address(address)); + assert!(is_implicit_address(implicit_address)); + assert!(!is_implicit_address("h3rman.near")); + assert!(!is_implicit_address("0x85f17cf997934a597031b2e18a9ab6ebd4b9f6a4")); assert_eq!(parsed.as_bytes().len(), 32); - assert_eq!(parsed.encode(), address); - assert!(!validate_address("invalid")); - assert!(!validate_address("e3ac115fd911eb985ffd884ee60302c84dc94df52127ccde8d6fb97ad6d229")); + assert_eq!(parsed.encode(), implicit_address); } } diff --git a/core/crates/gem_near/src/lib.rs b/core/crates/gem_near/src/lib.rs index 1e07cf043f..8c308f1b37 100644 --- a/core/crates/gem_near/src/lib.rs +++ b/core/crates/gem_near/src/lib.rs @@ -1,5 +1,5 @@ pub mod address; -pub use address::validate_address; +pub use address::is_valid_address; #[cfg(feature = "rpc")] pub mod rpc; diff --git a/core/crates/name_resolver/Cargo.toml b/core/crates/name_resolver/Cargo.toml index f80354fe72..0371390449 100644 --- a/core/crates/name_resolver/Cargo.toml +++ b/core/crates/name_resolver/Cargo.toml @@ -23,6 +23,7 @@ primitives = { path = "../primitives" } gem_evm = { path = "../gem_evm" } gem_ton = { path = "../gem_ton" } gem_solana = { path = "../gem_solana" } +gem_near = { path = "../gem_near", features = ["rpc", "reqwest"] } gem_hash = { path = "../gem_hash" } [dev-dependencies] diff --git a/core/crates/name_resolver/src/client.rs b/core/crates/name_resolver/src/client.rs index d66d264bff..3962895b0a 100644 --- a/core/crates/name_resolver/src/client.rs +++ b/core/crates/name_resolver/src/client.rs @@ -2,6 +2,8 @@ use std::error::Error; use std::sync::Arc; use async_trait::async_trait; +use gem_evm::ethereum_address_checksum; +use primitives::EVMChain; use primitives::chain::Chain; use primitives::name::{NameProvider, NameRecord}; @@ -59,6 +61,10 @@ impl Client { let provider = self.matched_provider(name, chain)?; let address = provider.resolve(&query, chain).await?; + let address = match EVMChain::from_chain(chain) { + Some(_) => ethereum_address_checksum(&address)?, + None => address, + }; Ok(NameRecord { provider: provider.provider(), @@ -113,6 +119,33 @@ mod tests { use super::{Client, NameConfig}; use primitives::chain::Chain; + #[tokio::test] + async fn test_resolve_checksums_evm_address() { + let client = Client::new( + vec![ + Box::new(TestProvider::new( + NameProvider::Ud, + vec!["crypto"], + vec![Chain::Ethereum], + Ok("0x5615e8ab93b9d695b6d4d6545f7792aa59e1069a"), + )), + Box::new(TestProvider::new( + NameProvider::Sns, + vec!["sol"], + vec![Chain::Solana], + Ok("GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2"), + )), + ], + NameConfig { max_name_length: 20 }, + ); + + let ethereum = client.resolve("example.crypto", Chain::Ethereum).await.unwrap(); + let solana = client.resolve("example.sol", Chain::Solana).await.unwrap(); + + assert_eq!(ethereum.address, "0x5615E8AB93b9d695b6d4d6545f7792aA59e1069a"); + assert_eq!(solana.address, "GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2"); + } + #[tokio::test] async fn test_resolve_prefers_longer_domain_match() { let client = Client::new( diff --git a/core/crates/name_resolver/src/lib.rs b/core/crates/name_resolver/src/lib.rs index 0ef7bb70b7..161bb9ce7e 100644 --- a/core/crates/name_resolver/src/lib.rs +++ b/core/crates/name_resolver/src/lib.rs @@ -15,6 +15,7 @@ pub mod icns; pub mod injective; pub mod lens; pub mod model; +pub mod near; pub mod sns; pub mod spaceid; pub mod suins; @@ -44,6 +45,7 @@ impl NameProviderFactory { Box::new(base::Basenames::new(settings.name.base.url)), Box::new(hyperliquid::Hyperliquid::new(settings.name.hyperliquid.url)), Box::new(alldomains::AllDomainsClient::new(settings.name.alldomains.url)), + Box::new(near::NearNameClient::new(settings.chains.near.url)), ] } } diff --git a/core/crates/name_resolver/src/near.rs b/core/crates/name_resolver/src/near.rs new file mode 100644 index 0000000000..2c005cc421 --- /dev/null +++ b/core/crates/name_resolver/src/near.rs @@ -0,0 +1,42 @@ +use std::error::Error; + +use async_trait::async_trait; +use gem_client::ReqwestClient; +use gem_jsonrpc::client::JsonRpcClient; +use gem_near::rpc::NearClient; +use primitives::{Chain, NameProvider}; + +use crate::client::NameClient; +use crate::model::NameQuery; + +pub struct NearNameClient { + client: NearClient, +} + +impl NearNameClient { + pub fn new(url: String) -> Self { + Self { + client: NearClient::new(JsonRpcClient::new_reqwest(url)), + } + } +} + +#[async_trait] +impl NameClient for NearNameClient { + async fn resolve(&self, query: &NameQuery, _chain: Chain) -> Result> { + self.client.get_account(&query.domain).await?; + Ok(query.domain.clone()) + } + + fn provider(&self) -> NameProvider { + NameProvider::Near + } + + fn domains(&self) -> Vec<&'static str> { + vec!["near"] + } + + fn chains(&self) -> Vec { + vec![Chain::Near] + } +} diff --git a/core/crates/name_resolver/tests/integration_test.rs b/core/crates/name_resolver/tests/integration_test.rs index f2ffdd460f..0222b4c09d 100644 --- a/core/crates/name_resolver/tests/integration_test.rs +++ b/core/crates/name_resolver/tests/integration_test.rs @@ -10,6 +10,7 @@ mod tests { hyperliquid::Hyperliquid, injective::InjectiveNameClient, model::NameQuery, + near::NearNameClient, suins::SuinsClient, }; use primitives::{Chain, node_config::get_nodes_for_chain}; @@ -80,4 +81,12 @@ mod tests { let address = client.resolve(&NameQuery::new("miester.poor"), Chain::Solana).await.unwrap(); assert_eq!(address.trim(), "2EGGxj2qbNAJNgLCPKca8sxZYetyTjnoRspTPjzN2D67"); } + + #[tokio::test] + async fn test_resolve_near_account() { + let nodes = get_nodes_for_chain(Chain::Near); + let client = NearNameClient::new(nodes[0].url.clone()); + let address = client.resolve(&NameQuery::new("wrap.near"), Chain::Near).await.unwrap(); + assert_eq!(address, "wrap.near"); + } } diff --git a/core/crates/primitives/src/name.rs b/core/crates/primitives/src/name.rs index 845e759e1a..aa81b0accb 100644 --- a/core/crates/primitives/src/name.rs +++ b/core/crates/primitives/src/name.rs @@ -33,4 +33,5 @@ pub enum NameProvider { Basenames, Hyperliquid, AllDomains, + Near, } diff --git a/core/gemstone/src/address.rs b/core/gemstone/src/address.rs index 0c4fd6a492..5ad053bcaa 100644 --- a/core/gemstone/src/address.rs +++ b/core/gemstone/src/address.rs @@ -11,7 +11,7 @@ pub fn validate_address(address: &str, chain: Chain) -> bool { ChainType::Tron => gem_tron::validate_address(address), ChainType::Aptos => gem_aptos::validate_address(address), ChainType::Sui => gem_sui::validate_address(address), - ChainType::Near => gem_near::validate_address(address), + ChainType::Near => gem_near::is_valid_address(address), ChainType::Stellar => gem_stellar::validate_address(address), ChainType::Algorand => gem_algorand::validate_address(address), ChainType::Xrp => gem_xrp::validate_address(address), @@ -50,6 +50,8 @@ mod tests { assert!(validate_address("GvhwZwtV32kYUXUw965CUM3KGPdtBsDwPVpi92brY5R2", Chain::Solana)); assert!(validate_address("rnBFvgZphmN39GWzUJeUitaP22Fr9be75H", Chain::Xrp)); assert!(!validate_address("rnBFvgZphmN39GWzUJeUitaP22Fr9be75J", Chain::Xrp)); + assert!(validate_address("h3rman.near", Chain::Near)); + assert!(validate_address("0x85f17cf997934a597031b2e18a9ab6ebd4b9f6a4", Chain::Near)); assert!(validate_address("UQAzoUpalAaXnVm5MoiYWRZguLFzY0KxFjLv3MkRq5BXz3VV", Chain::Ton)); assert!(validate_address("15e6w4u9nH4Tb9HdJco2Zua4y5DpHb1hHXBKBGkUrLMTpuXo", Chain::Polkadot)); assert!(!validate_address("15e6w4u9nH4Tb9HdJco2Zua4y5DpHb1hHXBKBGkUrLMTpuXj", Chain::Polkadot)); diff --git a/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift b/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift index 4b1098eb12..95b7b40a03 100644 --- a/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift +++ b/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift @@ -68,6 +68,7 @@ struct RecipientSceneViewModelTests { model.addressInputModel.nameRecordViewModel.state = .loading #expect(model.actionButtonState == .disabled) + model.addressInputModel.text = "test.eth" model.addressInputModel.nameRecordViewModel.state = .complete(NameRecord.mock()) #expect(model.actionButtonState == .normal) } diff --git a/ios/Packages/Primitives/Sources/Generated/Name.swift b/ios/Packages/Primitives/Sources/Generated/Name.swift index efd8e6f7a4..981a9c8d6e 100644 --- a/ios/Packages/Primitives/Sources/Generated/Name.swift +++ b/ios/Packages/Primitives/Sources/Generated/Name.swift @@ -21,6 +21,7 @@ public enum NameProvider: String, Codable, Sendable { case basenames case hyperliquid case allDomains = "alldomains" + case near } public struct NameRecord: Codable, Hashable, Sendable { diff --git a/ios/Packages/PrimitivesComponents/Sources/Extensions/NameRecord+PrimitivesComponents.swift b/ios/Packages/PrimitivesComponents/Sources/Extensions/NameRecord+PrimitivesComponents.swift new file mode 100644 index 0000000000..9ba8e9fac3 --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/Extensions/NameRecord+PrimitivesComponents.swift @@ -0,0 +1,12 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import GemstonePrimitives +import Primitives + +extension NameRecord { + func isValidRecipient(name: String, chain: Chain) -> Bool { + self.name == name && + self.chain == chain && + chain.isValidAddress(address) + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/ViewModels/AddressInputViewModel.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/AddressInputViewModel.swift index 549918d8ac..180030812c 100644 --- a/ios/Packages/PrimitivesComponents/Sources/ViewModels/AddressInputViewModel.swift +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/AddressInputViewModel.swift @@ -49,7 +49,8 @@ public final class AddressInputViewModel { switch nameResolveState { case .none: inputModel.isValid && inputModel.text.isNotEmpty case .loading, .error: false - case .complete: true + case let .complete(record): + record.isValidRecipient(name: text, chain: chain) } } diff --git a/ios/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/AddressInputViewModelTests.swift b/ios/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/AddressInputViewModelTests.swift index 777ffb6063..45792be112 100644 --- a/ios/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/AddressInputViewModelTests.swift +++ b/ios/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/AddressInputViewModelTests.swift @@ -29,6 +29,17 @@ struct AddressInputViewModelTests { model.nameRecordViewModel.state = .complete(.mock()) #expect(model.validate()) + + model.nameRecordViewModel.state = .complete(.mock(name: "other.eth")) + #expect(model.validate() == false) + + model.nameRecordViewModel.state = .complete(.mock(address: "test.eth")) + #expect(model.validate() == false) + + model.chain = .near + model.inputModel.text = "h3rman.near" + model.nameRecordViewModel.state = .complete(.mock(name: "h3rman.near", chain: .near, address: "h3rman.near", provider: .near)) + #expect(model.validate()) } @Test