Skip to main content
Version: 0.4.7

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.

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