Solana partialSign and Separating the Fee Payer

How to collect signatures from several keys, why the fee payer does not have to be the authority, and the serialization rules that break multi-signer flows.

BoltTx Team··9 min read
solanamultisigpartialSignfee-payertransaction-landingbackend

Solana transactions can require several signatures, and the common assumption is that whoever signs is whoever pays. ★They are separate roles, and separating them deliberately is one of the more useful patterns available to a backend.★

The mechanics are simple. The parts that break are partialSign ordering and how you call serialize on a transaction that is not fully signed yet.

Who Pays Is Not Who Authorises

The fee payer is accountKeys[0]. It signs, and its balance covers the fee. Every other signer authorises something without necessarily paying anything.

const message = new TransactionMessage({
  payerKey: relayer.publicKey,     // ★pays the fee★
  recentBlockhash: blockhash,
  instructions,                    // ★user's authority signs inside★
}).compileToV0Message();

const tx = new VersionedTransaction(message);
tx.sign([relayer, userAuthority]);

This is what makes gasless flows possible. ★A user with no SOL can still act, because your relayer covers the fee while the user's key provides the authority.★ The user never needs a funded wallet to interact.

It also means a compromised relayer key cannot move user funds — it can only waste fees. Separating the roles bounds what each key can do.

partialSign and the Signature Slots

When signers are in different places, sign incrementally:

const tx = new Transaction({
  feePayer: relayer.publicKey,
  blockhash,
  lastValidBlockHeight,
}).add(...instructions);

tx.partialSign(relayer);          // ★first, wherever it happens★
// ... send to the user, or to the next approver ...
tx.partialSign(userAuthority);    // ★second, elsewhere★

★The order of partialSign calls does not matter, but the message must be identical for every signer.★ Signatures cover the serialized message — the instructions, the accounts, the blockhash. Change any of it after the first signature and that signature is silently invalid.

This is the single most common multi-signer bug. A backend that adds a setComputeUnitLimit instruction after the user signed has invalidated the user's signature, and the failure appears as a signature verification error that points at the wrong key.

Serializing a Partially Signed Transaction

The default serialization refuses to produce bytes for an incomplete transaction, which is correct and also the thing everyone hits first:

// ★Throws: signature verification failed★
// const raw = tx.serialize();

// ★Correct for a partially signed transaction.★
const raw = tx.serialize({
  requireAllSignatures: false,
  verifySignatures: false,
});

const encoded = raw.toString("base64");   // ship this to the next signer

On the receiving side:

const tx = Transaction.from(Buffer.from(encoded, "base64"));
tx.partialSign(nextSigner);

For versioned transactions the shape differs — signatures live in an array positioned by signer index:

const tx = VersionedTransaction.deserialize(bytes);
tx.sign([nextSigner]);            // ★fills its own slot, keeps the others★

★VersionedTransaction.sign is additive rather than replacing★, so calling it with one keypair does not clear signatures already collected.

If your signing flow is correct and the transaction still misses, a free BoltTx key is one line to test the submission path.

Checking What Is Still Missing

Before sending, verify every required slot is filled:

const missing = tx.signatures
  .filter((s) => s.signature === null)
  .map((s) => s.publicKey.toBase58());

if (missing.length) {
  throw new Error(`unsigned: ${missing.join(", ")}`);
}

For a versioned transaction, compare against the header count:

const required = tx.message.header.numRequiredSignatures;
const present = tx.signatures.filter((s) => s.some((b) => b !== 0)).length;

★An all-zero signature is an empty slot, not a signature.★ Sending a transaction with an empty slot produces a signature verification failure, which reads as a key problem rather than as a missing approval.

The Blockhash Is the Deadline for Everyone

A multi-signer flow inherits the same expiry as any other transaction, and it is the constraint that makes these flows hard.

★Roughly 150 blocks — around a minute — from when the blockhash was fetched to when the last signature arrives and the transaction is sent.★ A human approver in another timezone will not make that window.

Two designs, and the second is usually better:

Sign last. Collect intent first, and build the actual signed transaction only when the final approval arrives. The window starts at the end instead of the beginning.

Use a durable nonce. When signatures genuinely cannot be regenerated on demand — hardware wallets in a vault, offline signers — a nonce removes the deadline entirely.

