Custom Signer
The built-in signers (local, AWS KMS, HashiCorp Vault, Fireblocks, Privy) cover the most common production cases. For anything else, implement the signer.Signer interface yourself: the CRE Connect SDK will wire your custody system into the operation flow with no further changes.
The interface
The contract is intentionally tiny:
package signer
type Signer interface {
Sign(ctx context.Context, hash []byte) ([]byte, error)
}
type TypedDataSigner interface {
SignTypedData(ctx context.Context, typedData *TypedData) ([]byte, error)
}
Source: signer.go.
Sign receives a 32-byte digest (the EIP-712 hash of the Operation) and must return a signature that recovers to the address you registered in AllowedEcdsaSigners.
What "signature" means here
The CRE Connect Smart Account uses on-chain ecrecover to verify ECDSA signatures, so:
- Length must be exactly 65 bytes:
r(32) ∥s(32) ∥v(1). smust be in the lower half of the secp256k1 curve order (BIP-62 / EIP-2). go-ethereum'scrypto.Signalready enforces this.vmust be 27 or 28, not0/1. If your custody system returns0/1, add 27 before returning.- The recovered address must be present in the wallet's
AllowedEcdsaSignerslist.
For RSA-backed wallets the Smart Account uses RSA verification instead: the signature shape differs (PKCS#1 v1.5 over the digest); see HashiCorp Vault Signer.
Minimal example: Multisig approval service
package mysigner
import (
"context"
"fmt"
"github.com/smartcontractkit/crec-sdk/transact/signer"
)
type MultisigSigner struct {
endpoint string
apiKey string
keyID string
expected common.Address
http *http.Client
}
var _ signer.Signer = (*MultisigSigner)(nil)
func New(endpoint, apiKey, keyID string, expected common.Address) *MultisigSigner {
return &MultisigSigner{
endpoint: endpoint, apiKey: apiKey, keyID: keyID,
expected: expected, http: &http.Client{Timeout: 30 * time.Second},
}
}
func (s *MultisigSigner) Sign(ctx context.Context, hash []byte) ([]byte, error) {
body, _ := json.Marshal(map[string]any{
"key_id": s.keyID,
"digest": "0x" + hex.EncodeToString(hash),
})
req, _ := http.NewRequestWithContext(ctx, "POST", s.endpoint+"/sign", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+s.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := s.http.Do(req)
if err != nil { return nil, fmt.Errorf("multisig sign: %w", err) }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("multisig sign returned %d: %s", resp.StatusCode, string(b))
}
var out struct{ Signature string `json:"signature"` }
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, err }
sig, err := hex.DecodeString(strings.TrimPrefix(out.Signature, "0x"))
if err != nil { return nil, err }
if len(sig) != 65 { return nil, fmt.Errorf("expected 65-byte signature, got %d", len(sig)) }
if sig[64] <= 1 { sig[64] += 27 }
if err := s.verifyRecover(hash, sig); err != nil {
return nil, fmt.Errorf("signature does not recover to expected signer: %w", err)
}
return sig, nil
}
func (s *MultisigSigner) verifyRecover(hash, sig []byte) error {
pub, err := crypto.SigToPub(hash, sig)
if err != nil { return err }
if got := crypto.PubkeyToAddress(*pub); got != s.expected {
return fmt.Errorf("recovered %s, expected %s", got.Hex(), s.expected.Hex())
}
return nil
}
Use it exactly like a built-in signer:
ms := mysigner.New("https://multisig.example/api", os.Getenv("APPROVAL_API_KEY"), "treasury-key", expectedAddr)
opr, err := client.Transact.ExecuteOperation(ctx, channelID, ms, op, chainSelector)
Optionally implement TypedDataSigner
If your custody system supports typed-data signing natively (e.g. so it can render the message to approvers), also implement signer.TypedDataSigner:
func (s *MultisigSigner) SignTypedData(ctx context.Context, td *signer.TypedData) ([]byte, error) {
body, _ := json.Marshal(map[string]any{
"key_id": s.keyID,
"typed_data": td,
})
// ... POST and return signature ...
}
The CRE Connect SDK's Transact.SignOperation always calls Sign(ctx, hash) today. If you want to use SignTypedData, build the typed-data document yourself with op.TypedData(chainID) and call SignTypedData directly, then submit the resulting signature with Transact.SendSignedOperation.
Register the signer's address
Whatever address your custody system signs as must appear in the wallet's AllowedEcdsaSigners (or AllowedRsaSigners for RSA). When you provision the wallet, derive the address up front:
addr := common.HexToAddress(myCustodySystem.GetSignerAddress())
ecdsa := []string{addr.Hex()}
client.Wallets.Create(ctx, wallets.CreateInput{
// ...
AllowedEcdsaSigners: &ecdsa,
})
See Manage Wallet Signers for the full provisioning flow.
Hardening checklist
- Time-out the upstream call. Don't let a hung custody backend pin a CRE Connect goroutine indefinitely; give every HTTP / gRPC call a
context.WithTimeout. - Honour
ctx. Ifctx.Done()fires, abandon any polling loop and returnctx.Err(). - Idempotence. If your custody system retries internally, make sure the hash you sign is the same on retry. The Smart Account's
op.IDalready provides operation-level idempotence. - Audit log. Every
Signcall is a security-critical event. Persist(timestamp, op.ID, signer key, requesting service, recovered address)somewhere immutable. - Test with the real Smart Account. Use the Local Signer in tests to confirm the wallet accepts your signatures, then swap in your custom signer behind the same interface.
Next steps
- Build and Sign Operations: the operation contract your
Signis fulfilling. - Smart Accounts: how the recovered address is checked on chain.
- Signing Transparency: what to expose to humans before your custom signer signs.