Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions public/locales/en-US/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,12 @@
"account_page_payment_channels": "Payment Channels",
Comment thread
kuan121 marked this conversation as resolved.
"account_page_payment_channels_text": "{{currency}} available in {{number}} channel(s)",
"account_page_nft_minter": "NFT Minter",
"account_page_sponsored_fees_reserves_title": "Sponsored fees & reserves",
"account_page_sponsored_scope": "Scope",
"account_page_sponsored_by": "Sponsored by",
"account_page_sponsored_scope_transaction_fees": "Transaction Fees",
"account_page_sponsored_scope_base_reserve": "Base Reserve",
"account_page_sponsored_status_active": "Active",
"account_page_asset_held_title": "Assets Held",
"account_page_asset_issued_title": "Assets Issued",
"account_page_asset_tab_iou": "IOUs ({{count}})",
Expand Down
65 changes: 65 additions & 0 deletions src/containers/Accounts/SponsoredFeesReserves/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useTranslation } from 'react-i18next'
import { Account } from '../../shared/components/Account'
import { shortenAccount } from '../../shared/utils'
import type { AccountState } from '../../../rippled/accountState'
import './styles.scss'

interface Props {
account: AccountState
}

type ScopeKey =
| 'account_page_sponsored_scope_transaction_fees'
| 'account_page_sponsored_scope_base_reserve'

