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).
  • s must be in the lower half of the secp256k1 curve order (BIP-62 / EIP-2). go-ethereum's crypto.Sign already enforces this.
  • v must be 27 or 28, not 0/1. If your custody system returns 0/1, add 27 before returning.
  • The recovered address must be present in the wallet's AllowedEcdsaSigners list.

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. If ctx.Done() fires, abandon any polling loop and return ctx.Err().
  • Idempotence. If your custody system retries internally, make sure the hash you sign is the same on retry. The Smart Account's op.ID already provides operation-level idempotence.
  • Audit log. Every Sign call 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

Get the latest Chainlink content straight to your inbox.