@hsuite/native-connect-ui 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,8 +22,10 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
22
22
  import { Component, Input, computed } from '@angular/core';
23
23
  import { CommonModule } from '@angular/common';
24
24
  import { TranslocoModule } from '@ngneat/transloco';
25
+ import { LedgerIds } from '@hsuite/native-connect-types';
25
26
  import { copyToClipboard } from '../../utils/clipboard';
26
27
  import { getUILogger } from '../../utils/ui-logger';
28
+ import { formatLedgerAmount } from '../../utils/format-ledger-amount';
27
29
  const logger = getUILogger('HsTransferItem');
28
30
  import { IonicModule } from '@ionic/angular';
29
31
  let HsTransferItemComponent = class HsTransferItemComponent {
@@ -78,9 +80,13 @@ let HsTransferItemComponent = class HsTransferItemComponent {
78
80
  const numAmount = parseFloat(this.amount);
79
81
  if (isNaN(numAmount))
80
82
  return this.amount;
81
- // For HBAR, convert from tinybars
83
+ // For HBAR, render via the canonical formatter (display decimals + trailing-
84
+ // zero trim); the symbol is shown separately by typeLabel, so omit the suffix.
82
85
  if (this.type === 'hbar') {
83
- return (numAmount / 100000000).toFixed(8).replace(/\.?0+$/, '');
86
+ // HBAR is intrinsically Hedera's native unit, so the ledger is known at
87
+ // compile time here — expressed via the well-known-id constant rather
88
+ // than a bare 'hedera' literal (typo-safe, coherent with other sites).
89
+ return formatLedgerAmount(numAmount, LedgerIds.Hedera, { symbol: false });
84
90
  }
85
91
  // For tokens, use provided decimals
86
92
  if (this.type === 'token' && this.decimals !== undefined) {
@@ -17,6 +17,7 @@ import { Component, Input, Output, EventEmitter, signal, ChangeDetectionStrategy
17
17
  import { CommonModule } from '@angular/common';
18
18
  import { IonIcon } from '@ionic/angular/standalone';
19
19
  import { TranslocoModule } from '@ngneat/transloco';
20
+ import { formatLedgerAmount } from '../../utils/format-ledger-amount';
20
21
  import { addIcons } from 'ionicons';
21
22
  import { chevronDown, chevronUp, copy, openOutline, checkmark, arrowUp, arrowDown, swapHorizontal, arrowForward, linkOutline, addCircleOutline, flameOutline, personAddOutline, personOutline, personRemoveOutline, shieldCheckmarkOutline, shieldOutline, calendarOutline, createOutline, calendarClearOutline, chatbubblesOutline, chatbubbleOutline, documentOutline, settingsOutline, lockClosedOutline, lockOpenOutline, closeCircleOutline, gitBranchOutline, downloadOutline, documentTextOutline, cashOutline, peopleOutline, ticketOutline, imageOutline, pricetagOutline, checkmarkCircleOutline, layersOutline, helpCircleOutline, snowOutline, sunnyOutline, pauseCircleOutline, playCircleOutline, trashOutline, } from 'ionicons/icons';
22
23
  import { HsBadgeComponent } from '../../atoms/hs-badge/hs-badge.component';
@@ -344,18 +345,12 @@ let HsTransactionDetailComponent = class HsTransactionDetailComponent {
344
345
  return `${sign}${formatted}`;
345
346
  }
346
347
  formatNativeAmount(amount) {
347
- const divisor = this.ledgerId === 'hedera' ? 100000000 : 1000000; // tinybars or drops
348
- const value = amount / divisor;
349
- // Always show at least 2 decimals, up to 8 for small amounts
350
- if (Math.abs(value) < 0.01) {
351
- return `${value.toFixed(8)} ${this.currency}`;
352
- }
353
- else if (Math.abs(value) < 1) {
354
- return `${value.toFixed(6)} ${this.currency}`;
355
- }
356
- else {
357
- return `${value.toFixed(4)} ${this.currency}`;
358
- }
348
+ // Canonical formatter (decimals via the bundled-chain display map,
349
+ // trailing-zero trim, NaN/Infinity safety) replaces the previous hardcoded
350
+ // `ledgerId === 'hedera' ? 1e8 : 1e6` divisor + bespoke precision buckets.
351
+ // Display path — formatLedgerAmount stays lenient (returns '—' on an
352
+ // unknown ledger) and never throws.
353
+ return formatLedgerAmount(amount, this.ledgerId, { symbol: this.currency });
359
354
  }
360
355
  formatTokenAmount(amount, decimals = 0) {
361
356
  if (decimals === 0)
@@ -7,8 +7,94 @@
7
7
  * This file is part of HSuite Native Connect. For commercial licensing,
8
8
  * visit https://hsuite.finance/licensing
9
9
  */
10
- import { TrackByFunction, AfterViewInit, OnDestroy } from '@angular/core';
10
+ import { TrackByFunction, AfterViewInit, OnDestroy, EventEmitter } from '@angular/core';
11
11
  import { type RiskLevel, type RiskFactorInput } from '../../molecules/hs-risk-card/hs-risk-card.component';
12
+ /**
13
+ * @interface TransactionDetailData
14
+ * Canonical, read-only data shape for the transaction detail surface. It is the
15
+ * single source of truth shared by {@link HsTransactionDetailModalComponent}
16
+ * (which `implements` it, so the component is guaranteed to expose every field)
17
+ * and by host services/wrappers that drive it. Keeping one type prevents the
18
+ * field-drift that silently drops data when the component and its host disagree.
19
+ */
20
+ export interface TransactionDetailData {
21
+ /** Transaction ID/hash */
22
+ transactionId: string;
23
+ /** Display name for the transaction type */
24
+ transactionName: string;
25
+ /** Transaction type for icon selection */
26
+ transactionType: string;
27
+ /** Custom icon override */
28
+ transactionIcon?: string;
29
+ /** Ledger identifier */
30
+ ledgerId: 'hedera' | 'xrpl';
31
+ /** Transaction status */
32
+ status?: string;
33
+ /** Transaction timestamp */
34
+ timestamp?: number;
35
+ /** Main amount in smallest units (tinybars/drops) */
36
+ mainAmount?: number;
37
+ /** Currency symbol */
38
+ currency?: string;
39
+ /** Native currency transfers */
40
+ nativeTransfers?: Array<{
41
+ account: string;
42
+ amount: number;
43
+ label?: string;
44
+ }>;
45
+ /** Token transfers */
46
+ tokenTransfers?: Array<{
47
+ token_id: string;
48
+ account: string;
49
+ amount: number;
50
+ decimals?: number;
51
+ }>;
52
+ /** NFT transfers */
53
+ nftTransfers?: Array<{
54
+ token_id: string;
55
+ sender_account_id: string;
56
+ receiver_account_id: string;
57
+ serial_number: number;
58
+ }>;
59
+ /** Transaction fee in smallest units */
60
+ transactionFee?: number;
61
+ /** Transaction memo */
62
+ memo?: string;
63
+ /** XRPL source account */
64
+ sourceAccount?: string;
65
+ /** XRPL destination account */
66
+ destinationAccount?: string;
67
+ /** XRPL memos */
68
+ xrplMemos?: Array<{
69
+ data?: string;
70
+ type?: string;
71
+ format?: string;
72
+ }>;
73
+ /** User's own account addresses for highlighting */
74
+ userAccounts?: string[];
75
+ /** Token metadata for symbol/name resolution */
76
+ tokenMetadataMap?: Map<string, {
77
+ name?: string;
78
+ symbol?: string;
79
+ decimals?: number;
80
+ }>;
81
+ /** Contact resolver function */
82
+ contactResolver?: (address: string, ledgerId: string) => string | null;
83
+ /**
84
+ * Host handler for the "View in Explorer" action. The component is
85
+ * presentational and delegates the click here; the host builds the
86
+ * network-aware URL and opens it. When omitted, the button is hidden.
87
+ */
88
+ onExplorer?: (txId: string) => void;
89
+ /** Scam / suspicious-transaction risk level for this transaction. */
90
+ riskLevel?: RiskLevel;
91
+ /** Pre-translated risk factors shown in the banner (empty = none). */
92
+ riskFactors?: RiskFactorInput[];
93
+ /** Pre-translated recommendation line shown under the factors. */
94
+ riskRecommendation?: string | null;
95
+ /** Banner title (already localized, e.g. the high/medium badge label). */
96
+ riskTitle?: string;
97
+ }
12
98
  /**
13
99
  * @component HsTransactionDetailModalComponent
14
100
  * Modal component for displaying comprehensive transaction details.
@@ -22,7 +108,7 @@ import { type RiskLevel, type RiskFactorInput } from '../../molecules/hs-risk-ca
22
108
  * - Explorer link integration
23
109
  * - Contact name resolution
24
110
  */
25
- export declare class HsTransactionDetailModalComponent implements AfterViewInit, OnDestroy {
111
+ export declare class HsTransactionDetailModalComponent implements AfterViewInit, OnDestroy, TransactionDetailData {
26
112
  /**
27
113
  * Hedera system accounts that must NEVER appear in the Native Transfers
28
114
  * section of the UI. Union of node-fee accounts (0.0.3..0.0.34) and the
@@ -82,11 +168,26 @@ export declare class HsTransactionDetailModalComponent implements AfterViewInit,
82
168
  decimals?: number;
83
169
  }>;
84
170
  contactResolver?: (address: string, ledgerId: string) => string | null;
171
+ /**
172
+ * Host-provided handler for the "View in Explorer" action. The modal is a
173
+ * presentational component and must NOT know explorer domains or networks, so
174
+ * it delegates: the host builds the network-aware URL (mainnet vs testnet) and
175
+ * opens it via its SOC2-safe external-URL service. Called with the tx id/hash.
176
+ * When omitted, the "View in Explorer" button is hidden.
177
+ */
178
+ onExplorer?: (txId: string) => void;
85
179
  maxDisplayedTransfers: number;
86
180
  riskLevel?: RiskLevel;
87
181
  riskFactors: RiskFactorInput[];
88
182
  riskRecommendation?: string | null;
89
183
  riskTitle: string;
184
+ /**
185
+ * Emitted when the user requests to close this view. When a host binds this
186
+ * output (e.g. a drawer wrapper), it takes over closing and the component
187
+ * does NOT call ModalController — keeping the component container-agnostic.
188
+ * Unbound (the legacy modal path), `dismiss()` falls back to ModalController.
189
+ */
190
+ closeRequested: EventEmitter<void>;
90
191
  /**
91
192
  * Whether to render the scam-risk banner. Only medium/high verdicts surface a
92
193
  * banner; `'low'`/absent stays silent so benign transactions are visually
@@ -14,7 +14,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
14
14
  return c > 3 && r && Object.defineProperty(target, key, r), r;
15
15
  };
16
16
  var HsTransactionDetailModalComponent_1;
17
- import { Component, Input, signal, inject, ChangeDetectionStrategy, ElementRef, HostListener, } from '@angular/core';
17
+ import { Component, Input, signal, inject, ChangeDetectionStrategy, ElementRef, HostListener, Output, EventEmitter, } from '@angular/core';
18
18
  import { CommonModule, DOCUMENT } from '@angular/common';
19
19
  import { IonContent, IonFooter, IonToolbar, IonButton, IonIcon, ModalController, } from '@ionic/angular/standalone';
20
20
  import { addIcons } from 'ionicons';
@@ -82,6 +82,14 @@ let HsTransactionDetailModalComponent = class HsTransactionDetailModalComponent
82
82
  tokenMetadataMap;
83
83
  // Contact resolver
84
84
  contactResolver;
85
+ /**
86
+ * Host-provided handler for the "View in Explorer" action. The modal is a
87
+ * presentational component and must NOT know explorer domains or networks, so
88
+ * it delegates: the host builds the network-aware URL (mainnet vs testnet) and
89
+ * opens it via its SOC2-safe external-URL service. Called with the tx id/hash.
90
+ * When omitted, the "View in Explorer" button is hidden.
91
+ */
92
+ onExplorer;
85
93
  maxDisplayedTransfers = 10;
86
94
  // Scam / suspicious-transaction risk assessment.
87
95
  // Surfaced as a banner at the top of the modal so a user who taps into a
@@ -93,6 +101,13 @@ let HsTransactionDetailModalComponent = class HsTransactionDetailModalComponent
93
101
  riskFactors = [];
94
102
  riskRecommendation;
95
103
  riskTitle = 'Risk assessment';
104
+ /**
105
+ * Emitted when the user requests to close this view. When a host binds this
106
+ * output (e.g. a drawer wrapper), it takes over closing and the component
107
+ * does NOT call ModalController — keeping the component container-agnostic.
108
+ * Unbound (the legacy modal path), `dismiss()` falls back to ModalController.
109
+ */
110
+ closeRequested = new EventEmitter();
96
111
  /**
97
112
  * Whether to render the scam-risk banner. Only medium/high verdicts surface a
98
113
  * banner; `'low'`/absent stays silent so benign transactions are visually
@@ -513,22 +528,20 @@ let HsTransactionDetailModalComponent = class HsTransactionDetailModalComponent
513
528
  }
514
529
  }
515
530
  async dismiss() {
531
+ // Container-agnostic close: if a host (e.g. drawer) listens, let it close;
532
+ // otherwise fall back to the Ionic modal path (legacy callers).
533
+ if (this.closeRequested.observed) {
534
+ this.closeRequested.emit();
535
+ return;
536
+ }
516
537
  await this.modalController.dismiss(null, 'cancel');
517
538
  }
518
539
  onExplorerClick() {
519
- const txId = this.transactionId || '';
520
- let explorerUrl = '';
521
- if (this.ledgerId === 'hedera') {
522
- // Default to testnet, could be enhanced with networkId input
523
- explorerUrl = `https://hashscan.io/testnet/transaction/${txId}`;
524
- }
525
- else if (this.ledgerId === 'xrpl') {
526
- explorerUrl = `https://testnet.xrpl.org/transactions/${txId}`;
527
- }
528
- if (explorerUrl) {
529
- // §29.8 SOC2 CC6.1: noopener,noreferrer blocks reverse-tabnabbing.
530
- window.open(explorerUrl, '_blank', 'noopener,noreferrer');
531
- }
540
+ if (!this.transactionId)
541
+ return;
542
+ // Delegate to the host: it knows the network and opens the link through its
543
+ // SOC2-safe external-URL service. The modal never hardcodes a network.
544
+ this.onExplorer?.(this.transactionId);
532
545
  }
533
546
  };
534
547
  __decorate([
@@ -591,6 +604,9 @@ __decorate([
591
604
  __decorate([
592
605
  Input()
593
606
  ], HsTransactionDetailModalComponent.prototype, "contactResolver", void 0);
607
+ __decorate([
608
+ Input()
609
+ ], HsTransactionDetailModalComponent.prototype, "onExplorer", void 0);
594
610
  __decorate([
595
611
  Input()
596
612
  ], HsTransactionDetailModalComponent.prototype, "maxDisplayedTransfers", void 0);
@@ -606,6 +622,9 @@ __decorate([
606
622
  __decorate([
607
623
  Input()
608
624
  ], HsTransactionDetailModalComponent.prototype, "riskTitle", void 0);
625
+ __decorate([
626
+ Output()
627
+ ], HsTransactionDetailModalComponent.prototype, "closeRequested", void 0);
609
628
  __decorate([
610
629
  HostListener('document:keydown.escape', ['$event'])
611
630
  ], HsTransactionDetailModalComponent.prototype, "onEscapeKey", null);
@@ -8,12 +8,14 @@
8
8
  * visit https://hsuite.finance/licensing
9
9
  */
10
10
  /**
11
- * @file Canonical formatter for HBAR / XRP rendering across the wallet UI.
11
+ * @file Canonical formatter for native-unit amounts across the wallet UI
12
+ * (HBAR, XRP, and any SDK-registered ledger when its decimals are supplied).
12
13
  *
13
14
  * Callers MUST pass raw base units — tinybars for Hedera, drops for XRPL.
14
15
  * Handles `bigint` exactly (no precision loss above `Number.MAX_SAFE_INTEGER`)
15
16
  * and returns a safe placeholder for `NaN` / `Infinity` so a malformed input
16
- * cannot leak literal "NaN" text into the UI.
17
+ * cannot leak literal "NaN" text into the UI. Returns the `—` sentinel for an
18
+ * unknown ledger when no `decimals` override is supplied via options.
17
19
  */
18
20
  export type LedgerId = 'hedera' | 'xrpl';
19
21
  export interface FormatLedgerAmountOptions {
@@ -25,5 +27,12 @@ export interface FormatLedgerAmountOptions {
25
27
  * xrpl).
26
28
  */
27
29
  symbol?: string | false;
30
+ /**
31
+ * Authoritative decimal places for this ledger's native unit (e.g. from the
32
+ * SDK registry's `getNativeDecimals`). When provided it OVERRIDES the built-in
33
+ * display-default map, so any ledger — including ones not bundled here — formats
34
+ * correctly. Omit to fall back to the display-only default for known chains.
35
+ */
36
+ decimals?: number;
28
37
  }
29
- export declare function formatLedgerAmount(rawBaseUnits: number | bigint, ledgerId: LedgerId, options?: FormatLedgerAmountOptions): string;
38
+ export declare function formatLedgerAmount(rawBaseUnits: number | bigint, ledgerId: LedgerId | (string & {}), options?: FormatLedgerAmountOptions): string;
@@ -7,6 +7,10 @@
7
7
  * This file is part of HSuite Native Connect. For commercial licensing,
8
8
  * visit https://hsuite.finance/licensing
9
9
  */
10
+ // Display-only last-resort decimals. The authoritative value is the adapter
11
+ // manifest's nativeDecimals (SDK registry); money-adjacent callers SHOULD pass
12
+ // it explicitly via options.decimals. This map only covers the bundled chains
13
+ // so the formatter still renders something sane when no override is supplied.
10
14
  const DECIMALS = { hedera: 8, xrpl: 6 };
11
15
  const DEFAULT_SYMBOL = { hedera: 'ℏ', xrpl: 'XRP' };
12
16
  /**
@@ -21,11 +25,18 @@ function bigintToFixedString(abs, decimals) {
21
25
  return `${whole}.${frac.toString().padStart(decimals, '0')}`;
22
26
  }
23
27
  export function formatLedgerAmount(rawBaseUnits, ledgerId, options = {}) {
24
- if (!(ledgerId in DECIMALS)) {
25
- throw new Error(`formatLedgerAmount: unknown ledgerId "${ledgerId}"`);
28
+ // Authoritative override wins; otherwise fall back to the display-only default.
29
+ const decimals = options.decimals ?? DECIMALS[ledgerId];
30
+ // Unknown ledger with no explicit decimals — or an out-of-range override — cannot
31
+ // be scaled safely. Return the display "unavailable" sentinel rather than NaN or a
32
+ // thrown error: this is a display path (≠ money path), so it must stay lenient. The
33
+ // upper bound of 100 keeps `decimals` inside `Number.prototype.toFixed`'s legal
34
+ // range and prevents a pathological `10n ** BigInt(decimals)` from hanging the
35
+ // render — no real ledger exceeds ~18 native decimals.
36
+ if (decimals === undefined || !Number.isInteger(decimals) || decimals < 0 || decimals > 100) {
37
+ return '—';
26
38
  }
27
- const decimals = DECIMALS[ledgerId];
28
- const symbol = options.symbol === false ? '' : (options.symbol ?? DEFAULT_SYMBOL[ledgerId]);
39
+ const symbol = options.symbol === false ? '' : (options.symbol ?? DEFAULT_SYMBOL[ledgerId] ?? '');
29
40
  const suffix = symbol ? ` ${symbol}` : '';
30
41
  // Build the signed magnitude string. Bigint stays in bigint space so XRPL
31
42
  // total-supply-sized amounts (>2^53 drops) format exactly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hsuite/native-connect-ui",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "license": "PolyForm-Noncommercial-1.0.0",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -27,7 +27,7 @@
27
27
  "@angular/core": "^20.3.16",
28
28
  "@angular/forms": "^20.3.16",
29
29
  "@angular/router": "^20.3.16",
30
- "@hsuite/native-connect-types": "^2.1.0",
30
+ "@hsuite/native-connect-types": "^2.1.1",
31
31
  "@ionic/angular": "^8.0.0",
32
32
  "@ngneat/transloco": "^6.0.0",
33
33
  "animejs": "^3.2.2",
@@ -17,8 +17,10 @@
17
17
  import { Component, Input, computed } from '@angular/core';
18
18
  import { CommonModule } from '@angular/common';
19
19
  import { TranslocoModule } from '@ngneat/transloco';
20
+ import { LedgerIds } from '@hsuite/native-connect-types';
20
21
  import { copyToClipboard } from '../../utils/clipboard';
21
22
  import { getUILogger } from '../../utils/ui-logger';
23
+ import { formatLedgerAmount } from '../../utils/format-ledger-amount';
22
24
 
23
25
  const logger = getUILogger('HsTransferItem');
24
26
  import { IonicModule } from '@ionic/angular';
@@ -93,9 +95,13 @@ export class HsTransferItemComponent {
93
95
  const numAmount = parseFloat(this.amount);
94
96
  if (isNaN(numAmount)) return this.amount;
95
97
 
96
- // For HBAR, convert from tinybars
98
+ // For HBAR, render via the canonical formatter (display decimals + trailing-
99
+ // zero trim); the symbol is shown separately by typeLabel, so omit the suffix.
97
100
  if (this.type === 'hbar') {
98
- return (numAmount / 100000000).toFixed(8).replace(/\.?0+$/, '');
101
+ // HBAR is intrinsically Hedera's native unit, so the ledger is known at
102
+ // compile time here — expressed via the well-known-id constant rather
103
+ // than a bare 'hedera' literal (typo-safe, coherent with other sites).
104
+ return formatLedgerAmount(numAmount, LedgerIds.Hedera, { symbol: false });
99
105
  }
100
106
 
101
107
  // For tokens, use provided decimals
@@ -23,6 +23,7 @@ import {
23
23
  import { CommonModule } from '@angular/common';
24
24
  import { IonIcon } from '@ionic/angular/standalone';
25
25
  import { TranslocoModule } from '@ngneat/transloco';
26
+ import { formatLedgerAmount } from '../../utils/format-ledger-amount';
26
27
  import { addIcons } from 'ionicons';
27
28
  import {
28
29
  chevronDown,
@@ -433,17 +434,12 @@ export class HsTransactionDetailComponent implements OnInit, OnChanges {
433
434
  }
434
435
 
435
436
  formatNativeAmount(amount: number): string {
436
- const divisor = this.ledgerId === 'hedera' ? 100000000 : 1000000; // tinybars or drops
437
- const value = amount / divisor;
438
-
439
- // Always show at least 2 decimals, up to 8 for small amounts
440
- if (Math.abs(value) < 0.01) {
441
- return `${value.toFixed(8)} ${this.currency}`;
442
- } else if (Math.abs(value) < 1) {
443
- return `${value.toFixed(6)} ${this.currency}`;
444
- } else {
445
- return `${value.toFixed(4)} ${this.currency}`;
446
- }
437
+ // Canonical formatter (decimals via the bundled-chain display map,
438
+ // trailing-zero trim, NaN/Infinity safety) replaces the previous hardcoded
439
+ // `ledgerId === 'hedera' ? 1e8 : 1e6` divisor + bespoke precision buckets.
440
+ // Display path formatLedgerAmount stays lenient (returns '—' on an
441
+ // unknown ledger) and never throws.
442
+ return formatLedgerAmount(amount, this.ledgerId, { symbol: this.currency });
447
443
  }
448
444
 
449
445
  formatTokenAmount(amount: number, decimals: number = 0): string {
@@ -267,7 +267,12 @@
267
267
  <!-- Footer with Explorer Action -->
268
268
  <ion-footer class="tx-modal-footer">
269
269
  <ion-toolbar>
270
- <ion-button expand="block" fill="outline" (click)="onExplorerClick()" *ngIf="transactionId">
270
+ <ion-button
271
+ expand="block"
272
+ fill="outline"
273
+ (click)="onExplorerClick()"
274
+ *ngIf="transactionId && onExplorer"
275
+ >
271
276
  <ion-icon slot="start" name="open-outline"></ion-icon>
272
277
  View in Explorer
273
278
  </ion-button>
@@ -174,4 +174,30 @@ describe('HsTransactionDetailModalComponent', () => {
174
174
  expect(component.showRiskBanner).toBe(false);
175
175
  });
176
176
  });
177
+
178
+ describe('onExplorerClick', () => {
179
+ it('delegates to the onExplorer callback with the transaction id (no hardcoded network)', () => {
180
+ const onExplorer = vi.fn();
181
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
182
+ component.transactionId = '0.0.55@1700000000.000000000';
183
+ component.ledgerId = 'hedera';
184
+ component.onExplorer = onExplorer;
185
+
186
+ component.onExplorerClick();
187
+
188
+ // The modal must delegate to the host (which builds the network-aware
189
+ // URL), not open a hardcoded-testnet link itself.
190
+ expect(onExplorer).toHaveBeenCalledWith('0.0.55@1700000000.000000000');
191
+ expect(openSpy).not.toHaveBeenCalled();
192
+ openSpy.mockRestore();
193
+ });
194
+
195
+ it('does nothing when no onExplorer handler is provided', () => {
196
+ component.transactionId = '0.0.55@1700000000.000000000';
197
+ component.ledgerId = 'hedera';
198
+ component.onExplorer = undefined;
199
+
200
+ expect(() => component.onExplorerClick()).not.toThrow();
201
+ });
202
+ });
177
203
  });
@@ -19,6 +19,8 @@ import {
19
19
  OnDestroy,
20
20
  ElementRef,
21
21
  HostListener,
22
+ Output,
23
+ EventEmitter,
22
24
  } from '@angular/core';
23
25
  import { CommonModule, DOCUMENT } from '@angular/common';
24
26
  import {
@@ -87,6 +89,76 @@ import {
87
89
  } from '../../molecules/hs-risk-card/hs-risk-card.component';
88
90
  import { formatLedgerAmount, type LedgerId } from '../../utils/format-ledger-amount';
89
91
 
92
+ /**
93
+ * @interface TransactionDetailData
94
+ * Canonical, read-only data shape for the transaction detail surface. It is the
95
+ * single source of truth shared by {@link HsTransactionDetailModalComponent}
96
+ * (which `implements` it, so the component is guaranteed to expose every field)
97
+ * and by host services/wrappers that drive it. Keeping one type prevents the
98
+ * field-drift that silently drops data when the component and its host disagree.
99
+ */
100
+ export interface TransactionDetailData {
101
+ /** Transaction ID/hash */
102
+ transactionId: string;
103
+ /** Display name for the transaction type */
104
+ transactionName: string;
105
+ /** Transaction type for icon selection */
106
+ transactionType: string;
107
+ /** Custom icon override */
108
+ transactionIcon?: string;
109
+ /** Ledger identifier */
110
+ ledgerId: 'hedera' | 'xrpl';
111
+ /** Transaction status */
112
+ status?: string;
113
+ /** Transaction timestamp */
114
+ timestamp?: number;
115
+ /** Main amount in smallest units (tinybars/drops) */
116
+ mainAmount?: number;
117
+ /** Currency symbol */
118
+ currency?: string;
119
+ /** Native currency transfers */
120
+ nativeTransfers?: Array<{ account: string; amount: number; label?: string }>;
121
+ /** Token transfers */
122
+ tokenTransfers?: Array<{ token_id: string; account: string; amount: number; decimals?: number }>;
123
+ /** NFT transfers */
124
+ nftTransfers?: Array<{
125
+ token_id: string;
126
+ sender_account_id: string;
127
+ receiver_account_id: string;
128
+ serial_number: number;
129
+ }>;
130
+ /** Transaction fee in smallest units */
131
+ transactionFee?: number;
132
+ /** Transaction memo */
133
+ memo?: string;
134
+ /** XRPL source account */
135
+ sourceAccount?: string;
136
+ /** XRPL destination account */
137
+ destinationAccount?: string;
138
+ /** XRPL memos */
139
+ xrplMemos?: Array<{ data?: string; type?: string; format?: string }>;
140
+ /** User's own account addresses for highlighting */
141
+ userAccounts?: string[];
142
+ /** Token metadata for symbol/name resolution */
143
+ tokenMetadataMap?: Map<string, { name?: string; symbol?: string; decimals?: number }>;
144
+ /** Contact resolver function */
145
+ contactResolver?: (address: string, ledgerId: string) => string | null;
146
+ /**
147
+ * Host handler for the "View in Explorer" action. The component is
148
+ * presentational and delegates the click here; the host builds the
149
+ * network-aware URL and opens it. When omitted, the button is hidden.
150
+ */
151
+ onExplorer?: (txId: string) => void;
152
+ /** Scam / suspicious-transaction risk level for this transaction. */
153
+ riskLevel?: RiskLevel;
154
+ /** Pre-translated risk factors shown in the banner (empty = none). */
155
+ riskFactors?: RiskFactorInput[];
156
+ /** Pre-translated recommendation line shown under the factors. */
157
+ riskRecommendation?: string | null;
158
+ /** Banner title (already localized, e.g. the high/medium badge label). */
159
+ riskTitle?: string;
160
+ }
161
+
90
162
  /**
91
163
  * @component HsTransactionDetailModalComponent
92
164
  * Modal component for displaying comprehensive transaction details.
@@ -119,7 +191,9 @@ import { formatLedgerAmount, type LedgerId } from '../../utils/format-ledger-amo
119
191
  styleUrls: ['./hs-transaction-detail-modal.component.scss'],
120
192
  changeDetection: ChangeDetectionStrategy.OnPush,
121
193
  })
122
- export class HsTransactionDetailModalComponent implements AfterViewInit, OnDestroy {
194
+ export class HsTransactionDetailModalComponent
195
+ implements AfterViewInit, OnDestroy, TransactionDetailData
196
+ {
123
197
  /**
124
198
  * Hedera system accounts that must NEVER appear in the Native Transfers
125
199
  * section of the UI. Union of node-fee accounts (0.0.3..0.0.34) and the
@@ -180,6 +254,15 @@ export class HsTransactionDetailModalComponent implements AfterViewInit, OnDestr
180
254
  // Contact resolver
181
255
  @Input() contactResolver?: (address: string, ledgerId: string) => string | null;
182
256
 
257
+ /**
258
+ * Host-provided handler for the "View in Explorer" action. The modal is a
259
+ * presentational component and must NOT know explorer domains or networks, so
260
+ * it delegates: the host builds the network-aware URL (mainnet vs testnet) and
261
+ * opens it via its SOC2-safe external-URL service. Called with the tx id/hash.
262
+ * When omitted, the "View in Explorer" button is hidden.
263
+ */
264
+ @Input() onExplorer?: (txId: string) => void;
265
+
183
266
  @Input() maxDisplayedTransfers = 10;
184
267
 
185
268
  // Scam / suspicious-transaction risk assessment.
@@ -193,6 +276,14 @@ export class HsTransactionDetailModalComponent implements AfterViewInit, OnDestr
193
276
  @Input() riskRecommendation?: string | null;
194
277
  @Input() riskTitle = 'Risk assessment';
195
278
 
279
+ /**
280
+ * Emitted when the user requests to close this view. When a host binds this
281
+ * output (e.g. a drawer wrapper), it takes over closing and the component
282
+ * does NOT call ModalController — keeping the component container-agnostic.
283
+ * Unbound (the legacy modal path), `dismiss()` falls back to ModalController.
284
+ */
285
+ @Output() closeRequested = new EventEmitter<void>();
286
+
196
287
  /**
197
288
  * Whether to render the scam-risk banner. Only medium/high verdicts surface a
198
289
  * banner; `'low'`/absent stays silent so benign transactions are visually
@@ -652,23 +743,19 @@ export class HsTransactionDetailModalComponent implements AfterViewInit, OnDestr
652
743
  }
653
744
 
654
745
  async dismiss(): Promise<void> {
746
+ // Container-agnostic close: if a host (e.g. drawer) listens, let it close;
747
+ // otherwise fall back to the Ionic modal path (legacy callers).
748
+ if (this.closeRequested.observed) {
749
+ this.closeRequested.emit();
750
+ return;
751
+ }
655
752
  await this.modalController.dismiss(null, 'cancel');
656
753
  }
657
754
 
658
755
  onExplorerClick(): void {
659
- const txId = this.transactionId || '';
660
- let explorerUrl = '';
661
-
662
- if (this.ledgerId === 'hedera') {
663
- // Default to testnet, could be enhanced with networkId input
664
- explorerUrl = `https://hashscan.io/testnet/transaction/${txId}`;
665
- } else if (this.ledgerId === 'xrpl') {
666
- explorerUrl = `https://testnet.xrpl.org/transactions/${txId}`;
667
- }
668
-
669
- if (explorerUrl) {
670
- // §29.8 SOC2 CC6.1: noopener,noreferrer blocks reverse-tabnabbing.
671
- window.open(explorerUrl, '_blank', 'noopener,noreferrer');
672
- }
756
+ if (!this.transactionId) return;
757
+ // Delegate to the host: it knows the network and opens the link through its
758
+ // SOC2-safe external-URL service. The modal never hardcodes a network.
759
+ this.onExplorer?.(this.transactionId);
673
760
  }
674
761
  }
@@ -86,8 +86,41 @@ describe('formatLedgerAmount', () => {
86
86
  expect(formatLedgerAmount(NaN, 'hedera', { symbol: false })).toBe('?');
87
87
  });
88
88
 
89
- it('unknown ledgerId throws with descriptive message', () => {
90
- expect(() => formatLedgerAmount(1, 'hbar' as unknown as LedgerId)).toThrow(/unknown ledgerId/);
89
+ it('unknown ledgerId without decimals returns the — sentinel (no throw, no NaN)', () => {
90
+ const out = formatLedgerAmount(1, 'hbar' as LedgerId);
91
+ expect(out).toBe('—');
92
+ expect(out).not.toMatch(/NaN/);
93
+ });
94
+
95
+ it('explicit decimals override formats an unknown ledger correctly', () => {
96
+ expect(
97
+ formatLedgerAmount(1_500_000_000n, 'solana' as LedgerId, { decimals: 9, symbol: 'SOL' }),
98
+ ).toBe('1.5 SOL');
99
+ });
100
+
101
+ it('explicit decimals override beats the built-in default for a known ledger', () => {
102
+ expect(formatLedgerAmount(1000n, 'hedera', { decimals: 3, symbol: false })).toBe('1');
103
+ });
104
+
105
+ it('invalid explicit decimals (negative) returns — sentinel without throwing', () => {
106
+ expect(formatLedgerAmount(100n, 'hedera', { decimals: -1 })).toBe('—');
107
+ });
108
+
109
+ it('invalid explicit decimals (non-integer) returns — sentinel without throwing', () => {
110
+ expect(formatLedgerAmount(100n, 'hedera', { decimals: 1.5 })).toBe('—');
111
+ });
112
+
113
+ it('decimals:0 formats whole units (valid — must NOT hit the sentinel)', () => {
114
+ expect(formatLedgerAmount(1500n, 'solana' as LedgerId, { decimals: 0, symbol: 'TOKEN' })).toBe(
115
+ '1500 TOKEN',
116
+ );
117
+ });
118
+
119
+ it('out-of-range decimals (>100) returns — sentinel without throwing or hanging', () => {
120
+ // .toFixed(>100) throws RangeError and 10n ** BigInt(1e9) would hang the render;
121
+ // the upper-bound guard must turn both into the lenient sentinel instead.
122
+ expect(formatLedgerAmount(100, 'hedera', { decimals: 1e9 })).toBe('—');
123
+ expect(formatLedgerAmount(100n, 'hedera', { decimals: 1e9 })).toBe('—');
91
124
  });
92
125
 
93
126
  it('xrpl with custom symbol override and signed', () => {
@@ -9,12 +9,14 @@
9
9
  */
10
10
 
11
11
  /**
12
- * @file Canonical formatter for HBAR / XRP rendering across the wallet UI.
12
+ * @file Canonical formatter for native-unit amounts across the wallet UI
13
+ * (HBAR, XRP, and any SDK-registered ledger when its decimals are supplied).
13
14
  *
14
15
  * Callers MUST pass raw base units — tinybars for Hedera, drops for XRPL.
15
16
  * Handles `bigint` exactly (no precision loss above `Number.MAX_SAFE_INTEGER`)
16
17
  * and returns a safe placeholder for `NaN` / `Infinity` so a malformed input
17
- * cannot leak literal "NaN" text into the UI.
18
+ * cannot leak literal "NaN" text into the UI. Returns the `—` sentinel for an
19
+ * unknown ledger when no `decimals` override is supplied via options.
18
20
  */
19
21
 
20
22
  export type LedgerId = 'hedera' | 'xrpl';
@@ -28,10 +30,21 @@ export interface FormatLedgerAmountOptions {
28
30
  * xrpl).
29
31
  */
30
32
  symbol?: string | false;
33
+ /**
34
+ * Authoritative decimal places for this ledger's native unit (e.g. from the
35
+ * SDK registry's `getNativeDecimals`). When provided it OVERRIDES the built-in
36
+ * display-default map, so any ledger — including ones not bundled here — formats
37
+ * correctly. Omit to fall back to the display-only default for known chains.
38
+ */
39
+ decimals?: number;
31
40
  }
32
41
 
33
- const DECIMALS: Record<LedgerId, number> = { hedera: 8, xrpl: 6 };
34
- const DEFAULT_SYMBOL: Record<LedgerId, string> = { hedera: 'ℏ', xrpl: 'XRP' };
42
+ // Display-only last-resort decimals. The authoritative value is the adapter
43
+ // manifest's nativeDecimals (SDK registry); money-adjacent callers SHOULD pass
44
+ // it explicitly via options.decimals. This map only covers the bundled chains
45
+ // so the formatter still renders something sane when no override is supplied.
46
+ const DECIMALS: Record<string, number> = { hedera: 8, xrpl: 6 };
47
+ const DEFAULT_SYMBOL: Record<string, string> = { hedera: 'ℏ', xrpl: 'XRP' };
35
48
 
36
49
  /**
37
50
  * Convert a non-negative bigint to a fixed-decimal string without losing
@@ -47,15 +60,23 @@ function bigintToFixedString(abs: bigint, decimals: number): string {
47
60
 
48
61
  export function formatLedgerAmount(
49
62
  rawBaseUnits: number | bigint,
50
- ledgerId: LedgerId,
63
+ ledgerId: LedgerId | (string & {}),
51
64
  options: FormatLedgerAmountOptions = {},
52
65
  ): string {
53
- if (!(ledgerId in DECIMALS)) {
54
- throw new Error(`formatLedgerAmount: unknown ledgerId "${ledgerId}"`);
66
+ // Authoritative override wins; otherwise fall back to the display-only default.
67
+ const decimals = options.decimals ?? DECIMALS[ledgerId];
68
+
69
+ // Unknown ledger with no explicit decimals — or an out-of-range override — cannot
70
+ // be scaled safely. Return the display "unavailable" sentinel rather than NaN or a
71
+ // thrown error: this is a display path (≠ money path), so it must stay lenient. The
72
+ // upper bound of 100 keeps `decimals` inside `Number.prototype.toFixed`'s legal
73
+ // range and prevents a pathological `10n ** BigInt(decimals)` from hanging the
74
+ // render — no real ledger exceeds ~18 native decimals.
75
+ if (decimals === undefined || !Number.isInteger(decimals) || decimals < 0 || decimals > 100) {
76
+ return '—';
55
77
  }
56
78
 
57
- const decimals = DECIMALS[ledgerId];
58
- const symbol = options.symbol === false ? '' : (options.symbol ?? DEFAULT_SYMBOL[ledgerId]);
79
+ const symbol = options.symbol === false ? '' : (options.symbol ?? DEFAULT_SYMBOL[ledgerId] ?? '');
59
80
  const suffix = symbol ? ` ${symbol}` : '';
60
81
 
61
82
  // Build the signed magnitude string. Bigint stays in bigint space so XRPL