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
- reading-balance -- reads the encrypted balance ciphertext from chain (RPC call)
- decrypting -- decrypts it locally using your encryption key (compute-intensive)
- generating-proof -- generates a ZK proof that the transfer is valid without revealing amounts (slowest step, runs locally)
- 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:
reading-balancedecryptinggenerating-proofbuilding-transactionsigningsubmitting
Transaction types
| Type | Produced by | Contains | Next step |
|---|---|---|---|
PreparedTransaction | EVM prepare* (plain variants) or signTransaction / toSignedTransaction | tokenAddress, functionName, args | sendPreparedTransaction |
UnsignedTransaction | ZKP prepare*, or preparePublicToEncryptedTransferWithAuth | Above + typedData, nonce, signatureArgName | signTransaction or toSignedTransaction |