diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index 78b568e90..a8551be1f 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -170,3 +170,11 @@ fixNFTokenRemint # 2.0.0-b4 Amendments XChainBridge DID +# 2.2.0-b3 Amendments +fixNFTokenReserve +fixInnerObjTemplate +fixAMMOverflowOffer +PriceOracle +fixEmptyDID +fixXChainRewardRounding +fixPreviousTxnID diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 6af6fff70..2cdedf614 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -2,7 +2,7 @@ name: Integration test env: POETRY_VERSION: 1.4.2 - RIPPLED_DOCKER_IMAGE: rippleci/rippled:2.0.0-b4 + RIPPLED_DOCKER_IMAGE: rippleci/rippled:2.2.0-b3 on: push: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e03a52d4..e05640313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [[Unreleased]] - Included `ctid` field in the `tx` request. +### Added +- Support for the Price Oracles amendment (XLS-47). + ### Fixed - Added support for `XChainModifyBridge` flag maps (fixing an issue with `NFTokenCreateOffer` flag names) - Fixed `XChainModifyBridge` validation to allow just clearing of `MinAccountCreateAmount` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dcf7ce92b..af34609b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,7 +90,7 @@ poetry run poe test_unit To run integration tests, you'll need a standalone rippled node running with WS port `6006` and JSON RPC port `5005`. You can run a docker container for this: ```bash -docker run -p 5005:5005 -p 6006:6006 --interactive -t --volume $PWD/.ci-config:/opt/ripple/etc/ --platform linux/amd64 rippleci/rippled:2.0.0-b4 /opt/ripple/bin/rippled -a --conf /opt/ripple/etc/rippled.cfg +docker run -p 5005:5005 -p 6006:6006 --interactive -t --volume $PWD/.ci-config:/opt/ripple/etc/ --platform linux/amd64 rippleci/rippled:2.2.0-b3 /opt/ripple/bin/rippled -a --conf /opt/ripple/etc/rippled.cfg ``` Breaking down the command: diff --git a/tests/integration/it_utils.py b/tests/integration/it_utils.py index 72a74e3e3..b7713c861 100644 --- a/tests/integration/it_utils.py +++ b/tests/integration/it_utils.py @@ -1,4 +1,5 @@ """Utility functions and variables for integration tests.""" + import asyncio import importlib import inspect @@ -246,7 +247,8 @@ def decorator(test_function): # NOTE: passing `globals()` into `exec` is really bad practice and not safe at # all, but in this case it's fine because it's only running test code - def _run_sync_test(self, client): + def _run_sync_test(self, client, value): + self.value = value for i in range(num_retries): try: exec( @@ -261,7 +263,8 @@ def _run_sync_test(self, client): raise e sleep(2) - async def _run_async_test(self, client): + async def _run_async_test(self, client, value): + self.value = value if isinstance(client, AsyncWebsocketClient): await client.open() # this is happening with each test because IsolatedAsyncioTestCase is @@ -285,16 +288,16 @@ def modified_test(self): if not websockets_only: with self.subTest(version="async", client="json"): asyncio.run( - _run_async_test(self, _get_client(True, True, use_testnet)) + _run_async_test(self, _get_client(True, True, use_testnet), 1) ) with self.subTest(version="sync", client="json"): - _run_sync_test(self, _get_client(False, True, use_testnet)) + _run_sync_test(self, _get_client(False, True, use_testnet), 2) with self.subTest(version="async", client="websocket"): asyncio.run( - _run_async_test(self, _get_client(True, False, use_testnet)) + _run_async_test(self, _get_client(True, False, use_testnet), 3) ) with self.subTest(version="sync", client="websocket"): - _run_sync_test(self, _get_client(False, False, use_testnet)) + _run_sync_test(self, _get_client(False, False, use_testnet), 4) return modified_test diff --git a/tests/integration/transactions/test_delete_oracle.py b/tests/integration/transactions/test_delete_oracle.py new file mode 100644 index 000000000..702025af8 --- /dev/null +++ b/tests/integration/transactions/test_delete_oracle.py @@ -0,0 +1,59 @@ +import time + +from tests.integration.integration_test_case import IntegrationTestCase +from tests.integration.it_utils import ( + sign_and_reliable_submission_async, + test_async_and_sync, +) +from tests.integration.reusable_values import WALLET +from xrpl.models import AccountObjects, AccountObjectType, OracleDelete, OracleSet +from xrpl.models.response import ResponseStatus +from xrpl.models.transactions.oracle_set import PriceData +from xrpl.utils import str_to_hex + +_PROVIDER = str_to_hex("chainlink") +_ASSET_CLASS = str_to_hex("currency") + + +class TestDeleteOracle(IntegrationTestCase): + @test_async_and_sync(globals()) + async def test_basic(self, client): + oracle_id = self.value + + # Create PriceOracle, to be deleted later + tx = OracleSet( + account=WALLET.address, + # unlike the integration tests for OracleSet transaction, we do not have to + # dynamically change the oracle_document_id for these integration tests. + # This is because the Oracle LedgerObject is deleted by the end of the test. + oracle_document_id=oracle_id, + provider=_PROVIDER, + asset_class=_ASSET_CLASS, + last_update_time=int(time.time()), + price_data_series=[ + PriceData( + base_asset="XRP", quote_asset="USD", asset_price=740, scale=1 + ), + PriceData( + base_asset="BTC", quote_asset="EUR", asset_price=100, scale=2 + ), + ], + ) + response = await sign_and_reliable_submission_async(tx, WALLET, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Create PriceOracle to delete + tx = OracleDelete( + account=WALLET.address, + oracle_document_id=oracle_id, + ) + response = await sign_and_reliable_submission_async(tx, WALLET, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # confirm that the PriceOracle was actually deleted + account_objects_response = await client.request( + AccountObjects(account=WALLET.address, type=AccountObjectType.ORACLE) + ) + self.assertEqual(len(account_objects_response.result["account_objects"]), 0) diff --git a/tests/integration/transactions/test_set_oracle.py b/tests/integration/transactions/test_set_oracle.py new file mode 100644 index 000000000..fc948c16c --- /dev/null +++ b/tests/integration/transactions/test_set_oracle.py @@ -0,0 +1,53 @@ +import time + +from tests.integration.integration_test_case import IntegrationTestCase +from tests.integration.it_utils import ( + sign_and_reliable_submission_async, + test_async_and_sync, +) +from tests.integration.reusable_values import WALLET +from xrpl.models import AccountObjects, AccountObjectType, OracleSet +from xrpl.models.response import ResponseStatus +from xrpl.models.transactions.oracle_set import PriceData +from xrpl.utils import str_to_hex + +_PROVIDER = str_to_hex("provider") +_ASSET_CLASS = str_to_hex("currency") + + +class TestSetOracle(IntegrationTestCase): + @test_async_and_sync(globals()) + async def test_all_fields(self, client): + tx = OracleSet( + account=WALLET.address, + # if oracle_document_id is not modified, the (sync, async) + + # (json, websocket) combination of integration tests will update the same + # oracle object using identical "LastUpdateTime". Updates to an oracle must + # be more recent than its previous LastUpdateTime + # a unique value is obtained for each combination of test run within the + # implementation of the test_async_and_sync decorator. + oracle_document_id=self.value, + provider=_PROVIDER, + asset_class=_ASSET_CLASS, + last_update_time=int(time.time()), + price_data_series=[ + PriceData( + base_asset="XRP", quote_asset="USD", asset_price=740, scale=1 + ), + PriceData( + base_asset="BTC", quote_asset="EUR", asset_price=100, scale=2 + ), + ], + ) + response = await sign_and_reliable_submission_async(tx, WALLET, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # confirm that the PriceOracle was actually created + account_objects_response = await client.request( + AccountObjects(account=WALLET.address, type=AccountObjectType.ORACLE) + ) + + # subsequent integration tests (sync/async + json/websocket) add one + # oracle object to the account + self.assertTrue(len(account_objects_response.result["account_objects"]) > 0) diff --git a/tests/unit/models/requests/test_get_aggregate_price.py b/tests/unit/models/requests/test_get_aggregate_price.py new file mode 100644 index 000000000..dec902320 --- /dev/null +++ b/tests/unit/models/requests/test_get_aggregate_price.py @@ -0,0 +1,68 @@ +from unittest import TestCase + +from xrpl.models import XRPLModelException +from xrpl.models.requests import GetAggregatePrice +from xrpl.models.requests.get_aggregate_price import Oracle + +_ACCT_STR_1 = "rBwHKFS534tfG3mATXSycCnX8PAd3XJswj" +_ORACLE_DOC_ID_1 = 1 + +_ACCT_STR_2 = "rDMKwhm13oJBxBgiWS2SheZhKT5nZP8kez" +_ORACLE_DOC_ID_2 = 2 + + +class TestGetAggregatePrice(TestCase): + def test_invalid_requests(self): + """Unit test to validate invalid requests""" + with self.assertRaises(XRPLModelException): + # oracles array must contain at least one element + GetAggregatePrice( + base_asset="USD", + quote_asset="XRP", + oracles=[], + ) + + with self.assertRaises(XRPLModelException): + # base_asset is missing in the request + GetAggregatePrice( + quote_asset="XRP", + oracles=[ + Oracle(account=_ACCT_STR_1, oracle_document_id=_ORACLE_DOC_ID_1), + Oracle(account=_ACCT_STR_2, oracle_document_id=_ORACLE_DOC_ID_2), + ], + ) + + with self.assertRaises(XRPLModelException): + # quote_asset is missing in the request + GetAggregatePrice( + base_asset="USD", + oracles=[ + Oracle(account=_ACCT_STR_1, oracle_document_id=_ORACLE_DOC_ID_1), + Oracle(account=_ACCT_STR_2, oracle_document_id=_ORACLE_DOC_ID_2), + ], + ) + + def test_valid_request(self): + """Unit test for validating archetypical requests""" + request = GetAggregatePrice( + base_asset="USD", + quote_asset="XRP", + oracles=[ + Oracle(account=_ACCT_STR_1, oracle_document_id=_ORACLE_DOC_ID_1), + Oracle(account=_ACCT_STR_2, oracle_document_id=_ORACLE_DOC_ID_2), + ], + ) + self.assertTrue(request.is_valid()) + + # specifying trim and time_threshold value + request = GetAggregatePrice( + base_asset="USD", + quote_asset="XRP", + oracles=[ + Oracle(account=_ACCT_STR_1, oracle_document_id=_ORACLE_DOC_ID_1), + Oracle(account=_ACCT_STR_2, oracle_document_id=_ORACLE_DOC_ID_2), + ], + trim=20, + time_threshold=10, + ) + self.assertTrue(request.is_valid()) diff --git a/tests/unit/models/requests/test_ledger_entry.py b/tests/unit/models/requests/test_ledger_entry.py index 513ef9d1e..56ba0e84f 100644 --- a/tests/unit/models/requests/test_ledger_entry.py +++ b/tests/unit/models/requests/test_ledger_entry.py @@ -2,7 +2,7 @@ from xrpl.models import XRP, LedgerEntry, XChainBridge from xrpl.models.exceptions import XRPLModelException -from xrpl.models.requests.ledger_entry import RippleState +from xrpl.models.requests.ledger_entry import Oracle, RippleState class TestLedgerEntry(TestCase): @@ -119,3 +119,36 @@ def test_has_multiple_query_params_is_invalid(self): index="hello", account_root="hello", ) + + # fetch a valid PriceOracle object + def test_get_price_oracle(self): + # oracle_document_id is specified as uint + req = LedgerEntry( + oracle=Oracle( + account="rB6XJbxKx2oBSK1E3Hvh7KcZTCCBukWyhv", + oracle_document_id=1, + ), + ) + self.assertTrue(req.is_valid()) + + # oracle_document_id is specified as string + req = LedgerEntry( + oracle=Oracle( + account="rB6XJbxKx2oBSK1E3Hvh7KcZTCCBukWyhv", + oracle_document_id="1", + ), + ) + self.assertTrue(req.is_valid()) + + def test_invalid_price_oracle_object(self): + # missing oracle_document_id + with self.assertRaises(XRPLModelException): + LedgerEntry( + oracle=Oracle(account="rB6XJbxKx2oBSK1E3Hvh7KcZTCCBukWyhv"), + ) + + # missing account information + with self.assertRaises(XRPLModelException): + LedgerEntry( + oracle=Oracle(oracle_document_id=1), + ) diff --git a/tests/unit/models/transactions/test_oracle_delete.py b/tests/unit/models/transactions/test_oracle_delete.py new file mode 100644 index 000000000..84cf112c3 --- /dev/null +++ b/tests/unit/models/transactions/test_oracle_delete.py @@ -0,0 +1,14 @@ +from unittest import TestCase + +from xrpl.models.transactions import OracleDelete + +_ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" + + +class TestDeleteOracle(TestCase): + def test_valid(self): + tx = OracleDelete( + account=_ACCOUNT, + oracle_document_id=1, + ) + self.assertTrue(tx.is_valid()) diff --git a/tests/unit/models/transactions/test_oracle_set.py b/tests/unit/models/transactions/test_oracle_set.py new file mode 100644 index 000000000..d99903311 --- /dev/null +++ b/tests/unit/models/transactions/test_oracle_set.py @@ -0,0 +1,352 @@ +import time +from unittest import TestCase + +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions import OracleSet +from xrpl.models.transactions.oracle_set import ( + EPOCH_OFFSET, + MAX_ORACLE_PROVIDER, + MAX_ORACLE_SYMBOL_CLASS, + MAX_ORACLE_URI, + PriceData, +) + +_ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" +_PROVIDER = "chainlink" +_ASSET_CLASS = "currency" + +_EMPTY_PROVIDER = "" +_LENGTHY_PROVIDER = "X" * (MAX_ORACLE_PROVIDER + 1) + + +class TestSetOracle(TestCase): + def test_valid(self): + tx = OracleSet( + account=_ACCOUNT, + oracle_document_id=1, + provider=_PROVIDER, + asset_class=_ASSET_CLASS, + last_update_time=int(time.time()), + price_data_series=[ + PriceData( + base_asset="XRP", quote_asset="USD", asset_price=740, scale=1 + ), + PriceData( + base_asset="BTC", quote_asset="EUR", asset_price=100, scale=2 + ), + ], + ) + self.assertTrue(tx.is_valid()) + + def test_missing_data_series(self): + with self.assertRaises(XRPLModelException) as err: + OracleSet( + account=_ACCOUNT, + oracle_document_id=1, + provider=_PROVIDER, + asset_class=_ASSET_CLASS, + last_update_time=int(time.time()), + ) + + self.assertEqual( + err.exception.args[0], + "{'price_data_series': " + "'price_data_series is not set'}", + ) + + def test_exceed_length_price_data_series(self): + # price_data_series exceeds the mandated length (10 elements) + with self.assertRaises(XRPLModelException) as err: + OracleSet( + account=_ACCOUNT, + oracle_document_id=1, + provider=_PROVIDER, + asset_class=_ASSET_CLASS, + last_update_time=int(time.time()), + price_data_series=[ + PriceData( + base_asset="XRP", quote_asset="USD1", asset_price=741, scale=1 + ), + PriceData( + base_asset="XRP", quote_asset="USD2", asset_price=742, scale=1 + ), + PriceData( + base_asset="XRP", quote_asset="USD3", asset_price=743, scale=1 + ), + PriceData( + base_asset="XRP", quote_asset="USD4", asset_price=744, scale=1 + ), + PriceData( + base_asset="XRP", quote_asset="USD5", asset_price=745, scale=1 + ), + PriceData( + base_asset="XRP", quote_asset="USD6", asset_price=746, scale=1 + ), + PriceData( + base_asset="XRP", quote_asset="USD7", asset_price=747, scale=1 + ), + PriceData( + base_asset="XRP", quote_asset="USD8", asset_price=748, scale=1 + ), + PriceData( + base_asset="XRP", quote_asset="USD9", asset_price=749, scale=1 + ), + PriceData( + base_asset="XRP", quote_asset="USD10", asset_price=750, scale=1 + ), + PriceData( + base_asset="XRP", quote_asset="USD11", asset_price=751, scale=1 + ), + ], + ) + + self.assertEqual( + err.exception.args[0], + "{'price_data_series': 'Field must " + + "have a length less than or equal to 10'}", + ) + + def test_valid_provider_field(self): + tx = OracleSet( + account=_ACCOUNT, + oracle_document_id=1, + provider=_PROVIDER, + asset_class=_ASSET_CLASS, + last_update_time=int(time.time()), + uri="https://some_data_provider.com/path", + price_data_series=[ + PriceData( + base_asset="XRP", quote_asset="USD", asset_price=740, scale=1 + ), + PriceData( + base_asset="BTC", quote_asset="EUR", asset_price=100, scale=2 + ), + ], + ) + self.assertTrue(tx.is_valid()) + + def test_empty_provider_field(self): + with self.assertRaises(XRPLModelException) as err: + OracleSet( + account=_ACCOUNT, + oracle_document_id=1, + provider=_EMPTY_PROVIDER, + asset_class=_ASSET_CLASS, + last_update_time=int(time.time()), + price_data_series=[ + PriceData( + base_asset="XRP", quote_asset="USD", asset_price=740, scale=1 + ), + PriceData( + base_asset="BTC", quote_asset="EUR", asset_price=100, scale=2 + ), + ], + ) + + self.assertEqual( + err.exception.args[0], + "{'provider': 'Field must have a " + "length greater than 0.'}", + ) + + def test_lengthy_provider_field(self): + # provider exceeds MAX_ORACLE_PROVIDER characters + with self.assertRaises(XRPLModelException) as err: + OracleSet( + account=_ACCOUNT, + oracle_document_id=1, + provider=_LENGTHY_PROVIDER, + asset_class=_ASSET_CLASS, + last_update_time=int(time.time()), + price_data_series=[ + PriceData( + base_asset="XRP", quote_asset="USD", asset_price=740, scale=1 + ), + PriceData( + base_asset="BTC", quote_asset="EUR", asset_price=100, scale=2 + ), + ], + ) + + self.assertEqual( + err.exception.args[0], + "{'provider': 'Field must have a " + "length less than or equal to 256.'}", + ) + + def test_valid_uri_field(self): + tx = OracleSet( + account=_ACCOUNT, + oracle_document_id=1, + provider=_PROVIDER, + asset_class=_ASSET_CLASS, + last_update_time=int(time.time()), + uri="https://some_data_provider.com/path", + price_data_series=[ + PriceData( + base_asset="XRP", quote_asset="USD", asset_price=740, scale=1 + ), + PriceData( + base_asset="BTC", quote_asset="EUR", asset_price=100, scale=2 + ), + ], + ) + self.assertTrue(tx.is_valid()) + + def test_empty_uri_field(self): + with self.assertRaises(XRPLModelException) as err: + OracleSet( + account=_ACCOUNT, + oracle_document_id=1, + provider=_PROVIDER, + asset_class=_ASSET_CLASS, + last_update_time=int(time.time()), + uri="", + price_data_series=[ + PriceData( + base_asset="XRP", quote_asset="USD", asset_price=740, scale=1 + ), + PriceData( + base_asset="BTC", quote_asset="EUR", asset_price=100, scale=2 + ), + ], + ) + + self.assertEqual( + err.exception.args[0], + "{'uri': 'Field must have a" + " length greater than 0.'}", + ) + + def test_lengthy_uri_field(self): + # URI exceeds MAX_ORACLE_URI characters + with self.assertRaises(XRPLModelException) as err: + OracleSet( + account=_ACCOUNT, + oracle_document_id=1, + provider=_PROVIDER, + asset_class=_ASSET_CLASS, + last_update_time=int(time.time()), + uri=("x" * (MAX_ORACLE_URI + 1)), + price_data_series=[ + PriceData( + base_asset="XRP", quote_asset="USD", asset_price=740, scale=1 + ), + PriceData( + base_asset="BTC", quote_asset="EUR", asset_price=100, scale=2 + ), + ], + ) + + self.assertEqual( + err.exception.args[0], + "{'uri': 'Field must have a" + " length less than or equal to 256.'}", + ) + + def test_valid_asset_class_field(self): + tx = OracleSet( + account=_ACCOUNT, + oracle_document_id=1, + provider=_PROVIDER, + asset_class=_ASSET_CLASS, + last_update_time=int(time.time()), + uri="https://some_data_provider.com/path", + price_data_series=[ + PriceData( + base_asset="XRP", quote_asset="USD", asset_price=740, scale=1 + ), + PriceData( + base_asset="BTC", quote_asset="EUR", asset_price=100, scale=2 + ), + ], + ) + self.assertTrue(tx.is_valid()) + + def test_empty_asset_class_field(self): + with self.assertRaises(XRPLModelException) as err: + OracleSet( + account=_ACCOUNT, + oracle_document_id=1, + provider=_PROVIDER, + last_update_time=int(time.time()), + asset_class="", + price_data_series=[ + PriceData( + base_asset="XRP", quote_asset="USD", asset_price=740, scale=1 + ), + PriceData( + base_asset="BTC", quote_asset="EUR", asset_price=100, scale=2 + ), + ], + ) + + self.assertEqual( + err.exception.args[0], + "{'asset_class': 'Field must have" + " a length greater than 0.'}", + ) + + def test_lengthy_asset_class_field(self): + # URI exceeds MAX_ORACLE_SYMBOL_CLASS characters + with self.assertRaises(XRPLModelException) as err: + OracleSet( + account=_ACCOUNT, + oracle_document_id=1, + provider=_PROVIDER, + last_update_time=int(time.time()), + asset_class=("x" * (MAX_ORACLE_SYMBOL_CLASS + 1)), + price_data_series=[ + PriceData( + base_asset="XRP", quote_asset="USD", asset_price=740, scale=1 + ), + PriceData( + base_asset="BTC", quote_asset="EUR", asset_price=100, scale=2 + ), + ], + ) + + self.assertEqual( + err.exception.args[0], + "{'asset_class': 'Field must have" + " a length less than or equal to 16'}", + ) + + def test_early_last_update_time_field(self): + with self.assertRaises(XRPLModelException) as err: + OracleSet( + account=_ACCOUNT, + oracle_document_id=1, + provider=_PROVIDER, + asset_class=_ASSET_CLASS, + last_update_time=EPOCH_OFFSET - 1, + price_data_series=[ + PriceData( + base_asset="XRP", quote_asset="USD", asset_price=740, scale=1 + ), + PriceData( + base_asset="BTC", quote_asset="EUR", asset_price=100, scale=2 + ), + ], + ) + + self.assertEqual( + err.exception.args[0], + "{'last_update_time': 'LastUpdateTime" + + " must be greater than or equal to Ripple-Epoch 946684800.0 seconds'}", + ) + + # Validity depends on the time of the Last Closed Ledger. This test verifies the + # validity with respect to the Ripple Epoch time + def test_valid_last_update_time(self): + # Note: This test fails in an integration test because it's older than 300s + # with respect to the LastClosedLedger + tx = OracleSet( + account=_ACCOUNT, + oracle_document_id=1, + provider=_PROVIDER, + asset_class=_ASSET_CLASS, + last_update_time=EPOCH_OFFSET, + price_data_series=[ + PriceData( + base_asset="XRP", quote_asset="USD", asset_price=740, scale=1 + ), + PriceData( + base_asset="BTC", quote_asset="EUR", asset_price=100, scale=2 + ), + ], + ) + self.assertTrue(tx.is_valid()) diff --git a/xrpl/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index c98e9a988..797be9ce2 100644 --- a/xrpl/core/binarycodec/definitions/definitions.json +++ b/xrpl/core/binarycodec/definitions/definitions.json @@ -23,6 +23,7 @@ "UInt512": 23, "Issue": 24, "XChainBridge": 25, + "Currency": 26, "Transaction": 10001, "LedgerEntry": 10002, "Validation": 10003, @@ -51,6 +52,7 @@ "NFTokenOffer": 55, "AMM": 121, "DID": 73, + "Oracle": 128, "Any": -3, "Child": -2, "Nickname": 110, @@ -141,40 +143,40 @@ [ "LedgerEntry", { - "nth": 1, + "nth": 257, "isVLEncoded": false, "isSerialized": false, - "isSigningField": true, + "isSigningField": false, "type": "LedgerEntry" } ], [ "Transaction", { - "nth": 1, + "nth": 257, "isVLEncoded": false, "isSerialized": false, - "isSigningField": true, + "isSigningField": false, "type": "Transaction" } ], [ "Validation", { - "nth": 1, + "nth": 257, "isVLEncoded": false, "isSerialized": false, - "isSigningField": true, + "isSigningField": false, "type": "Validation" } ], [ "Metadata", { - "nth": 1, + "nth": 257, "isVLEncoded": false, - "isSerialized": true, - "isSigningField": true, + "isSerialized": false, + "isSigningField": false, "type": "Metadata" } ], @@ -208,6 +210,16 @@ "type": "UInt8" } ], + [ + "Scale", + { + "nth": 4, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt8" + } + ], [ "TickSize", { @@ -498,6 +510,16 @@ "type": "UInt32" } ], + [ + "LastUpdateTime", + { + "nth": 15, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt32" + } + ], [ "HighQualityIn", { @@ -828,6 +850,16 @@ "type": "UInt32" } ], + [ + "OracleDocumentID", + { + "nth": 51, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt32" + } + ], [ "IndexNext", { @@ -1028,6 +1060,16 @@ "type": "UInt64" } ], + [ + "AssetPrice", + { + "nth": 23, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt64" + } + ], [ "EmailHash", { @@ -1918,6 +1960,26 @@ "type": "Blob" } ], + [ + "AssetClass", + { + "nth": 28, + "isVLEncoded": true, + "isSerialized": true, + "isSigningField": true, + "type": "Blob" + } + ], + [ + "Provider", + { + "nth": 29, + "isVLEncoded": true, + "isSerialized": true, + "isSigningField": true, + "type": "Blob" + } + ], [ "Account", { @@ -2128,6 +2190,26 @@ "type": "PathSet" } ], + [ + "BaseAsset", + { + "nth": 1, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "Currency" + } + ], + [ + "QuoteAsset", + { + "nth": 2, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "Currency" + } + ], [ "LockingChainIssue", { @@ -2458,6 +2540,16 @@ "type": "STObject" } ], + [ + "PriceData", + { + "nth": 32, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "STObject" + } + ], [ "Signers", { @@ -2628,6 +2720,16 @@ "type": "STArray" } ], + [ + "PriceDataSeries", + { + "nth": 24, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "STArray" + } + ], [ "AuthAccounts", { @@ -2656,6 +2758,7 @@ "telWRONG_NETWORK": -386, "telREQUIRES_NETWORK_ID": -385, "telNETWORK_ID_MAKES_TX_NON_CANONICAL": -384, + "telENV_RPC_FAILED": -383, "temMALFORMED": -299, "temBAD_AMOUNT": -298, @@ -2703,6 +2806,8 @@ "temXCHAIN_BRIDGE_BAD_MIN_ACCOUNT_CREATE_AMOUNT": -256, "temXCHAIN_BRIDGE_BAD_REWARD_AMOUNT": -255, "temEMPTY_DID": -254, + "temARRAY_EMPTY": -253, + "temARRAY_TOO_LARGE": -252, "tefFAILURE": -199, "tefALREADY": -198, @@ -2739,7 +2844,6 @@ "terQUEUED": -89, "terPRE_TICKET": -88, "terNO_AMM": -87, - "terSUBMITTED": -86, "tesSUCCESS": 0, @@ -2781,7 +2885,7 @@ "tecKILLED": 150, "tecHAS_OBLIGATIONS": 151, "tecTOO_SOON": 152, - "tecHOOK_ERROR": 153, + "tecHOOK_REJECTED": 153, "tecMAX_SEQUENCE_REACHED": 154, "tecNO_SUITABLE_NFTOKEN_PAGE": 155, "tecNFTOKEN_BUY_SELL_MISMATCH": 156, @@ -2815,7 +2919,11 @@ "tecXCHAIN_SELF_COMMIT": 184, "tecXCHAIN_BAD_PUBLIC_KEY_ACCOUNT_PAIR": 185, "tecXCHAIN_CREATE_ACCOUNT_DISABLED": 186, - "tecEMPTY_DID": 187 + "tecEMPTY_DID": 187, + "tecINVALID_UPDATE_TIME": 188, + "tecTOKEN_PAIR_NOT_FOUND": 189, + "tecARRAY_EMPTY": 190, + "tecARRAY_TOO_LARGE": 191 }, "TRANSACTION_TYPES": { "Invalid": -1, @@ -2864,6 +2972,8 @@ "XChainCreateBridge": 48, "DIDSet": 49, "DIDDelete": 50, + "OracleSet": 51, + "OracleDelete": 52, "EnableAmendment": 100, "SetFee": 101, "UNLModify": 102 diff --git a/xrpl/models/requests/__init__.py b/xrpl/models/requests/__init__.py index 32b666a77..f6214f658 100644 --- a/xrpl/models/requests/__init__.py +++ b/xrpl/models/requests/__init__.py @@ -17,6 +17,7 @@ from xrpl.models.requests.fee import Fee from xrpl.models.requests.gateway_balances import GatewayBalances from xrpl.models.requests.generic_request import GenericRequest +from xrpl.models.requests.get_aggregate_price import GetAggregatePrice from xrpl.models.requests.ledger import Ledger from xrpl.models.requests.ledger_closed import LedgerClosed from xrpl.models.requests.ledger_current import LedgerCurrent @@ -66,6 +67,7 @@ "Fee", "GatewayBalances", "GenericRequest", + "GetAggregatePrice", "Ledger", "LedgerClosed", "LedgerCurrent", diff --git a/xrpl/models/requests/account_objects.py b/xrpl/models/requests/account_objects.py index 9a6e50961..65e2f57da 100644 --- a/xrpl/models/requests/account_objects.py +++ b/xrpl/models/requests/account_objects.py @@ -26,6 +26,7 @@ class AccountObjectType(str, Enum): ESCROW = "escrow" NFT_OFFER = "nft_offer" OFFER = "offer" + ORACLE = "oracle" PAYMENT_CHANNEL = "payment_channel" SIGNER_LIST = "signer_list" STATE = "state" diff --git a/xrpl/models/requests/get_aggregate_price.py b/xrpl/models/requests/get_aggregate_price.py new file mode 100644 index 000000000..d8efdd017 --- /dev/null +++ b/xrpl/models/requests/get_aggregate_price.py @@ -0,0 +1,63 @@ +""" +This module defines the GetAggregatePrice request API. It is used to fetch aggregate +statistics about the specified PriceOracles +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +from typing_extensions import TypedDict + +from xrpl.models.requests.request import Request, RequestMethod +from xrpl.models.required import REQUIRED +from xrpl.models.utils import require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True) +class GetAggregatePrice(Request): + """ + The get_aggregate_price method retrieves the aggregate price of specified Oracle + objects, returning three price statistics: mean, median, and trimmed mean. + """ + + method: RequestMethod = field(default=RequestMethod.GET_AGGREGATE_PRICE, init=False) + + base_asset: str = REQUIRED # type: ignore + """The currency code of the asset to be priced""" + + quote_asset: str = REQUIRED # type: ignore + """The currency code of the asset to quote the price of the base asset""" + + oracles: List[Oracle] = REQUIRED # type: ignore + """The oracle identifier""" + + trim: Optional[int] = None + """The percentage of outliers to trim. Valid trim range is 1-25. If included, the + API returns statistics for the trimmed mean""" + + time_threshold: Optional[int] = None + """Defines a time range in seconds for filtering out older price data. Default + value is 0, which doesn't filter any data""" + + def _get_errors(self: GetAggregatePrice) -> Dict[str, str]: + errors = super()._get_errors() + if len(self.oracles) == 0: + errors[ + "GetAggregatePrice" + ] = "Oracles array must contain at least one element" + return errors + + +@require_kwargs_on_init +@dataclass(frozen=True) +class Oracle(TypedDict): + """Represents one Oracle element. It is used in GetAggregatePrice request""" + + oracle_document_id: int = REQUIRED # type: ignore + """A unique identifier of the price oracle for the Account""" + + account: str = REQUIRED # type: ignore + """The XRPL account that controls the Oracle object""" diff --git a/xrpl/models/requests/ledger_entry.py b/xrpl/models/requests/ledger_entry.py index 374dfe76e..8fe2f8bea 100644 --- a/xrpl/models/requests/ledger_entry.py +++ b/xrpl/models/requests/ledger_entry.py @@ -32,6 +32,7 @@ class LedgerEntryType(str, Enum): FEE = "fee" HASHES = "hashes" OFFER = "offer" + ORACLE = "oracle" PAYMENT_CHANNEL = "payment_channel" SIGNER_LIST = "signer_list" STATE = "state" @@ -132,6 +133,29 @@ class Offer(BaseModel): """ +@require_kwargs_on_init +@dataclass(frozen=True) +class Oracle(BaseModel): + """ + Required fields for requesting a Price Oracle Ledger Entry, if not querying by + object ID. + """ + + account: str = REQUIRED # type: ignore + """ + This field is required. + + :meta hide-value: + """ + + oracle_document_id: Union[str, int] = REQUIRED # type: ignore + """ + This field is required. + + :meta hide-value: + """ + + @require_kwargs_on_init @dataclass(frozen=True) class RippleState(BaseModel): @@ -223,6 +247,7 @@ class LedgerEntry(Request, LookupByLedgerRequest): directory: Optional[Union[str, Directory]] = None escrow: Optional[Union[str, Escrow]] = None offer: Optional[Union[str, Offer]] = None + oracle: Optional[Oracle] = None payment_channel: Optional[str] = None ripple_state: Optional[RippleState] = None ticket: Optional[Union[str, Ticket]] = None @@ -250,6 +275,7 @@ def _get_errors(self: LedgerEntry) -> Dict[str, str]: self.directory, self.escrow, self.offer, + self.oracle, self.payment_channel, self.ripple_state, self.ticket, diff --git a/xrpl/models/requests/request.py b/xrpl/models/requests/request.py index 07da47849..3ac63ddd5 100644 --- a/xrpl/models/requests/request.py +++ b/xrpl/models/requests/request.py @@ -79,6 +79,9 @@ class RequestMethod(str, Enum): # amm methods AMM_INFO = "amm_info" + # price oracle methods + GET_AGGREGATE_PRICE = "get_aggregate_price" + # generic unknown/unsupported request # (there is no XRPL analog, this model is specific to xrpl-py) GENERIC_REQUEST = "zzgeneric_request" diff --git a/xrpl/models/transactions/__init__.py b/xrpl/models/transactions/__init__.py index 498bd4832..ee3304f1b 100644 --- a/xrpl/models/transactions/__init__.py +++ b/xrpl/models/transactions/__init__.py @@ -54,6 +54,8 @@ OfferCreateFlag, OfferCreateFlagInterface, ) +from xrpl.models.transactions.oracle_delete import OracleDelete +from xrpl.models.transactions.oracle_set import OracleSet from xrpl.models.transactions.payment import Payment, PaymentFlag, PaymentFlagInterface from xrpl.models.transactions.payment_channel_claim import ( PaymentChannelClaim, @@ -131,6 +133,8 @@ "OfferCreate", "OfferCreateFlag", "OfferCreateFlagInterface", + "OracleDelete", + "OracleSet", "Payment", "PaymentChannelClaim", "PaymentChannelClaimFlag", diff --git a/xrpl/models/transactions/oracle_delete.py b/xrpl/models/transactions/oracle_delete.py new file mode 100644 index 000000000..1e8def6c0 --- /dev/null +++ b/xrpl/models/transactions/oracle_delete.py @@ -0,0 +1,27 @@ +"""Model for OracleDelete transaction type.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True) +class OracleDelete(Transaction): + """Delete an Oracle ledger entry.""" + + account: str = REQUIRED # type: ignore + """This account must match the account in the Owner field of the Oracle object.""" + + oracle_document_id: int = REQUIRED # type: ignore + """A unique identifier of the price oracle for the Account.""" + + transaction_type: TransactionType = field( + default=TransactionType.ORACLE_DELETE, + init=False, + ) diff --git a/xrpl/models/transactions/oracle_set.py b/xrpl/models/transactions/oracle_set.py new file mode 100644 index 000000000..91619ce5a --- /dev/null +++ b/xrpl/models/transactions/oracle_set.py @@ -0,0 +1,168 @@ +"""Model for OracleSet transaction type.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +from xrpl.models.nested_model import NestedModel +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import require_kwargs_on_init + +MAX_ORACLE_DATA_SERIES = 10 +MAX_ORACLE_PROVIDER = 256 +MAX_ORACLE_URI = 256 +MAX_ORACLE_SYMBOL_CLASS = 16 + +# epoch offset must equal 946684800 seconds. It represents the diff between the +# genesis of Unix time and Ripple-Epoch time +EPOCH_OFFSET = ( + datetime.datetime(2000, 1, 1) - datetime.datetime(1970, 1, 1) +).total_seconds() + + +@require_kwargs_on_init +@dataclass(frozen=True) +class OracleSet(Transaction): + """Creates a new Oracle ledger entry or updates the fields of an existing one, + using the Oracle ID. + + The oracle provider must complete these steps before submitting this transaction: + + Create or own the XRPL account in the Owner field and have enough XRP to meet the + reserve and transaction fee requirements. + Publish the XRPL account public key, so it can be used for verification by dApps. + Publish a registry of available price oracles with their unique OracleDocumentID . + """ + + account: str = REQUIRED # type: ignore + """This account must match the account in the Owner field of the Oracle object.""" + + oracle_document_id: int = REQUIRED # type: ignore + """A unique identifier of the price oracle for the Account.""" + + provider: Optional[str] = None + """ + This field must be hex-encoded. You can use `xrpl.utils.str_to_hex` to + convert a UTF-8 string to hex. + + An arbitrary value that identifies an oracle provider, such as Chainlink, Band, or + DIA. This field is a string, up to 256 ASCII hex encoded characters (0x20-0x7E). + This field is required when creating a new Oracle ledger entry, but is optional for + updates. + """ + + uri: Optional[str] = None + """ + This field must be hex-encoded. You can use `xrpl.utils.str_to_hex` to + convert a UTF-8 string to hex. + + An optional Universal Resource Identifier to reference price data off-chain. This + field is limited to 256 bytes. + """ + + asset_class: Optional[str] = None + """ + This field must be hex-encoded. You can use `xrpl.utils.str_to_hex` to + convert a UTF-8 string to hex. + + Describes the type of asset, such as "currency", "commodity", or "index". This + field is a string, up to 16 ASCII hex encoded characters (0x20-0x7E). This field is + required when creating a new Oracle ledger entry, but is optional for updates. + """ + + last_update_time: int = REQUIRED # type: ignore + """LastUpdateTime is the specific point in time when the data was last updated. + The LastUpdateTime is represented as Unix Time - the number of seconds since + January 1, 1970 (00:00 UTC).""" + + price_data_series: List[PriceData] = REQUIRED # type: ignore + """An array of up to 10 PriceData objects, each representing the price information + for a token pair. More than five PriceData objects require two owner reserves.""" + + transaction_type: TransactionType = field( + default=TransactionType.ORACLE_SET, + init=False, + ) + + def _get_errors(self: OracleSet) -> Dict[str, str]: + errors = super()._get_errors() + + # If price_data_series is not set, do not perform further validation + if "price_data_series" not in errors and len(self.price_data_series) == 0: + errors["price_data_series"] = "Field must have a length greater than 0." + + if ( + "price_data_series" not in errors + and len(self.price_data_series) > MAX_ORACLE_DATA_SERIES + ): + errors["price_data_series"] = ( + "Field must have a length less than" + f" or equal to {MAX_ORACLE_DATA_SERIES}" + ) + + if self.asset_class is not None and len(self.asset_class) == 0: + errors["asset_class"] = "Field must have a length greater than 0." + + if ( + self.asset_class is not None + and len(self.asset_class) > MAX_ORACLE_SYMBOL_CLASS + ): + errors["asset_class"] = ( + "Field must have a length less than" + f" or equal to {MAX_ORACLE_SYMBOL_CLASS}" + ) + + if self.provider is not None and len(self.provider) == 0: + errors["provider"] = "Field must have a length greater than 0." + + if self.provider is not None and len(self.provider) > MAX_ORACLE_PROVIDER: + errors[ + "provider" + ] = f"Field must have a length less than or equal to {MAX_ORACLE_PROVIDER}." + + if self.uri is not None and len(self.uri) == 0: + errors["uri"] = "Field must have a length greater than 0." + + if self.uri is not None and len(self.uri) > MAX_ORACLE_URI: + errors[ + "uri" + ] = f"Field must have a length less than or equal to {MAX_ORACLE_URI}." + + # check on the last_update_time + if self.last_update_time < EPOCH_OFFSET: + errors["last_update_time"] = ( + "LastUpdateTime must be greater than or equal" + f" to Ripple-Epoch {EPOCH_OFFSET} seconds" + ) + + return errors + + +@require_kwargs_on_init +@dataclass(frozen=True) +class PriceData(NestedModel): + """Represents one PriceData element. It is used in OracleSet transaction""" + + base_asset: str = REQUIRED # type: ignore + """The primary asset in a trading pair. Any valid identifier, such as a stock + symbol, bond CUSIP, or currency code is allowed. For example, in the BTC/USD pair, + BTC is the base asset; in 912810RR9/BTC, 912810RR9 is the base asset.""" + + quote_asset: str = REQUIRED # type: ignore + """The quote asset in a trading pair. The quote asset denotes the price of one unit + of the base asset. For example, in the BTC/USD pair, BTC is the base asset; in + 912810RR9/BTC, 912810RR9 is the base asset.""" + + asset_price: Optional[int] = None + """The asset price after applying the Scale precision level. It's not included if + the last update transaction didn't include the BaseAsset/QuoteAsset pair.""" + + scale: Optional[int] = None + """The scaling factor to apply to an asset price. For example, if Scale is 6 and + original price is 0.155, then the scaled price is 155000. Valid scale ranges are + 0-10. It's not included if the last update transaction didn't include the + BaseAsset/QuoteAsset pair.""" diff --git a/xrpl/models/transactions/types/transaction_type.py b/xrpl/models/transactions/types/transaction_type.py index 5520d6087..0d14e2775 100644 --- a/xrpl/models/transactions/types/transaction_type.py +++ b/xrpl/models/transactions/types/transaction_type.py @@ -31,6 +31,8 @@ class TransactionType(str, Enum): NFTOKEN_MINT = "NFTokenMint" OFFER_CANCEL = "OfferCancel" OFFER_CREATE = "OfferCreate" + ORACLE_SET = "OracleSet" + ORACLE_DELETE = "OracleDelete" PAYMENT = "Payment" PAYMENT_CHANNEL_CLAIM = "PaymentChannelClaim" PAYMENT_CHANNEL_CREATE = "PaymentChannelCreate"