diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index 9c4721e93..15c7d8737 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -206,6 +206,7 @@ Batch TokenEscrow LendingProtocol PermissionDelegationV1_1 +Sponsor # This section can be used to simulate various FeeSettings scenarios for rippled node in standalone mode [voting] diff --git a/CHANGELOG.md b/CHANGELOG.md index c80c07f55..af2bd5e67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dropped support for Python 3.8 (EOL October 2024). The minimum supported Python version is now 3.9. +### Added + +- Added support for the XLS-68d Sponsored-Fees-Reserves amendment + ### Fixed - Fixed correct mapping of `sfMutableFlags`, `sfStartDate`, and `sfPreviousPaymentDueDate` fields in the binary codec `definitions.json`. diff --git a/tests/integration/reqs/test_account_objects_sponsored.py b/tests/integration/reqs/test_account_objects_sponsored.py new file mode 100644 index 000000000..8762d814d --- /dev/null +++ b/tests/integration/reqs/test_account_objects_sponsored.py @@ -0,0 +1,99 @@ +"""Integration tests for AccountObjects request with sponsored field and new +AccountObjectType values (XLS-68 sponsored fees).""" + +from tests.integration.integration_test_case import IntegrationTestCase +from tests.integration.it_utils import ( + fund_wallet_async, + sign_and_reliable_submission_async, + test_async_and_sync, +) +from xrpl.models import AccountObjects, AccountObjectType, SponsorshipSet +from xrpl.models.response import ResponseStatus +from xrpl.wallet import Wallet + + +class TestAccountObjectsSponsored(IntegrationTestCase): + @test_async_and_sync(globals()) + async def test_sponsored_field_true(self, client): + """Test that the sponsored=True filter returns only sponsored objects.""" + sponsor_wallet = Wallet.create() + sponsee_wallet = Wallet.create() + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + + # Create a sponsorship + tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + ) + response = await sign_and_reliable_submission_async(tx, sponsor_wallet, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Query with sponsored=True on the sponsee's account + account_objects_response = await client.request( + AccountObjects( + account=sponsee_wallet.address, + sponsored=True, + ) + ) + self.assertTrue(account_objects_response.is_successful()) + + @test_async_and_sync(globals()) + async def test_sponsored_field_false(self, client): + """Test that the sponsored=False filter returns only non-sponsored objects.""" + wallet = Wallet.create() + await fund_wallet_async(wallet) + + # Query with sponsored=False + account_objects_response = await client.request( + AccountObjects( + account=wallet.address, + sponsored=False, + ) + ) + self.assertTrue(account_objects_response.is_successful()) + + @test_async_and_sync(globals()) + async def test_sponsored_field_none(self, client): + """Test that omitting sponsored returns all objects (default behavior).""" + wallet = Wallet.create() + await fund_wallet_async(wallet) + + # Query without sponsored field (default None) + account_objects_response = await client.request( + AccountObjects( + account=wallet.address, + ) + ) + self.assertTrue(account_objects_response.is_successful()) + + @test_async_and_sync(globals()) + async def test_type_sponsorship_filter(self, client): + """Test filtering account_objects by type=SPONSORSHIP.""" + sponsor_wallet = Wallet.create() + sponsee_wallet = Wallet.create() + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + + # Create a sponsorship + tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + ) + response = await sign_and_reliable_submission_async(tx, sponsor_wallet, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Filter by SPONSORSHIP type on the sponsor's account + account_objects_response = await client.request( + AccountObjects( + account=sponsor_wallet.address, + type=AccountObjectType.SPONSORSHIP, + ) + ) + self.assertTrue(account_objects_response.is_successful()) + sponsorship_objects = account_objects_response.result["account_objects"] + self.assertGreater(len(sponsorship_objects), 0) + for obj in sponsorship_objects: + self.assertEqual(obj["LedgerEntryType"], "Sponsorship") diff --git a/tests/integration/reqs/test_ledger_entry_sponsorship.py b/tests/integration/reqs/test_ledger_entry_sponsorship.py new file mode 100644 index 000000000..d63e97988 --- /dev/null +++ b/tests/integration/reqs/test_ledger_entry_sponsorship.py @@ -0,0 +1,49 @@ +"""Integration tests for LedgerEntry request with Sponsorship (XLS-68).""" + +from tests.integration.integration_test_case import IntegrationTestCase +from tests.integration.it_utils import ( + fund_wallet_async, + sign_and_reliable_submission_async, + test_async_and_sync, +) +from xrpl.models import SponsorshipSet +from xrpl.models.requests.ledger_entry import LedgerEntry, Sponsorship +from xrpl.models.response import ResponseStatus +from xrpl.wallet import Wallet + + +class TestLedgerEntrySponsorship(IntegrationTestCase): + @test_async_and_sync(globals()) + async def test_ledger_entry_sponsorship_by_owner_and_sponsee(self, client): + """Query a Sponsorship ledger entry by owner + sponsee.""" + sponsor_wallet = Wallet.create() + sponsee_wallet = Wallet.create() + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + + # Create a sponsorship object. + tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + ) + response = await sign_and_reliable_submission_async(tx, sponsor_wallet, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Query via LedgerEntry with Sponsorship(owner, sponsee). + ledger_response = await client.request( + LedgerEntry( + sponsorship=Sponsorship( + sponsor=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + ) + ) + ) + self.assertTrue( + ledger_response.is_successful(), + f"LedgerEntry failed: {ledger_response.result}", + ) + node = ledger_response.result["node"] + self.assertEqual(node["LedgerEntryType"], "Sponsorship") + self.assertEqual(node["Owner"], sponsor_wallet.address) + self.assertEqual(node["Sponsee"], sponsee_wallet.address) diff --git a/tests/integration/transactions/test_account_delete.py b/tests/integration/transactions/test_account_delete.py index 63dff7bcf..2bcd6aa2d 100644 --- a/tests/integration/transactions/test_account_delete.py +++ b/tests/integration/transactions/test_account_delete.py @@ -1,43 +1,94 @@ +"""Integration test for sponsored AccountDelete (XLS-68 §12). + +Per §12, when a sponsored account is deleted: +- Destination must equal AccountRoot.Sponsor +- Remaining XRP transfers to the sponsor +- Sponsor's SponsoringAccountCount decrements +""" + from tests.integration.integration_test_case import IntegrationTestCase from tests.integration.it_utils import ( + LEDGER_ACCEPT_REQUEST, + fund_wallet_async, sign_and_reliable_submission_async, test_async_and_sync, ) -from tests.integration.reusable_values import DESTINATION, WALLET +from xrpl.asyncio.transaction import autofill, sign, submit +from xrpl.core.binarycodec import encode_for_signing +from xrpl.core.keypairs import sign as keypairs_sign +from xrpl.models import SponsorshipTransfer from xrpl.models.response import ResponseStatus from xrpl.models.transactions import AccountDelete +from xrpl.models.transactions.sponsorship_transfer import SponsorshipTransferFlag from xrpl.utils import xrp_to_drops +from xrpl.wallet import Wallet -# We can re-use the shared wallet bc this test should fail to actually delete -# the associated account. -ACCOUNT = WALLET.address - -# AccountDelete transactions have a special fee. +# AccountDelete requires a special fee of 5 XRP. # See https://xrpl.org/accountdelete.html#special-transaction-cost. -FEE = xrp_to_drops(5) -DESTINATION_TAG = 3 - - -class TestAccountDelete(IntegrationTestCase): - @test_async_and_sync(globals()) - async def test_all_fields(self, client): - account_delete = AccountDelete( - account=ACCOUNT, - fee=FEE, - destination=DESTINATION.address, - destination_tag=DESTINATION_TAG, +ACCOUNT_DELETE_FEE = xrp_to_drops(5) + +# AccountDelete requires current_ledger_index >= account_sequence + 256. +_LEDGERS_TO_ADVANCE = 260 + + +def _build_sponsor_signed_tx(transfer_tx, sponsee_wallet, sponsor_wallet): + """Sign a SponsorshipTransfer as the sponsee, then co-sign.""" + signed_tx = sign(transfer_tx, sponsee_wallet) + tx_json = signed_tx.to_xrpl() + sponsor_sig = keypairs_sign( + bytes.fromhex(encode_for_signing(tx_json)), + sponsor_wallet.private_key, + ) + tx_json["SponsorSignature"] = { + "SigningPubKey": sponsor_wallet.public_key, + "TxnSignature": sponsor_sig, + } + return SponsorshipTransfer.from_xrpl(tx_json) + + +class TestAccountDeleteSponsored(IntegrationTestCase): + + @test_async_and_sync( + globals(), + ["xrpl.transaction.autofill", "xrpl.transaction.submit"], + ) + async def test_sponsored_account_delete(self, client): + """Sponsored account deletes itself; destination = sponsor.""" + sponsor_wallet = Wallet.create() + sponsee_wallet = Wallet.create() + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + + # Step 1: Create account-level sponsorship (sponsor -> sponsee). + create_tx = SponsorshipTransfer( + account=sponsee_wallet.address, + flags=SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE, + sponsor_flags=2, + sponsor=sponsor_wallet.address, + ) + create_tx = await autofill(create_tx, client) + final_create_tx = _build_sponsor_signed_tx( + create_tx, sponsee_wallet, sponsor_wallet + ) + create_response = await submit(final_create_tx, client) + await client.request(LEDGER_ACCEPT_REQUEST) + self.assertEqual(create_response.status, ResponseStatus.SUCCESS) + self.assertEqual(create_response.result["engine_result"], "tesSUCCESS") + + # Step 2: Advance enough ledgers so the sponsee account + # satisfies the AccountDelete sequence requirement. + for _ in range(_LEDGERS_TO_ADVANCE): + await client.request(LEDGER_ACCEPT_REQUEST) + + # Step 3: Submit AccountDelete from the sponsee. + # Per XLS-68 §12, destination must be the sponsor. + delete_tx = AccountDelete( + account=sponsee_wallet.address, + destination=sponsor_wallet.address, + fee=ACCOUNT_DELETE_FEE, ) - response = await sign_and_reliable_submission_async( - account_delete, WALLET, client, check_fee=False + delete_response = await sign_and_reliable_submission_async( + delete_tx, sponsee_wallet, client, check_fee=False ) - self.assertEqual(response.status, ResponseStatus.SUCCESS) - - # Note, we can't test the `engine_result` without waiting a significant - # amount of time because accounts can't be deleted until some number of - # ledgers have closed since its creation. - # - # The documentation for `tecTOO_SOON` reads: - # "The AccountDelete transaction failed because the account to be deleted had a - # Sequence number that is too high. The current ledger index must be at least - # 256 higher than the account's sequence number." - # self.assertEqual(response.result['engine_result'], 'tesSUCCESS') + self.assertEqual(delete_response.status, ResponseStatus.SUCCESS) + self.assertEqual(delete_response.result["engine_result"], "tesSUCCESS") diff --git a/tests/integration/transactions/test_account_set.py b/tests/integration/transactions/test_account_set.py index 2d1b67cd8..fa8687cf5 100644 --- a/tests/integration/transactions/test_account_set.py +++ b/tests/integration/transactions/test_account_set.py @@ -1,11 +1,15 @@ from tests.integration.integration_test_case import IntegrationTestCase from tests.integration.it_utils import ( + fund_wallet_async, sign_and_reliable_submission_async, test_async_and_sync, ) from tests.integration.reusable_values import WALLET +from xrpl.models import SponsorshipSet from xrpl.models.response import ResponseStatus from xrpl.models.transactions import AccountSet +from xrpl.models.transactions.account_set import AccountSetAsfFlag +from xrpl.wallet import Wallet ACCOUNT = WALLET.address @@ -43,3 +47,65 @@ async def test_all_fields_minus_set_flag(self, client): response = await sign_and_reliable_submission_async(account_set, WALLET, client) self.assertEqual(response.status, ResponseStatus.SUCCESS) self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + @test_async_and_sync(globals()) + async def test_set_disallow_incoming_sponsor(self, client): + """Setting ASF_DISALLOW_INCOMING_SPONSOR blocks new sponsorships.""" + sponsee_wallet = Wallet.create() + sponsor_wallet = Wallet.create() + await fund_wallet_async(sponsee_wallet) + await fund_wallet_async(sponsor_wallet) + + # Set the flag on the sponsee account. + set_tx = AccountSet( + account=sponsee_wallet.address, + set_flag=AccountSetAsfFlag.ASF_DISALLOW_INCOMING_SPONSOR, + ) + set_resp = await sign_and_reliable_submission_async( + set_tx, sponsee_wallet, client + ) + self.assertEqual(set_resp.result["engine_result"], "tesSUCCESS") + + # Attempt to create a sponsorship targeting the sponsee. + sponsor_tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + ) + sponsor_resp = await sign_and_reliable_submission_async( + sponsor_tx, sponsor_wallet, client + ) + self.assertEqual(sponsor_resp.result["engine_result"], "tecNO_PERMISSION") + + @test_async_and_sync(globals()) + async def test_clear_disallow_incoming_sponsor(self, client): + """Clearing ASF_DISALLOW_INCOMING_SPONSOR allows sponsorships.""" + sponsee_wallet = Wallet.create() + sponsor_wallet = Wallet.create() + await fund_wallet_async(sponsee_wallet) + await fund_wallet_async(sponsor_wallet) + + # Set then clear the flag. + set_tx = AccountSet( + account=sponsee_wallet.address, + set_flag=AccountSetAsfFlag.ASF_DISALLOW_INCOMING_SPONSOR, + ) + await sign_and_reliable_submission_async(set_tx, sponsee_wallet, client) + + clear_tx = AccountSet( + account=sponsee_wallet.address, + clear_flag=AccountSetAsfFlag.ASF_DISALLOW_INCOMING_SPONSOR, + ) + clear_resp = await sign_and_reliable_submission_async( + clear_tx, sponsee_wallet, client + ) + self.assertEqual(clear_resp.result["engine_result"], "tesSUCCESS") + + # Now creating a sponsorship should succeed. + sponsor_tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + ) + sponsor_resp = await sign_and_reliable_submission_async( + sponsor_tx, sponsor_wallet, client + ) + self.assertEqual(sponsor_resp.result["engine_result"], "tesSUCCESS") diff --git a/tests/integration/transactions/test_sponsor_permissions.py b/tests/integration/transactions/test_sponsor_permissions.py new file mode 100644 index 000000000..571b7f2e0 --- /dev/null +++ b/tests/integration/transactions/test_sponsor_permissions.py @@ -0,0 +1,227 @@ +"""Integration tests verifying SponsorFee/SponsorReserve wire values (65549/65550).""" + +from tests.integration.integration_test_case import IntegrationTestCase +from tests.integration.it_utils import ( + fund_wallet_async, + sign_and_reliable_submission_async, + test_async_and_sync, +) +from xrpl.core.binarycodec import decode, encode +from xrpl.core.binarycodec.definitions.definitions import ( + _DELEGABLE_PERMISSIONS_CODE_TO_STR_MAP, + _DELEGABLE_PERMISSIONS_STR_TO_CODE_MAP, +) +from xrpl.models.requests import AccountObjects, AccountObjectType, LedgerEntry +from xrpl.models.requests.ledger_entry import Delegate +from xrpl.models.response import ResponseStatus +from xrpl.models.transactions import DelegateSet +from xrpl.models.transactions.delegate_set import GranularPermission, Permission +from xrpl.wallet.main import Wallet + +_SPONSOR_FEE_WIRE = 65549 +_SPONSOR_RESERVE_WIRE = 65550 + + +class TestSponsorPermissionsWireValues(IntegrationTestCase): + # ------------------------------------------------------------------ # + # Codec-level (no network) — verify 65549 / 65550 round-trip # + # ------------------------------------------------------------------ # + + def test_sponsor_fee_wire_value_in_definitions(self): + """SponsorFee must map to numeric wire value 65549.""" + self.assertEqual( + _DELEGABLE_PERMISSIONS_STR_TO_CODE_MAP["SponsorFee"], + _SPONSOR_FEE_WIRE, + ) + + def test_sponsor_reserve_wire_value_in_definitions(self): + """SponsorReserve must map to numeric wire value 65550.""" + self.assertEqual( + _DELEGABLE_PERMISSIONS_STR_TO_CODE_MAP["SponsorReserve"], + _SPONSOR_RESERVE_WIRE, + ) + + def test_sponsor_fee_reverse_lookup(self): + """Numeric code 65549 must decode back to 'SponsorFee'.""" + self.assertEqual( + _DELEGABLE_PERMISSIONS_CODE_TO_STR_MAP[_SPONSOR_FEE_WIRE], + "SponsorFee", + ) + + def test_sponsor_reserve_reverse_lookup(self): + """Numeric code 65550 must decode back to 'SponsorReserve'.""" + self.assertEqual( + _DELEGABLE_PERMISSIONS_CODE_TO_STR_MAP[_SPONSOR_RESERVE_WIRE], + "SponsorReserve", + ) + + def test_sponsor_fee_binary_codec_roundtrip(self): + """SponsorFee encodes to binary and decodes back via the codec.""" + tx = DelegateSet( + account="r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59", + authorize="rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + permissions=[Permission(permission_value=GranularPermission.SPONSOR_FEE)], + sequence=1, + fee="12", + ) + decoded = decode(encode(tx.to_xrpl())) + perm_value = decoded["Permissions"][0]["Permission"]["PermissionValue"] + self.assertEqual(perm_value, "SponsorFee") + + def test_sponsor_reserve_binary_codec_roundtrip(self): + """SponsorReserve encodes to binary and decodes back via the codec.""" + tx = DelegateSet( + account="r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59", + authorize="rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + permissions=[ + Permission(permission_value=GranularPermission.SPONSOR_RESERVE) + ], + sequence=1, + fee="12", + ) + decoded = decode(encode(tx.to_xrpl())) + perm_value = decoded["Permissions"][0]["Permission"]["PermissionValue"] + self.assertEqual(perm_value, "SponsorReserve") + + def test_both_sponsor_permissions_binary_codec_roundtrip(self): + """Both sponsor permissions encode/decode with correct wire values.""" + tx = DelegateSet( + account="r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59", + authorize="rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + permissions=[ + Permission(permission_value=GranularPermission.SPONSOR_FEE), + Permission(permission_value=GranularPermission.SPONSOR_RESERVE), + ], + sequence=1, + fee="12", + ) + decoded = decode(encode(tx.to_xrpl())) + perm_values = { + p["Permission"]["PermissionValue"] for p in decoded["Permissions"] + } + self.assertIn("SponsorFee", perm_values) + self.assertIn("SponsorReserve", perm_values) + + def test_sponsor_fee_enum_value_matches_wire(self): + """GranularPermission.SPONSOR_FEE string value maps to wire code 65549.""" + self.assertEqual( + _DELEGABLE_PERMISSIONS_STR_TO_CODE_MAP[GranularPermission.SPONSOR_FEE], + _SPONSOR_FEE_WIRE, + ) + + def test_sponsor_reserve_enum_value_matches_wire(self): + """GranularPermission.SPONSOR_RESERVE string value maps to wire code 65550.""" + self.assertEqual( + _DELEGABLE_PERMISSIONS_STR_TO_CODE_MAP[GranularPermission.SPONSOR_RESERVE], + _SPONSOR_RESERVE_WIRE, + ) + + # ------------------------------------------------------------------ # + # Network — DelegateSet with sponsor permissions accepted by rippled # + # ------------------------------------------------------------------ # + + @test_async_and_sync(globals()) + async def test_delegate_set_sponsor_fee_accepted(self, client): + """DelegateSet with SponsorFee is accepted by rippled (wire value correct).""" + alice = Wallet.create() + await fund_wallet_async(alice) + bob = Wallet.create() + await fund_wallet_async(bob) + + tx = DelegateSet( + account=alice.address, + authorize=bob.address, + permissions=[Permission(permission_value=GranularPermission.SPONSOR_FEE)], + ) + response = await sign_and_reliable_submission_async( + tx, alice, client, check_fee=False + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + @test_async_and_sync(globals()) + async def test_delegate_set_sponsor_reserve_accepted(self, client): + """DelegateSet with SponsorReserve is accepted.""" + alice = Wallet.create() + await fund_wallet_async(alice) + bob = Wallet.create() + await fund_wallet_async(bob) + + tx = DelegateSet( + account=alice.address, + authorize=bob.address, + permissions=[ + Permission(permission_value=GranularPermission.SPONSOR_RESERVE) + ], + ) + response = await sign_and_reliable_submission_async( + tx, alice, client, check_fee=False + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + @test_async_and_sync(globals()) + async def test_ledger_returns_sponsor_permission_values(self, client): + """Ledger entry round-trips SponsorFee and SponsorReserve string names.""" + alice = Wallet.create() + await fund_wallet_async(alice) + bob = Wallet.create() + await fund_wallet_async(bob) + + tx = DelegateSet( + account=alice.address, + authorize=bob.address, + permissions=[ + Permission(permission_value=GranularPermission.SPONSOR_FEE), + Permission(permission_value=GranularPermission.SPONSOR_RESERVE), + ], + ) + response = await sign_and_reliable_submission_async( + tx, alice, client, check_fee=False + ) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + ledger_response = await client.request( + LedgerEntry(delegate=Delegate(account=alice.address, authorize=bob.address)) + ) + self.assertTrue(ledger_response.is_successful()) + + perm_values = { + p["Permission"]["PermissionValue"] + for p in ledger_response.result["node"]["Permissions"] + } + self.assertIn(GranularPermission.SPONSOR_FEE.value, perm_values) + self.assertIn(GranularPermission.SPONSOR_RESERVE.value, perm_values) + + @test_async_and_sync(globals()) + async def test_account_objects_sponsor_permissions(self, client): + """AccountObjects returns SponsorFee and SponsorReserve permission values.""" + alice = Wallet.create() + await fund_wallet_async(alice) + bob = Wallet.create() + await fund_wallet_async(bob) + + tx = DelegateSet( + account=alice.address, + authorize=bob.address, + permissions=[ + Permission(permission_value=GranularPermission.SPONSOR_FEE), + Permission(permission_value=GranularPermission.SPONSOR_RESERVE), + ], + ) + response = await sign_and_reliable_submission_async( + tx, alice, client, check_fee=False + ) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + objects_response = await client.request( + AccountObjects(account=alice.address, type=AccountObjectType.DELEGATE) + ) + self.assertTrue(objects_response.is_successful()) + + perm_values = { + p["Permission"]["PermissionValue"] + for p in objects_response.result["account_objects"][0]["Permissions"] + } + self.assertIn(GranularPermission.SPONSOR_FEE.value, perm_values) + self.assertIn(GranularPermission.SPONSOR_RESERVE.value, perm_values) diff --git a/tests/integration/transactions/test_sponsor_signer.py b/tests/integration/transactions/test_sponsor_signer.py new file mode 100644 index 000000000..441a70469 --- /dev/null +++ b/tests/integration/transactions/test_sponsor_signer.py @@ -0,0 +1,172 @@ +"""Integration tests for sponsor-signature signing utilities (XLS-0068). + +These tests exercise the full co-signing flow described in XLS-0068 §3.2: + + 1. Sponsee builds and autofills a transaction with ``sponsor`` / + ``sponsor_flags`` fields. + 2. Sponsor (or sponsor key holders) co-sign via ``sign_as_sponsor``. + 3. Sponsee signs the resulting transaction with the standard ``sign`` helper. + 4. Transaction is submitted and validated. + +Two scenarios are covered: + +* **Single-signature sponsor** – the sponsor account uses a single key. +* **Multi-signature sponsor** – the sponsor account requires multiple keys; + each holder signs independently and the signatures are merged with + ``combine_sponsor_signers`` before the sponsee signs. + +NOTE: These tests assume the *featureSponsor* amendment is enabled on the +rippled server being tested against. If the amendment is not enabled, +transactions will return ``"temDISABLED"`` instead of ``"tesSUCCESS"``. +""" + +from tests.integration.integration_test_case import IntegrationTestCase +from tests.integration.it_utils import ( + fund_wallet_async, + sign_and_reliable_submission_async, + test_async_and_sync, +) +from xrpl.asyncio.transaction import autofill, submit +from xrpl.asyncio.transaction.main import sign +from xrpl.models import Payment +from xrpl.models.response import ResponseStatus +from xrpl.models.transactions import SignerEntry, SignerListSet +from xrpl.transaction import combine_sponsor_signers, sign_as_sponsor +from xrpl.wallet import Wallet + +# Sponsor-type flags (XLS-0068). +_TF_SPONSOR_FEE = 0x00000001 +_TF_SPONSOR_RESERVE = 0x00000002 + + +class TestSponsorSigner(IntegrationTestCase): + # ----------------------------------------------------------------------- + # Single-signature sponsor + # ----------------------------------------------------------------------- + + @test_async_and_sync( + globals(), ["xrpl.transaction.autofill", "xrpl.transaction.submit"] + ) + async def test_single_sig_sponsor_payment(self, client): + """Single-key sponsor co-signs a Payment; sponsee signs and submits.""" + sponsor_wallet = Wallet.create() + sponsee_wallet = Wallet.create() + destination_wallet = Wallet.create() + + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + await fund_wallet_async(destination_wallet) + + # Step 1 – Sponsee builds and autofills the transaction. + payment = Payment( + account=sponsee_wallet.address, + destination=destination_wallet.address, + amount="1000000", + sponsor=sponsor_wallet.address, + sponsor_flags=_TF_SPONSOR_FEE, + ) + autofilled = await autofill(payment, client) + + # Step 2 – Sponsee signs first (sets SigningPubKey + TxnSignature). + sponsee_signed = sign(autofilled, sponsee_wallet) + + self.assertIsNotNone(sponsee_signed.txn_signature) + self.assertEqual(sponsee_signed.signing_pub_key, sponsee_wallet.public_key) + + # Step 3 – Sponsor co-signs the already-signed transaction. + sponsor_result = sign_as_sponsor(sponsor_wallet, sponsee_signed) + + self.assertIsNotNone(sponsor_result.tx.sponsor_signature) + self.assertEqual( + sponsor_result.tx.sponsor_signature.signing_pub_key, + sponsor_wallet.public_key, + ) + self.assertIsNotNone(sponsor_result.tx.sponsor_signature.txn_signature) + self.assertIsNone(sponsor_result.tx.sponsor_signature.signers) + self.assertIsNotNone(sponsor_result.tx_blob) + + # Step 4 – Submit and verify. + response = await submit(sponsor_result.tx, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # ----------------------------------------------------------------------- + # Multi-signature sponsor + # ----------------------------------------------------------------------- + + @test_async_and_sync( + globals(), ["xrpl.transaction.autofill", "xrpl.transaction.submit"] + ) + async def test_multisig_sponsor_payment(self, client): + """Multi-key sponsor signs a Payment; signers merged, sponsee submits.""" + sponsor_wallet = Wallet.create() + sponsor_key1 = Wallet.create() + sponsor_key2 = Wallet.create() + sponsee_wallet = Wallet.create() + destination_wallet = Wallet.create() + + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + await fund_wallet_async(destination_wallet) + + # Set up a SignerList on the sponsor account so it can multi-sign. + signer_list_tx = SignerListSet( + account=sponsor_wallet.address, + signer_quorum=2, + signer_entries=[ + SignerEntry(account=sponsor_key1.address, signer_weight=1), + SignerEntry(account=sponsor_key2.address, signer_weight=1), + ], + ) + await sign_and_reliable_submission_async(signer_list_tx, sponsor_wallet, client) + + # Step 1 – Sponsee builds and autofills the transaction. + payment = Payment( + account=sponsee_wallet.address, + destination=destination_wallet.address, + amount="1000000", + sponsor=sponsor_wallet.address, + sponsor_flags=_TF_SPONSOR_FEE, + ) + autofilled = await autofill(payment, client) + + # Step 2 – Sponsee signs first (sets SigningPubKey + TxnSignature). + sponsee_signed = sign(autofilled, sponsee_wallet) + self.assertIsNotNone(sponsee_signed.txn_signature) + + # Step 3 – Each key holder produces a multisig sponsor contribution. + sig1_result = sign_as_sponsor(sponsor_key1, sponsee_signed, multisign=True) + sig2_result = sign_as_sponsor(sponsor_key2, sponsee_signed, multisign=True) + + # Each result must carry exactly one Signer entry. + self.assertIsNotNone(sig1_result.tx.sponsor_signature) + self.assertEqual(len(sig1_result.tx.sponsor_signature.signers), 1) + self.assertEqual( + sig1_result.tx.sponsor_signature.signers[0].account, + sponsor_key1.address, + ) + + self.assertIsNotNone(sig2_result.tx.sponsor_signature) + self.assertEqual(len(sig2_result.tx.sponsor_signature.signers), 1) + self.assertEqual( + sig2_result.tx.sponsor_signature.signers[0].account, + sponsor_key2.address, + ) + + # Step 4 – Merge all sponsor signers into one transaction. + combined = combine_sponsor_signers([sig1_result.tx, sig2_result.tx]) + + self.assertEqual(len(combined.tx.sponsor_signature.signers), 2) + # Signers must be sorted by canonical account ID bytes (ascending). + from xrpl.core.addresscodec import decode_classic_address + + ids = [ + decode_classic_address(s.account).hex().upper() + for s in combined.tx.sponsor_signature.signers + ] + self.assertEqual(ids, sorted(ids)) + + # Step 5 – Submit and verify. + response = await submit(combined.tx, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") diff --git a/tests/integration/transactions/test_sponsorship_set.py b/tests/integration/transactions/test_sponsorship_set.py new file mode 100644 index 000000000..a4ef9a4b2 --- /dev/null +++ b/tests/integration/transactions/test_sponsorship_set.py @@ -0,0 +1,255 @@ +"""Integration tests for SponsorshipSet transaction type (XLS-68 §9).""" + +from tests.integration.integration_test_case import IntegrationTestCase +from tests.integration.it_utils import ( + fund_wallet_async, + sign_and_reliable_submission_async, + test_async_and_sync, +) +from xrpl.asyncio.transaction import autofill, sign +from xrpl.models import AccountObjects, AccountObjectType, SponsorshipSet +from xrpl.models.requests import SubmitMultisigned +from xrpl.models.response import ResponseStatus +from xrpl.models.transactions import SignerEntry, SignerListSet +from xrpl.models.transactions.sponsorship_set import SponsorshipSetFlag +from xrpl.transaction.multisign import multisign +from xrpl.wallet import Wallet + + +class TestSponsorshipSet(IntegrationTestCase): + + # ── §9.1 CounterpartySponsor field (sponsee-initiated delete) ─────── + # Only the sponsor can create/update; the sponsee + # may only use CounterpartySponsor with tfDeleteObject. + + @test_async_and_sync(globals()) + async def test_delete_via_counterparty_sponsor(self, client): + """Sponsee deletes sponsorship using CounterpartySponsor.""" + sponsor_wallet = Wallet.create() + sponsee_wallet = Wallet.create() + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + + # Sponsor creates the sponsorship. + create_tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + ) + create_resp = await sign_and_reliable_submission_async( + create_tx, sponsor_wallet, client + ) + self.assertEqual(create_resp.result["engine_result"], "tesSUCCESS") + + # Sponsee deletes via CounterpartySponsor + tfDeleteObject. + delete_tx = SponsorshipSet( + account=sponsee_wallet.address, + counterparty_sponsor=sponsor_wallet.address, + flags=SponsorshipSetFlag.TF_DELETE_OBJECT, + ) + delete_resp = await sign_and_reliable_submission_async( + delete_tx, sponsee_wallet, client + ) + self.assertEqual(delete_resp.status, ResponseStatus.SUCCESS) + self.assertEqual(delete_resp.result["engine_result"], "tesSUCCESS") + + # Confirm the sponsorship object was deleted. + account_objects_response = await client.request( + AccountObjects( + account=sponsor_wallet.address, + type=AccountObjectType.SPONSORSHIP, + ) + ) + self.assertEqual( + len(account_objects_response.result["account_objects"]), + 0, + ) + + # ── §9.1 all optional fields together ────────────────────────────── + + @test_async_and_sync(globals()) + async def test_sponsorship_set_all_fields(self, client): + """SponsorshipSet with all optional fields populated.""" + sponsor_wallet = Wallet.create() + sponsee_wallet = Wallet.create() + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + + tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + fee_amount="2000000", + max_fee="100000", + reserve_count=10, + ) + response = await sign_and_reliable_submission_async(tx, sponsor_wallet, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + account_objects_response = await client.request( + AccountObjects( + account=sponsor_wallet.address, + type=AccountObjectType.SPONSORSHIP, + ) + ) + objs = account_objects_response.result["account_objects"] + self.assertTrue(len(objs) > 0) + + # ── Multi-signed sponsor creates sponsorship ───────────────────── + + @test_async_and_sync( + globals(), + [ + "xrpl.transaction.autofill", + "xrpl.transaction.sign", + ], + ) + async def test_create_with_multisign_sponsor(self, client): + """Sponsor with SignerList creates a sponsorship via multisign.""" + sponsor_wallet = Wallet.create() + sponsee_wallet = Wallet.create() + signer1 = Wallet.create() + signer2 = Wallet.create() + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + + # Set up a SignerList on the sponsor account. + signer_list_tx = SignerListSet( + account=sponsor_wallet.address, + signer_quorum=2, + signer_entries=[ + SignerEntry( + account=signer1.address, + signer_weight=1, + ), + SignerEntry( + account=signer2.address, + signer_weight=1, + ), + ], + ) + list_resp = await sign_and_reliable_submission_async( + signer_list_tx, sponsor_wallet, client + ) + self.assertEqual(list_resp.result["engine_result"], "tesSUCCESS") + + # Build and autofill the SponsorshipSet. + tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + fee_amount="1000000", + ) + autofilled_tx = await autofill(tx, client, len([signer1, signer2])) + + # Each signer signs for multisign. + tx_1 = sign(autofilled_tx, signer1, multisign=True) + tx_2 = sign(autofilled_tx, signer2, multisign=True) + multisigned_tx = multisign(autofilled_tx, [tx_1, tx_2]) + + response = await client.request(SubmitMultisigned(tx_json=multisigned_tx)) + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # ── §9.2 tfSponsorshipClearRequireSignForFee flag ────────────────── + + @test_async_and_sync(globals()) + async def test_clear_require_sign_for_fee(self, client): + """Set then clear lsfSponsorshipRequireSignForFee.""" + sponsor_wallet = Wallet.create() + sponsee_wallet = Wallet.create() + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + + # Create with the flag set. + create_tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + flags=(SponsorshipSetFlag.TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_FEE), + ) + create_resp = await sign_and_reliable_submission_async( + create_tx, sponsor_wallet, client + ) + self.assertEqual(create_resp.result["engine_result"], "tesSUCCESS") + + # Clear the flag. + clear_tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + flags=(SponsorshipSetFlag.TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_FEE), + ) + clear_resp = await sign_and_reliable_submission_async( + clear_tx, sponsor_wallet, client + ) + self.assertEqual(clear_resp.status, ResponseStatus.SUCCESS) + self.assertEqual(clear_resp.result["engine_result"], "tesSUCCESS") + + # ── §9.2 tfSponsorshipClearRequireSignForReserve flag ────────────── + + @test_async_and_sync(globals()) + async def test_clear_require_sign_for_reserve(self, client): + """Set then clear lsfSponsorshipRequireSignForReserve.""" + sponsor_wallet = Wallet.create() + sponsee_wallet = Wallet.create() + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + + # Create with the flag set. + create_tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + flags=(SponsorshipSetFlag.TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_RESERVE), + ) + create_resp = await sign_and_reliable_submission_async( + create_tx, sponsor_wallet, client + ) + self.assertEqual(create_resp.result["engine_result"], "tesSUCCESS") + + # Clear the flag. + clear_tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + flags=(SponsorshipSetFlag.TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_RESERVE), + ) + clear_resp = await sign_and_reliable_submission_async( + clear_tx, sponsor_wallet, client + ) + self.assertEqual(clear_resp.status, ResponseStatus.SUCCESS) + self.assertEqual(clear_resp.result["engine_result"], "tesSUCCESS") + + # ── §9.2 tfDeleteObject flag ─────────────────────────────────────── + + @test_async_and_sync(globals()) + async def test_sponsorship_set_delete(self, client): + """Create then delete a Sponsorship object.""" + sponsor_wallet = Wallet.create() + sponsee_wallet = Wallet.create() + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + + create_tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + ) + create_resp = await sign_and_reliable_submission_async( + create_tx, sponsor_wallet, client + ) + self.assertEqual(create_resp.status, ResponseStatus.SUCCESS) + self.assertEqual(create_resp.result["engine_result"], "tesSUCCESS") + + delete_tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + flags=SponsorshipSetFlag.TF_DELETE_OBJECT, + ) + delete_resp = await sign_and_reliable_submission_async( + delete_tx, sponsor_wallet, client + ) + self.assertEqual(delete_resp.status, ResponseStatus.SUCCESS) + self.assertEqual(delete_resp.result["engine_result"], "tesSUCCESS") + + account_objects_response = await client.request( + AccountObjects( + account=sponsor_wallet.address, + type=AccountObjectType.SPONSORSHIP, + ) + ) + self.assertEqual(len(account_objects_response.result["account_objects"]), 0) diff --git a/tests/integration/transactions/test_sponsorship_transfer.py b/tests/integration/transactions/test_sponsorship_transfer.py new file mode 100644 index 000000000..33f4fc255 --- /dev/null +++ b/tests/integration/transactions/test_sponsorship_transfer.py @@ -0,0 +1,210 @@ +"""Integration tests for SponsorshipTransfer transaction type (XLS-68).""" + +from tests.integration.integration_test_case import IntegrationTestCase +from tests.integration.it_utils import ( + LEDGER_ACCEPT_REQUEST, + fund_wallet_async, + sign_and_reliable_submission_async, + test_async_and_sync, +) +from xrpl.asyncio.transaction import autofill, sign, submit +from xrpl.core.addresscodec import decode_classic_address +from xrpl.core.binarycodec import encode_for_multisigning, encode_for_signing +from xrpl.core.keypairs import sign as keypairs_sign +from xrpl.models import SponsorshipTransfer +from xrpl.models.response import ResponseStatus +from xrpl.models.transactions import SignerEntry, SignerListSet +from xrpl.models.transactions.sponsorship_transfer import SponsorshipTransferFlag +from xrpl.wallet import Wallet + + +def _build_sponsor_signed_tx(transfer_tx, sponsee_wallet, sponsor_wallet): + """Sign a SponsorshipTransfer as the sponsee, then co-sign. + + Returns a fully-signed SponsorshipTransfer ready to submit. + """ + # Sign as the sponsee (primary signer) — sets SigningPubKey + signed_tx = sign(transfer_tx, sponsee_wallet) + + # Compute the sponsor's co-signature over the signed tx. + # SigningPubKey (isSigningField=true) is included in the hash; + # TxnSignature/SponsorSignature (isSigningField=false) excluded. + tx_json = signed_tx.to_xrpl() + sponsor_sig = keypairs_sign( + bytes.fromhex(encode_for_signing(tx_json)), + sponsor_wallet.private_key, + ) + + # Attach the SponsorSignature + tx_json["SponsorSignature"] = { + "SigningPubKey": sponsor_wallet.public_key, + "TxnSignature": sponsor_sig, + } + return SponsorshipTransfer.from_xrpl(tx_json) + + +class TestSponsorshipTransfer(IntegrationTestCase): + + @test_async_and_sync( + globals(), + ["xrpl.transaction.autofill", "xrpl.transaction.submit"], + ) + async def test_basic_sponsorship_transfer(self, client): + sponsor_wallet = Wallet.create() + sponsee_wallet = Wallet.create() + new_sponsor_wallet = Wallet.create() + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + await fund_wallet_async(new_sponsor_wallet) + + # Step 1: Create account-level sponsorship. + # No object_id means this is an account sponsor, + # not an object sponsor. + create_tx = SponsorshipTransfer( + account=sponsee_wallet.address, + flags=SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE, + sponsor_flags=2, + sponsor=sponsor_wallet.address, + ) + create_tx = await autofill(create_tx, client) + final_create_tx = _build_sponsor_signed_tx( + create_tx, sponsee_wallet, sponsor_wallet + ) + create_response = await submit(final_create_tx, client) + await client.request(LEDGER_ACCEPT_REQUEST) + self.assertEqual(create_response.status, ResponseStatus.SUCCESS) + self.assertEqual(create_response.result["engine_result"], "tesSUCCESS") + + # Step 2: Reassign the account sponsorship. + reassign_tx = SponsorshipTransfer( + account=sponsee_wallet.address, + flags=(SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN), + sponsor_flags=2, + sponsor=new_sponsor_wallet.address, + ) + reassign_tx = await autofill(reassign_tx, client) + final_reassign_tx = _build_sponsor_signed_tx( + reassign_tx, sponsee_wallet, new_sponsor_wallet + ) + reassign_response = await submit(final_reassign_tx, client) + await client.request(LEDGER_ACCEPT_REQUEST) + self.assertEqual(reassign_response.status, ResponseStatus.SUCCESS) + self.assertEqual( + reassign_response.result["engine_result"], + "tesSUCCESS", + ) + + @test_async_and_sync( + globals(), + ["xrpl.transaction.autofill", "xrpl.transaction.submit"], + ) + async def test_sponsored_to_unsponsored(self, client): + """Sponsored -> Unsponsored: sponsee ends sponsorship.""" + sponsor_wallet = Wallet.create() + sponsee_wallet = Wallet.create() + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + + # Create account-level sponsorship (sponsor -> sponsee). + create_tx = SponsorshipTransfer( + account=sponsee_wallet.address, + flags=SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE, + sponsor_flags=2, + sponsor=sponsor_wallet.address, + ) + create_tx = await autofill(create_tx, client) + final_create_tx = _build_sponsor_signed_tx( + create_tx, sponsee_wallet, sponsor_wallet + ) + create_response = await submit(final_create_tx, client) + await client.request(LEDGER_ACCEPT_REQUEST) + self.assertEqual(create_response.status, ResponseStatus.SUCCESS) + self.assertEqual(create_response.result["engine_result"], "tesSUCCESS") + + # End the sponsorship. The sponsee submits with + # tfSponsorshipEnd. No sponsor, sponsor_flags, or + # sponsor_signature needed. + end_tx = SponsorshipTransfer( + account=sponsee_wallet.address, + flags=SponsorshipTransferFlag.TF_SPONSORSHIP_END, + ) + end_response = await sign_and_reliable_submission_async( + end_tx, sponsee_wallet, client + ) + self.assertEqual(end_response.status, ResponseStatus.SUCCESS) + self.assertEqual(end_response.result["engine_result"], "tesSUCCESS") + + @test_async_and_sync( + globals(), + ["xrpl.transaction.autofill", "xrpl.transaction.submit"], + ) + async def test_create_with_multisign_sponsor(self, client): + """Create sponsorship where the sponsor uses a SignerList.""" + sponsor_wallet = Wallet.create() + sponsee_wallet = Wallet.create() + signer1 = Wallet.create() + signer2 = Wallet.create() + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + + # Set up a SignerList on the sponsor account. + signer_list_tx = SignerListSet( + account=sponsor_wallet.address, + signer_quorum=2, + signer_entries=[ + SignerEntry( + account=signer1.address, + signer_weight=1, + ), + SignerEntry( + account=signer2.address, + signer_weight=1, + ), + ], + ) + list_response = await sign_and_reliable_submission_async( + signer_list_tx, sponsor_wallet, client + ) + self.assertEqual(list_response.result["engine_result"], "tesSUCCESS") + + # Build and autofill the SponsorshipTransfer. + create_tx = SponsorshipTransfer( + account=sponsee_wallet.address, + flags=SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE, + sponsor_flags=2, + sponsor=sponsor_wallet.address, + ) + create_tx = await autofill(create_tx, client) + + # Sign as the sponsee (primary signer). + signed_tx = sign(create_tx, sponsee_wallet) + tx_json = signed_tx.to_xrpl() + + # Each signer signs via encode_for_multisigning. + signers = [] + for signer_wallet in [signer1, signer2]: + sig = keypairs_sign( + bytes.fromhex(encode_for_multisigning(tx_json, signer_wallet.address)), + signer_wallet.private_key, + ) + signers.append( + { + "Signer": { + "Account": signer_wallet.address, + "SigningPubKey": signer_wallet.public_key, + "TxnSignature": sig, + } + } + ) + + # Sort signers by decoded account (XRPL requirement). + signers.sort(key=lambda s: decode_classic_address(s["Signer"]["Account"])) + + # Attach multi-signed SponsorSignature. + tx_json["SponsorSignature"] = {"Signers": signers} + final_tx = SponsorshipTransfer.from_xrpl(tx_json) + + create_response = await submit(final_tx, client) + await client.request(LEDGER_ACCEPT_REQUEST) + self.assertEqual(create_response.status, ResponseStatus.SUCCESS) + self.assertEqual(create_response.result["engine_result"], "tesSUCCESS") diff --git a/tests/unit/models/transactions/test_better_transaction_flags.py b/tests/unit/models/transactions/test_better_transaction_flags.py index 67869ae8f..b781ebb58 100644 --- a/tests/unit/models/transactions/test_better_transaction_flags.py +++ b/tests/unit/models/transactions/test_better_transaction_flags.py @@ -230,6 +230,13 @@ def test_payment_channel_claim_flags(self): def test_payment_flags(self): dest = "ra5nK24KXen9AHvsdFTKHSANinZseWnPcX" amnt = "10000000" + # TF_SPONSOR_CREATED_ACCOUNT is mutually exclusive with the routing/quality + # flags below, so it is tested separately in test_payment.py. + compatible_flags = [ + models.PaymentFlag.TF_LIMIT_QUALITY, + models.PaymentFlag.TF_NO_RIPPLE_DIRECT, + models.PaymentFlag.TF_PARTIAL_PAYMENT, + ] actual = models.Payment( account=ACCOUNT, destination=dest, @@ -242,12 +249,11 @@ def test_payment_flags(self): ) self.assertTrue(actual.has_flag(flag=0x00010000)) self.assertTrue(actual.is_valid()) - flags = models.PaymentFlag expected = models.Payment( account=ACCOUNT, destination=dest, amount=amnt, - flags=[*flags], + flags=compatible_flags, ) signed_actual = sign( transaction=actual, diff --git a/tests/unit/models/transactions/test_payment.py b/tests/unit/models/transactions/test_payment.py index 5e354b9e1..ce5bca11f 100644 --- a/tests/unit/models/transactions/test_payment.py +++ b/tests/unit/models/transactions/test_payment.py @@ -294,3 +294,94 @@ def test_payment_with_domain_id_too_long(self): error.exception.args[0], "{'domain_id': 'domain_id length must be 64 characters.'}", ) + + def test_sponsor_created_account_flag(self): + """Payment with tfSponsorCreatedAccount flag.""" + tx = Payment( + account=_ACCOUNT, + destination=_DESTINATION, + amount="1000000", + flags=PaymentFlag.TF_SPONSOR_CREATED_ACCOUNT, + ) + self.assertTrue(tx.is_valid()) + + def test_sponsor_created_account_flag_value(self): + """Verify flag value is correct.""" + self.assertEqual(PaymentFlag.TF_SPONSOR_CREATED_ACCOUNT, 0x00080000) + + # ------------------------------------------------------------------ # + # Issue 11 — TF_SPONSOR_CREATED_ACCOUNT mutual-exclusion validation # + # ------------------------------------------------------------------ # + + def test_invalid_sponsor_created_account_with_no_ripple_direct(self): + """TF_SPONSOR_CREATED_ACCOUNT and TF_NO_RIPPLE_DIRECT are mutually exclusive.""" + with self.assertRaises(XRPLModelException) as cm: + Payment( + account=_ACCOUNT, + destination=_DESTINATION, + amount="1000000", + flags=( + PaymentFlag.TF_SPONSOR_CREATED_ACCOUNT + | PaymentFlag.TF_NO_RIPPLE_DIRECT + ), + ) + self.assertIn( + "`TF_SPONSOR_CREATED_ACCOUNT` cannot be combined with " + "`TF_NO_RIPPLE_DIRECT`.", + str(cm.exception), + ) + + def test_invalid_sponsor_created_account_with_partial_payment(self): + """TF_SPONSOR_CREATED_ACCOUNT and TF_PARTIAL_PAYMENT are mutually exclusive.""" + with self.assertRaises(XRPLModelException) as cm: + Payment( + account=_ACCOUNT, + destination=_DESTINATION, + amount="1000000", + send_max="2000000", + flags=( + PaymentFlag.TF_SPONSOR_CREATED_ACCOUNT + | PaymentFlag.TF_PARTIAL_PAYMENT + ), + ) + self.assertIn( + "`TF_SPONSOR_CREATED_ACCOUNT` cannot be combined with " + "`TF_PARTIAL_PAYMENT`.", + str(cm.exception), + ) + + def test_invalid_sponsor_created_account_with_limit_quality(self): + """TF_SPONSOR_CREATED_ACCOUNT and TF_LIMIT_QUALITY are mutually exclusive.""" + with self.assertRaises(XRPLModelException) as cm: + Payment( + account=_ACCOUNT, + destination=_DESTINATION, + amount="1000000", + flags=( + PaymentFlag.TF_SPONSOR_CREATED_ACCOUNT + | PaymentFlag.TF_LIMIT_QUALITY + ), + ) + self.assertIn( + "`TF_SPONSOR_CREATED_ACCOUNT` cannot be combined with " + "`TF_LIMIT_QUALITY`.", + str(cm.exception), + ) + + def test_invalid_sponsor_created_account_with_multiple_incompatible_flags(self): + """TF_SPONSOR_CREATED_ACCOUNT combined with multiple incompatible flags.""" + with self.assertRaises(XRPLModelException) as cm: + Payment( + account=_ACCOUNT, + destination=_DESTINATION, + amount="1000000", + send_max="2000000", + flags=( + PaymentFlag.TF_SPONSOR_CREATED_ACCOUNT + | PaymentFlag.TF_NO_RIPPLE_DIRECT + | PaymentFlag.TF_PARTIAL_PAYMENT + ), + ) + exception_str = str(cm.exception) + self.assertIn("`TF_NO_RIPPLE_DIRECT`", exception_str) + self.assertIn("`TF_PARTIAL_PAYMENT`", exception_str) diff --git a/tests/unit/models/transactions/test_sponsor_common_fields.py b/tests/unit/models/transactions/test_sponsor_common_fields.py new file mode 100644 index 000000000..e3fe25ce1 --- /dev/null +++ b/tests/unit/models/transactions/test_sponsor_common_fields.py @@ -0,0 +1,131 @@ +"""Tests for sponsor common fields on Transaction base class.""" + +from unittest import TestCase + +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions.batch import Batch +from xrpl.models.transactions.payment import Payment +from xrpl.models.transactions.pseudo_transactions import EnableAmendment +from xrpl.models.transactions.sponsor_signature import SponsorSignature +from xrpl.models.transactions.transaction import Signer + +_ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" +_SPONSOR = "rPyfep3gcLzkH4MYxKxJhE7bgUJfUCJM83" +_DESTINATION = "rN7n7otQDd6FczFgLdlqtyMVrn3HMfXpf" + + +class TestSponsorCommonFields(TestCase): + def test_payment_with_sponsor_fee(self): + """Payment with sponsor and tfSponsorFee flag.""" + tx = Payment( + account=_ACCOUNT, + destination=_DESTINATION, + amount="1000000", + sponsor=_SPONSOR, + sponsor_flags=0x00000001, # tfSponsorFee + ) + self.assertTrue(tx.is_valid()) + d = tx.to_dict() + self.assertEqual(d["sponsor"], _SPONSOR) + self.assertEqual(d["sponsor_flags"], 1) + + def test_payment_with_sponsor_reserve(self): + """Payment with sponsor and tfSponsorReserve flag.""" + tx = Payment( + account=_ACCOUNT, + destination=_DESTINATION, + amount="1000000", + sponsor=_SPONSOR, + sponsor_flags=0x00000002, # tfSponsorReserve + ) + self.assertTrue(tx.is_valid()) + + def test_payment_with_sponsor_both_flags(self): + """Payment with sponsor covering both fee and reserve.""" + tx = Payment( + account=_ACCOUNT, + destination=_DESTINATION, + amount="1000000", + sponsor=_SPONSOR, + sponsor_flags=0x00000003, # tfSponsorFee | tfSponsorReserve + ) + self.assertTrue(tx.is_valid()) + + def test_payment_with_sponsor_signature(self): + """Payment with full sponsor co-signing.""" + tx = Payment( + account=_ACCOUNT, + destination=_DESTINATION, + amount="1000000", + sponsor=_SPONSOR, + sponsor_flags=0x00000001, + sponsor_signature=SponsorSignature( + signing_pub_key="ED000000", + txn_signature="DEADBEEF", + ), + ) + self.assertTrue(tx.is_valid()) + + def test_payment_with_sponsor_multisig(self): + """Payment with sponsor multi-signature.""" + tx = Payment( + account=_ACCOUNT, + destination=_DESTINATION, + amount="1000000", + sponsor=_SPONSOR, + sponsor_flags=0x00000001, + sponsor_signature=SponsorSignature( + signers=[ + Signer( + account=_SPONSOR, + signing_pub_key="ED000000", + txn_signature="DEADBEEF", + ) + ] + ), + ) + self.assertTrue(tx.is_valid()) + + def test_payment_without_sponsor(self): + """Regular payment without any sponsor fields.""" + tx = Payment( + account=_ACCOUNT, + destination=_DESTINATION, + amount="1000000", + ) + self.assertTrue(tx.is_valid()) + d = tx.to_dict() + self.assertNotIn("sponsor", d) + self.assertNotIn("sponsor_flags", d) + self.assertNotIn("sponsor_signature", d) + + # ── XLS-68 §8.3.4: transactions that cannot be sponsored ── + + _UNSPONSORABLE_MSG = "cannot be sponsored" + + def test_batch_with_sponsor_rejected(self): + """Batch transaction with sponsor is rejected.""" + inner_tx = Payment( + account=_ACCOUNT, + destination=_DESTINATION, + amount="1000000", + ) + with self.assertRaises(XRPLModelException) as cm: + Batch( + account=_ACCOUNT, + raw_transactions=[inner_tx], + sponsor=_SPONSOR, + sponsor_flags=1, + ) + self.assertIn(self._UNSPONSORABLE_MSG, str(cm.exception)) + + def test_pseudo_transaction_with_sponsor_rejected(self): + """Pseudo-transaction with sponsor is rejected.""" + with self.assertRaises(XRPLModelException) as cm: + EnableAmendment( + amendment="A" * 64, + ledger_sequence=1, + sponsor=_SPONSOR, + sponsor_flags=1, + ) + self.assertIn(self._UNSPONSORABLE_MSG, str(cm.exception)) diff --git a/tests/unit/models/transactions/test_sponsor_permissions.py b/tests/unit/models/transactions/test_sponsor_permissions.py new file mode 100644 index 000000000..cd8782c59 --- /dev/null +++ b/tests/unit/models/transactions/test_sponsor_permissions.py @@ -0,0 +1,115 @@ +"""Tests for sponsor-related granular permissions.""" + +from unittest import TestCase + +from xrpl.models.transactions.delegate_set import ( + DelegateSet, + GranularPermission, + Permission, +) + +_ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" +_ACCOUNT2 = "rPyfep3gcLzkH4MYxKxJhE7bgUJfUCJM83" + + +class TestSponsorGranularPermissions(TestCase): + # ------------------------------------------------------------------ # + # Enum identity (existing) # + # ------------------------------------------------------------------ # + + def test_sponsor_fee_permission_exists(self): + """Verify SponsorFee granular permission exists.""" + self.assertEqual(GranularPermission.SPONSOR_FEE, "SponsorFee") + + def test_sponsor_reserve_permission_exists(self): + """Verify SponsorReserve granular permission exists.""" + self.assertEqual(GranularPermission.SPONSOR_RESERVE, "SponsorReserve") + + # ------------------------------------------------------------------ # + # Serialization path — Permission nested model # + # ------------------------------------------------------------------ # + + def test_permission_sponsor_fee_to_dict(self): + """Permission wrapping SponsorFee serializes to the string value.""" + p = Permission(permission_value=GranularPermission.SPONSOR_FEE) + # NestedModel wraps the fields under the lower-cased class name key + d = p.to_dict() + self.assertEqual(d["permission"]["permission_value"], "SponsorFee") + + def test_permission_sponsor_reserve_to_dict(self): + """Permission wrapping SponsorReserve serializes to the string value.""" + p = Permission(permission_value=GranularPermission.SPONSOR_RESERVE) + d = p.to_dict() + self.assertEqual(d["permission"]["permission_value"], "SponsorReserve") + + def test_delegate_set_with_sponsor_fee_permission(self): + """DelegateSet containing SponsorFee permission is valid and serializes.""" + tx = DelegateSet( + account=_ACCOUNT, + authorize=_ACCOUNT2, + permissions=[Permission(permission_value=GranularPermission.SPONSOR_FEE)], + ) + self.assertTrue(tx.is_valid()) + d = tx.to_dict() + self.assertEqual( + d["permissions"][0]["permission"]["permission_value"], "SponsorFee" + ) + + def test_delegate_set_with_sponsor_reserve_permission(self): + """DelegateSet containing SponsorReserve permission is valid and serializes.""" + tx = DelegateSet( + account=_ACCOUNT, + authorize=_ACCOUNT2, + permissions=[ + Permission(permission_value=GranularPermission.SPONSOR_RESERVE) + ], + ) + self.assertTrue(tx.is_valid()) + d = tx.to_dict() + self.assertEqual( + d["permissions"][0]["permission"]["permission_value"], "SponsorReserve" + ) + + def test_delegate_set_with_both_sponsor_permissions(self): + """DelegateSet containing both sponsor permissions serializes correctly.""" + tx = DelegateSet( + account=_ACCOUNT, + authorize=_ACCOUNT2, + permissions=[ + Permission(permission_value=GranularPermission.SPONSOR_FEE), + Permission(permission_value=GranularPermission.SPONSOR_RESERVE), + ], + ) + self.assertTrue(tx.is_valid()) + d = tx.to_dict() + values = [p["permission"]["permission_value"] for p in d["permissions"]] + self.assertIn("SponsorFee", values) + self.assertIn("SponsorReserve", values) + + def test_delegate_set_to_xrpl_camel_case(self): + """to_xrpl() emits CamelCase keys for sponsor permission DelegateSet.""" + tx = DelegateSet( + account=_ACCOUNT, + authorize=_ACCOUNT2, + permissions=[Permission(permission_value=GranularPermission.SPONSOR_FEE)], + ) + xrpl_dict = tx.to_xrpl() + self.assertIn("Permissions", xrpl_dict) + self.assertIn("Authorize", xrpl_dict) + # to_xrpl wraps nested Permission as {'Permission': {'PermissionValue': ...}} + perm = xrpl_dict["Permissions"][0] + self.assertIn("Permission", perm) + self.assertEqual(perm["Permission"]["PermissionValue"], "SponsorFee") + + def test_delegate_set_from_dict_roundtrip_sponsor_fee(self): + """Roundtrip through to_dict() / from_dict() preserves SponsorFee.""" + tx = DelegateSet( + account=_ACCOUNT, + authorize=_ACCOUNT2, + permissions=[Permission(permission_value=GranularPermission.SPONSOR_FEE)], + ) + roundtripped = DelegateSet.from_dict(tx.to_dict()) + self.assertEqual( + roundtripped.permissions[0].permission_value, + GranularPermission.SPONSOR_FEE, + ) diff --git a/tests/unit/models/transactions/test_sponsor_signature.py b/tests/unit/models/transactions/test_sponsor_signature.py new file mode 100644 index 000000000..bf6a0018c --- /dev/null +++ b/tests/unit/models/transactions/test_sponsor_signature.py @@ -0,0 +1,132 @@ +from unittest import TestCase + +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions.sponsor_signature import SponsorSignature +from xrpl.models.transactions.transaction import Signer + +_ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" +_ACCOUNT2 = "rPyfep3gcLzkH4MYxKxJhE7bgUJfUCJM83" +_SIGNING_PUB_KEY = "ED5F5AC43F527AE97194AC44903F8E0397F1B8AFDC25990B3B8F093E2D1D8B0E2D" +_TXN_SIGNATURE = ( + "304402203B9B0B6E0735AD5F370B2B0B3A81CDE62CC5B7C3" + "3C5B15C76C3E4B8A0CEEF10220523D4C16C3F68C0840F1B1" + "F4BF7D5F1C6D3DA2F9D0E4EB7A4E6BF1C3A5D7E9" +) + + +class TestSponsorSignature(TestCase): + def test_valid_with_single_signature(self): + """Both signing_pub_key and txn_signature.""" + sig = SponsorSignature( + signing_pub_key=_SIGNING_PUB_KEY, + txn_signature=_TXN_SIGNATURE, + ) + self.assertTrue(sig.is_valid()) + + def test_valid_with_signers(self): + """Multi-signature with signers list.""" + sig = SponsorSignature( + signers=[ + Signer( + account=_ACCOUNT, + signing_pub_key=_SIGNING_PUB_KEY, + txn_signature=_TXN_SIGNATURE, + ), + Signer( + account=_ACCOUNT2, + signing_pub_key=_SIGNING_PUB_KEY, + txn_signature=_TXN_SIGNATURE, + ), + ], + ) + self.assertTrue(sig.is_valid()) + + def test_to_dict(self): + """Verify serialization to dict works correctly.""" + sig = SponsorSignature( + signing_pub_key=_SIGNING_PUB_KEY, + txn_signature=_TXN_SIGNATURE, + ) + result = sig.to_dict() + self.assertEqual(result["signing_pub_key"], _SIGNING_PUB_KEY) + self.assertEqual(result["txn_signature"], _TXN_SIGNATURE) + + def test_from_dict(self): + """Verify deserialization from dict works correctly.""" + data = { + "signing_pub_key": _SIGNING_PUB_KEY, + "txn_signature": _TXN_SIGNATURE, + } + sig = SponsorSignature.from_dict(data) + self.assertEqual(sig.signing_pub_key, _SIGNING_PUB_KEY) + self.assertEqual(sig.txn_signature, _TXN_SIGNATURE) + self.assertTrue(sig.is_valid()) + + def test_valid_sponsor_signature_single_sig(self): + """SponsorSignature with both signing_pub_key and txn_signature is valid.""" + sig = SponsorSignature( + signing_pub_key="ED000000", + txn_signature="DEADBEEF", + ) + self.assertTrue(sig.is_valid()) + + def test_valid_sponsor_signature_multi_sig(self): + """SponsorSignature with signers list is valid.""" + sig = SponsorSignature( + signers=[ + Signer( + account=_ACCOUNT2, + signing_pub_key="ED000000", + txn_signature="DEADBEEF", + ) + ] + ) + self.assertTrue(sig.is_valid()) + + def test_invalid_sponsor_signature_empty(self): + """SponsorSignature with no fields set must be rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorSignature() + self.assertIn( + "Must provide either (`signing_pub_key` + `txn_signature`) " + "for single-signature or `signers` for multi-signature.", + str(cm.exception), + ) + + def test_invalid_sponsor_signature_missing_txn_signature(self): + """signing_pub_key without txn_signature must be rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorSignature(signing_pub_key="ED000000") + self.assertIn( + "`txn_signature` is required when `signing_pub_key` is set.", + str(cm.exception), + ) + + def test_invalid_sponsor_signature_missing_pub_key(self): + """txn_signature without signing_pub_key must be rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorSignature(txn_signature="DEADBEEF") + self.assertIn( + "`signing_pub_key` is required when `txn_signature` is set.", + str(cm.exception), + ) + + def test_invalid_sponsor_signature_single_and_multi(self): + """Providing both single-sig fields and signers must be rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorSignature( + signing_pub_key="ED000000", + txn_signature="DEADBEEF", + signers=[ + Signer( + account=_ACCOUNT2, + signing_pub_key="ED000000", + txn_signature="DEADBEEF", + ) + ], + ) + self.assertIn( + "Cannot set both single-signature fields " + "(`signing_pub_key`/`txn_signature`) and `signers`.", + str(cm.exception), + ) diff --git a/tests/unit/models/transactions/test_sponsorship_set.py b/tests/unit/models/transactions/test_sponsorship_set.py new file mode 100644 index 000000000..5fdb5ce03 --- /dev/null +++ b/tests/unit/models/transactions/test_sponsorship_set.py @@ -0,0 +1,318 @@ +from unittest import TestCase + +from xrpl.models.amounts import IssuedCurrencyAmount +from xrpl.models.amounts.mpt_amount import MPTAmount +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions.sponsorship_set import SponsorshipSet, SponsorshipSetFlag +from xrpl.models.transactions.types import TransactionType + +_MPT_ISSUANCE_ID = "000004C463C52827307480341125DA0577DEFC38" + +_ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" +_ACCOUNT2 = "rPyfep3gcLzkH4MYxKxJhE7bgUJfUCJM83" +_ACCOUNT3 = "rN7n7otQDd6FczFgLdlqtyMVrn3HMfXpf" + + +class TestSponsorshipSet(TestCase): + # ------------------------------------------------------------------ # + # Valid cases # + # ------------------------------------------------------------------ # + + def test_valid_minimal(self): + """Sponsor submits with only the sponsee field.""" + tx = SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + ) + self.assertTrue(tx.is_valid()) + + def test_valid_all_fields(self): + """Sponsor submits with sponsee and every optional field set.""" + tx = SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + fee_amount="1000000", + max_fee="2000000", + reserve_count=5, + ) + self.assertTrue(tx.is_valid()) + + def test_valid_with_xrp_fee_amount(self): + """fee_amount as XRP drops string.""" + tx = SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + fee_amount="1000000", + ) + self.assertTrue(tx.is_valid()) + + def test_valid_with_xrp_max_fee(self): + """max_fee as XRP drops string.""" + tx = SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + max_fee="5000000", + ) + self.assertTrue(tx.is_valid()) + + def test_valid_with_flags(self): + """Using SponsorshipSetFlag values.""" + tx = SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + flags=SponsorshipSetFlag.TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_FEE, + ) + self.assertTrue(tx.is_valid()) + + def test_valid_with_combined_flags(self): + """Two non-conflicting flags combined.""" + tx = SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + flags=( + SponsorshipSetFlag.TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_FEE + | SponsorshipSetFlag.TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_RESERVE + ), + ) + self.assertTrue(tx.is_valid()) + + def test_valid_delete_object_flag(self): + """TF_DELETE_OBJECT alone with a sponsee is valid.""" + tx = SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + flags=SponsorshipSetFlag.TF_DELETE_OBJECT, + ) + self.assertTrue(tx.is_valid()) + + def test_valid_with_counterparty_sponsor(self): + """Sponsee submits, providing counterparty_sponsor (deletion scenario).""" + tx = SponsorshipSet( + account=_ACCOUNT, + counterparty_sponsor=_ACCOUNT2, + ) + self.assertTrue(tx.is_valid()) + + def test_valid_with_reserve_count(self): + """Setting reserve_count.""" + tx = SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + reserve_count=10, + ) + self.assertTrue(tx.is_valid()) + + def test_has_correct_transaction_type(self): + """Verify transaction_type is TransactionType.SPONSORSHIP_SET.""" + tx = SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + ) + self.assertEqual(tx.transaction_type, TransactionType.SPONSORSHIP_SET) + + def test_valid_clear_flags(self): + """Using clear flag variants (no conflict).""" + tx = SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + flags=SponsorshipSetFlag.TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_FEE, + ) + self.assertTrue(tx.is_valid()) + + tx2 = SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + flags=SponsorshipSetFlag.TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_RESERVE, + ) + self.assertTrue(tx2.is_valid()) + + def test_valid_delete_with_counterparty_sponsor(self): + """Sponsee deletes using counterparty_sponsor + TF_DELETE_OBJECT.""" + tx = SponsorshipSet( + account=_ACCOUNT, + counterparty_sponsor=_ACCOUNT2, + flags=SponsorshipSetFlag.TF_DELETE_OBJECT, + ) + self.assertTrue(tx.is_valid()) + + # ------------------------------------------------------------------ # + # Concern 1 — fee_amount / max_fee must be XRP (not IOU or MPT) # + # ------------------------------------------------------------------ # + + _FEE_AMOUNT_MSG = ( + "`fee_amount` must be XRP drops (a string), " + "not an issued currency or MPT amount." + ) + _MAX_FEE_MSG = ( + "`max_fee` must be XRP drops (a string), " + "not an issued currency or MPT amount." + ) + + def test_invalid_fee_amount_iou(self): + """fee_amount as IssuedCurrencyAmount is rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + fee_amount=IssuedCurrencyAmount( + currency="USD", + issuer=_ACCOUNT, + value="10", + ), + ) + self.assertIn(self._FEE_AMOUNT_MSG, str(cm.exception)) + + def test_invalid_max_fee_iou(self): + """max_fee as IssuedCurrencyAmount must be rejected with the correct message.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + max_fee=IssuedCurrencyAmount( + currency="USD", + issuer=_ACCOUNT, + value="100", + ), + ) + self.assertIn(self._MAX_FEE_MSG, str(cm.exception)) + + def test_invalid_fee_amount_mpt(self): + """fee_amount as MPTAmount must be rejected with the correct message.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + fee_amount=MPTAmount( + mpt_issuance_id=_MPT_ISSUANCE_ID, + value="100", + ), + ) + self.assertIn(self._FEE_AMOUNT_MSG, str(cm.exception)) + + def test_invalid_max_fee_mpt(self): + """max_fee as MPTAmount must be rejected with the correct message.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + max_fee=MPTAmount( + mpt_issuance_id=_MPT_ISSUANCE_ID, + value="100", + ), + ) + self.assertIn(self._MAX_FEE_MSG, str(cm.exception)) + + # ------------------------------------------------------------------ # + # Concern 2 — XOR: exactly one of counterparty_sponsor / sponsee # + # ------------------------------------------------------------------ # + + _XOR_MSG = ( + "Exactly one of `counterparty_sponsor` or `sponsee` must be present " + "(not both, not neither)." + ) + + def test_invalid_neither_counterparty_nor_sponsee(self): + """Providing neither counterparty_sponsor nor sponsee must be rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipSet(account=_ACCOUNT) + self.assertIn(self._XOR_MSG, str(cm.exception)) + + def test_invalid_both_counterparty_and_sponsee(self): + """Providing both counterparty_sponsor and sponsee must be rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipSet( + account=_ACCOUNT, + counterparty_sponsor=_ACCOUNT2, + sponsee=_ACCOUNT3, + ) + self.assertIn(self._XOR_MSG, str(cm.exception)) + + def test_invalid_sponsee_equals_account(self): + """sponsee identical to account must be rejected with the correct message.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT, + ) + self.assertIn("`sponsee` must differ from `account`.", str(cm.exception)) + + def test_invalid_counterparty_sponsor_equals_account(self): + """counterparty_sponsor identical to account must be rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipSet( + account=_ACCOUNT, + counterparty_sponsor=_ACCOUNT, + ) + self.assertIn( + "`counterparty_sponsor` must differ from `account`.", str(cm.exception) + ) + + # ------------------------------------------------------------------ # + # Concern 3 — mutually exclusive flag combinations # + # ------------------------------------------------------------------ # + + def test_invalid_set_and_clear_fee_flags(self): + """SET and CLEAR require-sign-for-fee flags are mutually exclusive.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + flags=( + SponsorshipSetFlag.TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_FEE + | SponsorshipSetFlag.TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_FEE + ), + ) + self.assertIn( + "`TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_FEE` and " + "`TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_FEE` are mutually exclusive.", + str(cm.exception), + ) + + def test_invalid_set_and_clear_reserve_flags(self): + """SET and CLEAR require-sign-for-reserve flags are mutually exclusive.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + flags=( + SponsorshipSetFlag.TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_RESERVE + | SponsorshipSetFlag.TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_RESERVE + ), + ) + self.assertIn( + "`TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_RESERVE` and " + "`TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_RESERVE` are mutually exclusive.", + str(cm.exception), + ) + + def test_invalid_delete_with_set_fee_flag(self): + """TF_DELETE_OBJECT can't combine with set fee flag.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + flags=( + SponsorshipSetFlag.TF_DELETE_OBJECT + | SponsorshipSetFlag.TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_FEE + ), + ) + self.assertIn( + "`TF_DELETE_OBJECT` cannot be combined with any set/clear flags.", + str(cm.exception), + ) + + def test_invalid_delete_with_clear_reserve_flag(self): + """TF_DELETE_OBJECT can't combine with clear reserve flag.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipSet( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + flags=( + SponsorshipSetFlag.TF_DELETE_OBJECT + | SponsorshipSetFlag.TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_RESERVE + ), + ) + self.assertIn( + "`TF_DELETE_OBJECT` cannot be combined with any set/clear flags.", + str(cm.exception), + ) diff --git a/tests/unit/models/transactions/test_sponsorship_transfer.py b/tests/unit/models/transactions/test_sponsorship_transfer.py new file mode 100644 index 000000000..f790f3a72 --- /dev/null +++ b/tests/unit/models/transactions/test_sponsorship_transfer.py @@ -0,0 +1,416 @@ +from unittest import TestCase + +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions.sponsor_signature import SponsorSignature +from xrpl.models.transactions.sponsorship_transfer import ( + SponsorshipTransfer, + SponsorshipTransferFlag, +) +from xrpl.models.transactions.transaction import Signer +from xrpl.models.transactions.types import TransactionType + +_ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" +_ACCOUNT2 = "rPyfep3gcLzkH4MYxKxJhE7bgUJfUCJM83" +_OBJECT_ID = "DB303FC1C7611B22C09E773B51044F6BEA02EF917DF59A2E2860871E167066A5" + + +class TestSponsorshipTransfer(TestCase): + def test_valid_minimal(self): + """SponsorshipTransfer with just account.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + ) + self.assertTrue(tx.is_valid()) + + def test_valid_with_object_id(self): + """Setting object_id (hex string, 64 chars).""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + ) + self.assertTrue(tx.is_valid()) + + def test_valid_with_sponsee(self): + """Setting sponsee.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + ) + self.assertTrue(tx.is_valid()) + + def test_valid_with_all_fields(self): + """Both object_id and sponsee set.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + sponsee=_ACCOUNT2, + ) + self.assertTrue(tx.is_valid()) + + def test_valid_end_flag(self): + """Using TF_SPONSORSHIP_END flag.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + flags=SponsorshipTransferFlag.TF_SPONSORSHIP_END, + ) + self.assertTrue(tx.is_valid()) + + def test_valid_create_flag(self): + """Using TF_SPONSORSHIP_CREATE flag (no sponsee — forbidden with CREATE).""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + flags=SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE, + ) + self.assertTrue(tx.is_valid()) + + def test_valid_reassign_flag(self): + """Using TF_SPONSORSHIP_REASSIGN flag.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + flags=SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN, + ) + self.assertTrue(tx.is_valid()) + + def test_has_correct_transaction_type(self): + """Verify transaction_type is TransactionType.SPONSORSHIP_TRANSFER.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + ) + self.assertEqual(tx.transaction_type, TransactionType.SPONSORSHIP_TRANSFER) + + def test_valid_with_flags_and_all_fields(self): + """All fields plus a flag.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + sponsee=_ACCOUNT2, + flags=SponsorshipTransferFlag.TF_SPONSORSHIP_END, + ) + self.assertTrue(tx.is_valid()) + + def test_to_dict_snake_case_fields(self): + """to_dict() produces snake_case field names and correct values.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + sponsee=_ACCOUNT2, + flags=SponsorshipTransferFlag.TF_SPONSORSHIP_END, + ) + d = tx.to_dict() + self.assertEqual(d["account"], _ACCOUNT) + self.assertEqual(d["object_id"], _OBJECT_ID) + self.assertEqual(d["sponsee"], _ACCOUNT2) + self.assertEqual(d["flags"], int(SponsorshipTransferFlag.TF_SPONSORSHIP_END)) + self.assertEqual(d["transaction_type"], "SponsorshipTransfer") + + def test_to_dict_omits_none_fields(self): + """to_dict() does not include fields set to None.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + ) + d = tx.to_dict() + self.assertNotIn("object_id", d) + self.assertNotIn("sponsee", d) + self.assertNotIn("flags", d) + self.assertNotIn("sponsor", d) + self.assertNotIn("sponsor_flags", d) + self.assertNotIn("sponsor_signature", d) + + def test_to_xrpl_camel_case_fields(self): + """to_xrpl() produces CamelCase field names.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + sponsee=_ACCOUNT2, + ) + xrpl_dict = tx.to_xrpl() + self.assertIn("Account", xrpl_dict) + self.assertIn("ObjectID", xrpl_dict) + self.assertIn("Sponsee", xrpl_dict) + self.assertIn("TransactionType", xrpl_dict) + self.assertEqual(xrpl_dict["TransactionType"], "SponsorshipTransfer") + self.assertNotIn("object_id", xrpl_dict) + self.assertNotIn("sponsee", xrpl_dict) + + def test_from_dict_roundtrip(self): + """Roundtrip through to_dict() and from_dict() preserves all fields.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + flags=SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN, + ) + roundtripped = SponsorshipTransfer.from_dict(tx.to_dict()) + self.assertEqual(roundtripped.account, tx.account) + self.assertEqual(roundtripped.object_id, tx.object_id) + self.assertEqual( + roundtripped.flags, + int(SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN), + ) + + def test_flags_interface_dict(self): + """Flags can be expressed as a SponsorshipTransferFlagInterface dict.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + flags={"TF_SPONSORSHIP_END": True}, + ) + self.assertTrue(tx.is_valid()) + d = tx.to_dict() + self.assertEqual(d["flags"], int(SponsorshipTransferFlag.TF_SPONSORSHIP_END)) + + def test_flags_interface_dict_create(self): + """FlagInterface dict with TF_SPONSORSHIP_CREATE (no sponsee).""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + flags={"TF_SPONSORSHIP_CREATE": True}, + ) + self.assertTrue(tx.is_valid()) + d = tx.to_dict() + self.assertEqual(d["flags"], int(SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE)) + + def test_flags_interface_dict_reassign(self): + """FlagInterface dict with TF_SPONSORSHIP_REASSIGN (no sponsee).""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + flags={"TF_SPONSORSHIP_REASSIGN": True}, + ) + self.assertTrue(tx.is_valid()) + d = tx.to_dict() + self.assertEqual( + d["flags"], int(SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN) + ) + + def test_has_flag_end(self): + """has_flag() returns True when TF_SPONSORSHIP_END is set.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + flags=SponsorshipTransferFlag.TF_SPONSORSHIP_END, + ) + self.assertTrue(tx.has_flag(int(SponsorshipTransferFlag.TF_SPONSORSHIP_END))) + self.assertFalse( + tx.has_flag(int(SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE)) + ) + + def test_with_sponsor_fee_fields(self): + """SponsorshipTransfer with sponsor and sponsor_flags (fee sponsorship).""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + sponsor=_ACCOUNT2, + sponsor_flags=0x00000001, # tfSponsorFee + ) + self.assertTrue(tx.is_valid()) + d = tx.to_dict() + self.assertEqual(d["sponsor"], _ACCOUNT2) + self.assertEqual(d["sponsor_flags"], 1) + + def test_with_sponsor_reserve_fields(self): + """SponsorshipTransfer with sponsor covering reserve costs.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + sponsee=_ACCOUNT2, + sponsor=_ACCOUNT2, + sponsor_flags=0x00000002, # tfSponsorReserve + ) + self.assertTrue(tx.is_valid()) + d = tx.to_dict() + self.assertEqual(d["sponsor_flags"], 2) + + def test_with_sponsor_signature(self): + """SponsorshipTransfer with a co-signed sponsor_signature.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + sponsee=_ACCOUNT2, + sponsor=_ACCOUNT2, + sponsor_flags=0x00000001, + sponsor_signature=SponsorSignature( + signing_pub_key="ED000000", + txn_signature="DEADBEEF", + ), + ) + self.assertTrue(tx.is_valid()) + d = tx.to_dict() + self.assertIn("sponsor_signature", d) + self.assertEqual(d["sponsor_signature"]["signing_pub_key"], "ED000000") + + def test_with_sponsor_multisig(self): + """SponsorshipTransfer with multi-signature sponsor.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + sponsor=_ACCOUNT2, + sponsor_flags=0x00000001, + sponsor_signature=SponsorSignature( + signers=[ + Signer( + account=_ACCOUNT2, + signing_pub_key="ED000000", + txn_signature="DEADBEEF", + ) + ] + ), + ) + self.assertTrue(tx.is_valid()) + + def test_flag_enum_values(self): + """Verify SponsorshipTransferFlag enum values match the spec.""" + self.assertEqual(int(SponsorshipTransferFlag.TF_SPONSORSHIP_END), 0x00000001) + self.assertEqual(int(SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE), 0x00000002) + self.assertEqual( + int(SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN), 0x00000004 + ) + + def test_immutable_frozen_dataclass(self): + """SponsorshipTransfer is frozen; mutating fields raises AttributeError.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + ) + with self.assertRaises(AttributeError): + tx.sponsee = _ACCOUNT2 # type: ignore[misc] + + def test_no_flags_in_dict_when_none(self): + """flags key is absent from to_dict() when no flags are set.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + ) + d = tx.to_dict() + self.assertNotIn("flags", d) + + def test_integer_flag_value(self): + """Passing an integer directly as flags is accepted.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + flags=0x00000001, + ) + self.assertTrue(tx.is_valid()) + self.assertEqual(tx.to_dict()["flags"], 1) + + # ------------------------------------------------------------------ # + # Concern 4 — SponsorshipTransfer flag validation # + # ------------------------------------------------------------------ # + + _MULTI_FLAG_MSG = ( + "Exactly one of `TF_SPONSORSHIP_END`, `TF_SPONSORSHIP_CREATE`, or " + "`TF_SPONSORSHIP_REASSIGN` may be set at a time." + ) + _SPONSEE_FLAG_MSG = ( + "`sponsee` cannot be set when `TF_SPONSORSHIP_CREATE` is active." + ) + + def test_invalid_end_and_create_flags(self): + """Setting TF_SPONSORSHIP_END and TF_SPONSORSHIP_CREATE together is rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipTransfer( + account=_ACCOUNT, + flags=( + SponsorshipTransferFlag.TF_SPONSORSHIP_END + | SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE + ), + ) + self.assertIn(self._MULTI_FLAG_MSG, str(cm.exception)) + + def test_invalid_end_and_reassign_flags(self): + """END and REASSIGN together is rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipTransfer( + account=_ACCOUNT, + flags=( + SponsorshipTransferFlag.TF_SPONSORSHIP_END + | SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN + ), + ) + self.assertIn(self._MULTI_FLAG_MSG, str(cm.exception)) + + def test_invalid_create_and_reassign_flags(self): + """CREATE and REASSIGN together is rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipTransfer( + account=_ACCOUNT, + flags=( + SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE + | SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN + ), + ) + self.assertIn(self._MULTI_FLAG_MSG, str(cm.exception)) + + def test_invalid_sponsee_with_create_flag(self): + """sponsee must not be set when TF_SPONSORSHIP_CREATE is active.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + sponsee=_ACCOUNT2, + flags=SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE, + ) + self.assertIn(self._SPONSEE_FLAG_MSG, str(cm.exception)) + + # ------------------------------------------------------------------ # + # Concern 5 — Transaction-level sponsor cross-field validation # + # ------------------------------------------------------------------ # + + def test_invalid_sponsor_equals_account(self): + """sponsor identical to account must be rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipTransfer( + account=_ACCOUNT, + sponsor=_ACCOUNT, + ) + self.assertIn("`sponsor` must differ from `account`.", str(cm.exception)) + + def test_invalid_sponsor_flags_without_sponsor(self): + """sponsor_flags without sponsor must be rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipTransfer( + account=_ACCOUNT, + sponsor_flags=0x00000001, + ) + self.assertIn( + "`sponsor_flags` requires `sponsor` to be set.", str(cm.exception) + ) + + def test_invalid_sponsor_signature_without_sponsor(self): + """sponsor_signature without sponsor must be rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipTransfer( + account=_ACCOUNT, + sponsor_signature=SponsorSignature( + signing_pub_key="ED000000", + txn_signature="DEADBEEF", + ), + ) + self.assertIn( + "`sponsor_signature` requires `sponsor` to be set.", str(cm.exception) + ) + + def test_invalid_sponsor_flags_bad_bits(self): + """sponsor_flags with bits beyond 0x3 must be rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipTransfer( + account=_ACCOUNT, + sponsor=_ACCOUNT2, + sponsor_flags=0x00000004, # bit 2 — outside allowed 0x1|0x2 + ) + self.assertIn( + "`sponsor_flags` may only use bits 0x1 (tfSponsorFee) " + "and 0x2 (tfSponsorReserve).", + str(cm.exception), + ) + + def test_invalid_sponsor_flags_combined_bad_bits(self): + """sponsor_flags mixing valid and invalid bits must be rejected.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipTransfer( + account=_ACCOUNT, + sponsor=_ACCOUNT2, + sponsor_flags=0x00000007, # 0x1 | 0x2 | 0x4 + ) + self.assertIn( + "`sponsor_flags` may only use bits 0x1 (tfSponsorFee) " + "and 0x2 (tfSponsorReserve).", + str(cm.exception), + ) diff --git a/xrpl/asyncio/transaction/main.py b/xrpl/asyncio/transaction/main.py index 35c53ce2c..9a2af55e2 100644 --- a/xrpl/asyncio/transaction/main.py +++ b/xrpl/asyncio/transaction/main.py @@ -583,6 +583,29 @@ async def _calculate_fee_per_transaction_type( ) base_fee += net_fee * counterparty_signers_count + # SponsorSignature with multi-sign + # Fee = (1 + |tx.Signers| + |SponsorSignature.Signers|) × base + if transaction.sponsor is not None: + sponsor_signers_count = await _fetch_counterparty_signers_count( + client, transaction.sponsor + ) + + if sponsor_signers_count > 1: + print( + ( + f"Warning: You are using autofill for a " + f"sponsored transaction: " + f"{transaction.to_dict()}. The fee " + "estimation is based on the number of " + "signers in the sponsor's SignerList. It " + "might be possible to optimize the fee by " + "considering the minimum quorum." + "\nIf you prefer optimized transaction fee," + " please fill the fee field manually." + ) + ) + base_fee += net_fee * (1 + sponsor_signers_count) + # Multi-signed/Multi-Account Batch Transactions # BaseFee × (1 + Number of Signatures Provided) if signers_count is not None and signers_count > 0: diff --git a/xrpl/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index ccd7b7990..4cb81edaa 100644 --- a/xrpl/core/binarycodec/definitions/definitions.json +++ b/xrpl/core/binarycodec/definitions/definitions.json @@ -850,6 +850,56 @@ "type": "UInt32" } ], + [ + "SponsoredOwnerCount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 69, + "type": "UInt32" + } + ], + [ + "SponsoringOwnerCount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 70, + "type": "UInt32" + } + ], + [ + "SponsoringAccountCount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 71, + "type": "UInt32" + } + ], + [ + "ReserveCount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 72, + "type": "UInt32" + } + ], + [ + "SponsorFlags", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 73, + "type": "UInt32" + } + ], [ "IndexNext", { @@ -1140,6 +1190,16 @@ "type": "UInt64" } ], + [ + "SponseeNode", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 32, + "type": "UInt64" + } + ], [ "EmailHash", { @@ -1530,6 +1590,16 @@ "type": "Hash256" } ], + [ + "ObjectID", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 39, + "type": "Hash256" + } + ], [ "Amount", { @@ -1800,6 +1870,26 @@ "type": "Amount" } ], + [ + "FeeAmount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 32, + "type": "Amount" + } + ], + [ + "MaxFee", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 33, + "type": "Amount" + } + ], [ "PublicKey", { @@ -2310,6 +2400,56 @@ "type": "AccountID" } ], + [ + "Sponsor", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 27, + "type": "AccountID" + } + ], + [ + "HighSponsor", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 28, + "type": "AccountID" + } + ], + [ + "LowSponsor", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 29, + "type": "AccountID" + } + ], + [ + "CounterpartySponsor", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 30, + "type": "AccountID" + } + ], + [ + "Sponsee", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 31, + "type": "AccountID" + } + ], [ "Number", { @@ -2840,6 +2980,16 @@ "type": "STObject" } ], + [ + "SponsorSignature", + { + "isSerialized": true, + "isSigningField": false, + "isVLEncoded": false, + "nth": 38, + "type": "STObject" + } + ], [ "Signers", { @@ -3462,7 +3612,8 @@ "Ticket": 84, "Vault": 132, "XChainOwnedClaimID": 113, - "XChainOwnedCreateAccountClaimID": 116 + "XChainOwnedCreateAccountClaimID": 116, + "Sponsorship": 144 }, "TRANSACTION_RESULTS": { "tecAMM_ACCOUNT": 168, @@ -3549,7 +3700,6 @@ "tecXCHAIN_SELF_COMMIT": 184, "tecXCHAIN_SENDING_ACCOUNT_MISMATCH": 179, "tecXCHAIN_WRONG_CHAIN": 176, - "tefALREADY": -198, "tefBAD_ADD_AUTH": -197, "tefBAD_AUTH": -196, @@ -3572,7 +3722,6 @@ "tefPAST_SEQ": -190, "tefTOO_BIG": -181, "tefWRONG_PRIOR": -189, - "telBAD_DOMAIN": -398, "telBAD_PATH_COUNT": -397, "telBAD_PUBLIC_KEY": -396, @@ -3590,7 +3739,6 @@ "telNO_DST_PARTIAL": -393, "telREQUIRES_NETWORK_ID": -385, "telWRONG_NETWORK": -386, - "temARRAY_EMPTY": -253, "temARRAY_TOO_LARGE": -252, "temBAD_AMM_TOKENS": -261, @@ -3641,7 +3789,6 @@ "temXCHAIN_BRIDGE_BAD_REWARD_AMOUNT": -255, "temXCHAIN_BRIDGE_NONDOOR_OWNER": -257, "temXCHAIN_EQUAL_DOOR_ACCOUNTS": -260, - "terADDRESS_COLLISION": -86, "terFUNDS_SPENT": -98, "terINSUF_FEE_B": -97, @@ -3657,7 +3804,6 @@ "terPRE_TICKET": -88, "terQUEUED": -89, "terRETRY": -99, - "tesSUCCESS": 0 }, "TRANSACTION_TYPES": { @@ -3736,7 +3882,9 @@ "XChainCommit": 42, "XChainCreateBridge": 48, "XChainCreateClaimID": 41, - "XChainModifyBridge": 47 + "XChainModifyBridge": 47, + "SponsorshipSet": 86, + "SponsorshipTransfer": 85 }, "TYPES": { "AccountID": 8, diff --git a/xrpl/core/binarycodec/definitions/definitions.py b/xrpl/core/binarycodec/definitions/definitions.py index 845eca767..2ef5587c9 100644 --- a/xrpl/core/binarycodec/definitions/definitions.py +++ b/xrpl/core/binarycodec/definitions/definitions.py @@ -72,6 +72,8 @@ def load_definitions(filename: str = "definitions.json") -> Dict[str, Any]: "PaymentBurn": 65546, "MPTokenIssuanceLock": 65547, "MPTokenIssuanceUnlock": 65548, + "SponsorFee": 65549, + "SponsorReserve": 65550, } _tx_delegations = { diff --git a/xrpl/models/requests/account_objects.py b/xrpl/models/requests/account_objects.py index 07aa9154f..681c6b655 100644 --- a/xrpl/models/requests/account_objects.py +++ b/xrpl/models/requests/account_objects.py @@ -38,6 +38,7 @@ class AccountObjectType(str, Enum): PAYMENT_CHANNEL = "payment_channel" PERMISSIONED_DOMAIN = "permissioned_domain" SIGNER_LIST = "signer_list" + SPONSORSHIP = "sponsorship" STATE = "state" TICKET = "ticket" VAULT = "vault" @@ -65,6 +66,7 @@ class AccountObjects(Request, LookupByLedgerRequest): """ method: RequestMethod = field(default=RequestMethod.ACCOUNT_OBJECTS, init=False) + sponsored: Optional[bool] = None type: Optional[AccountObjectType] = None deletion_blockers_only: bool = False limit: Optional[int] = None diff --git a/xrpl/models/requests/ledger_entry.py b/xrpl/models/requests/ledger_entry.py index 01afb02ae..42f1a08b6 100644 --- a/xrpl/models/requests/ledger_entry.py +++ b/xrpl/models/requests/ledger_entry.py @@ -43,6 +43,7 @@ class LedgerEntryType(str, Enum): PERMISSIONED_DOMAIN = "permissioned_domain" SIGNER_LIST = "signer_list" SINGLE_ASSET_VAULT = "vault" + SPONSORSHIP = "sponsorship" STATE = "state" TICKET = "ticket" MPT_ISSUANCE = "mpt_issuance" @@ -282,6 +283,26 @@ class Ticket(BaseModel): """ +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class Sponsorship(BaseModel): + """Required fields for requesting a Sponsorship if not querying by object ID.""" + + sponsor: str = REQUIRED + """ + This field is required. + + :meta hide-value: + """ + + sponsee: str = REQUIRED + """ + This field is required. + + :meta hide-value: + """ + + @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) class Vault(BaseModel): @@ -363,6 +384,7 @@ class LedgerEntry(Request, LookupByLedgerRequest): payment_channel: Optional[str] = None permissioned_domain: Optional[Union[str, PermissionedDomain]] = None ripple_state: Optional[RippleState] = None + sponsorship: Optional[Union[str, Sponsorship]] = None vault: Optional[Union[str, Vault]] = None ticket: Optional[Union[str, Ticket]] = None bridge_account: Optional[str] = None @@ -399,6 +421,7 @@ def _get_errors(self: Self) -> Dict[str, str]: self.payment_channel, self.permissioned_domain, self.ripple_state, + self.sponsorship, self.vault, self.ticket, self.xchain_claim_id, diff --git a/xrpl/models/transactions/__init__.py b/xrpl/models/transactions/__init__.py index 182faca8e..666231e76 100644 --- a/xrpl/models/transactions/__init__.py +++ b/xrpl/models/transactions/__init__.py @@ -100,6 +100,17 @@ from xrpl.models.transactions.permissioned_domain_set import PermissionedDomainSet from xrpl.models.transactions.set_regular_key import SetRegularKey from xrpl.models.transactions.signer_list_set import SignerEntry, SignerListSet +from xrpl.models.transactions.sponsor_signature import SponsorSignature +from xrpl.models.transactions.sponsorship_set import ( + SponsorshipSet, + SponsorshipSetFlag, + SponsorshipSetFlagInterface, +) +from xrpl.models.transactions.sponsorship_transfer import ( + SponsorshipTransfer, + SponsorshipTransferFlag, + SponsorshipTransferFlagInterface, +) from xrpl.models.transactions.ticket_create import TicketCreate from xrpl.models.transactions.transaction import ( Memo, @@ -226,6 +237,13 @@ "Signer", "SignerEntry", "SignerListSet", + "SponsorSignature", + "SponsorshipSet", + "SponsorshipSetFlag", + "SponsorshipSetFlagInterface", + "SponsorshipTransfer", + "SponsorshipTransferFlag", + "SponsorshipTransferFlagInterface", "TicketCreate", "Transaction", "TransactionFlag", diff --git a/xrpl/models/transactions/account_set.py b/xrpl/models/transactions/account_set.py index 2e60065a7..5e02dc5a2 100644 --- a/xrpl/models/transactions/account_set.py +++ b/xrpl/models/transactions/account_set.py @@ -112,6 +112,10 @@ class AccountSetAsfFlag(int, Enum): used in Escrow. """ + ASF_DISALLOW_INCOMING_SPONSOR = 19 + """Disallow other accounts from creating Sponsorship objects directed at this + account.""" + class AccountSetFlag(int, Enum): """ diff --git a/xrpl/models/transactions/delegate_set.py b/xrpl/models/transactions/delegate_set.py index b4cf5ba1b..574ca19a5 100644 --- a/xrpl/models/transactions/delegate_set.py +++ b/xrpl/models/transactions/delegate_set.py @@ -68,6 +68,12 @@ class GranularPermission(str, Enum): MPTOKEN_ISSUANCE_UNLOCK = "MPTokenIssuanceUnlock" """Use the MPTIssuanceSet transaction to unlock (unfreeze) a holder.""" + SPONSOR_FEE = "SponsorFee" + """Delegates ability to sponsor transaction fees.""" + + SPONSOR_RESERVE = "SponsorReserve" + """Delegates ability to sponsor object and account reserves.""" + @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) diff --git a/xrpl/models/transactions/payment.py b/xrpl/models/transactions/payment.py index a73d4dea4..ba951d454 100644 --- a/xrpl/models/transactions/payment.py +++ b/xrpl/models/transactions/payment.py @@ -50,6 +50,12 @@ class PaymentFlag(int, Enum): See `Limit `_ Quality for details. """ + TF_SPONSOR_CREATED_ACCOUNT = 0x00080000 + """ + Sponsor the creation of the destination account. The sending account + pays the account reserve for the new account. + """ + class PaymentFlagInterface(TransactionFlagInterface): """ @@ -62,6 +68,7 @@ class PaymentFlagInterface(TransactionFlagInterface): TF_NO_RIPPLE_DIRECT: bool TF_PARTIAL_PAYMENT: bool TF_LIMIT_QUALITY: bool + TF_SPONSOR_CREATED_ACCOUNT: bool @require_kwargs_on_init @@ -190,4 +197,22 @@ def _get_errors(self: Self) -> Dict[str, str]: if err: errors["domain_id"] = err + # TF_SPONSOR_CREATED_ACCOUNT is mutually exclusive with routing/quality flags. + # Guard against bad flags type (str etc.) — type errors are reported elsewhere. + if isinstance(self.flags, (int, dict, list)) and self.has_flag( + PaymentFlag.TF_SPONSOR_CREATED_ACCOUNT + ): + incompatible = [] + if self.has_flag(PaymentFlag.TF_NO_RIPPLE_DIRECT): + incompatible.append("`TF_NO_RIPPLE_DIRECT`") + if self.has_flag(PaymentFlag.TF_PARTIAL_PAYMENT): + incompatible.append("`TF_PARTIAL_PAYMENT`") + if self.has_flag(PaymentFlag.TF_LIMIT_QUALITY): + incompatible.append("`TF_LIMIT_QUALITY`") + if incompatible: + errors["flags"] = ( + "`TF_SPONSOR_CREATED_ACCOUNT` cannot be combined with " + f"{', '.join(incompatible)}." + ) + return errors diff --git a/xrpl/models/transactions/sponsor_signature.py b/xrpl/models/transactions/sponsor_signature.py new file mode 100644 index 000000000..02e400daf --- /dev/null +++ b/xrpl/models/transactions/sponsor_signature.py @@ -0,0 +1,60 @@ +"""Model for the SponsorSignature inner object used in SponsorshipSet.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List, Optional + +from typing_extensions import Self + +from xrpl.models.base_model import BaseModel +from xrpl.models.transactions.transaction import Signer +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class SponsorSignature(BaseModel): + """ + Signature payload supplied by the sponsor. + Fields: + - signing_pub_key: hex-encoded public key of the sponsor (required if + txn_signature is set). + - txn_signature: hex-encoded signature over the canonical transaction + (required if signing_pub_key is set). + - signers: optional multisign array reusing the standard Signer objects. + """ + + signing_pub_key: Optional[str] = None + txn_signature: Optional[str] = None + signers: Optional[List[Signer]] = None + + def _get_errors(self: Self) -> Dict[str, str]: + errors = super()._get_errors() + + has_single_sig = ( + self.signing_pub_key is not None or self.txn_signature is not None + ) + has_multi_sig = self.signers is not None + + if has_single_sig and has_multi_sig: + errors["SponsorSignature"] = ( + "Cannot set both single-signature fields " + "(`signing_pub_key`/`txn_signature`) and `signers`." + ) + elif not has_single_sig and not has_multi_sig: + errors["SponsorSignature"] = ( + "Must provide either (`signing_pub_key` + `txn_signature`) " + "for single-signature or `signers` for multi-signature." + ) + elif has_single_sig: + if self.signing_pub_key is None: + errors["signing_pub_key"] = ( + "`signing_pub_key` is required when `txn_signature` is set." + ) + if self.txn_signature is None: + errors["txn_signature"] = ( + "`txn_signature` is required when `signing_pub_key` is set." + ) + + return errors diff --git a/xrpl/models/transactions/sponsorship_set.py b/xrpl/models/transactions/sponsorship_set.py new file mode 100644 index 000000000..797bd5f5e --- /dev/null +++ b/xrpl/models/transactions/sponsorship_set.py @@ -0,0 +1,149 @@ +"""Model for SponsorshipSet transaction type.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Optional + +from typing_extensions import Self + +from xrpl.models.amounts import Amount +from xrpl.models.transactions.transaction import Transaction, TransactionFlagInterface +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +class SponsorshipSetFlag(int, Enum): + """ + Enum for SponsorshipSet Transaction Flags. + + Transactions of the SponsorshipSet type support additional values in the + Flags field. This enum represents those options. + """ + + TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_FEE = 0x00010000 + """Set the lsfSponsorshipRequireSignForFee flag on the Sponsorship object.""" + + TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_FEE = 0x00020000 + """Clear the lsfSponsorshipRequireSignForFee flag on the Sponsorship object.""" + + TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_RESERVE = 0x00040000 + """Set the lsfSponsorshipRequireSignForReserve flag on the Sponsorship object.""" + + TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_RESERVE = 0x00080000 + """Clear the lsfSponsorshipRequireSignForReserve flag on the Sponsorship object.""" + + TF_DELETE_OBJECT = 0x00100000 + """Delete the Sponsorship object.""" + + +class SponsorshipSetFlagInterface(TransactionFlagInterface): + """ + Transactions of the SponsorshipSet type support additional values in the + Flags field. This TypedDict represents those options. + """ + + TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_FEE: bool + TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_FEE: bool + TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_RESERVE: bool + TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_RESERVE: bool + TF_DELETE_OBJECT: bool + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class SponsorshipSet(Transaction): + """ + Represents a SponsorshipSet transaction, which creates or modifies + sponsorship objects on the XRP Ledger. + """ + + counterparty_sponsor: Optional[str] = None + """The account that is the counterparty sponsor.""" + + sponsee: Optional[str] = None + """The account that is being sponsored.""" + + fee_amount: Optional[Amount] = None + """The fee amount to be sponsored.""" + + max_fee: Optional[Amount] = None + """The maximum fee that can be sponsored.""" + + reserve_count: Optional[int] = None + """The number of reserves to sponsor.""" + + transaction_type: TransactionType = field( + default=TransactionType.SPONSORSHIP_SET, + init=False, + ) + + def _get_errors(self: Self) -> Dict[str, str]: + errors = super()._get_errors() + + # ── Concern 2: exactly one of counterparty_sponsor / sponsee ────────── + has_counterparty = self.counterparty_sponsor is not None + has_sponsee = self.sponsee is not None + + if has_counterparty == has_sponsee: # neither or both + errors["counterparty_sponsor"] = ( + "Exactly one of `counterparty_sponsor` or `sponsee` must be present " + "(not both, not neither)." + ) + elif has_counterparty and self.counterparty_sponsor == self.account: + errors["counterparty_sponsor"] = ( + "`counterparty_sponsor` must differ from `account`." + ) + elif has_sponsee and self.sponsee == self.account: + errors["sponsee"] = "`sponsee` must differ from `account`." + + # Determine effective flags for the remaining checks. + # has_flag() handles None / int / list / dict safely. + delete_obj = self.has_flag(int(SponsorshipSetFlag.TF_DELETE_OBJECT)) + set_fee = self.has_flag( + int(SponsorshipSetFlag.TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_FEE) + ) + clear_fee = self.has_flag( + int(SponsorshipSetFlag.TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_FEE) + ) + set_res = self.has_flag( + int(SponsorshipSetFlag.TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_RESERVE) + ) + clear_res = self.has_flag( + int(SponsorshipSetFlag.TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_RESERVE) + ) + + # ── Concern 3: mutually exclusive flag pairs ─────────────────────────── + if set_fee and clear_fee: + errors["flags"] = ( + "`TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_FEE` and " + "`TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_FEE` are mutually exclusive." + ) + if set_res and clear_res: + errors["flags"] = ( + "`TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_RESERVE`" + " and " + "`TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_RESERVE`" + " are mutually exclusive." + ) + if delete_obj and (set_fee or clear_fee or set_res or clear_res): + errors["flags"] = ( + "`TF_DELETE_OBJECT` cannot be combined with any set/clear flags." + ) + + # ── Concern 1: fee_amount / max_fee must be XRP (not IOU) ───────────── + # C++: if (!isXRP(amount)) return temBAD_AMOUNT (only for non-delete) + if not delete_obj: + if self.fee_amount is not None and not isinstance(self.fee_amount, str): + errors["fee_amount"] = ( + "`fee_amount` must be XRP drops (a string), " + "not an issued currency or MPT amount." + ) + if self.max_fee is not None and not isinstance(self.max_fee, str): + errors["max_fee"] = ( + "`max_fee` must be XRP drops (a string), " + "not an issued currency or MPT amount." + ) + + return errors diff --git a/xrpl/models/transactions/sponsorship_transfer.py b/xrpl/models/transactions/sponsorship_transfer.py new file mode 100644 index 000000000..133cf4824 --- /dev/null +++ b/xrpl/models/transactions/sponsorship_transfer.py @@ -0,0 +1,84 @@ +"""Model for SponsorshipTransfer transaction type.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Optional + +from typing_extensions import Self + +from xrpl.models.transactions.transaction import Transaction, TransactionFlagInterface +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +class SponsorshipTransferFlag(int, Enum): + """ + Enum for SponsorshipTransfer Transaction Flags. + + Transactions of the SponsorshipTransfer type support additional values in the + Flags field. This enum represents those options. + """ + + TF_SPONSORSHIP_END = 0x00000001 + """End sponsorship of an object.""" + + TF_SPONSORSHIP_CREATE = 0x00000002 + """Create sponsorship of an object.""" + + TF_SPONSORSHIP_REASSIGN = 0x00000004 + """Reassign sponsorship of an object.""" + + +class SponsorshipTransferFlagInterface(TransactionFlagInterface): + """ + Transactions of the SponsorshipTransfer type support additional values in the + Flags field. This TypedDict represents those options. + """ + + TF_SPONSORSHIP_END: bool + TF_SPONSORSHIP_CREATE: bool + TF_SPONSORSHIP_REASSIGN: bool + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class SponsorshipTransfer(Transaction): + """ + Represents a SponsorshipTransfer transaction, which transfers + sponsorship of ledger objects on the XRP Ledger. + """ + + object_id: Optional[str] = None + """The ID of the ledger object whose sponsorship is being transferred.""" + + sponsee: Optional[str] = None + """The account that is being sponsored.""" + + transaction_type: TransactionType = field( + default=TransactionType.SPONSORSHIP_TRANSFER, + init=False, + ) + + def _get_errors(self: Self) -> Dict[str, str]: + errors = super()._get_errors() + + end = self.has_flag(int(SponsorshipTransferFlag.TF_SPONSORSHIP_END)) + create = self.has_flag(int(SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE)) + reassign = self.has_flag(int(SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN)) + + # Exactly one of the three operation flags may be set at a time. + if sum([end, create, reassign]) > 1: + errors["flags"] = ( + "Exactly one of `TF_SPONSORSHIP_END`, `TF_SPONSORSHIP_CREATE`, or " + "`TF_SPONSORSHIP_REASSIGN` may be set at a time." + ) + + # sponsee is not used for CREATE operation. + if self.sponsee is not None and create: + errors["sponsee"] = ( + "`sponsee` cannot be set when `TF_SPONSORSHIP_CREATE` is active." + ) + + return errors diff --git a/xrpl/models/transactions/transaction.py b/xrpl/models/transactions/transaction.py index df62de5dd..70ad649e8 100644 --- a/xrpl/models/transactions/transaction.py +++ b/xrpl/models/transactions/transaction.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from enum import Enum from hashlib import sha512 -from typing import Any, Dict, List, Optional, Type, Union, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union, cast from typing_extensions import Final, Self @@ -26,6 +26,9 @@ from xrpl.models.types import XRPL_VALUE_TYPE from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init +if TYPE_CHECKING: + from xrpl.models.transactions.sponsor_signature import SponsorSignature + _TRANSACTION_HASH_PREFIX: Final[int] = 0x54584E00 @@ -277,6 +280,15 @@ class Transaction(BaseModel): delegate: Optional[str] = None """The delegate account that is sending the transaction.""" + sponsor: Optional[str] = None + """The sponsoring account covering fees or reserves for this transaction.""" + + sponsor_flags: Optional[int] = None + """Sponsorship type flags (tfSponsorFee=0x00000001, tfSponsorReserve=0x00000002).""" + + sponsor_signature: Optional[SponsorSignature] = None + """The sponsor's signing information for co-signed sponsorship.""" + def _get_errors(self: Self) -> Dict[str, str]: errors = super()._get_errors() if self.ticket_sequence is not None and ( @@ -291,6 +303,32 @@ def _get_errors(self: Self) -> Dict[str, str]: if self.account == self.delegate: errors["delegate"] = "Account and delegate addresses cannot be the same" + # ── Sponsor cross-field checks ───────────────────────────────────────── + if self.sponsor is not None and self.sponsor == self.account: + errors["sponsor"] = "`sponsor` must differ from `account`." + + if self.sponsor_flags is not None and self.sponsor is None: + errors["sponsor_flags"] = "`sponsor_flags` requires `sponsor` to be set." + elif self.sponsor_flags is not None and (self.sponsor_flags & ~0x3) != 0: + errors["sponsor_flags"] = ( + "`sponsor_flags` may only use bits 0x1 (tfSponsorFee) " + "and 0x2 (tfSponsorReserve)." + ) + + if self.sponsor_signature is not None and self.sponsor is None: + errors["sponsor_signature"] = ( + "`sponsor_signature` requires `sponsor` to be set." + ) + + # Pseudo-transactions and Batch cannot be sponsored (XLS-68 §8.3.4). + if self.sponsor is not None and ( + isinstance(self.transaction_type, PseudoTransactionType) + or self.transaction_type == TransactionType.BATCH + ): + errors["sponsor"] = ( + "Pseudo-transactions and Batch transactions " "cannot be sponsored." + ) + return errors def to_dict(self: Self) -> Dict[str, Any]: @@ -547,3 +585,11 @@ def from_xrpl(cls: Type[Self], value: Union[str, Dict[str, Any]]) -> Self: del processed_value["deliver_max"] return cls.from_dict(processed_value) + + +# Late import to avoid circular dependency (sponsor_signature imports Signer from this +# module). This makes SponsorSignature available in the module namespace so that +# get_type_hints() can resolve the forward reference in Transaction.sponsor_signature. +from xrpl.models.transactions.sponsor_signature import ( # noqa: E402, F811 + SponsorSignature, +) diff --git a/xrpl/models/transactions/types/transaction_type.py b/xrpl/models/transactions/types/transaction_type.py index 309d72837..ab6b4c731 100644 --- a/xrpl/models/transactions/types/transaction_type.py +++ b/xrpl/models/transactions/types/transaction_type.py @@ -61,6 +61,8 @@ class TransactionType(str, Enum): PERMISSIONED_DOMAIN_DELETE = "PermissionedDomainDelete" SET_REGULAR_KEY = "SetRegularKey" SIGNER_LIST_SET = "SignerListSet" + SPONSORSHIP_SET = "SponsorshipSet" + SPONSORSHIP_TRANSFER = "SponsorshipTransfer" TICKET_CREATE = "TicketCreate" TRUST_SET = "TrustSet" VAULT_CREATE = "VaultCreate" diff --git a/xrpl/transaction/__init__.py b/xrpl/transaction/__init__.py index e46e9c5e4..6fd0d02b3 100644 --- a/xrpl/transaction/__init__.py +++ b/xrpl/transaction/__init__.py @@ -23,16 +23,19 @@ ) from xrpl.transaction.multisign import multisign from xrpl.transaction.reliable_submission import submit_and_wait +from xrpl.transaction.sponsor_signer import combine_sponsor_signers, sign_as_sponsor __all__ = [ "autofill", "autofill_and_sign", "combine_batch_signers", "combine_loanset_counterparty_signers", + "combine_sponsor_signers", "compute_signature", "multisign", "sign", "sign_and_submit", + "sign_as_sponsor", "sign_loan_set_by_counterparty", "sign_multiaccount_batch", "simulate", diff --git a/xrpl/transaction/sponsor_signer.py b/xrpl/transaction/sponsor_signer.py new file mode 100644 index 000000000..695771bd5 --- /dev/null +++ b/xrpl/transaction/sponsor_signer.py @@ -0,0 +1,293 @@ +"""Helper functions for co-signing transactions as a fee/reserve sponsor (XLS-0068). + +XLS-0068 introduces a sponsored-fee/reserve model where a *sponsor* account can +cover the transaction fee and/or object reserve costs on behalf of a *sponsee*. +When ``lsfSponsorshipRequireSignForFee`` / ``lsfSponsorshipRequireSignForReserve`` +is set, or when there is no pre-funded ``Sponsorship`` ledger object, the sponsor +must co-sign each transaction before it is submitted. + +Signing flow (per rippled implementation): + +1. The sponsee constructs and autofills the transaction, setting the ``sponsor`` + and ``sponsor_flags`` fields. +2. The sponsee signs the transaction with the standard ``xrpl.transaction.sign`` + helper (this sets ``SigningPubKey``). +3. The sponsor calls :func:`sign_as_sponsor` on the signed transaction to add + their ``SponsorSignature``. +4. The sponsee submits the fully-signed transaction. + +Both the sponsor and the sponsee sign the same canonical signing data +(``HashPrefix::txSign`` + transaction fields). The sponsor's signature and +public key live inside the ``SponsorSignature`` inner object, while the +sponsee's live at the top level. + +For sponsor accounts that require multiple keys (multi-sig), each key holder +calls :func:`sign_as_sponsor` with ``multisign=True``, then all contributions +are merged with :func:`combine_sponsor_signers`` before the sponsee signs. + +This module mirrors the API of :mod:`xrpl.transaction.batch_signers` and +:mod:`xrpl.transaction.counterparty_signer` for consistency. +""" + +from dataclasses import dataclass +from typing import Dict, List, Optional, Union + +from xrpl.constants import XRPLException +from xrpl.core.addresscodec import ( + decode_classic_address, + is_valid_xaddress, + xaddress_to_classic_address, +) +from xrpl.core.binarycodec import encode, encode_for_multisigning, encode_for_signing +from xrpl.core.keypairs import sign as keypairs_sign +from xrpl.models.transactions import Transaction +from xrpl.models.transactions.sponsor_signature import SponsorSignature +from xrpl.models.transactions.transaction import Signer +from xrpl.wallet import Wallet + + +@dataclass +class SignSponsorResult: + """Result of signing a transaction as the fee/reserve sponsor.""" + + tx: Transaction + """The transaction object with ``sponsor_signature`` populated.""" + + tx_blob: str + """Serialised hex blob of the transaction.""" + + +@dataclass +class CombineSponsorSignersResult: + """Result of merging multiple sponsor multi-signatures into one transaction.""" + + tx: Transaction + """The transaction object with all sponsor signers merged.""" + + tx_blob: str + """Serialised hex blob ready to be signed by the sponsee and submitted.""" + + +def sign_as_sponsor( + wallet: Wallet, + transaction: Union[Transaction, str], + multisign: Union[bool, str] = False, +) -> SignSponsorResult: + """ + Sign a transaction as the fee/reserve sponsor (XLS-0068). + + The sponsor's cryptographic approval is placed in the ``SponsorSignature`` + field of the transaction. The sponsor signs the **same** canonical + transaction data that the sponsee will sign (``HashPrefix::txSign`` + + signing-field serialisation), so the sponsee's ``SigningPubKey`` must + already be present on the transaction when the sponsor signs. + + + Args: + wallet: The sponsor's wallet used for signing. + transaction: The autofilled transaction to co-sign. Can be either a + :class:`~xrpl.models.transactions.Transaction` object or a + hex-encoded transaction blob. + multisign: Pass ``True`` (or a classic/x-address string for regular-key + usage) to produce a multi-signature entry inside + ``SponsorSignature.Signers``. Defaults to ``False`` (single-sig). + + Returns: + A :class:`SignSponsorResult` containing: + + - ``tx`` – the transaction with ``sponsor_signature`` added. + - ``tx_blob`` – the serialised transaction blob (no sponsee sig yet). + + Raises: + XRPLException: If the transaction has no ``sponsor`` field, if + ``fee`` has not been autofilled yet, if a non-multisig + ``sponsor_signature`` already exists when ``multisign=False``, + or if ``signing_pub_key`` is empty + """ + if isinstance(transaction, str): + tx = Transaction.from_blob(transaction) + else: + tx = transaction + + if tx.sponsor is None: + raise XRPLException( + "Transaction must have a `sponsor` field set before the sponsor signs. " + "Set `sponsor` (and `sponsor_flags`) on the transaction and autofill it " + "first." + ) + + if tx.fee is None: + raise XRPLException( + "Transaction `fee` must be autofilled before the sponsor signs, " + "because the sponsor is approving the exact fee amount." + ) + + if not multisign and tx.sponsor_signature is not None: + raise XRPLException( + "Transaction already has a `sponsor_signature`. " + "Use multisign=True to add additional signatures to " + "`SponsorSignature.Signers`." + ) + + # The sponsor signs the same canonical data as the sponsee. That data + # includes SigningPubKey (a signing field) + if not tx.signing_pub_key: + raise XRPLException( + "Transaction `signing_pub_key` cannot be empty " + "during Sponsor signature step." + ) + tx_dict = tx.to_dict() + tx = Transaction.from_dict(tx_dict) + + tx_json = tx.to_xrpl() + + # Resolve multisign address (if any). + multisign_address: Optional[str] = None + if isinstance(multisign, str): + multisign_address = multisign + elif multisign: + multisign_address = wallet.address + + if multisign_address: + classic_address = ( + xaddress_to_classic_address(multisign_address)[0] + if is_valid_xaddress(multisign_address) + else multisign_address + ) + signature = keypairs_sign( + bytes.fromhex(encode_for_multisigning(tx_json, classic_address)), + wallet.private_key, + ) + sponsor_sig = SponsorSignature( + signers=[ + Signer( + account=classic_address, + signing_pub_key=wallet.public_key, + txn_signature=signature, + ) + ] + ) + else: + signature = keypairs_sign( + bytes.fromhex(encode_for_signing(tx_json)), + wallet.private_key, + ) + sponsor_sig = SponsorSignature( + signing_pub_key=wallet.public_key, + txn_signature=signature, + ) + + tx_dict = tx.to_dict() + tx_dict["sponsor_signature"] = sponsor_sig + signed_tx = Transaction.from_dict(tx_dict) + serialized = encode(signed_tx.to_xrpl()) + + return SignSponsorResult( + tx=signed_tx, + tx_blob=serialized, + ) + + +def combine_sponsor_signers( + transactions: List[Union[Transaction, str]], +) -> CombineSponsorSignersResult: + """ + Merge multiple sponsor multi-signatures into a single transaction. + + When the sponsor account requires multiple keys, each key holder calls + :func:`sign_as_sponsor` with ``multisign=True``. Pass all the resulting + transactions here to produce one transaction whose + ``SponsorSignature.Signers`` array contains every contribution. The + combined transaction is then handed to the sponsee, who adds their own + signature before submitting. + + Args: + transactions: A list of transactions (objects or hex blobs), each + containing a ``SponsorSignature`` with a non-empty ``Signers`` + array produced by :func:`sign_as_sponsor` with ``multisign=True``. + + Returns: + A :class:`CombineSponsorSignersResult` containing: + + - ``tx`` – the combined transaction object. + - ``tx_blob`` – the serialised hex blob ready for the sponsee to sign + and submit. + + Raises: + XRPLException: If ``transactions`` is empty, any transaction lacks + ``SponsorSignature.Signers``, or the transactions differ in fields + other than ``SponsorSignature.Signers``. + """ + if len(transactions) == 0: + raise XRPLException("There are 0 transactions to combine.") + + decoded: List[Transaction] = [] + for tx_or_blob in transactions: + tx = ( + Transaction.from_blob(tx_or_blob) + if isinstance(tx_or_blob, str) + else tx_or_blob + ) + if ( + tx.sponsor_signature is None + or tx.sponsor_signature.signers is None + or len(tx.sponsor_signature.signers) == 0 + ): + raise XRPLException( + "All transactions must have a `SponsorSignature` with a non-empty " + "`Signers` array. Use multisign=True when calling sign_as_sponsor." + ) + decoded.append(tx) + + _validate_sponsor_transaction_equivalence(decoded) + combined = _get_transaction_with_all_sponsor_signers(decoded) + + return CombineSponsorSignersResult( + tx=combined, + tx_blob=encode(combined.to_xrpl()), + ) + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + + +def _validate_sponsor_transaction_equivalence(transactions: List[Transaction]) -> None: + """Raise if any transaction differs from the first, ignoring Signers.""" + if len(transactions) <= 1: + return + + def _strip_signers(tx: Transaction) -> Dict[str, object]: + d = tx.to_xrpl() + if "SponsorSignature" in d: + d["SponsorSignature"] = {**d["SponsorSignature"], "Signers": None} + return d + + example = _strip_signers(transactions[0]) + for tx in transactions[1:]: + if _strip_signers(tx) != example: + raise XRPLException( + "All transactions must be identical except for " + "SponsorSignature.Signers." + ) + + +def _get_transaction_with_all_sponsor_signers( + transactions: List[Transaction], +) -> Transaction: + """Collect and sort all Signers from every transaction's SponsorSignature.""" + all_signers: List[Signer] = [] + for tx in transactions: + if ( + tx.sponsor_signature is not None + and tx.sponsor_signature.signers is not None + ): + all_signers.extend(tx.sponsor_signature.signers) + + # XRPL requires signers sorted by account ID (ascending). + all_signers.sort(key=lambda s: decode_classic_address(s.account).hex().upper()) + + tx_dict = transactions[0].to_dict() + tx_dict["sponsor_signature"] = SponsorSignature(signers=all_signers) + return Transaction.from_dict(tx_dict)