@dolaned/wallet-sdk-ts
Version:
Wallet SDK for the Nexa blockchain
446 lines (401 loc) • 16.7 kB
text/typescript
import {rostrumProvider} from '../network/RostrumProvider';
import {AccountKeys} from '../models/wallet.entities';
import {isNullOrEmpty, MAX_INT64, tokenIdToHex} from './CommonUtils';
import {
Address,
AddressType,
GroupToken,
Networkish,
PrivateKey,
Transaction,
TransactionBuilder,
UnitUtils,
UTXO
} from "libnexa-ts";
import {PermissionLabel, TxOptions} from "../models/transaction.entities";
import {dupAuthority, isAuthFit} from "./TokenUtils";
/** Maximum number of inputs/outputs allowed in a single transaction */
const MAX_INPUTS_OUTPUTS = 250;
/**
* Populate a transaction with Nexa UTXO inputs and set change output if needed
*
* This function automatically selects and adds UTXOs from the provided account keys
* to satisfy the transaction's Nexa amount requirements. It handles change calculation
* and can consolidate UTXOs when requested.
*
* @param txBuilder - The transaction builder to populate
* @param keys - Account keys containing receive and change addresses with balances
* @param totalTxValue - Total amount of Nexa required for the transaction (in satoshis)
* @param options - Transaction options including consolidation settings and change address
* @returns Promise resolving to array of private keys needed for signing
* @throws {Error} If insufficient balance or too many inputs required
*
* @example
* ```typescript
* const txBuilder = new TransactionBuilder();
* const keys = await account.getKeys();
* const privateKeys = await populateNexaInputsAndChange(
* txBuilder,
* keys,
* 1000000n, // 1 NEXA
* { isConsolidate: false, feeFromAmount: false }
* );
* ```
*/
export async function populateNexaInputsAndChange(txBuilder: TransactionBuilder, keys: AccountKeys, totalTxValue: bigint, options: TxOptions): Promise<PrivateKey[]> {
let rKeys = keys.receiveKeys.filter(k => BigInt(k.balance) > 0n);
let cKeys = keys.changeKeys.filter(k => BigInt(k.balance) > 0n);
let allKeys = rKeys.concat(cKeys);
if (isNullOrEmpty(allKeys)) {
throw new Error("Not enough Nexa balance.");
}
let usedKeys = new Map<string, PrivateKey>();
let origAmount = options.isConsolidate ? 0 : Number(totalTxValue);
for (let key of allKeys) {
let utxos = await rostrumProvider.getNexaUtxos(key.address);
for (let utxo of utxos) {
let input: UTXO = {
outpoint: utxo.outpoint_hash,
address: key.address,
satoshis: utxo.value,
templateData: options.templateData
}
txBuilder.from(input);
if (!usedKeys.has(key.address)) {
usedKeys.set(key.address, key.key.privateKey);
}
if (options.isConsolidate) {
txBuilder.change(options.toChange ?? keys.receiveKeys[keys.receiveKeys.length - 1].address);
if (txBuilder.transaction.inputs.length > MAX_INPUTS_OUTPUTS) {
return Array.from(usedKeys.values());
}
} else {
let tx = txBuilder.transaction;
if (tx.inputs.length > MAX_INPUTS_OUTPUTS) {
throw new Error("Too many inputs. Consider consolidate transactions or reduce the send amount.");
}
let unspent = tx.getUnspentValue();
if (unspent < 0n) {
continue;
}
if (unspent == 0n && options.feeFromAmount) {
let txFee = tx.estimateRequiredFee();
tx.updateOutputAmount(0, origAmount - txFee);
return Array.from(usedKeys.values());
}
txBuilder.change(options.toChange ?? keys.changeKeys[keys.changeKeys.length - 1].address);
if (options.feeFromAmount) {
let hasChange = tx.getChangeOutput();
let txFee = tx.estimateRequiredFee();
tx.updateOutputAmount(0, origAmount - txFee);
// edge case where change added after update
if (!hasChange && tx.getChangeOutput()) {
txFee = tx.estimateRequiredFee();
tx.updateOutputAmount(0, origAmount - txFee);
}
}
// check again after change output manipulation
if (tx.getUnspentValue() < tx.estimateRequiredFee()) {
// try to add more utxos to satisfy the minimum fee
continue;
}
return Array.from(usedKeys.values());
}
}
}
if (options.isConsolidate) {
if (usedKeys.size > 0) {
return Array.from(usedKeys.values());
}
throw new Error("Not enough Nexa balance.");
}
let err = {
errorMsg: "Not enough Nexa balance.",
amount: UnitUtils.formatNEXA(txBuilder.transaction.outputs[0].value),
fee: UnitUtils.formatNEXA(txBuilder.transaction.estimateRequiredFee())
}
throw new Error(JSON.stringify(err));
}
/**
* Populate a transaction with token UTXO inputs and set token change output if needed
*
* This function selects and adds token UTXOs from the provided account keys
* to satisfy the transaction's token amount requirements. It automatically
* handles token change calculation.
*
* @param txBuilder - The transaction builder to populate
* @param keys - Account keys containing addresses with token balances
* @param token - The token ID to spend
* @param outTokenAmount - Amount of tokens required for the transaction
* @returns Promise resolving to array of private keys needed for signing
* @throws {Error} If insufficient token balance or too many inputs required
*
* @example
* ```typescript
* const txBuilder = new TransactionBuilder();
* const keys = await account.getKeys();
* const privateKeys = await populateTokenInputsAndChange(
* txBuilder,
* keys,
* 'token_id_hex',
* 1000n // Amount of tokens
* );
* ```
*/
export async function populateTokenInputsAndChange(txBuilder: TransactionBuilder, keys: AccountKeys, token: string, outTokenAmount: bigint): Promise<PrivateKey[]> {
let tokenHex = tokenIdToHex(token);
let rKeys = keys.receiveKeys.filter(k => Object.keys(k.tokensBalance).includes(tokenHex));
let cKeys = keys.changeKeys.filter(k => Object.keys(k.tokensBalance).includes(tokenHex));
let allKeys = rKeys.concat(cKeys);
if (isNullOrEmpty(allKeys)) {
throw new Error("Not enough token balance.");
}
let usedKeys = new Map<string, PrivateKey>();
let inTokenAmount = 0n;
for (let key of allKeys) {
let utxos = await rostrumProvider.getTokenUtxos(key.address, token);
for (let utxo of utxos) {
if (utxo.token_amount < 0) {
continue;
}
txBuilder.from({
outpoint: utxo.outpoint_hash,
address: key.address,
satoshis: utxo.value,
groupId: utxo.group,
groupAmount: BigInt(utxo.token_amount),
});
inTokenAmount = inTokenAmount + BigInt(utxo.token_amount);
if (!usedKeys.has(key.address)) {
usedKeys.set(key.address, key.key.privateKey);
}
if (inTokenAmount > MAX_INT64) {
throw new Error("Token inputs exceeded max amount. Consider sending in small chunks");
}
if (txBuilder.transaction.inputs.length > MAX_INPUTS_OUTPUTS) {
throw new Error("Too many inputs. Consider consolidating transactions or reduce the send amount.");
}
if (inTokenAmount == outTokenAmount) {
return Array.from(usedKeys.values());
}
if (inTokenAmount > outTokenAmount) {
// change
txBuilder.to(keys.changeKeys[keys.changeKeys.length - 1].address, Transaction.DUST_AMOUNT, token, inTokenAmount - outTokenAmount);
return Array.from(usedKeys.values());
}
}
}
throw new Error("Not enough token balance");
}
/**
* Build a transaction to create a new token group
*
* This function creates a group token by using the first UTXO's outpoint
* to generate a unique group ID. The group token represents the authority
* to create fungible tokens within this group.
*
* @param txBuilder - The transaction builder to populate
* @param keys - Account keys to use for funding the transaction
* @param opReturnData - Optional data to include in the group creation
* @param network - Network to create the group on
* @returns Promise resolving to array of private keys needed for signing
* @throws {Error} If insufficient balance for group creation
*
* @example
* ```typescript
* const txBuilder = new TransactionBuilder();
* const keys = await account.getKeys();
* const privateKeys = await buildCreateGroupTransaction(
* txBuilder,
* keys,
* 'my_token_data',
* Networks.mainnet
* );
* ```
*/
export async function buildCreateGroupTransaction(
txBuilder: TransactionBuilder,
keys: AccountKeys,
opReturnData: string,
network: Networkish
): Promise<PrivateKey[]> {
// TODO validate opreturn data
const allKeys = keys.receiveKeys.concat(keys.changeKeys)
let outpoint = '';
let usedKeys: PrivateKey[] = [];
let signKey: PrivateKey | undefined = undefined;
for (let key of allKeys) {
let utxos = await rostrumProvider.getNexaUtxos(key.address);
for (let utxo of utxos) {
txBuilder.from({
outpoint: utxo.outpoint_hash,
address: key.address,
satoshis: utxo.value
});
if (isNullOrEmpty(outpoint)) {
outpoint = utxo.outpoint_hash;
let id = GroupToken.findGroupId(Buffer.from(outpoint, 'hex'), Buffer.from(opReturnData, 'hex'), GroupToken.authFlags.ACTIVE_FLAG_BITS);
const groupId = new Address(id.hashBuffer, network, AddressType.GroupIdAddress).toString()
txBuilder.to(keys.receiveKeys.at(-1)!.address, Transaction.DUST_AMOUNT, groupId, GroupToken.authFlags.ACTIVE_FLAG_BITS | id.nonce)
signKey = key.key.privateKey
usedKeys.push(signKey);
return usedKeys
}
}
}
throw new Error("Not enough Nexa balance.");
}
/**
* Prepare a transaction to delete/spend a specific UTXO
*
* This function adds a specific UTXO as input to the transaction.
* It's commonly used for spending token authority UTXOs or consolidating specific outputs.
*
* @param txBuilder - The transaction builder to populate
* @param keys - Account keys to find the private key for the UTXO
* @param outpoint - The outpoint (txid:vout) of the UTXO to spend
* @returns Promise resolving to array containing the private key for the UTXO
* @throws {Error} If the UTXO is not found or the associated key is not in the wallet
*
* @example
* ```typescript
* const txBuilder = new TransactionBuilder();
* const keys = await account.getKeys();
* const privateKeys = await prepareDeleteTransaction(
* txBuilder,
* keys,
* 'txid:0'
* );
* ```
*/
export async function prepareDeleteTransaction(txBuilder: TransactionBuilder, keys: AccountKeys, outpoint: string): Promise<PrivateKey[]> {
let utxo = await rostrumProvider.getUtxo(outpoint);
let address = utxo.addresses[0];
txBuilder.from({
outpoint: outpoint,
address: address,
satoshis: utxo.amount
});
let allKeys = keys.receiveKeys.concat(keys.changeKeys);
let addrKey = allKeys.find(k => k.address === address);
if (!addrKey) {
throw new Error('UTXO associated key not found in the wallet');
}
return [addrKey.key.privateKey];
}
/**
* Populate a transaction with token authority inputs for specific permissions
*
* This function finds and adds token authority UTXOs that have the required
* permissions for token operations like minting, melting, or creating subgroups.
* It automatically handles authority renewal if the authority allows it.
*
* @param txBuilder - The transaction builder to populate
* @param keys - Account keys containing addresses with token authorities
* @param token - The token ID to find authorities for
* @param perm - The permission type required ('mint', 'melt', 'subgroup', etc.)
* @param subgroup - Optional subgroup token ID for subgroup operations
* @param subgroupAddr - Optional address to receive subgroup authority
* @returns Promise resolving to array of private keys needed for signing
* @throws {Error} If the required authority is not found
*
* @example
* ```typescript
* const txBuilder = new TransactionBuilder();
* const keys = await account.getKeys();
* const privateKeys = await populateTokenAuth(
* txBuilder,
* keys,
* 'token_id_hex',
* 'mint'
* );
* ```
*/
export async function populateTokenAuth(txBuilder: TransactionBuilder, keys: AccountKeys, token: string, perm: PermissionLabel, subgroup = '', subgroupAddr = ''): Promise<PrivateKey[]> {
let allKeys = keys.receiveKeys.concat(keys.changeKeys);
for (let key of allKeys) {
let utxos = await rostrumProvider.getTokenUtxos(key.address, token);
for (let utxo of utxos) {
if (!isAuthFit(utxo.token_amount, perm)) {
continue;
}
txBuilder.from({
outpoint: utxo.outpoint_hash,
address: key.address,
satoshis: utxo.value
});
if (perm === 'subgroup') {
txBuilder.to(subgroupAddr, Transaction.DUST_AMOUNT, subgroup, dupAuthority(utxo.token_amount, false));
}
// if renew flag included, we don't want to burn it
if (GroupToken.allowsRenew(BigInt.asUintN(64, BigInt(utxo.token_amount)))) {
txBuilder.to(keys.receiveKeys.at(-1)!.address, Transaction.DUST_AMOUNT, token, dupAuthority(utxo.token_amount));
}
return [key.key.privateKey];
}
}
throw new Error("The requested authority not found");
}
/**
* Populate a transaction with multiple token authorities and create duplicates
*
* This function finds token authority UTXOs for multiple permissions and
* creates duplicate outputs for each authority. This is useful for complex
* token operations that require multiple permissions while preserving the authorities.
*
* @param txBuilder - The transaction builder to populate
* @param keys - Account keys containing addresses with token authorities
* @param token - The token ID to find authorities for
* @param perms - Array of permission types required
* @param toAddr
* @returns Promise resolving to array of private keys needed for signing
* @throws {Error} If any required authority is not found
*
* @example
* ```typescript
* const txBuilder = new TransactionBuilder();
* const keys = await account.getKeys();
* const privateKeys = await populateAndDuplicateTokenAuths(
* txBuilder,
* keys,
* 'token_id_hex',
* ['mint', 'melt']
* );
* ```
*/
export async function populateAndDuplicateTokenAuths(txBuilder: TransactionBuilder, keys: AccountKeys, token: string, perms: PermissionLabel[], toAddr?: string): Promise<PrivateKey[]> {
let allKeys = keys.receiveKeys.concat(keys.changeKeys);
let usedKeys: PrivateKey[] = [];
let reqiredPerms = new Set(perms);
reqiredPerms.add('authorise');
for (let key of allKeys) {
let utxos = await rostrumProvider.getTokenUtxos(key.address, token);
for (let utxo of utxos) {
if (utxo.token_amount > 0) {
continue;
}
let found = false;
for (let perm of reqiredPerms) {
if (isAuthFit(utxo.token_amount, perm)) {
reqiredPerms.delete(perm);
found = true;
}
}
if (!found) {
continue;
}
txBuilder.from({
outpoint: utxo.outpoint_hash,
address: key.address,
satoshis: utxo.value
});
usedKeys.push(key.key.privateKey);
// duplicate
txBuilder.to(toAddr != null ? toAddr : keys.receiveKeys.at(-1)!.address, Transaction.DUST_AMOUNT, token, dupAuthority(utxo.token_amount));
if (reqiredPerms.size === 0) {
return usedKeys;
}
}
}
throw new Error("The required authorities not found");
}