Build and Sign Operations
A CRE Connect operation is a batch of one or more transactions, signed once with EIP-712 and executed atomically by your Smart Account. This guide covers the build → sign half of the lifecycle. The submit → track half is in Submit and Track Operations.
The data model
import (
"math/big"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/smartcontractkit/crec-sdk/transact/types"
)
op := &types.Operation{
ID: big.NewInt(time.Now().Unix()),
Account: common.HexToAddress("0xYourSmartAccount"),
Deadline: big.NewInt(0),
Transactions: []types.Transaction{{
To: common.HexToAddress("0xTargetContract"),
Value: big.NewInt(0),
Data: hexutil.Bytes(callData),
}},
}
Field rules:
| Field | Notes |
|---|---|
ID | Unique-per-account nonce. Must be a non-negative integer. If you build the Operation yourself, pick any scheme that never repeats per Smart Account (a counter, a UUID-derived value). The Smart Account rejects re-used IDs. Note: ExecuteTransactions does not use this convention; it generates a random 128-bit ID for you. |
Account | Address of your Smart Account (not your EOA / signer). This is the verifyingContract in the EIP-712 domain. |
Deadline | Unix seconds. 0 means no expiration. The Smart Account reverts after block.timestamp > deadline. |
Transactions | At least one. Each Transaction is (to, value, data). |
Building calldata
The SDK does not include an ABI encoder; use go-ethereum's accounts/abi:
import (
"strings"
"github.com/ethereum/go-ethereum/accounts/abi"
)
const counterABI = `[{"inputs":[{"internalType":"uint256","name":"by","type":"uint256"}],"name":"incrementBy","outputs":[],"stateMutability":"nonpayable","type":"function"}]`
parsed, err := abi.JSON(strings.NewReader(counterABI))
if err != nil { return err }
callData, err := parsed.Pack("incrementBy", big.NewInt(7))
if err != nil { return err }
For batched operations, append further types.Transaction entries with their own callData.
Sign with EIP-712
The transact client embeds an eip712.Handler that:
- Resolves the chain ID from the chain selector (via
smartcontractkit/chain-selectors). - Builds the
TypedData(domainCLLSmartAccountv1, primary typeOperation). - Hashes it (
EIP712Hash). - Asks the configured
signer.Signerto produce a signature.
You can do this in one call:
import (
"github.com/ethereum/go-ethereum/crypto"
"github.com/smartcontractkit/crec-sdk/transact/signer/local"
)
pk, err := crypto.HexToECDSA(privateKeyHex) // 64-char hex, no 0x
if err != nil { return err }
ecdsa := local.NewSigner(pk)
opHash, sig, err := client.Transact.SignOperation(ctx, op, ecdsa, chainSelector)
Or in two steps if you want to inspect/audit the hash before signing:
opHash, err := client.Transact.HashOperation(op, chainSelector)
if err != nil { return err }
sig, err := client.Transact.SignOperationHash(ctx, opHash, ecdsa)
The returned signature is 65 bytes (r ∥ s ∥ v), suitable for eth_sign-style recovery on chain.
Picking the right signer
Any type that implements signer.Signer (and signer.TypedDataSigner) works:
| Signer | Package | Use for |
|---|---|---|
| Local ECDSA | crec-sdk/transact/signer/local | Tests, CLIs, dev environments. |
| AWS KMS | crec-sdk/transact/signer/kms | Production where keys must stay in HSM. |
| HashiCorp Vault | crec-sdk/transact/signer/vault | Self-hosted secret management. |
| Fireblocks | crec-sdk/transact/signer/fireblocks | MPC-managed keys with policy approval. |
| Privy | crec-sdk/transact/signer/privy | Embedded user wallets. |
| Custom | implement signer.Signer yourself | Bring-your-own KMS / multisig flow. |
The signer's address must match, or be allowed by, the Smart Account's signer set. See Manage Wallet Signers.
Re-signing for the same operation ID
Operation.ID is a per-account nonce. If you build an operation, sign it, then change anything (Transactions, Deadline, anything that changes the typed-data hash), you must:
- Either re-sign with the same
IDand make sure no copy of the original signed payload was sent. If both reach CRE Connect, the second one will be rejected on chain. - Or bump
ID(e.g.time.Now().Unix() + 1) and sign that.
Most flows just regenerate the operation from scratch.
End-to-end example
op := &types.Operation{
ID: big.NewInt(time.Now().Unix()),
Account: smartAccount,
Deadline: big.NewInt(0),
Transactions: []types.Transaction{{
To: counterAddr,
Value: big.NewInt(0),
Data: hexutil.Bytes(callData),
}},
}
opHash, sig, err := client.Transact.SignOperation(ctx, op, ecdsa, chainSelector)
if err != nil { return err }
fmt.Printf("hash=%s sig=0x%x\n", opHash.Hex(), sig)
The (op, sig) pair is now ready to send. Continue with Submit and Track Operations.
Deferred signing
If your signer cannot approve the operation synchronously, create a draft instead of producing the signature in this guide. A draft stores the unsigned operation in pending_signature, lets your approval system sign the digest later, and can be cancelled before execution.
Use this for MPC policy review, human approval queues, KMS workflows, or preview-before-sign screens. See Draft Operations for the model and Draft Operations: Create, Finalize, Cancel for the SDK and REST flow.
Next steps
- Submit and Track Operations: send the signed operation and watch the resulting status events.
- Draft Operations: Create, Finalize, Cancel: create an unsigned operation and finalize it later.
- Batch Multiple Transactions: group several calls into a single atomic operation.
- Signing Transparency: show users exactly what they're signing.