Kaspalytics Learn is a work in progress.
Updated: Aug 2, 2026

Transactions

Building, signing, and submitting Kaspa transactions from Rust, JavaScript, or Python - coin selection, mass and fees, and confirmation states.

Kaspa is a UTXO network, so sending a payment means: gather spendable UTXOs for an address, define the outputs, add a change output, compute mass and fees, sign each input, and submit. Every SDK ships a generator that does the first four steps for you — reach for the raw primitives only when you need custom lockup scripts, exact input ordering, payload data, or offline signing.

Use testnet-10 while learning. Test KAS is free.

What’s different about a Kaspa transaction

The shape is familiar — inputs, outputs, a few metadata fields — but three things differ from a Bitcoin-shaped chain:

  • Inputs carry their own UTXO context. Each input holds a reference to the entry it spends, so the signer doesn’t re-fetch the spent output for its amount and lockup.
  • Mass replaces “byte size × rate” as the fee model. Mass is computed over the transaction, including a storage component derived from input and output values, then multiplied by the prevailing fee rate. Fees themselves are implicit: inputs − outputs.
  • The atomic unit is the sompi, and 1 KAS = 100,000,000 sompi. Every amount in the transaction surface is an integer sompi count. See Units.

See Transaction Structure for the full field-by-field layout.

Keys and addresses

Kaspa’s BIP-44 coin type is 111111. Derive a key, encode it for the network you’re on:

use kaspa_addresses::{Address, Prefix, Version};
use kaspa_bip32::secp256k1::{Keypair, SECP256K1};

let secret_key_bytes: [u8; 32] = /* 32 bytes of key material */;
let keypair = Keypair::from_seckey_slice(SECP256K1, &secret_key_bytes)?;

let address = Address::new(
    Prefix::Testnet,
    Version::PubKey,
    &keypair.x_only_public_key().0.serialize(),
);
const { Mnemonic, XPrv, NetworkType } = kaspa;

const mnemonic = Mnemonic.random(); // or new Mnemonic(existingPhrase)
const xprv = new XPrv(mnemonic.toSeed());

const privateKey = xprv.derivePath("m/44'/111111'/0'/0/0").toPrivateKey();
const address = privateKey.toAddress(NetworkType.Testnet);
from kaspa import Mnemonic, NetworkType, XPrv

mnemonic = Mnemonic.random()          # or Mnemonic(existing_phrase)
xprv = XPrv(mnemonic.to_seed())

private_key = xprv.derive_path("m/44'/111111'/0'/0/0").to_private_key()
address = private_key.to_address(NetworkType.Testnet)

The address prefix is baked into the checksum, so a mainnet address won’t parse as a testnet one. See Addresses.

Build, sign, submit

The everyday path. Fetch UTXOs, hand them to the generator with your outputs and a change address, then sign and submit each transaction it yields:

use std::sync::Arc;

use futures::TryStreamExt;
use kaspa_rpc_core::api::rpc::RpcApi;
use kaspa_wallet_core::prelude::*;
use kaspa_wallet_core::tx::{Generator, GeneratorSettings};
use kaspa_wallet_core::utxo::UtxoEntryReference;
use kaspa_wrpc_client::prelude::{NetworkId, NetworkType};

let network_id = NetworkId::new(NetworkType::Mainnet);
let rpc: Arc<DynRpcApi> = Arc::new(client);

// 1. Gather UTXOs for the sending address.
let entries: Vec<UtxoEntryReference> = rpc
    .get_utxos_by_addresses(vec![source_address.clone()])
    .await?
    .into_iter()
    .map(UtxoEntryReference::from)
    .collect();

// 2. Describe the send. The generator handles selection, mass, and change.
let settings = GeneratorSettings::try_new_with_iterator(
    network_id,
    Box::new(entries.into_iter()),
    None,                                        // priority UTXOs to consume first
    source_address.clone(),                      // change address
    1,                                           // sig_op_count
    1,                                           // minimum_signatures
    PaymentDestination::from(PaymentOutput::new(destination, kaspa_to_sompi(0.2))),
    None,                                        // fee_rate — None uses the network minimum
    Fees::SenderPays(0),                         // priority fee on top of the base fee
    None,                                        // payload
    None,                                        // event multiplexer
)?;

