Skip to main content
Version: develop

Transaction Lifecycle

The SDK produces transactions in two shapes, depending on whether the contract needs an EIP-712 signature embedded in calldata.

EVM transactions without a signature

Register, plain deposit (WalletClient path only), auto-encrypt. The contract checks msg.sender or nothing — no permit needed. Simple prepare-and-send:

prepare() → PreparedTransaction { tokenAddress, functionName, args }

send() → { transactionHash }
// One call
await client.sendPublicToEncryptedTransfer({ token, amount, zkpAddress })

// Or two steps
const prepared = client.preparePublicToEncryptedTransfer({ token, amount, zkpAddress })
const { transactionHash } = await client.sendPreparedTransaction(prepared)

EVM transactions with an EIP-712 permit

When the contract needs a permit signed by the token owner — today that's publicToEncryptedTransferWithAuth, used on the bundler path because the UserOp submitter (SharedAccount) isn't the tokenholder — the flow adds a signing step:

prepare() → UnsignedTransaction { tokenAddress, functionName, args, typedData, nonce, signatureArgName }

signAuthorization() → PreparedTransaction (authorization signature inserted under `signatureArgName`)

send() → { transactionHash }
// One call — the convenience wrapper auto-routes wallet vs bundler and,
// on the bundler path, runs prepare → sign → send under the hood.
await client.sendPublicToEncryptedTransfer({ token, amount, zkpAddress })

// Or step-by-step (useful for external signers / custody flows)
const unsigned = await client.preparePublicToEncryptedTransferWithAuth({
token, amount, zkpAddress, owner: userAddress, deadline,
})
const signed = await client.signAuthorization(unsigned)
const { transactionHash } = await client.sendPreparedTransaction(signed)

signAuthorization is bound to the account passed to the factory. For raw signatures (custody, Ledger, Fireblocks), use toSignedTransaction(unsigned, signature):

const unsigned = await client.preparePublicToEncryptedTransferWithAuth({ ... })
const signature = await externalSigner.signTypedData(unsigned.typedData)
const signed = client.toSignedTransaction(unsigned, signature)
await client.sendPreparedTransaction(signed)

ZKP transactions (proof + controller signature)

