Skip to main content

Advanced Transaction Builder

With the introduction of newer smart contract features to Nexa, such as native introspection and GroupTokens, we've seen use cases for combining UTXOs of multiple different smart contracts within a single transaction - such as MAST Contracts. The simplified transaction builder only operates on a single smart contract, so to support more advanced use cases, you can use the Advanced Transaction Builder.

The Advanced Transaction Builder supports adding UTXOs from any number of different smart contracts and P2PKT UTXOs. While the simplified transaction builder automatically selects UTXOs for you and adds change outputs, the advanced transaction builder requires you to provide the UTXOs yourself and manage change carefully.

Instantiating a transaction builder​

new TransactionBuilder(options: TransactionBuilderOptions)

To start, you need to instantiate a transaction builder and pass in a NetworkProvider instance.

interface TransactionBuilderOptions {
provider: NetworkProvider;
}

Example​

import { ElectrumNetworkProvider, TransactionBuilder, Network } from '@nexscript/nexscript';

const provider = new ElectrumNetworkProvider(Network.MAINNET);
const transactionBuilder = new TransactionBuilder({ provider });

Transaction options​

addInput()​

transactionBuilder.addInput(utxo: Utxo, unlocker: Unlocker, options?: InputOptions): this

Adds a single input UTXO to the transaction that can be unlocked using the provided unlocker. The unlocker can be derived from a SignatureTemplate (unlockP2PKT()) or a Contract instance's spending functions (contract.unlock). The InputOptions object can be used to specify the sequence number of the input.

note

It is possible to create custom unlockers by implementing the Unlocker interface. Most use cases are covered by the SignatureTemplate and Contract classes.

Example​

import { contract, aliceTemplate, aliceAddress, provider, transactionBuilder } from './somewhere.js';

const contractUtxos = await contract.getUtxos();
const aliceUtxos = await provider.getUtxos(aliceAddress);

transactionBuilder.addInput(contractUtxos[0], contract.unlock.spend());
transactionBuilder.addInput(aliceUtxos[0], aliceTemplate.unlockP2PKT());

addInputs()​

transactionBuilder.addInputs(utxos: Utxo[], unlocker: Unlocker, options?: InputOptions): this
transactionBuilder.addInputs(utxos: UnlockableUtxo[]): this
interface UnlockableUtxo extends Utxo {
unlocker: Unlocker;
options?: InputOptions;
}

Adds a list of input UTXOs, either with a single shared unlocker or with individual unlockers for each UTXO. The InputOptions object can be used to specify the sequence number of the inputs.

Example​

import { contract, aliceTemplate, aliceAddress, provider, transactionBuilder } from './somewhere.js';

const contractUtxos = await contract.getUtxos();
const aliceUtxos = await provider.getUtxos(aliceAddress);

// Use a single unlocker for all inputs you're adding at a time
transactionBuilder.addInputs(contractUtxos, contract.unlock.spend());
transactionBuilder.addInputs(aliceUtxos, aliceTemplate.unlockP2PKT());

// Or combine the UTXOs with their unlockers in an array
const unlockableUtxos = [
{ ...contractUtxos[0], unlocker: contract.unlock.spend() },
{ ...aliceUtxos[0], unlocker: aliceTemplate.unlockP2PKT() },
];
transactionBuilder.addInputs(unlockableUtxos);

addReadOnlyInput()​

transactionBuilder.addReadOnlyInput(utxo: Utxo): this

Adds a read-only input, which refers to a UTXO without spending it. It has no unlocking script and is not signed, and its value is not counted in setMaxFee()'s fee check. Inputs keep the order in which they are added. Each transaction needs at least one input that is not read-only. As with fromReadOnly(), the input's locking script doesn't run.

Example​

transactionBuilder.addInput(contractUtxos[0], contract.unlock.spend());
transactionBuilder.addReadOnlyInput(oracleUtxo);

addOutput() & addOutputs()​

transactionBuilder.addOutput(output: Output): this
transactionBuilder.addOutputs(outputs: Output[]): this

Adds a single output or a list of outputs to the transaction. An output that carries tokens holds the amount it is given, which must be at least the dust limit of 546 satoshis: build() throws OutputSatoshisTooSmallError otherwise.

interface Output {
to: string | Uint8Array;
amount: bigint;
token?: TokenDetails;
}

interface TokenDetails {
amount: bigint;
groupId: string;
}

Example​

import { aliceAddress, bobAddress, transactionBuilder, tokenId } from './somewhere.js';

transactionBuilder.addOutput({
to: aliceAddress,
amount: 546n,
token: {
amount: 1000n,
groupId: tokenId,
}
});

