PageSourceSearch

https://rwa-docs.tbook.com/assets/js/62f45cdb.ed100406.js

js tbook.com collected 2026-10-02 07:25:12 UTC 11,560 bytes, 1 lines download raw bytes

1"use strict";(self.webpackChunktbook_docs=self.webpackChunktbook_docs||[]).push([["196"],{273(e,n,t){t.r(n),t.d(n,{metadata:()=>s,default:()=>h,frontMatter:()=>a,contentTitle:()=>d,toc:()=>c,assets:()=>o});var s=JSON.parse('{"id":"getting-started/custody-signing","title":"Custody & WaaS Signing","description":"TBook never holds your keys. Build endpoints return unsigned transaction","source":"@site/docs/getting-started/custody-signing.mdx","sourceDirName":"getting-started","slug":"/getting-started/custody-signing","permalink":"/getting-started/custody-signing","draft":false,"unlisted":false,"tags":[],"version":"current","sidebarPosition":10,"frontMatter":{"title":"Custody & WaaS Signing","sidebar_position":10},"sidebar":"docs","previous":{"title":"Server SDK","permalink":"/getting-started/server-sdk"},"next":{"title":"Client SDKs","permalink":"/getting-started/client-sdk"}}'),i=t(4848),r=t(8453);let a={title:"Custody & WaaS Signing",sidebar_position:10},d="Custody & WaaS Signing",o={},c=[{value:"The universal signing recipe",id:"the-universal-signing-recipe",level:2},{value:"Provider options",id:"provider-options",level:2},{value:"Turnkey",id:"turnkey",level:3},{value:"Privy",id:"privy",level:3},{value:"Crossmint",id:"crossmint",level:3},{value:"Verifying your integration",id:"verifying-your-integration",level:2}];function l(e){let n={a:"a",code:"code",h1:"h1",h2:"h2",h3:"h3",header:"header",li:"li",p:"p",pre:"pre",strong:"strong",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,r.R)(),...e.components};return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)(n.header,{children:(0,i.jsx)(n.h1,{id:"custody--waas-signing",children:"Custody & WaaS Signing"})}),"\n",(0,i.jsxs)(n.p,{children:["TBook never holds your keys. Build endpoints return ",(0,i.jsx)(n.strong,{children:"unsigned"})," transaction\nbytes (",(0,i.jsx)(n.code,{children:"sui:tx-bytes-base64"}),"); your backend signs them with whatever custody\nyou already use, submits to Sui, and confirms the digest back to the gateway.\nSwapping custody providers changes one object in your code \u2014 nothing else in\nthe money path moves."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-typescript",children:"// The whole seam. Implement these two methods over your custody backend.\ninterface SuiSigner {\n  getAddress(): Promise<string>;\n  /** Sign unsigned tx bytes (base64). Return the serialized Sui signature.\n   *  Must NOT submit \u2014 submission stays in your backend so the digest can\n   *  be confirmed to the gateway regardless of the signing backend. */\n  signTransaction(txBytesBase64: string): Promise<string>;\n}\n"})}),"\n",(0,i.jsxs)(n.p,{children:[":::tip Always decode-and-assert before signing\nWhatever the custody backend, independently decode the transaction bytes and\nassert the target contract, amounts and recipient before signing \u2014\n",(0,i.jsx)(n.code,{children:"@tbookdev/vault-node"})," ships this as ",(0,i.jsx)(n.code,{children:"assertSuiTransaction"}),". Never blind-sign\nbytes returned by any API, including ours.\n:::"]}),"\n",(0,i.jsx)(n.h2,{id:"the-universal-signing-recipe",children:"The universal signing recipe"}),"\n",(0,i.jsx)(n.p,{children:'Every provider that holds an Ed25519 key exposes the same primitive \u2014 "sign\nthese bytes with the key". None of them know about Sui\'s intent framing;\nthat part is your code, and it is identical for all of them:'}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-typescript",children:'import { messageWithIntent, toSerializedSignature } from "@mysten/sui/cryptography";\nimport { Ed25519PublicKey } from "@mysten/sui/keypairs/ed25519";\nimport { blake2b } from "@noble/hashes/blake2b";\n\n// 1. Intent-wrap the unsigned bytes from the build endpoint\nconst intent = messageWithIntent("TransactionData", txBytes);\n// 2. The 32-byte digest a provider raw-signs\nconst digest = blake2b(intent, { dkLen: 32 });\n// 3. rawSignature = your provider signs `digest` (64-byte Ed25519, r\u2016s)\n// 4. Assemble the submit-ready Sui signature\nconst signature = toSerializedSignature({\n  signatureScheme: "ED25519",\n  signature: rawSignature,\n  publicKey: new Ed25519PublicKey(accountPublicKey32Bytes),\n});\n// 5. client.executeTransaction({ transaction: txBytesBase64, signatures: [signature] })\n//    (@mysten/sui 2.x gRPC \u2014 executeTransactionBlock was JSON-RPC only)\n'})}),"\n",(0,i.jsx)(n.p,{children:"Only step 3 differs per provider."}),"\n",(0,i.jsx)(n.h2,{id:"provider-options",children:"Provider options"}),"\n",(0,i.jsxs)(n.p,{children:["Recommended default: the ",(0,i.jsx)(n.strong,{children:"Sui-official keypair"})," path \u2014 it is the simplest\nthing that works, and production key management is a KMS/Secrets-Manager\nconcern, not a code concern. Use a WaaS provider when you need managed\nper-user wallets at scale."]}),"\n",(0,i.jsxs)(n.table,{children:[(0,i.jsx)(n.thead,{children:(0,i.jsxs)(n.tr,{children:[(0,i.jsx)(n.th,{children:"Option"}),(0,i.jsx)(n.th,{children:"Step 3 mechanism"}),(0,i.jsx)(n.th,{children:"Status"})]})}),(0,i.jsxs)(n.tbody,{children:[(0,i.jsxs)(n.tr,{children:[(0,i.jsxs)(n.td,{children:[(0,i.jsx)(n.strong,{children:"Sui-official keypair"})," (default)"]}),(0,i.jsxs)(n.td,{children:[(0,i.jsx)(n.code,{children:"Ed25519Keypair.signTransaction"})," from ",(0,i.jsx)(n.code,{children:"@mysten/sui"})," \u2014 no third party"]}
1),(0,i.jsx)(n.td,{children:"\u2705 Verified end-to-end"})]}),(0,i.jsxs)(n.tr,{children:[(0,i.jsx)(n.td,{children:(0,i.jsx)(n.strong,{children:"Turnkey"})}),(0,i.jsxs)(n.td,{children:[(0,i.jsx)(n.code,{children:"signRawPayload"})," over the digest"]}),(0,i.jsx)(n.td,{children:"\u2705 Verified end-to-end on Sui testnet"})]}),(0,i.jsxs)(n.tr,{children:[(0,i.jsx)(n.td,{children:(0,i.jsx)(n.strong,{children:"Privy"})}),(0,i.jsxs)(n.td,{children:[(0,i.jsx)(n.code,{children:"raw_sign"})," over the intent bytes"]}),(0,i.jsx)(n.td,{children:"\u2705 Verified end-to-end on Sui testnet"})]}),(0,i.jsxs)(n.tr,{children:[(0,i.jsx)(n.td,{children:(0,i.jsx)(n.strong,{children:"Crossmint"})}),(0,i.jsx)(n.td,{children:"\u2014"}),(0,i.jsx)(n.td,{children:"\u274C No server-side Sui signing as of July 2026"})]}),(0,i.jsxs)(n.tr,{children:[(0,i.jsx)(n.td,{children:"Anything else (Fireblocks, HSM/KMS, multisig)"}),(0,i.jsx)(n.td,{children:"any raw Ed25519 sign over the digest"}),(0,i.jsx)(n.td,{children:"Same recipe applies"})]})]})]}
1),"\n",(0,i.jsx)(n.p,{children:'"Verified end-to-end" means a real provider-held key signed a real Sui\ntestnet transaction through this exact recipe and it executed successfully\non-chain.'}),"\n",(0,i.jsx)(n.h3,{id:"turnkey",children:"Turnkey"}),"\n",(0,i.jsxs)(n.p,{children:["One ",(0,i.jsx)(n.strong,{children:"sub-organization per end user"}),", each holding an Ed25519 wallet\naccount with address format Sui (path ",(0,i.jsx)(n.code,{children:"m/44'/784'/0'/0'/0'"}),"). The private\nkey never leaves Turnkey's enclave, and Turnkey policies can restrict what\nyour backend may sign. Turnkey signs the ",(0,i.jsx)(n.strong,{children:"pre-hashed digest"}),":"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-typescript",children:'const resp = await turnkeyClient.signRawPayload({\n  signWith: suiAddress, // the wallet account\'s Sui address\n  payload: Buffer.from(digest).toString("hex"), // blake2b-256(intent), step 2\n  encoding: "PAYLOAD_ENCODING_HEXADECIMAL",\n  hashFunction: "HASH_FUNCTION_NOT_APPLICABLE", // already hashed \u2014 sign as-is\n});\nconst rawSignature = Buffer.concat([\n  Buffer.from(resp.r, "hex"),\n  Buffer.from(resp.s, "hex"),\n]);\n'})}),"\n",(0,i.jsxs)(n.p,{children:["Notes from our verification: the dashboard shows a wallet account's address\nbut not its public key \u2014 fetch it with the ",(0,i.jsx)(n.code,{children:"get_wallet_account"})," API query.\nDashboard access (signup and minting the first API key) requires a passkey;\neverything after is pure API."]}),"\n",(0,i.jsx)(n.h3,{id:"privy",children:"Privy"}),"\n",(0,i.jsxs)(n.p,{children:["One ",(0,i.jsx)(n.strong,{children:"server wallet per end user"})," (",(0,i.jsx)(n.code,{children:'chain_type: "sui"'}),"); your backend\nauthenticates with the app's ID + secret and calls ",(0,i.jsx)(n.code,{children:"raw_sign"}),". Privy is\nsign-only (no broadcasting), which is exactly what this seam wants. Two\ndetails matter:"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-typescript",children:'const res = await fetch(`https://api.privy.io/v1/wallets/${walletId}/raw_sign`, {\n  method: "POST",\n  headers: {\n    Authorization: `Basic ${Buffer.from(`${appId}:${appSecret}`).toString("base64")}`,\n    "privy-app-id": appId,\n    "Content-Type": "application/json",\n  },\n  body: JSON.stringify({\n    params: {\n      // Pass the INTENT bytes (step 1), not the digest: Privy applies\n      // blake2b256 itself. Passing the digest here would double-hash.\n      bytes: Buffer.from(intent).toString("hex"),\n      encoding: "hex",\n      hash_function: "blake2b256",\n    },\n  }),\n});\nconst { data } = await res.json();\nconst rawSignature = Buffer.from(data.signature.replace(/^0x/, ""), "hex");\n'})}),"\n",(0,i.jsxs)(n.ul,{children:["\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.strong,{children:"Double-hash trap:"})," Privy hashes the bytes you send. Send the intent\nmessage with ",(0,i.jsx)(n.code,{children:'hash_function: "blake2b256"'})," \u2014 do not pre-hash."]}),"\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.strong,{children:"Public key encoding:"})," Privy returns the wallet's ",(0,i.jsx)(n.code,{children:"public_key"})," in the\nSui flag-prefixed form (",(0,i.jsx)(n.code,{children:"0x00"})," + 32 bytes, hex). Strip the leading ",(0,i.jsx)(n.code,{children:"0x00"}),"\nbyte before constructing ",(0,i.jsx)(n.code,{children:"Ed25519PublicKey"}),"."]}),"\n"]}),"\n",(0,i.jsx)(n.h3,{id:"crossmint",children:"Crossmint"}),"\n",(0,i.jsx)(n.p,{children:"Not available for Sui today: Crossmint's server-side signing covers\nEVM/Solana/Stellar and its staging environment has no Sui testnet. If you\nare a Crossmint customer, the same recipe will apply the day they expose raw\nEd25519 signing for Sui wallets."}),"\n",(0,i.jsx)(n.h2,{id:"verifying-your-integration",children:"Verifying your integration"}),"\n",(0,i.jsxs)(n.p,{children:["Before touching real flows, prove your signer with a self-transfer: build a\nminimal transaction, run it through your ",(0,i.jsx)(n.code,{children:"SuiSigner"}),", submit, and check the\ndigest executes with status ",(0,i.jsx)(n.code,{children:"success"}),". Then run the same signer against the\n",(0,i.jsx)(n.a,{href:"/getting-started/quickstart",children:"sandbox quickstart"})," deposit flow. If the chain\nrejects your signature, the failure is almost always in step 3's input (\ndigest vs intent bytes) or a public key that does not match the signing\naddress \u2014 assert ",(0,i.jsx)(n.code,{children:"new Ed25519PublicKey(pubkey).toSuiAddress() === address"}),"\nat startup."]})]})}function h(e={}){let{wrapper:n}={...(0,r.R)(),...e.components};return n?(0,i.jsx)(n,{...e,children:(0,i.jsx)(l,{...e})}):l(e)}},8453(e,n,t){t.d(n,{R:()=>a,x:()=>d});var s=t(6540);let i={},r=s.createContext(i);function a(e){let n=s.useContext(r);return s.useMemo(function(){return"function"==typeof e?e(n):{...n,...e}},[n,e])}function d(e){let n;return n=e.disableParentContext?"function"==typeof e.components?e.components(i):e.components||i:a(e.components),s.createElement(r.Provider,{value:n},e.children)}}}]);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.