Signing Transparency

When the Smart Account holder is a human (browser wallet, hardware wallet, MPC approval flow), they should be able to see what they are signing in unambiguous terms, not just an opaque 32-byte hash. CRE Connect operations are EIP-712 typed data, so the typed-data structure is itself the user-facing description; you just need to expose it well.

This guide collects the reusable building blocks. There is no built-in helper for "render-this-operation" today; the recipe below uses crec-sdk/transact plus standard go-ethereum packages.

What to surface

For every operation, expose at minimum:

FieldSource
Smart Account addressop.Account
Network (chain ID + chain selector)chainSelector argument + chain-selectors lookup
Operation ID (nonce)op.ID
Deadlineop.Deadline (decoded as Unix seconds → human time)
Each transaction's targettx.To
Each transaction's valuetx.Value (decoded as native units)
Each transaction's function + argumentsDecoded against the contract ABI
EIP-712 domainCLLSmartAccount v1, chainId = network chain ID, verifyingContract = op.Account
EIP-712 typed-data hash (the digest)client.Transact.HashOperation(op, chainSelector)

Build a typed-data summary

The SDK exposes op.TypedData(chainID) directly:

import (
    "encoding/json"
    "github.com/smartcontractkit/chain-selectors"
    "github.com/smartcontractkit/crec-sdk/transact/types"
)

family, _ := chain_selectors.GetSelectorFamily(chainSelectorUint)
chainID, _ := chain_selectors.GetChainIDFromSelector(chainSelectorUint)

td, err := op.TypedData(chainID)
if err != nil { return err }

pretty, _ := json.MarshalIndent(td, "", "  ")
fmt.Println(string(pretty))

This produces the exact apitypes.TypedData document that the signer consumes. Render it as JSON, or break it into the table above for a friendlier UI.

Decode each transaction's calldata

The Smart Account's job is to invoke to.call(value, data) for each transaction; you should resolve the (method, arguments) pair before showing it.

import (
    "github.com/ethereum/go-ethereum/accounts/abi"
)

func decodeCall(parsed abi.ABI, data []byte) (string, []any, error) {
    if len(data) < 4 { return "", nil, fmt.Errorf("calldata too short") }
    method, err := parsed.MethodById(data[:4])
    if err != nil { return "", nil, err }
    args, err := method.Inputs.Unpack(data[4:])
    if err != nil { return method.Name, nil, err }
    return method.Name, args, nil
}

Combine with the operation:

for i, tx := range op.Transactions {
    name, args, err := decodeCall(parsed, tx.Data)
    if err != nil {
        log.Printf("tx[%d] %s: undecodable calldata", i, tx.To.Hex())
        continue
    }
    log.Printf("tx[%d] %s.%s(%v) value=%s", i, tx.To.Hex(), name, args, tx.Value.String())
}

For unknown ABIs, fall back to the 4-byte selector and the raw hex payload.

Compute and display the hash

Always show the user the same digest the signer will sign:

opHash, err := client.Transact.HashOperation(op, chainSelector)
if err != nil { return err }
fmt.Println("EIP-712 digest:", opHash.Hex())

If you split the flow across two services (one renders the summary, another invokes the signer), pin the operation by its hash so you can detect tampering between the two stages.

Hardware wallet considerations

Hardware wallets that natively support EIP-712 (Ledger / Trezor with Eth app ≥ 1.10) will display the typed data directly. The user sees:

  • Domain: CLLSmartAccount, version 1, chainId, verifyingContract.
  • Primary type: Operation.
  • Message fields: id, account, deadline, and the array of transactions.

The on-screen display does not decode transactions[i].data: it shows the raw bytes. Pair the device confirmation with an off-device decoded summary so the user can cross-check.

MPC / approval-policy signers

For Fireblocks, Privy, and similar custody systems, push the typed-data document and the decoded summary into the approval payload. Most providers support a free-form "transaction note" field; populate it with a short, human description (e.g. Deposit 1.0 USDC into Vault 0x…cafe) that mirrors the decoded calldata. Approvers should be trained to reject if the note doesn't match the typed data.

A reusable rendering function

type Summary struct {
    SmartAccount string
    ChainID      string
    Nonce        string
    Deadline     time.Time
    Hash         string
    Transactions []TxSummary
}

type TxSummary struct {
    To       string
    Value    string
    Method   string
    Args     []any
    Selector string
    Raw      string
}

func Summarise(op *types.Operation, abis map[common.Address]abi.ABI, chainSelector string, chainID string, hash common.Hash) Summary {
    out := Summary{
        SmartAccount: op.Account.Hex(),
        ChainID:      chainID,
        Nonce:        op.ID.String(),
        Deadline:     time.Unix(op.Deadline.Int64(), 0).UTC(),
        Hash:         hash.Hex(),
    }
    for _, tx := range op.Transactions {
        sel := "0x" + common.Bytes2Hex(tx.Data[:4])
        sum := TxSummary{To: tx.To.Hex(), Value: tx.Value.String(), Selector: sel, Raw: "0x" + common.Bytes2Hex(tx.Data)}
        if abi, ok := abis[tx.To]; ok {
            if name, args, err := decodeCall(abi, tx.Data); err == nil {
                sum.Method = name
                sum.Args = args
            }
        }
        out.Transactions = append(out.Transactions, sum)
    }
    return out
}

Call Summarise immediately before SignOperationHash and surface the Summary to the user (web UI, CLI prompt, audit log, etc.).

Next steps

Get the latest Chainlink content straight to your inbox.