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' }}
+ />