diff --git a/src/vms/pvm/etna-builder/builder.test.ts b/src/vms/pvm/etna-builder/builder.test.ts index 14d9e2b49..6025c0aec 100644 --- a/src/vms/pvm/etna-builder/builder.test.ts +++ b/src/vms/pvm/etna-builder/builder.test.ts @@ -1,5 +1,7 @@ import { testContext as _testContext } from '../../../fixtures/context'; import { + getLockedUTXO, + getNotTransferOutput, getTransferableInputForTest, getTransferableOutForTest, getValidUtxo, @@ -936,4 +938,78 @@ describe('./src/vms/pvm/etna-builder/builder.test.ts', () => { expectTxs(unsignedTx.getTx(), expectedTx); }); }); + + describe('ImportTx', () => { + it('should create an ImportTx with both AVAX and non-AVAX assets', () => { + const utxos = [ + getLockedUTXO(), // Locked and should be ignored. + getNotTransferOutput(), // Invalid and should be ignored. + // AVAX Assets + getValidUtxo(new BigIntPr(BigInt(35 * 1e9)), testAvaxAssetID), + getValidUtxo(new BigIntPr(BigInt(28 * 1e9)), testAvaxAssetID), + // Non-AVAX Assets (Jupiter) + getValidUtxo(new BigIntPr(BigInt(15 * 1e9)), Id.fromString('jupiter')), + getValidUtxo(new BigIntPr(BigInt(11 * 1e9)), Id.fromString('jupiter')), + // Non-AVAX Asset (Mars) + getValidUtxo(new BigIntPr(BigInt(9 * 1e9)), Id.fromString('mars')), + ]; + + const unsignedTx = newImportTx( + { + fromAddressesBytes, + sourceChainId: testContext.cBlockchainID, + toAddresses: [testAddress1], + utxos, + }, + testContext, + ); + + const { baseTx, ins: importedIns } = unsignedTx.getTx() as ImportTx; + const { inputs, outputs } = baseTx; + + const [amountConsumed, expectedAmountConsumed, expectedFee] = + checkFeeIsCorrect({ + unsignedTx, + inputs, + outputs, + additionalInputs: importedIns, + }); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + + const expectedTx = new ImportTx( + AvaxBaseTx.fromNative( + testContext.networkID, + testContext.pBlockchainID, + [ + // "Other" assets are first. Sorted by TransferableInput.compare + TransferableOutput.fromNative('mars', BigInt(9 * 1e9), [ + testAddress1, + ]), + TransferableOutput.fromNative('jupiter', BigInt(26 * 1e9), [ + testAddress1, + ]), + // AVAX come last. + TransferableOutput.fromNative( + testContext.avaxAssetID, + BigInt((35 + 28) * 1e9) - expectedFee, + [testAddress1], + ), + ], + [], + new Uint8Array(), + ), + Id.fromString(testContext.cBlockchainID), + [ + TransferableInput.fromUtxoAndSigindicies(utxos[2], [0]), + TransferableInput.fromUtxoAndSigindicies(utxos[3], [0]), + TransferableInput.fromUtxoAndSigindicies(utxos[4], [0]), + TransferableInput.fromUtxoAndSigindicies(utxos[5], [0]), + TransferableInput.fromUtxoAndSigindicies(utxos[6], [0]), + ], + ); + + expectTxs(unsignedTx.getTx(), expectedTx); + }); + }); }); diff --git a/src/vms/pvm/etna-builder/builder.ts b/src/vms/pvm/etna-builder/builder.ts index 07cffdebe..507f88442 100644 --- a/src/vms/pvm/etna-builder/builder.ts +++ b/src/vms/pvm/etna-builder/builder.ts @@ -9,6 +9,7 @@ import { PlatformChainID, PrimaryNetworkID, } from '../../../constants/networkIDs'; +import type { TransferOutput } from '../../../serializable'; import { Input, NodeId, @@ -233,51 +234,55 @@ export const newImportTx: TxBuilderFn = ( const fromAddresses = addressesFromBytes(fromAddressesBytes); const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); - const importedInputs: TransferableInput[] = []; - const importedAmounts: Record = {}; - - for (const utxo of utxos) { - const out = utxo.output; - - if (!isTransferOut(out)) { - continue; - } - - const { sigIndicies: inputSigIndices } = - matchOwners( - utxo.getOutputOwners(), - fromAddresses, - defaultedOptions.minIssuanceTime, - ) || {}; - - if (inputSigIndices === undefined) { - // We couldn't spend this UTXO, so we skip to the next one. - continue; - } - - importedInputs.push( - new TransferableInput( - utxo.utxoId, - utxo.assetId, - new TransferInput( - out.amt, - new Input(inputSigIndices.map((value) => new Int(value))), - ), - ), + const { importedInputs, importedAmounts } = utxos + .filter((utxo): utxo is Utxo => isTransferOut(utxo.output)) + .reduce<{ + importedInputs: TransferableInput[]; + importedAmounts: Record; + }>( + (acc, utxo) => { + const { sigIndicies: inputSigIndices } = + matchOwners( + utxo.getOutputOwners(), + fromAddresses, + defaultedOptions.minIssuanceTime, + ) || {}; + + if (inputSigIndices === undefined) { + // We couldn't spend this UTXO, so we skip to the next one. + return acc; + } + + const assetId = utxo.getAssetId(); + + return { + importedInputs: [ + ...acc.importedInputs, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new TransferInput( + utxo.output.amt, + new Input(inputSigIndices.map((value) => new Int(value))), + ), + ), + ], + importedAmounts: { + ...acc.importedAmounts, + [assetId]: + (acc.importedAmounts[assetId] ?? 0n) + utxo.output.amount(), + }, + }; + }, + { importedInputs: [], importedAmounts: {} }, ); - const assetId = utxo.getAssetId(); - - importedAmounts[assetId] = (importedAmounts[assetId] ?? 0n) + out.amount(); - } - if (importedInputs.length === 0) { throw new Error('no UTXOs available to import'); } const importedAvax = importedAmounts[context.avaxAssetID]; - importedInputs.sort(TransferableInput.compare); const addressMaps = AddressMaps.fromTransferableInputs( importedInputs, utxos, @@ -285,14 +290,9 @@ export const newImportTx: TxBuilderFn = ( fromAddressesBytes, ); - const outputs: TransferableOutput[] = []; - - for (const [assetID, amount] of Object.entries(importedAmounts)) { - if (assetID === context.avaxAssetID) { - continue; - } - - outputs.push( + const outputs: TransferableOutput[] = Object.entries(importedAmounts) + .filter(([assetID]) => assetID !== context.avaxAssetID) + .map(([assetID, amount]) => TransferableOutput.fromNative( assetID, amount, @@ -301,7 +301,6 @@ export const newImportTx: TxBuilderFn = ( threshold, ), ); - } const memoComplexity = getMemoComplexity(defaultedOptions); @@ -341,7 +340,7 @@ export const newImportTx: TxBuilderFn = ( new Bytes(defaultedOptions.memo), ), Id.fromString(sourceChainId), - importedInputs, + importedInputs.sort(TransferableInput.compare), ), inputUTXOs, addressMaps,