Skip to content

Commit b711b7d

Browse files
Fuma419dhanz
andauthored
fix: wallet refresh button + auto-refresh on focus + internal send 0 amount (#71)
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. Co-authored-by: dhanz <dhanz@local>
1 parent d3f2c38 commit b711b7d

3 files changed

Lines changed: 221 additions & 12 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
5+
const mockCSL = require('@emurgo/cardano-serialization-lib-nodejs');
6+
7+
jest.mock('../../../api/loader', () => ({ __esModule: true, default: { Cardano: mockCSL } }));
8+
jest.mock('../../../api/util', () => ({
9+
compileOutputs: (outputList) => {
10+
const compiled = [];
11+
for (const output of outputList) {
12+
for (const amount of output.amount) {
13+
const entry = compiled.find((c) => c.unit === amount.unit);
14+
if (entry) {
15+
entry.quantity = (BigInt(entry.quantity) + BigInt(amount.quantity)).toString();
16+
} else {
17+
compiled.push({ unit: amount.unit, quantity: String(amount.quantity) });
18+
}
19+
}
20+
}
21+
return compiled;
22+
},
23+
hexToAscii: (hex) => hex,
24+
}));
25+
26+
const {
27+
calculateAmount,
28+
matchesAnyCredential,
29+
getAddressCredentials,
30+
getTxType,
31+
} = require('../../../ui/app/components/transaction');
32+
33+
function makeBaseAddress(paymentKeyHex, stakeKeyHex, networkId = 0) {
34+
const paymentHash = mockCSL.Ed25519KeyHash.from_hex(paymentKeyHex);
35+
const stakeHash = mockCSL.Ed25519KeyHash.from_hex(stakeKeyHex);
36+
return mockCSL.BaseAddress.new(
37+
networkId,
38+
mockCSL.Credential.from_keyhash(paymentHash),
39+
mockCSL.Credential.from_keyhash(stakeHash)
40+
)
41+
.to_address()
42+
.to_bech32();
43+
}
44+
45+
const PAYMENT_KEY_0 = 'a'.repeat(56);
46+
const STAKE_KEY_0 = 'b'.repeat(56);
47+
const PAYMENT_KEY_1 = 'c'.repeat(56);
48+
const STAKE_KEY_1 = 'd'.repeat(56);
49+
50+
const EXTERNAL_PAYMENT = 'e'.repeat(56);
51+
const EXTERNAL_STAKE = 'f'.repeat(56);
52+
53+
let addr0, addr1, externalAddr;
54+
55+
beforeAll(() => {
56+
addr0 = makeBaseAddress(PAYMENT_KEY_0, STAKE_KEY_0);
57+
addr1 = makeBaseAddress(PAYMENT_KEY_1, STAKE_KEY_1);
58+
externalAddr = makeBaseAddress(EXTERNAL_PAYMENT, EXTERNAL_STAKE);
59+
});
60+
61+
describe('matchesAnyCredential', () => {
62+
test('matches same address', () => {
63+
const [pay, stake] = getAddressCredentials(addr0);
64+
expect(matchesAnyCredential(addr0, [pay, stake])).toBe(true);
65+
});
66+
67+
test('does not match different account address', () => {
68+
const [pay, stake] = getAddressCredentials(addr0);
69+
expect(matchesAnyCredential(addr1, [pay, stake])).toBe(false);
70+
});
71+
72+
test('does not match external address', () => {
73+
const [pay, stake] = getAddressCredentials(addr0);
74+
expect(matchesAnyCredential(externalAddr, [pay, stake])).toBe(false);
75+
});
76+
77+
test('matches same payment cred even with different stake cred', () => {
78+
const samePayDiffStake = makeBaseAddress(PAYMENT_KEY_0, STAKE_KEY_1);
79+
const [pay, stake] = getAddressCredentials(addr0);
80+
expect(matchesAnyCredential(samePayDiffStake, [pay, stake])).toBe(true);
81+
});
82+
});
83+
84+
describe('calculateAmount — internal send (account 0 -> account 1)', () => {
85+
test('shows non-zero sent amount for internal transfer', () => {
86+
const uTxOList = {
87+
inputs: [
88+
{ address: addr0, value: '10000000', asset_list: [], tx_hash: 'aa'.repeat(32), tx_index: 0 },
89+
],
90+
outputs: [
91+
{ address: addr1, value: '5000000', asset_list: [], tx_hash: 'bb'.repeat(32), tx_index: 0 },
92+
{ address: addr0, value: '4829879', asset_list: [], tx_hash: 'bb'.repeat(32), tx_index: 1 },
93+
],
94+
};
95+
96+
const amounts = calculateAmount(addr0, uTxOList, true);
97+
const lovelace = amounts.find((a) => a.unit === 'lovelace');
98+
expect(lovelace).toBeDefined();
99+
expect(BigInt(lovelace.quantity)).toBeLessThan(0n);
100+
expect(BigInt(lovelace.quantity)).toBe(-5170121n);
101+
});
102+
});
103+
104+
describe('calculateAmount — external send', () => {
105+
test('shows negative amount for outgoing tx', () => {
106+
const uTxOList = {
107+
inputs: [
108+
{ address: addr0, value: '10000000', asset_list: [], tx_hash: 'aa'.repeat(32), tx_index: 0 },
109+
],
110+
outputs: [
111+
{ address: externalAddr, value: '5000000', asset_list: [], tx_hash: 'bb'.repeat(32), tx_index: 0 },
112+
{ address: addr0, value: '4829879', asset_list: [], tx_hash: 'bb'.repeat(32), tx_index: 1 },
113+
],
114+
};
115+
const amounts = calculateAmount(addr0, uTxOList, true);
116+
const lovelace = amounts.find((a) => a.unit === 'lovelace');
117+
expect(BigInt(lovelace.quantity)).toBe(-5170121n);
118+
});
119+
});
120+
121+
describe('calculateAmount — receive', () => {
122+
test('shows positive amount for incoming tx', () => {
123+
const uTxOList = {
124+
inputs: [
125+
{ address: externalAddr, value: '10000000', asset_list: [], tx_hash: 'aa'.repeat(32), tx_index: 0 },
126+
],
127+
outputs: [
128+
{ address: addr0, value: '5000000', asset_list: [], tx_hash: 'bb'.repeat(32), tx_index: 0 },
129+
{ address: externalAddr, value: '4829879', asset_list: [], tx_hash: 'bb'.repeat(32), tx_index: 1 },
130+
],
131+
};
132+
const amounts = calculateAmount(addr0, uTxOList, true);
133+
const lovelace = amounts.find((a) => a.unit === 'lovelace');
134+
expect(BigInt(lovelace.quantity)).toBe(5000000n);
135+
});
136+
});
137+
138+
describe('getTxType', () => {
139+
test('self transfer', () => {
140+
const uTxOList = {
141+
inputs: [{ address: addr0 }],
142+
outputs: [{ address: addr0 }],
143+
};
144+
expect(getTxType(addr0, [addr0, addr1], uTxOList)).toBe('self');
145+
});
146+
147+
test('internal out', () => {
148+
const uTxOList = {
149+
inputs: [{ address: addr0 }],
150+
outputs: [{ address: addr1 }, { address: addr0 }],
151+
};
152+
expect(getTxType(addr0, [addr0, addr1], uTxOList)).toBe('internalOut');
153+
});
154+
155+
test('external out', () => {
156+
const uTxOList = {
157+
inputs: [{ address: addr0 }],
158+
outputs: [{ address: externalAddr }, { address: addr0 }],
159+
};
160+
expect(getTxType(addr0, [addr0, addr1], uTxOList)).toBe('externalOut');
161+
});
162+
163+
test('external in', () => {
164+
const uTxOList = {
165+
inputs: [{ address: externalAddr }],
166+
outputs: [{ address: addr0 }],
167+
};
168+
expect(getTxType(addr0, [addr0, addr1], uTxOList)).toBe('externalIn');
169+
});
170+
171+
test('internal in', () => {
172+
const uTxOList = {
173+
inputs: [{ address: addr1 }],
174+
outputs: [{ address: addr0 }],
175+
};
176+
expect(getTxType(addr0, [addr0, addr1], uTxOList)).toBe('internalIn');
177+
});
178+
});

src/ui/app/components/transaction.jsx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -506,30 +506,31 @@ const getTimestamp = (date) => {
506506
};
507507

508508
const getAddressCredentials = (address) => {
509-
510509
if (!address) {
511510
return [null, null];
512511
}
513-
512+
514513
try {
515514
const cmlAddress = Loader.Cardano.Address.from_bech32(address);
516515
const paymentCred = cmlAddress.payment_cred()?.to_hex() || null;
517-
const stakingCred = cmlAddress.staking_cred()?.to_hex() || null;
518-
if (paymentCred || stakingCred) {
519-
return [paymentCred, stakingCred];
516+
517+
const baseAddr = Loader.Cardano.BaseAddress.from_address(cmlAddress);
518+
if (baseAddr) {
519+
const stakeCred = baseAddr.stake_cred()?.to_hex() || null;
520+
return [paymentCred, stakeCred];
520521
}
522+
521523
const rewardAddr = Loader.Cardano.RewardAddress.from_address(cmlAddress);
522524
if (rewardAddr) {
523525
return [null, rewardAddr.payment_cred()?.to_hex() || null];
524526
}
525-
return [null, null];
527+
528+
return [paymentCred, null];
526529
} catch (error) {
527530
try {
528-
// try casting as byron address
529531
const cmlAddress = Loader.Cardano.ByronAddress.from_base58(address);
530532
const paymentCred = cmlAddress.to_address()?.payment_cred()?.to_hex() || null;
531-
const stakingCred = cmlAddress.to_address()?.staking_cred()?.to_hex() || null;
532-
return [paymentCred, stakingCred];
533+
return [paymentCred, null];
533534
} catch (byronError) {
534535
console.error('Failed to parse address:', address, error);
535536
return [null, null];
@@ -539,8 +540,10 @@ const getAddressCredentials = (address) => {
539540

540541
const matchesAnyCredential = (address, [ownPaymentCred, ownStakingCred]) => {
541542
const [otherPaymentCred, otherStakingCred] = getAddressCredentials(address);
542-
const matches = otherPaymentCred === ownPaymentCred || otherStakingCred === ownStakingCred;
543-
return matches;
543+
if (otherPaymentCred && ownPaymentCred) {
544+
return otherPaymentCred === ownPaymentCred;
545+
}
546+
return otherStakingCred != null && otherStakingCred === ownStakingCred;
544547
}
545548

546549
const calculateAmount = (currentAddr, uTxOList, validContract = true) => {
@@ -682,3 +685,5 @@ const getTxExtra = (extra) =>
682685
);
683686

684687
export default Transaction;
688+
689+
export { calculateAmount, matchesAnyCredential, getAddressCredentials, getTxType };

src/ui/app/pages/wallet.jsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
TabPanel,
6363
Tooltip,
6464
Collapse,
65+
IconButton,
6566
} from '@chakra-ui/react';
6667
import {
6768
SettingsIcon,
@@ -92,7 +93,7 @@ import { FaGamepad, FaRegFileCode } from 'react-icons/fa';
9293
import { RxTokens } from "react-icons/rx";
9394
import { GoHistory } from "react-icons/go";
9495
import { GiToken } from 'react-icons/gi';
95-
import { MdHowToVote, MdOutlineHowToReg } from 'react-icons/md';
96+
import { MdHowToVote, MdOutlineHowToReg, MdRefresh } from 'react-icons/md';
9697
import CollectiblesViewer from '../components/collectiblesViewer';
9798
import AssetFingerprint from '@emurgo/cip14-js';
9899
import { useColorMode, useColorModeValue } from '@chakra-ui/react';
@@ -269,6 +270,7 @@ const Wallet = () => {
269270
const aboutRef = React.useRef();
270271
const deletAccountRef = React.useRef();
271272
const refreshTimeoutRef = React.useRef(null);
273+
const lastRefreshRef = React.useRef(Date.now());
272274
const [info, setInfo] = React.useState({
273275
avatar: '',
274276
name: '',
@@ -347,6 +349,7 @@ const Wallet = () => {
347349
delegation,
348350
}));
349351
setIsFetching(false);
352+
lastRefreshRef.current = Date.now();
350353
};
351354

352355
const schedulePostTxRefresh = (delayMs = 30000) => {
@@ -382,7 +385,20 @@ const Wallet = () => {
382385
});
383386
}).catch(() => {});
384387
});
388+
const REFRESH_DEBOUNCE_MS = 15000;
389+
const onVisibilityChange = () => {
390+
if (
391+
document.visibilityState === 'visible' &&
392+
isMounted.current &&
393+
Date.now() - lastRefreshRef.current > REFRESH_DEBOUNCE_MS
394+
) {
395+
getData({ forceUpdate: true });
396+
}
397+
};
398+
document.addEventListener('visibilitychange', onVisibilityChange);
399+
385400
return () => {
401+
document.removeEventListener('visibilitychange', onVisibilityChange);
386402
if (refreshTimeoutRef.current) {
387403
clearTimeout(refreshTimeoutRef.current);
388404
}
@@ -820,6 +836,16 @@ const Wallet = () => {
820836
) : (
821837
''
822838
)}
839+
<IconButton
840+
icon={<Icon as={MdRefresh} />}
841+
aria-label="Refresh wallet"
842+
variant="ghost"
843+
size="xs"
844+
ml={1}
845+
isLoading={isFetching}
846+
onClick={() => getData({ forceUpdate: true })}
847+
_hover={{ bg: 'whiteAlpha.200' }}
848+
/>
823849
</Flex>
824850
<UnitDisplay
825851
className="lineClamp"

0 commit comments

Comments
 (0)