diff --git a/lyra/constants.py b/lyra/constants.py new file mode 100644 index 0000000..e69de29 diff --git a/lyra/main.py b/lyra/main.py index 37e5a17..f276a1d 100644 --- a/lyra/main.py +++ b/lyra/main.py @@ -18,6 +18,14 @@ PUBLIC_HEADERS = {"accept": "application/json", "content-type": "application/json"} +ACTION_TYPEHASH = '0x4d7a9f27c403ff9c0f19bce61d76d82f9aa29f8d6d4b0c5474607d9770d1af17' +DOMAIN_SEPARATOR = '0xff2ba7c8d1c63329d3c2c6c9c19113440c004c51fe6413f65654962afaff00f3' +ASSET_ADDRESS = '0x8932cc48F7AD0c6c7974606cFD7bCeE2F543a124' +TRADE_MODULE_ADDRESS = '0x63Bc9D10f088eddc39A6c40Ff81E99516dfD5269' + +OPTION_NAME = 'ETH-20231027-1500-P' +OPTION_SUB_ID = '644245094401698393600' + class InstrumentType(Enum): ERC20 = "erc20" @@ -46,13 +54,7 @@ class TimeInForce(Enum): FOK = "fok" -ACTION_TYPEHASH = '0x4d7a9f27c403ff9c0f19bce61d76d82f9aa29f8d6d4b0c5474607d9770d1af17' -DOMAIN_SEPARATOR = '0xff2ba7c8d1c63329d3c2c6c9c19113440c004c51fe6413f65654962afaff00f3' -ASSET_ADDRESS = '0x8932cc48F7AD0c6c7974606cFD7bCeE2F543a124' -TRADE_MODULE_ADDRESS = '0x63Bc9D10f088eddc39A6c40Ff81E99516dfD5269' -OPTION_NAME = 'ETH-20231027-1500-P' -OPTION_SUB_ID = '644245094401698393600' w3 = Web3() @@ -197,6 +199,39 @@ def __encode_order(self, order): return w3.keccak(encoded) + # private apis + def create_subaccount( + amount, + asset_name, + currency="USDT", + margin_type="SM", + ): + """ + Create a subaccount + """ + endpoint = "create_subaccount" + url = f"{BASE_URL}/private/{endpoint}" + + payload = { + "amount": "", + "asset_name": "string", + "currency": "string", + "margin_type": "PM", + "nonce": 0, + "signature": "string", + "signature_expiry_sec": 0, + "signer": "string", + "wallet": "string" + } + headers = { + "accept": "application/json", + "content-type": "application/json" + } + + response = requests.post(url, json=payload, headers=headers) + + print(response.text) + def main(): """Execute the main function.""" diff --git a/node_demo/sample.js b/node_demo/sample.js new file mode 100644 index 0000000..00ac6c9 --- /dev/null +++ b/node_demo/sample.js @@ -0,0 +1,158 @@ +// This is a sample script that will submit an order to the Lyra testnet + +// To run this script, you must have a Lyra testnet account with some ETH and LYRA in it + + +const { ethers } = require("ethers"); +const { WebSocket } = require('ws'); +const dotenv = require('dotenv'); + +dotenv.config(); + +const PRIVATE_KEY = process.env.OWNER_PRIVATE_KEY +const PROVIDER_URL = 'https://l2-prod-testnet-0eakp60405.t.conduit.xyz'; +const WS_ADDRESS = process.env.WEBSOCKET_ADDRESS || 'ws://localhost:3000/ws'; +const ACTION_TYPEHASH = '0x4d7a9f27c403ff9c0f19bce61d76d82f9aa29f8d6d4b0c5474607d9770d1af17'; +const DOMAIN_SEPARATOR = '0xff2ba7c8d1c63329d3c2c6c9c19113440c004c51fe6413f65654962afaff00f3'; +// const ASSET_ADDRESS = '0x8932cc48F7AD0c6c7974606cFD7bCeE2F543a124'; +const ASSET_ADDRESS = '0x62CF2Cc6450Dc3FbD0662Bfd69af0a4D7485Fe4E'; +const TRADE_MODULE_ADDRESS = '0x63Bc9D10f088eddc39A6c40Ff81E99516dfD5269'; + +const PROVIDER = new ethers.JsonRpcProvider(PROVIDER_URL); +const wallet = new ethers.Wallet(PRIVATE_KEY, PROVIDER); +const encoder = ethers.AbiCoder.defaultAbiCoder(); +const subaccount_id = 550 + +const OPTION_NAME = 'ETH-PERP' +const OPTION_SUB_ID = '0' // can retreive with public/get_instrument + + +async function signAuthenticationHeader() { + const timestamp = Date.now().toString(); + const signature = await wallet.signMessage(timestamp); + return { + wallet: wallet.address, + timestamp: timestamp, + signature: signature, + }; +} + +const connectWs = async () => { + return new Promise((resolve, reject) => { + const ws = new WebSocket(WS_ADDRESS); + + ws.on('open', () => { + setTimeout(() => resolve(ws), 50); + }); + + ws.on('error', reject); + + ws.on('close', (code, reason) => { + if (code && reason.toString()) { + console.log(`WebSocket closed with code: ${code}`, `Reason: ${reason}`); + } + }); + }); +}; + +async function loginClient(wsc ) { + const login_request = JSON.stringify({ + method: 'public/login', + params: await signAuthenticationHeader(), + id: Math.floor(Math.random() * 10000) + }); + wsc.send(login_request); + console.log('Sent login request') + console.log(login_request) + await new Promise(resolve => setTimeout(resolve, 2000)); +} + +function defineOrder(){ + return { + instrument_name: OPTION_NAME, + subaccount_id: subaccount_id, + direction: "buy", + limit_price: 1310, + amount: 100, + signature_expiry_sec: Math.floor(Date.now() / 1000 + 300), + max_fee: "0.01", + nonce: Number(`${Date.now()}${Math.round(Math.random() * 999)}`), // LYRA nonce format: ${CURRENT UTC MS +/- 1 day}${RANDOM 3 DIGIT NUMBER} + signer: wallet.address, + order_type: "limit", + mmp: false, + signature: "filled_in_below" + }; +} + +function encodeTradeData(order){ + let encoded_data = encoder.encode( // same as "encoded_data" in public/order_debug + ['address', 'uint', 'int', 'int', 'uint', 'uint', 'bool'], + [ + ASSET_ADDRESS, + OPTION_SUB_ID, + ethers.parseUnits(order.limit_price.toString(), 18), + ethers.parseUnits(order.amount.toString(), 18), + ethers.parseUnits(order.max_fee.toString(), 18), + order.subaccount_id, order.direction === 'buy'] + ); + return ethers.keccak256(Buffer.from(encoded_data.slice(2), 'hex')) // same as "encoded_data_hashed" in public/order_debug +} + +async function signOrder(order) { + const tradeModuleData = encodeTradeData(order) + + const action_hash = ethers.keccak256( + encoder.encode( + ['bytes32', 'uint256', 'uint256', 'address', 'bytes32', 'uint256', 'address', 'address'], + [ + ACTION_TYPEHASH, + order.subaccount_id, + order.nonce, + TRADE_MODULE_ADDRESS, + tradeModuleData, + order.signature_expiry_sec, + wallet.address, + order.signer + ] + ) + ); // same as "action_hash" in public/order_debug + + order.signature = wallet.signingKey.sign( + ethers.keccak256(Buffer.concat([ + Buffer.from("1901", "hex"), + Buffer.from(DOMAIN_SEPARATOR.slice(2), "hex"), + Buffer.from(action_hash.slice(2), "hex") + ])) // same as "typed_data_hash" in public/order_debug + ).serialized; +} + +async function submitOrder(order, ws) { + return new Promise((resolve, reject) => { + const id = Math.floor(Math.random() * 1000); + ws.send(JSON.stringify({ + method: 'private/order', + params: order, + id: id + })); + + ws.on('message', (message) => { + const msg = JSON.parse(message); + if (msg.id === id) { + console.log('Got order response:', msg); + resolve(msg); + } + }); + }); +} + +async function completeOrder() { + const ws = await connectWs(); + await loginClient(ws); + const order = defineOrder(); + await signOrder(order); + console.log('Submitting order:', order); + await submitOrder(order, ws); +} + +completeOrder(); + diff --git a/node_demo/subaccount.js b/node_demo/subaccount.js new file mode 100644 index 0000000..d2414fd --- /dev/null +++ b/node_demo/subaccount.js @@ -0,0 +1,130 @@ +const {ethers, Contract} = require("ethers"); + +const axios = require('axios'); +const dotenv = require('dotenv'); + + +function getUTCEpochSec() { + return Math.floor(Date.now() / 1000); +} + +dotenv.config(); + +// Environment variables, double check these in the docs constants section +const PRIVATE_KEY = process.env.OWNER_PRIVATE_KEY +const PROVIDER_URL = 'https://l2-prod-testnet-0eakp60405.t.conduit.xyz' +const USDC_ADDRESS = '0xe80F2a02398BBf1ab2C9cc52caD1978159c215BD' +const DEPOSIT_MODULE_ADDRESS = '0xB430F3AE49f9d7a9B93fCCb558424972c385Cc38' +const CASH_ADDRESS = '0xb8a082B53BdCBFB7c44C8Baf2F924096711EADcA' +const ACTION_TYPEHASH = '0x4d7a9f27c403ff9c0f19bce61d76d82f9aa29f8d6d4b0c5474607d9770d1af17' +const STANDARD_RISK_MANAGER_ADDRESS = '0x089fde8A32CD4Ef8D9F69DAed1B4CD5aC67d1ed7' +const DOMAIN_SEPARATOR = '0xff2ba7c8d1c63329d3c2c6c9c19113440c004c51fe6413f65654962afaff00f3' + +// Ethers setup +const PROVIDER = new ethers.JsonRpcProvider(PROVIDER_URL); +const wallet = new ethers.Wallet(PRIVATE_KEY, PROVIDER); +const encoder = ethers.AbiCoder.defaultAbiCoder(); + +const depositAmount = "10000"; +const subaccountId = 0; // 0 For a new account + +async function approveUSDCForDeposit(wallet, amount) { + const USDCcontract = new ethers.Contract( + USDC_ADDRESS, + ["function approve(address _spender, uint256 _value) public returns (bool success)"], + wallet + ); + const nonce = await wallet.provider?.getTransactionCount(wallet.address, "pending"); + await USDCcontract.approve(DEPOSIT_MODULE_ADDRESS, ethers.parseUnits(amount, 6), { + gasLimit: 1000000, + nonce: nonce + }); +} + +function encodeDepositData(amount){ + let encoded_data = encoder.encode( // same as "encoded_data" in public/create_subaccount_debug + ['uint256', 'address', 'address'], + [ + ethers.parseUnits(amount, 6), + CASH_ADDRESS, + STANDARD_RISK_MANAGER_ADDRESS + ] + ); + return ethers.keccak256(Buffer.from(encoded_data.slice(2), 'hex')) // same as "encoded_data_hashed" in public/create_subaccount_debug +} + +function generateSignature(subaccountId, encodedData, expiry, nonce) { + const action_hash = ethers.keccak256( // same as "action_hash" in public/create_subaccount_debug + encoder.encode( + ['bytes32', 'uint256', 'uint256', 'address', 'bytes32', 'uint256', 'address', 'address'], + [ + ACTION_TYPEHASH, + subaccountId, + nonce, + DEPOSIT_MODULE_ADDRESS, + encodedData, + expiry, + wallet.address, + wallet.address + ] + ) + ); + + const typed_data_hash = ethers.keccak256( // same as "typed_data_hash" in public/create_subaccount_debug + Buffer.concat([ + Buffer.from("1901", "hex"), + Buffer.from(DOMAIN_SEPARATOR.slice(2), "hex"), + Buffer.from(action_hash.slice(2), "hex"), + ]) + ); + + return wallet.signingKey.sign(typed_data_hash).serialized +} + +async function signAuthenticationHeader() { + const timestamp = Date.now().toString(); + const signature = await wallet.signMessage(timestamp); + return { + "X-LyraWallet": wallet.address, + "X-LyraTimestamp": timestamp, + "X-LyraSignature": signature + }; +} + +async function createSubaccount() { + // An action nonce is used to prevent replay attacks + // LYRA nonce format: ${CURRENT UTC MS +/- 1 day}${RANDOM 3 DIGIT NUMBER} + const nonce = Number(`${Date.now()}${Math.round(Math.random() * 999)}`); + const expiry = getUTCEpochSec() + 300; // 5 min + + const encoded_data_hashed = encodeDepositData(depositAmount); // same as "encoded_data_hashed" in public/create_subaccount_debug + const depositSignature = generateSignature(subaccountId, encoded_data_hashed, expiry, nonce); + const authHeader = await signAuthenticationHeader(); + + await approveUSDCForDeposit(wallet, depositAmount); + + try { + const response = await axios.request({ + method: "POST", + url: "https://api-demo.lyra.finance/private/create_subaccount", + data: { + margin_type: "SM", + wallet: wallet.address, + signer: wallet.address, + nonce: nonce, + amount: depositAmount, + signature: depositSignature, + signature_expiry_sec: expiry, + asset_name: 'USDC', + }, + headers: authHeader, + }); + + console.log(JSON.stringify(response.data, null, '\t')); + } catch (error) { + console.error("Error depositing to subaccount:", error); + } +} + +createSubaccount(); + diff --git a/poetry.lock b/poetry.lock index 3d92833..1afdd35 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. +# This file is automatically @generated by Poetry and should not be changed by hand. [[package]] name = "aiohttp" @@ -1732,6 +1732,23 @@ files = [ dev = ["Django (>=1.11)", "check-manifest", "colorama (<=0.4.1)", "coverage", "flake8", "nose2", "readme-renderer (<25.0)", "tox", "wheel", "zest.releaser[recommended]"] doc = ["Sphinx", "sphinx-rtd-theme"] +[[package]] +name = "setuptools" +version = "68.2.2" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"}, + {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + [[package]] name = "tomli" version = "2.0.1" @@ -1822,6 +1839,23 @@ ipfs = ["ipfshttpclient (==0.8.0a2)"] linter = ["black (>=22.1.0)", "flake8 (==3.8.3)", "isort (>=5.11.0)", "mypy (==1.4.1)", "types-protobuf (==3.19.13)", "types-requests (>=2.26.1)", "types-setuptools (>=57.4.4)"] tester = ["eth-tester[py-evm] (==v0.9.1-b.1)", "py-geth (>=3.11.0)"] +[[package]] +name = "websocket-client" +version = "1.6.4" +description = "WebSocket client for Python with low level API options" +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websocket-client-1.6.4.tar.gz", hash = "sha256:b3324019b3c28572086c4a319f91d1dcd44e6e11cd340232978c684a7650d0df"}, + {file = "websocket_client-1.6.4-py3-none-any.whl", hash = "sha256:084072e0a7f5f347ef2ac3d8698a5e0b4ffbfcab607628cadabc650fc9a83a24"}, +] + +[package.extras] +docs = ["Sphinx (>=6.0)", "sphinx-rtd-theme (>=1.1.0)"] +optional = ["python-socks", "wsaccel"] +test = ["websockets"] + [[package]] name = "websockets" version = "12.0" @@ -2011,4 +2045,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<4.0" -content-hash = "1c5caa2792467f1aec5138a51cfef7620f4d5f5642e14033af51f2653c910c48" +content-hash = "036fbb112226ce5facc9e86cc07cfdd6f5058e38474b04f2a7f7bcda746054f0" diff --git a/pyproject.toml b/pyproject.toml index ceed45a..dbded9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,8 @@ eth-tester = "^0.9.1b1" black = "^23.10.1" isort = "^5.12.0" flake8 = "^6.1.0" +websocket-client = "^1.6.4" +setuptools = "^68.2.2" [build-system] diff --git a/sample.py b/sample.py new file mode 100644 index 0000000..ffef6ac --- /dev/null +++ b/sample.py @@ -0,0 +1,142 @@ +from datetime import datetime +from pprint import pprint +import json +import random +import time +from websocket import create_connection +from web3 import Web3 +from eth_account.messages import encode_defunct + +from tests.test_main import TEST_PRIVATE_KEY, TEST_WALLET + +PRIVATE_KEY = TEST_PRIVATE_KEY +PROVIDER_URL = 'https://l2-prod-testnet-0eakp60405.t.conduit.xyz' +WS_ADDRESS = "wss://api-demo.lyra.finance/ws" +ACTION_TYPEHASH = '0x4d7a9f27c403ff9c0f19bce61d76d82f9aa29f8d6d4b0c5474607d9770d1af17' +DOMAIN_SEPARATOR = '0xff2ba7c8d1c63329d3c2c6c9c19113440c004c51fe6413f65654962afaff00f3' +# ASSET_ADDRESS = '0x8932cc48F7AD0c6c7974606cFD7bCeE2F543a124' +ASSET_ADDRESS = '0x62CF2Cc6450Dc3FbD0662Bfd69af0a4D7485Fe4E' +TRADE_MODULE_ADDRESS = '0x63Bc9D10f088eddc39A6c40Ff81E99516dfD5269' + +w3 = Web3(Web3.HTTPProvider(PROVIDER_URL)) +account = w3.eth.account.from_key(PRIVATE_KEY) +subaccount_id = 550 + +OPTION_NAME = 'ETH-PERP' +OPTION_SUB_ID = '0' # can retrieve with public/get_instrument + + +def sign_authentication_header(): + timestamp = str(int(time.time() * 1000)) + msg = encode_defunct( + text=timestamp, + ) + signature = w3.eth.account.sign_message(msg, private_key=PRIVATE_KEY).signature.hex() + return { + 'wallet': account.address, + 'timestamp': str(timestamp), + 'signature': signature, + } + + +def connect_ws(): + ws = create_connection(WS_ADDRESS) + return ws + + +def login_client(ws): + login_request = json.dumps({ + 'method': 'public/login', + 'params': sign_authentication_header(), + 'id': str(int(time.time())) + }) + ws.send(login_request) + time.sleep(2) + + +def define_order(): + + ts = int(datetime.now().timestamp() * 1000) + + return { + 'instrument_name': OPTION_NAME, + 'subaccount_id': subaccount_id, + 'direction': 'buy', + 'limit_price': 310, + 'amount': 1, + 'signature_expiry_sec': int(ts / 1000) + 300, + 'max_fee': '0.01', + 'nonce': int(f"{int(ts)}{random.randint(100, 999)}"), + 'signer': account.address, + 'order_type': 'limit', + 'mmp': False, + 'signature': 'filled_in_below' + } + + +def encode_trade_data(order): + encoded_data = w3.solidity_keccak(['address', 'uint256', 'int256', 'int256', 'uint256', 'uint256', 'bool'], + [ASSET_ADDRESS, + int(OPTION_SUB_ID), + w3.to_wei(order['limit_price'], 'ether'), + w3.to_wei(order['amount'], 'ether'), + w3.to_wei(order['max_fee'], 'ether'), + order['subaccount_id'], + order['direction'] == 'buy']) + return encoded_data + + +def sign_order(order): + trade_module_data = encode_trade_data(order) + + action_hash = w3.solidity_keccak(['bytes32', 'uint256', 'uint256', 'address', 'bytes32', 'uint256', 'address', 'address'], + [ACTION_TYPEHASH, + order['subaccount_id'], + order['nonce'], + TRADE_MODULE_ADDRESS, + trade_module_data, + order['signature_expiry_sec'], + account.address, + order['signer']]) + + typed_data_hash = w3.solidity_keccak(['bytes2', 'bytes32', 'bytes32'], + ['0x19', + bytes.fromhex(DOMAIN_SEPARATOR[2:]), + action_hash]) + + print('Typed data hash:', typed_data_hash.hex()) + + msg = encode_defunct( + text=typed_data_hash.hex(), + ) + + order['signature'] = w3.eth.account.sign_message(msg, private_key=PRIVATE_KEY).signature.hex() + return order + + +def submit_order(order, ws): + id = str(int(time.time())) + ws.send(json.dumps({ + 'method': 'private/order', + 'params': order, + 'id': id + })) + + pprint(order) + + while True: + message = json.loads(ws.recv()) + if message['id'] == id: + print('Got order response:', message) + break + + +def complete_order(): + ws = connect_ws() + login_client(ws) + order = define_order() + order = sign_order(order) + submit_order(order, ws) + + +complete_order() diff --git a/tests/test_main.py b/tests/test_main.py index 31467d3..6c86b1b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -32,7 +32,8 @@ def test_lyra_client_fetch_tickers(lyra_client): """ Test the LyraClient class. """ - assert lyra_client.fetch_tickers(instrument_type=InstrumentType.PERP.value, currency=UnderlyingCurrency.ETH.value) + res = lyra_client.fetch_tickers(instrument_type=InstrumentType.PERP.value, currency=UnderlyingCurrency.ETH.value) + breakpoint() def test_create_signature_headers(lyra_client): @@ -46,7 +47,10 @@ def test_fetch_subaccounts(lyra_client): """ Test the LyraClient class. """ - assert lyra_client.fetch_subaccounts(TEST_WALLET) + accounts = lyra_client.fetch_subaccounts(TEST_WALLET) + breakpoint() + assert accounts['subaccounts'] + def test_create_order(lyra_client):