From 900b20d05a5b05011c479ccbbe9bbe6d576194c5 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 12 Mar 2026 12:04:39 -0700 Subject: [PATCH 01/14] initial draft of the feature implementation from AI agent --- CHANGELOG.md | 14 ++ .../reqs/test_account_objects_sponsored.py | 101 +++++++++++ .../transactions/test_sponsorship_set.py | 103 +++++++++++ .../transactions/test_sponsorship_transfer.py | 77 +++++++++ .../test_better_transaction_flags.py | 1 + .../unit/models/transactions/test_payment.py | 14 ++ .../test_sponsor_common_fields.py | 97 +++++++++++ .../transactions/test_sponsor_permissions.py | 15 ++ .../transactions/test_sponsor_signature.py | 95 ++++++++++ .../transactions/test_sponsorship_set.py | 137 +++++++++++++++ .../transactions/test_sponsorship_transfer.py | 92 ++++++++++ .../binarycodec/definitions/definitions.json | 162 +++++++++++++++++- xrpl/models/requests/account_objects.py | 2 + xrpl/models/requests/ledger_entry.py | 26 +++ xrpl/models/transactions/__init__.py | 18 ++ xrpl/models/transactions/account_set.py | 4 + xrpl/models/transactions/delegate_set.py | 7 + xrpl/models/transactions/payment.py | 7 + xrpl/models/transactions/sponsor_signature.py | 30 ++++ xrpl/models/transactions/sponsorship_set.py | 79 +++++++++ .../transactions/sponsorship_transfer.py | 60 +++++++ xrpl/models/transactions/transaction.py | 20 ++- .../transactions/types/transaction_type.py | 2 + 23 files changed, 1155 insertions(+), 8 deletions(-) create mode 100644 tests/integration/reqs/test_account_objects_sponsored.py create mode 100644 tests/integration/transactions/test_sponsorship_set.py create mode 100644 tests/integration/transactions/test_sponsorship_transfer.py create mode 100644 tests/unit/models/transactions/test_sponsor_common_fields.py create mode 100644 tests/unit/models/transactions/test_sponsor_permissions.py create mode 100644 tests/unit/models/transactions/test_sponsor_signature.py create mode 100644 tests/unit/models/transactions/test_sponsorship_set.py create mode 100644 tests/unit/models/transactions/test_sponsorship_transfer.py create mode 100644 xrpl/models/transactions/sponsor_signature.py create mode 100644 xrpl/models/transactions/sponsorship_set.py create mode 100644 xrpl/models/transactions/sponsorship_transfer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c80c07f55..242a42596 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,20 @@ 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 amendment (`featureSponsor`): + - New transaction types: `SponsorshipSet` and `SponsorshipTransfer` + - New ledger object type: `Sponsorship` (type code 144) + - New inner object model: `SponsorSignature` for sponsor co-signing + - Common transaction sponsor fields: `Sponsor` (AccountID), `SponsorFlags` (UInt32), `SponsorSignature` (STObject) on all transaction types for co-signed sponsorship + - Payment `TF_SPONSOR_CREATED_ACCOUNT` (0x00080000) flag for sponsoring account creation + - Granular permissions: `SponsorFee` (65549) and `SponsorReserve` (65550) for delegated sponsorship authority + - New flags: `SponsorshipSetFlag` (5 flags) and `SponsorshipTransferFlag` (3 flags) + - New `AccountSetAsfFlag.ASF_DISALLOW_INCOMING_SPONSOR` (19) + - New `AccountObjectType.SPONSORSHIP` and `LedgerEntryType.SPONSORSHIP` + - 15 new binary codec field definitions for sponsorship-related fields + ### 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..42c0b7682 --- /dev/null +++ b/tests/integration/reqs/test_account_objects_sponsored.py @@ -0,0 +1,101 @@ +"""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()) + # CK TODO: Make this test more robust by testing that all the returned objects are verifiably "sponsored" + + @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()) + # CK TODO: Make this test more robust by testing that all the returned objects are verifiably "not sponsored" + + @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/transactions/test_sponsorship_set.py b/tests/integration/transactions/test_sponsorship_set.py new file mode 100644 index 000000000..b8c352e5b --- /dev/null +++ b/tests/integration/transactions/test_sponsorship_set.py @@ -0,0 +1,103 @@ +"""Integration tests for SponsorshipSet transaction type (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 AccountObjects, AccountObjectType, SponsorshipSet +from xrpl.models.response import ResponseStatus +from xrpl.models.transactions.sponsorship_set import SponsorshipSetFlag +from xrpl.wallet import Wallet + + +# CK TODO: Write integration tests that include all potential fields of the SponsorshipSet transaction and associated flags +class TestSponsorshipSet(IntegrationTestCase): + @test_async_and_sync(globals()) + async def test_basic_sponsorship_set(self, client): + 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, + ) + response = await sign_and_reliable_submission_async(tx, sponsor_wallet, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Confirm that the Sponsorship object was created + account_objects_response = await client.request( + AccountObjects( + account=sponsor_wallet.address, + type=AccountObjectType.SPONSORSHIP, + ) + ) + self.assertTrue(len(account_objects_response.result["account_objects"]) > 0) + + @test_async_and_sync(globals()) + async def test_sponsorship_set_with_fee_amount(self, client): + 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="1000000", + ) + response = await sign_and_reliable_submission_async(tx, sponsor_wallet, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Confirm that the Sponsorship object was created + account_objects_response = await client.request( + AccountObjects( + account=sponsor_wallet.address, + type=AccountObjectType.SPONSORSHIP, + ) + ) + self.assertTrue(len(account_objects_response.result["account_objects"]) > 0) + + @test_async_and_sync(globals()) + async def test_sponsorship_set_delete(self, client): + sponsor_wallet = Wallet.create() + sponsee_wallet = Wallet.create() + await fund_wallet_async(sponsor_wallet) + await fund_wallet_async(sponsee_wallet) + + # First, create a sponsorship + create_tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + ) + create_response = await sign_and_reliable_submission_async( + create_tx, sponsor_wallet, client + ) + self.assertEqual(create_response.status, ResponseStatus.SUCCESS) + self.assertEqual(create_response.result["engine_result"], "tesSUCCESS") + + # Then, delete the sponsorship using TF_DELETE_OBJECT flag + delete_tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + flags=SponsorshipSetFlag.TF_DELETE_OBJECT, + ) + delete_response = await sign_and_reliable_submission_async( + delete_tx, sponsor_wallet, client + ) + self.assertEqual(delete_response.status, ResponseStatus.SUCCESS) + self.assertEqual(delete_response.result["engine_result"], "tesSUCCESS") + + # Confirm that 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) diff --git a/tests/integration/transactions/test_sponsorship_transfer.py b/tests/integration/transactions/test_sponsorship_transfer.py new file mode 100644 index 000000000..134d7ec45 --- /dev/null +++ b/tests/integration/transactions/test_sponsorship_transfer.py @@ -0,0 +1,77 @@ +"""Integration tests for SponsorshipTransfer transaction type (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 ( + AccountObjects, + AccountObjectType, + SponsorshipSet, + SponsorshipTransfer, +) +from xrpl.models.response import ResponseStatus +from xrpl.models.transactions.sponsorship_transfer import SponsorshipTransferFlag +from xrpl.wallet import Wallet + + +class TestSponsorshipTransfer(IntegrationTestCase): + @test_async_and_sync(globals()) + 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) + + # First, create a sponsorship + create_tx = SponsorshipSet( + account=sponsor_wallet.address, + sponsee=sponsee_wallet.address, + ) + create_response = await sign_and_reliable_submission_async( + create_tx, sponsor_wallet, client + ) + self.assertEqual(create_response.status, ResponseStatus.SUCCESS) + self.assertEqual(create_response.result["engine_result"], "tesSUCCESS") + + # Retrieve the Sponsorship object ID from the created objects + account_objects_response = await client.request( + AccountObjects( + account=sponsor_wallet.address, + type=AccountObjectType.SPONSORSHIP, + ) + ) + self.assertTrue(len(account_objects_response.result["account_objects"]) > 0) + sponsorship_object = account_objects_response.result["account_objects"][0] + object_id = sponsorship_object["index"] + + # Transfer the sponsorship to the new sponsor + transfer_tx = SponsorshipTransfer( + account=new_sponsor_wallet.address, + object_id=object_id, + sponsee=sponsee_wallet.address, + flags=SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN, + ) + transfer_response = await sign_and_reliable_submission_async( + transfer_tx, new_sponsor_wallet, client + ) + self.assertEqual(transfer_response.status, ResponseStatus.SUCCESS) + self.assertEqual(transfer_response.result["engine_result"], "tesSUCCESS") + + # Confirm the new sponsor now owns the Sponsorship object + new_sponsor_objects_response = await client.request( + AccountObjects( + account=new_sponsor_wallet.address, + type=AccountObjectType.SPONSORSHIP, + ) + ) + self.assertTrue(len(new_sponsor_objects_response.result["account_objects"]) > 0) + + # TODO: Confirm that the old sponsor does not have the transferred Object. + + +# TODO: Add integration tests that cover two other cases of sponsorship transfer. diff --git a/tests/unit/models/transactions/test_better_transaction_flags.py b/tests/unit/models/transactions/test_better_transaction_flags.py index 67869ae8f..ecdaac669 100644 --- a/tests/unit/models/transactions/test_better_transaction_flags.py +++ b/tests/unit/models/transactions/test_better_transaction_flags.py @@ -238,6 +238,7 @@ def test_payment_flags(self): TF_LIMIT_QUALITY=True, TF_NO_RIPPLE_DIRECT=True, TF_PARTIAL_PAYMENT=True, + TF_SPONSOR_CREATED_ACCOUNT=True, ), ) self.assertTrue(actual.has_flag(flag=0x00010000)) diff --git a/tests/unit/models/transactions/test_payment.py b/tests/unit/models/transactions/test_payment.py index 5e354b9e1..d9e8c9f34 100644 --- a/tests/unit/models/transactions/test_payment.py +++ b/tests/unit/models/transactions/test_payment.py @@ -294,3 +294,17 @@ 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) 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..91d38cb6c --- /dev/null +++ b/tests/unit/models/transactions/test_sponsor_common_fields.py @@ -0,0 +1,97 @@ +"""Tests for sponsor common fields on Transaction base class.""" + +from unittest import TestCase + +from xrpl.models.transactions.payment import Payment, PaymentFlag +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) 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..9ec524155 --- /dev/null +++ b/tests/unit/models/transactions/test_sponsor_permissions.py @@ -0,0 +1,15 @@ +"""Tests for sponsor-related granular permissions.""" + +from unittest import TestCase + +from xrpl.models.transactions.delegate_set import GranularPermission + + +class TestSponsorGranularPermissions(TestCase): + 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") 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..63ef6f3a2 --- /dev/null +++ b/tests/unit/models/transactions/test_sponsor_signature.py @@ -0,0 +1,95 @@ +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" +) + +# CK TODO: Update the tests to ensure that empty SponsorSignature objects fail with an appropriate error + + +class TestSponsorSignature(TestCase): + def test_valid_empty(self): + """SponsorSignature with no fields (all optional).""" + sig = SponsorSignature() + self.assertTrue(sig.is_valid()) + + def test_valid_with_signing_pub_key(self): + """Single signature with signing_pub_key.""" + sig = SponsorSignature( + signing_pub_key=_SIGNING_PUB_KEY, + ) + self.assertTrue(sig.is_valid()) + + def test_valid_with_txn_signature(self): + """With txn_signature.""" + sig = SponsorSignature( + txn_signature=_TXN_SIGNATURE, + ) + self.assertTrue(sig.is_valid()) + + 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_to_dict_empty(self): + """Verify empty SponsorSignature serializes correctly.""" + sig = SponsorSignature() + result = sig.to_dict() + self.assertIsInstance(result, dict) + + def test_from_dict_empty(self): + """Verify empty dict deserializes correctly.""" + sig = SponsorSignature.from_dict({}) + self.assertTrue(sig.is_valid()) 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..a005d79e4 --- /dev/null +++ b/tests/unit/models/transactions/test_sponsorship_set.py @@ -0,0 +1,137 @@ +from unittest import TestCase + +from xrpl.models.amounts import IssuedCurrencyAmount +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions.sponsorship_set import SponsorshipSet, SponsorshipSetFlag +from xrpl.models.transactions.types import TransactionType + +_ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" +_ACCOUNT2 = "rPyfep3gcLzkH4MYxKxJhE7bgUJfUCJM83" + + +class TestSponsorshipSet(TestCase): + def test_valid_minimal(self): + """SponsorshipSet with just account (all fields optional).""" + tx = SponsorshipSet( + account=_ACCOUNT, + ) + self.assertTrue(tx.is_valid()) + + def test_valid_all_fields(self): + """SponsorshipSet with all fields set.""" + tx = SponsorshipSet( + account=_ACCOUNT, + counterparty_sponsor=_ACCOUNT2, + 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 string (XRP drops).""" + tx = SponsorshipSet( + account=_ACCOUNT, + fee_amount="1000000", + ) + self.assertTrue(tx.is_valid()) + + def test_valid_with_issued_currency_fee_amount(self): + """fee_amount as IssuedCurrencyAmount.""" + tx = SponsorshipSet( + account=_ACCOUNT, + fee_amount=IssuedCurrencyAmount( + currency="USD", + issuer=_ACCOUNT, + value="10", + ), + ) + 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): + """Using multiple SponsorshipSetFlag values 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): + """Using TF_DELETE_OBJECT flag.""" + tx = SponsorshipSet( + account=_ACCOUNT, + flags=SponsorshipSetFlag.TF_DELETE_OBJECT, + ) + self.assertTrue(tx.is_valid()) + + def test_valid_with_counterparty_sponsor(self): + """Setting counterparty_sponsor field.""" + 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, + ) + self.assertEqual(tx.transaction_type, TransactionType.SPONSORSHIP_SET) + + def test_valid_clear_flags(self): + """Using clear flag variants.""" + tx = SponsorshipSet( + account=_ACCOUNT, + flags=SponsorshipSetFlag.TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_FEE, + ) + self.assertTrue(tx.is_valid()) + + tx2 = SponsorshipSet( + account=_ACCOUNT, + flags=SponsorshipSetFlag.TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_RESERVE, + ) + self.assertTrue(tx2.is_valid()) + + def test_valid_with_max_fee(self): + """Setting max_fee as IssuedCurrencyAmount.""" + tx = SponsorshipSet( + account=_ACCOUNT, + max_fee=IssuedCurrencyAmount( + currency="USD", + issuer=_ACCOUNT, + value="100", + ), + ) + self.assertTrue(tx.is_valid()) + + def test_valid_with_xrp_max_fee(self): + """Setting max_fee as XRP drops string.""" + tx = SponsorshipSet( + account=_ACCOUNT, + max_fee="5000000", + ) + self.assertTrue(tx.is_valid()) 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..4a910542d --- /dev/null +++ b/tests/unit/models/transactions/test_sponsorship_transfer.py @@ -0,0 +1,92 @@ +from unittest import TestCase + +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions.sponsorship_transfer import ( + SponsorshipTransfer, + SponsorshipTransferFlag, +) +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.""" + tx = SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + sponsee=_ACCOUNT2, + 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, + sponsee=_ACCOUNT2, + 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()) 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/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..6200db1b3 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,29 @@ class Ticket(BaseModel): """ +# CK TODO: Add integration tests that exercise all the new code paths in this file + + +@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.""" + + owner: 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 +387,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 +424,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..ad030ac4b 100644 --- a/xrpl/models/transactions/account_set.py +++ b/xrpl/models/transactions/account_set.py @@ -111,6 +111,10 @@ class AccountSetAsfFlag(int, Enum): If this account is an Issuer of IOU tokens, this flag allows such tokens to be used in Escrow. """ + # CK TODO: Add integ tests to validate this new addition + 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..2a17f7a11 100644 --- a/xrpl/models/transactions/delegate_set.py +++ b/xrpl/models/transactions/delegate_set.py @@ -68,6 +68,13 @@ class GranularPermission(str, Enum): MPTOKEN_ISSUANCE_UNLOCK = "MPTokenIssuanceUnlock" """Use the MPTIssuanceSet transaction to unlock (unfreeze) a holder.""" + # CK TODO: Add integ tests for DelegateSet transaction to validate this addition + 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..c1f4fd9df 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 diff --git a/xrpl/models/transactions/sponsor_signature.py b/xrpl/models/transactions/sponsor_signature.py new file mode 100644 index 000000000..cd74f65eb --- /dev/null +++ b/xrpl/models/transactions/sponsor_signature.py @@ -0,0 +1,30 @@ +"""Model for the SponsorSignature inner object used in SponsorshipSet.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + +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 + +# CK TODO: Add verification methods to validate the SponsorSignature values + + +@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 diff --git a/xrpl/models/transactions/sponsorship_set.py b/xrpl/models/transactions/sponsorship_set.py new file mode 100644 index 000000000..239cfb738 --- /dev/null +++ b/xrpl/models/transactions/sponsorship_set.py @@ -0,0 +1,79 @@ +"""Model for SponsorshipSet transaction type.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + +from xrpl.models.amounts import Amount +from xrpl.models.transactions.sponsor_signature import SponsorSignature +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, + ) diff --git a/xrpl/models/transactions/sponsorship_transfer.py b/xrpl/models/transactions/sponsorship_transfer.py new file mode 100644 index 000000000..db1cc3b14 --- /dev/null +++ b/xrpl/models/transactions/sponsorship_transfer.py @@ -0,0 +1,60 @@ +"""Model for SponsorshipTransfer transaction type.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + +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, + ) diff --git a/xrpl/models/transactions/transaction.py b/xrpl/models/transactions/transaction.py index df62de5dd..732b6286e 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 ( @@ -547,3 +559,9 @@ 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 SponsorSignature # noqa: E402, F811 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" From 517406aba837a4670f37516cdde64af47b3f704f Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 12 Mar 2026 15:57:41 -0700 Subject: [PATCH 02/14] draft: address initial code review suggestions from Claude. Integ tests are failing --- .ci-config/rippled.cfg | 1 + .../transactions/test_sponsor_permissions.py | 229 ++++++++++++ .../test_better_transaction_flags.py | 11 +- .../unit/models/transactions/test_payment.py | 77 ++++ .../transactions/test_sponsor_permissions.py | 105 +++++- .../transactions/test_sponsor_signature.py | 95 +++-- .../transactions/test_sponsorship_set.py | 237 ++++++++++-- .../transactions/test_sponsorship_transfer.py | 349 +++++++++++++++++- .../binarycodec/definitions/definitions.py | 2 + xrpl/models/transactions/payment.py | 18 + xrpl/models/transactions/sponsor_signature.py | 36 +- xrpl/models/transactions/sponsorship_set.py | 71 +++- .../transactions/sponsorship_transfer.py | 27 +- xrpl/models/transactions/transaction.py | 17 + 14 files changed, 1205 insertions(+), 70 deletions(-) create mode 100644 tests/integration/transactions/test_sponsor_permissions.py 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/tests/integration/transactions/test_sponsor_permissions.py b/tests/integration/transactions/test_sponsor_permissions.py new file mode 100644 index 000000000..7891c7162 --- /dev/null +++ b/tests/integration/transactions/test_sponsor_permissions.py @@ -0,0 +1,229 @@ +"""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 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_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/unit/models/transactions/test_better_transaction_flags.py b/tests/unit/models/transactions/test_better_transaction_flags.py index ecdaac669..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, @@ -238,17 +245,15 @@ def test_payment_flags(self): TF_LIMIT_QUALITY=True, TF_NO_RIPPLE_DIRECT=True, TF_PARTIAL_PAYMENT=True, - TF_SPONSOR_CREATED_ACCOUNT=True, ), ) 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 d9e8c9f34..ce5bca11f 100644 --- a/tests/unit/models/transactions/test_payment.py +++ b/tests/unit/models/transactions/test_payment.py @@ -308,3 +308,80 @@ def test_sponsor_created_account_flag(self): 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_permissions.py b/tests/unit/models/transactions/test_sponsor_permissions.py index 9ec524155..7cff4219e 100644 --- a/tests/unit/models/transactions/test_sponsor_permissions.py +++ b/tests/unit/models/transactions/test_sponsor_permissions.py @@ -2,10 +2,24 @@ from unittest import TestCase -from xrpl.models.transactions.delegate_set import GranularPermission +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions.delegate_set import ( + DelegateSet, + GranularPermission, + Permission, +) +from xrpl.models.transactions.sponsor_signature import SponsorSignature +from xrpl.models.transactions.transaction import Signer + +_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") @@ -13,3 +27,92 @@ def test_sponsor_fee_permission_exists(self): 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 index 63ef6f3a2..bf6a0018c 100644 --- a/tests/unit/models/transactions/test_sponsor_signature.py +++ b/tests/unit/models/transactions/test_sponsor_signature.py @@ -13,29 +13,8 @@ "F4BF7D5F1C6D3DA2F9D0E4EB7A4E6BF1C3A5D7E9" ) -# CK TODO: Update the tests to ensure that empty SponsorSignature objects fail with an appropriate error - class TestSponsorSignature(TestCase): - def test_valid_empty(self): - """SponsorSignature with no fields (all optional).""" - sig = SponsorSignature() - self.assertTrue(sig.is_valid()) - - def test_valid_with_signing_pub_key(self): - """Single signature with signing_pub_key.""" - sig = SponsorSignature( - signing_pub_key=_SIGNING_PUB_KEY, - ) - self.assertTrue(sig.is_valid()) - - def test_valid_with_txn_signature(self): - """With txn_signature.""" - sig = SponsorSignature( - txn_signature=_TXN_SIGNATURE, - ) - self.assertTrue(sig.is_valid()) - def test_valid_with_single_signature(self): """Both signing_pub_key and txn_signature.""" sig = SponsorSignature( @@ -83,13 +62,71 @@ def test_from_dict(self): self.assertEqual(sig.txn_signature, _TXN_SIGNATURE) self.assertTrue(sig.is_valid()) - def test_to_dict_empty(self): - """Verify empty SponsorSignature serializes correctly.""" - sig = SponsorSignature() - result = sig.to_dict() - self.assertIsInstance(result, dict) + 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_from_dict_empty(self): - """Verify empty dict deserializes correctly.""" - sig = SponsorSignature.from_dict({}) + 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 index a005d79e4..903761454 100644 --- a/tests/unit/models/transactions/test_sponsorship_set.py +++ b/tests/unit/models/transactions/test_sponsorship_set.py @@ -1,27 +1,35 @@ 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): - """SponsorshipSet with just account (all fields optional).""" + """Sponsor submits with only the sponsee field.""" tx = SponsorshipSet( account=_ACCOUNT, + sponsee=_ACCOUNT2, ) self.assertTrue(tx.is_valid()) def test_valid_all_fields(self): - """SponsorshipSet with all fields set.""" + """Sponsor submits with sponsee and every optional field set.""" tx = SponsorshipSet( account=_ACCOUNT, - counterparty_sponsor=_ACCOUNT2, sponsee=_ACCOUNT2, fee_amount="1000000", max_fee="2000000", @@ -30,22 +38,20 @@ def test_valid_all_fields(self): self.assertTrue(tx.is_valid()) def test_valid_with_xrp_fee_amount(self): - """fee_amount as string (XRP drops).""" + """fee_amount as XRP drops string.""" tx = SponsorshipSet( account=_ACCOUNT, + sponsee=_ACCOUNT2, fee_amount="1000000", ) self.assertTrue(tx.is_valid()) - def test_valid_with_issued_currency_fee_amount(self): - """fee_amount as IssuedCurrencyAmount.""" + def test_valid_with_xrp_max_fee(self): + """max_fee as XRP drops string.""" tx = SponsorshipSet( account=_ACCOUNT, - fee_amount=IssuedCurrencyAmount( - currency="USD", - issuer=_ACCOUNT, - value="10", - ), + sponsee=_ACCOUNT2, + max_fee="5000000", ) self.assertTrue(tx.is_valid()) @@ -59,7 +65,7 @@ def test_valid_with_flags(self): self.assertTrue(tx.is_valid()) def test_valid_with_combined_flags(self): - """Using multiple SponsorshipSetFlag values combined.""" + """Two non-conflicting flags combined.""" tx = SponsorshipSet( account=_ACCOUNT, sponsee=_ACCOUNT2, @@ -71,15 +77,16 @@ def test_valid_with_combined_flags(self): self.assertTrue(tx.is_valid()) def test_valid_delete_object_flag(self): - """Using TF_DELETE_OBJECT flag.""" + """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): - """Setting counterparty_sponsor field.""" + """Sponsee submits, providing counterparty_sponsor (deletion scenario).""" tx = SponsorshipSet( account=_ACCOUNT, counterparty_sponsor=_ACCOUNT2, @@ -99,39 +106,213 @@ 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.""" + """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_with_max_fee(self): - """Setting max_fee as IssuedCurrencyAmount.""" + def test_valid_delete_with_counterparty_sponsor(self): + """Sponsee deletes using counterparty_sponsor + TF_DELETE_OBJECT.""" tx = SponsorshipSet( account=_ACCOUNT, - max_fee=IssuedCurrencyAmount( - currency="USD", - issuer=_ACCOUNT, - value="100", - ), + counterparty_sponsor=_ACCOUNT2, + flags=SponsorshipSetFlag.TF_DELETE_OBJECT, ) self.assertTrue(tx.is_valid()) - def test_valid_with_xrp_max_fee(self): - """Setting max_fee as XRP drops string.""" - tx = SponsorshipSet( - account=_ACCOUNT, - max_fee="5000000", + # ------------------------------------------------------------------ # + # 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 must be rejected with the correct message.""" + 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 cannot be combined with TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_FEE.""" + 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 cannot be combined with TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_RESERVE.""" + 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), ) - self.assertTrue(tx.is_valid()) diff --git a/tests/unit/models/transactions/test_sponsorship_transfer.py b/tests/unit/models/transactions/test_sponsorship_transfer.py index 4a910542d..083c728ed 100644 --- a/tests/unit/models/transactions/test_sponsorship_transfer.py +++ b/tests/unit/models/transactions/test_sponsorship_transfer.py @@ -1,10 +1,13 @@ 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, + SponsorshipTransferFlagInterface, ) +from xrpl.models.transactions.transaction import Signer from xrpl.models.transactions.types import TransactionType _ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" @@ -55,21 +58,19 @@ def test_valid_end_flag(self): self.assertTrue(tx.is_valid()) def test_valid_create_flag(self): - """Using TF_SPONSORSHIP_CREATE flag.""" + """Using TF_SPONSORSHIP_CREATE flag (no sponsee — forbidden with CREATE).""" tx = SponsorshipTransfer( account=_ACCOUNT, object_id=_OBJECT_ID, - sponsee=_ACCOUNT2, flags=SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE, ) self.assertTrue(tx.is_valid()) def test_valid_reassign_flag(self): - """Using TF_SPONSORSHIP_REASSIGN flag.""" + """Using TF_SPONSORSHIP_REASSIGN flag (no sponsee — forbidden with REASSIGN).""" tx = SponsorshipTransfer( account=_ACCOUNT, object_id=_OBJECT_ID, - sponsee=_ACCOUNT2, flags=SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN, ) self.assertTrue(tx.is_valid()) @@ -90,3 +91,343 @@ def test_valid_with_flags_and_all_fields(self): 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` or " + "`TF_SPONSORSHIP_REASSIGN` 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): + """Setting TF_SPONSORSHIP_END and TF_SPONSORSHIP_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): + """Setting TF_SPONSORSHIP_CREATE and TF_SPONSORSHIP_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)) + + def test_invalid_sponsee_with_reassign_flag(self): + """sponsee must not be set when TF_SPONSORSHIP_REASSIGN is active.""" + with self.assertRaises(XRPLModelException) as cm: + SponsorshipTransfer( + account=_ACCOUNT, + object_id=_OBJECT_ID, + sponsee=_ACCOUNT2, + flags=SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN, + ) + 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/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/transactions/payment.py b/xrpl/models/transactions/payment.py index c1f4fd9df..ba951d454 100644 --- a/xrpl/models/transactions/payment.py +++ b/xrpl/models/transactions/payment.py @@ -197,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 index cd74f65eb..02e400daf 100644 --- a/xrpl/models/transactions/sponsor_signature.py +++ b/xrpl/models/transactions/sponsor_signature.py @@ -3,14 +3,14 @@ from __future__ import annotations from dataclasses import dataclass -from typing import List, Optional +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 -# CK TODO: Add verification methods to validate the SponsorSignature values - @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) @@ -28,3 +28,33 @@ class SponsorSignature(BaseModel): 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 index 239cfb738..12366d739 100644 --- a/xrpl/models/transactions/sponsorship_set.py +++ b/xrpl/models/transactions/sponsorship_set.py @@ -4,7 +4,9 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Optional +from typing import Dict, Optional + +from typing_extensions import Self from xrpl.models.amounts import Amount from xrpl.models.transactions.sponsor_signature import SponsorSignature @@ -77,3 +79,70 @@ class SponsorshipSet(Transaction): 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 index db1cc3b14..1df9dcfd1 100644 --- a/xrpl/models/transactions/sponsorship_transfer.py +++ b/xrpl/models/transactions/sponsorship_transfer.py @@ -4,7 +4,9 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Optional +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 @@ -58,3 +60,26 @@ class SponsorshipTransfer(Transaction): 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 or REASSIGN operations. + if self.sponsee is not None and (create or reassign): + errors["sponsee"] = ( + "`sponsee` cannot be set when `TF_SPONSORSHIP_CREATE` or " + "`TF_SPONSORSHIP_REASSIGN` is active." + ) + + return errors diff --git a/xrpl/models/transactions/transaction.py b/xrpl/models/transactions/transaction.py index 732b6286e..c68ca6e02 100644 --- a/xrpl/models/transactions/transaction.py +++ b/xrpl/models/transactions/transaction.py @@ -303,6 +303,23 @@ 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." + ) + return errors def to_dict(self: Self) -> Dict[str, Any]: From 2c88e4a939a8538c4c03fd166f43d7b21d3658de Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Mon, 16 Mar 2026 11:37:57 -0700 Subject: [PATCH 03/14] Update the sponsorship_transfer integ tests --- .../transactions/test_sponsorship_transfer.py | 132 ++++++++++++------ .../transactions/sponsorship_transfer.py | 7 +- 2 files changed, 96 insertions(+), 43 deletions(-) diff --git a/tests/integration/transactions/test_sponsorship_transfer.py b/tests/integration/transactions/test_sponsorship_transfer.py index 134d7ec45..c8974e907 100644 --- a/tests/integration/transactions/test_sponsorship_transfer.py +++ b/tests/integration/transactions/test_sponsorship_transfer.py @@ -2,23 +2,55 @@ 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.binarycodec import encode_for_signing +from xrpl.core.keypairs import sign as keypairs_sign from xrpl.models import ( AccountObjects, AccountObjectType, - SponsorshipSet, SponsorshipTransfer, ) from xrpl.models.response import ResponseStatus +from xrpl.models.transactions.sponsor_signature import SponsorSignature 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 as the sponsor. + + 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 transaction. + # SigningPubKey (isSigningField=true) is included in the hash; + # TxnSignature and SponsorSignature (isSigningField=false) are 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()) + + @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() @@ -27,51 +59,73 @@ async def test_basic_sponsorship_transfer(self, client): await fund_wallet_async(sponsee_wallet) await fund_wallet_async(new_sponsor_wallet) - # First, create a sponsorship - create_tx = SponsorshipSet( - account=sponsor_wallet.address, - sponsee=sponsee_wallet.address, + # Step 1: Create account-level sponsorship (sponsor -> sponsee). + # 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_response = await sign_and_reliable_submission_async( - create_tx, sponsor_wallet, client + 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") - # Retrieve the Sponsorship object ID from the created objects - account_objects_response = await client.request( - AccountObjects( - account=sponsor_wallet.address, - type=AccountObjectType.SPONSORSHIP, - ) - ) - self.assertTrue(len(account_objects_response.result["account_objects"]) > 0) - sponsorship_object = account_objects_response.result["account_objects"][0] - object_id = sponsorship_object["index"] - - # Transfer the sponsorship to the new sponsor - transfer_tx = SponsorshipTransfer( - account=new_sponsor_wallet.address, - object_id=object_id, - sponsee=sponsee_wallet.address, + # Step 2: Reassign the account sponsorship to a new sponsor. + reassign_tx = SponsorshipTransfer( + account=sponsee_wallet.address, flags=SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN, + sponsor_flags=2, + sponsor=new_sponsor_wallet.address, ) - transfer_response = await sign_and_reliable_submission_async( - transfer_tx, new_sponsor_wallet, client - ) - self.assertEqual(transfer_response.status, ResponseStatus.SUCCESS) - self.assertEqual(transfer_response.result["engine_result"], "tesSUCCESS") - - # Confirm the new sponsor now owns the Sponsorship object - new_sponsor_objects_response = await client.request( - AccountObjects( - account=new_sponsor_wallet.address, - type=AccountObjectType.SPONSORSHIP, - ) + reassign_tx = await autofill(reassign_tx, client) + final_reassign_tx = _build_sponsor_signed_tx( + reassign_tx, sponsee_wallet, new_sponsor_wallet ) - self.assertTrue(len(new_sponsor_objects_response.result["account_objects"]) > 0) + 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") - # TODO: Confirm that the old sponsor does not have the transferred Object. + @test_async_and_sync( + globals(), ["xrpl.transaction.autofill", "xrpl.transaction.submit"] + ) + async def test_sponsored_to_unsponsored(self, client): + """Sponsored -> Unsponsored: the sponsee ends their account 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") -# TODO: Add integration tests that cover two other cases of sponsorship transfer. + # 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") diff --git a/xrpl/models/transactions/sponsorship_transfer.py b/xrpl/models/transactions/sponsorship_transfer.py index 1df9dcfd1..133cf4824 100644 --- a/xrpl/models/transactions/sponsorship_transfer.py +++ b/xrpl/models/transactions/sponsorship_transfer.py @@ -75,11 +75,10 @@ def _get_errors(self: Self) -> Dict[str, str]: "`TF_SPONSORSHIP_REASSIGN` may be set at a time." ) - # sponsee is not used for CREATE or REASSIGN operations. - if self.sponsee is not None and (create or reassign): + # 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` or " - "`TF_SPONSORSHIP_REASSIGN` is active." + "`sponsee` cannot be set when `TF_SPONSORSHIP_CREATE` is active." ) return errors From 70da66fce3b493d9b3f16f847b9ab3036b2eed37 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Mon, 16 Mar 2026 11:50:25 -0700 Subject: [PATCH 04/14] fix all the linter errors update two incorrect unit tests --- .../reqs/test_account_objects_sponsored.py | 6 ++- .../transactions/test_sponsor_permissions.py | 6 +-- .../transactions/test_sponsorship_set.py | 3 +- .../transactions/test_sponsorship_transfer.py | 40 ++++++++++--------- .../test_sponsor_common_fields.py | 2 +- .../transactions/test_sponsor_permissions.py | 3 -- .../transactions/test_sponsorship_set.py | 6 +-- .../transactions/test_sponsorship_transfer.py | 27 +++---------- xrpl/models/transactions/sponsorship_set.py | 7 ++-- xrpl/models/transactions/transaction.py | 4 +- 10 files changed, 45 insertions(+), 59 deletions(-) diff --git a/tests/integration/reqs/test_account_objects_sponsored.py b/tests/integration/reqs/test_account_objects_sponsored.py index 42c0b7682..cbff39909 100644 --- a/tests/integration/reqs/test_account_objects_sponsored.py +++ b/tests/integration/reqs/test_account_objects_sponsored.py @@ -38,7 +38,8 @@ async def test_sponsored_field_true(self, client): ) ) self.assertTrue(account_objects_response.is_successful()) - # CK TODO: Make this test more robust by testing that all the returned objects are verifiably "sponsored" + # CK TODO: Make this test more robust by testing that all + # the returned objects are verifiably "sponsored" @test_async_and_sync(globals()) async def test_sponsored_field_false(self, client): @@ -54,7 +55,8 @@ async def test_sponsored_field_false(self, client): ) ) self.assertTrue(account_objects_response.is_successful()) - # CK TODO: Make this test more robust by testing that all the returned objects are verifiably "not sponsored" + # CK TODO: Make this test more robust by testing that + # all returned objects are verifiably "not sponsored" @test_async_and_sync(globals()) async def test_sponsored_field_none(self, client): diff --git a/tests/integration/transactions/test_sponsor_permissions.py b/tests/integration/transactions/test_sponsor_permissions.py index 7891c7162..571b7f2e0 100644 --- a/tests/integration/transactions/test_sponsor_permissions.py +++ b/tests/integration/transactions/test_sponsor_permissions.py @@ -141,7 +141,7 @@ async def test_delegate_set_sponsor_fee_accepted(self, client): @test_async_and_sync(globals()) async def test_delegate_set_sponsor_reserve_accepted(self, client): - """DelegateSet with SponsorReserve is accepted by rippled (wire value correct).""" + """DelegateSet with SponsorReserve is accepted.""" alice = Wallet.create() await fund_wallet_async(alice) bob = Wallet.create() @@ -182,9 +182,7 @@ async def test_ledger_returns_sponsor_permission_values(self, client): self.assertEqual(response.result["engine_result"], "tesSUCCESS") ledger_response = await client.request( - LedgerEntry( - delegate=Delegate(account=alice.address, authorize=bob.address) - ) + LedgerEntry(delegate=Delegate(account=alice.address, authorize=bob.address)) ) self.assertTrue(ledger_response.is_successful()) diff --git a/tests/integration/transactions/test_sponsorship_set.py b/tests/integration/transactions/test_sponsorship_set.py index b8c352e5b..fc0fab724 100644 --- a/tests/integration/transactions/test_sponsorship_set.py +++ b/tests/integration/transactions/test_sponsorship_set.py @@ -12,7 +12,8 @@ from xrpl.wallet import Wallet -# CK TODO: Write integration tests that include all potential fields of the SponsorshipSet transaction and associated flags +# CK TODO: Write integration tests that include all potential fields +# of the SponsorshipSet transaction and associated flags class TestSponsorshipSet(IntegrationTestCase): @test_async_and_sync(globals()) async def test_basic_sponsorship_set(self, client): diff --git a/tests/integration/transactions/test_sponsorship_transfer.py b/tests/integration/transactions/test_sponsorship_transfer.py index c8974e907..26ba9a9c6 100644 --- a/tests/integration/transactions/test_sponsorship_transfer.py +++ b/tests/integration/transactions/test_sponsorship_transfer.py @@ -10,28 +10,23 @@ 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 ( - AccountObjects, - AccountObjectType, - SponsorshipTransfer, -) +from xrpl.models import SponsorshipTransfer from xrpl.models.response import ResponseStatus -from xrpl.models.transactions.sponsor_signature import SponsorSignature 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 as the sponsor. + """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 transaction. + # Compute the sponsor's co-signature over the signed tx. # SigningPubKey (isSigningField=true) is included in the hash; - # TxnSignature and SponsorSignature (isSigningField=false) are excluded. + # TxnSignature/SponsorSignature (isSigningField=false) excluded. tx_json = signed_tx.to_xrpl() sponsor_sig = keypairs_sign( bytes.fromhex(encode_for_signing(tx_json)), @@ -49,7 +44,8 @@ def _build_sponsor_signed_tx(transfer_tx, sponsee_wallet, sponsor_wallet): class TestSponsorshipTransfer(IntegrationTestCase): @test_async_and_sync( - globals(), ["xrpl.transaction.autofill", "xrpl.transaction.submit"] + globals(), + ["xrpl.transaction.autofill", "xrpl.transaction.submit"], ) async def test_basic_sponsorship_transfer(self, client): sponsor_wallet = Wallet.create() @@ -59,8 +55,9 @@ async def test_basic_sponsorship_transfer(self, client): await fund_wallet_async(sponsee_wallet) await fund_wallet_async(new_sponsor_wallet) - # Step 1: Create account-level sponsorship (sponsor -> sponsee). - # No object_id means this is an account sponsor, not an object sponsor. + # 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, @@ -76,10 +73,10 @@ async def test_basic_sponsorship_transfer(self, client): self.assertEqual(create_response.status, ResponseStatus.SUCCESS) self.assertEqual(create_response.result["engine_result"], "tesSUCCESS") - # Step 2: Reassign the account sponsorship to a new sponsor. + # Step 2: Reassign the account sponsorship. reassign_tx = SponsorshipTransfer( account=sponsee_wallet.address, - flags=SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN, + flags=(SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN), sponsor_flags=2, sponsor=new_sponsor_wallet.address, ) @@ -90,13 +87,17 @@ async def test_basic_sponsorship_transfer(self, client): 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") + self.assertEqual( + reassign_response.result["engine_result"], + "tesSUCCESS", + ) @test_async_and_sync( - globals(), ["xrpl.transaction.autofill", "xrpl.transaction.submit"] + globals(), + ["xrpl.transaction.autofill", "xrpl.transaction.submit"], ) async def test_sponsored_to_unsponsored(self, client): - """Sponsored -> Unsponsored: the sponsee ends their account sponsorship.""" + """Sponsored -> Unsponsored: sponsee ends sponsorship.""" sponsor_wallet = Wallet.create() sponsee_wallet = Wallet.create() await fund_wallet_async(sponsor_wallet) @@ -118,8 +119,9 @@ async def test_sponsored_to_unsponsored(self, client): 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 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, diff --git a/tests/unit/models/transactions/test_sponsor_common_fields.py b/tests/unit/models/transactions/test_sponsor_common_fields.py index 91d38cb6c..abde3d2da 100644 --- a/tests/unit/models/transactions/test_sponsor_common_fields.py +++ b/tests/unit/models/transactions/test_sponsor_common_fields.py @@ -2,7 +2,7 @@ from unittest import TestCase -from xrpl.models.transactions.payment import Payment, PaymentFlag +from xrpl.models.transactions.payment import Payment from xrpl.models.transactions.sponsor_signature import SponsorSignature from xrpl.models.transactions.transaction import Signer diff --git a/tests/unit/models/transactions/test_sponsor_permissions.py b/tests/unit/models/transactions/test_sponsor_permissions.py index 7cff4219e..cd8782c59 100644 --- a/tests/unit/models/transactions/test_sponsor_permissions.py +++ b/tests/unit/models/transactions/test_sponsor_permissions.py @@ -2,14 +2,11 @@ from unittest import TestCase -from xrpl.models.exceptions import XRPLModelException from xrpl.models.transactions.delegate_set import ( DelegateSet, GranularPermission, Permission, ) -from xrpl.models.transactions.sponsor_signature import SponsorSignature -from xrpl.models.transactions.transaction import Signer _ACCOUNT = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" _ACCOUNT2 = "rPyfep3gcLzkH4MYxKxJhE7bgUJfUCJM83" diff --git a/tests/unit/models/transactions/test_sponsorship_set.py b/tests/unit/models/transactions/test_sponsorship_set.py index 903761454..5fdb5ce03 100644 --- a/tests/unit/models/transactions/test_sponsorship_set.py +++ b/tests/unit/models/transactions/test_sponsorship_set.py @@ -149,7 +149,7 @@ def test_valid_delete_with_counterparty_sponsor(self): ) def test_invalid_fee_amount_iou(self): - """fee_amount as IssuedCurrencyAmount must be rejected with the correct message.""" + """fee_amount as IssuedCurrencyAmount is rejected.""" with self.assertRaises(XRPLModelException) as cm: SponsorshipSet( account=_ACCOUNT, @@ -286,7 +286,7 @@ def test_invalid_set_and_clear_reserve_flags(self): ) def test_invalid_delete_with_set_fee_flag(self): - """TF_DELETE_OBJECT cannot be combined with TF_SPONSORSHIP_SET_REQUIRE_SIGN_FOR_FEE.""" + """TF_DELETE_OBJECT can't combine with set fee flag.""" with self.assertRaises(XRPLModelException) as cm: SponsorshipSet( account=_ACCOUNT, @@ -302,7 +302,7 @@ def test_invalid_delete_with_set_fee_flag(self): ) def test_invalid_delete_with_clear_reserve_flag(self): - """TF_DELETE_OBJECT cannot be combined with TF_SPONSORSHIP_CLEAR_REQUIRE_SIGN_FOR_RESERVE.""" + """TF_DELETE_OBJECT can't combine with clear reserve flag.""" with self.assertRaises(XRPLModelException) as cm: SponsorshipSet( account=_ACCOUNT, diff --git a/tests/unit/models/transactions/test_sponsorship_transfer.py b/tests/unit/models/transactions/test_sponsorship_transfer.py index 083c728ed..3651a440b 100644 --- a/tests/unit/models/transactions/test_sponsorship_transfer.py +++ b/tests/unit/models/transactions/test_sponsorship_transfer.py @@ -5,7 +5,6 @@ from xrpl.models.transactions.sponsorship_transfer import ( SponsorshipTransfer, SponsorshipTransferFlag, - SponsorshipTransferFlagInterface, ) from xrpl.models.transactions.transaction import Signer from xrpl.models.transactions.types import TransactionType @@ -169,9 +168,7 @@ def test_flags_interface_dict_create(self): ) self.assertTrue(tx.is_valid()) d = tx.to_dict() - self.assertEqual( - d["flags"], int(SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE) - ) + self.assertEqual(d["flags"], int(SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE)) def test_flags_interface_dict_reassign(self): """FlagInterface dict with TF_SPONSORSHIP_REASSIGN (no sponsee).""" @@ -192,9 +189,7 @@ def test_has_flag_end(self): account=_ACCOUNT, flags=SponsorshipTransferFlag.TF_SPONSORSHIP_END, ) - self.assertTrue( - tx.has_flag(int(SponsorshipTransferFlag.TF_SPONSORSHIP_END)) - ) + self.assertTrue(tx.has_flag(int(SponsorshipTransferFlag.TF_SPONSORSHIP_END))) self.assertFalse( tx.has_flag(int(SponsorshipTransferFlag.TF_SPONSORSHIP_CREATE)) ) @@ -304,8 +299,7 @@ def test_integer_flag_value(self): "`TF_SPONSORSHIP_REASSIGN` may be set at a time." ) _SPONSEE_FLAG_MSG = ( - "`sponsee` cannot be set when `TF_SPONSORSHIP_CREATE` or " - "`TF_SPONSORSHIP_REASSIGN` is active." + "`sponsee` cannot be set when `TF_SPONSORSHIP_CREATE` is active." ) def test_invalid_end_and_create_flags(self): @@ -321,7 +315,7 @@ def test_invalid_end_and_create_flags(self): self.assertIn(self._MULTI_FLAG_MSG, str(cm.exception)) def test_invalid_end_and_reassign_flags(self): - """Setting TF_SPONSORSHIP_END and TF_SPONSORSHIP_REASSIGN together is rejected.""" + """END and REASSIGN together is rejected.""" with self.assertRaises(XRPLModelException) as cm: SponsorshipTransfer( account=_ACCOUNT, @@ -333,7 +327,7 @@ def test_invalid_end_and_reassign_flags(self): self.assertIn(self._MULTI_FLAG_MSG, str(cm.exception)) def test_invalid_create_and_reassign_flags(self): - """Setting TF_SPONSORSHIP_CREATE and TF_SPONSORSHIP_REASSIGN together is rejected.""" + """CREATE and REASSIGN together is rejected.""" with self.assertRaises(XRPLModelException) as cm: SponsorshipTransfer( account=_ACCOUNT, @@ -355,17 +349,6 @@ def test_invalid_sponsee_with_create_flag(self): ) self.assertIn(self._SPONSEE_FLAG_MSG, str(cm.exception)) - def test_invalid_sponsee_with_reassign_flag(self): - """sponsee must not be set when TF_SPONSORSHIP_REASSIGN is active.""" - with self.assertRaises(XRPLModelException) as cm: - SponsorshipTransfer( - account=_ACCOUNT, - object_id=_OBJECT_ID, - sponsee=_ACCOUNT2, - flags=SponsorshipTransferFlag.TF_SPONSORSHIP_REASSIGN, - ) - self.assertIn(self._SPONSEE_FLAG_MSG, str(cm.exception)) - # ------------------------------------------------------------------ # # Concern 5 — Transaction-level sponsor cross-field validation # # ------------------------------------------------------------------ # diff --git a/xrpl/models/transactions/sponsorship_set.py b/xrpl/models/transactions/sponsorship_set.py index 12366d739..797bd5f5e 100644 --- a/xrpl/models/transactions/sponsorship_set.py +++ b/xrpl/models/transactions/sponsorship_set.py @@ -9,7 +9,6 @@ from typing_extensions import Self from xrpl.models.amounts import Amount -from xrpl.models.transactions.sponsor_signature import SponsorSignature 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 @@ -123,8 +122,10 @@ def _get_errors(self: Self) -> Dict[str, str]: ) 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." + "`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"] = ( diff --git a/xrpl/models/transactions/transaction.py b/xrpl/models/transactions/transaction.py index c68ca6e02..f7bbc2eba 100644 --- a/xrpl/models/transactions/transaction.py +++ b/xrpl/models/transactions/transaction.py @@ -581,4 +581,6 @@ def from_xrpl(cls: Type[Self], value: Union[str, Dict[str, Any]]) -> Self: # 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 SponsorSignature # noqa: E402, F811 +from xrpl.models.transactions.sponsor_signature import ( # noqa: E402, F811 + SponsorSignature, +) From fe7c7b804e9686283cd6829a72a389c439ef2b46 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Mon, 16 Mar 2026 12:05:37 -0700 Subject: [PATCH 05/14] test: add integ test for the case of multisigning SponsorshipTransfer transactions --- .../transactions/test_sponsorship_transfer.py | 79 ++++++++++++++++++- xrpl/asyncio/transaction/main.py | 23 ++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/tests/integration/transactions/test_sponsorship_transfer.py b/tests/integration/transactions/test_sponsorship_transfer.py index 26ba9a9c6..33f4fc255 100644 --- a/tests/integration/transactions/test_sponsorship_transfer.py +++ b/tests/integration/transactions/test_sponsorship_transfer.py @@ -8,10 +8,12 @@ test_async_and_sync, ) from xrpl.asyncio.transaction import autofill, sign, submit -from xrpl.core.binarycodec import encode_for_signing +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 @@ -131,3 +133,78 @@ async def test_sponsored_to_unsponsored(self, 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/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: From 1d757eba1e5b08eba85cde3da1b4438d38cb7a88 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Mon, 16 Mar 2026 12:16:47 -0700 Subject: [PATCH 06/14] feat: add validation for rejecting Batch, PseudoTransactions with sponsor-related fields --- .../test_sponsor_common_fields.py | 34 +++++++++++++++++++ xrpl/models/transactions/transaction.py | 9 +++++ 2 files changed, 43 insertions(+) diff --git a/tests/unit/models/transactions/test_sponsor_common_fields.py b/tests/unit/models/transactions/test_sponsor_common_fields.py index abde3d2da..e3fe25ce1 100644 --- a/tests/unit/models/transactions/test_sponsor_common_fields.py +++ b/tests/unit/models/transactions/test_sponsor_common_fields.py @@ -2,7 +2,10 @@ 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 @@ -95,3 +98,34 @@ def test_payment_without_sponsor(self): 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/xrpl/models/transactions/transaction.py b/xrpl/models/transactions/transaction.py index f7bbc2eba..70ad649e8 100644 --- a/xrpl/models/transactions/transaction.py +++ b/xrpl/models/transactions/transaction.py @@ -320,6 +320,15 @@ def _get_errors(self: Self) -> Dict[str, str]: "`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]: From d761020ec71a9ddea05fd9a29ae00741633daa8d Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Mon, 16 Mar 2026 12:32:24 -0700 Subject: [PATCH 07/14] integ test: validate AccountDelete transaction with sponsor --- .../transactions/test_account_delete.py | 113 +++++++++++++----- 1 file changed, 82 insertions(+), 31 deletions(-) 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") From 6f75c50d73b6575abbc544c9885f62c6fd7cd56f Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Mon, 16 Mar 2026 12:46:08 -0700 Subject: [PATCH 08/14] test: enhanced coverage for sponsorship_set transaction --- .../reqs/test_account_objects_sponsored.py | 4 - .../transactions/test_sponsorship_set.py | 197 ++++++++++++++++-- xrpl/models/transactions/delegate_set.py | 1 - 3 files changed, 174 insertions(+), 28 deletions(-) diff --git a/tests/integration/reqs/test_account_objects_sponsored.py b/tests/integration/reqs/test_account_objects_sponsored.py index cbff39909..8762d814d 100644 --- a/tests/integration/reqs/test_account_objects_sponsored.py +++ b/tests/integration/reqs/test_account_objects_sponsored.py @@ -38,8 +38,6 @@ async def test_sponsored_field_true(self, client): ) ) self.assertTrue(account_objects_response.is_successful()) - # CK TODO: Make this test more robust by testing that all - # the returned objects are verifiably "sponsored" @test_async_and_sync(globals()) async def test_sponsored_field_false(self, client): @@ -55,8 +53,6 @@ async def test_sponsored_field_false(self, client): ) ) self.assertTrue(account_objects_response.is_successful()) - # CK TODO: Make this test more robust by testing that - # all returned objects are verifiably "not sponsored" @test_async_and_sync(globals()) async def test_sponsored_field_none(self, client): diff --git a/tests/integration/transactions/test_sponsorship_set.py b/tests/integration/transactions/test_sponsorship_set.py index fc0fab724..a4ef9a4b2 100644 --- a/tests/integration/transactions/test_sponsorship_set.py +++ b/tests/integration/transactions/test_sponsorship_set.py @@ -1,4 +1,4 @@ -"""Integration tests for SponsorshipSet transaction type (XLS-68).""" +"""Integration tests for SponsorshipSet transaction type (XLS-68 §9).""" from tests.integration.integration_test_case import IntegrationTestCase from tests.integration.it_utils import ( @@ -6,41 +6,69 @@ 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 -# CK TODO: Write integration tests that include all potential fields -# of the SponsorshipSet transaction and associated flags 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_basic_sponsorship_set(self, client): + 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) - tx = SponsorshipSet( + # Sponsor creates the sponsorship. + create_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") + create_resp = await sign_and_reliable_submission_async( + create_tx, sponsor_wallet, client + ) + self.assertEqual(create_resp.result["engine_result"], "tesSUCCESS") - # Confirm that the Sponsorship object was created + # 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.assertTrue(len(account_objects_response.result["account_objects"]) > 0) + 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_with_fee_amount(self, client): + 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) @@ -49,52 +77,175 @@ async def test_sponsorship_set_with_fee_amount(self, client): tx = SponsorshipSet( account=sponsor_wallet.address, sponsee=sponsee_wallet.address, - fee_amount="1000000", + 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") - # Confirm that the Sponsorship object was created account_objects_response = await client.request( AccountObjects( account=sponsor_wallet.address, type=AccountObjectType.SPONSORSHIP, ) ) - self.assertTrue(len(account_objects_response.result["account_objects"]) > 0) + 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) - # First, create a sponsorship create_tx = SponsorshipSet( account=sponsor_wallet.address, sponsee=sponsee_wallet.address, ) - create_response = await sign_and_reliable_submission_async( + create_resp = await sign_and_reliable_submission_async( create_tx, sponsor_wallet, client ) - self.assertEqual(create_response.status, ResponseStatus.SUCCESS) - self.assertEqual(create_response.result["engine_result"], "tesSUCCESS") + self.assertEqual(create_resp.status, ResponseStatus.SUCCESS) + self.assertEqual(create_resp.result["engine_result"], "tesSUCCESS") - # Then, delete the sponsorship using TF_DELETE_OBJECT flag delete_tx = SponsorshipSet( account=sponsor_wallet.address, sponsee=sponsee_wallet.address, flags=SponsorshipSetFlag.TF_DELETE_OBJECT, ) - delete_response = await sign_and_reliable_submission_async( + delete_resp = await sign_and_reliable_submission_async( delete_tx, sponsor_wallet, client ) - self.assertEqual(delete_response.status, ResponseStatus.SUCCESS) - self.assertEqual(delete_response.result["engine_result"], "tesSUCCESS") + self.assertEqual(delete_resp.status, ResponseStatus.SUCCESS) + self.assertEqual(delete_resp.result["engine_result"], "tesSUCCESS") - # Confirm that the Sponsorship object was deleted account_objects_response = await client.request( AccountObjects( account=sponsor_wallet.address, diff --git a/xrpl/models/transactions/delegate_set.py b/xrpl/models/transactions/delegate_set.py index 2a17f7a11..574ca19a5 100644 --- a/xrpl/models/transactions/delegate_set.py +++ b/xrpl/models/transactions/delegate_set.py @@ -68,7 +68,6 @@ class GranularPermission(str, Enum): MPTOKEN_ISSUANCE_UNLOCK = "MPTokenIssuanceUnlock" """Use the MPTIssuanceSet transaction to unlock (unfreeze) a holder.""" - # CK TODO: Add integ tests for DelegateSet transaction to validate this addition SPONSOR_FEE = "SponsorFee" """Delegates ability to sponsor transaction fees.""" From d007128621743b94c4573744f34f85507ed6dcb0 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Mon, 16 Mar 2026 12:50:00 -0700 Subject: [PATCH 09/14] test: enahced coverage for ledger_entry RPC --- .../reqs/test_ledger_entry_sponsorship.py | 49 +++++++++++++++++++ xrpl/models/requests/ledger_entry.py | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/integration/reqs/test_ledger_entry_sponsorship.py 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/xrpl/models/requests/ledger_entry.py b/xrpl/models/requests/ledger_entry.py index 6200db1b3..9132d9edb 100644 --- a/xrpl/models/requests/ledger_entry.py +++ b/xrpl/models/requests/ledger_entry.py @@ -291,7 +291,7 @@ class Ticket(BaseModel): class Sponsorship(BaseModel): """Required fields for requesting a Sponsorship if not querying by object ID.""" - owner: str = REQUIRED + sponsor: str = REQUIRED """ This field is required. From 20f1c604b4475acf2ac192f6b9114c5d8638fe65 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Mon, 16 Mar 2026 12:53:49 -0700 Subject: [PATCH 10/14] test: integ tests for AccountSet transaction --- .../transactions/test_account_set.py | 66 +++++++++++++++++++ xrpl/models/requests/ledger_entry.py | 3 - xrpl/models/transactions/account_set.py | 2 +- 3 files changed, 67 insertions(+), 4 deletions(-) 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/xrpl/models/requests/ledger_entry.py b/xrpl/models/requests/ledger_entry.py index 9132d9edb..42f1a08b6 100644 --- a/xrpl/models/requests/ledger_entry.py +++ b/xrpl/models/requests/ledger_entry.py @@ -283,9 +283,6 @@ class Ticket(BaseModel): """ -# CK TODO: Add integration tests that exercise all the new code paths in this file - - @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) class Sponsorship(BaseModel): diff --git a/xrpl/models/transactions/account_set.py b/xrpl/models/transactions/account_set.py index ad030ac4b..5e02dc5a2 100644 --- a/xrpl/models/transactions/account_set.py +++ b/xrpl/models/transactions/account_set.py @@ -111,7 +111,7 @@ class AccountSetAsfFlag(int, Enum): If this account is an Issuer of IOU tokens, this flag allows such tokens to be used in Escrow. """ - # CK TODO: Add integ tests to validate this new addition + ASF_DISALLOW_INCOMING_SPONSOR = 19 """Disallow other accounts from creating Sponsorship objects directed at this account.""" From 3f0b69b764a988fc569a1e040daf57ef0428979f Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S <21219765+ckeshava@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:18:55 -0700 Subject: [PATCH 11/14] Update tests/unit/models/transactions/test_sponsorship_transfer.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- tests/unit/models/transactions/test_sponsorship_transfer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/models/transactions/test_sponsorship_transfer.py b/tests/unit/models/transactions/test_sponsorship_transfer.py index 3651a440b..f790f3a72 100644 --- a/tests/unit/models/transactions/test_sponsorship_transfer.py +++ b/tests/unit/models/transactions/test_sponsorship_transfer.py @@ -66,7 +66,7 @@ def test_valid_create_flag(self): self.assertTrue(tx.is_valid()) def test_valid_reassign_flag(self): - """Using TF_SPONSORSHIP_REASSIGN flag (no sponsee — forbidden with REASSIGN).""" + """Using TF_SPONSORSHIP_REASSIGN flag.""" tx = SponsorshipTransfer( account=_ACCOUNT, object_id=_OBJECT_ID, From 811f420df7e9446e07624ba8d8d8365093e64ba8 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 19 Mar 2026 08:29:05 -0700 Subject: [PATCH 12/14] update CL for brevity --- CHANGELOG.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 242a42596..af2bd5e67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,17 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added support for the XLS-68d Sponsored Fees amendment (`featureSponsor`): - - New transaction types: `SponsorshipSet` and `SponsorshipTransfer` - - New ledger object type: `Sponsorship` (type code 144) - - New inner object model: `SponsorSignature` for sponsor co-signing - - Common transaction sponsor fields: `Sponsor` (AccountID), `SponsorFlags` (UInt32), `SponsorSignature` (STObject) on all transaction types for co-signed sponsorship - - Payment `TF_SPONSOR_CREATED_ACCOUNT` (0x00080000) flag for sponsoring account creation - - Granular permissions: `SponsorFee` (65549) and `SponsorReserve` (65550) for delegated sponsorship authority - - New flags: `SponsorshipSetFlag` (5 flags) and `SponsorshipTransferFlag` (3 flags) - - New `AccountSetAsfFlag.ASF_DISALLOW_INCOMING_SPONSOR` (19) - - New `AccountObjectType.SPONSORSHIP` and `LedgerEntryType.SPONSORSHIP` - - 15 new binary codec field definitions for sponsorship-related fields +- Added support for the XLS-68d Sponsored-Fees-Reserves amendment ### Fixed From bc52ae32a419730b45d04c080bfe408a47d5dced Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Fri, 20 Mar 2026 11:48:02 -0700 Subject: [PATCH 13/14] feat: Add utility methods to help populate SponsorSignature field. Integ tests are also included to validate the sign_as_sponsor utility methods --- .../transactions/test_sponsor_signer.py | 172 ++++++++++ xrpl/transaction/__init__.py | 3 + xrpl/transaction/sponsor_signer.py | 293 ++++++++++++++++++ 3 files changed, 468 insertions(+) create mode 100644 tests/integration/transactions/test_sponsor_signer.py create mode 100644 xrpl/transaction/sponsor_signer.py 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/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..132f6e669 --- /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 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: + 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) From 67b08d8eab5c7ad94cd84e9d4e4ea1ab3f41d4e0 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Fri, 20 Mar 2026 11:51:10 -0700 Subject: [PATCH 14/14] [trivial] add type annotation for dict return type to satisfy mpyp --- xrpl/transaction/sponsor_signer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xrpl/transaction/sponsor_signer.py b/xrpl/transaction/sponsor_signer.py index 132f6e669..695771bd5 100644 --- a/xrpl/transaction/sponsor_signer.py +++ b/xrpl/transaction/sponsor_signer.py @@ -30,7 +30,7 @@ """ from dataclasses import dataclass -from typing import List, Optional, Union +from typing import Dict, List, Optional, Union from xrpl.constants import XRPLException from xrpl.core.addresscodec import ( @@ -258,7 +258,7 @@ def _validate_sponsor_transaction_equivalence(transactions: List[Transaction]) - if len(transactions) <= 1: return - def _strip_signers(tx: Transaction) -> dict: + def _strip_signers(tx: Transaction) -> Dict[str, object]: d = tx.to_xrpl() if "SponsorSignature" in d: d["SponsorSignature"] = {**d["SponsorSignature"], "Signers": None}