From 08a2238019283332cd4d4ad3f91ed63e9a0fb5d5 Mon Sep 17 00:00:00 2001 From: dhanz Date: Sun, 10 May 2026 20:51:00 +0000 Subject: [PATCH] fix: wallet refresh button + auto-refresh on focus + internal send 0 amount Refresh mechanism: - Add refresh icon button next to balance display (calls getData with forceUpdate). Shows spinner while fetching. - Auto-refresh on tab/window focus via visibilitychange listener, debounced to 15s to avoid spamming the API. Internal send 0.000000 amount fix: - Root cause: getAddressCredentials called Address.staking_cred() which doesn't exist in CSL v15 (only BaseAddress.stake_cred() does). The thrown error caused all addresses to return [null, null] credentials, making null === null match everything. Both input and output UTxOs were included, netting to ~0. - Fix: cast Address to BaseAddress first, use stake_cred(). Fall back gracefully for RewardAddress and ByronAddress. - matchesAnyCredential now requires payment credential match when both addresses have one (not OR staking). Staking cred is fallback for reward addresses only. - 12 new unit tests covering credential matching, calculateAmount for internal/external sends and receives, and getTxType classification. --- src/test/unit/ui/transaction-amount.test.js | 178 ++++++++++++++++++++ src/ui/app/components/transaction.jsx | 27 +-- src/ui/app/pages/wallet.jsx | 28 ++- 3 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 src/test/unit/ui/transaction-amount.test.js 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' }} + />