@dolaned/wallet-sdk-ts
Version:
Wallet SDK for the Nexa blockchain
522 lines (456 loc) • 21.6 kB
text/typescript
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"];
}