diff --git a/public/locales/ca-CA/translations.json b/public/locales/ca-CA/translations.json index cb84b3477..47abb0806 100644 --- a/public/locales/ca-CA/translations.json +++ b/public/locales/ca-CA/translations.json @@ -575,6 +575,7 @@ "check_mpt_id": null, "outstanding_amount": null, "locked": null, + "active": null, "can_lock": null, "require_auth": null, "can_escrow": null, @@ -944,5 +945,16 @@ "registered": null, "included": null, "maintenance_banner.notice": null, - "maintenance_banner.countdown_prefix": null + "maintenance_banner.countdown_prefix": null, + "immutable_flags": null, + "mutable": null, + "mutable_capability_tooltip": null, + "mutable_field_tooltip": null, + "immutable": null, + "immutable_capability_tooltip": null, + "immutable_field_tooltip": null, + "enabled_capability_tooltip": null, + "disabled_capability_tooltip": null, + "capabilities": null, + "fields": null } diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 0b4a9d9d5..02721c5b4 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -576,6 +576,7 @@ "check_mpt_id": "Please check your MPT Issuance ID", "outstanding_amount": "Issued Amount", "locked": "Locked", + "active": "Active", "can_lock": "Can Lock", "require_auth": "Require Auth", "can_escrow": "Can Escrow", @@ -945,5 +946,16 @@ "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", + "mutable": "Mutable", + "mutable_capability_tooltip": "The issuer can still enable this capability. Once enabled, it's permanent.", + "mutable_field_tooltip": "The issuer can still change or remove this value.", + "immutable": "Immutable", + "immutable_capability_tooltip": "Permanently off — the issuer can never enable this capability.", + "immutable_field_tooltip": "Permanent — the issuer can never change or remove this value.", + "immutable_flags": "Immutable Flags", + "enabled_capability_tooltip": "Turned on. MPT capabilities are enable-only, so this can never be turned off again.", + "disabled_capability_tooltip": "Currently off. The adjacent pill shows whether this can still change.", + "capabilities": "Capabilities", + "fields": "Fields" } diff --git a/public/locales/es-ES/translations.json b/public/locales/es-ES/translations.json index 5e80854b4..aa9b52cea 100644 --- a/public/locales/es-ES/translations.json +++ b/public/locales/es-ES/translations.json @@ -576,6 +576,7 @@ "check_mpt_id": "Por favor, verifica tu ID de Emisión MPT", "outstanding_amount": "Cantidad Emitida", "locked": "Bloqueado", + "active": null, "can_lock": "Se Puede Bloquear", "require_auth": "Requiere Autenticación", "can_escrow": "Puede Hacer Escrow", @@ -945,5 +946,16 @@ "registered": null, "included": null, "maintenance_banner.notice": null, - "maintenance_banner.countdown_prefix": null + "maintenance_banner.countdown_prefix": null, + "immutable_flags": null, + "mutable": null, + "mutable_capability_tooltip": null, + "mutable_field_tooltip": null, + "immutable": null, + "immutable_capability_tooltip": null, + "immutable_field_tooltip": null, + "enabled_capability_tooltip": null, + "disabled_capability_tooltip": null, + "capabilities": null, + "fields": null } diff --git a/public/locales/fr-FR/translations.json b/public/locales/fr-FR/translations.json index a2e67b4b5..dc63f5644 100644 --- a/public/locales/fr-FR/translations.json +++ b/public/locales/fr-FR/translations.json @@ -576,6 +576,7 @@ "check_mpt_id": null, "outstanding_amount": null, "locked": null, + "active": null, "can_lock": null, "require_auth": null, "can_escrow": null, @@ -945,5 +946,16 @@ "registered": null, "included": null, "maintenance_banner.notice": null, - "maintenance_banner.countdown_prefix": null + "maintenance_banner.countdown_prefix": null, + "immutable_flags": null, + "mutable": null, + "mutable_capability_tooltip": null, + "mutable_field_tooltip": null, + "immutable": null, + "immutable_capability_tooltip": null, + "immutable_field_tooltip": null, + "enabled_capability_tooltip": null, + "disabled_capability_tooltip": null, + "capabilities": null, + "fields": null } diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index f64660fdd..c7ad0a474 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -576,6 +576,7 @@ "check_mpt_id": null, "outstanding_amount": null, "locked": null, + "active": null, "can_lock": null, "require_auth": null, "can_escrow": null, @@ -945,5 +946,16 @@ "registered": null, "included": null, "maintenance_banner.notice": null, - "maintenance_banner.countdown_prefix": null + "maintenance_banner.countdown_prefix": null, + "immutable_flags": null, + "mutable": null, + "mutable_capability_tooltip": null, + "mutable_field_tooltip": null, + "immutable": null, + "immutable_capability_tooltip": null, + "immutable_field_tooltip": null, + "enabled_capability_tooltip": null, + "disabled_capability_tooltip": null, + "capabilities": null, + "fields": null } diff --git a/public/locales/ko-KR/translations.json b/public/locales/ko-KR/translations.json index cfeddbd31..36fc7d2f8 100644 --- a/public/locales/ko-KR/translations.json +++ b/public/locales/ko-KR/translations.json @@ -576,6 +576,7 @@ "check_mpt_id": null, "outstanding_amount": null, "locked": null, + "active": null, "can_lock": null, "require_auth": null, "can_escrow": null, @@ -945,5 +946,16 @@ "registered": null, "included": null, "maintenance_banner.notice": null, - "maintenance_banner.countdown_prefix": null + "maintenance_banner.countdown_prefix": null, + "immutable_flags": null, + "mutable": null, + "mutable_capability_tooltip": null, + "mutable_field_tooltip": null, + "immutable": null, + "immutable_capability_tooltip": null, + "immutable_field_tooltip": null, + "enabled_capability_tooltip": null, + "disabled_capability_tooltip": null, + "capabilities": null, + "fields": null } diff --git a/public/locales/my-MM/translations.json b/public/locales/my-MM/translations.json index b948a258d..2b2458991 100644 --- a/public/locales/my-MM/translations.json +++ b/public/locales/my-MM/translations.json @@ -576,6 +576,7 @@ "check_mpt_id": "ကျေးဇူးပြု၍ သင့် MPT ထုတ်ဝေမှု ID ကို စစ်ဆေးပါ", "outstanding_amount": "ထုတ်ပေးထားသော ပမာဏ", "locked": "သော့ခတ်ထား", + "active": null, "can_lock": "သော့ခတ်နိုင်", "require_auth": "ခွင့်ပြုချက် လိုအပ်", "can_escrow": "Escrow လုပ်နိုင်", @@ -945,5 +946,16 @@ "registered": null, "included": null, "maintenance_banner.notice": null, - "maintenance_banner.countdown_prefix": null + "maintenance_banner.countdown_prefix": null, + "immutable_flags": null, + "mutable": null, + "mutable_capability_tooltip": null, + "mutable_field_tooltip": null, + "immutable": null, + "immutable_capability_tooltip": null, + "immutable_field_tooltip": null, + "enabled_capability_tooltip": null, + "disabled_capability_tooltip": null, + "capabilities": null, + "fields": null } diff --git a/src/containers/Token/MPT/Header/GeneralOverview.tsx b/src/containers/Token/MPT/Header/GeneralOverview.tsx index a73e1df2b..9f5c25523 100644 --- a/src/containers/Token/MPT/Header/GeneralOverview.tsx +++ b/src/containers/Token/MPT/Header/GeneralOverview.tsx @@ -16,6 +16,7 @@ interface GeneralOverviewProps { showMptId: boolean holdersCount?: number holdersLoading?: boolean + flags?: string[] } export const GeneralOverview = ({ @@ -27,6 +28,7 @@ export const GeneralOverview = ({ showMptId: showMptIssuanceId, holdersCount, holdersLoading, + flags = [], }: GeneralOverviewProps): JSX.Element => { const { t } = useTranslation() @@ -57,6 +59,12 @@ export const GeneralOverview = ({ )} +
+
{t('status')}
+
+ {flags.includes('lsfMPTLocked') ? t('locked') : t('active')} +
+
{t('token_page.transfer_fee')}
diff --git a/src/containers/Token/MPT/Header/Settings.tsx b/src/containers/Token/MPT/Header/Settings.tsx index fac9ee031..77a2aeb7a 100644 --- a/src/containers/Token/MPT/Header/Settings.tsx +++ b/src/containers/Token/MPT/Header/Settings.tsx @@ -1,58 +1,102 @@ +import { MouseEvent } from 'react' import { useTranslation } from 'react-i18next' +import { Tooltip, useTooltip } from '../../../shared/components/Tooltip' + +const TOOLTIP_Y_OFFSET = 70 interface Props { flags?: string[] + immutableFlags?: string[] + isDynamicMPTEnabled?: boolean } interface FlagItem { key: string label: string enabled: boolean + immutableFlag: string +} + +interface FieldItem { + key: string + label: string + immutableFlag: string } -export const Settings = ({ flags = [] }: Props): JSX.Element => { +export const Settings = ({ + flags = [], + immutableFlags = [], + isDynamicMPTEnabled = false, +}: Props): JSX.Element => { const { t } = useTranslation() + const { tooltip, showTooltip, hideTooltip } = useTooltip() + + const isLocked = (immutableFlag: string): boolean => + immutableFlags.includes(immutableFlag) + + const showPillTooltip = (e: MouseEvent, text: string) => { + const rect = e.currentTarget.getBoundingClientRect() + showTooltip('text', e, text, { + x: rect.left + rect.width / 2, + y: rect.top - TOOLTIP_Y_OFFSET, + }) + } const flagItems: FlagItem[] = [ - { - key: 'locked', - label: t('locked'), - enabled: flags.includes('lsfMPTLocked'), - }, { key: 'canLock', label: t('can_lock'), enabled: flags.includes('lsfMPTCanLock'), + immutableFlag: 'lsifMPTCanLock', }, { key: 'requireAuth', label: t('require_auth'), enabled: flags.includes('lsfMPTRequireAuth'), + immutableFlag: 'lsifMPTRequireAuth', }, { key: 'canEscrow', label: t('can_escrow'), enabled: flags.includes('lsfMPTCanEscrow'), + immutableFlag: 'lsifMPTCanEscrow', }, { key: 'canTrade', label: t('can_trade'), enabled: flags.includes('lsfMPTCanTrade'), + immutableFlag: 'lsifMPTCanTrade', }, { key: 'canTransfer', label: t('can_transfer'), enabled: flags.includes('lsfMPTCanTransfer'), + immutableFlag: 'lsifMPTCanTransfer', }, { key: 'canClawback', label: t('can_clawback'), enabled: flags.includes('lsfMPTCanClawback'), + immutableFlag: 'lsifMPTCanClawback', }, { key: 'canConfidentialAmount', label: t('can_confidential_amount'), - enabled: flags.includes('lsfMPTCanConfidentialAmount'), + enabled: flags.includes('lsfMPTCanHoldConfidentialBalance'), + immutableFlag: 'lsifMPTCanHoldConfidentialBalance', + }, + ] + + const fieldItems: FieldItem[] = [ + { + key: 'metadata', + label: t('metadata'), + immutableFlag: 'lsifMPTMetadata', + }, + { + key: 'transferFee', + label: t('transfer_fee'), + immutableFlag: 'lsifMPTTransferFee', }, ] @@ -60,17 +104,114 @@ export const Settings = ({ flags = [] }: Props): JSX.Element => {
{t('settings')}
+ {isDynamicMPTEnabled && ( +
{t('capabilities')}
+ )} {flagItems.map((flag) => (
{flag.label}
-
- {flag.enabled ? t('enabled') : t('disabled')} +
+ {flag.enabled ? ( +
+ showPillTooltip(e, t('enabled_capability_tooltip')) + : undefined + } + onFocus={() => {}} + onMouseLeave={isDynamicMPTEnabled ? hideTooltip : undefined} + > + {t('enabled')} +
+ ) : ( + <> +
+ showPillTooltip(e, t('disabled_capability_tooltip')) + : undefined + } + onFocus={() => {}} + onMouseLeave={isDynamicMPTEnabled ? hideTooltip : undefined} + > + {t('disabled')} +
+ {isDynamicMPTEnabled && + (isLocked(flag.immutableFlag) ? ( +
+ showPillTooltip(e, t('immutable_capability_tooltip')) + } + onFocus={() => {}} + onMouseLeave={hideTooltip} + > + {t('immutable')} +
+ ) : ( +
+ showPillTooltip(e, t('mutable_capability_tooltip')) + } + onFocus={() => {}} + onMouseLeave={hideTooltip} + > + {t('mutable')} +
+ ))} + + )}
))} + {isDynamicMPTEnabled && ( + <> +
{t('fields')}
+ {fieldItems.map((field) => ( +
+
{field.label}
+
+ {isLocked(field.immutableFlag) ? ( +
+ showPillTooltip(e, t('immutable_field_tooltip')) + } + onFocus={() => {}} + onMouseLeave={hideTooltip} + > + {t('immutable')} +
+ ) : ( +
+ showPillTooltip(e, t('mutable_field_tooltip')) + } + onFocus={() => {}} + onMouseLeave={hideTooltip} + > + {t('mutable')} +
+ )} +
+
+ ))} + + )}
+
) } diff --git a/src/containers/Token/MPT/Header/index.tsx b/src/containers/Token/MPT/Header/index.tsx index 00106edc7..b7e0c4f24 100644 --- a/src/containers/Token/MPT/Header/index.tsx +++ b/src/containers/Token/MPT/Header/index.tsx @@ -38,6 +38,7 @@ interface Props { setError: (error: number | null) => void holdersCount?: number holdersLoading?: boolean + isDynamicMPTEnabled?: boolean circulatingSupply?: string circulatingSupplyLoading?: boolean } @@ -51,6 +52,7 @@ export const Header = (props: Props) => { setError, holdersCount, holdersLoading, + isDynamicMPTEnabled, circulatingSupply, circulatingSupplyLoading, } = props @@ -96,6 +98,7 @@ export const Header = (props: Props) => { confidentialOutstandingAmt, transferFee, flags, + immutableFlags, rawMPTMetadata, parsedMPTMetadata, isMPTMetadataCompliant, @@ -223,6 +226,7 @@ export const Header = (props: Props) => { showMptId={showMPTIssuanceId} holdersCount={holdersCount} holdersLoading={holdersLoading} + flags={flags} /> { circulatingSupply={circulatingSupply} circulatingSupplyLoading={circulatingSupplyLoading} /> - + {(parsedMPTMetadata || rawMPTMetadata) && ( { const [error, setError] = useState(null) const rippledSocket = useContext(SocketContext) + // Check whether the DynamicMPT amendment is active on the connected network + const { data: isDynamicMPTEnabled } = useQuery( + ['isDynamicMPTEnabled', rippledSocket], + async () => { + const result = await getFeature(rippledSocket, DYNAMIC_MPT_AMENDMENT_ID) + return result?.[DYNAMIC_MPT_AMENDMENT_ID]?.enabled === true + }, + { staleTime: Infinity }, + ) + // Holders pagination state const [holdersPage, setHoldersPage] = useState(INITIAL_PAGE) const holdersPageSize = PAGINATION_CONFIG.HOLDERS_PAGE_SIZE @@ -200,6 +214,7 @@ export const MPT = () => { setError={setError} holdersCount={holdersData?.totalHolders} holdersLoading={holdersLoading} + isDynamicMPTEnabled={isDynamicMPTEnabled} circulatingSupply={circulatingSupply} circulatingSupplyLoading={circulatingSupplyLoading} /> diff --git a/src/containers/Token/MPT/test/Header/Settings.test.tsx b/src/containers/Token/MPT/test/Header/Settings.test.tsx index 9d76e084c..11e13ad06 100644 --- a/src/containers/Token/MPT/test/Header/Settings.test.tsx +++ b/src/containers/Token/MPT/test/Header/Settings.test.tsx @@ -7,7 +7,11 @@ describe('Settings component', () => { const renderComponent = (props: any = {}) => render( - + , ) @@ -21,84 +25,238 @@ describe('Settings component', () => { ) }) - it('renders all 7 flag items', () => { - const { container } = renderComponent() - expect(container.querySelectorAll('.header-box-item')).toHaveLength(8) + // ── Amendment gate ─────────────────────────────────────────────────────────── + + it('renders only 7 capability rows when DynamicMPT is not enabled', () => { + const { container } = renderComponent({ isDynamicMPTEnabled: false }) + expect(container.querySelectorAll('.header-box-item')).toHaveLength(7) + }) + + it('renders 7 capability rows and 2 field rows (9 total) when DynamicMPT is enabled', () => { + const { container } = renderComponent({ isDynamicMPTEnabled: true }) + expect(container.querySelectorAll('.header-box-item')).toHaveLength(9) }) - it('shows locked flag as disabled by default', () => { - const { container } = renderComponent({ flags: [] }) - expect(container).toHaveTextContent('locked') - expect(container).toHaveTextContent('disabled') + it('shows no mutable/immutable pills when DynamicMPT is not enabled', () => { + const { queryAllByTestId } = renderComponent({ + flags: [], + isDynamicMPTEnabled: false, + }) + expect(queryAllByTestId('mutable-badge')).toHaveLength(0) + expect(queryAllByTestId('immutable-badge')).toHaveLength(0) + expect(queryAllByTestId('disabled-badge')).toHaveLength(7) }) - it('shows locked flag as enabled when present', () => { - const { container } = renderComponent({ flags: ['lsfMPTLocked'] }) - const flagItems = container.querySelectorAll('.header-box-item') - const lockedItem = flagItems[0] - expect(lockedItem.querySelectorAll('.flag-status.enabled')).toHaveLength(1) + // ── Capability enabled state ───────────────────────────────────────────────── + + it('shows only Enabled badge for an enabled capability — no second pill', () => { + const { queryAllByTestId } = renderComponent({ + flags: ['lsfMPTCanLock'], + isDynamicMPTEnabled: true, + }) + // Enabled caps show one pill only; 6 disabled caps each show Disabled + Mutable; 2 fields Mutable + expect(queryAllByTestId('enabled-badge')).toHaveLength(1) + expect(queryAllByTestId('disabled-badge')).toHaveLength(6) + expect(queryAllByTestId('mutable-badge')).toHaveLength(6 + 2) + expect(queryAllByTestId('immutable-badge')).toHaveLength(0) }) - it('shows can_lock flag status', () => { - const { container } = renderComponent({ flags: ['lsfMPTCanLock'] }) + it('shows Enabled for can_lock when lsfMPTCanLock present', () => { + const { container } = renderComponent({ + flags: ['lsfMPTCanLock'], + isDynamicMPTEnabled: true, + }) expect(container).toHaveTextContent('can_lock') - expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(1) + expect(queryAllEnabled(container)).toHaveLength(1) }) - it('shows require_auth flag status', () => { - const { container } = renderComponent({ flags: ['lsfMPTRequireAuth'] }) + it('shows Enabled for require_auth when lsfMPTRequireAuth present', () => { + const { container } = renderComponent({ + flags: ['lsfMPTRequireAuth'], + isDynamicMPTEnabled: true, + }) expect(container).toHaveTextContent('require_auth') - expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(1) + expect(queryAllEnabled(container)).toHaveLength(1) }) - it('shows can_escrow flag status', () => { - const { container } = renderComponent({ flags: ['lsfMPTCanEscrow'] }) - expect(container).toHaveTextContent('can_escrow') - expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(1) + it('shows Enabled for can_escrow when lsfMPTCanEscrow present', () => { + const { container } = renderComponent({ + flags: ['lsfMPTCanEscrow'], + isDynamicMPTEnabled: true, + }) + expect(queryAllEnabled(container)).toHaveLength(1) }) - it('shows can_trade flag status', () => { - const { container } = renderComponent({ flags: ['lsfMPTCanTrade'] }) - expect(container).toHaveTextContent('can_trade') - expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(1) + it('shows Enabled for can_trade when lsfMPTCanTrade present', () => { + const { container } = renderComponent({ + flags: ['lsfMPTCanTrade'], + isDynamicMPTEnabled: true, + }) + expect(queryAllEnabled(container)).toHaveLength(1) }) - it('shows can_transfer flag status', () => { - const { container } = renderComponent({ flags: ['lsfMPTCanTransfer'] }) - expect(container).toHaveTextContent('can_transfer') - expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(1) + it('shows Enabled for can_transfer when lsfMPTCanTransfer present', () => { + const { container } = renderComponent({ + flags: ['lsfMPTCanTransfer'], + isDynamicMPTEnabled: true, + }) + expect(queryAllEnabled(container)).toHaveLength(1) }) - it('shows can_clawback flag status', () => { - const { container } = renderComponent({ flags: ['lsfMPTCanClawback'] }) - expect(container).toHaveTextContent('can_clawback') - expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(1) + it('shows Enabled for can_clawback when lsfMPTCanClawback present', () => { + const { container } = renderComponent({ + flags: ['lsfMPTCanClawback'], + isDynamicMPTEnabled: true, + }) + expect(queryAllEnabled(container)).toHaveLength(1) }) - it('shows can_confidential_amount flag status', () => { + it('shows Enabled for can_confidential_amount when lsfMPTCanHoldConfidentialBalance present', () => { const { container } = renderComponent({ - flags: ['lsfMPTCanConfidentialAmount'], + flags: ['lsfMPTCanHoldConfidentialBalance'], + isDynamicMPTEnabled: true, }) - expect(container).toHaveTextContent('can_confidential_amount') - expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(1) + expect(queryAllEnabled(container)).toHaveLength(1) }) - it('handles multiple flags enabled', () => { - const { container } = renderComponent({ + it('handles multiple enabled capabilities', () => { + const { queryAllByTestId } = renderComponent({ flags: ['lsfMPTCanTransfer', 'lsfMPTCanTrade', 'lsfMPTCanLock'], + isDynamicMPTEnabled: true, + }) + expect(queryAllByTestId('enabled-badge')).toHaveLength(3) + expect(queryAllByTestId('disabled-badge')).toHaveLength(4) + expect(queryAllByTestId('mutable-badge')).toHaveLength(4 + 2) // 4 disabled caps + 2 fields + }) + + // ── Disabled + Mutable (default state, DynamicMPT on) ─────────────────────── + + it('shows Disabled + Mutable on every capability when nothing is set', () => { + const { queryAllByTestId } = renderComponent({ + flags: [], + isDynamicMPTEnabled: true, }) - expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(3) - expect(container.querySelectorAll('.flag-status.disabled')).toHaveLength(5) + expect(queryAllByTestId('disabled-badge')).toHaveLength(7) + expect(queryAllByTestId('mutable-badge')).toHaveLength(7 + 2) // 7 caps + 2 fields + expect(queryAllByTestId('enabled-badge')).toHaveLength(0) + expect(queryAllByTestId('immutable-badge')).toHaveLength(0) }) - it('handles empty flags array', () => { - const { container } = renderComponent({ flags: [] }) - expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(0) - expect(container.querySelectorAll('.flag-status.disabled')).toHaveLength(8) + it('shows Disabled + Mutable with undefined flags (DynamicMPT on)', () => { + const { queryAllByTestId } = renderComponent({ + flags: undefined, + isDynamicMPTEnabled: true, + }) + expect(queryAllByTestId('disabled-badge')).toHaveLength(7) + expect(queryAllByTestId('mutable-badge')).toHaveLength(9) }) - it('handles undefined flags', () => { - const { container } = renderComponent({ flags: undefined }) - expect(container.querySelectorAll('.flag-status.disabled')).toHaveLength(8) + // ── Disabled + Immutable (locked capabilities) ─────────────────────────────── + + it('shows Disabled + Immutable for a locked capability', () => { + const { queryAllByTestId } = renderComponent({ + flags: [], + immutableFlags: ['lsifMPTCanLock'], + isDynamicMPTEnabled: true, + }) + // canLock locked: Disabled + Immutable; 6 others: Disabled + Mutable; 2 fields: Mutable + expect(queryAllByTestId('immutable-badge')).toHaveLength(1) + expect(queryAllByTestId('disabled-badge')).toHaveLength(7) + expect(queryAllByTestId('mutable-badge')).toHaveLength(6 + 2) + }) + + it('shows Disabled + Immutable for locked confidential capability', () => { + const { queryAllByTestId } = renderComponent({ + flags: [], + immutableFlags: ['lsifMPTCanHoldConfidentialBalance'], + isDynamicMPTEnabled: true, + }) + expect(queryAllByTestId('immutable-badge')).toHaveLength(1) + expect(queryAllByTestId('mutable-badge')).toHaveLength(6 + 2) + }) + + it('shows Immutable on multiple locked capabilities', () => { + const { queryAllByTestId } = renderComponent({ + flags: [], + immutableFlags: [ + 'lsifMPTCanLock', + 'lsifMPTRequireAuth', + 'lsifMPTCanEscrow', + ], + isDynamicMPTEnabled: true, + }) + expect(queryAllByTestId('immutable-badge')).toHaveLength(3) + expect(queryAllByTestId('mutable-badge')).toHaveLength(4 + 2) + }) + + // ── Field rows (DynamicMPT on) ─────────────────────────────────────────────── + + it('shows Mutable on both field rows when neither is locked', () => { + const { queryAllByTestId } = renderComponent({ + flags: [], + immutableFlags: [], + isDynamicMPTEnabled: true, + }) + // 7 caps (Mutable) + 2 fields (Mutable) = 9 + expect(queryAllByTestId('mutable-badge')).toHaveLength(9) + expect(queryAllByTestId('immutable-badge')).toHaveLength(0) + }) + + it('shows Immutable on metadata field when lsifMPTMetadata is locked', () => { + const { queryAllByTestId } = renderComponent({ + flags: [], + immutableFlags: ['lsifMPTMetadata'], + isDynamicMPTEnabled: true, + }) + // 7 cap Mutable + 1 field Immutable (metadata) + 1 field Mutable (transferFee) + expect(queryAllByTestId('immutable-badge')).toHaveLength(1) + expect(queryAllByTestId('mutable-badge')).toHaveLength(7 + 1) + }) + + it('shows Immutable on both field rows when both are locked', () => { + const { queryAllByTestId } = renderComponent({ + flags: [], + immutableFlags: ['lsifMPTMetadata', 'lsifMPTTransferFee'], + isDynamicMPTEnabled: true, + }) + expect(queryAllByTestId('immutable-badge')).toHaveLength(2) + expect(queryAllByTestId('mutable-badge')).toHaveLength(7) // only caps + }) + + it('always renders both field rows regardless of immutableFlags when DynamicMPT is enabled', () => { + const { container: locked } = renderComponent({ + immutableFlags: ['lsifMPTMetadata', 'lsifMPTTransferFee'], + isDynamicMPTEnabled: true, + }) + const { container: unlocked } = renderComponent({ + immutableFlags: [], + isDynamicMPTEnabled: true, + }) + expect(locked.querySelectorAll('.header-box-item')).toHaveLength(9) + expect(unlocked.querySelectorAll('.header-box-item')).toHaveLength(9) + }) + + // ── Pill class names ───────────────────────────────────────────────────────── + + it('uses correct CSS class for immutable pill', () => { + const { container } = renderComponent({ + flags: [], + immutableFlags: ['lsifMPTCanLock'], + isDynamicMPTEnabled: true, + }) + expect(container.querySelectorAll('.flag-status.immutable')).toHaveLength(1) + }) + + it('uses correct CSS class for mutable pill', () => { + const { container } = renderComponent({ + flags: [], + immutableFlags: [], + isDynamicMPTEnabled: true, + }) + expect(container.querySelectorAll('.flag-status.mutable')).toHaveLength(9) }) }) + +function queryAllEnabled(container: HTMLElement) { + return container.querySelectorAll('.flag-status.enabled') +} diff --git a/src/containers/shared/Interfaces.tsx b/src/containers/shared/Interfaces.tsx index f9d11d00b..02d0bf7b9 100644 --- a/src/containers/shared/Interfaces.tsx +++ b/src/containers/shared/Interfaces.tsx @@ -48,6 +48,7 @@ export interface FormattedMPTIssuance { outstandingAmt?: string confidentialOutstandingAmt?: string flags?: string[] + immutableFlags?: string[] transferFee?: number rawMPTMetadata?: string parsedMPTMetadata?: Record diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/Simple.tsx b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/Simple.tsx index 28a9c62a6..23cee5929 100644 --- a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/Simple.tsx +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/Simple.tsx @@ -11,8 +11,14 @@ import './styles.scss' export const Simple: TransactionSimpleComponent = ({ data, }: TransactionSimpleProps) => { - const { issuanceID, metadata, assetScale, transferFee, maxAmount } = - data.instructions + const { + issuanceID, + metadata, + assetScale, + transferFee, + maxAmount, + immutableFlags, + } = data.instructions const { t } = useTranslation() const language = useLanguage() const formattedFee = @@ -64,6 +70,17 @@ export const Simple: TransactionSimpleComponent = ({ )} )} + {immutableFlags && immutableFlags.length > 0 && ( + + {immutableFlags.map((flag) => ( +
{flag}
+ ))} +
+ )} ) } diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/parser.ts b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/parser.ts index fd08c599f..8aa49165d 100644 --- a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/parser.ts +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/parser.ts @@ -1,19 +1,27 @@ import type { MPTokenIssuanceCreate } from 'xrpl' import { MPTokenIssuanceCreateInstructions } from './types' import { TransactionParser } from '../types' -import { convertHexToString } from '../../../../../rippled/lib/utils' +import { + buildFlags, + convertHexToString, +} from '../../../../../rippled/lib/utils' +import { MPT_IMMUTABLE_FLAGS } from '../../../transactionUtils' export const parser: TransactionParser< MPTokenIssuanceCreate, MPTokenIssuanceCreateInstructions -> = (tx, meta) => ({ - issuanceID: meta.mpt_issuance_id, - metadata: tx.MPTokenMetadata - ? convertHexToString(tx.MPTokenMetadata) - : undefined, - transferFee: tx.TransferFee, - assetScale: tx.AssetScale, - maxAmount: tx.MaximumAmount - ? BigInt(tx.MaximumAmount).toString(10) - : undefined, -}) +> = (tx, meta) => { + const immutableFlags = buildFlags(tx.ImmutableFlags, MPT_IMMUTABLE_FLAGS) + return { + issuanceID: meta.mpt_issuance_id, + metadata: tx.MPTokenMetadata + ? convertHexToString(tx.MPTokenMetadata) + : undefined, + transferFee: tx.TransferFee, + assetScale: tx.AssetScale, + maxAmount: tx.MaximumAmount + ? BigInt(tx.MaximumAmount).toString(10) + : undefined, + immutableFlags: immutableFlags.length ? immutableFlags : undefined, + } +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/MPTokenIssuanceCreateSimple.test.jsx b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/MPTokenIssuanceCreateSimple.test.jsx index be888d0d6..39b13537e 100644 --- a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/MPTokenIssuanceCreateSimple.test.jsx +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/MPTokenIssuanceCreateSimple.test.jsx @@ -1,7 +1,8 @@ import { createSimpleRenderFactory } from '../../test/createWrapperFactory' import { Simple } from '../Simple' -import { expectSimpleRowText } from '../../test' +import { expectSimpleRowText, expectSimpleRowNotToExist } from '../../test' import transactionSuccess from './mock_data/MPTokenIssuanceCreate.json' +import transactionDynamic from './mock_data/MPTokenIssuanceCreate_Dynamic.json' const renderComponent = createSimpleRenderFactory(Simple) @@ -22,6 +23,18 @@ describe('MPTokenIssuanceCreate', () => { 'https://ipfs.io/ipfs/QmZnjmB9Tk4xaA9E679ytrPXda3beWMLUnMB5RFj1eStLp', ) expectSimpleRowText(container, 'mpt-fee', '0.010%') + expectSimpleRowNotToExist(container, 'mpt-immutable-flags') + unmount() + }) + + it('handles Dynamic MPT MPTokenIssuanceCreate simple view', () => { + const { container, unmount } = renderComponent(transactionDynamic) + + // ImmutableFlags 196610 = tifMPTCanLock (0x2) + + // tifMPTMetadata (0x10000) + tifMPTTransferFee (0x20000) + expectSimpleRowText(container, 'mpt-immutable-flags', 'tifMPTCanLock') + expectSimpleRowText(container, 'mpt-immutable-flags', 'tifMPTMetadata') + expectSimpleRowText(container, 'mpt-immutable-flags', 'tifMPTTransferFee') unmount() }) }) diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/mock_data/MPTokenIssuanceCreate_Dynamic.json b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/mock_data/MPTokenIssuanceCreate_Dynamic.json new file mode 100644 index 000000000..be5b44f6e --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/mock_data/MPTokenIssuanceCreate_Dynamic.json @@ -0,0 +1,51 @@ +{ + "tx": { + "Account": "rfGb6p2kWy3zQweWnYNxSFYoHeymcx7mhg", + "AssetScale": 2, + "Fee": "10", + "Flags": 34, + "MPTokenMetadata": "68747470733A2F2F697066732E696F2F697066732F516D5A6E6A6D4239546B34786141394536373979747250586461336265574D4C556E4D423552466A316553744C70", + "MaximumAmount": "9223372036854775807", + "ImmutableFlags": 196610, + "Sequence": 5496, + "SigningPubKey": "EDAD408FAEE57EB4A347E6FE395B834DD47C6531C3C37B09ACC35528161CAD4B0E", + "TransactionType": "MPTokenIssuanceCreate", + "TransferFee": 10, + "TxnSignature": "F7AA8083EE7D7EFD10E11FF5A12B73D2D45A80094AEED4B41FBF2A90C9E03E5E4D162E91FA1EC156BF94E770E70E7633DF09665A5C2D5408178FC376BFC9B100", + "ctid": "C000157A00000000", + "date": 1710949602000, + "hash": "undefined", + "inLedger": "undefined", + "ledger_index": "undefined", + "meta": "undefined", + "validated": "undefined", + "metaData": "undefined", + "status": "undefined" + }, + "meta": { + "AffectedNodes": [ + { + "CreatedNode": { + "LedgerEntryType": "MPTokenIssuance", + "LedgerIndex": "265CEA78D8246B8B51D5CCC20AF4DB95569DE09E53115C888B176A3D1D05048A", + "NewFields": { + "AssetScale": 2, + "Flags": 34, + "Issuer": "rfGb6p2kWy3zQweWnYNxSFYoHeymcx7mhg", + "MPTokenMetadata": "68747470733A2F2F697066732E696F2F697066732F516D5A6E6A6D4239546B34786141394536373979747250586461336265574D4C556E4D423552466A316553744C70", + "MaximumAmount": "9223372036854775807", + "ImmutableFlags": 196610, + "Sequence": 5496, + "TransferFee": 10 + } + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS", + "mpt_issuance_id": "0000157844C3F3B57A8B579FEE1033CC8E8498729D063617" + }, + "hash": "9686DA1322D2D8F9CD97C5848A8E3CADB9D3F73154DA59BB3A3CACC4CA43671C", + "ledger_index": 5498, + "date": 1710949602000 +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/types.ts b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/types.ts index 065d02976..caa4d9912 100644 --- a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/types.ts +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/types.ts @@ -4,4 +4,6 @@ export interface MPTokenIssuanceCreateInstructions { transferFee?: number assetScale?: number maxAmount?: string + // i18n keys of the fields/flags permanently locked via Dynamic MPT (XLS-94). + immutableFlags?: string[] } diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceSet/Simple.tsx b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/Simple.tsx index 9b0777e17..78f43bf5b 100644 --- a/src/containers/shared/components/Transaction/MPTokenIssuanceSet/Simple.tsx +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/Simple.tsx @@ -4,23 +4,44 @@ import { SimpleRow } from '../SimpleRow' import { TransactionSimpleComponent, TransactionSimpleProps } from '../types' import { Account } from '../../Account' import { MPTokenLink } from '../../MPTokenLink' -import { shortenEncryptionKey } from '../../../utils' - -interface MPTokenIssuanceSetExtended extends MPTokenIssuanceSet { - IssuerEncryptionKey?: string - AuditorEncryptionKey?: string -} +import { + isValidJsonString, + localizeNumber, + shortenEncryptionKey, +} from '../../../utils' +import { useLanguage } from '../../../hooks' +import { JsonView } from '../../JsonView' +import { + buildFlags, + convertHexToString, +} from '../../../../../rippled/lib/utils' +import { MPT_IMMUTABLE_FLAGS } from '../../../transactionUtils' export const Simple: TransactionSimpleComponent = ({ data, -}: TransactionSimpleProps) => { +}: TransactionSimpleProps) => { const { MPTokenIssuanceID, Holder, + MPTokenMetadata, + TransferFee, + ImmutableFlags, IssuerEncryptionKey, AuditorEncryptionKey, } = data.instructions const { t } = useTranslation() + const language = useLanguage() + + const metadata = MPTokenMetadata + ? convertHexToString(MPTokenMetadata) + : undefined + const formattedFee = + TransferFee != null + ? `${localizeNumber((TransferFee / 1000).toPrecision(5), language, { + minimumFractionDigits: 3, + })}%` + : undefined + const flagChanges = buildFlags(ImmutableFlags, MPT_IMMUTABLE_FLAGS) return ( <> @@ -32,6 +53,35 @@ export const Simple: TransactionSimpleComponent = ({ )} + {TransferFee != null && ( + + {formattedFee} + + )} + {metadata && ( + + {isValidJsonString(metadata) ? ( + + ) : ( + metadata + )} + + )} + {flagChanges.length > 0 && ( + + {flagChanges.map((flag) => ( +
{flag}
+ ))} +
+ )} {IssuerEncryptionKey && ( { unmount() }) + it('handles Dynamic MPT MPTokenIssuanceSet simple view', () => { + const { container, unmount } = renderComponent(transactionDynamic) + + expectSimpleRowText( + container, + 'mpt-issuance-id', + '000002609BB39CEC721B5AB337B6BD862ACD2811CBBB5F18', + ) + expectSimpleRowText(container, 'mpt-fee', '0.500%') + expectSimpleRowText(container, 'mpt-metadata', 'FOO') + // ImmutableFlags 16 (0x10) = tifMPTCanTrade + expectSimpleRowText(container, 'mpt-immutable-flags', 'tifMPTCanTrade') + expectSimpleRowNotToExist(container, 'mpt-holder') + unmount() + }) + it('handles failed MPTokenIssuanceSet simple view ', () => { const { container, unmount } = renderComponent(transactionFail) expectSimpleRowText( diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet_Dynamic.json b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet_Dynamic.json new file mode 100644 index 000000000..b84dca60e --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet_Dynamic.json @@ -0,0 +1,73 @@ +{ + "tx": { + "Account": "rEUGuTqrySk9o1rZSVx8seuvcsEZymeEYM", + "Fee": "10", + "Flags": 0, + "MPTokenIssuanceID": "000002609BB39CEC721B5AB337B6BD862ACD2811CBBB5F18", + "ImmutableFlags": 16, + "TransferFee": 500, + "MPTokenMetadata": "464F4F", + "Sequence": 609, + "SigningPubKey": "ED92DDE49AA689EC63589623067968B85E4885A9874B3CAB89E07D192EBFA42FF9", + "TransactionType": "MPTokenIssuanceSet", + "TxnSignature": "6678BF017A62360DAA39156112960D934440467D2E4098958F053B6758A2D237DA82CD27FDA3B35BAB610D74669BF6C4DEB12D77984E48158B2D79C8BAEC3303", + "ctid": "C000026400000000", + "date": 1711133159000, + "hash": "undefined", + "inLedger": "undefined", + "ledger_index": "undefined", + "meta": "undefined", + "validated": "undefined", + "metaData": "undefined", + "status": "undefined" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rEUGuTqrySk9o1rZSVx8seuvcsEZymeEYM", + "Balance": "99999980", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 610 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "1D54D263727856612FC7C7A27D93532ED0C41B74FA651992C125AD19C900669D", + "PreviousFields": { + "Balance": "99999990", + "Sequence": 609 + }, + "PreviousTxnID": "CF68A8D929F089F6F53B071250935DD7C3F52F6E175D82E53118265D776D4BF7", + "PreviousTxnLgrSeq": 610 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 32, + "Issuer": "rEUGuTqrySk9o1rZSVx8seuvcsEZymeEYM", + "ImmutableFlags": 131106, + "OutstandingAmount": "0", + "OwnerNode": "0", + "Sequence": 608, + "TransferFee": 500 + }, + "LedgerEntryType": "MPTokenIssuance", + "LedgerIndex": "E8DF796110B1FBD9086A4637491E49843D33D897FBC32F03A33140F76378EE86", + "PreviousFields": { + "Flags": 34, + "TransferFee": 0 + }, + "PreviousTxnID": "CF68A8D929F089F6F53B071250935DD7C3F52F6E175D82E53118265D776D4BF7", + "PreviousTxnLgrSeq": 610 + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }, + "hash": "73C21D8B5DFBF5DE03FCAF0D69C00E9E2918280561E9898C556A1C743A566D47", + "ledger_index": 612, + "date": 1711133159000 +} diff --git a/src/containers/shared/transactionUtils.ts b/src/containers/shared/transactionUtils.ts index 6b3c4900c..5d0288bda 100644 --- a/src/containers/shared/transactionUtils.ts +++ b/src/containers/shared/transactionUtils.ts @@ -74,8 +74,14 @@ export const TX_FLAGS: Record> = { MPTokenIssuanceSet: { 0x00000001: 'tfMPTLock', 0x00000002: 'tfMPTUnlock', - 0x00001000: 'tfMPTSetCanConfidentialAmount', - 0x00002000: 'tfMPTClearCanConfidentialAmount', + // Capability-setting flags (XLS-94 DynamicMPT): one-way, moved into Flags + 0x00000004: 'tfMPTSetCanLock', + 0x00000008: 'tfMPTSetRequireAuth', + 0x00000010: 'tfMPTSetCanEscrow', + 0x00000020: 'tfMPTSetCanTrade', + 0x00000040: 'tfMPTSetCanTransfer', + 0x00000080: 'tfMPTSetCanClawback', + 0x00000100: 'tfMPTSetCanHoldConfidentialBalance', }, NFTokenMint: { 0x00000001: 'tfBurnable', @@ -119,6 +125,27 @@ export const TX_FLAGS: Record> = { }, } +// Dynamic MPT (XLS-94) ImmutableFlags fields. These are separate UInt32 fields +// from the standard `Flags` field, so they are decoded with their own maps. +// Semantics: by default all fields/capabilities are mutable; setting a bit in +// ImmutableFlags permanently locks that field/capability so it can never be +// changed again via MPTokenIssuanceSet. + +// MPTokenIssuanceCreate.ImmutableFlags / MPTokenIssuanceSet.ImmutableFlags: +// `tif` prefix (Transaction ImmutableFlag). Setting a bit permanently locks +// the corresponding capability/field. Same bit values as the ledger `lsif*` map. +export const MPT_IMMUTABLE_FLAGS: Record = { + 0x00000002: 'tifMPTCanLock', + 0x00000004: 'tifMPTRequireAuth', + 0x00000008: 'tifMPTCanEscrow', + 0x00000010: 'tifMPTCanTrade', + 0x00000020: 'tifMPTCanTransfer', + 0x00000040: 'tifMPTCanClawback', + 0x00000080: 'tifMPTCanHoldConfidentialBalance', + 0x00010000: 'tifMPTMetadata', + 0x00020000: 'tifMPTTransferFee', +} + export const ACCOUNT_FLAGS: Record = { 17: 'asfAllowTrustLineLocking', 16: 'asfAllowTrustLineClawback', diff --git a/src/rippled/lib/test/utils.test.ts b/src/rippled/lib/test/utils.test.ts index 370251961..0b543f0be 100644 --- a/src/rippled/lib/test/utils.test.ts +++ b/src/rippled/lib/test/utils.test.ts @@ -188,7 +188,7 @@ describe('rippled utils:', () => { OutstandingAmount: '500000000000', TransferFee: 500, Sequence: 1, - Flags: 0x7f, // All flags + Flags: 0xff, // All lsf flags incl. lsfMPTCanHoldConfidentialBalance (0x80) MPTokenMetadata: metadataHex, } @@ -207,6 +207,8 @@ describe('rippled utils:', () => { expect(result.flags).toContain('lsfMPTCanTrade') expect(result.flags).toContain('lsfMPTCanTransfer') expect(result.flags).toContain('lsfMPTCanClawback') + expect(result.flags).toContain('lsfMPTCanHoldConfidentialBalance') + expect(result.immutableFlags).toHaveLength(0) expect(result.rawMPTMetadata).toBe('{"t":"USD","in":"Test Issuer"}') expect(result.parsedMPTMetadata).toEqual({ ticker: 'USD', @@ -215,5 +217,53 @@ describe('rippled utils:', () => { // isMPTMetadataCompliant is false because the test metadata doesn't meet full XLS-89 requirements expect(result.isMPTMetadataCompliant).toBe(false) }) + + it('should decode ImmutableFlags into lsif* flag names', () => { + // 0x10006 = lsifMPTCanLock(0x2) + lsifMPTRequireAuth(0x4) + lsifMPTMetadata(0x10000) + const result = formatMPTIssuance({ + Issuer: 'rMPTTestAccount123456789', + AssetScale: 6, + TransferFee: 0, + Sequence: 1, + Flags: 0, + ImmutableFlags: 0x00010006, + } as any) + + expect(result.immutableFlags).toContain('lsifMPTCanLock') + expect(result.immutableFlags).toContain('lsifMPTRequireAuth') + expect(result.immutableFlags).toContain('lsifMPTMetadata') + expect(result.immutableFlags).not.toContain('lsifMPTCanEscrow') + expect(result.immutableFlags).not.toContain('lsifMPTCanTrade') + expect(result.immutableFlags).not.toContain('lsifMPTCanTransfer') + expect(result.immutableFlags).not.toContain('lsifMPTCanClawback') + expect(result.immutableFlags).not.toContain( + 'lsifMPTCanHoldConfidentialBalance', + ) + expect(result.immutableFlags).not.toContain('lsifMPTTransferFee') + }) + + it('should decode all ImmutableFlags when fully set', () => { + // 0x300fe = all 7 capability bits (0xfe) + both field bits (0x10000 | 0x20000) + const result = formatMPTIssuance({ + Issuer: 'rMPTTestAccount123456789', + AssetScale: 6, + TransferFee: 0, + Sequence: 1, + Flags: 0, + ImmutableFlags: 0x000300fe, + } as any) + + expect(result.immutableFlags).toContain('lsifMPTCanLock') + expect(result.immutableFlags).toContain('lsifMPTRequireAuth') + expect(result.immutableFlags).toContain('lsifMPTCanEscrow') + expect(result.immutableFlags).toContain('lsifMPTCanTrade') + expect(result.immutableFlags).toContain('lsifMPTCanTransfer') + expect(result.immutableFlags).toContain('lsifMPTCanClawback') + expect(result.immutableFlags).toContain( + 'lsifMPTCanHoldConfidentialBalance', + ) + expect(result.immutableFlags).toContain('lsifMPTMetadata') + expect(result.immutableFlags).toContain('lsifMPTTransferFee') + }) }) }) diff --git a/src/rippled/lib/utils.ts b/src/rippled/lib/utils.ts index 823870688..3a46d09ad 100644 --- a/src/rippled/lib/utils.ts +++ b/src/rippled/lib/utils.ts @@ -45,7 +45,22 @@ const MPT_ISSUANCE_FLAGS: FlagMap = { 0x00000010: 'lsfMPTCanTrade', 0x00000020: 'lsfMPTCanTransfer', 0x00000040: 'lsfMPTCanClawback', - 0x00000080: 'lsfMPTCanConfidentialAmount', + 0x00000080: 'lsfMPTCanHoldConfidentialBalance', +} +// ImmutableFlags on the MPTokenIssuance ledger object (Dynamic MPT, XLS-94). +// A set bit permanently locks the corresponding field/capability — the issuer +// can no longer change it via MPTokenIssuanceSet. Absent = everything mutable. +// `lsif` prefix mirrors the `tif` transaction-level names; bit values are shared. +const MPT_ISSUANCE_IMMUTABLE_FLAGS: FlagMap = { + 0x00000002: 'lsifMPTCanLock', + 0x00000004: 'lsifMPTRequireAuth', + 0x00000008: 'lsifMPTCanEscrow', + 0x00000010: 'lsifMPTCanTrade', + 0x00000020: 'lsifMPTCanTransfer', + 0x00000040: 'lsifMPTCanClawback', + 0x00000080: 'lsifMPTCanHoldConfidentialBalance', + 0x00010000: 'lsifMPTMetadata', + 0x00020000: 'lsifMPTTransferFee', } const MPTOKEN_FLAGS: FlagMap = { 0x00000001: 'lsfMPTLocked', @@ -267,6 +282,7 @@ interface MPTIssuanceInfo { Sequence: number MPTokenMetadata?: string Flags: number + ImmutableFlags?: number IssuerEncryptionKey?: string AuditorEncryptionKey?: string } @@ -281,6 +297,7 @@ interface FormattedMPTIssuance { sequence: number metadata?: any flags: string[] + immutableFlags: string[] rawMPTMetadata?: string parsedMPTMetadata?: Record isMPTMetadataCompliant: boolean @@ -312,6 +329,10 @@ const formatMPTIssuance = (info: MPTIssuanceInfo): FormattedMPTIssuance => { parsedMPTMetadata: parseMPTMetadata(rawMPTMetadataHex), isMPTMetadataCompliant: isMPTMetadataCompliant(rawMPTMetadataHex), flags: buildFlags(info.Flags, MPT_ISSUANCE_FLAGS), + immutableFlags: buildFlags( + info.ImmutableFlags, + MPT_ISSUANCE_IMMUTABLE_FLAGS, + ), issuerEncryptionKey: info.IssuerEncryptionKey, auditorEncryptionKey: info.AuditorEncryptionKey, }