From fd229a0cac3345441acd3df189bfa1d9ec169a76 Mon Sep 17 00:00:00 2001 From: samtin0x <40127309+samtin0x@users.noreply.github.com> Date: Sun, 4 Aug 2024 18:17:43 +0200 Subject: [PATCH 1/2] enable NobleClient native transfers --- .../indexer/rest/modules/markets.py | 1 + .../indexer/rest/noble_client.py | 240 +++++------------- .../tests/indexer/rest/test_noble_client.py | 79 +++--- 3 files changed, 113 insertions(+), 207 deletions(-) diff --git a/v4-client-py-v2/dydx_v4_client/indexer/rest/modules/markets.py b/v4-client-py-v2/dydx_v4_client/indexer/rest/modules/markets.py index 79153352..f72c9d54 100644 --- a/v4-client-py-v2/dydx_v4_client/indexer/rest/modules/markets.py +++ b/v4-client-py-v2/dydx_v4_client/indexer/rest/modules/markets.py @@ -80,6 +80,7 @@ async def get_perpetual_market_candles( dict: The candle data. """ uri = f"/v4/candles/perpetualMarkets/{market}" + return await self.get( uri, params={ diff --git a/v4-client-py-v2/dydx_v4_client/indexer/rest/noble_client.py b/v4-client-py-v2/dydx_v4_client/indexer/rest/noble_client.py index 714a151a..7b596ca1 100644 --- a/v4-client-py-v2/dydx_v4_client/indexer/rest/noble_client.py +++ b/v4-client-py-v2/dydx_v4_client/indexer/rest/noble_client.py @@ -1,35 +1,16 @@ +import hashlib from typing import List, Optional - import grpc -from ecdsa.util import sigencode_string_canonize -from v4_proto.cosmos.auth.v1beta1 import query_pb2_grpc as auth -from v4_proto.cosmos.auth.v1beta1.auth_pb2 import BaseAccount -from v4_proto.cosmos.auth.v1beta1.query_pb2 import QueryAccountRequest +from bech32 import convertbits, bech32_encode +from google.protobuf.any_pb2 import Any from v4_proto.cosmos.bank.v1beta1 import query_pb2 as bank_query +from v4_proto.cosmos.bank.v1beta1 import tx_pb2 as bank_tx from v4_proto.cosmos.bank.v1beta1 import query_pb2_grpc as bank_query_grpc -from v4_proto.cosmos.base.abci.v1beta1.abci_pb2 import TxResponse from v4_proto.cosmos.base.v1beta1.coin_pb2 import Coin -from v4_proto.cosmos.crypto.secp256k1.keys_pb2 import PubKey -from v4_proto.cosmos.tx.signing.v1beta1.signing_pb2 import SignMode from v4_proto.cosmos.tx.v1beta1 import service_pb2_grpc -from v4_proto.cosmos.tx.v1beta1.service_pb2 import ( - BroadcastMode, - BroadcastTxRequest, - SimulateRequest, -) -from v4_proto.cosmos.tx.v1beta1.tx_pb2 import ( - AuthInfo, - Fee, - ModeInfo, - SignDoc, - SignerInfo, - Tx, - TxBody, -) - -from dydx_v4_client.config import GAS_MULTIPLIER -from dydx_v4_client.node.builder import as_any -from dydx_v4_client.wallet import from_mnemonic +from v4_proto.cosmos.tx.v1beta1.service_pb2 import SimulateRequest, BroadcastTxRequest, BroadcastMode +from v4_proto.cosmos.tx.v1beta1.tx_pb2 import Tx, TxBody, AuthInfo, Fee +from dydx_v4_client.wallet import from_mnemonic, Wallet class NobleClient: @@ -73,189 +54,104 @@ async def connect(self, mnemonic: str): if not mnemonic: raise ValueError("Mnemonic not provided") private_key = from_mnemonic(mnemonic) - self.wallet = private_key + self.wallet = Wallet(private_key, 0, 0) self.channel = grpc.secure_channel( self.rest_endpoint, grpc.ssl_channel_credentials(), ) - async def get_account_balances( - self, address: str - ) -> bank_query.QueryAllBalancesResponse: + async def get_account_balances(self) -> List[Coin]: """ - Retrieves the account balances for the specified address. - - Args: - address (str): The account address. + Retrieves the account balances for the connected wallet. Returns: - bank_query.QueryAllBalancesResponse: The response containing the account balances. + List[Coin]: A list of Coin objects representing the account balances. Raises: - ValueError: If the client channel is not initialized. + ValueError: If the client is not connected. """ - if self.channel is None: - raise ValueError("NobleClient channel not initialized") + if not self.is_connected: + raise ValueError("Client is not connected") + + address = self.wallet.address stub = bank_query_grpc.QueryStub(self.channel) - return stub.AllBalances(bank_query.QueryAllBalancesRequest(address=address)) + request = bank_query.QueryAllBalancesRequest(address=address) + response = stub.AllBalances(request) + return response.balances - async def get_account_balance( - self, address: str, denom: str - ) -> bank_query.QueryBalanceResponse: + async def simulate_transfer_native_token(self, amount: str, recipient: str) -> Fee: """ - Retrieves the account balance for the specified address and denomination. + Simulates a transfer of native tokens. Args: - address (str): The account address. - denom (str): The balance denomination. + amount (str): The amount of tokens to transfer. + recipient (str): The recipient's address. Returns: - bank_query.QueryBalanceResponse: The response containing the account balance. + Fee: The estimated fee for the transaction. Raises: - ValueError: If the client channel is not initialized. + ValueError: If the client is not connected. """ - if self.channel is None: - raise ValueError("NobleClient channel not initialized") - stub = bank_query_grpc.QueryStub(self.channel) - return stub.Balance( - bank_query.QueryBalanceRequest(address=address, denom=denom) + if not self.is_connected: + raise ValueError("Client is not connected") + + # Create a MsgSend transaction + msg_send = bank_tx.MsgSend( + from_address=self.wallet.address, + to_address=recipient, + amount=[Coin(amount=amount, denom="uusdc")] ) + any_msg = Any() + any_msg.Pack(msg_send) - async def get_account(self, address: str) -> BaseAccount: - """ - Retrieves the account information for the specified address. + tx_body = TxBody(messages=[any_msg]) + auth_info = AuthInfo() + tx = Tx(body=tx_body, auth_info=auth_info) - Args: - address (str): The account address. + stub = service_pb2_grpc.ServiceStub(self.channel) + simulate_request = SimulateRequest(tx=tx) + simulate_response = stub.Simulate(simulate_request) - Returns: - BaseAccount: The account information. + return Fee(amount=simulate_response.gas_info.fee.amount, gas_limit=simulate_response.gas_info.gas_used) - Raises: - ValueError: If the client channel is not initialized. - Exception: If the account unpacking fails. - """ - if self.channel is None: - raise ValueError("NobleClient channel not initialized") - account = BaseAccount() - response = auth.QueryStub(self.channel).Account( - QueryAccountRequest(address=address) - ) - if not response.account.Unpack(account): - raise Exception("Failed to unpack account") - return account - - async def send( - self, - messages: List[dict], - gas_price: str = "0.025uusdc", - memo: Optional[str] = None, - ) -> TxResponse: + + async def transfer_native(self, amount: str, recipient: str) -> str: """ - Sends a transaction with the specified messages. + Transfers native tokens to the specified recipient. Args: - messages (List[dict]): The list of transaction messages. - gas_price (str): The gas price for the transaction (default: "0.025uusdc"). - memo (Optional[str]): The transaction memo. + amount (str): The amount of tokens to transfer. + recipient (str): The recipient's address. Returns: - TxResponse: The transaction response. + str: The transaction hash. Raises: - ValueError: If the client channel or wallet is not initialized. + ValueError: If the client is not connected. """ - if self.channel is None: - raise ValueError("NobleClient channel not initialized") - if self.wallet is None: - raise ValueError("NobleClient wallet not initialized") - - # Simulate to get the gas estimate - fee = await self.simulate_transaction( - messages, gas_price, memo or self.default_client_memo - ) + if not self.is_connected: + raise ValueError("Client is not connected") - # Sign and broadcast the transaction - signer_info = SignerInfo( - public_key=as_any( - PubKey(key=self.wallet.get_verifying_key().to_string("compressed")) - ), - mode_info=ModeInfo(single=ModeInfo.Single(mode=SignMode.SIGN_MODE_DIRECT)), - sequence=self.get_account( - self.wallet.get_verifying_key().to_string() - ).sequence, - ) - body = TxBody(messages=messages, memo=memo or self.default_client_memo) - auth_info = AuthInfo(signer_infos=[signer_info], fee=fee) - signature = self.wallet.sign( - SignDoc( - body_bytes=body.SerializeToString(), - auth_info_bytes=auth_info.SerializeToString(), - account_number=self.get_account( - self.wallet.get_verifying_key().to_string() - ).account_number, - chain_id=self.chain_id, - ).SerializeToString(), - sigencode=sigencode_string_canonize, + # Create a MsgSend transaction + msg_send = bank_tx.MsgSend( + from_address=self.wallet.address, + to_address=recipient, + amount=[Coin(amount=amount, denom="uusdc")] ) + any_msg = Any() + any_msg.Pack(msg_send) - tx = Tx(body=body, auth_info=auth_info, signatures=[signature]) - request = BroadcastTxRequest( - tx_bytes=tx.SerializeToString(), mode=BroadcastMode.BROADCAST_MODE_SYNC - ) - return service_pb2_grpc.ServiceStub(self.channel).BroadcastTx(request) - - async def simulate_transaction( - self, - messages: List[dict], - gas_price: str = "0.025uusdc", - memo: Optional[str] = None, - ) -> Fee: - """ - Simulates a transaction to estimate the gas fee. - Args: - messages (List[dict]): The list of transaction messages. - gas_price (str): The gas price for the transaction (default: "0.025uusdc"). - memo (Optional[str]): The transaction memo. + tx_body = TxBody(messages=[any_msg], memo=self.default_client_memo) + fee = await self.simulate_transfer_native_token(amount, recipient) - Returns: - Fee: The estimated gas fee. + auth_info = AuthInfo(fee=fee) + tx = Tx(body=tx_body, auth_info=auth_info) + signed_tx = self.wallet.sign_tx(tx) - Raises: - ValueError: If the client channel or wallet is not initialized. - """ - if self.channel is None: - raise ValueError("NobleClient channel not initialized") - if self.wallet is None: - raise ValueError("NobleClient wallet not initialized") - - # Get simulated response - signer_info = SignerInfo( - public_key=as_any( - PubKey(key=self.wallet.get_verifying_key().to_string("compressed")) - ), - mode_info=ModeInfo(single=ModeInfo.Single(mode=SignMode.SIGN_MODE_DIRECT)), - sequence=self.get_account( - self.wallet.get_verifying_key().to_string() - ).sequence, - ) - body = TxBody(messages=messages, memo=memo or self.default_client_memo) - auth_info = AuthInfo(signer_infos=[signer_info], fee=Fee(gas_limit=0)) - request = SimulateRequest( - tx=Tx(body=body, auth_info=auth_info), - ) - response = service_pb2_grpc.ServiceStub(self.channel).Simulate(request) - - # Calculate and return the fee - gas_limit = int(response.gas_info.gas_used * GAS_MULTIPLIER) - return Fee( - amount=[ - Coin( - amount=str(int(gas_limit * float(gas_price.split("u")[0]))), - denom=gas_price.split("u")[1], - ) - ], - gas_limit=gas_limit, - ) + stub = service_pb2_grpc.ServiceStub(self.channel) + broadcast_request = BroadcastTxRequest(tx_bytes=signed_tx.SerializeToString(), mode=BroadcastMode.BROADCAST_MODE_BLOCK) + broadcast_response = stub.BroadcastTx(broadcast_request) + + return broadcast_response.tx_response.txhash diff --git a/v4-client-py-v2/tests/indexer/rest/test_noble_client.py b/v4-client-py-v2/tests/indexer/rest/test_noble_client.py index d4006da4..5c2fd056 100644 --- a/v4-client-py-v2/tests/indexer/rest/test_noble_client.py +++ b/v4-client-py-v2/tests/indexer/rest/test_noble_client.py @@ -1,5 +1,6 @@ import pytest -from v4_proto.cosmos.base.abci.v1beta1.abci_pb2 import TxResponse + +from dydx_v4_client.node.fee import Coin, Fee @pytest.mark.asyncio @@ -8,43 +9,51 @@ async def test_is_connected(noble_client): @pytest.mark.asyncio -@pytest.mark.skip(reason="This test is not implemented") -async def test_ibc_transfer(node_client, noble_client): - message = { - "source_port": "transfer", - "source_channel": "channel-0", - "token": {"denom": "usdc", "amount": "1000"}, - "sender": noble_client.wallet.get_verifying_key().to_string(), - "receiver": "cosmos1...", - "timeout_height": 0, - "timeout_timestamp": 0, - } - tx_response = await node_client.ibc_transfer([message]) - assert isinstance(tx_response, TxResponse) - assert tx_response.code == 0 +async def test_get_address(noble_client): + address = noble_client.wallet.address + + assert isinstance(address, str) + assert len(address) == 43 + assert address.startswith('dydx') @pytest.mark.asyncio -@pytest.mark.skip(reason="This test is not implemented") -async def test_send(noble_client): - message = { - "depositor": noble_client.wallet.get_verifying_key().to_string(), - "amount": {"denom": "usdc", "amount": "1000"}, - } - tx_response = await noble_client.send([message]) - assert isinstance(tx_response, TxResponse) - assert tx_response.code == 0 +async def test_get_account_balances(noble_client): + balances = await noble_client.get_account_balances() + + assert isinstance(balances, list) + assert len(balances) > 0 + assert all(isinstance(coin, Coin) for coin in balances) + + # Check if there's a balance for the native token (uusdc) + uusdc_balance = next((coin for coin in balances if coin.denom == 'uusdc'), None) + assert uusdc_balance is not None + assert int(uusdc_balance.amount) > 0 @pytest.mark.asyncio -@pytest.mark.skip(reason="This test is not implemented") -async def test_simulate_transaction(noble_client, node_client): - message = { - "depositor": noble_client.wallet.get_verifying_key().to_string(), - "amount": {"denom": "usdc", "amount": "1000"}, - } - fee = await noble_client.simulate_transaction([message]) - assert isinstance(fee, dict) - assert fee["gas_limit"] > 0 - assert len(fee["amount"]) == 1 - assert "usdc" in fee["amount"][0]["denom"] +async def test_simulate_transfer_native_token(noble_client, test_address): + amount = "1000000" # 1 USDC (assuming 6 decimal places) + recipient = "dydx15ndn9c895f8ntck25qughtuck9spv2d9svw5qx" + + fee = await noble_client.simulate_transfer_native_token(amount, recipient) + + assert isinstance(fee, Fee) + assert len(fee.amount) > 0 + assert fee.gas_limit > 0 + + # Check if the fee is reasonable (e.g., less than 1% of the transfer amount) + fee_amount = sum(int(coin.amount) for coin in fee.amount) + assert fee_amount < int(amount) * 0.01 + + +@pytest.mark.asyncio +async def test_transfer_native_token(noble_client): + amount = "1000000" # 1 USDC (6 decimal places) + recipient = "dydx15ndn9c895f8ntck25qughtuck9spv2d9svw5qx" + + tx_hash = await noble_client.transfer_native(amount, recipient) + + assert isinstance(tx_hash, str) + assert len(tx_hash) == 64 # Typical length of a transaction hash + From 2517fe37cbf3002ed99cc434060297424e1483c6 Mon Sep 17 00:00:00 2001 From: samtin0x <40127309+samtin0x@users.noreply.github.com> Date: Fri, 9 Aug 2024 15:53:56 +0100 Subject: [PATCH 2/2] add native transfer tests --- .../indexer/rest/noble_client.py | 22 +++++++++++++------ v4-client-py-v2/dydx_v4_client/network.py | 2 +- .../tests/indexer/rest/test_noble_client.py | 11 +++++----- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/v4-client-py-v2/dydx_v4_client/indexer/rest/noble_client.py b/v4-client-py-v2/dydx_v4_client/indexer/rest/noble_client.py index 7b596ca1..07f360db 100644 --- a/v4-client-py-v2/dydx_v4_client/indexer/rest/noble_client.py +++ b/v4-client-py-v2/dydx_v4_client/indexer/rest/noble_client.py @@ -8,7 +8,11 @@ from v4_proto.cosmos.bank.v1beta1 import query_pb2_grpc as bank_query_grpc from v4_proto.cosmos.base.v1beta1.coin_pb2 import Coin from v4_proto.cosmos.tx.v1beta1 import service_pb2_grpc -from v4_proto.cosmos.tx.v1beta1.service_pb2 import SimulateRequest, BroadcastTxRequest, BroadcastMode +from v4_proto.cosmos.tx.v1beta1.service_pb2 import ( + SimulateRequest, + BroadcastTxRequest, + BroadcastMode, +) from v4_proto.cosmos.tx.v1beta1.tx_pb2 import Tx, TxBody, AuthInfo, Fee from dydx_v4_client.wallet import from_mnemonic, Wallet @@ -100,7 +104,7 @@ async def simulate_transfer_native_token(self, amount: str, recipient: str) -> F msg_send = bank_tx.MsgSend( from_address=self.wallet.address, to_address=recipient, - amount=[Coin(amount=amount, denom="uusdc")] + amount=[Coin(amount=amount, denom="uusdc")], ) any_msg = Any() any_msg.Pack(msg_send) @@ -113,8 +117,10 @@ async def simulate_transfer_native_token(self, amount: str, recipient: str) -> F simulate_request = SimulateRequest(tx=tx) simulate_response = stub.Simulate(simulate_request) - return Fee(amount=simulate_response.gas_info.fee.amount, gas_limit=simulate_response.gas_info.gas_used) - + return Fee( + amount=simulate_response.gas_info.fee.amount, + gas_limit=simulate_response.gas_info.gas_used, + ) async def transfer_native(self, amount: str, recipient: str) -> str: """ @@ -137,12 +143,11 @@ async def transfer_native(self, amount: str, recipient: str) -> str: msg_send = bank_tx.MsgSend( from_address=self.wallet.address, to_address=recipient, - amount=[Coin(amount=amount, denom="uusdc")] + amount=[Coin(amount=amount, denom="uusdc")], ) any_msg = Any() any_msg.Pack(msg_send) - tx_body = TxBody(messages=[any_msg], memo=self.default_client_memo) fee = await self.simulate_transfer_native_token(amount, recipient) @@ -151,7 +156,10 @@ async def transfer_native(self, amount: str, recipient: str) -> str: signed_tx = self.wallet.sign_tx(tx) stub = service_pb2_grpc.ServiceStub(self.channel) - broadcast_request = BroadcastTxRequest(tx_bytes=signed_tx.SerializeToString(), mode=BroadcastMode.BROADCAST_MODE_BLOCK) + broadcast_request = BroadcastTxRequest( + tx_bytes=signed_tx.SerializeToString(), + mode=BroadcastMode.BROADCAST_MODE_BLOCK, + ) broadcast_response = stub.BroadcastTx(broadcast_request) return broadcast_response.tx_response.txhash diff --git a/v4-client-py-v2/dydx_v4_client/network.py b/v4-client-py-v2/dydx_v4_client/network.py index e0ae9266..8cff7dff 100644 --- a/v4-client-py-v2/dydx_v4_client/network.py +++ b/v4-client-py-v2/dydx_v4_client/network.py @@ -62,7 +62,7 @@ def make_config( ) TESTNET = make_testnet() TESTNET_FAUCET = "https://faucet.v4testnet.dydx.exchange" -TESTNET_NOBLE = "https://rpc.testnet.noble.strange.love" +TESTNET_NOBLE = "https://noble-testnet-rpc.polkachu.com" local_node = partial( diff --git a/v4-client-py-v2/tests/indexer/rest/test_noble_client.py b/v4-client-py-v2/tests/indexer/rest/test_noble_client.py index 5c2fd056..d3bea562 100644 --- a/v4-client-py-v2/tests/indexer/rest/test_noble_client.py +++ b/v4-client-py-v2/tests/indexer/rest/test_noble_client.py @@ -14,10 +14,11 @@ async def test_get_address(noble_client): assert isinstance(address, str) assert len(address) == 43 - assert address.startswith('dydx') + assert address.startswith("dydx") @pytest.mark.asyncio +@pytest.mark.skip(reason="NobleClient testnet is not available") async def test_get_account_balances(noble_client): balances = await noble_client.get_account_balances() @@ -26,12 +27,13 @@ async def test_get_account_balances(noble_client): assert all(isinstance(coin, Coin) for coin in balances) # Check if there's a balance for the native token (uusdc) - uusdc_balance = next((coin for coin in balances if coin.denom == 'uusdc'), None) + uusdc_balance = next((coin for coin in balances if coin.denom == "uusdc"), None) assert uusdc_balance is not None assert int(uusdc_balance.amount) > 0 @pytest.mark.asyncio +@pytest.mark.skip(reason="NobleClient testnet is not available") async def test_simulate_transfer_native_token(noble_client, test_address): amount = "1000000" # 1 USDC (assuming 6 decimal places) recipient = "dydx15ndn9c895f8ntck25qughtuck9spv2d9svw5qx" @@ -42,12 +44,12 @@ async def test_simulate_transfer_native_token(noble_client, test_address): assert len(fee.amount) > 0 assert fee.gas_limit > 0 - # Check if the fee is reasonable (e.g., less than 1% of the transfer amount) fee_amount = sum(int(coin.amount) for coin in fee.amount) assert fee_amount < int(amount) * 0.01 @pytest.mark.asyncio +@pytest.mark.skip(reason="NobleClient testnet is not available") async def test_transfer_native_token(noble_client): amount = "1000000" # 1 USDC (6 decimal places) recipient = "dydx15ndn9c895f8ntck25qughtuck9spv2d9svw5qx" @@ -55,5 +57,4 @@ async def test_transfer_native_token(noble_client): tx_hash = await noble_client.transfer_native(amount, recipient) assert isinstance(tx_hash, str) - assert len(tx_hash) == 64 # Typical length of a transaction hash - + assert len(tx_hash) == 64