Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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?,
Comment thread
0xh3rman marked this conversation as resolved.
Outdated
validateAddress: ValidateAddressOperator,
): Boolean = validateAddress(address, chain).getOrNull() == true &&
(resolvedNameRecord == null || resolvedNameRecord.matchesRecipient(inputAddress, address, chain))
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}

Expand All @@ -194,10 +196,11 @@ class RecipientViewModel @Inject constructor(
destination: DestinationAddress,
amountAction: AmountTransactionAction,
confirmAction: ConfirmTransactionAction,
resolvedNameRecord: NameRecord? = null,
Comment thread
0xh3rman marked this conversation as resolved.
Outdated
) {
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 }
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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 validRecipientRequiresValidAddressAndMatchingRecord() {
val accountId = "wrap.near"
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 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 = "0x1234567890123456789012345678901234567890"
val ethereumDestination = DestinationAddress(address = ethereumAddress, name = ethereumName)
val ethereumRecord = NameRecord(
Comment thread
0xh3rman marked this conversation as resolved.
Outdated
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 {
override fun invoke(address: String, chain: Chain): Result<Boolean> = Result.success(result)
}
}
Original file line number Diff line number Diff line change
@@ -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
Comment thread
0xh3rman marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ enum class NameProvider(val string: String) {
Hyperliquid("hyperliquid"),
@SerialName("alldomains")
AllDomains("alldomains"),
@SerialName("near")
Near("near"),
}

@Serializable
Expand Down
1 change: 1 addition & 0 deletions core/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 38 additions & 7 deletions core/crates/gem_near/src/address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,23 @@ impl AddressTrait for NearAddress {
}

pub fn validate_address(address: &str) -> bool {
Comment thread
0xh3rman marked this conversation as resolved.
Outdated
is_implicit_address(address)
validate_account_id(address)
}

pub fn validate_account_id(account_id: &str) -> bool {
Comment thread
0xh3rman marked this conversation as resolved.
Outdated
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 {
Expand All @@ -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);
}
}
1 change: 1 addition & 0 deletions core/crates/name_resolver/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions core/crates/name_resolver/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)),
]
}
}
42 changes: 42 additions & 0 deletions core/crates/name_resolver/src/near.rs
Original file line number Diff line number Diff line change
@@ -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<ReqwestClient>,
}

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<String, Box<dyn Error + Send + Sync>> {
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<Chain> {
vec![Chain::Near]
}
}
9 changes: 9 additions & 0 deletions core/crates/name_resolver/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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");
}
}
1 change: 1 addition & 0 deletions core/crates/primitives/src/name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@ pub enum NameProvider {
Basenames,
Hyperliquid,
AllDomains,
Near,
}
2 changes: 2 additions & 0 deletions core/gemstone/src/address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions ios/Packages/Primitives/Sources/Generated/Name.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading