Skip to content

Commit f189b47

Browse files
authoredJan 2, 2024
Move to Typescript + other changes (#9)
* Initial move to typescript * Convert ledger.js to ts + fix dust fees * Update react version * Convert all JS pages to TSX * Amount formatting utils and fixes * lints * Set page types * Components from JS to TSX * Tests to TS * TS target es2015 * Pass device type to message form * lint and config * Attempt to use 2 UTXOs when change is needed
1 parent f1ac593 commit f189b47

24 files changed

+293
-147
lines changed
 

‎app/layout.js ‎app/layout.tsx

+1-2
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,8 @@ export default function RootLayout({ children }) {
2222
</head>
2323
<body className={inter.className}>
2424
<MantineProvider
25-
withGlobalStyles
2625
defaultColorScheme='dark'
2726
theme={{
28-
colorScheme: 'dark',
2927
fontFamily: 'Lato',
3028
fontFamilyMonospace: 'Roboto Mono,Courier New,Courier,monospace',
3129
colors: {
@@ -39,6 +37,7 @@ export default function RootLayout({ children }) {
3937
'#49EACB',
4038
'#49EACB',
4139
'#49EACB',
40+
'#49EACB',
4241
],
4342
},
4443
primaryColor: 'brand',

‎app/page.js ‎app/page.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import styles from './page.module.css';
4-
import { initTransport, getAppAndVersion } from '../lib/ledger.js';
4+
import { initTransport, getAppAndVersion } from '../lib/ledger';
55
import { useRouter } from 'next/navigation';
66
import { notifications } from '@mantine/notifications';
77

@@ -102,7 +102,7 @@ export default function Home() {
102102
>
103103
<h2>
104104
<Group style={smallStyles}>
105-
<IconBluetooth styles={smallStyles} /> Go to Demo Mode <span>-&gt;</span>
105+
<IconBluetooth style={smallStyles} /> Go to Demo Mode <span>-&gt;</span>
106106
</Group>
107107
</h2>
108108
<Text>(Replaced with bluetooth in the future)</Text>
File renamed without changes.

‎app/wallet/overview-tab.js ‎app/wallet/overview-tab.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ export default function OverviewTab(props) {
7878
console.error(e);
7979
notifications.show({
8080
title: 'Address not verified',
81-
message: 'Failed to verify address',
81+
message: 'Failed to verify address on the device',
82+
color: 'red',
8283
});
8384
}
8485

@@ -167,7 +168,9 @@ export default function OverviewTab(props) {
167168
);
168169
break;
169170
case 'Message':
170-
signSection = <MessageForm selectedAddress={selectedAddress} />;
171+
signSection = (
172+
<MessageForm selectedAddress={selectedAddress} deviceType={props.deviceType} />
173+
);
171174
break;
172175
default:
173176
break;

‎app/wallet/page.js ‎app/wallet/page.tsx

+11-55
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
'use client';
22

33
import styles from './page.module.css';
4-
import { getAddress, fetchAddressDetails, initTransport } from '../../lib/ledger.js';
4+
import { getAddress, fetchAddressDetails, initTransport } from '@/lib/ledger';
55
import { useState, useEffect } from 'react';
6-
import { Box, Stack, Tabs, Breadcrumbs, Anchor, Button, Center } from '@mantine/core';
6+
import { Stack, Tabs, Breadcrumbs, Anchor, Button, Center } from '@mantine/core';
77
import Header from '../../components/header';
88
import AddressesTab from './addresses-tab';
99
import OverviewTab from './overview-tab';
@@ -17,29 +17,11 @@ import { delay } from '@/lib/util';
1717

1818
import { useElementSize } from '@mantine/hooks';
1919
import { notifications } from '@mantine/notifications';
20-
import { eslint } from '@/next.config';
20+
import SettingsStore from '@/lib/settings-store';
2121

2222
let loadingAddressBatch = false;
2323
let addressInitialized = false;
2424

25-
function loadAddresses(bip32, addressType = 0, from = 0, to = from + 10) {
26-
const addresses = [];
27-
28-
for (let addressIndex = from; addressIndex < to; addressIndex++) {
29-
const derivationPath = `44'/111111'/0'/${addressType}/${addressIndex}`;
30-
const address = bip32.getAddress(addressType, addressIndex);
31-
32-
addresses.push({
33-
derivationPath,
34-
address,
35-
addressIndex,
36-
addressType,
37-
});
38-
}
39-
40-
return addresses;
41-
}
42-
4325
const addressFilter = (lastReceiveIndex) => {
4426
return (addressData, index) => {
4527
return (
@@ -174,43 +156,14 @@ function getDemoXPub() {
174156
};
175157
}
176158

177-
class SettingsStore {
178-
constructor(storageKey) {
179-
this.storageKey = `kasvault:${storageKey}`;
180-
this.settings = localStorage.getItem(this.storageKey);
181-
182-
if (this.settings) {
183-
this.settings = JSON.parse(this.settings);
184-
} else {
185-
this.settings = {
186-
receiveAddresses: {},
187-
lastReceiveIndex: 0,
188-
changeAddresses: {},
189-
lastChangeIndex: -1,
190-
version: 0,
191-
};
192-
localStorage.setItem(this.storageKey, JSON.stringify(this.settings));
193-
}
194-
}
195-
196-
setSetting(property, value) {
197-
this.settings[property] = value;
198-
localStorage.setItem(this.storageKey, JSON.stringify(this.settings));
199-
}
200-
201-
getSetting(property) {
202-
return this.settings[property];
203-
}
204-
}
205-
206-
export default function Dashboard(props) {
159+
export default function Dashboard() {
207160
const [addresses, setAddresses] = useState([]);
208161
const [rawAddresses, setRawAddresses] = useState([]);
209162
const [selectedAddress, setSelectedAddress] = useState(null);
210163
const [activeTab, setActiveTab] = useState('addresses');
211164
const [isTransportInitialized, setTransportInitialized] = useState(false);
212-
const [bip32base, setBIP32Base] = useState();
213-
const [userSettings, setUserSettings] = useState();
165+
const [bip32base, setBIP32Base] = useState<KaspaBIP32>();
166+
const [userSettings, setUserSettings] = useState<SettingsStore>();
214167
const [enableGenerate, setEnableGenerate] = useState(false);
215168

216169
const { ref: containerRef, width: containerWidth, height: containerHeight } = useElementSize();
@@ -296,14 +249,14 @@ export default function Dashboard(props) {
296249

297250
useEffect(() => {
298251
if (isTransportInitialized) {
299-
return;
252+
return () => {};
300253
}
301254

302255
if (deviceType === 'demo') {
303256
setTransportInitialized(true);
304257
const xpub = getDemoXPub();
305258
setBIP32Base(new KaspaBIP32(xpub.compressedPublicKey, xpub.chainCode));
306-
return;
259+
return () => {};
307260
}
308261

309262
let unloaded = false;
@@ -317,6 +270,8 @@ export default function Dashboard(props) {
317270
setBIP32Base(new KaspaBIP32(xpub.compressedPublicKey, xpub.chainCode)),
318271
);
319272
}
273+
274+
return null;
320275
})
321276
.catch((e) => {
322277
notifications.show({
@@ -431,6 +386,7 @@ export default function Dashboard(props) {
431386
setAddresses={setAddresses}
432387
containerWidth={containerWidth}
433388
containerHeight={containerHeight}
389+
deviceType={deviceType}
434390
/>
435391
</Tabs.Panel>
436392

‎app/wallet/transactions-tab.js ‎app/wallet/transactions-tab.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
Box,
1212
Loader,
1313
} from '@mantine/core';
14-
import { fetchTransactions, fetchTransactionCount } from '../../lib/ledger.js';
14+
import { fetchTransactions, fetchTransactionCount } from '@/lib/ledger';
1515
import { useEffect, useState } from 'react';
1616
import { format } from 'date-fns';
1717

File renamed without changes.
File renamed without changes.
File renamed without changes.

‎components/message-form.js ‎components/message-form.tsx

+7-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import MessageModal from './message-modal';
88
import { notifications } from '@mantine/notifications';
99

1010
export default function MessageForm(props) {
11-
const [signature, setSignature] = useState();
11+
const [signature, setSignature] = useState('');
1212
const [opened, { open, close }] = useDisclosure(false);
1313

1414
const form = useForm({
@@ -41,7 +41,12 @@ export default function MessageForm(props) {
4141

4242
try {
4343
const path = props.selectedAddress.derivationPath.split('/');
44-
const result = await signMessage(form.values.message, Number(path[3]), Number(path[4]));
44+
const result = await signMessage(
45+
form.values.message,
46+
Number(path[3]),
47+
Number(path[4]),
48+
props.deviceType,
49+
);
4550
setSignature(result.signature);
4651

4752
open();
File renamed without changes.

‎components/send-form.js ‎components/send-form.tsx

+41-30
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@ import { useSearchParams } from 'next/navigation';
1818

1919
import { useState, useEffect } from 'react';
2020
import { createTransaction, sendAmount, selectUtxos } from '@/lib/ledger';
21-
import styles from './send-form.module.css';
2221
import AddressText from '@/components/address-text';
2322
import { useForm } from '@mantine/form';
23+
import { kasToSompi, sompiToKas } from '@/lib/kaspa-util';
2424

2525
export default function SendForm(props) {
2626
const [confirming, setConfirming] = useState(false);
27-
const [fee, setFee] = useState('-');
28-
const [amountDescription, setAmountDescription] = useState();
27+
const [fee, setFee] = useState<string | number>('-');
28+
const [amountDescription, setAmountDescription] = useState<string>();
2929

3030
const [canSendAmount, setCanSendAmount] = useState(false);
3131

@@ -37,7 +37,7 @@ export default function SendForm(props) {
3737

3838
const form = useForm({
3939
initialValues: {
40-
amount: '',
40+
amount: undefined,
4141
sendTo: '',
4242
includeFeeInAmount: false,
4343
sentAmount: '',
@@ -55,20 +55,18 @@ export default function SendForm(props) {
5555
// Reset setup
5656
setConfirming(false);
5757
setFee('-');
58-
const baseValues = { amount: '', sendTo: '', includeFeeInAmount: false };
58+
let baseValues = { amount: '', sendTo: '', includeFeeInAmount: false };
5959

6060
if (resetAllValues) {
61-
baseValues.sentTo = '';
62-
baseValues.sentTxId = '';
63-
baseValues.sentAmount = '';
61+
form.setValues({ sentTo: '', sentTxId: '', sentAmount: '', ...baseValues });
62+
} else {
63+
form.setValues(baseValues);
6464
}
65-
66-
form.setValues(baseValues);
6765
};
6866

6967
const cleanupOnSuccess = (transactionId) => {
7068
const targetAmount = form.values.includeFeeInAmount
71-
? Number((form.values.amount - fee).toFixed(8))
69+
? (Number(form.values.amount) - Number(fee)).toFixed(8)
7270
: form.values.amount;
7371

7472
form.setValues({
@@ -117,7 +115,7 @@ export default function SendForm(props) {
117115
} else if (deviceType == 'usb') {
118116
try {
119117
const { tx } = createTransaction(
120-
Math.round(form.values.amount * 100000000),
118+
kasToSompi(Number(form.values.amount)),
121119
form.values.sendTo,
122120
props.addressContext.utxos,
123121
props.addressContext.derivationPath,
@@ -147,26 +145,41 @@ export default function SendForm(props) {
147145
setAmountDescription('');
148146

149147
if (amount && sendTo) {
150-
let calculatedFee = '-';
148+
let calculatedFee: string | number = '-';
151149
if (deviceType === 'demo') {
152150
calculatedFee =
153-
fee === '-' ? Math.round(Math.random() * 10000) / 100000000 : Number(fee);
151+
fee === '-' ? sompiToKas(Math.round(Math.random() * 10000)) : Number(fee);
154152
setCanSendAmount(Number(amount) <= props.addressContext.balance - calculatedFee);
155153
if (includeFeeInAmount) {
156-
setAmountDescription(`Amount after fee: ${amount - calculatedFee}`);
154+
const afterFeeDisplay = sompiToKas(kasToSompi(amount) - calculatedFee);
155+
setAmountDescription(`Amount after fee: ${afterFeeDisplay}`);
157156
}
158157
} else if (deviceType === 'usb') {
159-
const [hasEnough, selectedUtxos, feeCalcResult] = selectUtxos(
160-
amount * 100000000,
161-
props.addressContext.utxos,
162-
includeFeeInAmount,
163-
);
158+
const {
159+
hasEnough,
160+
fee: feeCalcResult,
161+
total: utxoTotalAmount,
162+
} = selectUtxos(kasToSompi(amount), props.addressContext.utxos, includeFeeInAmount);
164163

165164
if (hasEnough) {
166-
calculatedFee = feeCalcResult / 100000000;
165+
let changeAmount = utxoTotalAmount - kasToSompi(amount);
166+
if (!includeFeeInAmount) {
167+
changeAmount -= feeCalcResult;
168+
}
169+
170+
let expectedFee = feeCalcResult;
171+
// The change is added to the fee if it's less than 0.0001 KAS
172+
console.info('changeAmount', changeAmount);
173+
if (changeAmount < 10000) {
174+
console.info(`Adding dust change ${changeAmount} sompi to fee`);
175+
expectedFee += changeAmount;
176+
}
177+
178+
calculatedFee = sompiToKas(expectedFee);
179+
const afterFeeDisplay = sompiToKas(kasToSompi(amount) - expectedFee);
167180
setCanSendAmount(true);
168181
if (includeFeeInAmount) {
169-
setAmountDescription(`Amount after fee: ${amount - calculatedFee}`);
182+
setAmountDescription(`Amount after fee: ${afterFeeDisplay}`);
170183
}
171184
} else {
172185
setCanSendAmount(false);
@@ -189,7 +202,7 @@ export default function SendForm(props) {
189202
}, 0);
190203

191204
form.setValues({
192-
amount: Number((total / 100000000).toFixed(8)),
205+
amount: sompiToKas(total),
193206
includeFeeInAmount: true,
194207
});
195208
};
@@ -201,7 +214,7 @@ export default function SendForm(props) {
201214
label='Send to Address'
202215
placeholder='Address'
203216
{...form.getInputProps('sendTo')}
204-
disabled={form.getInputProps('sendTo').disabled || confirming}
217+
disabled={confirming}
205218
required
206219
/>
207220

@@ -228,7 +241,7 @@ export default function SendForm(props) {
228241
<Checkbox
229242
{...form.getInputProps('includeFeeInAmount', { type: 'checkbox' })}
230243
label='Include fee in amount'
231-
disabled={confirming || form.getInputProps('includeFeeInAmount').disabled}
244+
disabled={confirming}
232245
/>
233246

234247
<Group justify='space-between'>
@@ -252,7 +265,7 @@ export default function SendForm(props) {
252265
size={viewportWidth > 700 ? 'auto' : 'md'}
253266
>
254267
<Stack align='center'>
255-
<Text size='lg' align='center' c='brand'>
268+
<Text size='lg' c='brand'>
256269
Sent!
257270
</Text>
258271

@@ -262,23 +275,21 @@ export default function SendForm(props) {
262275
href={`https://explorer.kaspa.org/txs/${form.values.sentTxId}`}
263276
target='_blank'
264277
c='brand'
265-
align='center'
266278
w={'calc(var(--modal-size) - 6rem)'}
267279
style={{ overflowWrap: 'break-word' }}
268280
>
269281
{form.values.sentTxId}
270282
</Anchor>
271283

272-
<Text component='h2' align='center' fw={600}>
284+
<Text component='h2' fw={600}>
273285
{form.values.sentAmount} KAS
274286
</Text>
275287

276-
<Text align='center'>sent to</Text>
288+
<Text>sent to</Text>
277289

278290
<Text
279291
w={'calc(var(--modal-size) - 6rem)'}
280292
style={{ overflowWrap: 'break-word' }}
281-
align='center'
282293
>
283294
<AddressText address={form.values.sentTo} />
284295
</Text>

‎lib/base32.js ‎lib/base32.ts

+9-7
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010
/***
1111
* Charset containing the 32 symbols used in the base32 encoding.
1212
*/
13-
var CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
13+
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
1414

1515
/***
1616
* Inverted index mapping each symbol into its index within the charset.
1717
*/
18-
var CHARSET_INVERSE_INDEX = {
18+
const CHARSET_INVERSE_INDEX = {
1919
q: 0,
2020
p: 1,
2121
z: 2,
@@ -55,7 +55,7 @@ var CHARSET_INVERSE_INDEX = {
5555
*
5656
* @param {Array} data Array of integers between 0 and 31 inclusive.
5757
*/
58-
function encode(data) {
58+
export function encode(data) {
5959
if (!(data instanceof Array)) {
6060
throw new Error('Must be Array');
6161
}
@@ -75,7 +75,7 @@ function encode(data) {
7575
*
7676
* @param {string} base32
7777
*/
78-
function decode(base32) {
78+
export function decode(base32) {
7979
if (typeof base32 !== 'string') {
8080
throw new Error('Must be base32-encoded string');
8181
}
@@ -90,7 +90,9 @@ function decode(base32) {
9090
return data;
9191
}
9292

93-
module.exports = {
94-
encode: encode,
95-
decode: decode,
93+
const base32 = {
94+
encode,
95+
decode,
9696
};
97+
98+
export default base32;

‎lib/bip32.js ‎lib/bip32.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import ecc from '@bitcoinerlab/secp256k1';
2-
import BIP32Factory from 'bip32';
2+
import BIP32Factory, { BIP32API, BIP32Interface } from 'bip32';
33
import { publicKeyToAddress } from './kaspa-util';
44

5-
const bip32 = BIP32Factory(ecc);
5+
const bip32: BIP32API = BIP32Factory(ecc);
66

77
export default class KaspaBIP32 {
8-
constructor(compressedPublicKey, chainCode) {
8+
rootNode: BIP32Interface;
9+
10+
constructor(compressedPublicKey: Buffer, chainCode: Buffer) {
911
this.rootNode = bip32.fromPublicKey(compressedPublicKey, chainCode);
1012
}
1113

12-
getAddress(type = 0, index = 0) {
14+
getAddress(type: number = 0, index: number = 0) {
1315
const child = this.rootNode.derivePath(`${type}/${index}`);
1416

1517
// child.publicKey is a compressed public key

‎lib/kaspa-util.js ‎lib/kaspa-util.ts

+35-11
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import base32 from './base32';
1+
import base32 from '@/lib/base32';
22

3-
function convertBits(data, from, to, strict) {
3+
function convertBits(data: number[], from: number, to: number, strict: boolean = false): number[] {
44
strict = strict || false;
55
var accumulator = 0;
66
var bits = 0;
@@ -89,7 +89,11 @@ function checksumToArray(checksum) {
8989
return result.reverse();
9090
}
9191

92-
export function publicKeyToAddress(hashBuffer, stripPrefix, type = 'schnorr') {
92+
export function publicKeyToAddress(
93+
hashBuffer: Buffer,
94+
stripPrefix: boolean,
95+
type: string = 'schnorr',
96+
): string {
9397
function getTypeBits(type) {
9498
switch (type) {
9599
case 'schnorr':
@@ -103,12 +107,12 @@ export function publicKeyToAddress(hashBuffer, stripPrefix, type = 'schnorr') {
103107
}
104108
}
105109

106-
var eight0 = [0, 0, 0, 0, 0, 0, 0, 0];
110+
var eight0: number[] = [0, 0, 0, 0, 0, 0, 0, 0];
107111
var prefixData = prefixToArray('kaspa').concat([0]);
108-
var versionByte = getTypeBits(type);
109-
var arr = Array.prototype.slice.call(hashBuffer, 0);
110-
var payloadData = convertBits([versionByte].concat(arr), 8, 5);
111-
var checksumData = prefixData.concat(payloadData).concat(eight0);
112+
var versionByte: number = getTypeBits(type);
113+
var arr: number[] = Array.prototype.slice.call(hashBuffer, 0);
114+
var payloadData: number[] = convertBits([versionByte].concat(arr), 8, 5);
115+
var checksumData: number[] = prefixData.concat(payloadData).concat(eight0);
112116
var payload = payloadData.concat(checksumToArray(polymod(checksumData)));
113117
if (stripPrefix === true) {
114118
return base32.encode(payload);
@@ -117,7 +121,7 @@ export function publicKeyToAddress(hashBuffer, stripPrefix, type = 'schnorr') {
117121
}
118122
}
119123

120-
export function addressToPublicKey(address) {
124+
export function addressToPublicKey(address: string): { version: number; publicKey: number[] } {
121125
const addrPart = address.split(':')[1];
122126

123127
const payload = convertBits(base32.decode(addrPart), 5, 8);
@@ -133,7 +137,7 @@ export function addressToPublicKey(address) {
133137
}
134138
}
135139

136-
function numArrayToHexString(numArray = []) {
140+
function numArrayToHexString(numArray = []): string {
137141
const hexArr = [];
138142

139143
for (const num of numArray) {
@@ -143,7 +147,7 @@ function numArrayToHexString(numArray = []) {
143147
return hexArr.join('');
144148
}
145149

146-
export function addressToScriptPublicKey(address) {
150+
export function addressToScriptPublicKey(address: string): string {
147151
const { version, publicKey } = addressToPublicKey(address);
148152

149153
switch (version) {
@@ -157,3 +161,23 @@ export function addressToScriptPublicKey(address) {
157161
throw new Error('Address could not be translated to script public key');
158162
}
159163
}
164+
165+
export function sompiToKas(amount: number) {
166+
const amountStr = '00000000' + amount;
167+
return Number(amountStr.slice(0, -8) + '.' + amountStr.slice(-8));
168+
}
169+
170+
export function kasToSompi(amount: number) {
171+
const amountStr = String(amount);
172+
const parts = amountStr.split('.');
173+
174+
if (parts.length === 1) {
175+
return Number(amountStr + '00000000');
176+
} else if (parts.length === 2) {
177+
const [left, right] = parts;
178+
const rightStr = right + '00000000';
179+
return Number(left + rightStr.slice(0, 8));
180+
} else {
181+
throw new Error('Invalid amount');
182+
}
183+
}

‎lib/ledger.js ‎lib/ledger.ts

+54-20
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import Transport from '@ledgerhq/hw-transport';
21
import TransportWebHID from '@ledgerhq/hw-transport-webhid';
32
import axios from 'axios';
43
import axiosRetry from 'axios-retry';
@@ -19,13 +18,32 @@ let transportState = {
1918
type: null,
2019
};
2120

22-
export async function fetchTransaction(transactionId) {
21+
export async function fetchTransaction(transactionId: string) {
2322
const { data: txData } = await axios.get(`https://api.kaspa.org/transactions/${transactionId}`);
2423

2524
return txData;
2625
}
2726

28-
export function selectUtxos(amount, utxosInput, feeIncluded = false) {
27+
export type UtxoSelectionResult = {
28+
hasEnough: boolean;
29+
utxos: Array<any>;
30+
fee: number;
31+
total: number;
32+
};
33+
34+
/**
35+
* Selects the UTXOs to fulfill the amount requested
36+
*
37+
* @param amount - the amount to select for, in SOMPI
38+
* @param utxosInput - the utxos array to select from
39+
* @param feeIncluded - whether or not fees are included in the amount passed
40+
* @returns [has_enough, utxos, fee, total]
41+
*/
42+
export function selectUtxos(
43+
amount: number,
44+
utxosInput: UtxoInfo[],
45+
feeIncluded: boolean = false,
46+
): UtxoSelectionResult {
2947
// Fee does not have to be accurate. It just has to be over the absolute minimum.
3048
// https://kaspa-mdbook.aspectron.com/transactions/constraints/fees.html
3149
// Fee = (total mass) x (min_relay_tx_fee) / 1000
@@ -60,23 +78,25 @@ export function selectUtxos(amount, utxosInput, feeIncluded = false) {
6078

6179
selected.push(utxo);
6280

63-
const targetAmount = feeIncluded ? Number((amount - fee).toFixed(8)) : amount;
81+
const targetAmount = feeIncluded ? amount - fee : amount;
6482
console.info({
6583
targetAmount,
6684
amount,
6785
fee,
6886
total,
6987
});
7088

71-
if (total >= targetAmount + fee) {
89+
const totalSpend = targetAmount + fee;
90+
// If we have change, we want to try to use at least 2 UTXOs
91+
if (total == totalSpend || (total > totalSpend && selected.length > 1)) {
7292
// We have enough
7393
break;
7494
}
7595
}
7696

7797
// [has_enough, utxos, fee, total]
78-
const targetAmount = feeIncluded ? Number((amount - fee).toFixed(8)) : amount;
79-
return [total >= targetAmount + fee, selected, fee, total];
98+
const targetAmount = feeIncluded ? amount - fee : amount;
99+
return { hasEnough: total >= targetAmount + fee, utxos: selected, fee, total };
80100
}
81101

82102
export async function initTransport(type = 'usb') {
@@ -103,6 +123,12 @@ export async function fetchTransactionCount(address) {
103123
return txCount.total || 0;
104124
}
105125

126+
export type UtxoInfo = {
127+
prevTxId: string;
128+
outpointIndex: number;
129+
amount: number;
130+
};
131+
106132
export async function fetchAddressDetails(address, derivationPath) {
107133
const { data: balanceData } = await axios.get(
108134
`https://api.kaspa.org/addresses/${address}/balance`,
@@ -111,15 +137,15 @@ export async function fetchAddressDetails(address, derivationPath) {
111137

112138
// UTXOs sorted by decreasing amount. Using the biggest UTXOs first minimizes number of utxos needed
113139
// in a transaction
114-
const utxos = utxoData
140+
const utxos: UtxoInfo[] = utxoData
115141
.map((utxo) => {
116142
return {
117143
prevTxId: utxo.outpoint.transactionId,
118144
outpointIndex: utxo.outpoint.index,
119145
amount: Number(utxo.utxoEntry.amount),
120146
};
121147
})
122-
.sort((a, b) => b.amount - a.amount);
148+
.sort((a: UtxoInfo, b: UtxoInfo) => b.amount - a.amount);
123149

124150
const path = derivationPath.split('/');
125151

@@ -217,19 +243,24 @@ export const sendTransaction = async (signedTx) => {
217243
};
218244

219245
export function createTransaction(
220-
amount,
221-
sendTo,
222-
utxosInput,
223-
derivationPath,
224-
address,
225-
feeIncluded,
246+
amount: number,
247+
sendTo: string,
248+
utxosInput: any,
249+
derivationPath: string,
250+
changeAddress: string,
251+
feeIncluded: boolean = false,
226252
) {
227253
console.info('Amount:', amount);
228254
console.info('Send to:', sendTo);
229255
console.info('UTXOs:', utxosInput);
230256
console.info('Derivation Path:', derivationPath);
231257

232-
const [hasEnough, utxos, fee, totalUtxoAmount] = selectUtxos(amount, utxosInput, feeIncluded);
258+
const {
259+
hasEnough,
260+
utxos,
261+
fee,
262+
total: totalUtxoAmount,
263+
} = selectUtxos(amount, utxosInput, feeIncluded);
233264

234265
console.info('hasEnough', hasEnough);
235266
console.info(utxos);
@@ -242,7 +273,7 @@ export function createTransaction(
242273
const path = derivationPath.split('/');
243274
console.info('Split Path:', path);
244275

245-
const inputs = utxos.map(
276+
const inputs: TransactionInput[] = utxos.map(
246277
(utxo) =>
247278
new TransactionInput({
248279
value: utxo.amount,
@@ -253,7 +284,7 @@ export function createTransaction(
253284
}),
254285
);
255286

256-
const outputs = [];
287+
const outputs: TransactionOutput[] = [];
257288

258289
const targetAmount = feeIncluded ? Number((amount - fee).toFixed(8)) : amount;
259290

@@ -266,14 +297,17 @@ export function createTransaction(
266297

267298
const changeAmount = totalUtxoAmount - targetAmount - fee;
268299

269-
if (changeAmount > 0) {
300+
// Any change smaller than 0.0001 is contributed to the fee to avoid dust
301+
if (changeAmount >= 10000) {
270302
// Send remainder back to self:
271303
outputs.push(
272304
new TransactionOutput({
273305
value: Math.round(changeAmount),
274-
scriptPublicKey: addressToScriptPublicKey(address),
306+
scriptPublicKey: addressToScriptPublicKey(changeAddress),
275307
}),
276308
);
309+
} else {
310+
console.info(`Adding dust change ${changeAmount} sompi to fee`);
277311
}
278312

279313
const tx = new Transaction({

‎lib/settings-store.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
class SettingsStore {
2+
storageKey: string;
3+
settings: Object;
4+
constructor(storageKey: string) {
5+
this.storageKey = `kasvault:${storageKey}`;
6+
7+
const storedSettings: string = localStorage.getItem(this.storageKey);
8+
9+
if (storedSettings) {
10+
this.settings = JSON.parse(storedSettings);
11+
} else {
12+
this.settings = {
13+
receiveAddresses: {},
14+
lastReceiveIndex: 0,
15+
changeAddresses: {},
16+
lastChangeIndex: -1,
17+
version: 0,
18+
};
19+
localStorage.setItem(this.storageKey, JSON.stringify(this.settings));
20+
}
21+
}
22+
23+
setSetting(property, value) {
24+
this.settings[property] = value;
25+
localStorage.setItem(this.storageKey, JSON.stringify(this.settings));
26+
}
27+
28+
getSetting(property) {
29+
return this.settings[property];
30+
}
31+
}
32+
33+
export default SettingsStore;

‎lib/util.js

-5
This file was deleted.

‎lib/util.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export function delay(ms: number = 0) {
2+
return new Promise((resolve: (args: void) => void) => {
3+
setTimeout(resolve, ms);
4+
});
5+
}

‎package-lock.json

+29-5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,14 @@
3535
"react-qrcode-logo": "^2.9.0"
3636
},
3737
"devDependencies": {
38+
"@types/react": "18.2.46",
3839
"eslint": "^8.54.0",
3940
"eslint-config-next": "13.4.7",
4041
"eslint-config-prettier": "^8.10.0",
4142
"eslint-plugin-prettier": "^4.2.1",
4243
"jest": "^29.7.0",
43-
"prettier": "^2.8.8"
44+
"prettier": "^2.8.8",
45+
"typescript": "^5.3.3"
4446
},
4547
"overrides": {
4648
"eslint-plugin-import": {
File renamed without changes.
File renamed without changes.

‎tsconfig.json

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"compilerOptions": {
3+
"paths": {
4+
"@/*": [
5+
"./*"
6+
]
7+
},
8+
"target": "esnext",
9+
"lib": [
10+
"es6",
11+
"es7",
12+
"esnext",
13+
"dom"
14+
],
15+
"allowJs": true,
16+
"skipLibCheck": true,
17+
"strict": false, /* Enable all strict type-checking options. */
18+
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
19+
// "strictNullChecks": true, /* Enable strict null checks. */
20+
"strictFunctionTypes": true, /* Enable strict checking of function types. */
21+
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
22+
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
23+
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
24+
"noUnusedLocals": true, /* Report errors on unused locals. */
25+
"noUnusedParameters": true, /* Report errors on unused parameters. */
26+
"noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
27+
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
28+
"incremental": true,
29+
"esModuleInterop": true,
30+
"module": "esnext",
31+
"moduleResolution": "node",
32+
"resolveJsonModule": true,
33+
"isolatedModules": true,
34+
"jsx": "preserve",
35+
"plugins": [
36+
{
37+
"name": "next"
38+
}
39+
],
40+
"noEmit": true
41+
},
42+
"include": [
43+
"next-env.d.ts",
44+
".next/types/**/*.ts",
45+
"**/*.ts",
46+
"**/*.tsx"
47+
],
48+
"exclude": [
49+
"node_modules"
50+
]
51+
}

0 commit comments

Comments
 (0)
Please sign in to comment.