transactionBuilder.addOutputs([
{ to: aliceAddress, amount: 50_000n },
{ to: bobAddress, amount: 50_000n },
]);

addOpReturnOutput()​

transactionBuilder.addOpReturnOutput(chunks: string[]): this

Adds an OP_RETURN output to the transaction with the provided data chunks in string format. If the string is 0x-prefixed, it is treated as a hex string, otherwise it is treated as a UTF-8 string.

Example​

transactionBuilder.addOpReturnOutput(['0x6d02', 'Hello World!']);

setLocktime()​

transactionBuilder.setLocktime(locktime: number): this

Sets the locktime for the transaction to set a transaction-level absolute timelock (see Timelock documentation for more information). The locktime can be set to a specific block height or a unix timestamp: values of 500,000,000 and above are read as a unix timestamp.

setMaxFee()​

transactionBuilder.setMaxFee(maxFee: bigint): this

Sets a max fee for the transaction. Because the advanced transaction builder does not automatically add a change output, you can set a max fee as a safety measure to make sure you don't accidentally pay too much in fees. If the transaction fee exceeds the max fee, an error will be thrown when building the transaction.

Example​

transactionBuilder.setMaxFee(1000n);

Transaction building​

send()​

async transactionBuilder.send(): Promise<ITransaction>
async transactionBuilder.send(raw: true): Promise<string>

After completing a transaction, the send() function can be used to send the transaction to the Nexa network. An incomplete transaction cannot be sent. Like the simple transaction builder's send(), it returns the sent transaction as a libnexa-ts ITransaction object, or as a raw hex string when called as send(true).

Example​

import { TransactionBuilder } from '@nexscript/nexscript';
import { aliceTemplate, aliceAddress, bobAddress, contract, provider } from './somewhere.js';

const contractUtxos = await contract.getUtxos();
const aliceUtxos = await provider.getUtxos(aliceAddress);

const txDetails = await new TransactionBuilder({ provider })
.addInput(contractUtxos[0], contract.unlock.spend(aliceTemplate, 1000n))
.addInput(aliceUtxos[0], aliceTemplate.unlockP2PKT())
.addOutput({ to: bobAddress, amount: 100_000n })
.addOpReturnOutput(['0x6d02', 'Hello World!'])
.setMaxFee(2000n)
.send()

build()​

transactionBuilder.build(): string

After completing a transaction, the build() function can be used to build the entire transaction and return the signed transaction hex string. This can then be imported into other libraries or applications as necessary.

Example​

import { TransactionBuilder } from '@nexscript/nexscript';
import { aliceTemplate, aliceAddress, bobAddress, contract, provider } from './somewhere.js';

const contractUtxos = await contract.getUtxos();
const aliceUtxos = await provider.getUtxos(aliceAddress);

const txHex = new TransactionBuilder({ provider })
.addInput(contractUtxos[0], contract.unlock.spend(aliceTemplate, 1000n))
.addInput(aliceUtxos[0], aliceTemplate.unlockP2PKT())
.addOutput({ to: bobAddress, amount: 100_000n })
.addOpReturnOutput(['0x6d02', 'Hello World!'])
.setMaxFee(2000n)
.build()

Custom unlockers​

interface Unlocker {
addInput: (options: AddInputOptions) => void;
signInput: (options: SignInputOptions) => void;
}

interface AddInputOptions {
txBuilder: TransactionBuilder; // libnexa-ts TransactionBuilder
input: Utxo;
}

interface SignInputOptions {
txBuilder: TransactionBuilder; // libnexa-ts TransactionBuilder
inputIndex: number;
network: string;
}

An unlocker tells the transaction builder how to add and sign one input. build() first calls addInput() for every input in order, which must add the UTXO to the libnexa-ts txBuilder. After setting the sequence numbers and locktime, it calls signInput() for every input with its index in the transaction, which must set the input's unlocking script or signature.

Example​

import { Unlocker } from '@nexscript/nexscript';
import { PrivateKey, SighashType } from 'libnexa-ts';

// Spends a P2PKT UTXO of privateKey's address, like SignatureTemplate.unlockP2PKT()
const p2pktUnlocker = (privateKey: PrivateKey): Unlocker => ({
addInput({ txBuilder, input }) {
txBuilder.from({ outpoint: input.txid, satoshis: Number(input.satoshis), address: input.address });
},
signInput({ txBuilder, inputIndex }) {
txBuilder.signInput(inputIndex, privateKey, SighashType.ALL);
},
});

transactionBuilder.addInput(aliceUtxos[0], p2pktUnlocker(alicePrivateKey));

Transaction errors​

Transactions can fail for a number of reasons. Refer to the Transaction Errors section of the simplified transaction builder documentation for more information. Errors thrown by transactionBuilder.send() have an empty meep field.