Fetch the blockhash as late as possible. If all signers are online, get it right before the first signature rather than at the start of the flow.

Where the Failures Point

Symptom Actual cause
Missing required signature ★A slot was never filled★
Signature verification failed ★Message changed after signing★
Blockhash not found Collection outlasted the window
Serialize throws Needs requireAllSignatures: false
Wrong account paid Fee payer is not index 0

★The second row is the one that wastes the most time,★ because the error names a key that is perfectly valid. The key did sign — it signed a different message than the one you submitted.

What Landing Looks Like

Real transactions through our delivery nodes: median confirmation 336ms — under one slot.

★Collecting signatures takes as long as it takes; landing is the part that stays measurable.★ For a flow that spent a minute gathering approvals, losing the transaction to a slow submission path is the avoidable half.

Where BoltTx Fits

We handle submission. Signing is entirely yours, however many keys it involves.

Submissions route through our own delivery nodes in four regions with stake-weighted routing and no public mempool exposure, so a transaction is not observable in transit before it lands — which matters when it is a treasury movement several people just approved.

You sign locally. We never hold funds, never sign, and never modify transaction contents — which is what makes us safe in a multi-signer flow, since any modification would invalidate every signature already collected. The tip travels inside the transaction, paid on chain from the fee payer's wallet, and reverts with the transaction if it fails, because that is how Solana handles atomic transactions. You pay only on transactions that reach the chain.

Get a free API key. No monthly fee:

const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");

FAQ

What is partialSign in Solana? A method that adds one signature to a transaction without requiring the others. It lets you collect signatures from keys held in different places before submitting.

Can the fee payer be different from the signer? Yes, and separating them is deliberate. The fee payer is accountKeys[0] and covers the fee, while other signers authorise actions without paying. This is what makes gasless flows possible.

How do I let users trade without SOL? Make your relayer the fee payer and have the user's key sign as authority. The user authorises the action while your key covers the fee, so they never need a funded wallet.

Why does serialize throw on a partially signed transaction? Because it verifies signatures by default. Pass requireAllSignatures: false and verifySignatures: false to produce bytes for a transaction that is still collecting approvals.

Does the order of partialSign calls matter? No, but the message must be identical for every signer. Adding an instruction after someone signed invalidates their signature, which is the most common multi-signer bug.

Why does signature verification fail when the key is correct? The key signed a different message than the one you submitted. Something changed after signing — commonly a compute budget instruction added by the backend — so the signature no longer matches.

How do I send a partially signed transaction to another party? Serialize with requireAllSignatures: false, encode as base64, and reconstruct with Transaction.from on the other side. For versioned transactions use VersionedTransaction.deserialize.

Does signing a versioned transaction clear other signatures? No. VersionedTransaction.sign fills the signer's own slot and leaves existing signatures intact, so it can be called by each party in turn.

How do I check which signatures are still missing? Inspect tx.signatures for entries with a null or all-zero signature. Those are empty slots, and submitting with one produces a verification failure rather than a clear message.

How long do I have to collect signatures? Roughly 150 blocks from when the blockhash was fetched — about a minute. Human approvers in different timezones will not fit in that, which is when a durable nonce becomes necessary.

Should a multisig use a durable nonce? Only if collecting all signatures reliably takes longer than the blockhash window. If you can produce the signed transaction at the moment of final approval, a normal blockhash is simpler.

Can I change a transaction after someone signs it? No. Signatures cover the serialized message, so any change silently invalidates every signature already collected. Build the final instruction set before requesting the first signature.

Is the fee payer always index 0? Yes. It is always a signer, always writable, and always first. If the wrong account is paying, that is the position to check.

How many signers can one transaction have? Enough for practical use, bounded by the 1232-byte size limit at 64 bytes per signature. Signature bytes are usually a smaller constraint than the 32 bytes per account key.

Does a relayer paying fees see the user's private key? No. The user signs locally with their own key and sends you a partially signed transaction. Your relayer adds its own signature as fee payer and never handles the user's key.

What happens if the fee payer has insufficient SOL? The transaction fails before execution. Check the fee payer balance with getBalance before building, since this failure looks identical to other pre-execution rejections.

← Back to all posts