Custody & WaaS Signing
TBook never holds your keys. Build endpoints return unsigned transaction
bytes (sui:tx-bytes-base64); your backend signs them with whatever custody
you already use, submits to Sui, and confirms the digest back to the gateway.
Swapping custody providers changes one object in your code — nothing else in
the money path moves.
// The whole seam. Implement these two methods over your custody backend.
interface SuiSigner {
getAddress(): Promise<string>;
/** Sign unsigned tx bytes (base64). Return the serialized Sui signature.
* Must NOT submit — submission stays in your backend so the digest can
* be confirmed to the gateway regardless of the signing backend. */
signTransaction(txBytesBase64: string): Promise<string>;
}
:::tip Always decode-and-assert before signing
Whatever the custody backend, independently decode the transaction bytes and
assert the target contract, amounts and recipient before signing —
@tbookdev/vault-node ships this as assertSuiTransaction. Never blind-sign
bytes returned by any API, including ours.
:::
The universal signing recipe
Every provider that holds an Ed25519 key exposes the same primitive — "sign these bytes with the key". None of them know about Sui's intent framing; that part is your code, and it is identical for all of them:
import { messageWithIntent, toSerializedSignature } from "@mysten/sui/cryptography";
import { Ed25519PublicKey } from "@mysten/sui/keypairs/ed25519";
import { blake2b } from "@noble/hashes/blake2b";
// 1. Intent-wrap the unsigned bytes from the build endpoint
const intent = messageWithIntent("TransactionData", txBytes);
// 2. The 32-byte digest a provider raw-signs
const digest = blake2b(intent, { dkLen: 32 });
// 3. rawSignature = your provider signs `digest` (64-byte Ed25519, r‖s)
// 4. Assemble the submit-ready Sui signature
const signature = toSerializedSignature({
signatureScheme: "ED25519",
signature: rawSignature,
publicKey: new Ed25519PublicKey(accountPublicKey32Bytes),
});
// 5. client.executeTransactionBlock({ transactionBlock: txBytesBase64, signature })
Only step 3 differs per provider.
Provider options
Recommended default: the Sui-official keypair path — it is the simplest thing that works, and production key management is a KMS/Secrets-Manager concern, not a code concern. Use a WaaS provider when you need managed per-user wallets at scale.
| Option | Step 3 mechanism | Status |
|---|---|---|
| Sui-official keypair (default) | Ed25519Keypair.signTransaction from @mysten/sui — no third party | ✅ Verified end-to-end |
| Turnkey | signRawPayload over the digest | ✅ Verified end-to-end on Sui testnet |
| Privy | raw_sign over the intent bytes | ✅ Verified end-to-end on Sui testnet |
| Crossmint | — | ❌ No server-side Sui signing as of July 2026 |
| Anything else (Fireblocks, HSM/KMS, multisig) | any raw Ed25519 sign over the digest | Same recipe applies |
"Verified end-to-end" means a real provider-held key signed a real Sui testnet transaction through this exact recipe and it executed successfully on-chain.
Turnkey
One sub-organization per end user, each holding an Ed25519 wallet
account with address format Sui (path m/44'/784'/0'/0'/0'). The private
key never leaves Turnkey's enclave, and Turnkey policies can restrict what
your backend may sign. Turnkey signs the pre-hashed digest:
const resp = await turnkeyClient.signRawPayload({
signWith: suiAddress, // the wallet account's Sui address
payload: Buffer.from(digest).toString("hex"), // blake2b-256(intent), step 2
encoding: "PAYLOAD_ENCODING_HEXADECIMAL",
hashFunction: "HASH_FUNCTION_NOT_APPLICABLE", // already hashed — sign as-is
});
const rawSignature = Buffer.concat([
Buffer.from(resp.r, "hex"),
Buffer.from(resp.s, "hex"),
]);
Notes from our verification: the dashboard shows a wallet account's address
but not its public key — fetch it with the get_wallet_account API query.
Dashboard access (signup and minting the first API key) requires a passkey;
everything after is pure API.
Privy
One server wallet per end user (chain_type: "sui"); your backend
authenticates with the app's ID + secret and calls raw_sign. Privy is
sign-only (no broadcasting), which is exactly what this seam wants. Two
details matter:
const res = await fetch(`https://api.privy.io/v1/wallets/${walletId}/raw_sign`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${appId}:${appSecret}`).toString("base64")}`,
"privy-app-id": appId,
"Content-Type": "application/json",
},
body: JSON.stringify({
params: {
// Pass the INTENT bytes (step 1), not the digest: Privy applies
// blake2b256 itself. Passing the digest here would double-hash.
bytes: Buffer.from(intent).toString("hex"),
encoding: "hex",
hash_function: "blake2b256",
},
}),
});
const { data } = await res.json();
const rawSignature = Buffer.from(data.signature.replace(/^0x/, ""), "hex");
- Double-hash trap: Privy hashes the bytes you send. Send the intent
message with
hash_function: "blake2b256"— do not pre-hash. - Public key encoding: Privy returns the wallet's
public_keyin the Sui flag-prefixed form (0x00+ 32 bytes, hex). Strip the leading0x00byte before constructingEd25519PublicKey.
Crossmint
Not available for Sui today: Crossmint's server-side signing covers EVM/Solana/Stellar and its staging environment has no Sui testnet. If you are a Crossmint customer, the same recipe will apply the day they expose raw Ed25519 signing for Sui wallets.
Verifying your integration
Before touching real flows, prove your signer with a self-transfer: build a
minimal transaction, run it through your SuiSigner, submit, and check the
digest executes with status success. Then run the same signer against the
sandbox quickstart deposit flow. If the chain
rejects your signature, the failure is almost always in step 3's input (
digest vs intent bytes) or a public key that does not match the signing
address — assert new Ed25519PublicKey(pubkey).toSuiAddress() === address
at startup.