let generator = Generator::try_new(settings, None, None)?;

// 3. Sign and submit each transaction in the chain.
let mut stream = generator.stream();
while let Some(pending) = stream.try_next().await? {
    pending.try_sign_with_keys(&[secret_key_bytes], None)?;
    let txid = pending.try_submit(&rpc).await?;
    println!("submitted: {txid}");
}

println!("{:?}", generator.summary());
const { createTransactions, kaspaToSompi } = kaspa;

// 1. Gather UTXOs for the sending address.
const { entries } = await rpc.getUtxosByAddresses([sourceAddress]);

// 2. Build. Coin selection, change, mass and fees all happen here.
const { transactions, summary } = await createTransactions({
	entries,
	outputs: [{ address: destinationAddress, amount: kaspaToSompi('0.2') }],
	priorityFee: 0n, // extra sompi on top of the base fee
	changeAddress: sourceAddress
});

console.log('Summary:', summary);

// 3. Sign and submit each transaction in the chain.
for (const pending of transactions) {
	await pending.sign([privateKey]);
	const txid = await pending.submit(rpc);
	console.log('submitted:', txid);
}
from kaspa import create_transactions

# 1. Gather UTXOs for the sending address.
utxos = await client.get_utxos_by_addresses({"addresses": [source_address.to_string()]})

# 2. Build. Coin selection, change, mass and fees all happen here.
result = create_transactions(
    network_id="mainnet",
    entries=utxos["entries"],
    change_address=source_address,
    outputs=[{"address": destination_address, "amount": 20_000_000}],   # 0.2 KAS
    priority_fee=0,
)

print(result["summary"])

# 3. Sign and submit each transaction in the chain.
for pending in result["transactions"]:
    pending.sign([private_key])
    print("submitted:", await pending.submit(client))

The result is a list because a payment whose input set exceeds the per-transaction mass limit is automatically split: the generator emits a chain of consolidating transactions followed by the final payment. A typical payment produces one. Loop and submit each in order.

Check that the node is synced before submitting — every SDK exposes this on the server-info response.

Estimate before you sign

Same coin selection, no signatures, no submission. Use this to show a user the fee before asking them to authorize:

let summary = generator.summary();
println!("{} fees across {} transactions", summary.fees, summary.number_of_generated_transactions);
const { estimateTransactions } = kaspa;

const summary = await estimateTransactions({
	entries,
	outputs: [{ address: destinationAddress, amount: kaspaToSompi('0.2') }],
	priorityFee: 0n,
	changeAddress: sourceAddress
});

console.log(summary.fees, summary.transactions, summary.utxos);
from kaspa import estimate_transactions

summary = estimate_transactions(
    network_id="mainnet",
    entries=utxos["entries"],
    change_address=source_address,
    outputs=[{"address": destination_address, "amount": 20_000_000}],
)

print(summary.fees, summary.transactions, summary.utxos)

Estimating doesn’t consume the generator — you can iterate it for real afterwards.

For a live fee rate, call getFeeEstimate (see RPC Calls), which returns suggested fee-rate buckets. Passing an explicit fee rate below the network minimum gets the transaction rejected.

Signing

The generator’s pending transactions sign in one call. Hand in every key any input needs — the signer for each input is inferred from that input’s UTXO lockup:

pending.try_sign_with_keys(&[key_a, key_b], None)?;   // multisig: every cosigner
await pending.sign([privateKey]);
await pending.sign([key1, key2, key3]); // multisig
pending.sign([private_key])
pending.sign([key1, key2, key3])        # multisig

Kaspa defaults to Schnorr signatures over secp256k1; ECDSA is supported for inputs locked to ECDSA addresses.

A few things worth knowing:

  • Sign last. Changing inputs, outputs, or mass after signing invalidates the signature.
  • Mass is signed over. On the manual path, fill mass before signing.
  • SighashType.All is the default and the only one most code should use — it commits to every input and every output. The other variants exist for collaborative flows like coinjoins, where cosigners add inputs or outputs after one party signs.
  • sig_op_count and minimum_signatures feed the mass calculation. For a single-key spend both are 1; for M-of-N multisig, sig_op_count is M. Wrong values give wrong mass, and the transaction is either rejected as underpaying or overpays. On version 1 transactions a per-input compute budget takes the place of the sigop count — see Transaction Structure.

