import { TransactionOutput } from "../builders/transaction.builder"; import { CardanoAsset, AssetUtils, Multiasset } from "./assets"; import { ScriptData } from "./scriptData"; import { CardanoTransaction } from "./transactions"; import { HexUtils } from "./hex"; import { CborUnsigned } from "./cbor/unsigned"; import { CborMultiasset } from "./cbor/multiasset"; const COLLATERAL_VALUE = 5000000; export type CardanoUTXO = { transaction: CardanoTransaction; block?: string; outputIndex?: number; utxo: { address: string; amount: { coin: number; multiasset?: Multiasset; datum?: string; }; }; }; export type ExceedingInputs = { [address: string]: { totalCoin?: number; tokens?: CardanoAsset[]; }; }; export type RequiredInputs = { [address: string]: { totalCoin?: number; tokens?: CardanoAsset[]; }; }; export type RequiredOutputItem = { totalCoin?: number; tokens?: CardanoAsset[]; datum?: ScriptData; }; export type RequiredOutputs = { [address: string]: { split?: RequiredOutputItem[]; // If you want to ensure the outputs are split into diff utxos, use this field } & RequiredOutputItem; }; const getAssetUtxo = async ( utxos: CardanoUTXO[], asset: string ): Promise => { const assetUtxo = utxos.find((utxo: CardanoUTXO) => hasAsset(utxo, asset)); if (assetUtxo) { return assetUtxo; } else return null; }; export type AdaUTXOs = { satisfied: boolean; totalCoin: number; target: number; utxos: CardanoUTXO[]; bestCase?: boolean; }; const hasAsset = (utxo: CardanoUTXO, asset: string) => { const details = AssetUtils.getAssetDetails(asset); const policyObj = utxo.utxo.amount.multiasset && utxo.utxo.amount.multiasset[details.assetPolicy]; if (isMultiassetValue(policyObj)) { const asset = policyObj[details.assetName]; if (!isMultiassetValue(asset)) { return asset === 1; } } return false; }; const isMultiassetValue = (value: unknown): value is Multiasset => { return !!value && typeof value !== "number"; }; const getTokenUtxo = async ( utxos: CardanoUTXO[], policyId: string, assetId: string, value: number, ignoreUtxos?: string[] ) => { utxos.sort((u1, u2) => { if (u1.utxo?.amount?.multiasset) { return 1; } else if (u2.utxo?.amount?.multiasset) { return -1; } return u2.utxo.amount.coin - u1.utxo.amount.coin; }); const ftUtxos = utxos.reduce( (ftUtxos: AdaUTXOs, utxo: CardanoUTXO, i) => { // If the asset is not present, keep looking const multiasset = utxo?.utxo?.amount?.multiasset?.[policyId]; if (!multiasset || typeof multiasset === "number") return ftUtxos; const totalTokens = multiasset[assetId]; if (typeof totalTokens !== "number") return ftUtxos; if ( ftUtxos?.bestCase || // In case we already found a single utxo for this transaction ignoreUtxos?.some( (ignoredUtxo) => `${utxo.transaction.hash}${utxo.transaction.index}` === ignoredUtxo ) ) return ftUtxos; if (totalTokens > value) { return { satisfied: true, totalCoin: totalTokens, utxos: [utxo], target: value, bestCase: true, }; } else if (ftUtxos.utxos.length && !ftUtxos.satisfied) { const totalCoin = ftUtxos.totalCoin + totalTokens; return { satisfied: totalCoin >= value ? true : false, totalCoin, target: value, utxos: [...ftUtxos.utxos, utxo], }; } else if (!ftUtxos.satisfied) { return { satisfied: false, totalCoin: utxo.utxo.amount.coin, target: value, utxos: [utxo], }; } else { return ftUtxos; } }, { satisfied: false, totalCoin: 0, target: value, utxos: [], } ); return clearExceedingTokenInputs(ftUtxos, policyId, assetId); }; const getAdaUtxo = async ( utxos: CardanoUTXO[], value: number, ignoreUtxos?: string[] ): Promise => { const clonedUtxos = [...utxos]; utxos.sort((u1, u2) => { if (u1.utxo?.amount?.multiasset) { return 1; } else if (u2.utxo?.amount?.multiasset) { return -1; } return u2.utxo.amount.coin - u1.utxo.amount.coin; }); const adaUtxos = utxos.reduce( (adaUtxos: AdaUTXOs, utxo: CardanoUTXO, i) => { if ( adaUtxos?.bestCase || // In case we already found a single utxo for this transaction ignoreUtxos?.some( (ignoredUtxo) => `${utxo.transaction.hash}${utxo.transaction.index}` === ignoredUtxo ) || (getAvailableCoinFromUTXO(utxo) === COLLATERAL_VALUE && clonedUtxos.findIndex( (t) => t.transaction.hash === utxo.transaction.hash && t.transaction.index === utxo.transaction.index ) == 0) // TODO: confirm if this is really an issue - Some UTXOs with 5000000 total coin value keep throwing 'BadInputs' ) return adaUtxos; if (getAvailableCoinFromUTXO(utxo) > value) { return { satisfied: true, totalCoin: utxo.utxo.amount.coin, utxos: [utxo], target: value, bestCase: true, }; } else if (adaUtxos.utxos.length && !adaUtxos.satisfied) { const totalCoin = getTotalAvailableCoin([...adaUtxos.utxos, utxo]); return { satisfied: totalCoin >= value ? true : false, totalCoin, target: value, utxos: [...adaUtxos.utxos, utxo], }; } else if (!adaUtxos.satisfied) { return { satisfied: false, totalCoin: utxo.utxo.amount.coin, target: value, utxos: [utxo], }; } else { return adaUtxos; } }, { satisfied: false, totalCoin: 0, target: value, utxos: [], } ); return clearExceedingADAInputs(adaUtxos); }; const toCardanoUTXO = ( owner: string, utxo: any, dataHash?: string, block?: string, outputIndex?: number ): CardanoUTXO => { return { transaction: { hash: utxo.tx_hash, index: utxo.tx_index, }, block, outputIndex, utxo: { address: owner, amount: { coin: utxo.amount.reduce( (total: number, v: { quantity: string; unit: string }) => v.unit === "lovelace" ? (total += parseInt(v.quantity)) : total, 0 ), multiasset: AssetUtils.toMultiasset(utxo.amount), ...(dataHash && { datum: dataHash, }), }, }, }; }; const getTotalAvailableCoin = (utxos: CardanoUTXO[]) => { return utxos.reduce((sum, u) => { return sum + getAvailableCoinFromUTXO(u); }, 0); }; const isMultiassetUTXO = (utxo: CardanoUTXO) => { return !!utxo?.utxo?.amount?.multiasset; }; const calculateMinUtxoCost = (utxo: CardanoUTXO) => { if (!utxo.utxo.amount.multiasset) return 1000000; // 1 ADA if there is no asset return calculateMinMultiassetCost( utxo.utxo.amount.multiasset, !!utxo.utxo.amount.datum ); }; const getAvailableCoinFromUTXO = (utxo: CardanoUTXO) => { return ( (isMultiassetUTXO(utxo) ? utxo.utxo.amount.coin - calculateMinUtxoCost(utxo) : utxo.utxo.amount.coin) || 0 ); }; const clearExceedingADAInputs = (adaUtxos: AdaUTXOs) => { const utxos = adaUtxos.utxos.sort( (utxo1, utxo2) => utxo2.utxo.amount.coin - utxo1.utxo.amount.coin ); const cleanInputs = utxos.reduce( (cleanAdaUtxos, utxo) => { if (cleanAdaUtxos.totalCoin >= cleanAdaUtxos.target) { return cleanAdaUtxos; } else { cleanAdaUtxos.totalCoin = getTotalAvailableCoin([ ...cleanAdaUtxos.utxos, utxo, ]); cleanAdaUtxos.utxos.push(utxo); return cleanAdaUtxos; } }, { ...adaUtxos, totalCoin: 0, utxos: [], } as AdaUTXOs ); return cleanInputs; }; const clearExceedingTokenInputs = ( tokenUtxos: AdaUTXOs, policyId: string, assetId: string ) => { const utxos = tokenUtxos.utxos.sort((utxo1, utxo2) => { const utxo1Multiasset = utxo1?.utxo?.amount?.multiasset?.[policyId]; const utxo2Multiasset = utxo2?.utxo?.amount?.multiasset?.[policyId]; if (typeof utxo1Multiasset === "number") return 0; if (typeof utxo2Multiasset === "number") return 0; const totalUtxo1 = utxo1Multiasset[assetId] ? (utxo1Multiasset[assetId] as number) : 0; const totalUtxo2 = utxo2Multiasset[assetId] ? (utxo2Multiasset[assetId] as number) : 0; return totalUtxo2 - totalUtxo1; }); const cleanInputs = utxos.reduce( (cleanTokenUtxos, utxo) => { const utxoMultiasset = utxo?.utxo?.amount?.multiasset?.[policyId]; if (typeof utxoMultiasset === "number") return cleanTokenUtxos; const totalTokens = utxoMultiasset[assetId] ? (utxoMultiasset[assetId] as number) : 0; if (cleanTokenUtxos.totalCoin >= cleanTokenUtxos.target) { return cleanTokenUtxos; } else { cleanTokenUtxos.totalCoin = cleanTokenUtxos.totalCoin + totalTokens; cleanTokenUtxos.utxos.push(utxo); return cleanTokenUtxos; } }, { ...tokenUtxos, totalCoin: 0, utxos: [], } as AdaUTXOs ); return cleanInputs; }; const getExceedingInputs = ( utxos: CardanoUTXO[], requiredInputs: RequiredInputs ): ExceedingInputs => { const utxosAssetsPerOwner = utxos.reduce((acc, c) => { if (acc[c.utxo.address]) { acc[c.utxo.address].totalCoin += c.utxo.amount.coin; acc[c.utxo.address].assets = AssetUtils.mergeAssets([ ...acc[c.utxo.address].assets, ...AssetUtils.getAssetsFromUtxo(c), ]); } else { acc[c.utxo.address] = { totalCoin: c.utxo.amount.coin, assets: AssetUtils.getAssetsFromUtxo(c), }; } return acc; }, {} as { [address: string]: { totalCoin: number; assets: CardanoAsset[] } }); return Object.keys(utxosAssetsPerOwner).reduce((acc, addr) => { const inputs = utxosAssetsPerOwner[addr]; const required = requiredInputs[addr]; const adaDiff = (required.totalCoin || 0) - (inputs.totalCoin || 0); const tokenDiff = AssetUtils.getExceedingAssetsFromLeft( inputs.assets, required.tokens ); acc[addr] = { totalCoin: adaDiff, tokens: tokenDiff, }; return acc; }, {} as ExceedingInputs); }; const calculateMinMultiassetCost = ( multiasset: Multiasset, hasDatum?: boolean ) => { // All those costs are in WORD size // More info: https://github.com/input-output-hk/cardano-ledger/blob/master/doc/explanations/min-utxo-alonzo.rst const ASSET_BASE_COST = 12; const POLICY_COST = 28; const ADDITIONAL_COST_PRE_DIVIDING = 7; const ADDITIONAL_COST_POST_DIVIDING = 6; const BASE_UTXO_ENTRY_SIZE_WITHOUT_VAL = 27; const DATUM_HASH_VALUE = hasDatum ? 10 : 0; const policies = Object.keys(multiasset); const totalAssetsInfo = Object.keys(multiasset).reduce( (acc, key) => { return { total: acc.total + Object.keys(multiasset[key]).length, charactersSum: acc.charactersSum + Object.keys(multiasset[key]).reduce((characterSum, asset) => { return characterSum + asset.length; }, 0), }; }, { total: 0, charactersSum: 0, } ); const totalByteCost = ASSET_BASE_COST * totalAssetsInfo.total + totalAssetsInfo.charactersSum / 2 + policies.length * POLICY_COST + ADDITIONAL_COST_PRE_DIVIDING; const totalWordCost = Math.floor(totalByteCost / 8) + ADDITIONAL_COST_POST_DIVIDING; return ( (totalWordCost + BASE_UTXO_ENTRY_SIZE_WITHOUT_VAL + DATUM_HASH_VALUE) * 34482 ); }; const calculateOutputs = ( inputsUtxo: CardanoUTXO[], requiredInputs: RequiredInputs, requiredOutputs: RequiredOutputs ): TransactionOutput[] => { const exceedingInputs = getExceedingInputs(inputsUtxo, requiredInputs); const exceedingInputsAddresses = Object.keys(exceedingInputs); const requiredOutputsAddresses = Object.keys(requiredOutputs); const outputAddresses = [ ...exceedingInputsAddresses, ...requiredOutputsAddresses.filter( (adr) => !exceedingInputsAddresses.includes(adr) ), ]; const result = outputAddresses.reduce( ({ outputs, notAssignedOutputs }, address) => { const tokens = [...(exceedingInputs?.[address]?.tokens || [])]; const totalCoin = Math.abs(exceedingInputs?.[address]?.totalCoin || 0); let newOutputs: TransactionOutput[] = []; if (totalCoin < 1000000) { if (notAssignedOutputs[address]) { notAssignedOutputs[address].tokens = [ ...notAssignedOutputs[address].tokens, ...tokens, ]; notAssignedOutputs[address].totalCoin = notAssignedOutputs[address].totalCoin + totalCoin; } else { notAssignedOutputs[address] = exceedingInputs[address]; } } else { const multiassets = tokens.length ? AssetUtils.fromAssetsToPolicyMap(tokens) : null; newOutputs = [ { address, amount: multiassets ? [totalCoin, multiassets] : totalCoin, datum: requiredOutputs?.[address]?.datum, }, ]; } return { outputs: [...outputs, ...newOutputs], notAssignedOutputs, }; }, { notAssignedOutputs: {} as ExceedingInputs, outputs: [] as TransactionOutput[], } ); Object.keys(requiredOutputs).forEach((addressKey) => { const requiredOutput = requiredOutputs[addressKey]; const notAssigned = result.notAssignedOutputs[addressKey]; const unassignedOutputTokens = notAssigned && notAssigned.tokens?.length > 0 ? notAssigned.tokens : []; const unassignedTotalCoin = notAssigned && Math.abs(notAssigned.totalCoin) > 0 ? Math.abs(notAssigned.totalCoin) : 0; if (requiredOutput.split) { for (let i = 0; i < requiredOutput.split.length; i++) { const reqOutput = requiredOutput.split[i]; result.outputs.push({ address: addressKey, amount: reqOutput.tokens?.length ? [ reqOutput.totalCoin + (i === 0 ? unassignedTotalCoin : 0), AssetUtils.fromAssetsToPolicyMap([ ...reqOutput.tokens, ...(i === 0 ? unassignedOutputTokens : []), ]), ] : reqOutput.totalCoin, datum: reqOutput.datum, }); } } else { result.outputs.push({ address: addressKey, amount: requiredOutput.tokens?.length ? [ requiredOutput.totalCoin + unassignedTotalCoin, AssetUtils.fromAssetsToPolicyMap([ ...requiredOutput.tokens, ...unassignedOutputTokens, ]), ] : requiredOutput.totalCoin, datum: requiredOutput.datum, }); } }); return result.outputs?.filter((o) => typeof o.amount === "number" ? o.amount > 0 : o.amount[0] > 0 || !!o.amount[1] ); }; const sort = (utxos: CardanoUTXO[]) => { const sorted = [...utxos].sort((utxo1, utxo2) => { const utxo1bytes = HexUtils.hexToBytes(utxo1.transaction.hash); const utxo2bytes = HexUtils.hexToBytes(utxo2.transaction.hash); for (let i = 0; i < utxo1bytes.length; i++) { if (utxo1bytes[i] > utxo2bytes[i]) { return 1; } else { return -1; } } }); return sorted; }; export const UTXOS = { getAssetUtxo, getAdaUtxo, toCardanoUTXO, getExceedingInputs, calculateOutputs, getAvailableCoinFromUTXO, getTotalAvailableCoin, calculateMinUtxoCost, calculateMinMultiassetCost, sort, hasAsset, getTokenUtxo, };