diff --git a/.gitignore b/.gitignore index 0b73fb1..3ff5164 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ build __pycache__ *venv .devcontainer -*.egg-info \ No newline at end of file +*.egg-info +testing.py \ No newline at end of file diff --git a/docs/README.rfq.md b/docs/README.rfq.md index 0a7404a..e5de8bc 100644 --- a/docs/README.rfq.md +++ b/docs/README.rfq.md @@ -1,6 +1,6 @@ # RFQClient -A Python-based client for interacting with Bluefin RFQ (Request for Quote) protocol on the Sui blockchain. This library module allows users to create, sign, and manage quotes, as well as deposit and withdraw assets from vaults. +A Python-based client for interacting with Bluefin RFQ (Request for Quote) protocol on the Sui blockchain. This library module allows users to create and sign quotes, as well as manage on-chain RFQ vaults. ## Features @@ -8,6 +8,8 @@ A Python-based client for interacting with Bluefin RFQ (Request for Quote) proto - Create and sign quotes for token swaps. - Deposit tokens into a vault. - Withdraw tokens from a vault (vault manager only). +- Update vault manager and coin configurations. +- Query vault balances. ## Installation @@ -33,18 +35,18 @@ At the root of your working directory, create `rfq-contracts.json` and add the c ```json { - "UpgradeCap": "0x23cffdc270102d2dbb36546ef202e7be100d1a56cc0e508eef505efd240988e3", - "ProtocolConfig": "0x7b8a9994e1887c82cfd925bd511abb33f8e2cf045fc6e605c73c2e8d51e89dba", - "Package": "0x872809300103e7812e6b515d277488ad747aecc6a0f537097a96ea0865c3952a", - "AdminCap": "0xa04ab81dcd60867f785639500d22dfd3fabdbed3b6daeac7d6bb2cd0745a3c3b", - "BasePackage": "0x872809300103e7812e6b515d277488ad747aecc6a0f537097a96ea0865c3952a", + "AdminCap": "0x4e39464652a02bada3332ca5bcd03744fd8d6c1a6aee0e40bc52c1ce10e8ecce", + "ProtocolConfig": "0xc6b29a60c3924776bedc78df72c127ea52b86aeb655432979a38f13d742dedaa", + "UpgradeCap": "0xe94ed0534120596d2e44d67aa4502b1c327da0b65c2340444e4baf67558a6911", + "Package": "0xe1c74115896c6d66e7e9569f767628bf472584eea69cbc7ebe378430866b1c86", + "BasePackage": "0xf8870f988ab09be7c5820a856bd5e9da84fc7192e095a7a8829919293b00a36c", "vaults": [ - "0x40923d059eae6ccbbb91ac9442b80b9bec8262122a5756d96021e34cf33f0b1d" + "0xd03a88fea3a40facd4218cdb230ea4626e6b946ab814866cdfaf45729579ae4e", + "0x76441b56cb8e410ffa0fdde6760fca111d0b60ed2d741ee35b944a9bfbcc41d0" ] } ``` -> Note: These are different for mainnet/testnet ### Initializing the Client @@ -57,7 +59,7 @@ TEST_NETWORK = "https://fullnode.mainnet.sui.io:443" # Initialize SuiWallet using seed wallet = SuiWallet(seed=TEST_ACCT_PHRASE) -# Read the contracts config (you can also specify filepath as an argument to read_json, by default it looks for rfq-contracts.json at root of working directory ) +# Read the contracts config (you can also specify filepath as an argument to read_json(filepath), by default it looks for rfq-contracts.json at root of working directory ) contracts_config = read_json() # Initialize RFQContracts instance using the contract conffigs @@ -65,7 +67,6 @@ rfq_contracts = RFQContracts(contracts_config) # Initialize RFQClient rfq_client = RFQClient(wallet=wallet, url=TEST_NETWORK, rfq_contracts=rfq_contracts) - ``` ### Creating and Signing a Quote @@ -79,40 +80,39 @@ rfq_client = RFQClient(wallet=wallet, url=TEST_NETWORK, rfq_contracts=rfq_contra - `vault` (str): On-chain vault object ID. - `quote_id` (str): Unique quote ID. - `taker` (str): Address of the receiver. -- `token_in_amount` (int): Amount of the input token. +- `token_in_amount` (int): Amount of the input token () - `token_out_amount` (int): Amount of the output token. - `token_in_type` (str): On-chain token type of input coin. - `token_out_type` (str): On-chain token type of output coin. - `created_at_utc_ms` (int, optional): Creation UTC timestamp (default: current time). -- `expires_at_utc_ms` (int, optional): Expiry UTC timestamp (default: 10 seconds after creation timestamp). +- `expires_at_utc_ms` (int, optional): Expiry UTC timestamp (default: 30 seconds after creation timestamp). ```python - # To only create the quote, use quote = rfq_client.create_quote( vault="0x67399451f127894ee0f9ff7182cbe914008a0197a97b54e86226d1c33635c368", quote_id="quote-123", taker="0x67399451f127894ee0f9ff7182cbe914008a0197a97b54e86226d1c33635c368", - token_in_amount=1000000000, - token_out_amount=200000000, - token_in_type="0x2::sui::SUI", - token_out_type="0x2::example::TOKEN", + token_in_amount=1000000000, # (scaled to supported coin decimals, eg. 1000000000 for 1 Sui) + token_out_amount=200000000, # (scaled to supported coin decimals, eg. 1000000 for 1 USDC) + token_in_type="0000000000000000000000000000000000000000000000000000000000000002::sui::SUI", # without 0x prefix + token_out_type="dba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC", # without 0x prefix expired_at_utc_ms=1699765400, created_at_utc_ms=1698765400, ) -# To sign the created quote , use +# To sign the created quote, use quote.sign(rfq_client.wallet) -#To create quote and sign it at the same time, use +# To create quote and sign it at the same time, use quote, signature = rfq_client.create_and_sign_quote( vault="0x67399451f127894ee0f9ff7182cbe914008a0197a97b54e86226d1c33635c368", quote_id="quote-123", taker="0x67399451f127894ee0f9ff7182cbe914008a0197a97b54e86226d1c33635c368", - token_in_amount=1000000000, - token_out_amount=200000000, - token_in_type="0x2::sui::SUI", - token_out_type="0x2::example::TOKEN", + token_in_amount=1000000000, # (scaled to supported coin decimals, eg. 1000000000 for 1 Sui) + token_out_amount=200000000, # (scaled to supported coin decimals, eg. 1000000 for 1 USDC) + token_in_type="0000000000000000000000000000000000000000000000000000000000000002::sui::SUI", # without 0x prefix + token_out_type="dba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC", # without 0x prefix expired_at_utc_ms=1699765400, created_at_utc_ms=1698765400, ) @@ -127,6 +127,7 @@ quote, signature = rfq_client.create_and_sign_quote( ```python rfq_client.create_vault( manager="0x40923d059eae6ccbbb91ac9442b80b9bec8262122a5756d96021e34cf33f0b1d", + gasbudget="100000000" # Optional, defaults to 0.1 Sui ) ``` @@ -137,15 +138,15 @@ rfq_client.create_vault( - `ED25519` ```python -rfq.add_coin_suppot( +rfq_client.add_coin_support( vault="0x84511b56cb8e410ffa0fdde6760fca111d0b60ed2d741ee35b944a9bfbcc3456", coin_type="0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC", - min_amount="1000000" # Should be scaled to the appropriate decimals of the coin to be supported + min_amount="1000000", # (scaled to supported coin decimals, eg. 1000000000 for 1 Sui) + gasbudget="100000000" # Optional, defaults to 0.1 Sui ) ``` - -### Depositing Tokens into a Vault +### Depositing Tokens into a Vault (Allowed for everyone) #### Supported wallets (blake2b hashing required, since its a sui transaction): @@ -154,8 +155,9 @@ rfq.add_coin_suppot( ```python rfq_client.deposit_in_vault( vault="0x40923d059eae6ccbbb91ac9442b80b9bec8262122a5756d96021e34cf33f0b1d", - amount="200000000000", - token_type="0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI" + amount="2000000000", # (scaled to supported coin decimals, eg. 1000000000 for 1 Sui) + coin_type="0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI", + gasbudget="100000000" # Optional, defaults to 0.1 Sui ) ``` @@ -168,13 +170,64 @@ rfq_client.deposit_in_vault( ```python rfq_client.withdraw_from_vault( vault="0x40923d059eae6ccbbb91ac9442b80b9bec8262122a5756d96021e34cf33f0b1d", - amount="200000000000" - token_type="0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI" + amount="200000000000", # (scaled to supported coin decimals, eg. 1000000000 for 1 Sui) + coin_type="0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI", + gasbudget="100000000" # Optional, defaults to 0.1 Sui +) +``` + +### Updating Vault Manager (Only Vault Manager) + +#### Supported wallets (blake2b hashing required, since its a sui transaction): + +- `ED25519` + +```python +rfq_client.update_vault_manager( + vault="0x40923d059eae6ccbbb91ac9442b80b9bec8262122a5756d96021e34cf33f0b1d", + new_manager="0xnew_manager_address", + gasbudget="100000000" # Optional, defaults to 0.1 Sui +) +``` + +### Updating Minimum Deposit Amount (Only Vault Manager) + +#### Supported wallets (blake2b hashing required, since its a sui transaction): + +- `ED25519` + +```python +rfq_client.update_min_deposit_for_coin( + vault="0x40923d059eae6ccbbb91ac9442b80b9bec8262122a5756d96021e34cf33f0b1d", + coin_type="0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI", + min_amount="1000000", # (scaled to supported coin decimals, eg. 1000000000 for 1 Sui) + gasbudget="100000000" # Optional, defaults to 0.1 Sui +) +``` + +### Getting Vault Balance for specified token + +```python +balance = rfq_client.get_vault_coin_balance( + vault="0x40923d059eae6ccbbb91ac9442b80b9bec8262122a5756d96021e34cf33f0b1d", + coin_type="0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI" ) ``` ## API Reference +### Return Types + +#### TransactionResult +A structured response from the SUI chain containing: +- `effects`: Transaction effects including status, gas used, and mutated objects +- `digest`: Transaction digest +- `transaction`: Transaction details +- `object_changes`: Changes to objects +- `events`: Transaction events + +### Methods + #### `RFQClient(wallet: SuiWallet, url: str, rfq_contracts: RFQContracts)` Initializes the RFQClient. @@ -185,34 +238,130 @@ Initializes the RFQClient. - `url` (str): RPC URL of the chain node. - `rfq_contracts` (RFQContracts): Instance of RFQContracts. -#### `create_quote(...) -> Quote`
`create_and_sign_quote(...) -> Tuple[Quote, str]` +##### Returns: +- `RFQClient`: Instance of RFQClient. + +#### `create_quote(...) -> Quote` -Creates or/and signs a quote. +Creates a quote instance. ##### Parameters: - `vault` (str): On-chain vault object ID. - `quote_id` (str): Unique quote ID. - `taker` (str): Address of the receiver. -- `token_in_amount` (int): Amount of the input token. -- `token_out_amount` (int): Amount of the output token. -- `token_in_type` (str): On-chain token type of input coin. +- `token_in_amount` (int): Amount of the input token () +- `token_out_amount` (int): Amount of the output token (scaled to supported coin decimals, eg. 1000000000 for 1 Sui). +- `token_in_type` (str): On-chain token type of input coin. (scaled to supported coin decimals, eg. 1000000 for 1 USDC). - `token_out_type` (str): On-chain token type of output coin. - `created_at_utc_ms` (int, optional): Creation UTC timestamp in milliseconds (default: current time). -- `expires_at_utc_ms` (int, optional): Expiry UTC timestamp in milliseconds (default: 10 seconds after creation timestamp). +- `expires_at_utc_ms` (int, optional): Expiry UTC timestamp in milliseconds (default: 30 seconds after creation timestamp). + +##### Returns: +- `Quote`: Instance of Quote class. + +#### `create_and_sign_quote(...) -> Tuple[Quote, str]` + +Creates and signs a quote. + +##### Parameters: +Same as `create_quote` + +##### Returns: +- `Tuple[Quote, str]`: Tuple containing Quote instance and base64 encoded signature. -#### `deposit_in_vault(vault: str, amount: str, token_type: str) -> Tuple[bool, dict]` +#### `deposit_in_vault(vault: str, amount: str, coin_type: str, gasbudget: str = "100000000") -> Tuple[bool, TransactionResult]` Deposits a token amount into the vault. -#### `withdraw_from_vault(vault: str, amount: str, token_type: str) -> Tuple[bool, dict]` +##### Parameters: +- `vault` (str): On-chain vault object ID. +- `amount` (str): Amount to deposit (scaled to supported coin decimals, eg. 1000000000 for 1 Sui). +- `coin_type` (str): On-chain token type. +- `gasbudget` (str, optional): Gas budget for transaction (default: "100000000", 0.1 Sui). + +##### Returns: +- `Tuple[bool, TransactionResult]`: Success status and transaction result. + +#### `withdraw_from_vault(vault: str, amount: str, coin_type: str, gasbudget: str = "100000000") -> Tuple[bool, TransactionResult]` + +Withdraws a token amount from the vault (only vault manager). + +##### Parameters: +- `vault` (str): On-chain vault object ID. +- `amount` (str): Amount to withdraw (scaled to supported coin decimals, eg. 1000000000 for 1 Sui). +- `coin_type` (str): On-chain token type. +- `gasbudget` (str, optional): Gas budget for transaction (default: "100000000", 0.1 Sui). + +##### Returns: +- `Tuple[bool, TransactionResult]`: Success status and transaction result. + +#### `create_vault(manager: str, gasbudget: str = "100000000") -> Tuple[bool, TransactionResult]` + +Creates a new vault on bluefin RFQ protocol. + +##### Parameters: +- `manager` (str): Address of the vault manager. +- `gasbudget` (str, optional): Gas budget for transaction (default: "100000000", 0.1 Sui). + +##### Returns: +- `Tuple[bool, TransactionResult]`: Success status and transaction result. + +#### `add_coin_support(vault: str, coin_type: str, min_amount: str, gasbudget: str = "100000000") -> Tuple[bool, TransactionResult]` + +Adds support for a coin in the vault (only vault manager). + +##### Parameters: +- `vault` (str): On-chain vault object ID. +- `coin_type` (str): On-chain token type. +- `min_amount` (str): Minimum deposit amount (scaled to supported coin decimals, eg. 1000000000 for 1 Sui). +- `gasbudget` (str, optional): Gas budget for transaction (default: "100000000", 0.1 Sui). + +##### Returns: +- `Tuple[bool, TransactionResult]`: Success status and transaction result. + +#### `update_vault_manager(vault: str, new_manager: str, gasbudget: str = "100000000") -> Tuple[bool, TransactionResult]` + +Updates the vault manager (only current manager). + +##### Parameters: +- `vault` (str): On-chain vault object ID. +- `new_manager` (str): Address of the new manager. +- `gasbudget` (str, optional): Gas budget for transaction (default: "100000000", 0.1 Sui). + +##### Returns: +- `Tuple[bool, TransactionResult]`: Success status and transaction result. + +#### `update_min_deposit_for_coin(vault: str, coin_type: str, min_amount: str, gasbudget: str = "100000000") -> Tuple[bool, TransactionResult]` + +Updates minimum deposit amount for a coin (only vault manager). + +##### Parameters: +- `vault` (str): On-chain vault object ID. +- `coin_type` (str): On-chain token type. +- `min_amount` (str): New minimum amount (scaled to supported coin decimals, eg. 1000000000 for 1 Sui). +- `gasbudget` (str, optional): Gas budget for transaction (default: "100000000", 0.1 Sui). + +##### Returns: +- `Tuple[bool, TransactionResult]`: Success status and transaction result. + +#### `get_vault_coin_balance(vault: str, coin_type: str) -> str` + +Gets the balance of a specific coin in the vault. + +##### Parameters: +- `vault` (str): On-chain vault object ID. +- `coin_type` (str): On-chain token type. -Withdraws a token amount from the vault (only vault manager can withdraw). +##### Returns: +- `str`: Balance of the coin ((scaled to supported coin decimals, eg. 1000000000 for 1 Sui)). -#### `def create_vault(self, manager: str ) -> tuple[bool, dict]` +## Error Handling -Creates a new vault on bluefin rfq protocol with provided vault manager. +The client may raise the following exceptions: +- `ValueError`: When required parameters are missing or invalid +- `Exception`: When transaction execution fails or other errors occur ## Contact -For issues and inquiries, please open a GitHub issue. +For issues and inquiries, please open a GitHub issue or contact Bluefin team. diff --git a/pyproject.toml b/pyproject.toml index 1da093b..771b3af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bluefin_v2_client_sui" -version = "1.1.13" +version = "1.2.0" description = "Library to interact with Bluefin exchange protocol including its off-chain api-gateway and on-chain contracts" readme = "README.md" requires-python = ">=3.8" diff --git a/src/bluefin_rfq_client/rfq.py b/src/bluefin_rfq_client/rfq.py index 4d0faf2..a38dff8 100644 --- a/src/bluefin_rfq_client/rfq.py +++ b/src/bluefin_rfq_client/rfq.py @@ -5,17 +5,17 @@ from .contracts import RFQContracts class RFQClient: - def __init__(self, wallet: SuiWallet = None , url: str = None, rfq_contracts : RFQContracts = None): + def __init__(self, wallet: SuiWallet = None , url: str = None, rfq_contracts : RFQContracts = None): """ Initializes the RFQClient instance with provided input fields. - Parameters: - wallet (SuiWallet): instance of SuiWallet class. - url (str): RPC url of chain node (e.g https://fullnode..sui.io:443) - rfq_contracts (RFQContracts): instance of RFQContracts class. + Inputs: + wallet (SuiWallet): instance of SuiWallet class. + url (str): RPC url of chain node (e.g https://fullnode..sui.io:443) + rfq_contracts (RFQContracts): instance of RFQContracts class. - Returns: - instance of RFQClient. + Output: + instance of RFQClient. """ if wallet is None: raise ValueError( @@ -31,8 +31,11 @@ def __init__(self, wallet: SuiWallet = None , url: str = None, rfq_contracts : R self.rfq_contracts = rfq_contracts self.signer = Signer() + ########################################################### + ############## Quote Management Methods ################### + ########################################################### + @staticmethod - def create_quote( vault: str, quote_id: str, @@ -41,30 +44,30 @@ def create_quote( token_out_amount: int, token_in_type: str, token_out_type: str, - created_at_utc_ms: int = None, - expires_at_utc_ms: int = None ) -> Quote: + created_at_utc_ms: int | None = None, + expires_at_utc_ms: int | None = None ) -> Quote: """ Creates an instance of Quote with provided params. - Parameters: - vault (str): on chain vault object ID. - quote_id (int): unique quote ID assigned for on chain verification and security. - taker (str): address of the reciever account. - token_in_amount (int): amount of the input token reciever is willing to swap [scaled to default base of the coin (i.e for 1 USDC(1e6) , provide input as 1000000 )] - token_out_amount (int): amount of the output token to be paid by quote initiator [scaled to default base of the coin (i.e for 1 SUI(1e9) , provide input as 1000000000 )] - token_in_type (str): on chain token type of input coin (i.e for SUI , 0x2::sui::SUI) - token_out_type (str): on chain token type of output coin (i.e for USDC , usdc_Address::usdc::USDC) - created_at_utc_ms (int): the unix timestamp at which the quote was created in milliseconds (Defaults to current timestamp) - expires_at_utc_ms (int): the unix timestamp at which the quote is to be expired in milliseconds ( Defaults to 10 seconds of creation timestamp ) - - Returns: - instance of Quote Class and signature in hex format. + Inputs: + vault (str): on chain vault object ID. + quote_id (str): unique quote ID assigned for on chain verification and security. + taker (str): address of the reciever account. + token_in_amount (int): amount of the input token (scaled to supported coin decimals, eg. 1000000000 for 1 Sui). + token_out_amount (int): amount of the output token (scaled to supported coin decimals, eg. 1000000 for 1 USDC). + token_in_type (str): on chain token type of input coin, without 0x prefix (i.e for SUI , 0000000000000000000000000000000000000000000000000000000000000002::sui::SUI). + token_out_type (str): on chain token type of output coin, without 0x prefix (i.e for USDC , dba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC). + created_at_utc_ms (int): the unix timestamp at which the quote was created in milliseconds (Defaults to current timestamp). + expires_at_utc_ms (int): the unix timestamp at which the quote is to be expired in milliseconds (Defaults to 30 seconds after creation timestamp). + + Output: + instance of Quote Class. """ if created_at_utc_ms is None: created_at_utc_ms = int(datetime.now(timezone.utc).timestamp()) * 1000 if expires_at_utc_ms is None: - expires_at_utc_ms = created_at_utc_ms + 10000 # 10 seconds expiration + expires_at_utc_ms = created_at_utc_ms + 30000 # 30 seconds expiration return Quote( vault=vault, @@ -87,31 +90,31 @@ def create_and_sign_quote( token_out_amount: int, token_in_type: str, token_out_type: str, - created_at_utc_ms: int = None, - expires_at_utc_ms: int = None ) -> Tuple[Quote,str]: + created_at_utc_ms: int | None = None, + expires_at_utc_ms: int | None = None ) -> Tuple[Quote,str]: """ Creates an instance of Quote with provided params and signs it. - Parameters: - vault (str): on chain vault object ID. - quote_id (int): unique quote ID assigned for on chain verification and security. - taker (str): address of the reciever account. - token_in_amount (int): amount of the input token reciever is willing to swap [scaled to default base of the coin (i.e for 1 USDC(1e6) , provide input as 1000000 )] - token_out_amount (int): amount of the output token to be paid by quote initiator [scaled to default base of the coin (i.e for 1 SUI(1e9) , provide input as 1000000000 )] - token_in_type (str): on chain token type of input coin (i.e for SUI , 0x2::sui::SUI) - token_out_type (str): on chain token type of output coin (i.e for USDC , usdc_Address::usdc::USDC) - created_at_utc_ms (int): the unix timestamp at which the quote was created in milliseconds (Defaults to current timestamp) - expires_at_utc_ms (int): the unix timestamp at which the quote is to be expired in milliseconds ( Defaults to 10 seconds of creation timestamp ) - - Returns: - Tuple of Quote instance and signature. + Inputs: + vault (str): on chain vault object ID. + quote_id (str): unique quote ID assigned for on chain verification and security. + taker (str): address of the reciever account. + token_in_amount (int): amount of the input token (scaled to supported coin decimals, eg. 1000000000 for 1 Sui). + token_out_amount (int): amount of the output token (scaled to supported coin decimals, eg. 1000000 for 1 USDC). + token_in_type (str): on chain token type of input coin, without 0x prefix (i.e for SUI , 0000000000000000000000000000000000000000000000000000000000000002::sui::SUI). + token_out_type (str): on chain token type of output coin, without 0x prefix (i.e for USDC , dba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC). + created_at_utc_ms (int): the unix timestamp at which the quote was created in milliseconds (Defaults to current timestamp). + expires_at_utc_ms (int): the unix timestamp at which the quote is to be expired in milliseconds (Defaults to 30 seconds after creation timestamp). + + Output: + Tuple of Quote instance and base64 encoded signature. """ if created_at_utc_ms is None: created_at_utc_ms = int(datetime.now(timezone.utc).timestamp()) if expires_at_utc_ms is None: - expires_at_utc_ms = created_at_utc_ms + 10000 # 10 seconds expiration + expires_at_utc_ms = created_at_utc_ms + 30000 # 30 seconds expiration quote = Quote( vault=vault, @@ -126,32 +129,76 @@ def create_and_sign_quote( ) signature = quote.sign(self.wallet) - return (quote, base64.b64encode(signature).decode('utf-8')) + + ########################################################### + ############## Vault Management Methods ################## + ########################################################### + + def create_vault(self, + manager: str, + gasbudget: str | None = 100000000 + ) -> tuple[bool, TransactionResult] : + """ + Creates new vault on bluefin RFQ protocol with provided vault manager. + + Inputs: + manager (str): address of the account that needs to be manager of vault. + gasbudget (str, optional): Gas budget for transaction (default: "100000000", 0.1 Sui). + + Output: + Tuple of bool (indicating status of execution) and TransactionResult. + """ + + move_function_params = [ + self.rfq_contracts.get_protocol_config(), + manager + ] + + tx_bytes = rpc_unsafe_moveCall( + url=self.url, + params=move_function_params, + function_name='create_rfq_vault', + function_library='gateway', + userAddress=self.wallet.getUserAddress(), + packageId=self.rfq_contracts.get_package(), + gasBudget=gasbudget + ) + + signature = self.signer.sign_tx(tx_bytes, self.wallet) + res = rpc_sui_executeTransactionBlock(self.url, tx_bytes, signature) + tx_response = TransactionResult(res) + try: + success = tx_response.effects.status == "success" + return success, tx_response + except Exception as e: + return False , tx_response + def deposit_in_vault(self, vault: str, amount: str, - token_type: str - ) -> tuple[bool, dict] : + coin_type: str, + gasbudget: str | None = 100000000 + ) -> tuple[bool, TransactionResult] : """ Deposits coin amount in the vault. - Parameters: - vault (str): on chain vault object ID. - amount (str): amount of the coin that is to be deposited [scaled to default base of the coin (i.e for 1 USDC(1e6) , provide input as 1000000 )] - token_type (str): on chain token type of input coin (i.e for USDC , usdc_Address::usdc::USDC) + Inputs: + vault (str): on chain vault object ID. + amount (str): amount of the coin that is to be deposited (scaled to supported coin decimals, eg. 1000000000 for 1 Sui). + coin_type (str): on chain token type of input coin (i.e for USDC , usdc_Address::usdc::USDC). + gasbudget (str, optional): Gas budget for transaction (default: "100000000", 0.1 Sui). - Returns: - Tuple of bool (indicating status of execution) and sui chain response (dict). + Output: + Tuple of bool (indicating status of execution) and TransactionResult. """ - coin_id = get_coin_having_balance( - user_address=self.wallet.getUserAddress(), - coin_type=token_type, - balance=amount, - url=self.url, - exact_match=True) + coin_id = CoinUtils.create_coin_with_balance( + coin_type=coin_type, + balance=int(amount), + wallet=self.wallet, + url=self.url) move_function_params = [ vault, @@ -159,7 +206,7 @@ def deposit_in_vault(self, coin_id ] move_function_type_arguments = [ - token_type + coin_type ] tx_bytes = rpc_unsafe_moveCall( @@ -169,33 +216,36 @@ def deposit_in_vault(self, function_library='gateway', userAddress=self.wallet.getUserAddress(), packageId=self.rfq_contracts.get_package(), - gasBudget=100000000, + gasBudget=gasbudget, typeArguments=move_function_type_arguments ) signature = self.signer.sign_tx(tx_bytes, self.wallet) res = rpc_sui_executeTransactionBlock(self.url, tx_bytes, signature) + tx_response = TransactionResult(res) try: - success = res["result"]["effects"]["status"]["status"] == "success" - return success, res + success = tx_response.effects.status == "success" + return success, tx_response except Exception as e: - return False , res + return False , tx_response def withdraw_from_vault(self, vault: str, amount: str, - token_type: str - ) -> tuple[bool, dict] : + coin_type: str, + gasbudget: str | None = 100000000 + ) -> tuple[bool, TransactionResult] : """ - Withdraws coin amount from the vault (Note: Only vault manager can withdraw from vault) + Withdraws coin amount from the vault (Note: Only vault manager can withdraw from vault). - Parameters: - vault (str): on chain vault object ID. - amount (str): amount of the coin that is to be withdrawn [scaled to default base of the coin (i.e for 1 USDC(1e6) , provide input as 1000000 )] - token_type (str): on chain token type of the coin (i.e for USDC , usdc_Address::usdc::USDC) + Inputs: + vault (str): on chain vault object ID. + amount (str): amount of the coin that is to be withdrawn (scaled to supported coin decimals, eg. 1000000000 for 1 Sui). + coin_type (str): on chain token type of the coin (i.e for USDC , usdc_Address::usdc::USDC). + gasbudget (str, optional): Gas budget for transaction (default: "100000000", 0.1 Sui). - Returns: - Tuple of bool (indicating status of execution) and sui chain response (dict). + Output: + Tuple of bool (indicating status of execution) and TransactionResult. """ move_function_params = [ vault, @@ -203,7 +253,7 @@ def withdraw_from_vault(self, amount ] move_function_type_arguments = [ - token_type + coin_type ] tx_bytes = rpc_unsafe_moveCall( @@ -213,96 +263,126 @@ def withdraw_from_vault(self, function_library='gateway', userAddress=self.wallet.getUserAddress(), packageId=self.rfq_contracts.get_package(), - gasBudget=100000000, + gasBudget=gasbudget, typeArguments=move_function_type_arguments ) signature = self.signer.sign_tx(tx_bytes, self.wallet) res = rpc_sui_executeTransactionBlock(self.url, tx_bytes, signature) + tx_response = TransactionResult(res) try: - success = res["result"]["effects"]["status"]["status"] == "success" - return success, res + success = tx_response.effects.status == "success" + return success, tx_response except Exception as e: - return False , res - - def get_vault_coin_balance(self, + return False , tx_response + + def update_vault_manager(self, vault: str, - token_type: str - ) -> str : + new_manager: str, + gasbudget: str | None = 100000000 + ) -> tuple[bool, TransactionResult] : """ - get balance of specified token type locked in the vault + Updates the vault manager (Note: Only current manager can update vault manager). - Parameters: - vault (str): on chain vault object ID. - token_type (str): on chain token type of the coin (i.e for USDC , usdc_Address::usdc::USDC) + Inputs: + vault (str): on chain vault object ID. + new_manager (str): address of the new manager. + gasbudget (str, optional): Gas budget for transaction (default: "100000000", 0.1 Sui). - Returns: - balance(str): balance of the coin scaled by coin decimals. + Output: + Tuple of bool (indicating status of execution) and TransactionResult. """ + + move_function_params = [ + vault, + self.rfq_contracts.get_protocol_config(), + new_manager + ] + + tx_bytes = rpc_unsafe_moveCall( + url=self.url, + params=move_function_params, + function_name='set_manager', + function_library='gateway', + userAddress=self.wallet.getUserAddress(), + packageId=self.rfq_contracts.get_package(), + gasBudget=gasbudget + ) - res = rpc_sui_getDynamicFieldObject( - self.url, - vault, - strip_hex_prefix(token_type), - SUI_CUSTOM_OBJECT_TYPE) + signature = self.signer.sign_tx(tx_bytes, self.wallet) + res = rpc_sui_executeTransactionBlock(self.url, tx_bytes, signature) + tx_response = TransactionResult(res) try: - balance = res["result"]["data"]["content"]["fields"]["value"]["fields"]["swaps"] - return balance + success = tx_response.effects.status == "success" + return success, tx_response except Exception as e: - raise Exception("Could not fetch coin balance",e) - - def create_vault(self, - manager: str - ) -> tuple[bool, dict] : + return False , tx_response + + def update_min_deposit_for_coin(self, + vault: str, + coin_type: str, + min_amount: str, + gasbudget: str | None = 100000000 + ) -> tuple[bool, TransactionResult] : """ - Creates new vault on bluefin RFQ protocol with provided vault manager + Updates minimum deposit amount for a coin (Note: Only vault manager can update min deposit). - Parameters: - manager (str): address of the account that needs to be manager of vault. + Inputs: + vault (str): on chain vault object ID. + coin_type (str): on chain token type of the coin (i.e for USDC , usdc_Address::usdc::USDC). + min_amount (str): new minimum amount of the coin that can be deposited (scaled to supported coin decimals, eg. 1000000000 for 1 Sui). + gasbudget (str, optional): Gas budget for transaction (default: "100000000", 0.1 Sui). - Returns: - Tuple of bool (indicating status of execution) and sui chain response (dict). + Output: + Tuple of bool (indicating status of execution) and TransactionResult. """ move_function_params = [ + vault, self.rfq_contracts.get_protocol_config(), - manager + min_amount + ] + move_function_type_arguments = [ + coin_type ] tx_bytes = rpc_unsafe_moveCall( url=self.url, params=move_function_params, - function_name='create_rfq_vault', + function_name='update_min_deposit', function_library='gateway', userAddress=self.wallet.getUserAddress(), packageId=self.rfq_contracts.get_package(), - gasBudget=100000000, - typeArguments=[] + gasBudget=gasbudget, + typeArguments=move_function_type_arguments ) signature = self.signer.sign_tx(tx_bytes, self.wallet) res = rpc_sui_executeTransactionBlock(self.url, tx_bytes, signature) + tx_response = TransactionResult(res) try: - success = res["result"]["effects"]["status"]["status"] == "success" - return success, res + success = tx_response.effects.status == "success" + return success, tx_response except Exception as e: - return False , res - + return False , tx_response + def add_coin_support(self, vault: str, coin_type: str, min_amount: str, - ) -> tuple[bool, dict] : + gasbudget: str | None = 100000000 + ) -> tuple[bool, TransactionResult] : """ - Adds coin support to the vault + Adds support for a coin in the vault (Note: Only vault manager can add coin support). - Parameters: - vault (str): on chain vault object ID. - coin_type (str): on chain token type of the coin (i.e for USDC , usdc_Address::usdc::USDC) - min_amount (str): minimum amount of the coin that is to be supported in the vault [scaled to default base of the coin (i.e for 1 USDC(1e6) , provide input as 1000000 )] + Inputs: + vault (str): on chain vault object ID. + coin_type (str): on chain token type of the coin (i.e for USDC , usdc_Address::usdc::USDC). + min_amount (str): minimum amount of the coin that can be deposited (scaled to supported coin decimals, eg. 1000000000 for 1 Sui). + gasbudget (str, optional): Gas budget for transaction (default: "100000000", 0.1 Sui). - Returns: - Tuple of bool (indicating status of execution) and sui chain response (dict). + Output: + Tuple of bool (indicating status of execution) and TransactionResult. """ move_function_params = [ @@ -321,16 +401,43 @@ def add_coin_support(self, function_library='gateway', userAddress=self.wallet.getUserAddress(), packageId=self.rfq_contracts.get_package(), - gasBudget=100000000, + gasBudget=gasbudget, typeArguments=move_function_type_arguments ) signature = self.signer.sign_tx(tx_bytes, self.wallet) res = rpc_sui_executeTransactionBlock(self.url, tx_bytes, signature) + tx_response = TransactionResult(res) try: - success = res["result"]["effects"]["status"]["status"] == "success" - return success, res + success = tx_response.effects.status == "success" + return success, tx_response + except Exception as e: + return False , tx_response + + def get_vault_coin_balance(self, + vault: str, + coin_type: str + ) -> str : + """ + Gets the balance of a specific coin in the vault. + + Inputs: + vault (str): on chain vault object ID. + coin_type (str): on chain token type of the coin (i.e for USDC , usdc_Address::usdc::USDC). + + Output: + str: Balance of the coin (scaled to supported coin decimals, eg. 1000000000 for 1 Sui). + """ + + res = rpc_sui_getDynamicFieldObject( + self.url, + vault, + strip_hex_prefix(coin_type), + SUI_CUSTOM_OBJECT_TYPE) + try: + balance = res["result"]["data"]["content"]["fields"]["value"]["fields"]["swaps"] + return balance except Exception as e: - return False , res + raise Exception(f"Failed to get vault coin balance, Exception: {e}") \ No newline at end of file diff --git a/src/bluefin_v2_client/client.py b/src/bluefin_v2_client/client.py index abf17f2..5f2314e 100644 --- a/src/bluefin_v2_client/client.py +++ b/src/bluefin_v2_client/client.py @@ -690,8 +690,8 @@ async def get_native_chain_token_balance(self, userAddress: str = None) -> float result = rpc_call_sui_function( self.url, callArgs, method="suix_getBalance" - )["totalBalance"] - return fromSuiBase(result) + ) + return fromSuiBase(result.raw_response["totalBalance"]) except Exception as e: raise (Exception(f"Failed to get balance, error: {e}")) @@ -705,7 +705,7 @@ def get_usdc_coins(self, userAddress: str = None): callArgs.append(self.contracts.get_currency_type()) result = rpc_call_sui_function( self.url, callArgs, method="suix_getCoins") - return result + return result.raw_response except Exception as e: raise (Exception("Failed to get USDC coins, Exception: {}".format(e))) @@ -719,8 +719,8 @@ async def get_usdc_balance(self, userAddress: str = None) -> float: callArgs.append(self.contracts.get_currency_type()) result = rpc_call_sui_function( self.url, callArgs, method="suix_getBalance" - )["totalBalance"] - return fromUsdcBase(result) + ) + return fromUsdcBase(result.raw_response["totalBalance"]) except Exception as e: raise (Exception("Failed to get balance, Exception: {}".format(e))) @@ -738,10 +738,10 @@ async def get_user_position_from_chain(self, market: MARKET_SYMBOLS, userAddress result = rpc_call_sui_function( self.url, call_args, method="suix_getDynamicFieldObject" ) - if "error" in result: - if result["error"]["code"] == "dynamicFieldNotFound": + if "error" in result.raw_response: + if result.raw_response["error"]["code"] == "dynamicFieldNotFound": return "Given user have no position open" - return result["data"]["content"]["fields"]["value"]["fields"] + return result.raw_response["data"]["content"]["fields"]["value"]["fields"] except Exception as e: raise (Exception("Failed to get positions, Exception: {}".format(e))) @@ -758,7 +758,7 @@ async def get_margin_bank_balance(self, userAddress: str = None) -> float: ) result = rpc_call_sui_function( self.url, call_args, method="suix_getDynamicFieldObject" - ) + ).raw_response balance = fromSuiBase( result["data"]["content"]["fields"]["value"]["fields"]["balance"] diff --git a/src/sui_utils/__init__.py b/src/sui_utils/__init__.py index 4ce7153..49d65d6 100644 --- a/src/sui_utils/__init__.py +++ b/src/sui_utils/__init__.py @@ -3,4 +3,6 @@ from .rpc import * from .signer import * from .bcs import * -from .enumerations import * \ No newline at end of file +from .enumerations import * +from .coin_utils import * +from .sui_interfaces import * \ No newline at end of file diff --git a/src/sui_utils/coin_utils.py b/src/sui_utils/coin_utils.py new file mode 100644 index 0000000..ae9896d --- /dev/null +++ b/src/sui_utils/coin_utils.py @@ -0,0 +1,164 @@ +from .rpc import * +from .signer import Signer +from .account import SuiWallet +from .utilities import * +from .sui_interfaces import Coin +from typing import Tuple, List, Union +from decimal import Decimal + +class CoinUtils: + """ + A Class to handle coin creation, merging, and finding on the SUI chain. + """ + @staticmethod + def create_coin_with_balance(coin_type: str, balance: int, wallet: SuiWallet, url: str) -> str: + """ + Creates a new coin with the specified balance by splitting/merging coins if required. + Can take long time if the coin is not found and has to merge large number of coins. + + Input: + coin_type (str): The type of the coin. + balance (int): The balance for the new coin scaled to coin decimals supported by the coin. Eg: 1000000000 for 1 SUI. + wallet (SuiWallet): The wallet to sign the transaction. + + Output: + str: The ID of the new coin. + """ + try: + if balance == 0: + raise ValueError("Currently sdk does not support creating zero coin") + + available_coins = CoinUtils.sort_ascending(get_coins_with_type(wallet.getUserAddress(), coin_type, url)) + available_coins_balance = CoinUtils.sum_coins(available_coins) + + if balance > available_coins_balance: + raise Exception(f"User: {wallet.getUserAddress()} does not have enough coins of type: {coin_type}") + + coin, has_exact_balance = CoinUtils.find_coin_with_balance(available_coins, balance) + + # if no coin is found, merge all available coins and create a new coin + if coin is None: + coin = available_coins[0] + coin_id = coin.coin_object_id + # merge all available coins into the first coin + CoinUtils.merge_coins(available_coins[1:], wallet, url, coin_id) + # if all coins had exact balance as required, then return the first coin id after merging + if available_coins_balance == balance: + return coin_id + else: + coin_id = coin.coin_object_id + + # if the coin is found and has exact balance, return the coin id + if has_exact_balance: + return coin_id + + # if the coin has more balance, split the coin and create a new coin + split_amount = [str(balance)] + tx_result = CoinUtils.split_coin(coin_id, split_amount, wallet, url) + if tx_result.effects.status == "success": + return tx_result.effects.created[0].reference.object_id + raise Exception("Failed to create coin with balance") + except Exception as e: + raise Exception(f"Failed to create coin with balance, Exception: {e}") + + @staticmethod + def merge_coins(coins: List[Coin], wallet: SuiWallet, url: str, primary_coin_id: str|None = None) -> str: + """ + Merges provided coins into a primary coin. + Can take long time to merge large number of coins. + Recommended to combine with get_all_coins to provide a list of coins to merge. + + Input: + coins (List[Coin]): List of Coin objects to merge. + wallet (SuiWallet): The wallet to sign the transaction. + url (str): The URL of the SUI node. + primary_coin_id (str): optional ID of the primary coin to merge into. If not provided, the first coin in the list will be used as the primary coin. + + Output: + str: The ID of the primary coin. + """ + if primary_coin_id is None: + primary_coin_id = coins[0].coin_object_id + + signer = Signer() + for coin in coins: + tx_bytes = rpc_sui_createMergeCoinsTransaction(url, primary_coin_id, coin.coin_object_id, wallet.getUserAddress()) + signer.sign_and_execute_tx(tx_bytes, wallet, url) + return primary_coin_id + + @staticmethod + def split_coin(coin_id: str, amounts: List[int], wallet: SuiWallet, url: str) -> TransactionResult: + """ + splits a coin into multiple coins of the specified amounts. + + Input: + coin_id (str): The ID of the coin to split. + amounts (List[int]): The amounts of balance required for the new coins [combined must be equal or less than the balance of the provided coin and scaled to supported decimals of the coin. Eg: 1000000000 for 1 SUI] + wallet (SuiWallet): The wallet to sign the transaction. + url (str): The URL of the SUI node. + """ + signer = Signer() + tx_bytes = rpc_sui_createSplitCoinsTransaction(wallet.getUserAddress(), coin_id, [str(amount) for amount in amounts], url) + return signer.sign_and_execute_tx(tx_bytes, wallet, url) + + + @staticmethod + def find_coin_with_balance(coins: List[Coin], amount: int) -> Tuple[Coin | None, bool]: + """ + Finds the coin having the provided balance or more. + Recommended to combine with get_all_coins to provide a list of coins to find the coin with the balance. + + Input: + coins (List[Coin]): List of Coin objects. + amount (int): The amount of balance the coin must have scaled to supported decimals of the coin. Eg: 1000000000 for 1 SUI. + + Output: + Tuple[Coin | None, bool]: The Coin object and a boolean indicating if the coin has exact balance or more. + """ + for coin in coins: + coin_balance = int(coin.balance) + if coin_balance >= amount: + return coin, coin_balance == amount + return None, False + + @staticmethod + def sort_ascending(coins: List[Coin]) -> List[Coin]: + """ + Sorts the list of coins in ascending order based on their balance. + Recommended to combine with get_all_coins to provide a list of coins to sort. + Input: + coins (List[Coin]): List of Coin objects. + + Returns: + List[Coin]: Sorted list of Coin objects. + """ + return sorted(coins, key=lambda coin: int(coin.balance)) + + @staticmethod + def sum_coins(coins: List[Coin]) -> int: + """ + Sums up the balance of all coins. + Recommended to combine with get_all_coins to provide a list of coins to sum. + + Input: + coins (List[Coin]): List of Coin objects. + + Returns: + int: The total balance of all coins. + """ + return sum(int(coin.balance) for coin in coins) + + @staticmethod + def get_all_coins(address: str, coin_type: str, url: str) -> List[Coin]: + """ + Gets all coin objects for an address. + Input: + address (str): The address of the user. + coin_type (str): The type of the coin. + url (str): The URL of the SUI node. + Output: + List[Coin]: A list of Coin objects. + """ + return get_coins_with_type(address, coin_type, url) + + diff --git a/src/sui_utils/rpc.py b/src/sui_utils/rpc.py index 0c42acd..f3f23cb 100644 --- a/src/sui_utils/rpc.py +++ b/src/sui_utils/rpc.py @@ -1,11 +1,29 @@ import requests import json import time +from .sui_interfaces import * LOCKED_OBJECT_ERROR_CODE = ( "Failed to sign transaction by a quorum of validators because of locked objects" ) +def rpc_sui_getTransactionBytes(url: str, json_rpc_payload: str) -> str: + """ + gets transaction bytes with the given json rpc payload to get txBytes. + + Inputs: + url (str): URL of the node. + payload (str): JSON payload to send in the request. + + Output: + str: The txBytes. + """ + headers = {"Content-Type": "application/json"} + response = requests.request("POST", url, headers=headers, data=json_rpc_payload) + responseJson = json.loads(response.text) + if "result" not in responseJson or "txBytes" not in responseJson["result"]: + raise Exception(f"Failed to create transaction bytes due to: {responseJson}") + return responseJson["result"]["txBytes"] def rpc_unsafe_moveCall( url: str, @@ -19,7 +37,7 @@ def rpc_unsafe_moveCall( ): """ Does the RPC call to SUI chain - Input: + Inputs: url: url of the node params: a list of arguments to be passed to function function_name: name of the function to call on sui @@ -51,18 +69,12 @@ def rpc_unsafe_moveCall( payload = json.dumps(base_dict) - headers = {"Content-Type": "application/json"} - response = requests.request("POST", url, headers=headers, data=payload) - responseJson = json.loads(response.text) - if "result" not in responseJson or "txBytes" not in responseJson["result"] : - raise (Exception(f"Failed to create transaction bytes due to: {responseJson}")) - return responseJson["result"]["txBytes"] + return rpc_sui_getTransactionBytes(url, payload) - -def rpc_sui_executeTransactionBlock(url, txBytes, signature, maxRetries=5): +def rpc_sui_executeTransactionBlock(url: str, txBytes: str, signature: str , maxRetries=5) -> any: """ Execute the SUI call on sui chain - Input: + Inputs: url: url of the node txBytes: the call in serialised form signature: txBytes signed by signer @@ -107,7 +119,7 @@ def rpc_sui_executeTransactionBlock(url, txBytes, signature, maxRetries=5): def rpc_sui_getDynamicFieldObject(url:str, parentObjectId: str, fieldName: str,fieldSuiObjectType:str, maxRetries=5): """ Fetches the on-chain dynamic field object corresponding to specified input params - Input: + Inputs: url: url of the sui chain node parentObjectId: id of the parent object for which dynamic field needs to be queried fieldName: name of the dynamic field @@ -142,13 +154,16 @@ def rpc_sui_getDynamicFieldObject(url:str, parentObjectId: str, fieldName: str,f time.sleep(1) return result -def rpc_call_sui_function(url, params, method="suix_getCoins"): +def rpc_call_sui_function(url: str, params: list[Any], method: str = "suix_getCoins") -> SuiGetResponse: """ - for calling sui functions: - Input: + for calling sui chain functions: + Inputs: url: url of node params: arguments of function method(optional): the name of method in sui we want to call. defaults to suix_getCoins + + Output: + SuiGetResponse: The response containing data. """ base_dict = {} base_dict["jsonrpc"] = "2.0" @@ -160,46 +175,170 @@ def rpc_call_sui_function(url, params, method="suix_getCoins"): headers = {"Content-Type": "application/json"} response = requests.request("POST", url, headers=headers, data=payload) result = json.loads(response.text) - return result["result"]["data"] - -def get_coins(user_address: str = None, coin_type: str = "0x::sui::SUI", url: str = None): - """ - Returns the list of the coins of type tokenType owned by user - """ - try: - callArgs = [] - callArgs.append(user_address) - callArgs.append(coin_type) - result = rpc_call_sui_function( - url, callArgs, method="suix_getCoins") - return result - except Exception as e: - raise (Exception("Failed to get coins, Exception: {}".format(e))) - -async def get_coin_balance(user_address: str = None, coin_type: str = "0x::sui::SUI", url: str = None) -> str: - """ - Returns user's token balance. - """ - try: - callArgs = [] - callArgs.append(user_address) - callArgs.append(coin_type) - result = rpc_call_sui_function( - url, callArgs, method="suix_getBalance" - )["totalBalance"] - return result - except Exception as e: - raise (Exception("Failed to get coin balance, Exception: {}".format(e))) + return SuiGetResponse(result["result"]) + +def rpc_sui_createSplitCoinsTransaction(owner: str, primary_coin_id: str = None, split_amounts: list[str] = [], url: str = None, gas_budget: int = 100000000): + """ + Creates a transaction to split a coin into smaller amounts as specified in split_amounts. + + Inputs: + owner: Address of the owner. + primary_coin_id: ID of the primary coin to split. + split_amounts: List of amounts to split into (as big number strings scaled in coin decimals supported by the coin). Eg: 1000000000 for 1 SUI. + url: URL of the node. + gas_budget: Gas budget for the transaction (default: 100000000). + + Output: + transaction bytes of the split operation. + """ + try: + # Check if split_amounts contains only number strings + for amount in split_amounts: + if not amount.isdigit(): + raise ValueError(f"Invalid amount in split_amounts: {amount}") + + base_dict = { + "jsonrpc": "2.0", + "method": "unsafe_splitCoin", + "id": 1, + "params": [ + owner, + primary_coin_id, + split_amounts, + None, # gas object ID, let node pick one + str(gas_budget) + ] + } + + payload = json.dumps(base_dict) + + tx_bytes = rpc_sui_getTransactionBytes(url, payload) + return tx_bytes + except Exception as e: + raise Exception(f"Failed to split coins, Exception: {e}") + +def rpc_sui_createMergeCoinsTransaction(url: str, primary_coin_id: str, coin_id: str, userAddress: str, gasBudget: int = 100000000) -> str: + """ + Creates a transaction to merge a coin into a primary coin. + + Inputs: + url (str): URL of the node. + primary_coin_id (str): The ID of the primary coin to merge into. + coin_id (str): The ID of the coin to merge. + userAddress (str): Address of the user. + gasBudget (int): Gas budget for the transaction. + + Output: + str: The transaction bytes. + """ + try: + base_dict = { + "jsonrpc": "2.0", + "method": "unsafe_mergeCoins", + "id": 1, + "params": [userAddress, primary_coin_id, coin_id, None, str(gasBudget)] + } + + payload = json.dumps(base_dict) + return rpc_sui_getTransactionBytes(url, payload) + except Exception as e: + raise Exception(f"Failed to merge coins, Exception: {e}") + +def get_coin_balance(user_address: str = None, coin_type: str = "0x::sui::SUI", url: str = None) -> str: + """ + Gets the balance of the specified coin type for the user. + Input: + user_address (str): The address of the user. + coin_type (str): The type of the coin. + url (str): The URL of the SUI node. + Output: + str: The balance of the coin scaled to coin decimals supported by the coin. Eg: 1000000000 for 1 SUI. + """ + try: + callArgs = [] + callArgs.append(user_address) + callArgs.append(coin_type) + result = rpc_call_sui_function( + url, callArgs, method="suix_getBalance" + ) + return result.raw["totalBalance"] + except Exception as e: + raise (Exception("Failed to get coin balance, Exception: {}".format(e))) def get_coin_having_balance(user_address: str = None, coin_type: str = "0x::sui::SUI", balance: str = None , url: str = None, exact_match: bool = False) -> str: - coin_list = get_coins(user_address, coin_type, url) - for coin in coin_list: - if exact_match: - if int(coin["balance"]) == int(balance): - return coin["coinObjectId"] - elif int(coin["balance"]) >= balance: - return coin["coinObjectId"] - raise Exception( - "Not enough balance available in single coin, please merge your coins" - ) \ No newline at end of file + """ + Gets the coin having the specified balance. + Input: + user_address (str): The address of the user. + coin_type (str): The type of the coin. + balance (str): The balance of the coin scaled to coin decimals supported by the coin. Eg: 1000000000 for 1 SUI. + url (str): The URL of the SUI node. + exact_match (bool): Whether to find an exact match or a coin with balance greater than or equal to the specified balance. + Output: + str: The ID of the coin. + """ + coin_list = get_coins_with_type(user_address, coin_type, url) + for coin in coin_list: + if exact_match: + if int(coin.balance) == int(balance): + return coin.coin_object_id + elif int(coin.balance) >= int(balance): + return coin.coin_object_id + raise Exception( + "Not enough balance available in single coin, please merge your coins" + ) + +def get_coin_metadata(url: str, coin_type: str) -> CoinMetadata: + """ + Fetches the metadata for the specified coin type. + + Input: + url (str): URL of the node. + coin_type (str): The coin type to fetch metadata for. + + Output: + CoinMetadata: The metadata of the coin. + """ + base_dict = { + "jsonrpc": "2.0", + "id": 1, + "method": "suix_getCoinMetadata", + "params": [coin_type] + } + + payload = json.dumps(base_dict) + headers = {"Content-Type": "application/json"} + response = requests.request("POST", url, headers=headers, data=payload) + responseJson = json.loads(response.text) + if "result" not in responseJson: + raise Exception(f"Failed to fetch coin metadata due to: {responseJson}") + return CoinMetadata(responseJson["result"]) + +def get_coins_with_type(user_address: str = None, coin_type: str = "0x::sui::SUI", url: str = None) -> list[Coin]: + """ + Returns the list of the coins of type tokenType owned by user. + + Input: + user_address (str): The address of the user. + coin_type (str): The coin type to fetch. + url (str): The URL of the node. + + Output: + list[Coin]: A list of Coin objects. + """ + try: + coins = [] + cursor = None + while True: + callArgs = [user_address, coin_type] + if cursor: + callArgs.append(cursor) + response = rpc_call_sui_function(url, callArgs, method="suix_getCoins") + coins.extend([Coin(element) for element in response.data]) + if not response.has_next_page: + break + cursor = response.next_cursor + return coins + except Exception as e: + raise Exception(f"Failed to get coins, Exception: {e}") \ No newline at end of file diff --git a/src/sui_utils/signer.py b/src/sui_utils/signer.py index 9f0b21b..b954e6b 100644 --- a/src/sui_utils/signer.py +++ b/src/sui_utils/signer.py @@ -3,23 +3,47 @@ import json import base64 from nacl.signing import * +from .sui_interfaces import TransactionResult from .enumerations import WALLET_SCHEME from .account import SuiWallet from .utilities import * from .bcs import * +from .rpc import rpc_sui_executeTransactionBlock class Signer: - def __init__(self): - pass - - def sign_tx(self, tx_bytes_str: str, sui_wallet: SuiWallet) -> str: + """ + A class to sign transactions and execute them on the SUI chain. + """ + def __init__(self, sui_wallet: SuiWallet = None): + """ + Initializes the Signer class. + Input: + sui_wallet: optional SuiWallet object. """ - expects the msg in str - expects the suiwallet object - Signs the msg and returns the signature. - Returns the value in b64 encoded format + self.sui_wallet = sui_wallet + + def sign_tx(self, tx_bytes_str: str, sui_wallet: SuiWallet = None) -> str: """ + Signs the transaction and returns the signature. + Input: + tx_bytes_str: The transaction bytes in base64 encoded string format. + sui_wallet: optional SuiWallet object. + Output: + Returns the signature in base64 encoded format. + """ + + # pick sui wallet from fucntion parameter or from the class instance + preferred_sui_wallet = sui_wallet + + # if no sui wallet is provided, use the one from the class instance + if preferred_sui_wallet is None: + preferred_sui_wallet = self.sui_wallet + + # if no sui wallet is provided, nor from the class instance, raise an error + if preferred_sui_wallet is None: + raise ValueError("SuiWallet is not provided") + tx_bytes = base64.b64decode(tx_bytes_str) intent = bytearray() @@ -34,15 +58,62 @@ def sign_tx(self, tx_bytes_str: str, sui_wallet: SuiWallet) -> str: temp.extend(sui_wallet.publicKeyBytes) res = base64.b64encode(temp) return res.decode() + + + def sign_and_execute_tx(self, tx_bytes: str, sui_wallet: SuiWallet = None, url: str = None) -> TransactionResult: + """ + Signs the transaction and executes it on the SUI chain. + + Input: + tx_bytes (str): The transaction bytes in string format. + sui_wallet (SuiWallet): The SuiWallet object. + url (str): The URL of the node. + + Output: + dict: The result of the transaction execution. + """ + + # pick sui wallet from fucntion parameter or from the class instance + preferred_sui_wallet = sui_wallet + + # if no sui wallet is provided, use the one from the class instance + if preferred_sui_wallet is None: + preferred_sui_wallet = self.sui_wallet + + # if no sui wallet is provided, nor from the class instance, raise an error + if preferred_sui_wallet is None: + raise ValueError("SuiWallet is not provided") + + try: + signature = self.sign_tx(tx_bytes, preferred_sui_wallet) + tx = rpc_sui_executeTransactionBlock(url, tx_bytes, signature) + return TransactionResult(tx) + except Exception as e: + raise Exception(f"Failed to sign and execute transaction, Exception: {e}") + def sign_hash(self, hash, private_key, append=""): """ Signs the hash and returns the signature. + Input: + hash: The hash to sign. + private_key: The private key to sign the hash. + append: optional string to append to the signature. + Output: + Returns the signature of the hash in bytes format. """ result = nacl.signing.SigningKey(private_key).sign(hash)[:64] return result.hex() + "1" + append - def encode_message(self, msg: dict): + + def encode_message(self, msg: dict) -> bytes: + """ + Encodes the message and returns the hash. + Input: + msg: The message to encode. + Output: + Returns the hash of the message in bytes format. + """ msg = json.dumps(msg, separators=(",", ":")) msg_bytearray = bytearray(msg.encode("utf-8")) intent = bytearray() @@ -53,7 +124,26 @@ def encode_message(self, msg: dict): hash = hashlib.blake2b(intent, digest_size=32) return hash.digest() - def sign_personal_msg(self, serialized_bytes: bytearray, wallet : SuiWallet ): + def sign_personal_msg(self, serialized_bytes: bytearray, wallet: SuiWallet = None) -> bytes: + """ + Signs the personal message and returns the signature. + Input: + serialized_bytes: The serialized bytes of the message. + wallet: The wallet to sign the message. + Output: + Returns the signature of the message in bytes format. + """ + # pick wallet from fucntion parameter or from the class instance + preferred_wallet = wallet + + # if no wallet is provided, use the one from the class instance + if preferred_wallet is None: + preferred_wallet = self.sui_wallet + + # if no wallet is provided, nor from the class instance, raise an error + if preferred_wallet is None: + raise ValueError("SuiWallet is not provided") + serializer = BCSSerializer() # this function adds len as an Unsigned Little Endian Base 128 similar to mysten SDK serializer.serialize_uint8_array(list(serialized_bytes)) @@ -61,7 +151,7 @@ def sign_personal_msg(self, serialized_bytes: bytearray, wallet : SuiWallet ): # Add personal message intent bytes intent = bytearray() - intent.extend([ 3, 0, 0]) # Intent scope for personal message + intent.extend([3, 0, 0]) # Intent scope for personal message # Combine the intent and msg_bytes intent = intent + serialized_bytes @@ -72,32 +162,46 @@ def sign_personal_msg(self, serialized_bytes: bytearray, wallet : SuiWallet ): # Sign the hash signature = nacl.signing.SigningKey(wallet.privateKeyBytes).sign(blake2bHash)[:64] - serializer = BCSSerializer() serializer.serialize_u8(WALLET_SCHEME[wallet.getKeyScheme()]) # Construct Signature in accurate format (scheme + signature + publicKey) - return serializer.get_bytes()+ signature + wallet.publicKeyBytes + return serializer.get_bytes() + signature + wallet.publicKeyBytes - def sign_bytes(self, bytes: bytearray, private_key: bytes) -> bytes: + def sign_bytes(self, bytes: bytearray, private_key: bytes = None) -> bytes: """ - Signs the bytes and returns the signature bytes. + Signs the provided bytes and returns the signature. + Input: + bytes: The bytes to sign. + private_key: The private key to sign the bytes. + Output: + Returns the signature of the bytes in bytes format. """ - result = nacl.signing.SigningKey(private_key).sign(bytes)[:64] + # if no private key is provided, use the one from the class instance + preferred_private_key = private_key + + # if no private key is provided, use the one from the class instance + if preferred_private_key is None: + preferred_private_key = self.sui_wallet.privateKeyBytes + + # if no private key is provided, nor from the class instance, raise an error + if preferred_private_key is None: + raise ValueError("Private key is not provided") + + result = nacl.signing.SigningKey(preferred_private_key).sign(bytes)[:64] return result def verify_signature(self, message: bytes, signature: bytes, public_key: bytes, scheme: str) -> bool: """ Verifies the signature using the specified scheme. - - Parameters: - message (bytes): The message to verify. - signature (bytes): The signature to verify. - public_key (bytes): The public key to use for verification. - scheme (str): The signature scheme. - - Returns: - bool: True if the signature is valid, False otherwise. + Input: + message (bytes): The message to verify. + signature (bytes): The signature to verify. + public_key (bytes): The public key to use for verification. + scheme (str): The signature scheme. + + Output: + Returns True if the signature is valid, False otherwise. """ if scheme == "ED25519": verify_key = VerifyKey(public_key) @@ -114,11 +218,11 @@ def parse_serialized_signature(self, signature: bytes) -> dict: """ Parses the serialized signature to extract the scheme, signature, and public key. - Parameters: - signature (bytes): The serialized signature. + Input: + signature (bytes): The serialized signature. - Returns: - dict: A dictionary containing the signature scheme, signature, and public key. + Output: + dict: A dictionary containing the signature scheme, signature, and public key. """ scheme = signature[0] signature_bytes = signature[1:65] @@ -135,7 +239,6 @@ def parse_serialized_signature(self, signature: bytes) -> dict: "publicKey": public_key } - - \ No newline at end of file + diff --git a/src/sui_utils/sui_interfaces.py b/src/sui_utils/sui_interfaces.py new file mode 100644 index 0000000..615b401 --- /dev/null +++ b/src/sui_utils/sui_interfaces.py @@ -0,0 +1,92 @@ +from typing import Any + + +class GasUsed: + def __init__(self, gas_used: dict): + self.computation_cost = gas_used.get("computationCost") + self.storage_cost = gas_used.get("storageCost") + self.storage_rebate = gas_used.get("storageRebate") + self.non_refundable_storage_fee = gas_used.get("nonRefundableStorageFee") + +class Owner: + def __init__(self, owner: dict): + self.address_owner = owner.get("AddressOwner") + self.object_owner = owner.get("ObjectOwner") + +class Reference: + def __init__(self, reference: dict): + self.object_id = reference.get("objectId") + self.version = reference.get("version") + self.digest = reference.get("digest") + +class MutatedObject: + def __init__(self, mutated: dict): + self.owner = Owner(mutated.get("owner")) + self.reference = Reference(mutated.get("reference")) + +class GasObject: + def __init__(self, gas_object: dict): + self.owner = Owner(gas_object.get("owner")) + self.reference = Reference(gas_object.get("reference")) + +class Effects: + def __init__(self, effects: dict): + self.message_version = effects.get("messageVersion") + self.status = effects.get("status").get("status") + self.executed_epoch = effects.get("executedEpoch") + self.gas_used = GasUsed(effects.get("gasUsed")) + self.transaction_digest = effects.get("transactionDigest") + self.mutated = [MutatedObject(m) for m in effects.get("mutated", [])] + self.created = [MutatedObject(m) for m in effects.get("created", [])] + self.gas_object = GasObject(effects.get("gasObject")) + self.events_digest = effects.get("eventsDigest") + +class TransactionResult: + def __init__(self, result: dict): + self.full_transaction_data = result.get("result") + self.digest = self.full_transaction_data.get("digest") + self.transaction = self.full_transaction_data.get("transaction") + self.effects = Effects(self.full_transaction_data.get("effects")) + self.object_changes = self.full_transaction_data.get("objectChanges") + self.events = self.full_transaction_data.get("events") + + + +class CoinMetadata: + def __init__(self, metadata: dict): + self.decimals = metadata.get("decimals") + self.name = metadata.get("name") + self.symbol = metadata.get("symbol") + self.description = metadata.get("description") + self.icon_url = metadata.get("iconUrl") + self.id = metadata.get("id") + + +class Coin: + def __init__(self, coin_data: dict): + self.coin_type : str = coin_data.get("coinType") + self.coin_object_id : str= coin_data.get("coinObjectId") + self.version : str = coin_data.get("version") + self.digest: str = coin_data.get("digest") + self.balance : str = coin_data.get("balance") + self.previous_transaction : str = coin_data.get("previousTransaction") + + def __repr__(self): + return f"Coin(coin_type={self.coin_type}, coin_object_id={self.coin_object_id}, balance={self.balance})" + +class NextCursor: + def __init__(self, cursor: dict): + self.tx_digest = cursor.get("txDigest", "") + self.event_seq = cursor.get("eventSeq", "") + + +class SuiGetResponse: + def __init__(self, response: dict): + self.raw_response : dict = response + self.data : list[Any] = response.get("data", []) + next_cursor = response.get("nextCursor", "") + if isinstance(next_cursor, dict): + self.next_cursor : NextCursor = NextCursor(next_cursor) + else: + self.next_cursor: str = next_cursor + self.has_next_page: bool = response.get("hasNextPage",False) diff --git a/src/sui_utils/utilities.py b/src/sui_utils/utilities.py index 482388a..24c19ae 100644 --- a/src/sui_utils/utilities.py +++ b/src/sui_utils/utilities.py @@ -18,6 +18,7 @@ BASE_1E9 = 1000000000 SUI_STRING_OBJECT_TYPE = "0x1::string::String" SUI_CUSTOM_OBJECT_TYPE = "0x1::type_name::TypeName" +SUI_NATIVE_PACKAGE_ID = "0x2" def getsha256Hash(callArgs: list) -> str: @@ -199,7 +200,14 @@ def decimal_to_bcs(num): return bcs_bytes -def read_json(file_path: str = None): +def read_json(file_path: str | None = None) -> dict: + """ + Reads a JSON file and returns the data as a dictionary. + Input: + file_path: optional path to the JSON file, defaults to './rfq-contracts.json' + Output: + Returns the data as a dictionary. + """ try: if file_path is None: file_path = './rfq-contracts.json'