From 8c2636b22817bafff46013f28b37c96cfff9e167 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:45:30 +0900 Subject: [PATCH 1/3] lookup .near account --- .../viewmodel/RecipientValidation.kt | 15 +++++++ .../recipient/viewmodel/RecipientViewModel.kt | 13 +++--- .../viewmodel/RecipientValidationTest.kt | 33 +++++++++++++++ .../com/gemwallet/android/ext/NameRecord.kt | 7 ++++ .../wallet/core/primitives/generated/Name.kt | 2 + core/Cargo.lock | 1 + core/crates/name_resolver/Cargo.toml | 1 + core/crates/name_resolver/src/lib.rs | 2 + core/crates/name_resolver/src/near.rs | 42 +++++++++++++++++++ .../name_resolver/tests/integration_test.rs | 9 ++++ core/crates/primitives/src/name.rs | 1 + .../Primitives/Sources/Generated/Name.swift | 1 + 12 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientValidation.kt create mode 100644 android/features/recipient/viewmodels/src/test/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientValidationTest.kt create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/ext/NameRecord.kt create mode 100644 core/crates/name_resolver/src/near.rs 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..1fb9793fdc --- /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, + resolvedNameRecord: NameRecord?, + validateAddress: ValidateAddressOperator, +): Boolean = resolvedNameRecord?.matchesRecipient(inputAddress, address, chain) == true || + validateAddress(address, chain).getOrNull() == true 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..31100a478b 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, + resolvedNameRecord = resolvedNameRecord, ) } @@ -194,10 +196,11 @@ class RecipientViewModel @Inject constructor( destination: DestinationAddress, amountAction: AmountTransactionAction, confirmAction: ConfirmTransactionAction, + resolvedNameRecord: 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, resolvedNameRecord) 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, resolvedNameRecord: NameRecord? = null): RecipientError = + if (destination.isValidRecipient(address.value, asset.chain, resolvedNameRecord, 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..45f3088de5 --- /dev/null +++ b/android/features/recipient/viewmodels/src/test/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientValidationTest.kt @@ -0,0 +1,33 @@ +package com.gemwallet.android.features.recipient.viewmodel + +import com.gemwallet.android.blockchain.operators.ValidateAddressOperator +import com.gemwallet.android.model.DestinationAddress +import com.wallet.core.primitives.Chain +import com.wallet.core.primitives.NameProvider +import com.wallet.core.primitives.NameRecord +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecipientValidationTest { + + @Test + fun resolvedNameRequiresMatchingRecord() { + val accountId = "wrap.near" + val otherAccountId = "other.near" + val chain = Chain.Near + val rejectAddress = addressValidator(false) + val destination = DestinationAddress(address = accountId, name = accountId) + val record = NameRecord(name = accountId, chain = chain, address = accountId, provider = NameProvider.Near) + + assertTrue(destination.isValidRecipient(accountId, chain, record, rejectAddress)) + assertFalse(destination.isValidRecipient(otherAccountId, chain, record, rejectAddress)) + assertFalse(destination.isValidRecipient(accountId, Chain.Ethereum, record, rejectAddress)) + assertFalse(destination.isValidRecipient(accountId, chain, null, rejectAddress)) + assertTrue(destination.isValidRecipient(accountId, chain, null, addressValidator(true))) + } + + 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/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/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/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/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 { From 84ed44ef69b25a9ebbc7156a3a65d86b42054b78 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:39:44 +0900 Subject: [PATCH 2/3] add validate_account_id to rust --- .../viewmodel/RecipientValidation.kt | 4 +- .../viewmodel/RecipientValidationTest.kt | 38 +++++++++++++--- core/crates/gem_near/src/address.rs | 45 ++++++++++++++++--- core/gemstone/src/address.rs | 2 + .../Tests/RecipientSceneViewModelTests.swift | 1 + .../NameRecord+PrimitivesComponents.swift | 12 +++++ .../ViewModels/AddressInputViewModel.swift | 3 +- .../AddressInputViewModelTests.swift | 11 +++++ 8 files changed, 99 insertions(+), 17 deletions(-) create mode 100644 ios/Packages/PrimitivesComponents/Sources/Extensions/NameRecord+PrimitivesComponents.swift 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 index 1fb9793fdc..0886323813 100644 --- 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 @@ -11,5 +11,5 @@ internal fun DestinationAddress.isValidRecipient( chain: Chain, resolvedNameRecord: NameRecord?, validateAddress: ValidateAddressOperator, -): Boolean = resolvedNameRecord?.matchesRecipient(inputAddress, address, chain) == true || - validateAddress(address, chain).getOrNull() == true +): Boolean = validateAddress(address, chain).getOrNull() == true && + (resolvedNameRecord == null || resolvedNameRecord.matchesRecipient(inputAddress, address, chain)) 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 index 45f3088de5..b15034149a 100644 --- 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 @@ -12,19 +12,43 @@ import org.junit.Test class RecipientValidationTest { @Test - fun resolvedNameRequiresMatchingRecord() { + fun validRecipientRequiresValidAddressAndMatchingRecord() { val accountId = "wrap.near" val otherAccountId = "other.near" val chain = Chain.Near - val rejectAddress = addressValidator(false) val destination = DestinationAddress(address = accountId, name = accountId) val record = NameRecord(name = accountId, chain = chain, address = accountId, provider = NameProvider.Near) + val validAddress = addressValidator(true) + val invalidAddress = addressValidator(false) - assertTrue(destination.isValidRecipient(accountId, chain, record, rejectAddress)) - assertFalse(destination.isValidRecipient(otherAccountId, chain, record, rejectAddress)) - assertFalse(destination.isValidRecipient(accountId, Chain.Ethereum, record, rejectAddress)) - assertFalse(destination.isValidRecipient(accountId, chain, null, rejectAddress)) - assertTrue(destination.isValidRecipient(accountId, chain, null, addressValidator(true))) + 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 = "0x1234567890123456789012345678901234567890" + val ethereumDestination = DestinationAddress(address = ethereumAddress, name = ethereumName) + val ethereumRecord = NameRecord( + name = ethereumName, + chain = Chain.Ethereum, + address = ethereumAddress, + provider = NameProvider.Ens, + ) + 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 { diff --git a/core/crates/gem_near/src/address.rs b/core/crates/gem_near/src/address.rs index 44b14d4533..104db78f35 100644 --- a/core/crates/gem_near/src/address.rs +++ b/core/crates/gem_near/src/address.rs @@ -17,7 +17,23 @@ impl AddressTrait for NearAddress { } pub fn validate_address(address: &str) -> bool { - is_implicit_address(address) + validate_account_id(address) +} + +pub fn validate_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!(validate_account_id(address)); + } + for address in ["a", "Alice.near", "ƒelicia.near", ".near", "alice..near", "alice.near-"] { + assert!(!validate_account_id(address)); + } + assert!(!validate_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/gemstone/src/address.rs b/core/gemstone/src/address.rs index 0c4fd6a492..dc947d50b4 100644 --- a/core/gemstone/src/address.rs +++ b/core/gemstone/src/address.rs @@ -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/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 From b8c0f8e1e198de8040ee24bede966d77a6de614c Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:09:59 +0900 Subject: [PATCH 3/3] review changes --- .../recipient/viewmodels/build.gradle.kts | 1 + .../viewmodel/RecipientValidation.kt | 4 +-- .../recipient/viewmodel/RecipientViewModel.kt | 10 +++--- .../viewmodel/RecipientValidationTest.kt | 10 +++--- .../android/testkit/NameRecordMock.kt | 17 ++++++++++ core/crates/gem_near/src/address.rs | 12 +++---- core/crates/gem_near/src/lib.rs | 2 +- core/crates/name_resolver/src/client.rs | 33 +++++++++++++++++++ core/gemstone/src/address.rs | 2 +- 9 files changed, 70 insertions(+), 21 deletions(-) create mode 100644 android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/NameRecordMock.kt 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 index 0886323813..b05d8ea088 100644 --- 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 @@ -9,7 +9,7 @@ import com.wallet.core.primitives.NameRecord internal fun DestinationAddress.isValidRecipient( inputAddress: String, chain: Chain, - resolvedNameRecord: NameRecord?, + nameRecord: NameRecord?, validateAddress: ValidateAddressOperator, ): Boolean = validateAddress(address, chain).getOrNull() == true && - (resolvedNameRecord == null || resolvedNameRecord.matchesRecipient(inputAddress, address, chain)) + (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 31100a478b..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 @@ -178,7 +178,7 @@ class RecipientViewModel @Inject constructor( ), amountAction = amountAction, confirmAction = confirmAction, - resolvedNameRecord = resolvedNameRecord, + nameRecord = resolvedNameRecord, ) } @@ -196,11 +196,11 @@ class RecipientViewModel @Inject constructor( destination: DestinationAddress, amountAction: AmountTransactionAction, confirmAction: ConfirmTransactionAction, - resolvedNameRecord: NameRecord? = null, + nameRecord: NameRecord? = null, ) { val asset = type.assetInfo.asset destination.copy(address = asset.chain.checksumAddress(destination.address)).let { destination -> - val validation = validateDestination(asset, destination, resolvedNameRecord) + val validation = validateDestination(asset, destination, nameRecord) if (validation != RecipientError.None) { if (!resolveName.canResolveName(destination.address)) { addressError.update { validation } @@ -271,8 +271,8 @@ class RecipientViewModel @Inject constructor( confirmAction(params) } - private fun validateDestination(asset: Asset, destination: DestinationAddress, resolvedNameRecord: NameRecord? = null): RecipientError = - if (destination.isValidRecipient(address.value, asset.chain, resolvedNameRecord, validateAddressOperator)) { + 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 index b15034149a..67449eccb8 100644 --- 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 @@ -2,9 +2,9 @@ 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 com.wallet.core.primitives.NameRecord import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -17,7 +17,7 @@ class RecipientValidationTest { val otherAccountId = "other.near" val chain = Chain.Near val destination = DestinationAddress(address = accountId, name = accountId) - val record = NameRecord(name = accountId, chain = chain, address = accountId, provider = NameProvider.Near) + val record = mockNameRecord(name = accountId, chain = chain, address = accountId, provider = NameProvider.Near) val validAddress = addressValidator(true) val invalidAddress = addressValidator(false) @@ -29,13 +29,11 @@ class RecipientValidationTest { assertTrue(destination.isValidRecipient(accountId, chain, null, validAddress)) val ethereumName = "example.eth" - val ethereumAddress = "0x1234567890123456789012345678901234567890" + val ethereumAddress = "0x5615E8AB93b9d695b6d4d6545f7792aA59e1069a" val ethereumDestination = DestinationAddress(address = ethereumAddress, name = ethereumName) - val ethereumRecord = NameRecord( + val ethereumRecord = mockNameRecord( name = ethereumName, - chain = Chain.Ethereum, address = ethereumAddress, - provider = NameProvider.Ens, ) val unresolvedEthereumRecord = ethereumRecord.copy(address = ethereumName) val unresolvedEthereumDestination = DestinationAddress(address = ethereumName, name = ethereumName) 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/crates/gem_near/src/address.rs b/core/crates/gem_near/src/address.rs index 104db78f35..a73a1432cc 100644 --- a/core/crates/gem_near/src/address.rs +++ b/core/crates/gem_near/src/address.rs @@ -16,11 +16,11 @@ impl AddressTrait for NearAddress { } } -pub fn validate_address(address: &str) -> bool { - validate_account_id(address) +pub fn is_valid_address(address: &str) -> bool { + is_implicit_address(address) || is_valid_account_id(address) } -pub fn validate_account_id(account_id: &str) -> bool { +pub fn is_valid_account_id(account_id: &str) -> bool { if !(2..=64).contains(&account_id.len()) { return false; } @@ -51,12 +51,12 @@ mod tests { let deterministic_address = "0s85f17cf997934a597031b2e18a9ab6ebd4b9f6a4"; for address in ["aa", "alice-near_1.testnet", "h3rman.near", implicit_address, eth_implicit_address, deterministic_address] { - assert!(validate_account_id(address)); + assert!(is_valid_account_id(address)); } for address in ["a", "Alice.near", "ƒelicia.near", ".near", "alice..near", "alice.near-"] { - assert!(!validate_account_id(address)); + assert!(!is_valid_account_id(address)); } - assert!(!validate_account_id(&"a".repeat(65))); + assert!(!is_valid_account_id(&"a".repeat(65))); } #[test] 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/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/gemstone/src/address.rs b/core/gemstone/src/address.rs index dc947d50b4..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),