export const SponsoredFeesReserves = ({ account }: Props) => {
const { t } = useTranslation()

const rows: { scopeKey: ScopeKey; sponsor: string }[] = (
[
{
scopeKey: 'account_page_sponsored_scope_transaction_fees',
sponsor: account.sponsorship?.owner,
},
{
scopeKey: 'account_page_sponsored_scope_base_reserve',
sponsor: account.info?.sponsor,
},
] as { scopeKey: ScopeKey; sponsor: string | undefined }[]
).filter((row): row is { scopeKey: ScopeKey; sponsor: string } =>
Boolean(row.sponsor),
)

return (
<div className="sponsored-fees-reserves-section">
<h2 className="sponsored-fees-reserves-title">
{t('account_page_sponsored_fees_reserves_title')}
</h2>
<div className="sponsored-fees-reserves-table-wrapper">
<table className="sponsored-fees-reserves-table">
<thead>
<tr>
<th>{t('account_page_sponsored_scope')}</th>
Comment thread
kuan121 marked this conversation as resolved.
<th>{t('account_page_sponsored_by')}</th>
<th>{t('status')}</th>
</tr>
</thead>
<tbody>
{rows.map(({ scopeKey, sponsor }) => (
<tr key={scopeKey}>
<td>{t(scopeKey)}</td>
<td>
<Account
account={sponsor}
displayText={shortenAccount(sponsor)}
/>
</td>
<td>{t('account_page_sponsored_status_active')}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
43 changes: 43 additions & 0 deletions src/containers/Accounts/SponsoredFeesReserves/styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
@use '../../shared/css/variables' as *;

.sponsored-fees-reserves-section {
padding: 24px 0;

.sponsored-fees-reserves-title {
@include bold;

margin: 0 0 20px;
font-size: 20px;
}
}

.sponsored-fees-reserves-table-wrapper {
-webkit-overflow-scrolling: touch;
overflow-x: auto;
}

.sponsored-fees-reserves-table {
width: 100%;
border-collapse: collapse;

thead th {
@include semibold;

padding: 10px 12px;
color: $black-50;
font-size: 12px;
text-align: left;
text-transform: uppercase;
}

tbody td {
padding: 10px 12px;
border-bottom: 1px solid $black-80;
color: $white;
font-size: 14px;
}

tbody tr:first-child td {
border-top: 1px solid $black-80;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { render, screen } from '@testing-library/react'
import { I18nextProvider } from 'react-i18next'
import { BrowserRouter as Router } from 'react-router'
import i18n from '../../../../i18n/testConfigEnglish'
import { SponsoredFeesReserves } from '../index'
import type { AccountState } from '../../../../rippled/accountState'

jest.mock('../../../shared/components/Account', () => ({
Account: ({ account }: { account: string }) => (
<span data-testid="account-component">{account}</span>
),
}))

const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<I18nextProvider i18n={i18n}>
<Router>{children}</Router>
</I18nextProvider>
)

const baseAccount: AccountState = {
account: 'rAccount1111111111111111111111111',
info: { ticketCount: 0, flags: [] },
deleted: false,
}

describe('SponsoredFeesReserves Component', () => {
it('renders no rows when the account has no sponsorship', () => {
render(
<TestWrapper>
<SponsoredFeesReserves account={baseAccount} />
</TestWrapper>,
)

expect(screen.getByText('Sponsored fees & reserves')).toBeInTheDocument()
expect(screen.queryByText('Transaction Fees')).not.toBeInTheDocument()
expect(screen.queryByText('Base Reserve')).not.toBeInTheDocument()
})

it('renders only the Base Reserve row when only the reserve is sponsored', () => {
const account: AccountState = {
...baseAccount,
info: {
...baseAccount.info,
sponsor: 'rBaseReserveSponsor11111111111111',
},
}

render(
<TestWrapper>
<SponsoredFeesReserves account={account} />
</TestWrapper>,
)

expect(screen.getByText('Base Reserve')).toBeInTheDocument()
expect(screen.queryByText('Transaction Fees')).not.toBeInTheDocument()
expect(screen.getByTestId('account-component')).toHaveTextContent(
'rBaseReserveSponsor11111111111111',
)
})

it('renders only the Transaction Fees row when only fees are sponsored', () => {
const account: AccountState = {
...baseAccount,
sponsorship: {
owner: 'rFeeSponsor2222222222222222222222',
sponsee: baseAccount.account,
},
}

render(
<TestWrapper>
<SponsoredFeesReserves account={account} />
</TestWrapper>,
)

expect(screen.getByText('Transaction Fees')).toBeInTheDocument()
expect(screen.queryByText('Base Reserve')).not.toBeInTheDocument()
expect(screen.getByTestId('account-component')).toHaveTextContent(
'rFeeSponsor2222222222222222222222',
)
})

it('renders both rows when both fees and reserve are sponsored', () => {
const account: AccountState = {
...baseAccount,
info: {
...baseAccount.info,
sponsor: 'rBaseReserveSponsor11111111111111',
},
sponsorship: {
owner: 'rFeeSponsor2222222222222222222222',
sponsee: baseAccount.account,
},
}

render(
<TestWrapper>
<SponsoredFeesReserves account={account} />
</TestWrapper>,
)

expect(screen.getByText('Transaction Fees')).toBeInTheDocument()
expect(screen.getByText('Base Reserve')).toBeInTheDocument()
expect(screen.getAllByTestId('account-component')).toHaveLength(2)
expect(screen.getAllByText('Active')).toHaveLength(2)
})
})
4 changes: 4 additions & 0 deletions src/containers/Accounts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { AccountSummary } from './AccountSummary'
import { useXRPToUSDRate } from '../shared/hooks/useXRPToUSDRate'
import AccountAsset from './AccountAsset'
import AccountHeader from './AccountHeader'
import { SponsoredFeesReserves } from './SponsoredFeesReserves'

export const Accounts = () => {
const { trackScreenLoaded, trackException } = useAnalytics()
Expand Down Expand Up @@ -65,6 +66,9 @@ export const Accounts = () => {
{showAccount && (
<>
<AccountSummary account={account} xrpToUSDRate={xrpToUSDRate} />
{(account.sponsorship?.owner || account.info?.sponsor) && (
<SponsoredFeesReserves account={account} />
)}
<AccountAsset
// Use account.account since `accountId` could be an extended account
accountId={account.account}
Expand Down
11 changes: 11 additions & 0 deletions src/rippled/accountState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import {
getAccountInfo,
getAccountPaychannels,
getAccountSponsorship,
getServerInfo,
getAccountTransactions,
} from './lib/rippled'
Expand Down Expand Up @@ -34,6 +35,13 @@ export interface AccountState {
quorum: number
maxSigners: number
}
sponsorship?: {
owner: string
sponsee: string
feeAmount?: string
maxFee?: string
reserveCount?: number
Comment thread
kuan121 marked this conversation as resolved.
Outdated
}
info: {
accountTransactionID?: string
reserve?: number
Expand All @@ -43,6 +51,7 @@ export interface AccountState {
emailHash?: string
flags: string[]
nftMinter?: string
sponsor?: string
}
xAddress?: {
classicAddress: string
Expand Down Expand Up @@ -90,13 +99,15 @@ 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),
signerList: info.signer_lists?.[0]
? formatSignerList(info.signer_lists[0])
: undefined,
paychannels: data[1],
sponsorship: data[2],
xAddress: decomposedAddress || undefined,
deleted: false,
})),
Expand Down
48 changes: 48 additions & 0 deletions src/rippled/lib/rippled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
reserveCount: d.ReserveCount,
Comment thread
kuan121 marked this conversation as resolved.
Outdated
})

const executeQuery = async (
rippledSocket: XrplClient,
params: any,
Expand Down Expand Up @@ -366,6 +374,45 @@ const getAccountBridges = async (
return undefined
}

// get the sponsorship covering this account's fees/reserves, if any
const getAccountSponsorship = async (
rippledSocket: ExplorerXrplClient,
account: string,
ledgerIndex: string | number = 'validated',
): Promise<any> => {
const resp = await query(rippledSocket, {
command: 'account_objects',
account,
ledger_index: ledgerIndex,
type: 'sponsorship',
limit: 400,

@kuan121 kuan121 Aug 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The limit of rippled’s RPC methods does not mean “search all objects for this account and return at most 400 sponsorship objects.” Instead, it means “examine at most 400 account objects and return any sponsorship objects found within those 400.”

For example, if an account has 450 objects and its sponsorship objects happen to be the 405th and 420th objects, a request with limit: 400 could return zero sponsorship objects.

I think we’ll need to loop through the paginated results, either until we’ve examined all account objects or until we reach a predefined cap, such as 4,000 objects.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getAccountSponsorship now pages through account_objects and examines up to 4000 objects. Added a test covering the multi-page case

})
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)
}

if (!resp.account_objects.length) {
return undefined
}

// A Sponsorship object is linked into both the sponsor's and sponsee's
// owner directories, so only keep the one where this account is sponsored.
const sponsorship = resp.account_objects.find(
(d: any) => d.Sponsee === account,
)

return sponsorship ? formatSponsorship(sponsorship) : undefined
}

// get Token balance summary
const getBalances = async (
rippledSocket: ExplorerXrplClient,
Expand Down Expand Up @@ -929,6 +976,7 @@ export {
getAccountEscrows,
getAccountPaychannels,
getAccountBridges,
getAccountSponsorship,
getAccountNFTs,
getAccountObjects,
getNFTsIssuedByAccount,
Expand Down
Loading