diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index 818d6d800..af9203438 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -72,9 +72,6 @@ pool.ntp.org [ips] r.ripple.com 51235 -[validators_file] -validators.txt - [rpc_startup] { "command": "log_level", "severity": "info" } diff --git a/.github/workflows/publish_to_pypi.yml b/.github/workflows/publish_to_pypi.yml index d3b5bcb1f..9c2e783e5 100644 --- a/.github/workflows/publish_to_pypi.yml +++ b/.github/workflows/publish_to_pypi.yml @@ -3,6 +3,7 @@ on: push: tags: - '*' + workflow_dispatch: jobs: build: diff --git a/pyproject.toml b/pyproject.toml index 494996937..a05165105 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "xrpl-py" -version = "4.1.0" -description = "A complete Python library for interacting with the XRP ledger" +version = "4.2.0b3" +description = "A complete Python library for interacting with the XRP ledger (Smart Escrow beta)" license = "ISC" readme = "README.md" authors = [ diff --git a/tests/faucet/test_faucet_wallet.py b/tests/faucet/test_faucet_wallet.py index c9c42f4c5..c3b555c79 100644 --- a/tests/faucet/test_faucet_wallet.py +++ b/tests/faucet/test_faucet_wallet.py @@ -1,7 +1,11 @@ import asyncio import time from threading import Thread -from unittest import TestCase + +try: + from unittest import IsolatedAsyncioTestCase +except ImportError: + from aiounittest import AsyncTestCase as IsolatedAsyncioTestCase # type: ignore import httpx @@ -9,11 +13,9 @@ from xrpl.asyncio.clients import AsyncJsonRpcClient, AsyncWebsocketClient from xrpl.asyncio.wallet import generate_faucet_wallet from xrpl.clients import JsonRpcClient, WebsocketClient -from xrpl.core.addresscodec.main import classic_address_to_xaddress from xrpl.models.requests import AccountInfo from xrpl.models.transactions import Payment from xrpl.wallet import generate_faucet_wallet as sync_generate_faucet_wallet -from xrpl.wallet.main import Wallet # Add retry logic for wallet funding to handle newly introduced faucet rate limiting. MAX_RETRY_DURATION = 600 # 10 minutes @@ -107,8 +109,8 @@ async def generate_faucet_wallet_and_fund_again( self.assertTrue(new_balance > balance) -class TestWallet(TestCase): - def test_run_faucet_tests(self): +class TestWallet(IsolatedAsyncioTestCase): + async def test_run_faucet_tests(self): # run all the tests that start with `_test_` in parallel def run_test(test_name): with self.subTest(method=test_name): @@ -199,7 +201,8 @@ async def _parallel_test_generate_faucet_wallet_devnet_async_websockets(self): ) as client: await generate_faucet_wallet_and_fund_again(self, client) - def test_wallet_get_xaddress(self): - wallet = Wallet.create() - expected = classic_address_to_xaddress(wallet.address, None, False) - self.assertEqual(wallet.get_xaddress(), expected) + async def _parallel_test_generate_faucet_wallet_wasm_devnet_async_websockets(self): + async with AsyncWebsocketClient( + "wss://wasm.devnet.rippletest.net:51233" + ) as client: + await generate_faucet_wallet_and_fund_again(self, client) diff --git a/tests/integration/it_utils.py b/tests/integration/it_utils.py index 77cd6c72d..6e95c963e 100644 --- a/tests/integration/it_utils.py +++ b/tests/integration/it_utils.py @@ -221,7 +221,7 @@ async def sign_and_reliable_submission_async( def accept_ledger( - use_json_client: bool = True, delay: float = LEDGER_ACCEPT_TIME + use_json_client: bool = True, delay: float = LEDGER_ACCEPT_TIME, wait: bool = False ) -> None: """ Allows integration tests for sync clients to send a `ledger_accept` request @@ -232,11 +232,13 @@ def accept_ledger( delay: float for how many seconds to wait before accepting ledger. """ client = _choose_client(use_json_client) - SyncTestTimer(client, delay) + timer = SyncTestTimer(client, delay) + if wait: + timer._timer.join() # Wait for the timer to finish async def accept_ledger_async( - use_json_client: bool = True, delay: float = LEDGER_ACCEPT_TIME + use_json_client: bool = True, delay: float = LEDGER_ACCEPT_TIME, wait: bool = False ) -> None: """ Allows integration tests for async clients to send a `ledger_accept` request @@ -247,7 +249,9 @@ async def accept_ledger_async( delay: float for how many seconds to wait before accepting ledger. """ client = _choose_client_async(use_json_client) - AsyncTestTimer(client, delay) + timer = AsyncTestTimer(client, delay) + if wait: + await timer._job() def _choose_client(use_json_client: bool) -> SyncClient: diff --git a/tests/integration/transactions/test_escrow.py b/tests/integration/transactions/test_escrow.py new file mode 100644 index 000000000..ed4947903 --- /dev/null +++ b/tests/integration/transactions/test_escrow.py @@ -0,0 +1,133 @@ +from tests.integration.integration_test_case import IntegrationTestCase +from tests.integration.it_utils import ( + accept_ledger_async, + sign_and_reliable_submission_async, + test_async_and_sync, +) +from tests.integration.reusable_values import DESTINATION, WALLET +from xrpl.models import EscrowCancel, EscrowCreate, EscrowFinish, Ledger +from xrpl.models.response import ResponseStatus + +ACCOUNT = WALLET.address + +AMOUNT = "10000" +CONDITION = ( + "A0258020E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855810100" +) +DESTINATION_TAG = 23480 +SOURCE_TAG = 11747 + +FINISH_FUNCTION = ( + "0061736d010000000105016000017f02190108686f73745f6c69620c6765" + "744c656467657253716e00000302010005030100100611027f00418080c0" + "000b7f00418080c0000b072e04066d656d6f727902000666696e69736800" + "010a5f5f646174615f656e6403000b5f5f686561705f6261736503010a09" + "010700100041044a0b004d0970726f64756365727302086c616e67756167" + "65010452757374000c70726f6365737365642d6279010572757374631d31" + "2e38352e31202834656231363132353020323032352d30332d3135290049" + "0f7461726765745f6665617475726573042b0f6d757461626c652d676c6f" + "62616c732b087369676e2d6578742b0f7265666572656e63652d74797065" + "732b0a6d756c746976616c7565" +) + + +class TestEscrow(IntegrationTestCase): + @test_async_and_sync(globals()) + async def test_all_fields_cancel(self, client): + ledger = await client.request(Ledger(ledger_index="validated")) + close_time = ledger.result["ledger"]["close_time"] + escrow_create = EscrowCreate( + account=ACCOUNT, + amount=AMOUNT, + destination=DESTINATION.classic_address, + destination_tag=DESTINATION_TAG, + cancel_after=close_time + 3, + finish_after=close_time + 2, + source_tag=SOURCE_TAG, + ) + response = await sign_and_reliable_submission_async( + escrow_create, WALLET, client + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + sequence = response.result["tx_json"]["Sequence"] + # TODO: check account_objects + + for _ in range(3): + await accept_ledger_async(wait=True) + + escrow_cancel = EscrowCancel( + account=ACCOUNT, + owner=ACCOUNT, + offer_sequence=sequence, + ) + response = await sign_and_reliable_submission_async( + escrow_cancel, WALLET, client + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + @test_async_and_sync(globals()) + async def test_all_fields_finish(self, client): + ledger = await client.request(Ledger(ledger_index="validated")) + close_time = ledger.result["ledger"]["close_time"] + escrow_create = EscrowCreate( + account=ACCOUNT, + amount=AMOUNT, + destination=DESTINATION.classic_address, + destination_tag=DESTINATION_TAG, + finish_after=close_time + 2, + source_tag=SOURCE_TAG, + ) + response = await sign_and_reliable_submission_async( + escrow_create, WALLET, client + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + sequence = response.result["tx_json"]["Sequence"] + # TODO: check account_objects + + for _ in range(2): + await accept_ledger_async(wait=True) + + escrow_finish = EscrowFinish( + account=ACCOUNT, + owner=ACCOUNT, + offer_sequence=sequence, + ) + response = await sign_and_reliable_submission_async( + escrow_finish, WALLET, client + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + @test_async_and_sync(globals()) + async def test_finish_function(self, client): + ledger = await client.request(Ledger(ledger_index="validated")) + close_time = ledger.result["ledger"]["close_time"] + escrow_create = EscrowCreate( + account=ACCOUNT, + amount=AMOUNT, + destination=DESTINATION.classic_address, + finish_function=FINISH_FUNCTION, + cancel_after=close_time + 200, + ) + response = await sign_and_reliable_submission_async( + escrow_create, WALLET, client + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + sequence = response.result["tx_json"]["Sequence"] + # TODO: check account_objects + + escrow_finish = EscrowFinish( + account=ACCOUNT, + owner=ACCOUNT, + offer_sequence=sequence, + computation_allowance=5, + ) + response = await sign_and_reliable_submission_async( + escrow_finish, WALLET, client + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") diff --git a/tests/integration/transactions/test_escrow_cancel.py b/tests/integration/transactions/test_escrow_cancel.py deleted file mode 100644 index 42cae81d6..000000000 --- a/tests/integration/transactions/test_escrow_cancel.py +++ /dev/null @@ -1,27 +0,0 @@ -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.response import ResponseStatus -from xrpl.models.transactions import EscrowCancel - -ACCOUNT = WALLET.address -OWNER = "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" -OFFER_SEQUENCE = 7 - - -class TestEscrowCancel(IntegrationTestCase): - @test_async_and_sync(globals()) - async def test_all_fields(self, client): - escrow_cancel = EscrowCancel( - account=ACCOUNT, - owner=OWNER, - offer_sequence=OFFER_SEQUENCE, - ) - response = await sign_and_reliable_submission_async( - escrow_cancel, WALLET, client - ) - # Actual engine_result is `tecNO_TARGET since OWNER account doesn't exist - self.assertEqual(response.status, ResponseStatus.SUCCESS) diff --git a/tests/integration/transactions/test_escrow_create.py b/tests/integration/transactions/test_escrow_create.py deleted file mode 100644 index 4e1df99ac..000000000 --- a/tests/integration/transactions/test_escrow_create.py +++ /dev/null @@ -1,38 +0,0 @@ -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 DESTINATION, WALLET -from xrpl.models import EscrowCreate, Ledger -from xrpl.models.response import ResponseStatus - -ACCOUNT = WALLET.address - -AMOUNT = "10000" -CONDITION = ( - "A0258020E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855810100" -) -DESTINATION_TAG = 23480 -SOURCE_TAG = 11747 - - -class TestEscrowCreate(IntegrationTestCase): - @test_async_and_sync(globals()) - async def test_all_fields(self, client): - ledger = await client.request(Ledger(ledger_index="validated")) - close_time = ledger.result["ledger"]["close_time"] - escrow_create = EscrowCreate( - account=WALLET.classic_address, - amount=AMOUNT, - destination=DESTINATION.classic_address, - destination_tag=DESTINATION_TAG, - cancel_after=close_time + 3, - finish_after=close_time + 2, - source_tag=SOURCE_TAG, - ) - response = await sign_and_reliable_submission_async( - escrow_create, WALLET, client - ) - self.assertEqual(response.status, ResponseStatus.SUCCESS) - self.assertEqual(response.result["engine_result"], "tesSUCCESS") diff --git a/tests/integration/transactions/test_escrow_finish.py b/tests/integration/transactions/test_escrow_finish.py deleted file mode 100644 index a415cb3fa..000000000 --- a/tests/integration/transactions/test_escrow_finish.py +++ /dev/null @@ -1,38 +0,0 @@ -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.response import ResponseStatus -from xrpl.models.transactions import EscrowFinish - -# Special fee for EscrowFinish transactions that contain a fulfillment. -# See note here: https://xrpl.org/escrowfinish.html -FEE = "600000000" - -ACCOUNT = WALLET.address -OWNER = "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" -OFFER_SEQUENCE = 7 -CONDITION = ( - "A0258020E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855810100" -) -FULFILLMENT = "A0028000" - - -class TestEscrowFinish(IntegrationTestCase): - @test_async_and_sync(globals()) - async def test_all_fields(self, client): - escrow_finish = EscrowFinish( - account=ACCOUNT, - owner=OWNER, - offer_sequence=OFFER_SEQUENCE, - condition=CONDITION, - fulfillment=FULFILLMENT, - ) - response = await sign_and_reliable_submission_async( - escrow_finish, WALLET, client - ) - # Actual engine_result will be 'tecNO_TARGET' since using non-extant - # account for OWNER - self.assertEqual(response.status, ResponseStatus.SUCCESS) diff --git a/tests/unit/asyn/wallet/test_wallet.py b/tests/unit/asyn/wallet/test_wallet.py index 19114aacd..c2501417e 100644 --- a/tests/unit/asyn/wallet/test_wallet.py +++ b/tests/unit/asyn/wallet/test_wallet.py @@ -1,8 +1,9 @@ from unittest import TestCase from xrpl.asyncio.wallet.wallet_generation import ( - _DEV_FAUCET_URL, - _TEST_FAUCET_URL, + _DEVNET_FAUCET_URL, + _TESTNET_FAUCET_URL, + _WASM_DEVNET_FAUCET_URL, XRPLFaucetException, get_faucet_url, process_faucet_host_url, @@ -18,8 +19,9 @@ def test_wallet_get_xaddress(self): self.assertEqual(wallet.get_xaddress(), expected) def test_get_faucet_wallet_valid(self): - self.assertEqual(get_faucet_url(1), _TEST_FAUCET_URL) - self.assertEqual(get_faucet_url(2), _DEV_FAUCET_URL) + self.assertEqual(get_faucet_url(1), _TESTNET_FAUCET_URL) + self.assertEqual(get_faucet_url(2), _DEVNET_FAUCET_URL) + self.assertEqual(get_faucet_url(2002), _WASM_DEVNET_FAUCET_URL) def test_get_faucet_wallet_invalid(self): with self.assertRaises(XRPLFaucetException): diff --git a/tests/unit/models/transactions/test_escrow_create.py b/tests/unit/models/transactions/test_escrow_create.py index d7f301a7a..5dd21eccd 100644 --- a/tests/unit/models/transactions/test_escrow_create.py +++ b/tests/unit/models/transactions/test_escrow_create.py @@ -5,14 +5,34 @@ class TestEscrowCreate(TestCase): + def test_all_fields_valid(self): + account = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" + amount = "amount" + cancel_after = 3 + destination = "destination" + destination_tag = 1 + finish_after = 2 + finish_function = "abcdef" + condition = "abcdef" + + escrow_create = EscrowCreate( + account=account, + amount=amount, + destination=destination, + destination_tag=destination_tag, + cancel_after=cancel_after, + finish_after=finish_after, + finish_function=finish_function, + condition=condition, + ) + self.assertTrue(escrow_create.is_valid()) + def test_final_after_less_than_cancel_after(self): account = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" amount = "amount" cancel_after = 1 finish_after = 2 destination = "destination" - fee = "0.00001" - sequence = 19048 with self.assertRaises(XRPLModelException): EscrowCreate( @@ -20,7 +40,21 @@ def test_final_after_less_than_cancel_after(self): amount=amount, cancel_after=cancel_after, destination=destination, - fee=fee, finish_after=finish_after, - sequence=sequence, + ) + + def test_no_finish(self): + account = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" + amount = "amount" + cancel_after = 1 + destination = "destination" + destination_tag = 1 + + with self.assertRaises(XRPLModelException): + EscrowCreate( + account=account, + amount=amount, + destination=destination, + destination_tag=destination_tag, + cancel_after=cancel_after, ) diff --git a/xrpl/asyncio/transaction/main.py b/xrpl/asyncio/transaction/main.py index 4e2dc5346..d1e7a4ccd 100644 --- a/xrpl/asyncio/transaction/main.py +++ b/xrpl/asyncio/transaction/main.py @@ -23,6 +23,7 @@ Transaction, TransactionFlag, ) +from xrpl.models.transactions.escrow_create import EscrowCreate from xrpl.models.transactions.transaction import ( transaction_json_to_binary_codec_form as model_transaction_to_binary_codec, ) @@ -38,6 +39,7 @@ # More context: https://github.com/XRPLF/rippled/pull/4370 _RESTRICTED_NETWORKS = 1024 _REQUIRED_NETWORKID_VERSION = "1.11.0" +_MICRO_DROPS_PER_DROP = 1_000_000 T = TypeVar("T", bound=Transaction, default=Transaction) @@ -499,6 +501,16 @@ async def _calculate_fee_per_transaction_type( fulfillment_bytes = escrow_finish.fulfillment.encode("ascii") # BaseFee × (33 + (Fulfillment size in bytes / 16)) base_fee = math.ceil(net_fee * (33 + (len(fulfillment_bytes) / 16))) + if escrow_finish.computation_allowance is not None: + gas_price = await _fetch_gas_price(client) + base_fee += math.ceil( + gas_price * escrow_finish.computation_allowance / _MICRO_DROPS_PER_DROP + ) + + if transaction.transaction_type == TransactionType.ESCROW_CREATE: + escrow_create = cast(EscrowCreate, transaction) + if escrow_create.finish_function is not None: + base_fee += 1000 # AccountDelete Transaction elif transaction.transaction_type in ( @@ -591,3 +603,9 @@ def _validate_field(field_name: str, expected_value: Union[str, None]) -> None: inner_txs.append(raw_txn_dict) return inner_txs + + +async def _fetch_gas_price(client: Client) -> int: + server_state = await client._request_impl(ServerState()) + fee = server_state.result["state"]["validated_ledger"]["gas_price"] + return int(fee) diff --git a/xrpl/asyncio/wallet/wallet_generation.py b/xrpl/asyncio/wallet/wallet_generation.py index c5bf7418c..eb6f10a29 100644 --- a/xrpl/asyncio/wallet/wallet_generation.py +++ b/xrpl/asyncio/wallet/wallet_generation.py @@ -13,11 +13,19 @@ from xrpl.constants import XRPLException from xrpl.wallet.main import Wallet -_TEST_FAUCET_URL: Final[str] = "https://faucet.altnet.rippletest.net/accounts" -_DEV_FAUCET_URL: Final[str] = "https://faucet.devnet.rippletest.net/accounts" +_TESTNET_FAUCET_URL: Final[str] = "https://faucet.altnet.rippletest.net/accounts" +_DEVNET_FAUCET_URL: Final[str] = "https://faucet.devnet.rippletest.net/accounts" +_WASM_DEVNET_FAUCET_URL: Final[str] = ( + "https://wasmfaucet.devnet.rippletest.net/accounts" +) + _TIMEOUT_SECONDS: Final[int] = 40 -_NETWORK_ID_URL_MAP: Dict[int, str] = {1: _TEST_FAUCET_URL, 2: _DEV_FAUCET_URL} +_NETWORK_ID_URL_MAP: Dict[int, str] = { + 1: _TESTNET_FAUCET_URL, + 2: _DEVNET_FAUCET_URL, + 2002: _WASM_DEVNET_FAUCET_URL, +} class XRPLFaucetException(XRPLException): @@ -35,7 +43,7 @@ async def generate_faucet_wallet( user_agent: Optional[str] = "xrpl-py", ) -> Wallet: """ - Generates a random wallet and funds it using the XRPL Testnet Faucet. + Generates a random wallet and funds it using an XRPL Testnet Faucet. Args: client: the network client used to make network calls. @@ -220,7 +228,7 @@ async def _request_funding( json_body = {"destination": address, "userAgent": user_agent} if usage_context is not None: json_body["usageContext"] = usage_context - response = await http_client.post(url=url, json=json_body) + response = await http_client.post(url=url, json=json_body, timeout=10) if not response.status_code == httpx.codes.OK: response.raise_for_status() diff --git a/xrpl/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index 4899a4df0..cc34c5139 100644 --- a/xrpl/core/binarycodec/definitions/definitions.json +++ b/xrpl/core/binarycodec/definitions/definitions.json @@ -680,6 +680,56 @@ "type": "UInt32" } ], + [ + "ExtensionComputeLimit", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 53, + "type": "UInt32" + } + ], + [ + "ExtensionSizeLimit", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 54, + "type": "UInt32" + } + ], + [ + "GasPrice", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 55, + "type": "UInt32" + } + ], + [ + "ComputationAllowance", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 56, + "type": "UInt32" + } + ], + [ + "GasUsed", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 57, + "type": "UInt32" + } + ], [ "IndexNext", { @@ -1890,6 +1940,16 @@ "type": "Blob" } ], + [ + "FinishFunction", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 32, + "type": "Blob" + } + ], [ "Account", { @@ -3159,6 +3219,7 @@ "tecUNFUNDED_AMM": 162, "tecUNFUNDED_OFFER": 103, "tecUNFUNDED_PAYMENT": 104, + "tecWASM_REJECTED": 199, "tecWRONG_ASSET": 194, "tecXCHAIN_ACCOUNT_CREATE_PAST": 181, "tecXCHAIN_ACCOUNT_CREATE_TOO_MANY": 182, @@ -3197,8 +3258,10 @@ "tefNOT_MULTI_SIGNING": -184, "tefNO_AUTH_REQUIRED": -191, "tefNO_TICKET": -180, + "tefNO_WASM": -177, "tefPAST_SEQ": -190, "tefTOO_BIG": -181, + "tefWASM_FIELD_NOT_INCLUDED": -176, "tefWRONG_PRIOR": -189, "telBAD_DOMAIN": -398, diff --git a/xrpl/models/transactions/escrow_create.py b/xrpl/models/transactions/escrow_create.py index 3d564ed53..0ed8a85ab 100644 --- a/xrpl/models/transactions/escrow_create.py +++ b/xrpl/models/transactions/escrow_create.py @@ -67,6 +67,10 @@ class EscrowCreate(Transaction): fulfilled. """ + finish_function: Optional[str] = None + + data: Optional[str] = None + transaction_type: TransactionType = field( default=TransactionType.ESCROW_CREATE, init=False, @@ -82,5 +86,14 @@ def _get_errors(self: Self) -> Dict[str, str]: errors["EscrowCreate"] = ( "The finish_after time must be before the cancel_after time." ) + if ( + self.finish_after is None + and self.condition is None + and self.finish_function is None + ): + errors["EscrowCreate"] = ( + "At least one of finish_after, condition, or finish_function must be " + "set." + ) return errors diff --git a/xrpl/models/transactions/escrow_finish.py b/xrpl/models/transactions/escrow_finish.py index 07bb87fa4..93176700f 100644 --- a/xrpl/models/transactions/escrow_finish.py +++ b/xrpl/models/transactions/escrow_finish.py @@ -63,6 +63,8 @@ class EscrowFinish(Transaction): """Credentials associated with sender of this transaction. The credentials included must not be expired.""" + computation_allowance: Optional[int] = None + def _get_errors(self: Self) -> Dict[str, str]: errors = super()._get_errors() if self.condition and not self.fulfillment: