+
{t('sponsor')}
+
+ }}
+ />
+
+ {scopes.length > 0 && (
+
+ {scopes
+ .map((scope) => t(SPONSOR_SCOPE_LABEL_KEYS[scope]))
+ .join(', ')}
+
+ )}
+ {data.tx.SponsorSignature && (
+
{t('sponsor_co_signed')}
+ )}
+
+ )
+ }
+
const renderSigners = () =>
data.tx.Signers ? (
@@ -128,6 +167,7 @@ export const DetailTab: FC<{ data: any }> = ({ data }) => {
{renderStatus()}
{renderSigners()}
+ {renderSponsor()}
{renderFlags()}
{renderFee()}
diff --git a/src/containers/Transactions/SimpleTab.tsx b/src/containers/Transactions/SimpleTab.tsx
index b0d7fe6de..6cd599b27 100644
--- a/src/containers/Transactions/SimpleTab.tsx
+++ b/src/containers/Transactions/SimpleTab.tsx
@@ -8,6 +8,7 @@ import { Simple } from './Simple'
import { useLanguage } from '../shared/hooks'
import { RouteLink } from '../shared/routing'
import { CURRENCY_OPTIONS, XRP_BASE } from '../shared/transactionUtils'
+import { getOperation } from '../shared/components/Transaction/SponsorshipTransfer/parser'
import { SimpleRow } from '../shared/components/Transaction/SimpleRow'
import '../shared/css/simpleTab.scss'
import './simpleTab.scss'
@@ -41,6 +42,7 @@ export const SimpleTab: FC<{ data: any; width: number }> = ({
sequence,
ticketSequence,
isHook,
+ sponsor,
) => (
<>
= ({
)}
+ {sponsor && (
+
+
+
+ )}
= ({
)
: 0
+ // On create/reassign, SponsorshipTransfer reuses the common Sponsor field
+ // for its own "new sponsor of the target object" meaning, not for
+ // co-sponsoring this outer transaction, so it's excluded from the generic
+ // Sponsor row there. On tfSponsorshipEnd the spec requires Sponsor to be
+ // omitted for that purpose, so if it's present it can only be genuine
+ // outer co-sponsorship.
+ const isNewSponsorField =
+ processed.tx.TransactionType === 'SponsorshipTransfer' &&
+ getOperation(processed.tx.Flags || 0) !== 'end'
+ const sponsor = isNewSponsorField ? undefined : processed.tx.Sponsor
+
const rowIndex = renderRowIndex(
time,
ledgerIndex,
@@ -99,6 +117,7 @@ export const SimpleTab: FC<{ data: any; width: number }> = ({
processed.tx.Sequence,
processed.tx.TicketSequence,
!!processed.tx.EmitDetails,
+ sponsor,
)
return (
diff --git a/src/containers/Transactions/test/DetailTab.test.tsx b/src/containers/Transactions/test/DetailTab.test.tsx
index fb678634b..98ab0012a 100644
--- a/src/containers/Transactions/test/DetailTab.test.tsx
+++ b/src/containers/Transactions/test/DetailTab.test.tsx
@@ -7,6 +7,7 @@ import FailedTransaction from '../../shared/components/Transaction/SignerListSet
import HookPayment from './mock_data/HookPayment.json'
import EmittedPayment from './mock_data/EmittedPayment.json'
import TrustSet from './mock_data/TrustSet.json'
+import SponsorshipTransferCreate from '../../shared/components/Transaction/SponsorshipTransfer/test/mock_data/SponsorshipTransferCreate.json'
import { DetailTab } from '../DetailTab'
import i18n from '../../../i18n/testConfigEnglish'
import { convertHexToString } from '../../../rippled/lib/utils'
@@ -151,6 +152,28 @@ describe('DetailTab container', () => {
expect(detailLines?.[3]).toHaveTextContent('Emitted 0 transactions')
})
+ it('renders a Sponsor section for a co-sponsored transaction', () => {
+ const sponsoredTransaction = {
+ ...Transaction,
+ tx: {
+ ...Transaction.tx,
+ Sponsor: 'rSponsor1111111111111111111111111',
+ SponsorFlags: 1,
+ },
+ }
+ const { container } = renderDetailTab(sponsoredTransaction)
+ expect(
+ container.querySelector('[data-testid="sponsor-section"]'),
+ ).toBeInTheDocument()
+ })
+
+ it('does not render a Sponsor section for SponsorshipTransfer, which reuses the field for its own meaning', () => {
+ const { container } = renderDetailTab(SponsorshipTransferCreate)
+ expect(
+ container.querySelector('[data-testid="sponsor-section"]'),
+ ).not.toBeInTheDocument()
+ })
+
it('renders flags', () => {
const { container } = renderDetailTab(TrustSet)
const expectedFlags = new Set([
diff --git a/src/containers/Transactions/test/SimpleTab.test.tsx b/src/containers/Transactions/test/SimpleTab.test.tsx
index 05f22a133..2efd9a1ac 100644
--- a/src/containers/Transactions/test/SimpleTab.test.tsx
+++ b/src/containers/Transactions/test/SimpleTab.test.tsx
@@ -6,6 +6,8 @@ import { QueryClientProvider } from 'react-query'
import EnableAmendment from './mock_data/EnableAmendment.json'
import Payment from '../../shared/components/Transaction/Payment/test/mock_data/Payment.json'
import DelegatePayment from './mock_data/DelegatePayment.json'
+import SponsorshipTransferCreate from '../../shared/components/Transaction/SponsorshipTransfer/test/mock_data/SponsorshipTransferCreate.json'
+import SponsorshipTransferEnd from '../../shared/components/Transaction/SponsorshipTransfer/test/mock_data/SponsorshipTransferEnd.json'
import { SimpleTab } from '../SimpleTab'
import summarize from '../../../rippled/lib/txSummary'
import i18n from '../../../i18n/testConfig'
@@ -79,4 +81,45 @@ describe('SimpleTab container', () => {
expectSimpleRowText(container, 'sequence', '2947132')
expectSimpleRowText(container, 'tx-cost', '\uE9000.000001')
})
+
+ it('renders a Sponsor row for a co-sponsored transaction', () => {
+ const sponsoredPayment = {
+ ...Payment,
+ tx: {
+ ...Payment.tx,
+ Sponsor: 'rSponsor1111111111111111111111111',
+ SponsorFlags: 1,
+ },
+ }
+ const { container } = renderSimpleTab(sponsoredPayment)
+ expectSimpleRowText(
+ container,
+ 'sponsor',
+ 'rSponsor1111111111111111111111111',
+ )
+ })
+
+ it('does not render a Sponsor row for SponsorshipTransfer create/reassign, which reuses the field for its own meaning', () => {
+ const { container } = renderSimpleTab(SponsorshipTransferCreate)
+ expect(
+ container.querySelector('[data-testid="sponsor"]'),
+ ).not.toBeInTheDocument()
+ })
+
+ it('renders a Sponsor row for SponsorshipTransfer end, where the field is free for genuine co-sponsorship', () => {
+ const coSponsoredEnd = {
+ ...SponsorshipTransferEnd,
+ tx: {
+ ...SponsorshipTransferEnd.tx,
+ Sponsor: 'rSponsor1111111111111111111111111',
+ SponsorFlags: 1,
+ },
+ }
+ const { container } = renderSimpleTab(coSponsoredEnd)
+ expectSimpleRowText(
+ container,
+ 'sponsor',
+ 'rSponsor1111111111111111111111111',
+ )
+ })
})
diff --git a/src/containers/shared/components/Transaction/DefaultSimple.tsx b/src/containers/shared/components/Transaction/DefaultSimple.tsx
index 481616688..070fc07ca 100644
--- a/src/containers/shared/components/Transaction/DefaultSimple.tsx
+++ b/src/containers/shared/components/Transaction/DefaultSimple.tsx
@@ -20,6 +20,9 @@ const DEFAULT_TX_ELEMENTS = [
'NetworkID',
'Sequence',
'SigningPubKey',
+ 'Sponsor',
+ 'SponsorFlags',
+ 'SponsorSignature',
'TransactionType',
'TxnSignature',
'ctid',
diff --git a/src/containers/shared/components/Transaction/SponsorshipSet/Description.tsx b/src/containers/shared/components/Transaction/SponsorshipSet/Description.tsx
new file mode 100644
index 000000000..f40b9baad
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipSet/Description.tsx
@@ -0,0 +1,44 @@
+import { Trans } from 'react-i18next'
+import type { SponsorshipSet } from 'xrpl'
+import { TransactionDescriptionProps } from '../types'
+import { Account } from '../../Account'
+import { parser } from './parser'
+
+function getDescriptionKey(
+ isDelete: boolean,
+ hasFeeDelta: boolean,
+ hasReserveDelta: boolean,
+) {
+ if (isDelete) return 'sponsorship_set_delete'
+ if (hasFeeDelta && hasReserveDelta)
+ return 'sponsorship_set_description_fee_reserve'
+ if (hasReserveDelta) return 'sponsorship_set_description_reserve'
+ if (hasFeeDelta) return 'sponsorship_set_description_fee'
+ return 'sponsorship_set_description_generic'
+}
+
+export const Description = ({
+ data,
+}: TransactionDescriptionProps) => {
+ const {
+ sponsor,
+ sponsee,
+ isDelete,
+ feeAmountDelta,
+ remainingOwnerCountDelta,
+ } = parser(data.tx)
+
+ return (
+ ,
+ Sponsee: ,
+ }}
+ />
+ )
+}
diff --git a/src/containers/shared/components/Transaction/SponsorshipSet/Simple.tsx b/src/containers/shared/components/Transaction/SponsorshipSet/Simple.tsx
new file mode 100644
index 000000000..0d4cbe115
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipSet/Simple.tsx
@@ -0,0 +1,76 @@
+import { useTranslation } from 'react-i18next'
+import { TransactionSimpleComponent, TransactionSimpleProps } from '../types'
+import { SimpleRow } from '../SimpleRow'
+import { Account } from '../../Account'
+import { Amount } from '../../Amount'
+
+export const Simple: TransactionSimpleComponent = ({
+ data,
+}: TransactionSimpleProps) => {
+ const { t } = useTranslation()
+ const {
+ sponsor,
+ sponsee,
+ isDelete,
+ feeAmountDelta,
+ maxFee,
+ remainingOwnerCountDelta,
+ requireSignForFee,
+ requireSignForReserve,
+ } = data.instructions
+
+ return (
+ <>
+
+
+
+
+
+
+ {isDelete && (
+
+ {t('sponsorship_deleted')}
+
+ )}
+ {!isDelete && feeAmountDelta && (
+
+
+
+ )}
+ {!isDelete && maxFee && (
+
+
+
+ )}
+ {!isDelete && remainingOwnerCountDelta !== undefined && (
+
+ {remainingOwnerCountDelta > 0
+ ? `+${remainingOwnerCountDelta}`
+ : remainingOwnerCountDelta}
+
+ )}
+ {!isDelete && requireSignForFee && (
+
+ {t('yes')}
+
+ )}
+ {!isDelete && requireSignForReserve && (
+
+ {t('yes')}
+
+ )}
+ >
+ )
+}
diff --git a/src/containers/shared/components/Transaction/SponsorshipSet/TableDetail.tsx b/src/containers/shared/components/Transaction/SponsorshipSet/TableDetail.tsx
new file mode 100644
index 000000000..b9bf60a42
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipSet/TableDetail.tsx
@@ -0,0 +1,62 @@
+import { useTranslation } from 'react-i18next'
+import { TransactionTableDetailProps } from '../types'
+import { Account } from '../../Account'
+import { Amount } from '../../Amount'
+
+export const TableDetail = ({ instructions }: TransactionTableDetailProps) => {
+ const { t } = useTranslation()
+ const {
+ sponsor,
+ sponsee,
+ isDelete,
+ feeAmountDelta,
+ maxFee,
+ remainingOwnerCountDelta,
+ } = instructions
+
+ return (
+
+
+
+ {isDelete && (
+
+ {t('status')}
+
+ {t('sponsorship_deleted')}
+
+
+ )}
+ {!isDelete && feeAmountDelta && (
+
+
{t('fee_amount_delta')}
+
+
+ )}
+ {!isDelete && maxFee && (
+
+ )}
+ {!isDelete && remainingOwnerCountDelta !== undefined && (
+
+ {t('reserve_count_delta')}
+
+ {remainingOwnerCountDelta > 0
+ ? `+${remainingOwnerCountDelta}`
+ : remainingOwnerCountDelta}
+
+
+ )}
+
+ )
+}
diff --git a/src/containers/shared/components/Transaction/SponsorshipSet/index.ts b/src/containers/shared/components/Transaction/SponsorshipSet/index.ts
new file mode 100644
index 000000000..ffe94a69a
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipSet/index.ts
@@ -0,0 +1,18 @@
+import {
+ TransactionAction,
+ TransactionCategory,
+ TransactionMapping,
+} from '../types'
+import { Simple } from './Simple'
+import { Description } from './Description'
+import { TableDetail } from './TableDetail'
+import { parser } from './parser'
+
+export const SponsorshipSetTransaction: TransactionMapping = {
+ Description,
+ Simple,
+ TableDetail,
+ parser,
+ action: TransactionAction.MODIFY,
+ category: TransactionCategory.ACCOUNT,
+}
diff --git a/src/containers/shared/components/Transaction/SponsorshipSet/parser.ts b/src/containers/shared/components/Transaction/SponsorshipSet/parser.ts
new file mode 100644
index 000000000..148bd85aa
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipSet/parser.ts
@@ -0,0 +1,40 @@
+import type { SponsorshipSet } from 'xrpl'
+import { formatAmount } from '../../../../../rippled/lib/txSummary/formatAmount'
+
+const TF_DELETE_OBJECT = 0x00100000
+const TF_SET_REQUIRE_SIGN_FOR_FEE = 0x00010000
+const TF_SET_REQUIRE_SIGN_FOR_RESERVE = 0x00040000
+
+// Signs the formatted delta amount explicitly, since a negative amount
+// (a withdrawal) would otherwise read identically to a positive one at a
+// glance in the UI.
+function getSignedDelta(delta: string | undefined) {
+ if (delta === undefined) return undefined
+ const formatted = formatAmount(delta)
+ const amount = Number(formatted.amount)
+ return {
+ value: { ...formatted, amount: Math.abs(amount) },
+ modifier: amount < 0 ? ('-' as const) : ('+' as const),
+ }
+}
+
+export function parser(tx: SponsorshipSet) {
+ const flags = typeof tx.Flags === 'number' ? tx.Flags : 0
+ // If CounterpartySponsor is given, this account is the sponsee; if Sponsee
+ // is given, this account is the sponsor.
+ const sponsor = tx.CounterpartySponsor ?? tx.Account
+ const sponsee = tx.Sponsee ?? tx.Account
+
+ return {
+ sponsor,
+ sponsee,
+ isDelete: Boolean(flags & TF_DELETE_OBJECT),
+ feeAmountDelta: getSignedDelta(tx.FeeAmountDelta),
+ maxFee: tx.MaxFee !== undefined ? formatAmount(tx.MaxFee) : undefined,
+ remainingOwnerCountDelta: tx.RemainingOwnerCountDelta,
+ requireSignForFee:
+ Boolean(flags & TF_SET_REQUIRE_SIGN_FOR_FEE) || undefined,
+ requireSignForReserve:
+ Boolean(flags & TF_SET_REQUIRE_SIGN_FOR_RESERVE) || undefined,
+ }
+}
diff --git a/src/containers/shared/components/Transaction/SponsorshipSet/test/SponsorshipSetDescription.test.tsx b/src/containers/shared/components/Transaction/SponsorshipSet/test/SponsorshipSetDescription.test.tsx
new file mode 100644
index 000000000..7619b8bec
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipSet/test/SponsorshipSetDescription.test.tsx
@@ -0,0 +1,70 @@
+import i18n from '../../../../../../i18n/testConfigEnglish'
+import { createDescriptionRenderFactory } from '../../test'
+import { Description } from '../Description'
+import SponsorshipSet from './mock_data/SponsorshipSet.json'
+import SponsorshipSetDelete from './mock_data/SponsorshipSetDelete.json'
+
+const renderComponent = createDescriptionRenderFactory(Description, i18n)
+
+const SPONSOR = 'rFeeSponsorAlpha11111111111111111'
+const SPONSEE = 'rncKvRcdDq9hVJpdLdTcKoxsS3NSkXsvfM'
+
+describe('SponsorshipSet: Description', () => {
+ it('describes fee and reserve sponsorship being set together', () => {
+ const { container, unmount } = renderComponent(SponsorshipSet)
+ expect(container).toHaveTextContent(
+ `${SPONSOR} sponsors transaction fees and reserves for ${SPONSEE}`,
+ )
+ unmount()
+ })
+
+ it('describes only fee sponsorship being set', () => {
+ const { container, unmount } = renderComponent({
+ tx: {
+ Account: SPONSOR,
+ Sponsee: SPONSEE,
+ FeeAmountDelta: '1000000',
+ },
+ })
+ expect(container).toHaveTextContent(
+ `${SPONSOR} sponsors transaction fees for ${SPONSEE}`,
+ )
+ unmount()
+ })
+
+ it('describes only reserve sponsorship being set', () => {
+ const { container, unmount } = renderComponent({
+ tx: {
+ Account: SPONSOR,
+ Sponsee: SPONSEE,
+ RemainingOwnerCountDelta: 5,
+ },
+ })
+ expect(container).toHaveTextContent(
+ `${SPONSOR} sponsors reserves for ${SPONSEE}`,
+ )
+ unmount()
+ })
+
+ it('falls back to a generic description when neither delta is present', () => {
+ const { container, unmount } = renderComponent({
+ tx: {
+ Account: SPONSOR,
+ Sponsee: SPONSEE,
+ MaxFee: '1000',
+ },
+ })
+ expect(container).toHaveTextContent(
+ `${SPONSOR} updates its sponsorship terms for ${SPONSEE}`,
+ )
+ unmount()
+ })
+
+ it('describes a fee sponsorship being ended', () => {
+ const { container, unmount } = renderComponent(SponsorshipSetDelete)
+ expect(container).toHaveTextContent(
+ `${SPONSOR} ends the fee sponsorship for ${SPONSEE}`,
+ )
+ unmount()
+ })
+})
diff --git a/src/containers/shared/components/Transaction/SponsorshipSet/test/SponsorshipSetSimple.test.tsx b/src/containers/shared/components/Transaction/SponsorshipSet/test/SponsorshipSetSimple.test.tsx
new file mode 100644
index 000000000..20892f5f9
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipSet/test/SponsorshipSetSimple.test.tsx
@@ -0,0 +1,38 @@
+import { createSimpleRenderFactory, expectSimpleRowText } from '../../test'
+import { Simple } from '../Simple'
+import i18n from '../../../../../../i18n/testConfigEnglish'
+import SponsorshipSet from './mock_data/SponsorshipSet.json'
+import SponsorshipSetDelete from './mock_data/SponsorshipSetDelete.json'
+
+const renderComponent = createSimpleRenderFactory(Simple, i18n)
+
+describe('SponsorshipSet: Simple', () => {
+ it('renders fee sponsorship fields', () => {
+ const { container, unmount } = renderComponent(SponsorshipSet)
+
+ expectSimpleRowText(
+ container,
+ 'sponsor',
+ 'rFeeSponsorAlpha11111111111111111',
+ )
+ expectSimpleRowText(
+ container,
+ 'sponsee',
+ 'rncKvRcdDq9hVJpdLdTcKoxsS3NSkXsvfM',
+ )
+ expectSimpleRowText(container, 'fee-amount-delta', '1.00 XRP')
+ expectSimpleRowText(container, 'max-fee', '0.001 XRP')
+ expectSimpleRowText(container, 'reserve-count-delta', '+5')
+ unmount()
+ })
+
+ it('renders deletion state without fee fields', () => {
+ const { container, unmount } = renderComponent(SponsorshipSetDelete)
+
+ expectSimpleRowText(container, 'sponsorship-deleted', 'Sponsorship Deleted')
+ expect(
+ container.querySelector('[data-testid="fee-amount-delta"]'),
+ ).not.toBeInTheDocument()
+ unmount()
+ })
+})
diff --git a/src/containers/shared/components/Transaction/SponsorshipSet/test/SponsorshipSetTableDetail.test.tsx b/src/containers/shared/components/Transaction/SponsorshipSet/test/SponsorshipSetTableDetail.test.tsx
new file mode 100644
index 000000000..92ab88755
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipSet/test/SponsorshipSetTableDetail.test.tsx
@@ -0,0 +1,44 @@
+import { createTableDetailRenderFactory } from '../../test'
+import { TableDetail } from '../TableDetail'
+import i18n from '../../../../../../i18n/testConfigEnglish'
+import SponsorshipSet from './mock_data/SponsorshipSet.json'
+import SponsorshipSetDelete from './mock_data/SponsorshipSetDelete.json'
+
+const renderComponent = createTableDetailRenderFactory(TableDetail, i18n)
+
+describe('SponsorshipSetTableDetail', () => {
+ it('renders fee sponsorship details', () => {
+ const { container, unmount } = renderComponent(SponsorshipSet)
+
+ expect(container.querySelectorAll('.account')[0]).toHaveTextContent(
+ 'rFeeSponsorAlpha11111111111111111',
+ )
+ expect(container.querySelectorAll('.account')[1]).toHaveTextContent(
+ 'rncKvRcdDq9hVJpdLdTcKoxsS3NSkXsvfM',
+ )
+ expect(
+ container.querySelectorAll('[data-testid="amount"]')[0],
+ ).toHaveTextContent('1.00 XRP')
+ expect(
+ container.querySelectorAll('[data-testid="amount"]')[1],
+ ).toHaveTextContent('0.001 XRP')
+ expect(container.querySelector('.sponsorship-set')).toHaveTextContent(
+ 'Reserve Count Change+5',
+ )
+
+ unmount()
+ })
+
+ it('renders deletion state', () => {
+ const { container, unmount } = renderComponent(SponsorshipSetDelete)
+
+ expect(
+ container.querySelector('[data-testid="sponsorship-deleted"]'),
+ ).toHaveTextContent('Sponsorship Deleted')
+ expect(container.querySelector('.sponsorship-set')).not.toHaveTextContent(
+ 'Fee Amount Change',
+ )
+
+ unmount()
+ })
+})
diff --git a/src/containers/shared/components/Transaction/SponsorshipSet/test/mock_data/SponsorshipSet.json b/src/containers/shared/components/Transaction/SponsorshipSet/test/mock_data/SponsorshipSet.json
new file mode 100644
index 000000000..e906662fc
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipSet/test/mock_data/SponsorshipSet.json
@@ -0,0 +1,23 @@
+{
+ "hash": "A1B2C3D4E5F6070809101112131415161718192021222324252627282930313A",
+ "ledger_index": 46635300,
+ "date": "2026-08-14T13:49:23+00:00",
+ "tx": {
+ "Account": "rFeeSponsorAlpha11111111111111111",
+ "Sponsee": "rncKvRcdDq9hVJpdLdTcKoxsS3NSkXsvfM",
+ "FeeAmountDelta": "1000000",
+ "MaxFee": "1000",
+ "RemainingOwnerCountDelta": 5,
+ "Fee": "12",
+ "Flags": 0,
+ "Sequence": 42,
+ "SigningPubKey": "ED5063FC56A0BD4398964FFD55B3FC353C291C34B81A60BFD33FDC0B036C4D403E",
+ "TransactionType": "SponsorshipSet",
+ "TxnSignature": "E98B5560AEE64C27C680E7B9A83239DB3035958761626186455E56C841EBD88438057A0B355C9410E6E5B1A494BFE957ADD8B4873FC9D4700A3BA1041451A905"
+ },
+ "meta": {
+ "AffectedNodes": [],
+ "TransactionIndex": 0,
+ "TransactionResult": "tesSUCCESS"
+ }
+}
diff --git a/src/containers/shared/components/Transaction/SponsorshipSet/test/mock_data/SponsorshipSetDelete.json b/src/containers/shared/components/Transaction/SponsorshipSet/test/mock_data/SponsorshipSetDelete.json
new file mode 100644
index 000000000..1525cfa2a
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipSet/test/mock_data/SponsorshipSetDelete.json
@@ -0,0 +1,20 @@
+{
+ "hash": "B1B2C3D4E5F6070809101112131415161718192021222324252627282930313B",
+ "ledger_index": 46635301,
+ "date": "2026-08-14T13:49:23+00:00",
+ "tx": {
+ "Account": "rFeeSponsorAlpha11111111111111111",
+ "Sponsee": "rncKvRcdDq9hVJpdLdTcKoxsS3NSkXsvfM",
+ "Fee": "12",
+ "Flags": 1048576,
+ "Sequence": 43,
+ "SigningPubKey": "ED5063FC56A0BD4398964FFD55B3FC353C291C34B81A60BFD33FDC0B036C4D403E",
+ "TransactionType": "SponsorshipSet",
+ "TxnSignature": "E98B5560AEE64C27C680E7B9A83239DB3035958761626186455E56C841EBD88438057A0B355C9410E6E5B1A494BFE957ADD8B4873FC9D4700A3BA1041451A905"
+ },
+ "meta": {
+ "AffectedNodes": [],
+ "TransactionIndex": 0,
+ "TransactionResult": "tesSUCCESS"
+ }
+}
diff --git a/src/containers/shared/components/Transaction/SponsorshipTransfer/Description.tsx b/src/containers/shared/components/Transaction/SponsorshipTransfer/Description.tsx
new file mode 100644
index 000000000..4b826eb59
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipTransfer/Description.tsx
@@ -0,0 +1,60 @@
+import { Trans } from 'react-i18next'
+import type { SponsorshipTransfer } from 'xrpl'
+import { TransactionDescriptionProps } from '../types'
+import { Account } from '../../Account'
+import { parser } from './parser'
+
+export const Description = ({
+ data,
+}: TransactionDescriptionProps) => {
+ const { operation, account, sponsor, sponsee } = parser(data.tx)
+
+ if (operation === 'create' && sponsor) {
+ return (
+ ,
+ Sponsor: ,
+ }}
+ />
+ )
+ }
+
+ if (operation === 'reassign' && sponsor) {
+ return (
+ ,
+ Sponsor: ,
+ }}
+ />
+ )
+ }
+
+ if (operation === 'end') {
+ if (sponsee) {
+ return (
+ ,
+ Sponsee: ,
+ }}
+ />
+ )
+ }
+
+ return (
+ ,
+ }}
+ />
+ )
+ }
+
+ return null
+}
diff --git a/src/containers/shared/components/Transaction/SponsorshipTransfer/Simple.tsx b/src/containers/shared/components/Transaction/SponsorshipTransfer/Simple.tsx
new file mode 100644
index 000000000..f5ddb6e0c
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipTransfer/Simple.tsx
@@ -0,0 +1,42 @@
+import { useTranslation } from 'react-i18next'
+import { TransactionSimpleComponent, TransactionSimpleProps } from '../types'
+import { SimpleRow } from '../SimpleRow'
+import { Account } from '../../Account'
+
+const OPERATION_LABEL_KEYS = {
+ create: 'sponsorship_operation_create',
+ reassign: 'sponsorship_operation_reassign',
+ end: 'sponsorship_operation_end',
+} as const
+
+export const Simple: TransactionSimpleComponent = ({
+ data,
+}: TransactionSimpleProps) => {
+ const { t } = useTranslation()
+ const { operation, objectId, sponsor, sponsee } = data.instructions
+
+ return (
+ <>
+ {operation && (
+
+ {t(OPERATION_LABEL_KEYS[operation])}
+
+ )}
+ {objectId && (
+
+ {objectId}
+
+ )}
+ {sponsor && (
+
+
+
+ )}
+ {sponsee && (
+
+
+
+ )}
+ >
+ )
+}
diff --git a/src/containers/shared/components/Transaction/SponsorshipTransfer/TableDetail.tsx b/src/containers/shared/components/Transaction/SponsorshipTransfer/TableDetail.tsx
new file mode 100644
index 000000000..816be7542
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipTransfer/TableDetail.tsx
@@ -0,0 +1,48 @@
+import { useTranslation } from 'react-i18next'
+import { TransactionTableDetailProps } from '../types'
+import { Account } from '../../Account'
+import { shortenTxHash } from '../../../utils'
+
+const OPERATION_LABEL_KEYS = {
+ create: 'sponsorship_operation_create',
+ reassign: 'sponsorship_operation_reassign',
+ end: 'sponsorship_operation_end',
+} as const
+
+export const TableDetail = ({ instructions }: TransactionTableDetailProps) => {
+ const { t } = useTranslation()
+ const { operation, objectId, sponsor, sponsee } = instructions
+
+ return (
+
+ {operation && (
+
+ {t('operation')}
+
+ {t(OPERATION_LABEL_KEYS[operation])}
+
+
+ )}
+ {objectId && (
+
+ {t('object_id')}
+
+ {shortenTxHash(objectId)}
+
+
+ )}
+ {sponsor && (
+
+ )}
+ {sponsee && (
+
+ )}
+
+ )
+}
diff --git a/src/containers/shared/components/Transaction/SponsorshipTransfer/index.ts b/src/containers/shared/components/Transaction/SponsorshipTransfer/index.ts
new file mode 100644
index 000000000..df713c406
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipTransfer/index.ts
@@ -0,0 +1,18 @@
+import {
+ TransactionAction,
+ TransactionCategory,
+ TransactionMapping,
+} from '../types'
+import { Simple } from './Simple'
+import { Description } from './Description'
+import { TableDetail } from './TableDetail'
+import { parser } from './parser'
+
+export const SponsorshipTransferTransaction: TransactionMapping = {
+ Description,
+ Simple,
+ TableDetail,
+ parser,
+ action: TransactionAction.MODIFY,
+ category: TransactionCategory.ACCOUNT,
+}
diff --git a/src/containers/shared/components/Transaction/SponsorshipTransfer/parser.ts b/src/containers/shared/components/Transaction/SponsorshipTransfer/parser.ts
new file mode 100644
index 000000000..81e96e64c
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipTransfer/parser.ts
@@ -0,0 +1,26 @@
+import type { SponsorshipTransfer } from 'xrpl'
+
+const TF_END = 0x00010000
+const TF_CREATE = 0x00020000
+const TF_REASSIGN = 0x00040000
+
+export type SponsorshipTransferOperation = 'create' | 'reassign' | 'end'
+
+export function getOperation(
+ flags: number,
+): SponsorshipTransferOperation | undefined {
+ if (flags & TF_CREATE) return 'create'
+ if (flags & TF_REASSIGN) return 'reassign'
+ if (flags & TF_END) return 'end'
+ return undefined
+}
+
+export function parser(tx: SponsorshipTransfer) {
+ return {
+ operation: getOperation(typeof tx.Flags === 'number' ? tx.Flags : 0),
+ account: tx.Account,
+ objectId: tx.ObjectID,
+ sponsor: tx.Sponsor,
+ sponsee: tx.Sponsee,
+ }
+}
diff --git a/src/containers/shared/components/Transaction/SponsorshipTransfer/test/SponsorshipTransferDescription.test.tsx b/src/containers/shared/components/Transaction/SponsorshipTransfer/test/SponsorshipTransferDescription.test.tsx
new file mode 100644
index 000000000..99eec68e2
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipTransfer/test/SponsorshipTransferDescription.test.tsx
@@ -0,0 +1,34 @@
+import i18n from '../../../../../../i18n/testConfigEnglish'
+import { createDescriptionRenderFactory } from '../../test'
+import { Description } from '../Description'
+import SponsorshipTransferCreate from './mock_data/SponsorshipTransferCreate.json'
+import SponsorshipTransferReassign from './mock_data/SponsorshipTransferReassign.json'
+import SponsorshipTransferEnd from './mock_data/SponsorshipTransferEnd.json'
+
+const renderComponent = createDescriptionRenderFactory(Description, i18n)
+
+describe('SponsorshipTransfer: Description', () => {
+ it('describes a create operation', () => {
+ const { container, unmount } = renderComponent(SponsorshipTransferCreate)
+ expect(container).toHaveTextContent(
+ 'rncKvRcdDq9hVJpdLdTcKoxsS3NSkXsvfM assigns rBaseReserveSponsor1111111111111 as its reserve sponsor',
+ )
+ unmount()
+ })
+
+ it('describes a reassign operation', () => {
+ const { container, unmount } = renderComponent(SponsorshipTransferReassign)
+ expect(container).toHaveTextContent(
+ 'rncKvRcdDq9hVJpdLdTcKoxsS3NSkXsvfM reassigns reserve sponsorship to rBaseReserveSponsor2222222222222',
+ )
+ unmount()
+ })
+
+ it('describes an end operation on behalf of a sponsee', () => {
+ const { container, unmount } = renderComponent(SponsorshipTransferEnd)
+ expect(container).toHaveTextContent(
+ 'rBaseReserveSponsor1111111111111 ends reserve sponsorship for rncKvRcdDq9hVJpdLdTcKoxsS3NSkXsvfM',
+ )
+ unmount()
+ })
+})
diff --git a/src/containers/shared/components/Transaction/SponsorshipTransfer/test/SponsorshipTransferSimple.test.tsx b/src/containers/shared/components/Transaction/SponsorshipTransfer/test/SponsorshipTransferSimple.test.tsx
new file mode 100644
index 000000000..52f5d8aea
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipTransfer/test/SponsorshipTransferSimple.test.tsx
@@ -0,0 +1,57 @@
+import { createSimpleRenderFactory, expectSimpleRowText } from '../../test'
+import { Simple } from '../Simple'
+import i18n from '../../../../../../i18n/testConfigEnglish'
+import SponsorshipTransferCreate from './mock_data/SponsorshipTransferCreate.json'
+import SponsorshipTransferReassign from './mock_data/SponsorshipTransferReassign.json'
+import SponsorshipTransferEnd from './mock_data/SponsorshipTransferEnd.json'
+
+const renderComponent = createSimpleRenderFactory(Simple, i18n)
+
+describe('SponsorshipTransfer: Simple', () => {
+ it('renders a create operation', () => {
+ const { container, unmount } = renderComponent(SponsorshipTransferCreate)
+
+ expectSimpleRowText(container, 'operation', 'Create')
+ expectSimpleRowText(
+ container,
+ 'new-sponsor',
+ 'rBaseReserveSponsor1111111111111',
+ )
+ expect(
+ container.querySelector('[data-testid="object-id"]'),
+ ).not.toBeInTheDocument()
+ unmount()
+ })
+
+ it('renders a reassign operation with an object id', () => {
+ const { container, unmount } = renderComponent(SponsorshipTransferReassign)
+
+ expectSimpleRowText(container, 'operation', 'Reassign')
+ expectSimpleRowText(
+ container,
+ 'object-id',
+ '04F57D5C7F5B660C187BB55227DFC703AEFE482AEA8EEAF4C7FA5ED9BFF3403E',
+ )
+ expectSimpleRowText(
+ container,
+ 'new-sponsor',
+ 'rBaseReserveSponsor2222222222222',
+ )
+ unmount()
+ })
+
+ it('renders an end operation with the sponsee', () => {
+ const { container, unmount } = renderComponent(SponsorshipTransferEnd)
+
+ expectSimpleRowText(container, 'operation', 'End')
+ expectSimpleRowText(
+ container,
+ 'sponsee',
+ 'rncKvRcdDq9hVJpdLdTcKoxsS3NSkXsvfM',
+ )
+ expect(
+ container.querySelector('[data-testid="new-sponsor"]'),
+ ).not.toBeInTheDocument()
+ unmount()
+ })
+})
diff --git a/src/containers/shared/components/Transaction/SponsorshipTransfer/test/SponsorshipTransferTableDetail.test.tsx b/src/containers/shared/components/Transaction/SponsorshipTransfer/test/SponsorshipTransferTableDetail.test.tsx
new file mode 100644
index 000000000..7c12017cf
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipTransfer/test/SponsorshipTransferTableDetail.test.tsx
@@ -0,0 +1,33 @@
+import { createTableDetailRenderFactory } from '../../test'
+import { TableDetail } from '../TableDetail'
+import i18n from '../../../../../../i18n/testConfigEnglish'
+import SponsorshipTransferCreate from './mock_data/SponsorshipTransferCreate.json'
+import SponsorshipTransferReassign from './mock_data/SponsorshipTransferReassign.json'
+
+const renderComponent = createTableDetailRenderFactory(TableDetail, i18n)
+
+describe('SponsorshipTransferTableDetail', () => {
+ it('renders a create operation', () => {
+ const { container, unmount } = renderComponent(SponsorshipTransferCreate)
+
+ expect(
+ container.querySelector('[data-testid="operation"]'),
+ ).toHaveTextContent('Create')
+ expect(container.querySelectorAll('.account')[0]).toHaveTextContent(
+ 'rBaseReserveSponsor1111111111111',
+ )
+ unmount()
+ })
+
+ it('renders a reassign operation with a shortened object id', () => {
+ const { container, unmount } = renderComponent(SponsorshipTransferReassign)
+
+ expect(
+ container.querySelector('[data-testid="operation"]'),
+ ).toHaveTextContent('Reassign')
+ expect(
+ container.querySelector('[data-testid="object-id"]'),
+ ).toHaveTextContent('04F57D...F3403E')
+ unmount()
+ })
+})
diff --git a/src/containers/shared/components/Transaction/SponsorshipTransfer/test/mock_data/SponsorshipTransferCreate.json b/src/containers/shared/components/Transaction/SponsorshipTransfer/test/mock_data/SponsorshipTransferCreate.json
new file mode 100644
index 000000000..b4ad9cc9f
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipTransfer/test/mock_data/SponsorshipTransferCreate.json
@@ -0,0 +1,21 @@
+{
+ "hash": "C1B2C3D4E5F6070809101112131415161718192021222324252627282930313C",
+ "ledger_index": 46635302,
+ "date": "2026-08-14T13:49:23+00:00",
+ "tx": {
+ "Account": "rncKvRcdDq9hVJpdLdTcKoxsS3NSkXsvfM",
+ "Sponsor": "rBaseReserveSponsor1111111111111",
+ "SponsorFlags": 2,
+ "Fee": "12",
+ "Flags": 131072,
+ "Sequence": 44,
+ "SigningPubKey": "ED5063FC56A0BD4398964FFD55B3FC353C291C34B81A60BFD33FDC0B036C4D403E",
+ "TransactionType": "SponsorshipTransfer",
+ "TxnSignature": "E98B5560AEE64C27C680E7B9A83239DB3035958761626186455E56C841EBD88438057A0B355C9410E6E5B1A494BFE957ADD8B4873FC9D4700A3BA1041451A905"
+ },
+ "meta": {
+ "AffectedNodes": [],
+ "TransactionIndex": 0,
+ "TransactionResult": "tesSUCCESS"
+ }
+}
diff --git a/src/containers/shared/components/Transaction/SponsorshipTransfer/test/mock_data/SponsorshipTransferEnd.json b/src/containers/shared/components/Transaction/SponsorshipTransfer/test/mock_data/SponsorshipTransferEnd.json
new file mode 100644
index 000000000..f27d41868
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipTransfer/test/mock_data/SponsorshipTransferEnd.json
@@ -0,0 +1,20 @@
+{
+ "hash": "E1B2C3D4E5F6070809101112131415161718192021222324252627282930313E",
+ "ledger_index": 46635304,
+ "date": "2026-08-14T13:49:23+00:00",
+ "tx": {
+ "Account": "rBaseReserveSponsor1111111111111",
+ "Sponsee": "rncKvRcdDq9hVJpdLdTcKoxsS3NSkXsvfM",
+ "Fee": "12",
+ "Flags": 65536,
+ "Sequence": 46,
+ "SigningPubKey": "ED5063FC56A0BD4398964FFD55B3FC353C291C34B81A60BFD33FDC0B036C4D403E",
+ "TransactionType": "SponsorshipTransfer",
+ "TxnSignature": "E98B5560AEE64C27C680E7B9A83239DB3035958761626186455E56C841EBD88438057A0B355C9410E6E5B1A494BFE957ADD8B4873FC9D4700A3BA1041451A905"
+ },
+ "meta": {
+ "AffectedNodes": [],
+ "TransactionIndex": 0,
+ "TransactionResult": "tesSUCCESS"
+ }
+}
diff --git a/src/containers/shared/components/Transaction/SponsorshipTransfer/test/mock_data/SponsorshipTransferReassign.json b/src/containers/shared/components/Transaction/SponsorshipTransfer/test/mock_data/SponsorshipTransferReassign.json
new file mode 100644
index 000000000..98c3ee378
--- /dev/null
+++ b/src/containers/shared/components/Transaction/SponsorshipTransfer/test/mock_data/SponsorshipTransferReassign.json
@@ -0,0 +1,22 @@
+{
+ "hash": "D1B2C3D4E5F6070809101112131415161718192021222324252627282930313D",
+ "ledger_index": 46635303,
+ "date": "2026-08-14T13:49:23+00:00",
+ "tx": {
+ "Account": "rncKvRcdDq9hVJpdLdTcKoxsS3NSkXsvfM",
+ "ObjectID": "04F57D5C7F5B660C187BB55227DFC703AEFE482AEA8EEAF4C7FA5ED9BFF3403E",
+ "Sponsor": "rBaseReserveSponsor2222222222222",
+ "SponsorFlags": 2,
+ "Fee": "12",
+ "Flags": 262144,
+ "Sequence": 45,
+ "SigningPubKey": "ED5063FC56A0BD4398964FFD55B3FC353C291C34B81A60BFD33FDC0B036C4D403E",
+ "TransactionType": "SponsorshipTransfer",
+ "TxnSignature": "E98B5560AEE64C27C680E7B9A83239DB3035958761626186455E56C841EBD88438057A0B355C9410E6E5B1A494BFE957ADD8B4873FC9D4700A3BA1041451A905"
+ },
+ "meta": {
+ "AffectedNodes": [],
+ "TransactionIndex": 0,
+ "TransactionResult": "tesSUCCESS"
+ }
+}
diff --git a/src/containers/shared/components/Transaction/index.ts b/src/containers/shared/components/Transaction/index.ts
index 42ff43b4d..8a4faf61a 100644
--- a/src/containers/shared/components/Transaction/index.ts
+++ b/src/containers/shared/components/Transaction/index.ts
@@ -38,6 +38,8 @@ import { SetFeeTransaction as SetFee } from './SetFee'
import { SetHookTransaction as SetHook } from './SetHook'
import { SetRegularKeyTransaction as SetRegularKey } from './SetRegularKey'
import { SignerListSetTransaction as SignerListSet } from './SignerListSet'
+import { SponsorshipSetTransaction as SponsorshipSet } from './SponsorshipSet'
+import { SponsorshipTransferTransaction as SponsorshipTransfer } from './SponsorshipTransfer'
import { XChainAccountCreateCommitTransaction as XChainAccountCreateCommit } from './XChainAccountCreateCommit'
import { XChainAddAccountCreateAttestationTransaction as XChainAddAccountCreateAttestation } from './XChainAddAccountCreateAttestation'
import { XChainAddClaimAttestationTransaction as XChainAddClaimAttestation } from './XChainAddClaimAttestation'
@@ -120,6 +122,8 @@ export const transactionTypes: { [key: string]: TransactionMapping } = {
SetHook,
SetRegularKey,
SignerListSet,
+ SponsorshipSet,
+ SponsorshipTransfer,
XChainAccountCreateCommit,
XChainAddAccountCreateAttestation,
XChainAddClaimAttestation,
diff --git a/src/containers/shared/transactionUtils.ts b/src/containers/shared/transactionUtils.ts
index 6b3c4900c..a1532d110 100644
--- a/src/containers/shared/transactionUtils.ts
+++ b/src/containers/shared/transactionUtils.ts
@@ -13,6 +13,27 @@ export const XRP_BASE = 1000000
export const hexMatch = /^(0x)?[0-9A-Fa-f]+$/
export const ACCOUNT_ZERO = 'rrrrrrrrrrrrrrrrrrrrrhoLvTp'
+// Common `SponsorFlags` values (also reused by SponsorshipTransfer.SponsorFlags)
+const SPF_SPONSOR_FEE = 0x00000001
+const SPF_SPONSOR_RESERVE = 0x00000002
+
+export type SponsorScope = 'fee' | 'reserve'
+
+export const getSponsorScopes = (
+ sponsorFlags: number | undefined,
+): SponsorScope[] => {
+ const flags = sponsorFlags || 0
+ const scopes: SponsorScope[] = []
+ if (flags & SPF_SPONSOR_FEE) scopes.push('fee')
+ if (flags & SPF_SPONSOR_RESERVE) scopes.push('reserve')
+ return scopes
+}
+
+export const SPONSOR_SCOPE_LABEL_KEYS = {
+ fee: 'account_page_sponsored_scope_transaction_fees',
+ reserve: 'account_page_sponsored_scope_base_reserve',
+} as const
+
export const TX_FLAGS: Record> = {
all: {
0x80000000: 'tfFullyCanonicalSig',
@@ -50,6 +71,18 @@ export const TX_FLAGS: Record> = {
LoanSet: {
0x00010000: 'tfLoanOverpayment',
},
+ SponsorshipSet: {
+ 0x00010000: 'tfSponsorshipSetRequireSignForFee',
+ 0x00020000: 'tfSponsorshipClearRequireSignForFee',
+ 0x00040000: 'tfSponsorshipSetRequireSignForReserve',
+ 0x00080000: 'tfSponsorshipClearRequireSignForReserve',
+ 0x00100000: 'tfDeleteObject',
+ },
+ SponsorshipTransfer: {
+ 0x00010000: 'tfSponsorshipEnd',
+ 0x00020000: 'tfSponsorshipCreate',
+ 0x00040000: 'tfSponsorshipReassign',
+ },
LoanManage: {
0x00010000: 'tfLoanDefault',
0x00020000: 'tfLoanImpair',
@@ -97,6 +130,7 @@ export const TX_FLAGS: Record> = {
0x00010000: 'tfNoDirectRipple',
0x00020000: 'tfPartialPayment',
0x00040000: 'tfLimitQuality',
+ 0x00080000: 'tfSponsorCreatedAccount',
},
PaymentChannelClaim: {
0x00010000: 'tfRenew',
diff --git a/src/rippled/accountState.ts b/src/rippled/accountState.ts
index 2af1d8991..4f26ac438 100644
--- a/src/rippled/accountState.ts
+++ b/src/rippled/accountState.ts
@@ -6,6 +6,7 @@ import {
import {
getAccountInfo,
getAccountPaychannels,
+ getAccountSponsorship,
getServerInfo,
getAccountTransactions,
} from './lib/rippled'
@@ -34,6 +35,13 @@ export interface AccountState {
quorum: number
maxSigners: number
}
+ sponsorship?: {
+ owner: string
+ sponsee: string
+ feeAmount?: string
+ maxFee?: string
+ remainingOwnerCount?: number
+ }[]
info: {
accountTransactionID?: string
reserve?: number
@@ -43,6 +51,7 @@ export interface AccountState {
emailHash?: string
flags: string[]
nftMinter?: string
+ sponsor?: string
}
xAddress?: {
classicAddress: string
@@ -90,6 +99,7 @@ async function getAccountState(
Promise.all([
getAccountPaychannels(rippledSocket, classicAddress, info.ledger_index),
getServerInfo(rippledSocket),
+ getAccountSponsorship(rippledSocket, classicAddress, info.ledger_index),
]).then((data) => ({
account: info.Account as string,
info: formatAccountInfo(info, data[1].info.validated_ledger),
@@ -97,6 +107,7 @@ async function getAccountState(
? formatSignerList(info.signer_lists[0])
: undefined,
paychannels: data[1],
+ sponsorship: data[2],
xAddress: decomposedAddress || undefined,
deleted: false,
})),
diff --git a/src/rippled/lib/rippled.ts b/src/rippled/lib/rippled.ts
index 487b6f380..174043b2d 100644
--- a/src/rippled/lib/rippled.ts
+++ b/src/rippled/lib/rippled.ts
@@ -27,6 +27,14 @@ const formatPaychannel = (d: any) => ({
settleDelay: d.SettleDelay,
})
+const formatSponsorship = (d: any) => ({
+ owner: d.Owner,
+ sponsee: d.Sponsee,
+ feeAmount: d.FeeAmount,
+ maxFee: d.MaxFee,
+ remainingOwnerCount: d.RemainingOwnerCount,
+})
+
const executeQuery = async (
rippledSocket: XrplClient,
params: any,
@@ -366,6 +374,67 @@ const getAccountBridges = async (
return undefined
}
+// `limit` on account_objects bounds how many objects rippled examines per
+// page, not how many `type`-filtered objects are returned, so a sponsee's
+// Sponsorship objects could be missed entirely if only the first page were
+// read. Page through via `marker` until either everything has been examined
+// or a safety cap is hit.
+const SPONSORSHIP_PAGE_SIZE = 400
+const SPONSORSHIP_MAX_EXAMINED = 4000
+
+// get the sponsorship covering this account's fees/reserves, if any
+const getAccountSponsorship = async (
+ rippledSocket: ExplorerXrplClient,
+ account: string,
+ ledgerIndex: string | number = 'validated',
+): Promise => {
+ const found: any[] = []
+
+ const fetchPage = async (
+ marker: any,
+ examined: number,
+ ): Promise => {
+ const resp = await query(rippledSocket, {
+ command: 'account_objects',
+ account,
+ ledger_index: ledgerIndex,
+ type: 'sponsorship',
+ limit: SPONSORSHIP_PAGE_SIZE,
+ marker,
+ })
+ if (resp.error === 'actNotFound') {
+ throw new Error('account not found', 404)
+ }
+ if (resp.error === 'invalidParams') {
+ // thrown when the Sponsorship amendment is not activated
+ // TODO: remove this when XLS-68 is live in mainnet
+ return undefined
+ }
+ if (resp.error_message) {
+ throw new Error(resp.error_message, 500)
+ }
+
+ // A Sponsorship object is linked into both the sponsor's and sponsee's
+ // owner directories, so only keep the ones where this account is sponsored.
+ found.push(
+ ...resp.account_objects.filter((d: any) => d.Sponsee === account),
+ )
+
+ const totalExamined = examined + resp.account_objects.length
+ if (resp.marker && totalExamined < SPONSORSHIP_MAX_EXAMINED) {
+ return fetchPage(resp.marker, totalExamined)
+ }
+ return found
+ }
+
+ const result = await fetchPage(undefined, 0)
+ if (result === undefined) {
+ return undefined
+ }
+
+ return found.length ? found.map(formatSponsorship) : undefined
+}
+
// get Token balance summary
const getBalances = async (
rippledSocket: ExplorerXrplClient,
@@ -929,6 +998,7 @@ export {
getAccountEscrows,
getAccountPaychannels,
getAccountBridges,
+ getAccountSponsorship,
getAccountNFTs,
getAccountObjects,
getNFTsIssuedByAccount,
diff --git a/src/rippled/lib/test/rippled.test.ts b/src/rippled/lib/test/rippled.test.ts
index e12d5303f..54fbd8faf 100644
--- a/src/rippled/lib/test/rippled.test.ts
+++ b/src/rippled/lib/test/rippled.test.ts
@@ -3,6 +3,7 @@ import {
getLoanBroker,
getMPTIssuance,
getNegativeUNL,
+ getAccountSponsorship,
} from '../rippled'
const VAULT_INDEX =
@@ -226,3 +227,141 @@ describe('getNegativeUNL', () => {
await expect(getNegativeUNL(socket)).resolves.toEqual([])
})
})
+
+describe('getAccountSponsorship', () => {
+ const ACCOUNT = 'rSponsee11111111111111111111111111'
+ const SPONSOR = 'rSponsor2222222222222222222222222'
+
+ it('returns the formatted sponsorship when this account is the sponsee', async () => {
+ const socket = makeSocket({
+ account_objects: [
+ {
+ LedgerEntryType: 'Sponsorship',
+ Owner: SPONSOR,
+ Sponsee: ACCOUNT,
+ FeeAmount: '1000',
+ MaxFee: '5000',
+ RemainingOwnerCount: 2,
+ },
+ ],
+ })
+
+ await expect(getAccountSponsorship(socket, ACCOUNT)).resolves.toEqual([
+ {
+ owner: SPONSOR,
+ sponsee: ACCOUNT,
+ feeAmount: '1000',
+ maxFee: '5000',
+ remainingOwnerCount: 2,
+ },
+ ])
+ expect(socket.send).toHaveBeenCalledWith({
+ command: 'account_objects',
+ account: ACCOUNT,
+ ledger_index: 'validated',
+ type: 'sponsorship',
+ limit: 400,
+ })
+ })
+
+ it('returns all sponsorships when this account has multiple sponsors', async () => {
+ const SPONSOR_2 = 'rSponsor3333333333333333333333333'
+ const socket = makeSocket({
+ account_objects: [
+ {
+ LedgerEntryType: 'Sponsorship',
+ Owner: SPONSOR,
+ Sponsee: ACCOUNT,
+ FeeAmount: '1000',
+ },
+ {
+ LedgerEntryType: 'Sponsorship',
+ Owner: SPONSOR_2,
+ Sponsee: ACCOUNT,
+ FeeAmount: '2000',
+ },
+ ],
+ })
+
+ await expect(getAccountSponsorship(socket, ACCOUNT)).resolves.toEqual([
+ { owner: SPONSOR, sponsee: ACCOUNT, feeAmount: '1000' },
+ { owner: SPONSOR_2, sponsee: ACCOUNT, feeAmount: '2000' },
+ ])
+ })
+
+ it('pages through account_objects via marker to find sponsorships past the first page', async () => {
+ const socket = {
+ send: jest
+ .fn()
+ .mockResolvedValueOnce({
+ account_objects: [],
+ marker: 'page-2',
+ })
+ .mockResolvedValueOnce({
+ account_objects: [
+ {
+ LedgerEntryType: 'Sponsorship',
+ Owner: SPONSOR,
+ Sponsee: ACCOUNT,
+ FeeAmount: '1000',
+ },
+ ],
+ }),
+ } as any
+
+ await expect(getAccountSponsorship(socket, ACCOUNT)).resolves.toEqual([
+ { owner: SPONSOR, sponsee: ACCOUNT, feeAmount: '1000' },
+ ])
+ expect(socket.send).toHaveBeenCalledTimes(2)
+ expect(socket.send).toHaveBeenNthCalledWith(2, {
+ command: 'account_objects',
+ account: ACCOUNT,
+ ledger_index: 'validated',
+ type: 'sponsorship',
+ limit: 400,
+ marker: 'page-2',
+ })
+ })
+
+ it('ignores Sponsorship objects where this account is the sponsor, not the sponsee', async () => {
+ const socket = makeSocket({
+ account_objects: [
+ {
+ LedgerEntryType: 'Sponsorship',
+ Owner: ACCOUNT,
+ Sponsee: 'rSomeoneElse33333333333333333333',
+ FeeAmount: '1000',
+ },
+ ],
+ })
+
+ await expect(
+ getAccountSponsorship(socket, ACCOUNT),
+ ).resolves.toBeUndefined()
+ })
+
+ it('returns undefined when the account has no sponsorship objects', async () => {
+ const socket = makeSocket({ account_objects: [] })
+
+ await expect(
+ getAccountSponsorship(socket, ACCOUNT),
+ ).resolves.toBeUndefined()
+ })
+
+ it('returns undefined when the Sponsorship amendment is not enabled', async () => {
+ const socket = makeSocket({ error: 'invalidParams' })
+
+ await expect(
+ getAccountSponsorship(socket, ACCOUNT),
+ ).resolves.toBeUndefined()
+ })
+
+ it('throws when the account is not found', async () => {
+ const socket = makeSocket({ error: 'actNotFound' })
+
+ await expect(getAccountSponsorship(socket, ACCOUNT)).rejects.toMatchObject({
+ message: 'account not found',
+ code: 404,
+ })
+ })
+})
diff --git a/src/rippled/lib/utils.ts b/src/rippled/lib/utils.ts
index 823870688..e37e25220 100644
--- a/src/rippled/lib/utils.ts
+++ b/src/rippled/lib/utils.ts
@@ -125,6 +125,7 @@ interface AccountInfo {
PreviousTxnID: string
PreviousTxnLgrSeq: number
NFTokenMinter?: string
+ Sponsor?: string
}
interface ServerInfoValidated {
@@ -147,6 +148,7 @@ interface FormattedAccountInfo {
previousTxn: string
previousLedger: number
nftMinter?: string
+ sponsor?: string
}
const formatAccountInfo = (
@@ -171,6 +173,7 @@ const formatAccountInfo = (
previousTxn: info.PreviousTxnID,
previousLedger: info.PreviousTxnLgrSeq,
nftMinter: info.NFTokenMinter,
+ sponsor: info.Sponsor,
})
const formatTransaction = (tx: any): any => {