Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
30 changes: 29 additions & 1 deletion public/locales/en-US/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,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_none": "No Sponsors",
"account_page_asset_held_title": "Assets Held",
"account_page_asset_issued_title": "Assets Issued",
"account_page_asset_tab_iou": "IOUs ({{count}})",
Expand Down Expand Up @@ -946,5 +952,27 @@
"registered": "Registered",
"included": "Included",
"maintenance_banner.notice": "Scheduled maintenance: {{window}} (~{{duration}} min downtime).",
"maintenance_banner.countdown_prefix": "Starts in"
"maintenance_banner.countdown_prefix": "Starts in",
"sponsor": "Sponsor",
"sponsee": "Sponsee",
"new_sponsor": "New Sponsor",
"fee_amount": "Fee Amount",
"max_fee": "Max Fee",
"reserve_count": "Reserve Count",
"require_sign_for_fee": "Require Sign For Fee",
"require_sign_for_reserve": "Require Sign For Reserve",
"sponsorship_deleted": "Sponsorship Deleted",
"operation": "Operation",
"object_id": "Object ID",
"sponsorship_operation_create": "Create",
"sponsorship_operation_reassign": "Reassign",
"sponsorship_operation_end": "End",
"sponsorship_set_description": "<Sponsor/> sponsors transaction fees for <Sponsee/>",

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.

Only transaction fees?

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.

There is now a branch to account for whether feeAmountDelta/remainingOwnerCountDelta are present here

"sponsorship_set_delete": "<Sponsor/> ends the fee sponsorship for <Sponsee/>",
"sponsorship_transfer_create": "<Account/> assigns <Sponsor/> as its reserve sponsor",
"sponsorship_transfer_reassign": "<Account/> reassigns reserve sponsorship to <Sponsor/>",
"sponsorship_transfer_end_other": "<Account/> ends reserve sponsorship for <Sponsee/>",
"sponsorship_transfer_end_self": "<Account/> ends its reserve sponsorship",
"sponsor_detail": "<Account/> is sponsoring this transaction",
"sponsor_co_signed": "The sponsor co-signed this transaction"
}
69 changes: 69 additions & 0 deletions src/containers/Accounts/SponsoredFeesReserves/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useTranslation } from 'react-i18next'
import { Account } from '../../shared/components/Account'
import { CollapsibleSection } from '../../shared/components/CollapsibleSection'
import { EmptyMessageTableRow } from '../../shared/EmptyMessageTableRow'
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 }[] = [
...(account.sponsorship ?? []).map(({ owner }) => ({
scopeKey: 'account_page_sponsored_scope_transaction_fees' as ScopeKey,
sponsor: owner,

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.

Should we check the values of FeeAmount and RemainingOwnerCount before claiming that the sponsor is sponsoring both fees and reserves? For example, if FeeAmount is 0 or RemainingOwnerCount is 0, do we still want to say that the sponsor is sponsoring both?

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.

Added a Number(feeAmount) > 0 filter before rendering a Transaction Fees row

})),
...(account.info?.sponsor
? [
{
scopeKey: 'account_page_sponsored_scope_base_reserve' as ScopeKey,
sponsor: account.info.sponsor,
},
]
: []),
]

return (
<CollapsibleSection
title={t('account_page_sponsored_fees_reserves_title')}
ariaLabel="Toggle sponsored fees & reserves section"
className="sponsored-fees-reserves-section"
defaultOpen={false}
>
<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>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<EmptyMessageTableRow colSpan={2}>
{t('account_page_sponsored_none')}
</EmptyMessageTableRow>
) : (
rows.map(({ scopeKey, sponsor }) => (
<tr key={`${scopeKey}-${sponsor}`}>
<td>{t(scopeKey)}</td>
<td>
<Account account={sponsor} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CollapsibleSection>
)
}
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-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;

&.empty-message {
padding: 16px;
color: $black-40;
font-size: 16px;
text-align: center;
}
}

tbody tr:first-child td {
border-top: 1px solid $black-80;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { render, screen, fireEvent } 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,
}

