Skip to content

External Signers

View as Markdown

Use this page when your app does not sign Thru transactions directly in the first-party wallet UI.

Typical external signer integrations include:

  • custody providers
  • HSM or KMS-backed signing services
  • backend transaction services
  • custom wallet adapters
  • embedded wallet providers

Treat the Thru transaction lifecycle as four separate steps:

  1. Build the transaction payload.
  2. Hand the signing payload to the external signer.
  3. Receive the signed wire transaction back.
  4. Submit and track the transaction with Thru RPC.

For external signers, the recommended SDK flow is:

  1. Build the transaction with thru.transactions.build(...).
  2. Produce a signing payload with tx.toWireForSigning().
  3. Pass that serialized payload to your signer.
  4. Receive the signed wire transaction from the signer.
  5. Submit it with thru.transactions.send(...) or thru.transactions.sendAndTrack(...).

This mirrors the wallet-facing signTransaction(serializedTransaction) contract used by Thru wallet integrations.

Once a signing payload has been produced, the signer should treat the transaction contents as fixed.

In particular, do not mutate:

  • fee payer public key
  • program public key
  • account ordering
  • instruction data
  • nonce
  • chain ID
  • validity window fields such as start_slot and expiry_after
  • requested resource limits

If any of those values need to change, rebuild the transaction and generate a new signing payload.

The current web wallet contract is:

  • input: base64-encoded serialized transaction payload
  • output: signed base64-encoded wire transaction payload

Thru transaction headers include fields such as:

  • fee_payer_signature
  • nonce
  • start_slot
  • expiry_after
  • fee_payer_pubkey
  • program_pubkey
  • chain_id

In practice, this means the external signer should be given the final signing payload after the app or backend has already selected the fee payer, program, accounts, instruction bytes, and validity settings.

import { createThruClient } from "@thru/sdk/client";
import { decodeAddress } from "@thru/sdk/helpers";
type ExternalSigner = {
signTransaction: (serializedTransaction: string) => Promise<string>;
};
const thru = createThruClient({
baseUrl: "https://grpc-web.alphanet.thruput.org",
});
async function sendWithExternalSigner(
signer: ExternalSigner,
feePayerAddress: string,
programAddress: string,
readWriteAccounts: string[],
readOnlyAccounts: string[],
instructionData: Uint8Array
) {
const tx = await thru.transactions.build({
feePayer: { publicKey: decodeAddress(feePayerAddress) },
program: programAddress,
accounts: {
readWrite: readWriteAccounts,
readOnly: readOnlyAccounts,
},
instructionData,
});
const serialized = tx.toWireForSigning();
const signedWire = await signer.signTransaction(serialized);
return await thru.transactions.sendAndTrack(signedWire);
}

Use transactions.buildAndSign(...) only when your signing implementation already lives inside your Thru wallet or SDK layer.

If the signer is outside that layer, prefer:

  1. transactions.build(...)
  2. tx.toWireForSigning()
  3. external signTransaction(...)
  4. transactions.send(...)

That keeps the signing boundary explicit and makes it easier to integrate a custody or backend signing service.

Passkey-managed flows add an inner authorization step, but the outer signing model stays the same.

The high-level sequence is:

  1. Fetch or derive the wallet nonce.
  2. Build the trailing instruction payload you want the wallet to authorize.
  3. Create the passkey challenge from the nonce, ordered accounts, and trailing instruction bytes.
  4. Produce the validate instruction from the WebAuthn result.
  5. Concatenate validate and the trailing instruction payload.
  6. Build the outer Thru transaction.
  7. Sign the outer transaction with the external signer.
  8. Submit the signed wire transaction.

Watch for these issues when integrating an external signer:

  • stale fee payer nonce
  • expired transaction validity window
  • wrong chain ID
  • mismatched fee payer key
  • account reordering after the signing payload was generated
  • modifying instruction data after the signer has already approved the payload
  • confusing inner program authorization with the outer Thru transaction signature

For most teams, the cleanest split looks like this:

  • app or backend: resolves accounts, instruction bytes, fee payer, and validity rules
  • external signer: signs the serialized transaction payload
  • app or backend: submits the signed wire transaction and tracks status

This keeps policy, custody, and audit responsibilities inside the signer while leaving chain-specific transaction assembly in the Thru integration layer.