Skip to content

External Signers

View as Markdown

Use this page when your app does not sign Thru transactions directly in a first-party wallet UI — for example a custody provider signing with a Blockdaemon Builder Vault TSM, Fireblocks, or a cloud KMS.

Typical external signer integrations include:

  • custody providers (MPC / threshold signing)
  • HSM or KMS-backed signing services
  • backend transaction services
  • custom wallet adapters
  • embedded wallet providers

Thru signatures are standard RFC-8032 Ed25519. There is nothing Thru-specific for your HSM or MPC backend to reproduce — no custom hash, no proprietary curve, no pre-image transform beyond a fixed domain tag. Your signer receives a short byte string and returns a raw 64-byte R‖S produced by plain Ed25519_sign. Any conformant PureEd25519 signer works.

The SDK gives you a small set of primitives that construct exactly the bytes to sign, verify the returned signature before it goes on chain, and assemble the wire transaction. You never reimplement Thru’s signing rules.

Treat the lifecycle as four separate steps:

  1. Build the transaction payload (no private key required).
  2. Derive the exact message and hand it to the external signer.
  3. Verify the returned signature and attach it.
  4. Submit and track with Thru RPC.

The SDK derives a fixed 48-byte message from the serialized transaction body:

M = "tn_txn_sign_v1__" ‖ SHA-256(body) // 16-byte ASCII domain tag ‖ 32-byte hash

Your signer signs M with plain Ed25519 (Ed25519_sign(secret_key, M)) and returns the raw 64-byte R‖S. Because M is only 48 bytes it fits comfortably within every provider’s RAW-sign size limit (including AWS KMS’s 4 KB cap).

Provider mapping — all of these produce a signature Thru accepts:

ProviderMode
Blockdaemon Builder Vault TSM (self-run)EdDSA / Ed25519 threshold sign over M
FireblocksMPC_EDDSA_ED25519, RAW message
AWS KMSECC_NIST_EDWARDS25519 key, ED25519_SHA_512, MessageType: RAW
GCP Cloud KMSEC_SIGN_ED25519 (PureEdDSA)

The node verifies with a strict/canonical policy: it rejects non-canonical point encodings, small-order points, and non-canonical S. A conformant signer never produces those, so this is transparent — but the SDK also re-verifies locally (see below) so a misbehaving backend fails on your machine, not on chain.

MPC and threshold signers (Builder Vault, Fireblocks) randomize the Ed25519 nonce, so signing the same M twice yields two different 64-byte signatures. Both are valid RFC-8032 signatures and both verify, because Thru’s verification is the standard equation — it does not require the deterministic RFC-8032 nonce. No special handling is needed on your side.

The recommended flow uses three SDK primitives:

  • thru.transactions.build(...) — assemble the transaction from a public key only.
  • buildTransactionSigningMessage(body) — produce an opaque signing message. The fee-payer key is read from the body itself, so the message cannot be aimed at the wrong key.
  • attachTransactionSignature(message, signature) — verify the signature against the body’s fee-payer under the strict/canonical policy, then return the complete body ‖ signature wire. A wrong-key or malformed signature throws here, before submission.
import { createThruClient } from "@thru/sdk/client";
import { buildTransactionSigningMessage, attachTransactionSignature } from "@thru/sdk";
import { decodeAddress } from "@thru/sdk/helpers";
// Your custody / HSM / MPC backend. It signs `message` with the custody key for
// `address` using standard PureEd25519 and returns a raw 64-byte R‖S. Nothing
// Thru-specific lives here.
type ExternalEd25519Signer = {
sign(address: string, message: Uint8Array): Promise<Uint8Array>;
};
const thru = createThruClient({ baseUrl: "https://rpc.alphanet.thru.org" });
async function sendWithExternalSigner(
signer: ExternalEd25519Signer,
feePayerAddress: string,
programAddress: string,
readWriteAccounts: string[],
instructionData: Uint8Array,
) {
// 1. Build the transaction. Only the fee-payer *public* key is needed; nonce,
// start slot, and chain ID are fetched from the chain when omitted.
const tx = await thru.transactions.build({
feePayer: { publicKey: decodeAddress(feePayerAddress) },
program: programAddress,
accounts: { readWrite: readWriteAccounts },
instructionData,
});
// 2. Serialize the body and derive the opaque signing message.
const body = tx.toWireForSigning();
const msg = buildTransactionSigningMessage(body);
// 3. Hand the 48-byte M to your signer:
// msg.m == "tn_txn_sign_v1__" ‖ SHA-256(body)
const signature = await signer.sign(feePayerAddress, msg.m);
// 4. Verify-before-attach. Re-verifies against the body's fee-payer and returns
// body ‖ signature. Throws on a wrong-key or malformed signature.
const signedWire = await attachTransactionSignature(msg, signature);
// 5. Submit. Resolves to the transaction signature string.
return await thru.transactions.send(signedWire);
}

msg.m and msg.expectedPublicKey are exposed if your signer or audit layer wants to inspect exactly what will be signed and which key it must verify under.

