Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ci-config/rippled.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Dropped support for Python 3.8 (EOL October 2024). The minimum supported Python version is now 3.9.

### Added

- Added support for the XLS-68d Sponsored-Fees-Reserves amendment

### Fixed

- Fixed correct mapping of `sfMutableFlags`, `sfStartDate`, and `sfPreviousPaymentDueDate` fields in the binary codec `definitions.json`.
Expand Down
99 changes: 99 additions & 0 deletions tests/integration/reqs/test_account_objects_sponsored.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Integration tests for AccountObjects request with sponsored field and new
AccountObjectType values (XLS-68 sponsored fees)."""

from tests.integration.integration_test_case import IntegrationTestCase
from tests.integration.it_utils import (
fund_wallet_async,
sign_and_reliable_submission_async,
test_async_and_sync,
)
from xrpl.models import AccountObjects, AccountObjectType, SponsorshipSet
from xrpl.models.response import ResponseStatus
from xrpl.wallet import Wallet


class TestAccountObjectsSponsored(IntegrationTestCase):
@test_async_and_sync(globals())
async def test_sponsored_field_true(self, client):
"""Test that the sponsored=True filter returns only sponsored objects."""
sponsor_wallet = Wallet.create()
sponsee_wallet = Wallet.create()
await fund_wallet_async(sponsor_wallet)
await fund_wallet_async(sponsee_wallet)

# Create a sponsorship
tx = SponsorshipSet(
account=sponsor_wallet.address,
sponsee=sponsee_wallet.address,
)
response = await sign_and_reliable_submission_async(tx, sponsor_wallet, client)
self.assertEqual(response.status, ResponseStatus.SUCCESS)
self.assertEqual(response.result["engine_result"], "tesSUCCESS")

# Query with sponsored=True on the sponsee's account
account_objects_response = await client.request(
AccountObjects(
account=sponsee_wallet.address,
sponsored=True,
)
)
self.assertTrue(account_objects_response.is_successful())

@test_async_and_sync(globals())
async def test_sponsored_field_false(self, client):
"""Test that the sponsored=False filter returns only non-sponsored objects."""
wallet = Wallet.create()
await fund_wallet_async(wallet)

# Query with sponsored=False
account_objects_response = await client.request(
AccountObjects(
account=wallet.address,
sponsored=False,
)
)
self.assertTrue(account_objects_response.is_successful())

@test_async_and_sync(globals())
async def test_sponsored_field_none(self, client):
"""Test that omitting sponsored returns all objects (default behavior)."""
wallet = Wallet.create()
await fund_wallet_async(wallet)

# Query without sponsored field (default None)
account_objects_response = await client.request(
AccountObjects(
account=wallet.address,
)
)
self.assertTrue(account_objects_response.is_successful())

@test_async_and_sync(globals())
async def test_type_sponsorship_filter(self, client):
"""Test filtering account_objects by type=SPONSORSHIP."""
sponsor_wallet = Wallet.create()
sponsee_wallet = Wallet.create()
await fund_wallet_async(sponsor_wallet)
await fund_wallet_async(sponsee_wallet)

# Create a sponsorship
tx = SponsorshipSet(
account=sponsor_wallet.address,
sponsee=sponsee_wallet.address,
)
response = await sign_and_reliable_submission_async(tx, sponsor_wallet, client)
self.assertEqual(response.status, ResponseStatus.SUCCESS)
self.assertEqual(response.result["engine_result"], "tesSUCCESS")

# Filter by SPONSORSHIP type on the sponsor's account
account_objects_response = await client.request(
AccountObjects(
account=sponsor_wallet.address,
type=AccountObjectType.SPONSORSHIP,
)
)
self.assertTrue(account_objects_response.is_successful())
sponsorship_objects = account_objects_response.result["account_objects"]
self.assertGreater(len(sponsorship_objects), 0)
for obj in sponsorship_objects:
self.assertEqual(obj["LedgerEntryType"], "Sponsorship")
49 changes: 49 additions & 0 deletions tests/integration/reqs/test_ledger_entry_sponsorship.py
Original file line number Diff line number Diff line change
@@ -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)
113 changes: 82 additions & 31 deletions tests/integration/transactions/test_account_delete.py
Original file line number Diff line number Diff line change
@@ -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")
66 changes: 66 additions & 0 deletions tests/integration/transactions/test_account_set.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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")
Loading
Loading