diff --git a/src/test/unit/ui/transaction-amount.test.js b/src/test/unit/ui/transaction-amount.test.js new file mode 100644 index 00000000..1c607b58 --- /dev/null +++ b/src/test/unit/ui/transaction-amount.test.js @@ -0,0 +1,178 @@ +/** + * @jest-environment node + */ + +const mockCSL = require('@emurgo/cardano-serialization-lib-nodejs'); + +jest.mock('../../../api/loader', () => ({ __esModule: true, default: { Cardano: mockCSL } })); +jest.mock('../../../api/util', () => ({ + compileOutputs: (outputList) => { + const compiled = []; + for (const output of outputList) { + for (const amount of output.amount) { + const entry = compiled.find((c) => c.unit === amount.unit); + if (entry) { + entry.quantity = (BigInt(entry.quantity) + BigInt(amount.quantity)).toString(); + } else { + compiled.push({ unit: amount.unit, quantity: String(amount.quantity) }); + } + } + } + return compiled; + }, + hexToAscii: (hex) => hex, +})); + +const { + calculateAmount, + matchesAnyCredential, + getAddressCredentials, + getTxType, +} = require('../../../ui/app/components/transaction'); + +function makeBaseAddress(paymentKeyHex, stakeKeyHex, networkId = 0) { + const paymentHash = mockCSL.Ed25519KeyHash.from_hex(paymentKeyHex); + const stakeHash = mockCSL.Ed25519KeyHash.from_hex(stakeKeyHex); + return mockCSL.BaseAddress.new( + networkId, + mockCSL.Credential.from_keyhash(paymentHash), + mockCSL.Credential.from_keyhash(stakeHash) + ) + .to_address() + .to_bech32(); +} + +const PAYMENT_KEY_0 = 'a'.repeat(56); +const STAKE_KEY_0 = 'b'.repeat(56); +const PAYMENT_KEY_1 = 'c'.repeat(56); +const STAKE_KEY_1 = 'd'.repeat(56); + +const EXTERNAL_PAYMENT = 'e'.repeat(56); +const EXTERNAL_STAKE = 'f'.repeat(56); + +let addr0, addr1, externalAddr; + +beforeAll(() => { + addr0 = makeBaseAddress(PAYMENT_KEY_0, STAKE_KEY_0); + addr1 = makeBaseAddress(PAYMENT_KEY_1, STAKE_KEY_1); + externalAddr = makeBaseAddress(EXTERNAL_PAYMENT, EXTERNAL_STAKE); +}); + +describe('matchesAnyCredential', () => { + test('matches same address', () => { + const [pay, stake] = getAddressCredentials(addr0); + expect(matchesAnyCredential(addr0, [pay, stake])).toBe(true); + }); + + test('does not match different account address', () => { + const [pay, stake] = getAddressCredentials(addr0); + expect(matchesAnyCredential(addr1, [pay, stake])).toBe(false); + }); + + test('does not match external address', () => { + const [pay, stake] = getAddressCredentials(addr0); + expect(matchesAnyCredential(externalAddr, [pay, stake])).toBe(false); + }); + + test('matches same payment cred even with different stake cred', () => { + const samePayDiffStake = makeBaseAddress(PAYMENT_KEY_0, STAKE_KEY_1); + const [pay, stake] = getAddressCredentials(addr0); + expect(matchesAnyCredential(samePayDiffStake, [pay, stake])).toBe(true); + }); +}); + +describe('calculateAmount — internal send (account 0 -> account 1)', () => { + test('shows non-zero sent amount for internal transfer', () => { + const uTxOList = { + inputs: [ + { address: addr0, value: '10000000', asset_list: [], tx_hash: 'aa'.repeat(32), tx_index: 0 }, + ], + outputs: [ + { address: addr1, value: '5000000', asset_list: [], tx_hash: 'bb'.repeat(32), tx_index: 0 }, + { address: addr0, value: '4829879', asset_list: [], tx_hash: 'bb'.repeat(32), tx_index: 1 }, + ], + }; + + const amounts = calculateAmount(addr0, uTxOList, true); + const lovelace = amounts.find((a) => a.unit === 'lovelace'); + expect(lovelace).toBeDefined(); + expect(BigInt(lovelace.quantity)).toBeLessThan(0n); + expect(BigInt(lovelace.quantity)).toBe(-5170121n); + }); +}); + +describe('calculateAmount — external send', () => { + test('shows negative amount for outgoing tx', () => { + const uTxOList = { + inputs: [ + { address: addr0, value: '10000000', asset_list: [], tx_hash: 'aa'.repeat(32), tx_index: 0 }, + ], + outputs: [ + { address: externalAddr, value: '5000000', asset_list: [], tx_hash: 'bb'.repeat(32), tx_index: 0 }, + { address: addr0, value: '4829879', asset_list: [], tx_hash: 'bb'.repeat(32), tx_index: 1 }, + ], + }; + const amounts = calculateAmount(addr0, uTxOList, true); + const lovelace = amounts.find((a) => a.unit === 'lovelace'); + expect(BigInt(lovelace.quantity)).toBe(-5170121n); + }); +}); + +describe('calculateAmount — receive', () => { + test('shows positive amount for incoming tx', () => { + const uTxOList = { + inputs: [ + { address: externalAddr, value: '10000000', asset_list: [], tx_hash: 'aa'.repeat(32), tx_index: 0 }, + ], + outputs: [ + { address: addr0, value: '5000000', asset_list: [], tx_hash: 'bb'.repeat(32), tx_index: 0 }, + { address: externalAddr, value: '4829879', asset_list: [], tx_hash: 'bb'.repeat(32), tx_index: 1 }, + ], + }; + const amounts = calculateAmount(addr0, uTxOList, true); + const lovelace = amounts.find((a) => a.unit === 'lovelace'); + expect(BigInt(lovelace.quantity)).toBe(5000000n); + }); +}); + +describe('getTxType', () => { + test('self transfer', () => { + const uTxOList = { + inputs: [{ address: addr0 }], + outputs: [{ address: addr0 }], + }; + expect(getTxType(addr0, [addr0, addr1], uTxOList)).toBe('self'); + }); + + test('internal out', () => { + const uTxOList = { + inputs: [{ address: addr0 }], + outputs: [{ address: addr1 }, { address: addr0 }], + }; + expect(getTxType(addr0, [addr0, addr1], uTxOList)).toBe('internalOut'); + }); + + test('external out', () => { + const uTxOList = { + inputs: [{ address: addr0 }], + outputs: [{ address: externalAddr }, { address: addr0 }], + }; + expect(getTxType(addr0, [addr0, addr1], uTxOList)).toBe('externalOut'); + }); + + test('external in', () => { + const uTxOList = { + inputs: [{ address: externalAddr }], + outputs: [{ address: addr0 }], + }; + expect(getTxType(addr0, [addr0, addr1], uTxOList)).toBe('externalIn'); + }); + + test('internal in', () => { + const uTxOList = { + inputs: [{ address: addr1 }], + outputs: [{ address: addr0 }], + }; + expect(getTxType(addr0, [addr0, addr1], uTxOList)).toBe('internalIn'); + }); +}); diff --git a/src/ui/app/components/transaction.jsx b/src/ui/app/components/transaction.jsx index 5b69a5fe..68fac0b8 100644 --- a/src/ui/app/components/transaction.jsx +++ b/src/ui/app/components/transaction.jsx @@ -506,30 +506,31 @@ const getTimestamp = (date) => { }; const getAddressCredentials = (address) => { - if (!address) { return [null, null]; } - + try { const cmlAddress = Loader.Cardano.Address.from_bech32(address); const paymentCred = cmlAddress.payment_cred()?.to_hex() || null; - const stakingCred = cmlAddress.staking_cred()?.to_hex() || null; - if (paymentCred || stakingCred) { - return [paymentCred, stakingCred]; + + const baseAddr = Loader.Cardano.BaseAddress.from_address(cmlAddress); + if (baseAddr) { + const stakeCred = baseAddr.stake_cred()?.to_hex() || null; + return [paymentCred, stakeCred]; } + const rewardAddr = Loader.Cardano.RewardAddress.from_address(cmlAddress); if (rewardAddr) { return [null, rewardAddr.payment_cred()?.to_hex() || null]; } - return [null, null]; + + return [paymentCred, null]; } catch (error) { try { - // try casting as byron address const cmlAddress = Loader.Cardano.ByronAddress.from_base58(address); const paymentCred = cmlAddress.to_address()?.payment_cred()?.to_hex() || null; - const stakingCred = cmlAddress.to_address()?.staking_cred()?.to_hex() || null; - return [paymentCred, stakingCred]; + return [paymentCred, null]; } catch (byronError) { console.error('Failed to parse address:', address, error); return [null, null]; @@ -539,8 +540,10 @@ const getAddressCredentials = (address) => { const matchesAnyCredential = (address, [ownPaymentCred, ownStakingCred]) => { const [otherPaymentCred, otherStakingCred] = getAddressCredentials(address); - const matches = otherPaymentCred === ownPaymentCred || otherStakingCred === ownStakingCred; - return matches; + if (otherPaymentCred && ownPaymentCred) { + return otherPaymentCred === ownPaymentCred; + } + return otherStakingCred != null && otherStakingCred === ownStakingCred; } const calculateAmount = (currentAddr, uTxOList, validContract = true) => { @@ -682,3 +685,5 @@ const getTxExtra = (extra) => ); export default Transaction; + +export { calculateAmount, matchesAnyCredential, getAddressCredentials, getTxType }; diff --git a/src/ui/app/pages/wallet.jsx b/src/ui/app/pages/wallet.jsx index 09283b3f..682a4ebe 100644 --- a/src/ui/app/pages/wallet.jsx +++ b/src/ui/app/pages/wallet.jsx @@ -62,6 +62,7 @@ import { TabPanel, Tooltip, Collapse, + IconButton, } from '@chakra-ui/react'; import { SettingsIcon, @@ -92,7 +93,7 @@ import { FaGamepad, FaRegFileCode } from 'react-icons/fa'; import { RxTokens } from "react-icons/rx"; import { GoHistory } from "react-icons/go"; import { GiToken } from 'react-icons/gi'; -import { MdHowToVote, MdOutlineHowToReg } from 'react-icons/md'; +import { MdHowToVote, MdOutlineHowToReg, MdRefresh } from 'react-icons/md'; import CollectiblesViewer from '../components/collectiblesViewer'; import AssetFingerprint from '@emurgo/cip14-js'; import { useColorMode, useColorModeValue } from '@chakra-ui/react'; @@ -269,6 +270,7 @@ const Wallet = () => { const aboutRef = React.useRef(); const deletAccountRef = React.useRef(); const refreshTimeoutRef = React.useRef(null); + const lastRefreshRef = React.useRef(Date.now()); const [info, setInfo] = React.useState({ avatar: '', name: '', @@ -347,6 +349,7 @@ const Wallet = () => { delegation, })); setIsFetching(false); + lastRefreshRef.current = Date.now(); }; const schedulePostTxRefresh = (delayMs = 30000) => { @@ -382,7 +385,20 @@ const Wallet = () => { }); }).catch(() => {}); }); + const REFRESH_DEBOUNCE_MS = 15000; + const onVisibilityChange = () => { + if ( + document.visibilityState === 'visible' && + isMounted.current && + Date.now() - lastRefreshRef.current > REFRESH_DEBOUNCE_MS + ) { + getData({ forceUpdate: true }); + } + }; + document.addEventListener('visibilitychange', onVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', onVisibilityChange); if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); } @@ -820,6 +836,16 @@ const Wallet = () => { ) : ( '' )} + } + aria-label="Refresh wallet" + variant="ghost" + size="xs" + ml={1} + isLoading={isFetching} + onClick={() => getData({ forceUpdate: true })} + _hover={{ bg: 'whiteAlpha.200' }} + />