Advanced and compatibility reference
Read this only for a task explicitly routed here by the main skill. This preserves existing lower-level capabilities; it is not the default new-application path. Check the installed declarations and the deployment’s operation support before using an older route. Never fall back from refused request-bound authorization to JWT/admin authorization. Retry advice here applies only when the operation is known safe to repeat; it never authorizes blindly repeating transaction submissions, native approval POSTs or signing requests with an uncertain outcome.
Sign and decrypt over the threshold
Section titled “Sign and decrypt over the threshold”Signing
Section titled “Signing”Through a session: s.sign(message, opts?) returns a FROST-Ed25519 signature
with the node coordinating the ceremony (a frost-mode slot); s.signDigest(digest)
is threshold ECDSA over secp256k1 for a slot backing an EVM account, returning the
same {groupPublicKey, r, s, yParity} as signEoaDigest after refreshing the JWT.
It always asks the session’s first node, so call signEoaDigest directly when you
have no session or need to choose the keeper. Both need
only the JWT; no key is assembled. A threshold-ECDSA slot is a distinct key type:
create it with createSlot({..., mode: 'tecdsa'}) (KeyRegistry.Mode also has
bls-bn254 and tecdsa-p256).
FIRST: does this deployment still accept the JWT route at all? signEoaDigest
and s.signDigest both POST to the keeper’s legacy bearer-JWT route
/v1/sign/eoa-digest. A keeper running the production posture
(api.production_posture = true) refuses it outright, whatever you send:
HTTP 403 {"error":"legacy JWT eoa-digest cannot satisfy committee-only or holder-bound operation authorization"}This is a deployment-wide refusal, not a fact about your slot: the same 403 comes back on a slot with no verifier policy and no dual-control policy, with a rule-satisfying JWT and with an admin-scoped one. Read literally it looks like tECDSA is unavailable. It is not — see “Threshold ECDSA under the production posture” below, which is the path that works there. Everything in the rest of this section applies to a deployment that has not enabled the posture.
Two further deployment-side conditions, and they are separate: the keepers must
key that mode (otherwise the slot never gets a group key), and they must be
able to complete the signing ceremony — /v1/sign/eoa-digest answers HTTP 504
with a ceremony-timeout error when they cannot reach each other well enough, even
though creation and DKG succeeded.
A third condition is per-request rather than per-deployment: a keeper that holds
the slot but is outside the chosen signing set answers HTTP 400 saying its local
node index is not in that set, so try the slot’s keepers in turn instead of
pinning one. signEoaDigest throws TasraHttpError for all of them; its
status tells them apart: 400, try the next keeper; 403, the deployment refuses
the JWT route entirely (go to the committee path); 404, the route is disabled on
this deployment; 504, the ceremony timed out — retryable is true, so retry it a
bounded number of times, on the next holder or after a backoff, rather than
failing on the first one. See tasra-handle-errors.
Wait for key generation. tECDSA includes an AuxInfo phase; completion time depends on the deployment and committee. Poll with a deadline rather than treating one empty-key response as an unsupported mode. Preserve the original slot intent if readiness does not complete.
Threshold ECDSA under the production posture
Section titled “Threshold ECDSA under the production posture”Use committeeSignEoaDigest from tasra-sdk/committee for
POST /v1/committee/sign/eoa-digest. Install with npm install tasra-sdk@latest.
Load a verified network manifest from tasra-releases and confirm that its services
support operation-bound ECDSA authorization and signing.
import {keccak256, serializeTransaction, hexToBytes, toHex} from 'viem'import {committeeSignEoaDigest} from 'tasra-sdk/committee'import {openVerifierAgentSession, awaitVerifierAgentResult, presentToRequestUri} from 'tasra-sdk/oid4vp'
const digest = keccak256(serializeTransaction(tx))const session = await openVerifierAgentSession({ verifierAgentUrl, chainId, keyRegistry, slotId, signer: creatorAccount, action: 'sign', message: hexToBytes(digest), description: 'Treasury transaction',})await presentToRequestUri(session.qrPayload, [{sdJwt: credential}], holder)const {token, verifierProofs} = await awaitVerifierAgentResult(session)const signature = await committeeSignEoaDigest({ nodeUrl: coordinatorUrl, committeeToken: token, verifierProofs, digest: hexToBytes(digest),})const signed = serializeTransaction(tx, { r: toHex(signature.r), s: toHex(signature.s), yParity: signature.yParity,})The session’s message is the 32-byte transaction digest. Its binding hashes those
bytes with SHA-256; the ECDSA ceremony signs the original digest. Do not substitute
a serialized transaction or a second Ethereum hash. The wire response uses
signature_v, not recovery_id; the SDK converts it to yParity and rejects
P-256 replies. Verify recovered sender before broadcasting.
Select a keeper according to the deployment’s coordinator convention. The
lowest-operator-id convention orders assigned keepers by their on-chain operator
IDs; registry draw order is different. The application API applies the configured
convention. Verify receipts and recovered sender before recording success.
Do not treat arbitrary HTTP 400s as permission to retry on another keeper.
Who may spend is the slot’s DCQL rule, and it can admit several people. Give the
rule a values list and each holder presents their own credential, independently —
no coordination, no shared key material:
{"credentials":[{"id":"signer","format":"dc+sd-jwt","meta":{"vct_values":["TreasurySigner"]}, "claims":[{"path":["iss"],"values":["did:web:hr.acme.example"]}, {"path":["sub"],"values":["did:demo:alice","did:demo:bob"]}]}]}A normal wallet filters out nonmatching credentials locally. To prove server-side
authorization, the negative test in examples/shared-account.ts deliberately sends
Mallory’s presentation with the lower-level wallet APIs and checks the verifier’s
explicit policy refusal; it does not count a timeout or wallet filter as that proof.
Dual control and tECDSA do not compose. KeyRegistry’s dual-control policy
(the CLI’s slot dual-control / dual-approve; the SDK write client exposes
neither) makes the committee EOA handler refuse the slot before it does anything
else — slot requires dual-control approval — and the approval route
/v1/dual-sign takes {key_slot_id, message_hex, signing_set}, the FROST shape,
not an EOA digest. A tECDSA slot with a dual-control policy pinned cannot sign at
all, and the policy is set-once, so recovering from it means a new slot. To let
several people share one EVM account today, use the multi-holder rule above.
Primitives, when you drive it yourself:
signCustody(opts)— one-shot, node-coordinated FROST.signWithShardDelivery(opts)— you coordinate; nodes return partial signatures;aggregateFrostSignatureandverifyFrostSignaturefinish locally.signUserRequest(secretKey, slotId, message, requestId)— a user-side authorization signature the node can verify (userSignaturePayloadis the bytes).signEoaDigest({nodeUrl, jwt, slotId, digest: Uint8Array /* 32-byte prehash */})→{groupPublicKey: Uint8Array /* 33-byte compressed secp256k1 */, r: Uint8Array /* 32 BE */, s: Uint8Array /* 32 BE, low-s */, yParity: 0 | 1}. r and s are byte arrays; convert them with viem’sbytesToHex(ethers:hexlify) where those libraries want hex.addressFromEoaPubkey(pubkey: Uint8Array)→ EIP-55 address (33-byte compressed or 65-byte0x04…uncompressed; a bare 64-byte X‖Y is not accepted).ethSignatureV(yParity: number, chainId?: number)→number: omitchainIdforpersonal_signand EIP-712 (27/28) and for typed transactions (useyParitydirectly); pass it only for legacy EIP-155 transactions.redeemRenewalToken(verifierUrl, renewalToken)→Promise<{token, exp, holder}>, the usualgetJwtsource for an adapter.fetchMpk(nodeUrl, slotId)→{mpkBytes: Uint8Array, epoch}, unauthenticated: the 33-byte compressed key of a threshold-ECDSA slot, hence the adapter’s address.- Inputs: the tECDSA slot id (
KK_TECDSA_SLOT_ID, a different slot from the frost/BLS one this ecosystem callsKK_SLOT_ID); the keepers that hold it, found by asking each known keeper (KK_ALL_NODE_URLS, elseKK_NODE_URLS) withfetchMpk, which answers 404 where the slot is not held (tasra-chainreads the committee on chain); a JWT the keepers accept for that slot:KK_JWTas is, orKK_RENEWAL_TOKENredeemed atKK_VERIFIER_URLand reused until itsexp(Unix seconds) — one token is not automatically good for both slots, andsignEoaDigestanswers 401/403 when it is not; andKK_CHAIN_ID.
An Ethereum account whose key is split across the fleet
Section titled “An Ethereum account whose key is split across the fleet”import {signEoaDigest, ethSignatureV, addressFromEoaPubkey, redeemRenewalToken} from 'tasra-sdk'
const {groupPublicKey, r, s, yParity} = await signEoaDigest({nodeUrl, jwt, slotId, digest})const from = addressFromEoaPubkey(groupPublicKey)const v = ethSignatureV(yParity, chainId) // chainId ONLY for legacy txs; omit it for // messages, EIP-712 and typed transactionsA viem Account adapter is included in node_modules/tasra-sdk/examples/viem-account.ts.
The shape below hashes locally and lets the fleet sign the digest:
import {toAccount} from 'viem/accounts'import {hashMessage, hashTypedData, keccak256, serializeTransaction, getTransactionType, numberToHex, bytesToHex, concatHex, hexToBytes} from 'viem'import {signEoaDigest, ethSignatureV, addressFromEoaPubkey, redeemRenewalToken, fetchMpk, TasraHttpError} from 'tasra-sdk'
// jwt: KK_JWT if set; otherwise redeem the renewal token once and reuse it until shortly before exp (Unix seconds)let issued: {token: string; exp: number} | undefinedconst getJwt = async () => { if (jwt) return jwt if (!issued || issued.exp - 30 < Date.now() / 1000) issued = await redeemRenewalToken(verifierUrl, renewalToken) return issued.token}// The keepers holding the slot, and its 33-byte compressed secp256k1 key: no ceremony needed.const holders: string[] = []let mpkBytes: Uint8Array | undefinedfor (const url of nodeUrls) { let got: Uint8Array // 404 means this keeper does not hold the slot; an unreachable keeper, a 5xx or a cold DKG // (fetchMpk throws a retryable TasraError for a slot served with no key) is that one // keeper's problem — skip it and keep asking the rest rather than abandoning discovery. try { got = (await fetchMpk(url, slotId)).mpkBytes } catch (e) { console.warn(`${url}: ${e}`); continue } // Every holder must serve the same group key; a disagreement means a stale or wrong deployment. if (mpkBytes && bytesToHex(got) !== bytesToHex(mpkBytes)) throw new Error(`${url} serves a different key for this slot`) mpkBytes = got holders.push(url)}if (!mpkBytes) throw new Error('no keeper holds this tECDSA slot')const address = addressFromEoaPubkey(mpkBytes)async function signDigest(digest: Uint8Array, chainId?: number) { let timedOut = 0 for (const nodeUrl of holders) { try { const {r, s, yParity} = await signEoaDigest({nodeUrl, jwt: await getJwt(), slotId, digest}) return {r: bytesToHex(r), s: bytesToHex(s), yParity, v: ethSignatureV(yParity, chainId)} // r, s are already 32 bytes } catch (e) { if (!(e instanceof TasraHttpError)) throw e if (e.status === 400) continue // outside the signing set: next holder if (e.status === 504 && ++timedOut < 3) continue // ceremony timed out: bounded retry on the next holder throw e // 404 route off, 401/403 auth, everything else } } throw new Error('no holder produced a signature')}const account = toAccount({ address, signMessage: async ({message}) => { const {r, s, v} = await signDigest(hexToBytes(hashMessage(message))); return concatHex([r, s, numberToHex(v, {size: 1})]) }, signTypedData: async (td) => { const {r, s, v} = await signDigest(hexToBytes(hashTypedData(td))); return concatHex([r, s, numberToHex(v, {size: 1})]) }, signTransaction: async (tx, opts) => { const serialize = opts?.serializer ?? serializeTransaction // honour a chain's custom serializer const legacyChainId = getTransactionType(tx) === 'legacy' ? tx.chainId : undefined const {r, s, yParity, v} = await signDigest(hexToBytes(keccak256(await serialize(tx))), legacyChainId) return serialize(tx, {r, s, yParity, v: BigInt(v)}) // typed txs use yParity; legacy uses the EIP-155 v },})account.signTransaction is typed as a plain Hex; cast to
TransactionSerialized before parseTransaction / recoverTransactionAddress
(the parameterised TransactionSerialized<'eip1559'> or <'legacy'> narrows the
parsed result when you already know the type). Declare typed-data literals as const so
signTypedData infers the primary type. An ethers v6 AbstractSigner is the
same three methods. The node route for
signEoaDigest (/v1/sign/eoa-digest) can be feature-gated per deployment; a
404 there means the operator has not enabled it.
Decryption, from managed to raw
Section titled “Decryption, from managed to raw”| Call | Assembles the key? | Use when |
|---|---|---|
session.decrypt(env) |
yes, lazily, in your process | a managed session on an exportable: true slot; other slots answer 403 |
createCommitteeSlotClient(...).decrypt(slotId, ...) |
no | committee path (tasra-committee-path) |
decryptCustody(opts) |
no | a node coordinates the ceremony |
decryptWithShardDelivery(opts) |
no | you coordinate; nodes return partial shares |
combineDecryptShares(shares, ct, identity, {verify}) |
no | you already hold the shares; verifyDecryptShare checks each |
decryptWithMasterKey(msk, ct, identity) |
you already did | pure local crypto |
The paths that send the ciphertext to keepers (the committee client, decryptCustody,
decryptWithShardDelivery) are bounded by the node’s request limits: a 64 KiB request body by
default, with the ciphertext base64-encoded in it, which leaves roughly 45 KiB of plaintext per
envelope. For larger data, put a random data key in the envelope and encrypt the data locally
with it.
Assembling the key (fetchAndAssembleKey({urls, jwt}, slotId)) is the one
operation that reconstructs whole key material client-side; the keepers allow
it only for slots created with exportable: true. Keep it only as long as
needed and wipe it (msk.fill(0); Session.close() does this).
decryptCustody({nodeUrl, jwt, slotId, ciphertext, identity, decryptingSet, blsPeers, ciphertextEpoch})
takes the envelope’s parts from fromBytes(env); decryptingSet are the BLS
identifiers of the keepers you ask to run the ceremony and blsPeers their
{id, peerId} pairs, both readable from each keeper’s /v1/info
(node_identifier, peer_id). The identity travels to the node as UTF-8
text, so encrypt under a text identity for this path —
session.encrypt(msg, {identity: new TextEncoder().encode('did:example:alice')}) —
the session’s default identity is the binary slot id and fails the AEAD check
on the node (HTTP 500 “AEAD failed”).
Verifying a FROST result: verifyFrostSignature(sig.groupPublicKey, message, sig.signature)
takes the original message bytes, not sig.messageSha256.
Common mistakes
Section titled “Common mistakes”- Using a
blsslot for signing, afrostslot for encryption, or either forsignEoaDigest. The slot’s mode decides which ceremonies it supports. - Assembling the key just to decrypt once. Prefer
decryptCustodyor the session’s lazy assembly withclose(). - Forgetting
chainIdinethSignatureVfor legacy transactions (typed transactions and messages want 27/28 or the bareyParity). - Treating
r/sas bigints or hex strings. The SDK returns 32-byte arrays; convert withbytesToHexonly where a library wants hex. - Passing
chainIdtoethSignatureVfor a message or typed-data signature. Those want 27/28; only legacy transactions want the EIP-155 form. Typed transactions are serialised fromyParity; avyou also pass is ignored.
Where to read more
Section titled “Where to read more”node_modules/tasra-sdk/dist/signing/*.d.ts,node_modules/tasra-sdk/dist/decryption/*.d.ts.