For per-input control — mixed-key wallets, hardware signers, partially co-signed flows — every SDK exposes a per-input signing call plus a way to produce a raw signature without filling the input, so it can be handed to a co-signer for aggregation.

The manual path

When you need custom lockup scripts, exact input ordering, or a payload, build the transaction from primitives. This is what the generator does internally:

from kaspa import (
    Transaction, TransactionInput, TransactionOutput, TransactionOutpoint,
    UtxoEntryReference, sign_transaction, pay_to_address_script,
    update_transaction_mass,
)

resp = await client.get_utxos_by_addresses({"addresses": [my_address]})

inputs = [
    TransactionInput(
        previous_outpoint=TransactionOutpoint(
            transaction_id=u["outpoint"]["transactionId"],
            index=u["outpoint"]["index"],
        ),
        signature_script="",          # filled at sign time
        sequence=0,
        sig_op_count=1,
        utxo=UtxoEntryReference(u),
    )
    for u in resp["entries"]
]

outputs = [
    TransactionOutput(value=amount, script_public_key=pay_to_address_script(recipient)),
    TransactionOutput(value=change_amount, script_public_key=pay_to_address_script(change_addr)),
]

tx = Transaction(
    version=0, inputs=inputs, outputs=outputs,
    lock_time=0,
    subnetwork_id="0000000000000000000000000000000000000000",
    gas=0, payload="", mass=0,
)

update_transaction_mass("mainnet", tx)              # mass is signed over — fill before signing
signed = sign_transaction(tx, [private_key], verify_sig=True)

await client.submit_transaction({"transaction": signed, "allowOrphan": False})

The WASM SDK has the same surface under camelCase names — Transaction, TransactionInput, payToAddressScript, updateTransactionMass, signTransaction — and Rust exposes the underlying consensus types directly from kaspa-consensus-core and kaspa-txscript.

version=0 above is the ordinary payment layout. Build a version 1 transaction instead when an output needs a covenant binding or an input needs a compute budget rather than a sigop count — set version=1, give each input a compute_budget, and attach a CovenantBinding to the outputs that need one. See Transaction Structure.

For non-standard lockups, see P2SH Script Format, SilverScript, and ZK Scripts.

Submission and confirmation

Submitting hands the transaction to a node, which gossips it and includes it in a block when capacity allows. Submission is acceptance into the mempool, not confirmation.

A Kaspa transaction has three observable states:

StateWhat it meansHow you observe it
In mempoolAccepted by the node, waiting for inclusion.The transaction id returned from submit.
Virtual-chain acceptedIncluded in a block that is part of the canonical DAG ordering.The virtual-chain-changed notification.
MatureConfirmed past the maturity threshold; new UTXOs are spendable.A maturity event from the wallet or UTXO processor.

The right gate for “is it confirmed” is the maturity event for that specific transaction — not a fixed sleep, and not the first time it appears in a block. A virtual-chain-accepted transaction can be reorged out, at which point its outputs are no longer mature; the SDKs surface that as a reorg event.

The allowOrphan flag on manual submission controls whether the node keeps the transaction when an input hasn’t been seen yet. Leave it false unless you’re deliberately submitting a chain out of order.

When submission fails

ErrorCause and fix
fee too lowMass changed after it was computed. Recompute mass after any input/output change, then re-sign.
orphanAn input references a transaction the node hasn’t seen. Wait for the parent, or set allowOrphan.
already in mempoolSame transaction id is already pending. Safe to ignore.
mass exceededOver a block mass limit, so no block could ever include it. Split the inputs across multiple transactions — the generator does this automatically, at its own 100,000-gram threshold.

Where to next

  • Transaction Structure — every field, and what the node checks.
  • Wallet SDK — long-running UTXO tracking so you don’t re-fetch entries on every send.
  • Wallet — the managed wallet, which wraps all of this with accounts and persistence.
  • PSKT Roles — multi-party and offline signing flows.
Kaspalytics strives to provide accurate data - our highest level of effort is given to data validation and maintenance. However, we cannot guarantee 100% accuracy. Data is subject to inaccuracies and change.

Contact | © 2026 Kaspalytics