From f189b471a2ec816fa18bc69f1375acc21e761712 Mon Sep 17 00:00:00 2001 From: coderofstuff <114628839+coderofstuff@users.noreply.github.com> Date: Mon, 1 Jan 2024 23:12:36 -0700 Subject: [PATCH] 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 --- app/{layout.js => layout.tsx} | 3 +- app/{page.js => page.tsx} | 4 +- .../{addresses-tab.js => addresses-tab.tsx} | 0 .../{overview-tab.js => overview-tab.tsx} | 7 +- app/wallet/{page.js => page.tsx} | 66 +++-------------- ...ansactions-tab.js => transactions-tab.tsx} | 2 +- .../{address-text.js => address-text.tsx} | 0 components/{header.js => header.tsx} | 0 .../{kaspa-qrcode.js => kaspa-qrcode.tsx} | 0 .../{message-form.js => message-form.tsx} | 9 ++- .../{message-modal.js => message-modal.tsx} | 0 components/{send-form.js => send-form.tsx} | 71 ++++++++++-------- lib/{base32.js => base32.ts} | 16 ++-- lib/{bip32.js => bip32.ts} | 10 ++- lib/{kaspa-util.js => kaspa-util.ts} | 46 +++++++++--- lib/{ledger.js => ledger.ts} | 74 ++++++++++++++----- lib/settings-store.ts | 33 +++++++++ lib/util.js | 5 -- lib/util.ts | 5 ++ package-lock.json | 34 +++++++-- package.json | 4 +- tests/{bip32.test.js => bip32.test.ts} | 0 ...{kaspa-util.test.js => kaspa-util.test.ts} | 0 tsconfig.json | 51 +++++++++++++ 24 files changed, 293 insertions(+), 147 deletions(-) rename app/{layout.js => layout.tsx} (95%) rename app/{page.js => page.tsx} (96%) rename app/wallet/{addresses-tab.js => addresses-tab.tsx} (100%) rename app/wallet/{overview-tab.js => overview-tab.tsx} (97%) rename app/wallet/{page.js => page.tsx} (89%) rename app/wallet/{transactions-tab.js => transactions-tab.tsx} (98%) rename components/{address-text.js => address-text.tsx} (100%) rename components/{header.js => header.tsx} (100%) rename components/{kaspa-qrcode.js => kaspa-qrcode.tsx} (100%) rename components/{message-form.js => message-form.tsx} (92%) rename components/{message-modal.js => message-modal.tsx} (100%) rename components/{send-form.js => send-form.tsx} (78%) rename lib/{base32.js => base32.ts} (89%) rename lib/{bip32.js => bip32.ts} (61%) rename lib/{kaspa-util.js => kaspa-util.ts} (73%) rename lib/{ledger.js => ledger.ts} (83%) create mode 100644 lib/settings-store.ts delete mode 100644 lib/util.js create mode 100644 lib/util.ts rename tests/{bip32.test.js => bip32.test.ts} (100%) rename tests/{kaspa-util.test.js => kaspa-util.test.ts} (100%) create mode 100644 tsconfig.json diff --git a/app/layout.js b/app/layout.tsx similarity index 95% rename from app/layout.js rename to app/layout.tsx index d3c3d1f..826de42 100644 --- a/app/layout.js +++ b/app/layout.tsx @@ -22,10 +22,8 @@ export default function RootLayout({ children }) {

- Go to Demo Mode -> + Go to Demo Mode ->

(Replaced with bluetooth in the future) diff --git a/app/wallet/addresses-tab.js b/app/wallet/addresses-tab.tsx similarity index 100% rename from app/wallet/addresses-tab.js rename to app/wallet/addresses-tab.tsx diff --git a/app/wallet/overview-tab.js b/app/wallet/overview-tab.tsx similarity index 97% rename from app/wallet/overview-tab.js rename to app/wallet/overview-tab.tsx index c896783..be2d399 100644 --- a/app/wallet/overview-tab.js +++ b/app/wallet/overview-tab.tsx @@ -78,7 +78,8 @@ export default function OverviewTab(props) { console.error(e); notifications.show({ title: 'Address not verified', - message: 'Failed to verify address', + message: 'Failed to verify address on the device', + color: 'red', }); } @@ -167,7 +168,9 @@ export default function OverviewTab(props) { ); break; case 'Message': - signSection = ; + signSection = ( + + ); break; default: break; diff --git a/app/wallet/page.js b/app/wallet/page.tsx similarity index 89% rename from app/wallet/page.js rename to app/wallet/page.tsx index 6e0a6dd..0c32aa3 100644 --- a/app/wallet/page.js +++ b/app/wallet/page.tsx @@ -1,9 +1,9 @@ 'use client'; import styles from './page.module.css'; -import { getAddress, fetchAddressDetails, initTransport } from '../../lib/ledger.js'; +import { getAddress, fetchAddressDetails, initTransport } from '@/lib/ledger'; import { useState, useEffect } from 'react'; -import { Box, Stack, Tabs, Breadcrumbs, Anchor, Button, Center } from '@mantine/core'; +import { Stack, Tabs, Breadcrumbs, Anchor, Button, Center } from '@mantine/core'; import Header from '../../components/header'; import AddressesTab from './addresses-tab'; import OverviewTab from './overview-tab'; @@ -17,29 +17,11 @@ import { delay } from '@/lib/util'; import { useElementSize } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; -import { eslint } from '@/next.config'; +import SettingsStore from '@/lib/settings-store'; let loadingAddressBatch = false; let addressInitialized = false; -function loadAddresses(bip32, addressType = 0, from = 0, to = from + 10) { - const addresses = []; - - for (let addressIndex = from; addressIndex < to; addressIndex++) { - const derivationPath = `44'/111111'/0'/${addressType}/${addressIndex}`; - const address = bip32.getAddress(addressType, addressIndex); - - addresses.push({ - derivationPath, - address, - addressIndex, - addressType, - }); - } - - return addresses; -} - const addressFilter = (lastReceiveIndex) => { return (addressData, index) => { return ( @@ -174,43 +156,14 @@ function getDemoXPub() { }; } -class SettingsStore { - constructor(storageKey) { - this.storageKey = `kasvault:${storageKey}`; - this.settings = localStorage.getItem(this.storageKey); - - if (this.settings) { - this.settings = JSON.parse(this.settings); - } else { - this.settings = { - receiveAddresses: {}, - lastReceiveIndex: 0, - changeAddresses: {}, - lastChangeIndex: -1, - version: 0, - }; - localStorage.setItem(this.storageKey, JSON.stringify(this.settings)); - } - } - - setSetting(property, value) { - this.settings[property] = value; - localStorage.setItem(this.storageKey, JSON.stringify(this.settings)); - } - - getSetting(property) { - return this.settings[property]; - } -} - -export default function Dashboard(props) { +export default function Dashboard() { const [addresses, setAddresses] = useState([]); const [rawAddresses, setRawAddresses] = useState([]); const [selectedAddress, setSelectedAddress] = useState(null); const [activeTab, setActiveTab] = useState('addresses'); const [isTransportInitialized, setTransportInitialized] = useState(false); - const [bip32base, setBIP32Base] = useState(); - const [userSettings, setUserSettings] = useState(); + const [bip32base, setBIP32Base] = useState(); + const [userSettings, setUserSettings] = useState(); const [enableGenerate, setEnableGenerate] = useState(false); const { ref: containerRef, width: containerWidth, height: containerHeight } = useElementSize(); @@ -296,14 +249,14 @@ export default function Dashboard(props) { useEffect(() => { if (isTransportInitialized) { - return; + return () => {}; } if (deviceType === 'demo') { setTransportInitialized(true); const xpub = getDemoXPub(); setBIP32Base(new KaspaBIP32(xpub.compressedPublicKey, xpub.chainCode)); - return; + return () => {}; } let unloaded = false; @@ -317,6 +270,8 @@ export default function Dashboard(props) { setBIP32Base(new KaspaBIP32(xpub.compressedPublicKey, xpub.chainCode)), ); } + + return null; }) .catch((e) => { notifications.show({ @@ -431,6 +386,7 @@ export default function Dashboard(props) { setAddresses={setAddresses} containerWidth={containerWidth} containerHeight={containerHeight} + deviceType={deviceType} /> diff --git a/app/wallet/transactions-tab.js b/app/wallet/transactions-tab.tsx similarity index 98% rename from app/wallet/transactions-tab.js rename to app/wallet/transactions-tab.tsx index e30daa3..74e8dc6 100644 --- a/app/wallet/transactions-tab.js +++ b/app/wallet/transactions-tab.tsx @@ -11,7 +11,7 @@ import { Box, Loader, } from '@mantine/core'; -import { fetchTransactions, fetchTransactionCount } from '../../lib/ledger.js'; +import { fetchTransactions, fetchTransactionCount } from '@/lib/ledger'; import { useEffect, useState } from 'react'; import { format } from 'date-fns'; diff --git a/components/address-text.js b/components/address-text.tsx similarity index 100% rename from components/address-text.js rename to components/address-text.tsx diff --git a/components/header.js b/components/header.tsx similarity index 100% rename from components/header.js rename to components/header.tsx diff --git a/components/kaspa-qrcode.js b/components/kaspa-qrcode.tsx similarity index 100% rename from components/kaspa-qrcode.js rename to components/kaspa-qrcode.tsx diff --git a/components/message-form.js b/components/message-form.tsx similarity index 92% rename from components/message-form.js rename to components/message-form.tsx index 1c46998..0d313b0 100644 --- a/components/message-form.js +++ b/components/message-form.tsx @@ -8,7 +8,7 @@ import MessageModal from './message-modal'; import { notifications } from '@mantine/notifications'; export default function MessageForm(props) { - const [signature, setSignature] = useState(); + const [signature, setSignature] = useState(''); const [opened, { open, close }] = useDisclosure(false); const form = useForm({ @@ -41,7 +41,12 @@ export default function MessageForm(props) { try { const path = props.selectedAddress.derivationPath.split('/'); - const result = await signMessage(form.values.message, Number(path[3]), Number(path[4])); + const result = await signMessage( + form.values.message, + Number(path[3]), + Number(path[4]), + props.deviceType, + ); setSignature(result.signature); open(); diff --git a/components/message-modal.js b/components/message-modal.tsx similarity index 100% rename from components/message-modal.js rename to components/message-modal.tsx diff --git a/components/send-form.js b/components/send-form.tsx similarity index 78% rename from components/send-form.js rename to components/send-form.tsx index 74df23c..4fcef81 100644 --- a/components/send-form.js +++ b/components/send-form.tsx @@ -18,14 +18,14 @@ import { useSearchParams } from 'next/navigation'; import { useState, useEffect } from 'react'; import { createTransaction, sendAmount, selectUtxos } from '@/lib/ledger'; -import styles from './send-form.module.css'; import AddressText from '@/components/address-text'; import { useForm } from '@mantine/form'; +import { kasToSompi, sompiToKas } from '@/lib/kaspa-util'; export default function SendForm(props) { const [confirming, setConfirming] = useState(false); - const [fee, setFee] = useState('-'); - const [amountDescription, setAmountDescription] = useState(); + const [fee, setFee] = useState('-'); + const [amountDescription, setAmountDescription] = useState(); const [canSendAmount, setCanSendAmount] = useState(false); @@ -37,7 +37,7 @@ export default function SendForm(props) { const form = useForm({ initialValues: { - amount: '', + amount: undefined, sendTo: '', includeFeeInAmount: false, sentAmount: '', @@ -55,20 +55,18 @@ export default function SendForm(props) { // Reset setup setConfirming(false); setFee('-'); - const baseValues = { amount: '', sendTo: '', includeFeeInAmount: false }; + let baseValues = { amount: '', sendTo: '', includeFeeInAmount: false }; if (resetAllValues) { - baseValues.sentTo = ''; - baseValues.sentTxId = ''; - baseValues.sentAmount = ''; + form.setValues({ sentTo: '', sentTxId: '', sentAmount: '', ...baseValues }); + } else { + form.setValues(baseValues); } - - form.setValues(baseValues); }; const cleanupOnSuccess = (transactionId) => { const targetAmount = form.values.includeFeeInAmount - ? Number((form.values.amount - fee).toFixed(8)) + ? (Number(form.values.amount) - Number(fee)).toFixed(8) : form.values.amount; form.setValues({ @@ -117,7 +115,7 @@ export default function SendForm(props) { } else if (deviceType == 'usb') { try { const { tx } = createTransaction( - Math.round(form.values.amount * 100000000), + kasToSompi(Number(form.values.amount)), form.values.sendTo, props.addressContext.utxos, props.addressContext.derivationPath, @@ -147,26 +145,41 @@ export default function SendForm(props) { setAmountDescription(''); if (amount && sendTo) { - let calculatedFee = '-'; + let calculatedFee: string | number = '-'; if (deviceType === 'demo') { calculatedFee = - fee === '-' ? Math.round(Math.random() * 10000) / 100000000 : Number(fee); + fee === '-' ? sompiToKas(Math.round(Math.random() * 10000)) : Number(fee); setCanSendAmount(Number(amount) <= props.addressContext.balance - calculatedFee); if (includeFeeInAmount) { - setAmountDescription(`Amount after fee: ${amount - calculatedFee}`); + const afterFeeDisplay = sompiToKas(kasToSompi(amount) - calculatedFee); + setAmountDescription(`Amount after fee: ${afterFeeDisplay}`); } } else if (deviceType === 'usb') { - const [hasEnough, selectedUtxos, feeCalcResult] = selectUtxos( - amount * 100000000, - props.addressContext.utxos, - includeFeeInAmount, - ); + const { + hasEnough, + fee: feeCalcResult, + total: utxoTotalAmount, + } = selectUtxos(kasToSompi(amount), props.addressContext.utxos, includeFeeInAmount); if (hasEnough) { - calculatedFee = feeCalcResult / 100000000; + let changeAmount = utxoTotalAmount - kasToSompi(amount); + if (!includeFeeInAmount) { + changeAmount -= feeCalcResult; + } + + let expectedFee = feeCalcResult; + // The change is added to the fee if it's less than 0.0001 KAS + console.info('changeAmount', changeAmount); + if (changeAmount < 10000) { + console.info(`Adding dust change ${changeAmount} sompi to fee`); + expectedFee += changeAmount; + } + + calculatedFee = sompiToKas(expectedFee); + const afterFeeDisplay = sompiToKas(kasToSompi(amount) - expectedFee); setCanSendAmount(true); if (includeFeeInAmount) { - setAmountDescription(`Amount after fee: ${amount - calculatedFee}`); + setAmountDescription(`Amount after fee: ${afterFeeDisplay}`); } } else { setCanSendAmount(false); @@ -189,7 +202,7 @@ export default function SendForm(props) { }, 0); form.setValues({ - amount: Number((total / 100000000).toFixed(8)), + amount: sompiToKas(total), includeFeeInAmount: true, }); }; @@ -201,7 +214,7 @@ export default function SendForm(props) { label='Send to Address' placeholder='Address' {...form.getInputProps('sendTo')} - disabled={form.getInputProps('sendTo').disabled || confirming} + disabled={confirming} required /> @@ -228,7 +241,7 @@ export default function SendForm(props) { @@ -252,7 +265,7 @@ export default function SendForm(props) { size={viewportWidth > 700 ? 'auto' : 'md'} > - + Sent! @@ -262,23 +275,21 @@ export default function SendForm(props) { href={`https://explorer.kaspa.org/txs/${form.values.sentTxId}`} target='_blank' c='brand' - align='center' w={'calc(var(--modal-size) - 6rem)'} style={{ overflowWrap: 'break-word' }} > {form.values.sentTxId} - + {form.values.sentAmount} KAS - sent to + sent to diff --git a/lib/base32.js b/lib/base32.ts similarity index 89% rename from lib/base32.js rename to lib/base32.ts index e72b996..7893e4b 100644 --- a/lib/base32.js +++ b/lib/base32.ts @@ -10,12 +10,12 @@ /*** * Charset containing the 32 symbols used in the base32 encoding. */ -var CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; +const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; /*** * Inverted index mapping each symbol into its index within the charset. */ -var CHARSET_INVERSE_INDEX = { +const CHARSET_INVERSE_INDEX = { q: 0, p: 1, z: 2, @@ -55,7 +55,7 @@ var CHARSET_INVERSE_INDEX = { * * @param {Array} data Array of integers between 0 and 31 inclusive. */ -function encode(data) { +export function encode(data) { if (!(data instanceof Array)) { throw new Error('Must be Array'); } @@ -75,7 +75,7 @@ function encode(data) { * * @param {string} base32 */ -function decode(base32) { +export function decode(base32) { if (typeof base32 !== 'string') { throw new Error('Must be base32-encoded string'); } @@ -90,7 +90,9 @@ function decode(base32) { return data; } -module.exports = { - encode: encode, - decode: decode, +const base32 = { + encode, + decode, }; + +export default base32; diff --git a/lib/bip32.js b/lib/bip32.ts similarity index 61% rename from lib/bip32.js rename to lib/bip32.ts index b98ff38..2b847dd 100644 --- a/lib/bip32.js +++ b/lib/bip32.ts @@ -1,15 +1,17 @@ import ecc from '@bitcoinerlab/secp256k1'; -import BIP32Factory from 'bip32'; +import BIP32Factory, { BIP32API, BIP32Interface } from 'bip32'; import { publicKeyToAddress } from './kaspa-util'; -const bip32 = BIP32Factory(ecc); +const bip32: BIP32API = BIP32Factory(ecc); export default class KaspaBIP32 { - constructor(compressedPublicKey, chainCode) { + rootNode: BIP32Interface; + + constructor(compressedPublicKey: Buffer, chainCode: Buffer) { this.rootNode = bip32.fromPublicKey(compressedPublicKey, chainCode); } - getAddress(type = 0, index = 0) { + getAddress(type: number = 0, index: number = 0) { const child = this.rootNode.derivePath(`${type}/${index}`); // child.publicKey is a compressed public key diff --git a/lib/kaspa-util.js b/lib/kaspa-util.ts similarity index 73% rename from lib/kaspa-util.js rename to lib/kaspa-util.ts index 4913f78..8ac734d 100644 --- a/lib/kaspa-util.js +++ b/lib/kaspa-util.ts @@ -1,6 +1,6 @@ -import base32 from './base32'; +import base32 from '@/lib/base32'; -function convertBits(data, from, to, strict) { +function convertBits(data: number[], from: number, to: number, strict: boolean = false): number[] { strict = strict || false; var accumulator = 0; var bits = 0; @@ -89,7 +89,11 @@ function checksumToArray(checksum) { return result.reverse(); } -export function publicKeyToAddress(hashBuffer, stripPrefix, type = 'schnorr') { +export function publicKeyToAddress( + hashBuffer: Buffer, + stripPrefix: boolean, + type: string = 'schnorr', +): string { function getTypeBits(type) { switch (type) { case 'schnorr': @@ -103,12 +107,12 @@ export function publicKeyToAddress(hashBuffer, stripPrefix, type = 'schnorr') { } } - var eight0 = [0, 0, 0, 0, 0, 0, 0, 0]; + var eight0: number[] = [0, 0, 0, 0, 0, 0, 0, 0]; var prefixData = prefixToArray('kaspa').concat([0]); - var versionByte = getTypeBits(type); - var arr = Array.prototype.slice.call(hashBuffer, 0); - var payloadData = convertBits([versionByte].concat(arr), 8, 5); - var checksumData = prefixData.concat(payloadData).concat(eight0); + var versionByte: number = getTypeBits(type); + var arr: number[] = Array.prototype.slice.call(hashBuffer, 0); + var payloadData: number[] = convertBits([versionByte].concat(arr), 8, 5); + var checksumData: number[] = prefixData.concat(payloadData).concat(eight0); var payload = payloadData.concat(checksumToArray(polymod(checksumData))); if (stripPrefix === true) { return base32.encode(payload); @@ -117,7 +121,7 @@ export function publicKeyToAddress(hashBuffer, stripPrefix, type = 'schnorr') { } } -export function addressToPublicKey(address) { +export function addressToPublicKey(address: string): { version: number; publicKey: number[] } { const addrPart = address.split(':')[1]; const payload = convertBits(base32.decode(addrPart), 5, 8); @@ -133,7 +137,7 @@ export function addressToPublicKey(address) { } } -function numArrayToHexString(numArray = []) { +function numArrayToHexString(numArray = []): string { const hexArr = []; for (const num of numArray) { @@ -143,7 +147,7 @@ function numArrayToHexString(numArray = []) { return hexArr.join(''); } -export function addressToScriptPublicKey(address) { +export function addressToScriptPublicKey(address: string): string { const { version, publicKey } = addressToPublicKey(address); switch (version) { @@ -157,3 +161,23 @@ export function addressToScriptPublicKey(address) { throw new Error('Address could not be translated to script public key'); } } + +export function sompiToKas(amount: number) { + const amountStr = '00000000' + amount; + return Number(amountStr.slice(0, -8) + '.' + amountStr.slice(-8)); +} + +export function kasToSompi(amount: number) { + const amountStr = String(amount); + const parts = amountStr.split('.'); + + if (parts.length === 1) { + return Number(amountStr + '00000000'); + } else if (parts.length === 2) { + const [left, right] = parts; + const rightStr = right + '00000000'; + return Number(left + rightStr.slice(0, 8)); + } else { + throw new Error('Invalid amount'); + } +} diff --git a/lib/ledger.js b/lib/ledger.ts similarity index 83% rename from lib/ledger.js rename to lib/ledger.ts index cec6203..7ad0349 100644 --- a/lib/ledger.js +++ b/lib/ledger.ts @@ -1,4 +1,3 @@ -import Transport from '@ledgerhq/hw-transport'; import TransportWebHID from '@ledgerhq/hw-transport-webhid'; import axios from 'axios'; import axiosRetry from 'axios-retry'; @@ -19,13 +18,32 @@ let transportState = { type: null, }; -export async function fetchTransaction(transactionId) { +export async function fetchTransaction(transactionId: string) { const { data: txData } = await axios.get(`https://api.kaspa.org/transactions/${transactionId}`); return txData; } -export function selectUtxos(amount, utxosInput, feeIncluded = false) { +export type UtxoSelectionResult = { + hasEnough: boolean; + utxos: Array; + fee: number; + total: number; +}; + +/** + * Selects the UTXOs to fulfill the amount requested + * + * @param amount - the amount to select for, in SOMPI + * @param utxosInput - the utxos array to select from + * @param feeIncluded - whether or not fees are included in the amount passed + * @returns [has_enough, utxos, fee, total] + */ +export function selectUtxos( + amount: number, + utxosInput: UtxoInfo[], + feeIncluded: boolean = false, +): UtxoSelectionResult { // Fee does not have to be accurate. It just has to be over the absolute minimum. // https://kaspa-mdbook.aspectron.com/transactions/constraints/fees.html // Fee = (total mass) x (min_relay_tx_fee) / 1000 @@ -60,7 +78,7 @@ export function selectUtxos(amount, utxosInput, feeIncluded = false) { selected.push(utxo); - const targetAmount = feeIncluded ? Number((amount - fee).toFixed(8)) : amount; + const targetAmount = feeIncluded ? amount - fee : amount; console.info({ targetAmount, amount, @@ -68,15 +86,17 @@ export function selectUtxos(amount, utxosInput, feeIncluded = false) { total, }); - if (total >= targetAmount + fee) { + const totalSpend = targetAmount + fee; + // If we have change, we want to try to use at least 2 UTXOs + if (total == totalSpend || (total > totalSpend && selected.length > 1)) { // We have enough break; } } // [has_enough, utxos, fee, total] - const targetAmount = feeIncluded ? Number((amount - fee).toFixed(8)) : amount; - return [total >= targetAmount + fee, selected, fee, total]; + const targetAmount = feeIncluded ? amount - fee : amount; + return { hasEnough: total >= targetAmount + fee, utxos: selected, fee, total }; } export async function initTransport(type = 'usb') { @@ -103,6 +123,12 @@ export async function fetchTransactionCount(address) { return txCount.total || 0; } +export type UtxoInfo = { + prevTxId: string; + outpointIndex: number; + amount: number; +}; + export async function fetchAddressDetails(address, derivationPath) { const { data: balanceData } = await axios.get( `https://api.kaspa.org/addresses/${address}/balance`, @@ -111,7 +137,7 @@ export async function fetchAddressDetails(address, derivationPath) { // UTXOs sorted by decreasing amount. Using the biggest UTXOs first minimizes number of utxos needed // in a transaction - const utxos = utxoData + const utxos: UtxoInfo[] = utxoData .map((utxo) => { return { prevTxId: utxo.outpoint.transactionId, @@ -119,7 +145,7 @@ export async function fetchAddressDetails(address, derivationPath) { amount: Number(utxo.utxoEntry.amount), }; }) - .sort((a, b) => b.amount - a.amount); + .sort((a: UtxoInfo, b: UtxoInfo) => b.amount - a.amount); const path = derivationPath.split('/'); @@ -217,19 +243,24 @@ export const sendTransaction = async (signedTx) => { }; export function createTransaction( - amount, - sendTo, - utxosInput, - derivationPath, - address, - feeIncluded, + amount: number, + sendTo: string, + utxosInput: any, + derivationPath: string, + changeAddress: string, + feeIncluded: boolean = false, ) { console.info('Amount:', amount); console.info('Send to:', sendTo); console.info('UTXOs:', utxosInput); console.info('Derivation Path:', derivationPath); - const [hasEnough, utxos, fee, totalUtxoAmount] = selectUtxos(amount, utxosInput, feeIncluded); + const { + hasEnough, + utxos, + fee, + total: totalUtxoAmount, + } = selectUtxos(amount, utxosInput, feeIncluded); console.info('hasEnough', hasEnough); console.info(utxos); @@ -242,7 +273,7 @@ export function createTransaction( const path = derivationPath.split('/'); console.info('Split Path:', path); - const inputs = utxos.map( + const inputs: TransactionInput[] = utxos.map( (utxo) => new TransactionInput({ value: utxo.amount, @@ -253,7 +284,7 @@ export function createTransaction( }), ); - const outputs = []; + const outputs: TransactionOutput[] = []; const targetAmount = feeIncluded ? Number((amount - fee).toFixed(8)) : amount; @@ -266,14 +297,17 @@ export function createTransaction( const changeAmount = totalUtxoAmount - targetAmount - fee; - if (changeAmount > 0) { + // Any change smaller than 0.0001 is contributed to the fee to avoid dust + if (changeAmount >= 10000) { // Send remainder back to self: outputs.push( new TransactionOutput({ value: Math.round(changeAmount), - scriptPublicKey: addressToScriptPublicKey(address), + scriptPublicKey: addressToScriptPublicKey(changeAddress), }), ); + } else { + console.info(`Adding dust change ${changeAmount} sompi to fee`); } const tx = new Transaction({ diff --git a/lib/settings-store.ts b/lib/settings-store.ts new file mode 100644 index 0000000..1fb4ff4 --- /dev/null +++ b/lib/settings-store.ts @@ -0,0 +1,33 @@ +class SettingsStore { + storageKey: string; + settings: Object; + constructor(storageKey: string) { + this.storageKey = `kasvault:${storageKey}`; + + const storedSettings: string = localStorage.getItem(this.storageKey); + + if (storedSettings) { + this.settings = JSON.parse(storedSettings); + } else { + this.settings = { + receiveAddresses: {}, + lastReceiveIndex: 0, + changeAddresses: {}, + lastChangeIndex: -1, + version: 0, + }; + localStorage.setItem(this.storageKey, JSON.stringify(this.settings)); + } + } + + setSetting(property, value) { + this.settings[property] = value; + localStorage.setItem(this.storageKey, JSON.stringify(this.settings)); + } + + getSetting(property) { + return this.settings[property]; + } +} + +export default SettingsStore; diff --git a/lib/util.js b/lib/util.js deleted file mode 100644 index fdbc8f9..0000000 --- a/lib/util.js +++ /dev/null @@ -1,5 +0,0 @@ -export function delay(ms = 0) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} diff --git a/lib/util.ts b/lib/util.ts new file mode 100644 index 0000000..5234ffa --- /dev/null +++ b/lib/util.ts @@ -0,0 +1,5 @@ +export function delay(ms: number = 0) { + return new Promise((resolve: (args: void) => void) => { + setTimeout(resolve, ms); + }); +} diff --git a/package-lock.json b/package-lock.json index 7634244..0116e78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,12 +32,14 @@ "react-qrcode-logo": "^2.9.0" }, "devDependencies": { + "@types/react": "18.2.46", "eslint": "^8.54.0", "eslint-config-next": "13.4.7", "eslint-config-prettier": "^8.10.0", "eslint-plugin-prettier": "^4.2.1", "jest": "^29.7.0", - "prettier": "^2.8.8" + "prettier": "^2.8.8", + "typescript": "^5.3.3" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -1947,6 +1949,29 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" }, + "node_modules/@types/prop-types": { + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", + "devOptional": true + }, + "node_modules/@types/react": { + "version": "18.2.46", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.46.tgz", + "integrity": "sha512-nNCvVBcZlvX4NU1nRRNV/mFl1nNRuTuslAJglQsq+8ldXe5Xv0Wd2f7WTE3jOxhLH2BFfiZGC6GCp+kHQbgG+w==", + "devOptional": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "devOptional": true + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -7657,11 +7682,10 @@ "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==" }, "node_modules/typescript": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", - "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 0dab4c3..d9a0f48 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,14 @@ "react-qrcode-logo": "^2.9.0" }, "devDependencies": { + "@types/react": "18.2.46", "eslint": "^8.54.0", "eslint-config-next": "13.4.7", "eslint-config-prettier": "^8.10.0", "eslint-plugin-prettier": "^4.2.1", "jest": "^29.7.0", - "prettier": "^2.8.8" + "prettier": "^2.8.8", + "typescript": "^5.3.3" }, "overrides": { "eslint-plugin-import": { diff --git a/tests/bip32.test.js b/tests/bip32.test.ts similarity index 100% rename from tests/bip32.test.js rename to tests/bip32.test.ts diff --git a/tests/kaspa-util.test.js b/tests/kaspa-util.test.ts similarity index 100% rename from tests/kaspa-util.test.js rename to tests/kaspa-util.test.ts diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..df7993e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,51 @@ +{ + "compilerOptions": { + "paths": { + "@/*": [ + "./*" + ] + }, + "target": "esnext", + "lib": [ + "es6", + "es7", + "esnext", + "dom" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": false, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + "noUnusedLocals": true, /* Report errors on unused locals. */ + "noUnusedParameters": true, /* Report errors on unused parameters. */ + "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + "incremental": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ], + "noEmit": true + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +}