UNPKG

@dolaned/wallet-sdk-ts

Version:

Wallet SDK for the Nexa blockchain

522 lines (456 loc) 21.6 kB
import {rostrumProvider} from "../network/RostrumProvider"; import {AccountIndexes, AccountKeys, AddressKey, Balance} from "../models/wallet.entities"; import * as Bip39 from 'bip39'; import bigDecimal from "js-big-decimal"; import {ITXHistory, ITXInput, ITXOutput} from "../models/rostrum.entities"; import {Address, AddressType, HDPrivateKey, Networkish} from "libnexa-ts"; import {isNil} from "lodash-es"; import {BaseAccount} from "../wallet/accounts/interfaces/BaseAccountInterface"; import DAppAccount from "../wallet/accounts/models/DappAccount"; import DefaultAccount from "../wallet/accounts/models/DefaultAccount"; import VaultAccount from "../wallet/accounts/models/VaultAccount"; import {TransactionEntity, TxEntityState} from "../models/transaction.entities"; import {currentTimestamp, isNullOrEmpty} from "./CommonUtils"; export enum TxTokenType { NO_GROUP, CREATE, MINT, MELT, RENEW, TRANSFER } export enum AccountType { NEXA_ACCOUNT, VAULT_ACCOUNT, DAPP_ACCOUNT, } export function isValidNexaAddress(address: string, network: Networkish, type = AddressType.PayToScriptTemplate) { return Address.isValid(address, network, type); } export function generateMasterKey(mnemonic: string, passphrase?: string | undefined) { const seed = Bip39.mnemonicToSeedSync(mnemonic, passphrase); const masterKey = HDPrivateKey.fromSeed(seed); return masterKey.deriveChild(44, true).deriveChild(29223, true); } export function generateAccountKey(masterKey: HDPrivateKey, account: number) { return masterKey.deriveChild(account, true); } export function generateKeysAndAddresses(accountKey: HDPrivateKey, fromRIndex: number, rIndex: number, fromCIndex: number, cIndex: number): AccountKeys { if (fromRIndex < 0) { throw new Error(`Can not generate keys with fromRIndex ${fromRIndex}. must be >= 0.`); } if (fromCIndex < 0) { throw new Error(`Can not generate keys with fromCIndex ${fromCIndex}. must be >= 0.`); } let receive = accountKey.deriveChild(0, false); let change = accountKey.deriveChild(1, false); let rKeys: AddressKey[] = [], cKeys: AddressKey[] = []; for (let index = fromRIndex; index < rIndex; index++) { let k = receive.deriveChild(index, false); let addr = k.privateKey.toAddress().toString(); rKeys.push({key: k, address: addr, balance: "0", tokensBalance: {}}); } for (let index = fromCIndex; index < cIndex; index++) { let k = change.deriveChild(index, false); let addr = k.privateKey.toAddress().toString(); cKeys.push({key: k, address: addr, balance: "0", tokensBalance: {}}); } return {receiveKeys: rKeys, changeKeys: cKeys}; } export function generateKeyAndAddress(accountKey: HDPrivateKey, rIndex: number): AddressKey { let receive = accountKey.deriveChild(0, false); let k = receive.deriveChild(rIndex, false); let addr = k.privateKey.toAddress().toString(); return ({key: k, address: addr, balance: "0", tokensBalance: {}}); } async function discoverUsedAccountIndexes(deriveKey: HDPrivateKey) { let lastUsed = -1, index = 0, toScan = 20; while (toScan > 0) { toScan--; let rAddr = deriveKey.deriveChild(index, false).privateKey.toAddress().toString(); let isUsed = await isAddressUsed(rAddr); if (isUsed) { lastUsed = index; toScan = 20; } index++; } // return the last used index, returns -1 if no indexes are used return lastUsed; } export async function discoverNexaAccount(accountKey: HDPrivateKey) { let receiveKey = accountKey.deriveChild(0, false); let changeKey = accountKey.deriveChild(1, false); let rIndexPromise = discoverUsedAccountIndexes(receiveKey); let cIndexPromise = discoverUsedAccountIndexes(changeKey); let [rIndex, cIndex] = await Promise.all([rIndexPromise, cIndexPromise]); // get the index that is the last used nexa addr let indexes: AccountIndexes = { rIndex: rIndex, cIndex: cIndex }; return indexes; } export async function discoverNexaAccounts(masterKey: HDPrivateKey) { let accounts: DefaultAccount[] = []; let index = 0; while (true) { const nexaAccountKey = generateAccountKey(masterKey, index); const indexes = await discoverNexaAccount(nexaAccountKey); if (indexes.rIndex < 0 && indexes.cIndex < 0) { break; } if (indexes.rIndex < 0) { indexes.rIndex = 0; } if (indexes.cIndex < 0) { indexes.cIndex = 0; } // make account after break check, otherwise we might push an empty account const nexaAccount = new DefaultAccount(index, indexes, generateKeysAndAddresses(nexaAccountKey, indexes.rIndex + 1, indexes.rIndex + 20, indexes.cIndex + 1, indexes.cIndex + 20)) await nexaAccount.loadBalances() accounts.push(nexaAccount); if (index == 0) { index = 100; } else { index++; } } if (accounts.length == 0) { // default account was unused but we need to populate at least one account, // make the default account here let defaultNexaAccountKey = generateAccountKey(masterKey, 0); // get the last used indexes, -1 means unused let defaultIndexes: AccountIndexes = { rIndex: 0, cIndex: 0 }; const defaultAccount = new DefaultAccount(0, defaultIndexes, generateKeysAndAddresses(defaultNexaAccountKey, defaultIndexes.rIndex, defaultIndexes.rIndex + 20, defaultIndexes.cIndex, defaultIndexes.cIndex + 20)) await defaultAccount.loadBalances() accounts.push(defaultAccount) } return accounts; } async function findUsedVaultAccounts(masterKey: HDPrivateKey) { // all vaults are in bip44 account 1 let vaultAccountKey = generateAccountKey(masterKey, 1); // all vaults are in the external chain let vaultChain = vaultAccountKey.deriveChild(0, false); // get the index that is the last used vault return await discoverUsedAccountIndexes(vaultChain); } export async function discoverVaults(masterKey: HDPrivateKey) { let accounts: VaultAccount[] = []; // all vaults are in bip44 account 1 let vaultAccountKey = generateAccountKey(masterKey, 1); // find the next unused vault let lastUsedVaultIndex: number = await findUsedVaultAccounts(masterKey); // if all vaults unused, generate at least the first vault account if (lastUsedVaultIndex < 0) lastUsedVaultIndex = 0; // for each vault found, make the DefaultAccount for that vault for (let index = 0; index <= lastUsedVaultIndex; index++) { const vaultAccount = new VaultAccount(1, index, generateKeyAndAddress(vaultAccountKey, index)) await vaultAccount.loadBalances(); accounts.push(vaultAccount); } return accounts; } async function findUsedDappAccounts(masterKey: HDPrivateKey) { // all dApp accounts are in bip44 account 2 let dappAccountKey = generateAccountKey(masterKey, 2); // all dApp accounts use the external chain let dappChain = dappAccountKey.deriveChild(0, false); // get the index that is the next unused dApp account return await discoverUsedAccountIndexes(dappChain); } export async function discoverDappAccounts(masterKey: HDPrivateKey) { let accounts: DAppAccount[] = []; // all dApp accounts are in bip44 account 2 let dappAccountKey = generateAccountKey(masterKey, 2); // find the next unused dapp account let lastUsedDappIndex: number = await findUsedDappAccounts(masterKey); // if all dapp accounts unused, generate at least the first dapp account if (lastUsedDappIndex < 0) lastUsedDappIndex = 0; // for each dApp account found, make the DefaultAccount for that dApp account // for each vault found, make the DefaultAccount for that vault for (let index = 0; index <= lastUsedDappIndex; index++) { const dappAccount = new DAppAccount(2, index, generateKeyAndAddress(dappAccountKey, index)) await dappAccount.loadBalances(); accounts.push(dappAccount); } return accounts; } export async function discoverWallet(masterKey: HDPrivateKey) { let accounts: BaseAccount[] = []; // accounts 0, 100+ const nexaAccounts = await discoverNexaAccounts(masterKey); // vaults in bip44 account 1 const vaultAccounts = await discoverVaults(masterKey); // dApp accounts in bip44 account 2 const dappAccounts = await discoverDappAccounts(masterKey); // 3 - 99 are reserved and will go here when added accounts = accounts.concat(nexaAccounts); accounts = accounts.concat(vaultAccounts); accounts = accounts.concat(dappAccounts); return accounts; } async function isAddressUsed(address: string) { try { let firstUse = await rostrumProvider.getFirstUse(address); return firstUse.tx_hash && firstUse.tx_hash !== ""; } catch (e) { if (e instanceof Error && e.message.includes("not found")) { return false; } throw e; } } async function getKeyTokenBalance(key: AddressKey) { let tokensBalance = await rostrumProvider.getTokensBalance(key.address); let balance: Record<string, Balance> = {}; for (const cToken in tokensBalance.confirmed) { if (tokensBalance.confirmed[cToken] != 0) { balance[cToken] = { confirmed: BigInt(tokensBalance.confirmed[cToken]).toString(), unconfirmed: "0" } } } for (const uToken in tokensBalance.unconfirmed) { if (tokensBalance.unconfirmed[uToken] != 0) { if (balance[uToken]) { balance[uToken].unconfirmed = BigInt(tokensBalance.unconfirmed[uToken]).toString(); } else { balance[uToken] = { confirmed: "0", unconfirmed: BigInt(tokensBalance.unconfirmed[uToken]).toString() } } } } return balance; } async function getAndUpdateAddressKeyBalance(key: AddressKey) { let balance = await rostrumProvider.getBalance(key.address); key.balance = (BigInt(balance.confirmed) + BigInt(balance.unconfirmed)).toString(); key.tokensBalance = await getKeyTokenBalance(key); return balance; } export async function fetchTotalBalance(keys: AddressKey[]) { let promises: Promise<Balance>[] = []; keys.forEach(key => { let b = getAndUpdateAddressKeyBalance(key); promises.push(b); }); return await Promise.all(promises); } export function sumBalance(balances: Balance[]): Balance { let confirmed = new bigDecimal(0), unconfirmed = new bigDecimal(0); balances.forEach(b => { confirmed = confirmed.add(new bigDecimal(b.confirmed)); unconfirmed = unconfirmed.add(new bigDecimal(b.unconfirmed)); }); return {confirmed: confirmed.getValue(), unconfirmed: unconfirmed.getValue()}; } export function sumTokensBalance(balances: Record<string, Balance>[]) { let tokensBalance: Record<string, Balance> = {}; balances.forEach(b => { for (const key in b) { if (tokensBalance[key]) { tokensBalance[key].confirmed = (BigInt(tokensBalance[key].confirmed) + BigInt(b[key].confirmed)).toString(); tokensBalance[key].unconfirmed = (BigInt(tokensBalance[key].unconfirmed) + BigInt(b[key].unconfirmed)).toString(); } else { tokensBalance[key] = { confirmed: b[key].confirmed, unconfirmed: b[key].unconfirmed }; } } }); return tokensBalance; } export async function fetchTransactionsHistory(addresses: string[], fromHeight: number) { let index = 0, i = 0, data = new Map<string, ITXHistory>(), maxHeight = fromHeight; for (let address of addresses) { i++; let txHistory = await rostrumProvider.getTransactionsHistory(address); if (txHistory && txHistory.length > 0) { index = i; for (let tx of txHistory) { if (tx.height === 0 || tx.height > fromHeight) { maxHeight = Math.max(maxHeight, tx.height); data.set(tx.tx_hash, tx); } } } } return {index: index, txs: data, lastHeight: maxHeight}; } async function rescanAddressesHistory(addresses: string[]) { let index = 0, i = 0, minHeight = Number.MAX_SAFE_INTEGER; for (let address of addresses) { i++; let txHistory = await rostrumProvider.getTransactionsHistory(address); if (!isNil(txHistory)) { index = i; let heights = txHistory.filter(tx => tx.height > 0).map(h => h.height); if (!isNil(heights)) { minHeight = Math.min(minHeight, ...heights); } } } return {index: (index > 0 ? index + 1 : 0), height: (minHeight == Number.MAX_SAFE_INTEGER ? 0 : minHeight)}; } export async function getNextAccountIndex(accountType: AccountType, masterKey: HDPrivateKey) { if (accountType == AccountType.NEXA_ACCOUNT) { let defaultNexaAccountKey = generateAccountKey(masterKey, 0); const defaultIndexes = await discoverNexaAccount(defaultNexaAccountKey); if (defaultIndexes.rIndex < 0 && defaultIndexes.cIndex < 0){ return 0; } else { // account 0 was not empty for (let index = 100; ; index++) { const nexaAccountKey = generateAccountKey(masterKey, index); const indexes = await discoverNexaAccount(nexaAccountKey); if (indexes.rIndex < 0 && indexes.cIndex < 0) { return index; } } } } else if (accountType == AccountType.VAULT_ACCOUNT) { // find the next unused vault const lastUsedVault: number = await findUsedVaultAccounts(masterKey); return lastUsedVault + 1; } else if (accountType == AccountType.DAPP_ACCOUNT) { // find the next unused dapp account const lastUsedDappAccount: number = await findUsedDappAccounts(masterKey); return lastUsedDappAccount + 1; } else { throw new Error("Can not get next account index. Invalid accountType."); } } export async function classifyTransaction(txHistory: ITXHistory, myAddresses: string[]) { let t = await rostrumProvider.getTransaction(txHistory.tx_hash); let outputs = t.vout.filter(utxo => !isNil(utxo.scriptPubKey.addresses)); let isOutgoing = t.vin.length > 0 && myAddresses.includes(t.vin[0].addresses[0]); let isIncoming = !isOutgoing || outputs.every(utxo => myAddresses.includes(utxo.scriptPubKey.addresses[0])); let isConfirmed = t.height > 0; let txEntry = {} as TransactionEntity; txEntry.txId = t.txid; txEntry.txIdem = t.txidem; txEntry.height = isConfirmed ? t.height : 0; txEntry.time = isConfirmed ? t.time : currentTimestamp(); txEntry.fee = t.fee_satoshi; if (isOutgoing && isIncoming) { txEntry.state = 'both'; txEntry.value = "0"; txEntry.payTo = "Payment to yourself"; } else if (isIncoming) { txEntry.state = 'incoming'; let utxos = outputs.filter(utxo => myAddresses.includes(utxo.scriptPubKey.addresses[0])); let amount = new bigDecimal(0); utxos.forEach(utxo => { amount = amount.add(new bigDecimal(utxo.value_satoshi)); }); txEntry.value = amount.getValue(); txEntry.payTo = utxos[0].scriptPubKey.addresses[0]; } else if(isOutgoing) { txEntry.state = 'outgoing'; let utxos = outputs.filter(utxo => !myAddresses.includes(utxo.scriptPubKey.addresses[0])); let amount = new bigDecimal(0); utxos.forEach(utxo => { amount = amount.add(new bigDecimal(utxo.value_satoshi)); }); txEntry.value = amount.getValue(); txEntry.payTo = utxos[0].scriptPubKey.addresses[0]; } let [txType, txGroup, tokenAmount, extraGroup] = classifyTokenTransaction(t.vin, outputs, txEntry.state, myAddresses); txEntry.txGroupType = txType; txEntry.token = txGroup; txEntry.tokenAmount = tokenAmount; txEntry.extraGroup = extraGroup; return txEntry; } function classifyTokenTransaction(vin: ITXInput[], vout: ITXOutput[], txState: TxEntityState, myAddresses: string[]): [TxTokenType, string, string, string] { let groupInputs = vin.filter(input => !isNullOrEmpty(input.group)); let groupOutputs = vout.filter(output => !isNullOrEmpty(output.scriptPubKey.group)); if (isNullOrEmpty(groupInputs) && isNullOrEmpty(groupOutputs)) { return [TxTokenType.NO_GROUP, "none", "0", "none"]; } let myGroupInputs = groupInputs.filter(input => myAddresses.includes(input.addresses[0])); let myGroupOutputs = groupOutputs.filter(output => myAddresses.includes(output.scriptPubKey.addresses[0])); if (isNullOrEmpty(myGroupInputs) && isNullOrEmpty(myGroupOutputs)) { return [TxTokenType.NO_GROUP, "none", "0", "none"]; } if (isNullOrEmpty(groupInputs)) { let group = myGroupOutputs.find(output => BigInt(output.scriptPubKey.groupQuantity) < 0n)?.scriptPubKey.group ?? "none"; return [TxTokenType.CREATE, group, "0", "none"]; } if (isNullOrEmpty(groupOutputs)) { if (txState === 'incoming') { return [TxTokenType.NO_GROUP, "none", "0", "none"]; } let inputs = myGroupInputs.filter(input => BigInt(input.groupQuantity) > 0n); if (!isNullOrEmpty(inputs)) { let amount = new bigDecimal(0); inputs.forEach(utxo => { amount = amount.add(new bigDecimal(utxo.groupQuantity)); }); let group = inputs[0].group; let extraGroup = myGroupInputs.find(input => BigInt(input.groupQuantity) < 0n && inputs[0].group != input.group)?.group ?? "none"; return [TxTokenType.MELT, group, amount.getValue(), extraGroup]; } let group = myGroupInputs.find(input => BigInt(input.groupQuantity) < 0n)?.group ?? "none"; let extraGroup = myGroupInputs.find(input => BigInt(input.groupQuantity) < 0n && group != input.group)?.group ?? "none"; return [TxTokenType.MELT, group, "0", extraGroup]; } let tokenInputs = groupInputs.filter(input => BigInt(input.groupQuantity) > 0n); let tokenOutputs = groupOutputs.filter(output => BigInt(output.scriptPubKey.groupQuantity) > 0n); if (isNullOrEmpty(tokenInputs) && isNullOrEmpty(tokenOutputs)) { let group = groupInputs.find(input => BigInt(input.groupQuantity) < 0n)?.group ?? "none"; let extraGroup = groupOutputs.find(output => BigInt(output.scriptPubKey.groupQuantity) < 0n && group != output.scriptPubKey.group)?.scriptPubKey.group ?? "none"; return [TxTokenType.RENEW, extraGroup !== 'none' ? extraGroup : group, "0", extraGroup !== 'none' ? group : extraGroup]; } if (isNullOrEmpty(tokenInputs)) { let group = tokenOutputs[0].scriptPubKey.group; let amount = new bigDecimal(0); tokenOutputs.forEach(utxo => { amount = amount.add(new bigDecimal(utxo.scriptPubKey.groupQuantity)); }); let extraGroup = groupInputs.find(input => BigInt(input.groupQuantity) < 0n && group != input.group)?.group ?? "none"; return [TxTokenType.MINT, group, amount.getValue(), extraGroup]; } if (isNullOrEmpty(tokenOutputs)) { let group = tokenInputs[0].group; let amount = new bigDecimal(0); tokenInputs.forEach(utxo => { amount = amount.add(new bigDecimal(utxo.groupQuantity)); }); let extraGroup = groupInputs.find(input => BigInt(input.groupQuantity) < 0n && group != input.group)?.group ?? "none"; return [TxTokenType.MELT, group, amount.getValue(), extraGroup]; } let outQuantitySum = tokenOutputs.map(output => BigInt(output.scriptPubKey.groupQuantity)).reduce((a, b) => a + b, 0n); let inQuantitySum = tokenInputs.map(input => BigInt(input.groupQuantity)).reduce((a, b) => a + b, 0n); if (outQuantitySum > inQuantitySum) { let group = tokenOutputs[0].scriptPubKey.group; let extraGroup = groupInputs.find(input => BigInt(input.groupQuantity) < 0n && group != input.group)?.group ?? "none"; return [TxTokenType.MINT, group, (outQuantitySum - inQuantitySum).toString(), extraGroup]; } if (inQuantitySum > outQuantitySum) { let group = tokenInputs[0].group; let extraGroup = groupInputs.find(input => BigInt(input.groupQuantity) < 0n && group != input.group)?.group ?? "none"; return [TxTokenType.MELT, group, (inQuantitySum - outQuantitySum).toString(), extraGroup]; } let group = tokenOutputs[0].scriptPubKey.group; let amount = ""; if (txState === 'incoming') { amount = tokenOutputs .filter(output => myAddresses.includes(output.scriptPubKey.addresses[0])) .map(output => BigInt(output.scriptPubKey.groupQuantity)) .reduce((a, b) => a + b, 0n) .toString(); } else if (txState === 'outgoing') { amount = tokenOutputs .filter(output => !myAddresses.includes(output.scriptPubKey.addresses[0])) .map(output => BigInt(output.scriptPubKey.groupQuantity)) .reduce((a, b) => a + b, 0n) .toString(); } else { amount = "0"; } return [TxTokenType.TRANSFER, group, amount, "none"]; }