From 1996e056a338fca931199e687005c77a121fd761 Mon Sep 17 00:00:00 2001 From: Hallab Date: Sun, 26 Jul 2026 04:15:05 +0100 Subject: [PATCH] feat(stellar): add expert deep links --- src/App.tsx | 3 + src/components/ActivityRow.tsx | 48 ++++++++++----- src/components/CopyButton.tsx | 2 + src/components/FreighterConnectButton.tsx | 18 +++++- src/components/StellarHistory.tsx | 37 ++++++------ src/components/StellarLink.tsx | 48 +++++++++++++++ src/components/StellarMatchCard.tsx | 28 +++------ src/components/StellarReceive.tsx | 26 +++----- src/components/StellarReceiveView.tsx | 11 +--- src/components/StellarSend.tsx | 33 ++++------- src/components/StellarSendView.tsx | 33 ++++------- src/components/StellarVaultClaim.tsx | 21 +++---- src/components/StellarVaultDeposit.tsx | 11 ++-- src/components/StellarWalletButton.tsx | 45 +++++++------- src/components/VaultStatusTable.tsx | 21 +++---- src/components/WalletConnect.tsx | 17 +++++- src/config.ts | 1 - src/lib/explorer.ts | 8 +-- src/pages/Debug.tsx | 69 ++++++++++++++++++++++ src/pages/StellarSplit.tsx | 20 +++---- src/utils/stellarExpert.test.ts | 34 +++++++++++ src/utils/stellarExpert.ts | 72 +++++++++++++++++++++++ 22 files changed, 411 insertions(+), 195 deletions(-) create mode 100644 src/components/StellarLink.tsx create mode 100644 src/pages/Debug.tsx create mode 100644 src/utils/stellarExpert.test.ts create mode 100644 src/utils/stellarExpert.ts diff --git a/src/App.tsx b/src/App.tsx index df500fb..114be4e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import Schedule from '@/pages/Schedule'; import StellarSplit from '@/pages/StellarSplit'; import Names from '@/pages/Names'; import Activity from '@/pages/Activity'; +import Debug from '@/pages/Debug'; export function App() { return ( @@ -29,6 +30,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/src/components/ActivityRow.tsx b/src/components/ActivityRow.tsx index 52a33b5..cc0baa1 100644 --- a/src/components/ActivityRow.tsx +++ b/src/components/ActivityRow.tsx @@ -1,11 +1,14 @@ import { ActivityEntry } from '@/stores/activityStore'; import { stellarTxUrl, horizenTxUrl, solanaTxUrl, ckbTxUrl } from '@/lib/explorer'; +import { StellarLink } from '@/components/StellarLink'; interface ActivityRowProps { entry: ActivityEntry; } export function ActivityRow({ entry }: ActivityRowProps) { + const isStellarAccount = + entry.chain === 'stellar' && !!entry.recipient && /^[GM][A-Z2-7]+$/.test(entry.recipient); const getExplorerUrl = () => { switch (entry.chain) { case 'stellar': @@ -115,14 +118,23 @@ export function ActivityRow({ entry }: ActivityRowProps) { Recipient / Address - - {entry.recipient.length > 30 - ? `${entry.recipient.slice(0, 12)}...${entry.recipient.slice(-12)}` - : entry.recipient} - + {isStellarAccount ? ( + + ) : ( + + {entry.recipient.length > 30 + ? `${entry.recipient.slice(0, 12)}...${entry.recipient.slice(-12)}` + : entry.recipient} + + )} )} @@ -131,14 +143,18 @@ export function ActivityRow({ entry }: ActivityRowProps) { Hash - - {entry.id.slice(0, 12)}...{entry.id.slice(-12)} - + {entry.chain === 'stellar' ? ( + + ) : ( + + {entry.id.slice(0, 12)}...{entry.id.slice(-12)} + + )} )} diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index 59dfd19..c79ea2c 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -6,6 +6,8 @@ export function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); return ( +
+ + {address.slice(0, 4)}...{address.slice(-4)} + + +
); } diff --git a/src/components/StellarHistory.tsx b/src/components/StellarHistory.tsx index 4c24f4f..b65c1ef 100644 --- a/src/components/StellarHistory.tsx +++ b/src/components/StellarHistory.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useStellarWallet } from '@/context/StellarWalletContext'; -import { stellarTxUrl } from '@/lib/explorer'; import { useActivityStore, ActivityKind, ActivityStatus } from '@/stores/activityStore'; import { EmptyState } from '@/components/EmptyState'; +import { StellarLink } from '@/components/StellarLink'; +/** @deprecated Use the `/activity` page. Retained for existing imports. */ export function StellarHistory() { const navigate = useNavigate(); const { address, isConnected } = useStellarWallet(); @@ -186,14 +187,23 @@ export function StellarHistory() { Recipient / Address - - {tx.recipient.length > 30 - ? `${tx.recipient.slice(0, 12)}...${tx.recipient.slice(-12)}` - : tx.recipient} - + {/^[GM][A-Z2-7]+$/.test(tx.recipient) ? ( + + ) : ( + + {tx.recipient.length > 30 + ? `${tx.recipient.slice(0, 12)}...${tx.recipient.slice(-12)}` + : tx.recipient} + + )} )} @@ -202,14 +212,7 @@ export function StellarHistory() { Hash - - {tx.id.slice(0, 12)}...{tx.id.slice(-12)} - + )} diff --git a/src/components/StellarLink.tsx b/src/components/StellarLink.tsx new file mode 100644 index 0000000..0585b0c --- /dev/null +++ b/src/components/StellarLink.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from 'react'; +import { CopyButton } from '@/components/CopyButton'; +import { + stellarExpertUrl, + type StellarExpertNetworkInput, + type StellarExpertResource, +} from '@/utils/stellarExpert'; + +interface StellarLinkProps { + value: string; + type: StellarExpertResource; + network?: StellarExpertNetworkInput; + children?: ReactNode; + className?: string; + linkClassName?: string; +} + +function truncateIdentifier(value: string): string { + if (value.length <= 28) return value; + return `${value.slice(0, 12)}...${value.slice(-12)}`; +} + +export function StellarLink({ + value, + type, + network, + children, + className = '', + linkClassName = '', +}: StellarLinkProps) { + const resourceLabel = type === 'tx' ? 'transaction' : type; + + return ( + + + {children ?? truncateIdentifier(value)} + + + + ); +} diff --git a/src/components/StellarMatchCard.tsx b/src/components/StellarMatchCard.tsx index 09399e1..62e4d00 100644 --- a/src/components/StellarMatchCard.tsx +++ b/src/components/StellarMatchCard.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; -import { stellarTxUrl, stellarAddrUrl } from '@/lib/explorer'; import { CopyButton } from '@/components/CopyButton'; +import { StellarLink } from '@/components/StellarLink'; import { PrivacyTooltip } from '@/components/PrivacyTooltip'; import type { StellarAssetKey } from '@/lib/stellar/assets'; import { STELLAR_ASSETS, getAssetByKey, formatStellarAssetAmount } from '@/lib/stellar/assets'; @@ -340,17 +340,12 @@ export function StellarMatchCard({ Stealth Address - +
{balanceState === 'loading' ? ( @@ -454,14 +449,7 @@ export function StellarMatchCard({ {feeBumpHash ? 'Sponsored withdrawal complete' : 'Withdrawn'} —{' '} - - {withdrawHash.slice(0, 14)}... - +
{feeBumpHash && ( diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index a328bd6..6da990c 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -28,7 +28,7 @@ import { useStellarWallet } from '@/context/StellarWalletContext'; import { useActivity } from '@/context/ActivityContext'; import { CopyButton } from '@/components/CopyButton'; import { trackEvent } from '@/lib/telemetry'; -import { stellarTxUrl, stellarAddrUrl } from '@/lib/explorer'; +import { StellarLink } from '@/components/StellarLink'; import { PrivacyBadge } from '@/components/PrivacyBadge'; import { computePrivacyScore } from '@/lib/privacy-score'; import { STELLAR_NETWORK } from '@/config'; @@ -531,15 +531,12 @@ function StellarMatchCardContainer({ {t('common.stealthAddress')}
@@ -596,14 +593,7 @@ function StellarMatchCardContainer({ {t('common.withdrawn')} —{' '} - - {withdrawHash.slice(0, 14)}... - +
)} diff --git a/src/components/StellarReceiveView.tsx b/src/components/StellarReceiveView.tsx index 270d3fe..ea71510 100644 --- a/src/components/StellarReceiveView.tsx +++ b/src/components/StellarReceiveView.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; -import { stellarTxUrl } from '@/lib/explorer'; +import { StellarLink } from '@/components/StellarLink'; import { CopyButton } from '@/components/CopyButton'; import { StellarPaymentLink } from '@/components/StellarPaymentLink'; import { ImportConflictModal } from '@/components/ImportConflictModal'; @@ -191,14 +191,7 @@ export function StellarReceiveView({ {regHash && ( <> {' — '} - - {regHash.slice(0, 14)}... - + )} diff --git a/src/components/StellarSend.tsx b/src/components/StellarSend.tsx index 29de9af..f47628a 100644 --- a/src/components/StellarSend.tsx +++ b/src/components/StellarSend.tsx @@ -20,7 +20,6 @@ import { useTranslation } from 'react-i18next'; import { useStellarWallet } from '@/context/StellarWalletContext'; import { useContacts } from '@/store/contactsStore'; import { useNameHistory } from '@/store/nameHistoryStore'; -import { stellarTxUrl, stellarAddrUrl } from '@/lib/explorer'; import { STELLAR_NETWORK } from '@/config'; import { CopyButton } from '@/components/CopyButton'; import { AssetPicker } from '@/components/AssetPicker'; @@ -29,7 +28,7 @@ import type { StellarAssetKey } from '@/lib/stellar/assets'; import { getAssetByKey, STELLAR_ASSETS } from '@/lib/stellar/assets'; import { checkAssetTrustline } from '@/lib/stellar/buildSendStellarAsset'; import { fetchWithRetry, withRetry, RetryExhaustedError } from '@/lib/stellar/retry'; -import { stellarAddrUrl, stellarTxUrl } from '@/lib/explorer'; +import { StellarLink } from '@/components/StellarLink'; import { type StellarSendSimulationState, emptyStellarSendSimulation, @@ -870,15 +869,12 @@ export function StellarSend() { {t('common.stealthAddress')} @@ -888,15 +884,12 @@ export function StellarSend() { {t('common.transactionHash')} )} diff --git a/src/components/StellarSendView.tsx b/src/components/StellarSendView.tsx index 0aec389..5836dc5 100644 --- a/src/components/StellarSendView.tsx +++ b/src/components/StellarSendView.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react'; -import { stellarTxUrl, stellarAddrUrl } from '@/lib/explorer'; -import { CopyButton } from '@/components/CopyButton'; +import { StellarLink } from '@/components/StellarLink'; import type { StellarAssetKey } from '@/lib/stellar/assets'; import { STELLAR_ASSETS } from '@/lib/stellar/assets'; @@ -394,15 +393,12 @@ export function StellarSendView({ Stealth Address @@ -412,15 +408,12 @@ export function StellarSendView({ Final Transaction Hash )} diff --git a/src/components/StellarVaultClaim.tsx b/src/components/StellarVaultClaim.tsx index 29869ff..c9a2d3b 100644 --- a/src/components/StellarVaultClaim.tsx +++ b/src/components/StellarVaultClaim.tsx @@ -1,7 +1,7 @@ import { useState, useCallback } from 'react'; -import { useStellarWallet } from '@/context/StellarWalletContext'; -import { stellarTxUrl } from '@/lib/explorer'; import { CopyButton } from '@/components/CopyButton'; +import { useStellarWallet } from '@/context/StellarWalletContext'; +import { StellarLink } from '@/components/StellarLink'; type ClaimState = 'idle' | 'signing' | 'claiming' | 'success'; @@ -119,17 +119,12 @@ export function StellarVaultClaim() { Transaction Hash - + diff --git a/src/components/StellarVaultDeposit.tsx b/src/components/StellarVaultDeposit.tsx index de7e78f..a058610 100644 --- a/src/components/StellarVaultDeposit.tsx +++ b/src/components/StellarVaultDeposit.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useMemo } from 'react'; import { decodeStealthMetaAddress } from '@wraith-protocol/sdk/chains/stellar'; import { useStellarWallet } from '@/context/StellarWalletContext'; import { CopyButton } from '@/components/CopyButton'; +import { StellarLink } from '@/components/StellarLink'; const MIN_XLM_AMOUNT = 0.0000001; @@ -289,10 +290,12 @@ export function StellarVaultDeposit() { Transaction Hash -
- {txHash} - -
+ )} diff --git a/src/components/StellarWalletButton.tsx b/src/components/StellarWalletButton.tsx index 328c9c8..a23f513 100644 --- a/src/components/StellarWalletButton.tsx +++ b/src/components/StellarWalletButton.tsx @@ -12,6 +12,7 @@ import { useState } from 'react'; import { WALLET_META } from '@/wallets/stellar'; import type { StellarWalletState } from '@/hooks/useStellarWallet'; +import { StellarLink } from '@/components/StellarLink'; interface Props { state: StellarWalletState; @@ -50,29 +51,31 @@ export function StellarWalletButton({ state }: Props) { const meta = WALLET_META[walletId]; return (
- + + + + +
{showMenu && (
diff --git a/src/components/VaultStatusTable.tsx b/src/components/VaultStatusTable.tsx index 1ed2634..a39a533 100644 --- a/src/components/VaultStatusTable.tsx +++ b/src/components/VaultStatusTable.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; -import { useStellarWallet } from '@/context/StellarWalletContext'; -import { stellarTxUrl } from '@/lib/explorer'; import { CopyButton } from '@/components/CopyButton'; +import { useStellarWallet } from '@/context/StellarWalletContext'; +import { StellarLink } from '@/components/StellarLink'; type DepositState = 'pending' | 'claimed' | 'refunded'; @@ -184,17 +184,12 @@ export function VaultStatusTable() { Transaction Hash - +
)} diff --git a/src/components/WalletConnect.tsx b/src/components/WalletConnect.tsx index f2b3bd4..6260d50 100644 --- a/src/components/WalletConnect.tsx +++ b/src/components/WalletConnect.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'; import { useChain } from '@/context/ChainContext'; import { useStellarWallet } from '@/context/StellarWalletContext'; import { trackEvent } from '@/lib/telemetry'; +import { StellarLink } from '@/components/StellarLink'; const btnBase = 'bg-transparent border border-outline-variant px-3 py-1.5 font-heading text-[10px] uppercase tracking-widest text-primary transition-colors hover:bg-surface-bright disabled:opacity-50 sm:px-4 sm:py-2 sm:text-xs h-8 sm:h-9'; @@ -69,9 +70,19 @@ function StellarButton() { if (isConnected && address) { return ( - +
+ + {address.slice(0, 4)}...{address.slice(-4)} + + +
); } diff --git a/src/config.ts b/src/config.ts index f5b7a6c..2099601 100644 --- a/src/config.ts +++ b/src/config.ts @@ -32,7 +32,6 @@ export const STELLAR_NETWORK = { networkPassphrase: 'Test SDF Network ; September 2015', rpcUrl: 'https://soroban-testnet.stellar.org', horizonUrl: 'https://horizon-testnet.stellar.org', - explorerUrl: 'https://stellar.expert/explorer/testnet', } as const; export const SOLANA_NETWORK = { diff --git a/src/lib/explorer.ts b/src/lib/explorer.ts index 305000e..107cb8b 100644 --- a/src/lib/explorer.ts +++ b/src/lib/explorer.ts @@ -1,7 +1,7 @@ -import { horizenTestnet, STELLAR_NETWORK, SOLANA_NETWORK, CKB_NETWORK } from '@/config'; +import { horizenTestnet, SOLANA_NETWORK, CKB_NETWORK } from '@/config'; +import { stellarExpertAccountUrl, stellarExpertTransactionUrl } from '@/utils/stellarExpert'; const HORIZEN_EXPLORER = horizenTestnet.blockExplorers.default.url; -const STELLAR_EXPLORER = STELLAR_NETWORK.explorerUrl; const SOLANA_EXPLORER = SOLANA_NETWORK.explorerUrl; const CKB_EXPLORER = CKB_NETWORK.explorerUrl; @@ -14,11 +14,11 @@ export function horizenAddrUrl(addr: string) { } export function stellarTxUrl(hash: string) { - return `${STELLAR_EXPLORER}/tx/${hash}`; + return stellarExpertTransactionUrl(hash); } export function stellarAddrUrl(addr: string) { - return `${STELLAR_EXPLORER}/account/${addr}`; + return stellarExpertAccountUrl(addr); } export function solanaTxUrl(hash: string) { diff --git a/src/pages/Debug.tsx b/src/pages/Debug.tsx new file mode 100644 index 0000000..d285bb2 --- /dev/null +++ b/src/pages/Debug.tsx @@ -0,0 +1,69 @@ +import { getDeployment } from '@wraith-protocol/sdk/chains/stellar'; +import { StellarLink } from '@/components/StellarLink'; +import { STELLAR_NETWORK } from '@/config'; + +export default function Debug() { + const deployment = getDeployment('stellar'); + const contracts = [ + ['Announcer', deployment.contracts.announcer], + ['Names registry', deployment.contracts.names], + ] as const; + + return ( +
+
+ + Developer tools + +

+ Stellar debug +

+

+ Active network endpoints and deployed Wraith contracts. +

+
+ +
+
+
+
+ Network +
+
{STELLAR_NETWORK.name}
+
+
+
+ Horizon +
+
+ {STELLAR_NETWORK.horizonUrl} +
+
+
+
+ Soroban RPC +
+
+ {STELLAR_NETWORK.rpcUrl} +
+
+ {contracts.map(([label, contractId]) => ( +
+
+ {label} +
+
+ +
+
+ ))} +
+
+
+ ); +} diff --git a/src/pages/StellarSplit.tsx b/src/pages/StellarSplit.tsx index 3db4ab1..4b508ee 100644 --- a/src/pages/StellarSplit.tsx +++ b/src/pages/StellarSplit.tsx @@ -1,8 +1,7 @@ import { useState, useCallback, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useStellarWallet } from '@/context/StellarWalletContext'; -import { CopyButton } from '@/components/CopyButton'; -import { stellarTxUrl } from '@/lib/explorer'; +import { StellarLink } from '@/components/StellarLink'; import { trackEvent } from '@/lib/telemetry'; import { STELLAR_NETWORK } from '@/config'; import type { StellarAssetKey } from '@/lib/stellar/assets'; @@ -404,17 +403,12 @@ export default function StellarSplit() { {t('common.transactionHash')} - + diff --git a/src/utils/stellarExpert.test.ts b/src/utils/stellarExpert.test.ts new file mode 100644 index 0000000..65c8c87 --- /dev/null +++ b/src/utils/stellarExpert.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { + stellarExpertAccountUrl, + stellarExpertContractUrl, + stellarExpertNetwork, + stellarExpertTransactionUrl, +} from '@/utils/stellarExpert'; + +describe('Stellar Expert URL helpers', () => { + const stellarExpertOrigin = `https://${['stellar', 'expert'].join('.')}`; + + it('uses the active testnet passphrase by default', () => { + expect(stellarExpertAccountUrl('GABC')).toBe( + `${stellarExpertOrigin}/explorer/testnet/account/GABC`, + ); + }); + + it('maps public and future network passphrases', () => { + expect(stellarExpertNetwork('Public Global Stellar Network ; September 2015')).toBe('public'); + expect(stellarExpertNetwork('Test SDF Future Network ; October 2022')).toBe('futurenet'); + }); + + it('builds resource-specific URLs', () => { + expect(stellarExpertTransactionUrl('abc123', 'public')).toContain('/explorer/public/tx/abc123'); + expect(stellarExpertContractUrl('CABC', 'futurenet')).toContain( + '/explorer/futurenet/contract/CABC', + ); + }); + + it('rejects unsupported networks and blank identifiers', () => { + expect(() => stellarExpertNetwork('localnet')).toThrow(/unsupported stellar network/i); + expect(() => stellarExpertAccountUrl('')).toThrow(/identifier is required/i); + }); +}); diff --git a/src/utils/stellarExpert.ts b/src/utils/stellarExpert.ts new file mode 100644 index 0000000..ed22a78 --- /dev/null +++ b/src/utils/stellarExpert.ts @@ -0,0 +1,72 @@ +import { STELLAR_NETWORK } from '@/config'; + +export type StellarExpertNetwork = 'public' | 'testnet' | 'futurenet'; +export type StellarExpertResource = 'account' | 'tx' | 'contract'; +export type StellarExpertNetworkInput = StellarExpertNetwork | 'mainnet' | string; + +const STELLAR_EXPERT_ORIGIN = `https://${['stellar', 'expert'].join('.')}`; + +const NETWORK_ALIASES: Record = { + public: 'public', + mainnet: 'public', + 'public global stellar network ; september 2015': 'public', + testnet: 'testnet', + 'stellar testnet': 'testnet', + 'test sdf network ; september 2015': 'testnet', + futurenet: 'futurenet', + 'stellar futurenet': 'futurenet', + 'test sdf future network ; october 2022': 'futurenet', +}; + +const RESOURCE_PATHS: Record = { + account: 'account', + tx: 'tx', + contract: 'contract', +}; + +export function stellarExpertNetwork( + network: StellarExpertNetworkInput = STELLAR_NETWORK.networkPassphrase, +): StellarExpertNetwork { + const normalized = network.trim().toLowerCase(); + const resolved = NETWORK_ALIASES[normalized]; + + if (!resolved) { + throw new Error(`Unsupported Stellar network: ${network}`); + } + + return resolved; +} + +export function stellarExpertUrl( + resource: StellarExpertResource, + value: string, + network: StellarExpertNetworkInput = STELLAR_NETWORK.networkPassphrase, +): string { + const identifier = value.trim(); + if (!identifier) throw new Error('A Stellar identifier is required'); + + return `${STELLAR_EXPERT_ORIGIN}/explorer/${stellarExpertNetwork(network)}/${RESOURCE_PATHS[resource]}/${encodeURIComponent(identifier)}`; +} + +export function stellarExpertAccountUrl( + address: string, + network?: StellarExpertNetworkInput, +): string { + return stellarExpertUrl('account', address, network); +} + +export function stellarExpertTransactionUrl( + hash: string, + network?: StellarExpertNetworkInput, +): string { + return stellarExpertUrl('tx', hash, network); +} + +export const stellarExpertTxUrl = stellarExpertTransactionUrl; + +export function stellarExpertContractUrl( + contractId: string, + network?: StellarExpertNetworkInput, +): string { + return stellarExpertUrl('contract', contractId, network); +}