const openSection = () => {
fireEvent.click(
screen.getByLabelText('Toggle sponsored fees & reserves section'),
)
}

describe('SponsoredFeesReserves Component', () => {
it('renders the title and starts collapsed', () => {
render(
<TestWrapper>
<SponsoredFeesReserves account={baseAccount} />
</TestWrapper>,
)

expect(screen.getByText('Sponsored Fees & Reserves')).toBeInTheDocument()
expect(screen.queryByText('No Sponsors')).not.toBeInTheDocument()
})

it('shows "No Sponsors" when the account has no sponsorship', () => {
render(
<TestWrapper>
<SponsoredFeesReserves account={baseAccount} />
</TestWrapper>,
)

openSection()

expect(screen.getByText('No Sponsors')).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>,
)

openSection()

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

openSection()

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

openSection()

expect(screen.getByText('Transaction Fees')).toBeInTheDocument()
expect(screen.getByText('Base Reserve')).toBeInTheDocument()
expect(screen.getAllByTestId('account-component')).toHaveLength(2)
})

it('renders one Transaction Fees row per sponsor when there are multiple fee sponsors', () => {
const account: AccountState = {
...baseAccount,
sponsorship: [
{
owner: 'rFeeSponsor2222222222222222222222',
sponsee: baseAccount.account,
},
{
owner: 'rFeeSponsor3333333333333333333333',
sponsee: baseAccount.account,
},
],
}

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

openSection()

expect(screen.getAllByText('Transaction Fees')).toHaveLength(2)
expect(screen.getAllByTestId('account-component')).toHaveLength(2)
expect(screen.getAllByTestId('account-component')[0]).toHaveTextContent(
'rFeeSponsor2222222222222222222222',
)
expect(screen.getAllByTestId('account-component')[1]).toHaveTextContent(
'rFeeSponsor3333333333333333333333',
)
})
})
2 changes: 2 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'
import { PermissionDelegation } from './PermissionDelegation'

export const Accounts = () => {
Expand Down Expand Up @@ -66,6 +67,7 @@ export const Accounts = () => {
{showAccount && (
<>
<AccountSummary account={account} xrpToUSDRate={xrpToUSDRate} />
<SponsoredFeesReserves account={account} />
<PermissionDelegation accountId={account.account} />
<AccountAsset
// Use account.account since `accountId` could be an extended account
Expand Down
29 changes: 29 additions & 0 deletions src/containers/Transactions/DetailTab/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
XRP_BASE,
buildFlags,
buildMemos,
getSponsorScopes,
SPONSOR_SCOPE_LABEL_KEYS,
} from '../../shared/transactionUtils'
import './detailTab.scss'
import { useLanguage } from '../../shared/hooks'
Expand Down Expand Up @@ -109,6 +111,32 @@ export const DetailTab: FC<{ data: any }> = ({ data }) => {
) : null
}

const renderSponsor = () => {
if (!data.tx.Sponsor) return null
const scopes = getSponsorScopes(data.tx.SponsorFlags)
return (
<div className="detail-section" data-testid="sponsor-section">
<div className="title">{t('sponsor')}</div>
<div>
<Trans
i18nKey="sponsor_detail"
components={{ Account: <Account account={data.tx.Sponsor} /> }}
/>
</div>
{scopes.length > 0 && (
<div className="sponsor-scopes">
{scopes
.map((scope) => t(SPONSOR_SCOPE_LABEL_KEYS[scope]))
.join(', ')}
</div>
)}
{data.tx.SponsorSignature && (
<div className="sponsor-co-signed">{t('sponsor_co_signed')}</div>
)}
</div>
)
}

const renderSigners = () =>
data.tx.Signers ? (
<div className="detail-section">
Expand All @@ -128,6 +156,7 @@ export const DetailTab: FC<{ data: any }> = ({ data }) => {
{renderStatus()}
<TransactionDescription data={data} />
{renderSigners()}
{renderSponsor()}
<HookDetails data={data} />
{renderFlags()}
{renderFee()}
Expand Down
Loading
Loading