Encrypted transfers. Same prepare → sign → submit pattern as the permit flow above, but prepare additionally generates a ZK proof and the signing step (signTransaction, distinct from the permit's signAuthorization) uses the controller key instead of the token-owner account:

prepare() → UnsignedTransaction { tokenAddress, functionName, args, typedData, nonce, signatureArgName }

signTransaction() → PreparedTransaction (controller EIP-712 signature added)

send() → { transactionHash }
// One call (handles all steps internally)
await client.sendEncryptedTransfer({ token, amount, to })

// Or step-by-step
const unsigned = await client.prepareEncryptedTransfer({ token, amount, to })
const signed = await client.signTransaction(unsigned)
const { transactionHash } = await client.sendPreparedTransaction(signed)

What prepare does internally

  1. reading-balance -- reads the encrypted balance ciphertext from chain (RPC call)
  2. decrypting -- decrypts it locally using your encryption key (compute-intensive)
  3. generating-proof -- generates a ZK proof that the transfer is valid without revealing amounts (slowest step, runs locally)
  4. building-transaction -- constructs the EIP-712 typed data and transaction args

What signTransaction does

Signs the EIP-712 typed data with the controller spending key (CSK). This produces a secp256k1 signature that the contract verifies via ecrecover.

What send does

Encodes the function call with the controller signature appended, then submits:

  • WalletClient -- sendTransaction (standard EVM transaction)
  • BundlerClient -- sendUserOperation (ERC-4337 UserOp)

By default, every send* / sendPreparedTransaction is terminal on both paths: it waits for inclusion and resolves a { transactionHash } receipt carrying the confirmed, mined on-chain tx hash — via waitForUserOperationReceipt on the bundler path, waitForTransactionReceipt on the WalletClient path. No follow-up receipt wait is needed.

const { transactionHash } = await client.sendEncryptedTransfer({ token, amount, to })

A resolved receipt means success. A reverted transaction throws SendRevertError on both paths — carrying the on-chain transactionHash, the submission path, and a best-effort decoded revert reason (.decoded / .reason). The bundler reason comes straight from the user-operation receipt; the WalletClient reason is recovered by replaying the call at the mined block, so on a non-archival RPC it may be unavailable (.reasonUnavailable === true) — but the revert is always surfaced, never swallowed.

import { SendRevertError } from '@cardinal-cryptography/core'

try {
await client.sendEncryptedTransfer({ token, amount, to })
} catch (e) {
if (e instanceof SendRevertError) console.error(e.decoded?.summary ?? e.message)
}

Pass { waitForReceipt: false } to resolve immediately, before mining. You get back a pending send — the submission hash (a user-operation hash on the bundler path, a submitted tx hash on the WalletClient path) plus a bound waitForReceipt() to confirm when you choose:

const { hash, waitForReceipt } = await client.sendEncryptedTransfer(
{ token, amount, to },
{ waitForReceipt: false },
)
// ...later
const { transactionHash } = await waitForReceipt()

A standalone waitForReceipt(client, hash) is also exported for when you hold a submission hash from elsewhere. A read issued after a { waitForReceipt: false } send but before the receipt resolves can observe pre-transaction state (see Read-after-write consistency).

Where reverts surface. { waitForReceipt: false } defers only the receipt wait, not the submission — send* still submits (and can throw) before returning the pending send. On the bundler path the op is simulated before acceptance, so the common revert is rejected at submission and send* throws UserOpRevertError on the send call itself, even with { waitForReceipt: false } (there is no hash to defer). A revert that only manifests after inclusion — a rare bundler execution revert, or any WalletClient revert (sendTransaction doesn't simulate) — surfaces later, as SendRevertError thrown from waitForReceipt(). So { waitForReceipt: false } is not "won't throw": the send can still reject at submission; the option only defers the mined-receipt check.

Nonce allocation

prepare assigns each authorization a nonce -- a fresh random uint256 by default; override with nonce?. Almost every controller- and owner-signed authorization on a single ZKEMT consumes from one shared replay-protection bitmap, keyed only by the signing address -- the same namespace across the *WithAuth ZKP operations (encryptedTransfer, encryptedToPublicTransfer, publicToEncryptedTransfer, managePendingBalance, setAutoEncrypt / toggleAutoEncrypt) and the ERC-3009 transferWithAuthorization / receiveWithAuthorization flows, whose bytes32 nonce is read as a uint256 bit index into the same bitmap. There is no per-operation nonce lane.

It is a Permit2-style bitmap: any unused uint256 is valid and nonces need not be sequential (it is not a counter) -- which is why the SDK's random default is safe with no coordination.

If you assign nonces yourself, draw them from a single pool per (signer, token) and keep them globally unique across all authorization types -- never partition or restart per operation. A value already consumed by any authorization from the same signer reverts with NonceAlreadyUsed, even for a different operation type.

The namespace is scoped to one signing address on one ZKEMT (EIP-712 verifyingContract): a different signer, a different token, and the Hub are all independent. The one owner-signed flow that does not draw from this bitmap is ERC-2612 permit, which uses OpenZeppelin's separate sequential nonces(owner) counter -- its own space.

Progress callbacks

The multi-step operations report progress:

await client.sendEncryptedTransfer({
token: 'zkUSD',
amount: 100n,
to: 'zk1...',
onProgress: ({ step, total, stage }) => {
console.log(`[${step}/${total}] ${stage}`)
},
})

Stages in order:

  1. reading-balance
  2. decrypting
  3. generating-proof
  4. building-transaction
  5. signing
  6. submitting

Transaction types

TypeProduced byContainsNext step
PreparedTransactionEVM prepare* (plain variants) or signTransaction / toSignedTransactiontokenAddress, functionName, argssendPreparedTransaction
UnsignedTransactionZKP prepare*, or preparePublicToEncryptedTransferWithAuthAbove + typedData, nonce, signatureArgNamesignTransaction or toSignedTransaction