Transaction Building & Signing
An in-depth guide to how Stacks transactions are constructed, securely signed with Turnkey, and broadcasted to the network.
The Anatomy of a Transaction
Every action you take that changes the state of the blockchain—like sending tokens or interacting with a smart contract—is a transaction. The process of creating and sending a transaction is a multi-step cryptographic dance designed to be highly secure, ensuring that only you can authorize actions from your account.
This guide breaks down the five core steps that happen behind the scenes every time you click "Send" or "Confirm."
The 5 Steps of a Transaction
1. Build the Unsigned Transaction
The first step is to create a clear statement of intent. The wallet constructs an unsigned transaction, which is a data structure that describes exactly what you want to do, but without the cryptographic proof of your approval. Think of it like writing a check and filling in the recipient and amount, but leaving the signature line blank.
The Stacks.js library provides helper functions like makeUnsignedSTXTokenTransfer to build this object correctly. It includes crucial pieces of information:
recipient: The Stacks address that will receive the assets.amount: The quantity of the token being sent, specified in the smallest unit (micro-STX for STX).publicKey: Your public key, which identifies your account as the sender.network: Specifies the target network ('testnet' or 'mainnet').nonce: A transaction counter unique to your account. This is a critical security feature that prevents someone from replaying an old transaction. The nonce for a new transaction must be exactly one higher than the last confirmed transaction from your account.fee: The amount of STX you're willing to pay miners to process your transaction.
// From: components/wallet/send-stx/SendStxForm.tsx
import { makeUnsignedSTXTokenTransfer } from "@stacks/transactions";
const transaction = await makeUnsignedSTXTokenTransfer({
recipient,
amount: BigInt(parseFloat(amount) * 1_000_000), // Convert to micro-STX
publicKey: account.publicKey,
network: "testnet",
// Nonce and fee are typically estimated automatically by the library.
});2. Generate the Signature Hash (SigHash)
Signing the entire transaction object would be inefficient. Instead, the wallet uses a cryptographic hash function to create a unique, fixed-size fingerprint of the transaction's most important details. This is called a signature hash (or SigHash).
This process ensures two things:
- Integrity: If even a single byte of the transaction data were changed, the resulting SigHash would be completely different.
- Efficiency: The signing algorithm only needs to process this small hash, not the full transaction data.
// From: components/wallet/send-stx/SendStxForm.tsx
import { TransactionSigner, sigHashPreSign } from "@stacks/transactions";
// Create a signer object to manage the signing process
const signer = new TransactionSigner(transaction);
// Generate the hash that needs to be signed
const preSignSigHash = sigHashPreSign(
signer.sigHash,
transaction.auth.authType,
transaction.auth.spendingCondition.fee,
transaction.auth.spendingCondition.nonce
);3. Sign the Hash with Turnkey
This is the core of the security model. The wallet sends the signature hash—and only the hash—to Turnkey's secure environment. Your private key is never exposed to the browser, the dApp, or even the wallet application itself.
Zero-Knowledge Signing
The signing operation happens within Turnkey's secure infrastructure, initiated by your passkey authentication (e.g., Face ID, fingerprint). The wallet doesn't need to know your private key; it only needs to ask Turnkey to prove you've approved the transaction hash.
The hashFunction: "HASH_FUNCTION_NO_OP" parameter tells Turnkey that we have already hashed the data and it should sign this hash directly, rather than hashing it a second time.
// From: components/wallet/send-stx/SendStxForm.tsx
import { useTurnkey } from "@turnkey/react-wallet-kit";
const { httpClient } = useTurnkey();
// Securely request a signature for the hash from Turnkey
const signature = await httpClient.signRawPayload({
signWith: account.publicKey,
payload: `0x${preSignSigHash}`, // The hash to be signed
encoding: "PAYLOAD_ENCODING_HEXADECIMAL",
hashFunction: "HASH_FUNCTION_NO_OP", // Tells Turnkey to not hash the payload again
});4. Attach the Signature
Turnkey returns a cryptographic signature, which is mathematical proof that the owner of the private key has approved the transaction. This signature is then formatted and attached to the spendingCondition property of the original unsigned transaction object.
With the signature attached, the transaction is now considered signed and cryptographically complete. It contains both the user's intent (the transaction data) and their approval (the signature).
// From: components/wallet/send-stx/SendStxForm.tsx
import { createMessageSignature, SingleSigSpendingCondition } from "@stacks/transactions";
// Format the signature from Turnkey's {v, r, s} response
const formattedSignature = `${signature.v}${signature.r.padStart(64,"0")}${signature.s.padStart(64, "0")}`;
// Attach the signature to the transaction's authorization field
(transaction.auth.spendingCondition as SingleSigSpendingCondition).signature =
createMessageSignature(formattedSignature);5. Broadcast to the Network
The final step is to send the fully signed transaction to a Stacks blockchain node. The broadcastTransaction function handles this, submitting the transaction to the network's "mempool."
The mempool is a waiting area for valid, signed transactions. Miners on the network pick transactions from the mempool to include in the next block. Once included in a block, the transaction is considered confirmed and immutable. The broadcast function returns a txid (transaction ID), which you can use to track its status on a block explorer.
// From: components/wallet/send-stx/SendStxForm.tsx
import { broadcastTransaction } from "@stacks/transactions";
// Send the completed transaction to the Stacks network
const result = await broadcastTransaction({
transaction: transaction,
network: "testnet",
});
// The result will contain the transaction ID (txid) if successful
console.log("Broadcast successful! TXID:", result.txid);