Skip to content

File tree

.changeset/red-swans-rescue.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@iota/apps-ui-kit': patch
3+
---
4+
5+
Tooltip can have links in text prop

apps/explorer/src/components/ui/Link.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const linkStyles = cva([], {
1111
variants: {
1212
variant: {
1313
text: 'text-body-md font-semibold text-iota-neutral-40 hover:text-iota-neutral-60 active:text-steel disabled:text-gray-60',
14-
mono: 'text-body-md text-iota-primary-30 hover:text-iota-primary-20',
14+
mono: 'text-body-md text-iota-primary-30 dark:text-iota-primary-80 hover:text-iota-primary-50 dark:hover:text-iota-primary-60',
1515
textHeroDark: 'text-pBody font-medium text-hero-dark hover:text-hero-darkest',
1616
},
1717
uppercase: {

apps/explorer/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export * from './useGetTransactionBlocks';
1515
export * from './useMediaQuery';
1616
export * from './useNetwork';
1717
export * from './useNormalizedMoveModule';
18+
export * from './usePackageUpgradePolicy';
1819
export * from './useSearch';
1920
export * from './useVerifiedSourceCode';
2021
export * from './useEndOfEpochTransactionFromCheckpoint';
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright (c) 2026 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useGetTransaction } from '@iota/core';
5+
import { useIotaClientQuery } from '@iota/dapp-kit';
6+
import { normalizeIotaAddress } from '@iota/iota-sdk/utils';
7+
import { useMemo } from 'react';
8+
import { UPGRADE_POLICIES, type UpgradePolicyInfo } from '~/lib';
9+
10+
const UPGRADE_CAP_TYPE = '0x2::package::UpgradeCap';
11+
12+
const IMMUTABLE_POLICY: UpgradePolicyInfo = {
13+
label: 'Immutable',
14+
description: 'Prevents any upgrades to the package. The UpgradeCap has been destroyed.',
15+
isImmutable: true,
16+
};
17+
18+
const CUSTOM_POLICY: UpgradePolicyInfo = {
19+
label: 'Custom',
20+
description:
21+
'The UpgradeCap is wrapped inside a custom policy object. The package may still be upgradeable under custom conditions.',
22+
isImmutable: false,
23+
};
24+
25+
const MAKE_IMMUTABLE_FUNCTION = {
26+
package: '0x2',
27+
module: 'package',
28+
function: 'make_immutable',
29+
} as const;
30+
31+
export function usePackageUpgradePolicy(txDigest: string | null | undefined): {
32+
upgradePolicy: UpgradePolicyInfo | null;
33+
isPending: boolean;
34+
} {
35+
const { data: txnData, isPending: isTxPending } = useGetTransaction(txDigest ?? '');
36+
37+
const upgradeCapObjectId = useMemo(() => {
38+
if (!txnData?.objectChanges) return undefined;
39+
const upgradeCapChange = txnData.objectChanges.find(
40+
(change) =>
41+
change.type === 'created' &&
42+
'objectType' in change &&
43+
change.objectType === UPGRADE_CAP_TYPE,
44+
);
45+
return upgradeCapChange && 'objectId' in upgradeCapChange
46+
? upgradeCapChange.objectId
47+
: undefined;
48+
}, [txnData?.objectChanges]);
49+
50+
const { data: upgradeCapData, isPending: isUpgradeCapPending } = useIotaClientQuery(
51+
'getObject',
52+
{
53+
id: upgradeCapObjectId!,
54+
options: { showContent: true },
55+
},
56+
{
57+
enabled: !!upgradeCapObjectId,
58+
},
59+
);
60+
61+
// When the UpgradeCap is not accessible (deleted or wrapped), check whether
62+
// it was destroyed via `make_immutable` or wrapped in a custom policy object.
63+
const upgradeCapMissing =
64+
!!upgradeCapObjectId &&
65+
!isUpgradeCapPending &&
66+
(!!upgradeCapData?.error || !upgradeCapData?.data);
67+
68+
const { data: lastCapTxData, isPending: isLastCapTxPending } = useIotaClientQuery(
69+
'queryTransactionBlocks',
70+
{
71+
filter: { InputObject: upgradeCapObjectId! },
72+
options: { showInput: true },
73+
order: 'descending',
74+
limit: 1,
75+
},
76+
{
77+
enabled: upgradeCapMissing,
78+
},
79+
);
80+
81+
const upgradePolicy = useMemo<UpgradePolicyInfo | null>(() => {
82+
const isUpgradeCapLoading = !!upgradeCapObjectId && isUpgradeCapPending;
83+
const isLastCapTxLoading = upgradeCapMissing && isLastCapTxPending;
84+
85+
if (!txDigest || isTxPending || isUpgradeCapLoading || isLastCapTxLoading) {
86+
return null;
87+
}
88+
89+
if (!upgradeCapObjectId) {
90+
return IMMUTABLE_POLICY;
91+
}
92+
93+
// UpgradeCap exists and is accessible: read the policy field
94+
if (upgradeCapData?.data) {
95+
const content = upgradeCapData.data.content;
96+
if (content?.dataType === 'moveObject' && content.fields) {
97+
const fields = content.fields as Record<string, unknown>;
98+
const policy = Number(fields.policy);
99+
const policyInfo = UPGRADE_POLICIES[policy];
100+
return {
101+
label: policyInfo?.label ?? `Unknown (${policy})`,
102+
description: policyInfo?.description ?? '',
103+
isImmutable: false,
104+
};
105+
}
106+
return null;
107+
}
108+
109+
// UpgradeCap is missing: determine if it was destroyed or wrapped
110+
const lastTx = lastCapTxData?.data?.[0];
111+
if (lastTx?.transaction?.data?.transaction?.kind === 'ProgrammableTransaction') {
112+
const transactions = lastTx.transaction.data.transaction.transactions;
113+
const normalizedPkg = normalizeIotaAddress(MAKE_IMMUTABLE_FUNCTION.package);
114+
const wasMadeImmutable = transactions.some(
115+
(tx) =>
116+
'MoveCall' in tx &&
117+
normalizeIotaAddress(tx.MoveCall.package) === normalizedPkg &&
118+
tx.MoveCall.module === MAKE_IMMUTABLE_FUNCTION.module &&
119+
tx.MoveCall.function === MAKE_IMMUTABLE_FUNCTION.function,
120+
);
121+
return wasMadeImmutable ? IMMUTABLE_POLICY : CUSTOM_POLICY;
122+
}
123+
124+
return null;
125+
}, [
126+
txDigest,
127+
upgradeCapObjectId,
128+
upgradeCapData,
129+
upgradeCapMissing,
130+
lastCapTxData,
131+
isTxPending,
132+
isUpgradeCapPending,
133+
isLastCapTxPending,
134+
]);
135+
136+
const isPending =
137+
!!txDigest &&
138+
(isTxPending ||
139+
(!!upgradeCapObjectId && isUpgradeCapPending) ||
140+
(upgradeCapMissing && isLastCapTxPending));
141+
142+
return {
143+
upgradePolicy,
144+
isPending,
145+
};
146+
}

apps/explorer/src/lib/constants/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
export * from './footer.constants';
55
export * from './validator.constants';
66
export * from './pageSize.constants';
7+
export * from './policy.constants';
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) 2026 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { UpgradePolicy } from '@iota/iota-sdk/transactions';
5+
6+
export interface UpgradePolicyInfo {
7+
label: string;
8+
description: string;
9+
isImmutable: boolean;
10+
}
11+
12+
export const UPGRADE_DOCS_URL =
13+
'https://docs.iota.org/developer/iota-101/move-overview/package-upgrades/custom-policies';
14+
15+
export const UPGRADE_POLICIES: Record<number, { label: string; description: string }> = {
16+
[UpgradePolicy.COMPATIBLE]: {
17+
label: 'Compatible',
18+
description:
19+
'Permits changes to all function implementations, removal of ability constraints on generic type parameters, and modifications to private, public(friend), and entry function signatures. Public function signatures and existing types cannot be changed.',
20+
},
21+
[UpgradePolicy.ADDITIVE]: {
22+
label: 'Additive',
23+
description:
24+
'Allows adding new functionalities (e.g., new public functions or structs) but restricts changes to existing functionalities.',
25+
},
26+
[UpgradePolicy.DEP_ONLY]: {
27+
label: 'Dependency-only',
28+
description: "Limits modifications to the package's dependencies only.",
29+
},
30+
};

apps/explorer/src/pages/object-result/ObjectResult.tsx

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@ import { AddressAlias, useCopyToClipboard, useGetObjectOrPastObject } from '@iot
66
import { useParams } from 'react-router-dom';
77
import { ErrorBoundary, PageLayout } from '~/components';
88
import { PageHeader } from '~/components/ui';
9+
import { usePackageUpgradePolicy } from '~/hooks';
910
import { ObjectView } from '~/pages/object-result/views/ObjectView';
1011
import { translate, type DataType } from './ObjectResultType';
1112
import { PkgView, TokenView } from './views';
12-
import { InfoBox, InfoBoxStyle, InfoBoxType, LoadingIndicator } from '@iota/apps-ui-kit';
13+
import {
14+
Badge,
15+
BadgeType,
16+
InfoBox,
17+
InfoBoxStyle,
18+
InfoBoxType,
19+
LoadingIndicator,
20+
} from '@iota/apps-ui-kit';
1321
import { Warning } from '@iota/apps-ui-icons';
1422

1523
const PACKAGE_TYPE_NAME = 'Move Package';
@@ -19,6 +27,12 @@ export function ObjectResult(): JSX.Element {
1927
const { data, isPending, isError, isFetched } = useGetObjectOrPastObject(objID);
2028
const copyToClipboard = useCopyToClipboard();
2129

30+
const isPageError = !isPending && (isError || data?.error || (isFetched && !data));
31+
const resp = data && !isPageError ? translate(data) : null;
32+
const isPackage = resp ? resp.objType === PACKAGE_TYPE_NAME : false;
33+
const txDigest = isPackage ? resp?.data.tx_digest : undefined;
34+
const { upgradePolicy } = usePackageUpgradePolicy(txDigest);
35+
2236
if (isPending) {
2337
return (
2438
<PageLayout
@@ -31,11 +45,6 @@ export function ObjectResult(): JSX.Element {
3145
);
3246
}
3347

34-
const isPageError = isError || data?.error || (isFetched && !data);
35-
36-
const resp = data && !isPageError ? translate(data) : null;
37-
const isPackage = resp ? resp.objType === PACKAGE_TYPE_NAME : false;
38-
3948
return (
4049
<PageLayout
4150
content={
@@ -77,11 +86,27 @@ export function ObjectResult(): JSX.Element {
7786
type="Package"
7887
showCopyButton={false}
7988
title={
80-
<div className="flex flex-col gap-xs">
89+
<div className="flex items-center gap-xs">
8190
<AddressAlias
8291
address={resp.id}
8392
onCopy={() => copyToClipboard(resp.id)}
8493
/>
94+
{upgradePolicy && (
95+
<span className="shrink-0">
96+
<Badge
97+
label={
98+
upgradePolicy.isImmutable
99+
? 'Immutable'
100+
: 'Upgradeable'
101+
}
102+
type={
103+
upgradePolicy.isImmutable
104+
? BadgeType.Neutral
105+
: BadgeType.PrimarySoft
106+
}
107+
/>
108+
</span>
109+
)}
85110
</div>
86111
}
87112
/>

apps/explorer/src/pages/object-result/views/PkgView.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ import {
1111
CheckpointSequenceLink,
1212
EpochLink,
1313
ErrorBoundary,
14+
Link,
1415
ObjectLink,
1516
PkgModulesWrapper,
1617
TransactionBlocksForAddress,
1718
} from '~/components';
19+
import { usePackageUpgradePolicy } from '~/hooks';
1820
import { getOwnerStr, trimStdLibPrefix } from '~/lib/utils';
1921
import { type DataType } from '../ObjectResultType';
2022

@@ -28,7 +30,9 @@ import {
2830
SegmentedButton,
2931
SegmentedButtonType,
3032
Title,
33+
TooltipPosition,
3134
} from '@iota/apps-ui-kit';
35+
import { UPGRADE_DOCS_URL } from '~/lib';
3236

3337
const GENESIS_TX_DIGEST = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
3438

@@ -47,6 +51,7 @@ export function PkgView({ data }: PkgViewProps): JSX.Element {
4751
);
4852

4953
const { data: txnData, isPending } = useGetTransaction(data.data.tx_digest!);
54+
const { upgradePolicy } = usePackageUpgradePolicy(data.data.tx_digest);
5055

5156
if (isPending) {
5257
return <LoadingIndicator text="Loading data" />;
@@ -140,6 +145,21 @@ export function PkgView({ data }: PkgViewProps): JSX.Element {
140145
value={formatDate(Number(txnData.timestampMs))}
141146
/>
142147
)}
148+
{upgradePolicy && (
149+
<KeyValueInfo
150+
keyText="Upgrade Policy"
151+
tooltipText={
152+
<>
153+
{upgradePolicy.description}{' '}
154+
<Link href={UPGRADE_DOCS_URL} variant="mono">
155+
Read more
156+
</Link>
157+
</>
158+
}
159+
tooltipPosition={TooltipPosition.Bottom}
160+
value={upgradePolicy.label}
161+
/>
162+
)}
143163
</div>
144164
</div>
145165
</Panel>

apps/ui-kit/src/lib/components/atoms/key-value-info/KeyValueInfo.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ interface KeyValueProps {
2525
/**
2626
* The tooltip text.
2727
*/
28-
tooltipText?: string;
28+
tooltipText?: ReactNode;
2929
/**
3030
* The supporting label of the KeyValue (optional).
3131
*/

apps/ui-kit/src/lib/components/atoms/tooltip/Tooltip.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { useRef, useState, useLayoutEffect } from 'react';
5-
import type { PropsWithChildren } from 'react';
5+
import type { PropsWithChildren, ReactNode } from 'react';
66
import { createPortal } from 'react-dom';
77
import cx from 'classnames';
88
import { TooltipPosition } from './tooltip.enums';
99

1010
interface TooltipProps {
11-
text: string;
11+
text: ReactNode;
1212
position?: TooltipPosition;
1313
maxWidth?: string;
1414
offset?: number;

0 commit comments

Comments
 (0)