thru.transactions.send(...) submits and resolves to the transaction signature. To observe execution and finality, iterate thru.transactions.sendAndTrack(...) — it is an async iterable of status updates, not a promise, so it must be consumed with for await (awaiting it alone submits nothing and yields no updates):

for await (const update of thru.transactions.sendAndTrack(signedWire)) {
if (update.executionResult) {
// vmError === 0 means the transaction executed successfully.
console.log("vmError", update.executionResult.vmError);
}
}

Before an externally-held key can pay fees or own state it must exist on chain as an externally-owned account (EOA). An EOA is created through the built-in EOA program (address = all zeros, eoa.EOA_PROGRAM_ADDRESS). Creation is a two-signature operation:

  1. The new EOA key authorizes its own creation by signing a canonical 82-byte message.
  2. The fee-payer key signs the outer transaction (as in the previous section).

For a custody partner, both keys typically live in the same TSM/HSM.

The authorization message is domain-separated and signed raw (PureEd25519, no pre-hash — it is short):

create_msg = "tn_eoa_create_v1" ‖ chain_id(u16 LE) ‖ fee_payer(32) ‖ eoa(32) // 82 bytes

Use the eoa namespace to build the message, the account-creation instruction, and to reference the program address:

import { createThruClient } from "@thru/sdk/client";
import { eoa, buildTransactionSigningMessage, attachTransactionSignature } from "@thru/sdk";
import { decodeAddress } from "@thru/sdk/helpers";
import { StateProofType } from "@thru/sdk/proto";
const thru = createThruClient({ baseUrl: "https://rpc.alphanet.thru.org" });
async function createExternalEoa(
signer: ExternalEd25519Signer, // same interface as above; signs raw bytes per key
feePayerAddress: string, // custody key that pays fees and signs the outer txn
newEoaAddress: string, // custody key that will own the new EOA
) {
const feePayer = decodeAddress(feePayerAddress);
const newEoa = decodeAddress(newEoaAddress);
const chainId = await thru.chain.getChainId();
// 1. Prove the account does not yet exist (a CREATING membership proof).
const proof = await thru.proofs.generate({
address: newEoa,
proofType: StateProofType.CREATING,
});
// 2. The NEW EOA key authorizes its own creation. buildEOACreateMessage returns
// the exact 82 bytes the runtime verifies; sign them RAW (no pre-hash).
const authMsg = eoa.buildEOACreateMessage(chainId, feePayer, newEoa);
const authSig = await signer.sign(newEoaAddress, authMsg);
// 3. Wrap the authorization signature and the membership proof into the
// CREATE_ACCOUNT instruction. Accounts are ordered
// [feePayer, program, ...readWrite], so the single new EOA is at index 2.
const NEW_EOA_ACCOUNT_INDEX = 2;
const instructionData = eoa.buildCreateEOAInstruction(
NEW_EOA_ACCOUNT_INDEX,
authSig,
proof.proof,
);
// 4. Build the outer EOA-program transaction (fee-payer public key only).
const tx = await thru.transactions.build({
feePayer: { publicKey: feePayer },
program: eoa.EOA_PROGRAM_ADDRESS,
accounts: { readWrite: [newEoa] },
instructionData,
});
// 5. Sign the OUTER transaction with the fee-payer key (48-byte M), verify,
// attach, and submit. Resolves to the transaction signature; use
// sendAndTrack (see above) if you need to stream execution/finality.
const body = tx.toWireForSigning();
const msg = buildTransactionSigningMessage(body);
const feeSig = await signer.sign(feePayerAddress, msg.m);
const signedWire = await attachTransactionSignature(msg, feeSig);
return await thru.transactions.send(signedWire);
}

Once a signing message has been derived, the transaction contents are fixed. attachTransactionSignature re-hashes the body it was built from, so any mutation is caught locally — but conceptually, do not change:

  • fee payer public key
  • program public key
  • account ordering
  • instruction data
  • nonce
  • chain ID
  • validity window fields (start_slot, expiry_after)
  • requested resource limits

If any of those must change, rebuild the transaction and derive a new signing message.

Use thru.transactions.buildAndSign(...) only when the signing key already lives inside your Thru SDK layer (it takes a raw private key). For any external signer — custody, HSM, KMS, MPC — use the buildbuildTransactionSigningMessage → external sign → attachTransactionSignaturesend path above. It keeps the signing boundary explicit and the key outside the SDK process.

  • stale fee payer nonce
  • expired transaction validity window
  • wrong chain ID
  • fee-payer key mismatch (caught locally by attachTransactionSignature)
  • account reordering after the signing message was derived
  • modifying instruction data after the signer approved the payload
  • confusing an inner program authorization with the outer Thru transaction signature
  • for EOA creation: signing the create message under the wrong chain_id, fee_payer, or eoa — the runtime verifies the exact 82-byte pre-image

For most teams:

  • app or backend — resolves accounts, instruction bytes, fee payer, and validity rules; builds the transaction and derives the signing message.
  • external signer — signs the message bytes with the custody key.
  • app or backend — verifies, attaches, submits the signed wire transaction, and tracks status.

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