Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(walletconnect): walletconnect integration #2223

Open
wants to merge 167 commits into
base: dev
Choose a base branch
from
Open

Conversation

borngraced
Copy link
Member

@borngraced borngraced commented Sep 16, 2024

#1543
This PR introduces the integration of WalletConnect into the Komodo DeFi Framework (KDF), enabling secure wallet connections for Cosmos and EVM-based chains. KDF acts as the DApp(in this PR), allowing users to initiate and manage transactions securely with external wallets e.g via Metamask.
Key changes include:

  • Implement multi-session handling for concurrent WalletConnect connections across accounts, integrates SQLite/IndexedDB for persistent session storage across devices, enhances relayer disconnection handling for smoother session management
  • Implemented WalletConnect coin activation for Tendermint and EVM.
  • Implemented Withdraw and Swap functionalities for Tendermint and EVM

https://specs.walletconnect.com/2.0/specs/clients/sign/
https://specs.walletconnect.com/2.0/specs/clients/core/pairing/
https://specs.walletconnect.com/2.0/specs/clients/core/crypto/
https://specs.walletconnect.com/2.0/specs/servers/relay/
https://docs.reown.com/advanced/multichain/rpc-reference/ethereum-rpc

Additional improvements include cleanup of unused dependencies, minor code refinements, and WASM compatibility

Updated deps:
Added deps:
Removed deps:

How to test using EVM coin e.g ETH (Native && WASM)

  1. Start kdf (cargo run)
  2. Create WalletConnect session connection
    • Use the RPC below
    • Copy the connection URL and either:
      • Paste directly in your wallet or
      • Generate QR code using QR Code Generator and scan with your wallet (e.g., MetaMask)
{
     "method": "wc_new_connection",
	"userpass": "{{ _.userpass }}",
	"mmrpc": "2.0",
	"params": {
		"required_namespaces": {
			"eip155": {
				"chains": ["eip155:1"],
				"methods": [
					"eth_sendTransaction",
					"eth_signTransaction",
					"personal_sign"
				],
				"events": [
					"accountsChanged",
					"chainChanged"
				]
			}
		}
	}
}
  1. Activate ETH coin (set "priv_key_policy": "WalletConnect", in activation params).
  2. Approve authentication request in your Wallet
  3. Withdraw or trade.

Note: To add more eip155 chains, modify the chains array like this: ["eip155:1", "eip155:250"]

@borngraced borngraced self-assigned this Sep 16, 2024
mm2src/kdf_walletconnect/src/lib.rs Outdated Show resolved Hide resolved
mm2src/kdf_walletconnect/src/lib.rs Outdated Show resolved Hide resolved
mm2src/kdf_walletconnect/src/lib.rs Outdated Show resolved Hide resolved
let irn_metadata = param.irn_metadata();
let ttl = irn_metadata.ttl;
Copy link
Collaborator

@mariocynicys mariocynicys Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can use a timed/expirable map for this case. we do that for electrum and eth also i think. this is to avoid network-induced memory leaks.

mm2src/kdf_walletconnect/src/inbound_message.rs Outdated Show resolved Hide resolved
mm2src/kdf_walletconnect/src/session/rpc/delete.rs Outdated Show resolved Hide resolved
Copy link
Collaborator

@mariocynicys mariocynicys left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i mainly went over some of old comments and resolved the ones which i can see have been updated (could use a helping hand in the remaining ones by pointing where the updates are).

my estimation right now is i would need one more all-over iteration for approval (+ old comments), but i don't wanna promise since break my promises :D

Thanks for that huge work!

mm2src/kdf_walletconnect/src/lib.rs Outdated Show resolved Hide resolved
mm2src/kdf_walletconnect/src/lib.rs Outdated Show resolved Hide resolved
mm2src/kdf_walletconnect/src/session/rpc/settle.rs Outdated Show resolved Hide resolved

self.validate_chain_id(&session, chain_id)?;

