Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions src/test/unit/ui/transaction-amount.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
27 changes: 16 additions & 11 deletions src/ui/app/components/transaction.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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) => {
Expand Down Expand Up @@ -682,3 +685,5 @@ const getTxExtra = (extra) =>
);

export default Transaction;

export { calculateAmount, matchesAnyCredential, getAddressCredentials, getTxType };
28 changes: 27 additions & 1 deletion src/ui/app/pages/wallet.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
TabPanel,
Tooltip,
Collapse,
IconButton,
} from '@chakra-ui/react';
import {
SettingsIcon,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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: '',
Expand Down Expand Up @@ -347,6 +349,7 @@ const Wallet = () => {
delegation,
}));
setIsFetching(false);
lastRefreshRef.current = Date.now();
};

const schedulePostTxRefresh = (delayMs = 30000) => {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -820,6 +836,16 @@ const Wallet = () => {
) : (
''
)}
<IconButton
icon={<Icon as={MdRefresh} />}
aria-label="Refresh wallet"
variant="ghost"
size="xs"
ml={1}
isLoading={isFetching}
onClick={() => getData({ forceUpdate: true })}
_hover={{ bg: 'whiteAlpha.200' }}
/>
</Flex>
<UnitDisplay
className="lineClamp"
Expand Down