Skip to content

Commit

Permalink
enable NobleClient native transfers
Browse files Browse the repository at this point in the history
  • Loading branch information
samtin0x committed Aug 4, 2024
1 parent e2633f0 commit fd229a0
Show file tree
Hide file tree
Showing 3 changed files with 113 additions and 207 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
240 changes: 68 additions & 172 deletions v4-client-py-v2/dydx_v4_client/indexer/rest/noble_client.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
79 changes: 44 additions & 35 deletions v4-client-py-v2/tests/indexer/rest/test_noble_client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

0 comments on commit fd229a0

Please sign in to comment.