// TODO: uncomment when WalletConnect wallets start listening to chainChanged event
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So nothing happens(I mean wallets don't listen to this event) if we send such request

so what about just sending it for now and when wallets listen to it later we get this feat for free

@borngraced
Copy link
Member Author

borngraced commented Dec 26, 2024

so what about just sending it for now and when wallets listen to it later we get this feat for free

Two reasons I didn't want to, this will add extra latency and corresponding wallet will return error as they wont't recognize such session request

Copy link
Collaborator

@mariocynicys mariocynicys left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the changes! Here are my last notes.

Will follow up with the changes and approve once we covered/discussed all the important comments.

Comment on lines 195 to 198
Value::Object(map) => map
.iter()
.enumerate()
.map(|(_, (_, value))| {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't use the enumuration indices nor the map iter keys.
so we can replace map.iter().enumerate().map(|(index, (key, value))| {}) by map.value().map(|value| {}).

.as_u64()
.ok_or_else(|| serde::de::Error::custom("Invalid byte value"))
.and_then(|n| {
if n <= 255 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

better replace 255 with u8::MAX

Copy link
Member Author

@borngraced borngraced Jan 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

n is still of type u64 so we can't assert n <= u8::MAX unless we explicitly cast n to u8 which isn't something we want to do.

Comment on lines +235 to +237
let (topic, url) = self.pairing.create(self.metadata.clone(), None)?;

info!("[{topic}] Subscribing to topic");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's leave a todo here about cleaning up expired pairings that were abandoned.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if we leave deleting expired session to the users? enough decentralizations...xd

Comment on lines +299 to +301

pub(crate) fn get_sessions_full(&self) -> impl Iterator<Item = Session> { self.read().clone().into_values() }

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we can return only the pairs of session topics and controllers

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idea, I will come back to this...

Comment on lines 43 to 65
{
let mut session = ctx.session_manager.write();
let Some(session) = session.get_mut(topic) else {
return MmError::err(WalletConnectError::SessionError(format!("No session found for topic: {topic}")));
};
session.namespaces = settle.namespaces.0;
session.controller = settle.controller.clone();
session.relay = settle.relay;
session.expiry = settle.expiry;

if let Some(value) = settle.session_properties {
let session_properties = serde_json::from_value::<SessionProperties>(value)?;
session.session_properties = Some(session_properties);
};
};

// Update storage session.
let session = ctx
.session_manager
.get_session(topic)
.ok_or(MmError::new(WalletConnectError::SessionError(format!(
"session not found topic: {topic}"
))))?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we could unscope the codeblock above so not to have to query get_session one more time.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the scope for session update is on purpose since we can't hold an await over a sync RwLock, Mutex guard...the most obvious fix is the current arrangement..another fix would be using async mutex

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/// Process session settle request.
pub(crate) async fn reply_session_settle_request(
    ctx: &WalletConnectCtxImpl,
    topic: &Topic,
    settle: SessionSettleRequest,
) -> MmResult<(), WalletConnectError> {
    let mut sessions = ctx.session_manager.write();
    let Some(session) = sessions.get_mut(topic) else {
        return MmError::err(WalletConnectError::SessionError(format!("No session found for topic: {topic}")));
    };
    session.namespaces = settle.namespaces.0;
    session.controller = settle.controller.clone();
    session.relay = settle.relay;
    session.expiry = settle.expiry;

    if let Some(value) = settle.session_properties {
        let session_properties = serde_json::from_value::<SessionProperties>(value)?;
        session.session_properties = Some(session_properties);
    };

    let session = session.clone();
    drop(sessions);
    ctx.session_manager
        .storage()
        .update_session(&session)
        .await
        .mm_err(|err| WalletConnectError::StorageError(err.to_string()))?;

    info!("[{topic}] Session successfully settled for topic");

    // Delete other sessions with same controller
    // NOTE: we might not want to do this!
    let all_sessions = ctx.session_manager.get_sessions_full();
    for session in all_sessions {
        if session.controller == settle.controller && session.topic.as_ref() != topic.as_ref() {
            ctx.drop_session(&session.topic).await?;
            debug!("[{}] session deleted", session.topic);
        }
    }

    Ok(())
}

that's what i am suggesting, to drop the mutex before awaiting.
that said, this doesn't compile for some reason that i don't comprehend (still thinks sessions variable is inscope for some reason).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/// Process session settle request.
pub(crate) async fn reply_session_settle_request(
    ctx: &WalletConnectCtxImpl,
    topic: &Topic,
    settle: SessionSettleRequest,
) -> MmResult<(), WalletConnectError> {
    let session = {
        let mut sessions = ctx.session_manager.write();
        let Some(session) = sessions.get_mut(topic) else {
            return MmError::err(WalletConnectError::SessionError(format!("No session found for topic: {topic}")));
        };
        session.namespaces = settle.namespaces.0;
        session.controller = settle.controller.clone();
        session.relay = settle.relay;
        session.expiry = settle.expiry;

        if let Some(value) = settle.session_properties {
            let session_properties = serde_json::from_value::<SessionProperties>(value)?;
            session.session_properties = Some(session_properties);
        };
        session.clone()
    };

    ctx.session_manager
        .storage()
        .update_session(&session)
        .await
        .mm_err(|err| WalletConnectError::StorageError(err.to_string()))?;

    info!("[{topic}] Session successfully settled for topic");

    // Delete other sessions with same controller
    // NOTE: we might not want to do this!
    let all_sessions = ctx.session_manager.get_sessions_full();
    for session in all_sessions {
        if session.controller == settle.controller && session.topic.as_ref() != topic.as_ref() {
            ctx.drop_session(&session.topic).await?;
            debug!("[{}] session deleted", session.topic);
        }
    }

    Ok(())
}

a working sol

Copy link
Member Author

@borngraced borngraced Jan 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I avoided this because I didn't want to clone.
Actually I think I should have kept the async mutex I used earlier....cloning here is just an unnecessary overhead IMO.

updated bb73b1f

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I avoided this because I didn't want to clone.

calling get_session does already clone. also serializing this to the DB probably clone as well. that's a tiny overhead anyways.

Actually I think I should have kept the async mutex I used earlier....cloning here is just an unnecessary overhead IMO.

Not at all 😂, having that mutex async just locks the mutex way more than needed, degrading pref. Also being sync guards us in compile time from very nasty mistakes (e.g. forgetting to release the mutex before awaiting on a network request, and boom, you have a disaster of a synchronization mutex).

Comment on lines +84 to +86
message_id_generator: MessageIdGenerator,
pending_requests: Mutex<HashMap<MessageId, oneshot::Sender<SessionMessageType>>>,
abortable_system: AbortableQueue,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should really make pending_requests a timed map.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request handles which we don't get a response for will reside in the map forever

Comment on lines 178 to 184
struct SessionManagerImpl {
/// The currently active session topic.
active_topic: Mutex<Option<Topic>>,
/// A thread-safe map of sessions indexed by topic.
sessions: Arc<RwLock<HashMap<Topic, Session>>>,
pub(crate) storage: SessionStorageDb,
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with the latest changes, i don't think we need active_topic any more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed active_topic and active_sesson.

Comment on lines 226 to 237
/// Get active session topic or return error if no session has been activated.
pub fn get_active_topic_or_err(&self) -> MmResult<Topic, WalletConnectError> {
self.0
.active_topic
.lock()
.unwrap()
.clone()
.ok_or(MmError::new(WalletConnectError::SessionError(
"No active session".to_owned(),
)))
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and this method is never used.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

Comment on lines 245 to 247
let wallet_type = if wc.is_ledger_connection() {
TendermintWalletConnectionType::WcLedger(session_topic)
} else {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should check weather this session topic is a ledger connection and not the active session (and should remove the active session field anyway)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment on lines 106 to 109
wc.send_session_request_and_wait(session_topic, &chain_id, method, params, |data: CosmosTxSignedData| {
let signature = general_purpose::STANDARD
.decode(data.signature.signature)
.map_to_mm(|err| WalletConnectError::PayloadError(err.to_string()))?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we provide callback as the last args? why not get the return value and do the callback here?
doesn't seem intuitive

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want to use WalletConnect lib in coin mod... also taking callback as last args seems practical
https://stackoverflow.com/a/41394746

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want to use WalletConnect lib in coin mod

How is that using walletconnect lib in coins crate? the callback is used at the very end of send_session_request_and_wait, it could just be taken out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants