Building Transactions
The TxBuilder from @hydra-sdk/transaction gives you a fluent API for assembling Cardano transactions: selecting inputs, adding outputs, attaching scripts and redeemers, and completing an unsigned transaction. This guide walks through the core flows: building and submitting a simple transfer, signing (including multi-signature), and locking or unlocking assets with Plutus scripts.
Build and submit a transaction
The basic flow is always the same: fetch UTxOs for your address, feed them to the TxBuilder, add your outputs, set a change address, and call .complete() to get an unsigned transaction. You then sign it with your wallet and submit it through a provider.
Using Blockfrost Provider
import { AppWallet, NETWORK_ID, ProviderUtils } from '@hydra-sdk/core'
import { TxBuilder } from '@hydra-sdk/transaction'
// Initialize Blockfrost provider
const blockfrostProvider = new ProviderUtils.BlockfrostProvider({
apiKey: 'your-blockfrost-api-key',
network: 'preprod'
})
// Create wallet (real-world example)
const wallet = new AppWallet({
key: {
type: 'mnemonic',
words: 'armed pink solve client dignity alarm earn impose acquire rib eyebrow engage dragon face funny'.split(' ')
},
networkId: NETWORK_ID.PREPROD
})
async function buildSimpleTransaction() {
const baseAddressBech32 = wallet.getAccount().baseAddressBech32
console.log('>>> Wallet Address:', baseAddressBech32)
// Fetch UTxOs from Blockfrost
const utxos = await blockfrostProvider.fetcher.fetchAddressUTxOs(baseAddressBech32)
console.log('>>> UTxOs found:', utxos.length)
// Build transaction
const txBuilder = new TxBuilder({})
const tx = await txBuilder
.setInputs(utxos)
.addOutput({
address: baseAddressBech32, // Send back to same address
amount: [{ unit: 'lovelace', quantity: '1000000' }] // 1 ADA
})
.setChangeAddress(baseAddressBech32)
.complete()
console.log('>>> Unsigned Tx:', tx.to_hex())
// Sign transaction
const signedTx = await wallet.signTx(tx.to_hex())
console.log('>>> Signed Tx:', signedTx)
// Submit transaction (optional)
// const txHash = await blockfrostProvider.submitter.submitTx(signedTx)
// console.log('>>> Tx Hash:', txHash)
}
Using Ogmios Provider
The same build flow works with any provider. Swap the provider and use its submitter to broadcast the signed transaction:
import { AppWallet, NETWORK_ID, ProviderUtils } from '@hydra-sdk/core'
import { TxBuilder } from '@hydra-sdk/transaction'
// Initialize Ogmios provider
const ogmiosProvider = new ProviderUtils.OgmiosProvider({
apiEndpoint: 'https://preprod.cardano-rpc.hydrawallet.app',
network: 'preprod'
})
const wallet = new AppWallet({
key: {
type: 'mnemonic',
words: 'armed pink solve client dignity alarm earn impose acquire rib eyebrow engage dragon face funny'.split(' ')
},
networkId: NETWORK_ID.PREPROD
})
async function buildTransactionWithOgmios() {
const baseAddressBech32 = wallet.getAccount().baseAddressBech32
console.log('>>> Wallet Address:', baseAddressBech32)
// Fetch UTxOs from Ogmios
const utxos = await ogmiosProvider.fetcher.fetchAddressUTxOs(baseAddressBech32)
console.log('>>> UTxOs found:', JSON.stringify(utxos, null, 1))
const txBuilder = new TxBuilder({})
const tx = await txBuilder
.setInputs(utxos)
.addOutput({
address: baseAddressBech32,
amount: [{ unit: 'lovelace', quantity: '1000000' }]
})
.setChangeAddress(baseAddressBech32)
.complete()
console.log('>>> Unsigned Tx:', tx.to_hex())
const signedTx = await wallet.signTx(tx.to_hex())
console.log('>>> Signed Tx:', signedTx)
// Submit with Ogmios
const txHash = await ogmiosProvider.submitter.submitTx(signedTx)
console.log('>>> Submitted Tx Hash:', txHash)
}
Signing transactions
wallet.signTx() takes an unsigned transaction as CBOR hex and returns the signed CBOR. Once signed, you can deserialize it to read the transaction hash before submitting.
Single signature
import { AppWallet, NETWORK_ID, Deserializer } from '@hydra-sdk/core'
const wallet = new AppWallet({
key: {
type: 'mnemonic',
words: 'your mnemonic words here'.split(' ')
},
networkId: NETWORK_ID.PREPROD
})
async function signTransaction(cborHex: string, partialSign: boolean = false) {
try {
const signedTx = await wallet.signTx(cborHex, partialSign)
const txHash = Deserializer.deserializeTx(signedTx).transaction_hash().to_hex()
console.log('✅ Transaction signed:', txHash)
return { signedTx, txHash }
} catch (error) {
console.error('❌ Signing error:', error)
throw error
}
}
// Usage
const result = await signTransaction(transactionCborHex)
Multi-signature
When a transaction requires signatures from multiple wallets, chain the signing calls: each signer feeds the CBOR produced by the previous one back into signTx.
partialSign: true for every signer except the last. Partial signing appends a witness without finalizing the transaction, so the next signer can add theirs. The final signer signs without partialSign to complete the transaction.import { AppWallet, NETWORK_ID, Deserializer } from '@hydra-sdk/core'
class MultiSigWallet {
private wallets: AppWallet[] = []
addSigner(mnemonic: string[]) {
const wallet = new AppWallet({
key: { type: 'mnemonic', words: mnemonic },
networkId: NETWORK_ID.PREPROD
})
this.wallets.push(wallet)
}
async signWithAllWallets(cborHex: string) {
let currentCbor = cborHex
for (let i = 0; i < this.wallets.length; i++) {
const isLastSigner = i === this.wallets.length - 1
currentCbor = await this.wallets[i].signTx(currentCbor, !isLastSigner)
console.log(`✅ Wallet ${i + 1}/${this.wallets.length} signed`)
}
return {
finalTx: currentCbor,
txHash: Deserializer.deserializeTx(currentCbor).transaction_hash().to_hex()
}
}
}
// Usage
const multiSig = new MultiSigWallet()
multiSig.addSigner('first signer mnemonic'.split(' '))
multiSig.addSigner('second signer mnemonic'.split(' '))
const result = await multiSig.signWithAllWallets(unsignedTxCbor)
Performance Pattern
For high-throughput scenarios, wrap signing in a small helper that records timing and transaction shape. This lets you track signing latency and spot regressions across many transactions.
import { AppWallet, NETWORK_ID, Deserializer } from '@hydra-sdk/core'
class PerformanceSigner {
private wallet: AppWallet
private metrics: Array<{
timestamp: number
duration: number
txHash: string
inputCount: number
outputCount: number
}> = []
constructor(mnemonic: string[]) {
this.wallet = new AppWallet({
key: { type: 'mnemonic', words: mnemonic },
networkId: NETWORK_ID.PREPROD
})
}
async signWithMetrics(cborHex: string) {
const startTime = Date.now()
try {
const signedTx = await this.wallet.signTx(cborHex)
const duration = Date.now() - startTime
const tx = Deserializer.deserializeTx(signedTx)
const txHash = tx.transaction_hash().to_hex()
const body = tx.transaction_body()
this.metrics.push({
timestamp: startTime,
duration,
txHash,
inputCount: body.inputs().len(),
outputCount: body.outputs().len()
})
console.log(`⏱️ Signing took ${duration}ms: ${txHash}`)
return { signedTx, txHash, duration }
} catch (error) {
const duration = Date.now() - startTime
console.error(`❌ Signing failed after ${duration}ms:`, error)
throw error
}
}
getPerformanceStats() {
if (this.metrics.length === 0) return null
const durations = this.metrics.map(m => m.duration)
const avg = durations.reduce((a, b) => a + b, 0) / durations.length
return {
totalTransactions: this.metrics.length,
averageDuration: Math.round(avg),
minDuration: Math.min(...durations),
maxDuration: Math.max(...durations)
}
}
}
// Usage
const performanceSigner = new PerformanceSigner('your mnemonic words here'.split(' '))
await performanceSigner.signWithMetrics(tx1)
await performanceSigner.signWithMetrics(tx2)
const stats = performanceSigner.getPerformanceStats()
console.log('Stats:', stats)
Locking and unlocking with Plutus scripts
Plutus scripts let you lock assets at a contract address and release them only when a valid redeemer is supplied. Locking is a plain output to the script address plus an inline datum. Unlocking is more involved: you spend the script UTxO as an input, attach the redeemer and script, and provide collateral.
Lock assets in a contract script
This example locks 2 ADA at a Plutus script address, attaching an inline datum that the unlock step will validate against.
import { AppWallet, DatumUtils, Deserializer, NETWORK_ID, ParserUtils, ProviderUtils, UTxO } from '@hydra-sdk/core'
import { TxBuilder } from '@hydra-sdk/transaction'
const lock = async () => {
try {
// =========== Prepare Wallet ===========
const blockfrostProvider = new ProviderUtils.BlockfrostProvider({
apiKey: 'your-blockfrost-api-key',
network: 'preprod'
})
const wallet = new AppWallet({
key: {
type: 'mnemonic',
words: 'your_mnemonic_words'.split(' ')
},
networkId: NETWORK_ID.PREPROD,
fetcher: blockfrostProvider.fetcher,
submitter: blockfrostProvider.submitter
})
const account = wallet.getAccount(0, 0)
/**
* Query UTxOs for the account using Browser Wallet (Lace, Nami, etc.)
* or Blockfrost API.
* In this example, we're using Hexcore API.
*/
const addressUTxOs: UTxO[] = await wallet.queryUTxOs(account.baseAddressBech32)
// =========== Contract Configuration ===========
const contract = {
// Always true contract
address: 'addr_test1wr47xyv2z8vjgxw3kckdelrl9lunw59hegma8t7jtzu0k8qw08cvd',
type: 'PlutusScriptV3',
description: 'Generated by Aiken'
}
// =========== Build Lock Transaction ===========
const datum = DatumUtils.mkConstr(0, [
DatumUtils.mkInt(42),
DatumUtils.mkBytes(ParserUtils.stringToHex("Hello, World!"))
])
const txBuilder = new TxBuilder()
const txLock = await txBuilder
.setInputs(addressUTxOs) // Select UTxOs for inputs
.txOut(contract.address, [{ unit: 'lovelace', quantity: '2000000' }]) // Send 2 ADA to contract
.txOutInlineDatumValue(datum)
.changeAddress(account.baseAddressBech32)
.complete()
// =========== Sign Transaction ===========
const unsignedCborHex = txLock.to_hex()
const signedCbor = await wallet.signTx(unsignedCborHex)
// Get transaction hash (optional)
const txHash = Deserializer.deserializeTx(signedCbor).transaction_hash()
// =========== Submit Transaction ===========
/**
* Submit the signed transaction to the Cardano network.
* You can use:
* - Hexcore API
* - Ogmios API
* - Blockfrost API (https://docs.blockfrost.io)
* - Wallet Extension API (HydraWallet, Eternl, etc.)
*/
const txId = await wallet.submitTx(signedCbor)
console.log('Transaction submitted successfully:', txId)
return txId
} catch (error) {
console.error('Error creating lock transaction:', error)
throw error
}
}
// Execute the lock function
lock()
Unlock assets from a contract script
Unlocking assumes an asset is already locked (via the example above). You supply the script UTxO as an input, a redeemer, the Plutus script itself, and a collateral UTxO to cover script-execution failure.
import { AppWallet, Deserializer, NETWORK_ID, ProviderUtils, UTxO } from '@hydra-sdk/core'
import { buildRedeemer, TxBuilder } from '@hydra-sdk/transaction'
const unlock = async (txHash: `${string}#${number}`) => {
try {
// =========== Prepare Wallet ===========
const blockfrostProvider = new ProviderUtils.BlockfrostProvider({
apiKey: 'your-blockfrost-api-key',
network: 'preprod'
})
const wallet = new AppWallet({
key: {
type: 'mnemonic',
words: 'your_mnemonic_words'.split(' ')
},
networkId: NETWORK_ID.PREPROD,
fetcher: blockfrostProvider.fetcher,
submitter: blockfrostProvider.submitter
})
const account = wallet.getAccount(0, 0)
/**
* Query UTxOs for the account using Browser Wallet (Lace, Nami, etc.)
* or Blockfrost API.
* In this example, we're using Hexcore API.
*/
const addressUTxOs: UTxO[] = await wallet.queryUTxOs(account.baseAddressBech32)
// =========== Prepare Collateral UTxO ===========
// Find collateral UTxO: needs at least 5 ADA in lovelace
const collateralUTxOs = addressUTxOs.filter(utxo =>
utxo.output.amount.find(asset =>
asset.unit === 'lovelace' && Number(asset.quantity) >= 5_000_000
)
)
if (!collateralUTxOs.length) {
throw new Error('No suitable collateral UTxOs found (minimum 5 ADA required)')
}
const collateralUTxO = collateralUTxOs[0]
// =========== Contract Configuration ===========
const contract = {
// Always true contract
address: 'addr_test1wr47xyv2z8vjgxw3kckdelrl9lunw59hegma8t7jtzu0k8qw08cvd',
type: 'PlutusScriptV3',
description: 'Generated by Aiken',
cborHex: '58c958c701010029800aba2aba1aab9faab9eaab9dab9a48888896600264653001300700198039804000cc01c0092225980099b8748008c01cdd500144c8cc896600266e1d2000300a375400d132323322598009809001c56600266e3cdd7180898079baa00a48810a7365637265745f6b6579008cc004cdc79bae30113012300f375401491100a50a51403514a0806a2c8080dd718078009bae300f002300f001300b375400d16402460160026016601800260106ea800a2c8030600e00260066ea801e29344d95900101'
}
// =========== Query Contract UTxO ===========
/**
* Find the UTxO that corresponds to the script we want to unlock.
* Ensure that the asset is actually locked in the script.
*/
const contractUTxOs = await wallet.queryUTxOs(contract.address)
const scriptUTxO = contractUTxOs.find(utxo =>
`${utxo.input.txHash}#${utxo.input.outputIndex}` === txHash
)
if (!scriptUTxO) {
throw new Error(`No script UTxO found for transaction: ${txHash}`)
}
// =========== Build Redeemer ===========
/**
* Redeemer data is required to unlock the asset.
* The structure depends on the specific contract requirements.
* In this case, we need to provide:
* - secret_key: A string value
* - receive_addr: The address to receive the unlocked assets
*/
const txRedeemer = buildRedeemer({
key: 'secret_key',
receive_addr: 'addr_test1qpxsf0x8xypuhq5k408f9kh0meyy6jv2lxgqw2fefvjlte0u06dugtmxuhhw8hschdn4q59g64q5s9z42ax6qyg7ewsqt6e548'
})
// =========== Build Unlock Transaction ===========
const txBuilder = new TxBuilder()
const txUnlock = await txBuilder
// Add additional UTxO for transaction fees
.setInputs([collateralUTxOs[1]])
// Add the script UTxO as input
.txIn(
scriptUTxO.input.txHash,
scriptUTxO.input.outputIndex,
scriptUTxO.output.amount,
scriptUTxO.output.address
)
// Attach redeemer to the script input
.txInRedeemerValue(txRedeemer)
// Attach the Plutus script
.txInScript(contract.cborHex)
// Set collateral for script execution
.txInCollateral(
collateralUTxO.input.txHash,
collateralUTxO.input.outputIndex,
collateralUTxO.output.amount,
collateralUTxO.output.address
)
// Send unlocked assets back to our address
.txOut(account.baseAddressBech32, scriptUTxO.output.amount)
// Set change address
.changeAddress(account.baseAddressBech32)
// Set transaction fee (2 ADA)
.setFee('2000000')
// =========== Complete and Sign Transaction ===========
const tx = await txUnlock.complete()
const unsignedCborHex = tx.to_hex()
const signedCbor = await wallet.signTx(unsignedCborHex)
// Get transaction hash (optional)
const unlockTxHash = Deserializer.deserializeTx(signedCbor).transaction_hash()
// =========== Submit Transaction ===========
/**
* Submit the signed transaction to the Cardano network.
* You can use:
* - Hexcore API
* - Ogmios API
* - Blockfrost API (https://docs.blockfrost.io)
* - Wallet Extension API (HydraWallet, Eternl, etc.)
*/
const txHash = await wallet.submitTx(signedCbor)
console.log('Unlock transaction submitted successfully:', txHash)
return txHash
} catch (error) {
console.error('Error creating unlock transaction:', error)
throw error
}
}
// Execute the unlock function with the transaction hash from the lock operation
// unlock('your_lock_transaction_hash#output_index')
Key points
Lock transaction
- Sends assets to the contract address with
.txOut(). - Attaches an inline datum with
.txOutInlineDatumValue(). - No special script handling is required for locking.
Unlock transaction
- Requires a collateral UTxO (minimum 5 ADA).
- Uses the script UTxO as an input with
.txIn(). - Attaches the redeemer with
.txInRedeemerValue(). - Attaches the Plutus script with
.txInScript(). - Sets collateral with
.txInCollateral(). - Validates collateral availability and script UTxO existence before building.
Minting and burning tokens
The TxBuilder also supports minting and burning native tokens. You attach a native minting policy with .mintingScript(), call .mint() with a positive quantity to mint or a negative quantity to burn, and optionally attach CIP-25 token metadata. Because these flows have their own dedicated walkthrough, they are documented separately.
High-volume building (v1.2.0+)
Every CSL object lives in WASM memory and is only reclaimed by .free(). For one-off transactions the default complete() is fine, but when you build thousands in a row (e.g. inside a Hydra Head), use the leak-free helpers added in @hydra-sdk/transaction@1.2.0:
completeCbor()— builds and returns the CBOR hex, freeing the intermediateTransactionfor you. Prefer it overcomplete()+.to_hex()when you only need the bytes.dispose()/using— release all WASM memory a builder holds; reuse one builder withreset()across a spike anddispose()at the end.
import { TxBuilder } from '@hydra-sdk/transaction'
// Reuse one builder across many transactions, then dispose once
const builder = new TxBuilder()
for (const job of jobs) {
const cbor = await builder
.reset()
.setInputs(job.utxos)
.txOut(job.address, job.amount)
.changeAddress(job.changeAddress)
.completeCbor()
await submit(cbor)
}
builder.dispose()
See Performance → TxBuilder WASM Memory for benchmarks and the full pattern list.
Next steps
- Mint and Burn Tokens — create and destroy native tokens with metadata.
- Utilities Cookbook — helpers for datums, policies, addresses, and parsing.
- Transaction API reference — the full
TxBuilderand redeemer API. - Transactions in Hydra — how transactions differ inside a Hydra Head.
