Skip to content

Commit eb84ab6

Browse files
committed
mirrors ccip logic
1 parent fd1c288 commit eb84ab6

4 files changed

Lines changed: 292 additions & 8 deletions

File tree

deployment/changesets/nop_identities.go

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ type NOPIdentities struct {
4545
// unmappable on-chain signer surfaces later as an explicit error.
4646
func LoadNOPIdentities(ctx context.Context, env deployment.Environment) (*NOPIdentities, error) {
4747
signers := make(fetch_signing_keys.SigningKeysByNOP)
48+
// rawPubKeys is alias -> raw secp256k1 public key, sourced only from JD (the persisted
49+
// index below stores derived addresses, not keys). It lets newNOPIdentities derive a
50+
// NOP's signer address for a family it never declared directly.
51+
rawPubKeys := make(map[string]string)
4852

4953
// CL-mode NOPs from JD.
5054
if env.Offchain != nil && len(env.NodeIDs) > 0 {
@@ -68,6 +72,7 @@ func LoadNOPIdentities(ctx context.Context, env deployment.Environment) (*NOPIde
6872
for alias, byFamily := range report.Output.SigningKeysByNOP {
6973
signers[alias] = maps.Clone(byFamily)
7074
}
75+
rawPubKeys = report.Output.RawPubKeyByNOP
7176
}
7277

7378
// Standalone (and any other non-JD) NOPs from the persisted index. JD wins on
@@ -89,24 +94,56 @@ func LoadNOPIdentities(ctx context.Context, env deployment.Environment) (*NOPIde
8994
}
9095
}
9196

92-
return newNOPIdentities(signers), nil
97+
return newNOPIdentities(signers, rawPubKeys), nil
9398
}
9499

95-
// newNOPIdentities builds the inverse index from a forward signing-keys map.
96-
// Exposed (unexported) so tests can construct identities without a JD round-trip.
97-
func newNOPIdentities(signingKeys fetch_signing_keys.SigningKeysByNOP) *NOPIdentities {
100+
// newNOPIdentities builds the inverse index from a forward signing-keys map plus each
101+
// NOP's raw public key (where known). Exposed (unexported) so tests can construct
102+
// identities without a JD round-trip.
103+
//
104+
// The index is populated in two passes so that a signer recorded on-chain in a
105+
// counterparty verifier's family still resolves back to its NOP: pass 1 registers the
106+
// address each NOP declares directly per family; pass 2 fills every other translatable
107+
// family with the address derived from the NOP's known identities (see
108+
// signerAddressForFamily). Direct entries always win on collision.
109+
func newNOPIdentities(signingKeys fetch_signing_keys.SigningKeysByNOP, rawPubKeyByNOP map[string]string) *NOPIdentities {
98110
inv := make(map[string]map[string]shared.NOPAlias)
111+
112+
// set records family -> normalized address -> alias, first writer wins so that direct
113+
// (pass 1) entries are never overwritten by derived (pass 2) ones.
114+
set := func(family, signer string, alias shared.NOPAlias) {
115+
if signer == "" {
116+
return
117+
}
118+
if inv[family] == nil {
119+
inv[family] = make(map[string]shared.NOPAlias)
120+
}
121+
key := shared.NormalizeAddress(family, signer)
122+
if _, taken := inv[family][key]; taken {
123+
return
124+
}
125+
inv[family][key] = alias
126+
}
127+
128+
// Pass 1: addresses each NOP declares directly.
99129
for alias, byFamily := range signingKeys {
100130
for family, signer := range byFamily {
101-
if signer == "" {
131+
set(family, signer, shared.NOPAlias(alias))
132+
}
133+
}
134+
135+
// Pass 2: derive the NOP's address for every other translatable family.
136+
for alias, byFamily := range signingKeys {
137+
for _, family := range translatableSignerFamilies {
138+
if byFamily[family] != "" {
102139
continue
103140
}
104-
if inv[family] == nil {
105-
inv[family] = make(map[string]shared.NOPAlias)
141+
if derived, ok := signerAddressForFamily(byFamily, rawPubKeyByNOP[alias], family); ok {
142+
set(family, derived, shared.NOPAlias(alias))
106143
}
107-
inv[family][shared.NormalizeAddress(family, signer)] = shared.NOPAlias(alias)
108144
}
109145
}
146+
110147
return &NOPIdentities{signingKeys: signingKeys, aliasBySigner: inv}
111148
}
112149

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package changesets
2+
3+
import (
4+
"encoding/hex"
5+
"testing"
6+
7+
gethcrypto "github.com/ethereum/go-ethereum/crypto"
8+
chainsel "github.com/smartcontractkit/chain-selectors"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/smartcontractkit/chainlink-ccv/deployment/operations/fetch_signing_keys"
12+
)
13+
14+
// TestNOPIdentities_ResolvesCantonSignerAsEVM reproduces the reconstruction path that
15+
// failed after canton moved to JD-based signer sync: a canton verifier is known to JD
16+
// only under family "canton" (its identity is the raw secp256k1 public key), but on an
17+
// EVM committee verifier its signer is recorded as the EVM address derived from that key.
18+
// AliasForSigner("evm", <derived address>) must resolve back to the canton NOP.
19+
func TestNOPIdentities_ResolvesCantonSignerAsEVM(t *testing.T) {
20+
priv, err := gethcrypto.GenerateKey()
21+
require.NoError(t, err)
22+
23+
rawPubKey := hex.EncodeToString(gethcrypto.FromECDSAPub(&priv.PublicKey))
24+
evmAddr := gethcrypto.PubkeyToAddress(priv.PublicKey).Hex()
25+
26+
const cantonNOP = "canton-default-verifier-1"
27+
28+
// JD only knows this NOP under the canton family; the canton "address" is the raw
29+
// public key hex, and RawPubKeyByNOP carries the same key.
30+
signingKeys := fetch_signing_keys.SigningKeysByNOP{
31+
cantonNOP: {chainsel.FamilyCanton: rawPubKey},
32+
}
33+
rawPubKeyByNOP := map[string]string{cantonNOP: rawPubKey}
34+
35+
ids := newNOPIdentities(signingKeys, rawPubKeyByNOP)
36+
37+
alias, ok := ids.AliasForSigner(chainsel.FamilyEVM, evmAddr)
38+
require.True(t, ok, "canton NOP's EVM-derived signer should resolve back to the NOP")
39+
require.Equal(t, cantonNOP, string(alias))
40+
41+
// The directly-declared canton identity still resolves.
42+
alias, ok = ids.AliasForSigner(chainsel.FamilyCanton, rawPubKey)
43+
require.True(t, ok)
44+
require.Equal(t, cantonNOP, string(alias))
45+
}
46+
47+
// TestNOPIdentities_DirectSignerWinsOverDerived ensures a family's directly-declared
48+
// signer address is never shadowed by one derived for another NOP.
49+
func TestNOPIdentities_DirectSignerWinsOverDerived(t *testing.T) {
50+
priv, err := gethcrypto.GenerateKey()
51+
require.NoError(t, err)
52+
rawPubKey := hex.EncodeToString(gethcrypto.FromECDSAPub(&priv.PublicKey))
53+
evmAddr := gethcrypto.PubkeyToAddress(priv.PublicKey).Hex()
54+
55+
signingKeys := fetch_signing_keys.SigningKeysByNOP{
56+
"evm-nop": {chainsel.FamilyEVM: evmAddr},
57+
"canton-nop": {chainsel.FamilyCanton: rawPubKey},
58+
}
59+
rawPubKeyByNOP := map[string]string{"canton-nop": rawPubKey}
60+
61+
ids := newNOPIdentities(signingKeys, rawPubKeyByNOP)
62+
63+
// evmAddr is declared directly by evm-nop and would also be derived for canton-nop
64+
// (same key); the direct declaration must win.
65+
alias, ok := ids.AliasForSigner(chainsel.FamilyEVM, evmAddr)
66+
require.True(t, ok)
67+
require.Equal(t, "evm-nop", string(alias))
68+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package changesets
2+
3+
import (
4+
"encoding/hex"
5+
"fmt"
6+
"sort"
7+
"strings"
8+
9+
gethcommon "github.com/ethereum/go-ethereum/common"
10+
gethcrypto "github.com/ethereum/go-ethereum/crypto"
11+
12+
chainsel "github.com/smartcontractkit/chain-selectors"
13+
)
14+
15+
// This mirrors the cross-family signer translation in
16+
// chainlink-ccip/deployment/v2_0_0/changesets/lane_signing_helpers.go (added in #2169
17+
// "fix cross-family signer lookup"). That change fixed the *forward* path — deriving a
18+
// NOP's signer address for a destination family when the lane changeset writes it
19+
// on-chain. This is the *inverse* path: when reconstructing committee membership from
20+
// on-chain state (CommitteeInputFromState -> NOPIdentities.AliasForSigner), an on-chain
21+
// signer recorded in the verifier chain's family (e.g. a Canton verifier's EVM address on
22+
// an EVM committee verifier) must resolve back to the NOP even though JD only knows that
23+
// NOP under its own family (canton). Without the same translation the reconstruction
24+
// fails with "on-chain signer ... has no JD-known NOP".
25+
26+
// rawPubKeySourceFamily is a synthetic "family" tag used only as a translation source,
27+
// standing for a NOP's raw public key fetched directly from JD rather than derived from
28+
// any one chain family's own address representation. It is never a real chain family and
29+
// must never be passed as a translation target.
30+
const rawPubKeySourceFamily = "xxx_notarealfamily_raw_pubkey"
31+
32+
// addressClassFamilies are chain families whose signer identity is a 20-byte address
33+
// derived via secp256k1 -> keccak256 (EVM-style). Members encode identical bytes and
34+
// differ only in string formatting, so they are freely interconvertible in both
35+
// directions.
36+
var addressClassFamilies = map[string]bool{
37+
chainsel.FamilyEVM: true,
38+
chainsel.FamilySolana: true,
39+
}
40+
41+
// rawPubKeyClassFamilies are chain families (plus the synthetic rawPubKeySourceFamily)
42+
// whose signer identity is the full uncompressed secp256k1 public key, hex-encoded with
43+
// no per-family formatting differences. Members are interchangeable as-is.
44+
var rawPubKeyClassFamilies = map[string]bool{
45+
chainsel.FamilyAptos: true,
46+
chainsel.FamilyStellar: true,
47+
chainsel.FamilyCanton: true,
48+
rawPubKeySourceFamily: true,
49+
}
50+
51+
// translatableSignerFamilies is the set of real chain families the inverse index is
52+
// populated for. When a NOP has a signer identity known for one family, its address in
53+
// every other family in this set that can be derived is precomputed, so AliasForSigner
54+
// resolves an on-chain signer regardless of which family it was recorded in.
55+
var translatableSignerFamilies = []string{
56+
chainsel.FamilyEVM,
57+
chainsel.FamilySolana,
58+
chainsel.FamilyAptos,
59+
chainsel.FamilyStellar,
60+
chainsel.FamilyCanton,
61+
}
62+
63+
// signerAddressForFamily derives the signer address a NOP would present on targetFamily
64+
// from whatever identities it already has: its per-family signer addresses (JD- or
65+
// state-sourced) and/or its raw public key. It returns ok=false when no candidate can be
66+
// translated into targetFamily (e.g. only an address-class family is known but the target
67+
// needs a raw public key, which a hashed address cannot yield).
68+
func signerAddressForFamily(directByFamily map[string]string, rawPubKey, targetFamily string) (string, bool) {
69+
candidates := make(map[string]string, len(directByFamily)+1)
70+
for family, addr := range directByFamily {
71+
if addr != "" {
72+
candidates[family] = addr
73+
}
74+
}
75+
if rawPubKey != "" {
76+
if _, exists := candidates[rawPubKeySourceFamily]; !exists {
77+
candidates[rawPubKeySourceFamily] = rawPubKey
78+
}
79+
}
80+
delete(candidates, targetFamily)
81+
82+
families := make([]string, 0, len(candidates))
83+
for family := range candidates {
84+
families = append(families, family)
85+
}
86+
sort.Strings(families) // deterministic when more than one source family is available
87+
88+
for _, family := range families {
89+
if translated, err := translateSignerAddress(family, candidates[family], targetFamily); err == nil {
90+
return translated, true
91+
}
92+
}
93+
return "", false
94+
}
95+
96+
// translateSignerAddress converts a signer identity stored under sourceFamily into the
97+
// representation targetFamily expects, valid only when both families sign with the same
98+
// underlying secp256k1 key.
99+
//
100+
// Address-class families (evm, solana) store the same 20 bytes and translate in both
101+
// directions. Raw-pubkey-class families (aptos, stellar, canton) store the same
102+
// hex-encoded public key and translate in both directions. Deriving an address-class
103+
// value from a raw public key is one-directional: an address is a keccak256 hash of the
104+
// public key, so recovering the public key from an address is not possible — that
105+
// direction returns an error instead of a wrong answer.
106+
func translateSignerAddress(sourceFamily, value, targetFamily string) (string, error) {
107+
switch {
108+
case addressClassFamilies[sourceFamily] && addressClassFamilies[targetFamily]:
109+
addrBytes, err := decodeAddressHex(value)
110+
if err != nil {
111+
return "", fmt.Errorf("decode %s signer address: %w", sourceFamily, err)
112+
}
113+
return formatAddressForFamily(addrBytes, targetFamily)
114+
case rawPubKeyClassFamilies[sourceFamily] && rawPubKeyClassFamilies[targetFamily]:
115+
return strings.ToLower(strings.TrimPrefix(value, "0x")), nil
116+
case rawPubKeyClassFamilies[sourceFamily] && addressClassFamilies[targetFamily]:
117+
pubKeyBytes, err := hex.DecodeString(strings.TrimPrefix(strings.ToLower(value), "0x"))
118+
if err != nil {
119+
return "", fmt.Errorf("decode %s raw public key: %w", sourceFamily, err)
120+
}
121+
pubKey, err := gethcrypto.UnmarshalPubkey(pubKeyBytes)
122+
if err != nil {
123+
return "", fmt.Errorf("unmarshal %s public key: %w", sourceFamily, err)
124+
}
125+
return formatAddressForFamily(gethcrypto.PubkeyToAddress(*pubKey).Bytes(), targetFamily)
126+
case addressClassFamilies[sourceFamily] && rawPubKeyClassFamilies[targetFamily]:
127+
return "", fmt.Errorf(
128+
"cannot derive a %s raw public key from a %s address: address derivation (keccak256) is not "+
129+
"reversible; register the node's raw public key for family %s directly",
130+
targetFamily, sourceFamily, targetFamily)
131+
default:
132+
return "", fmt.Errorf("no signer address translation defined from family %s to family %s", sourceFamily, targetFamily)
133+
}
134+
}
135+
136+
// decodeAddressHex parses a 20-byte address from hex, tolerating an optional 0x prefix
137+
// and either case.
138+
func decodeAddressHex(s string) ([]byte, error) {
139+
s = strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X")
140+
b, err := hex.DecodeString(s)
141+
if err != nil {
142+
return nil, err
143+
}
144+
if len(b) != 20 {
145+
return nil, fmt.Errorf("expected 20-byte address, got %d bytes", len(b))
146+
}
147+
return b, nil
148+
}
149+
150+
// formatAddressForFamily renders a 20-byte address per family convention: EIP-55
151+
// checksummed with 0x for evm, lowercase without 0x for solana.
152+
func formatAddressForFamily(addr []byte, family string) (string, error) {
153+
switch family {
154+
case chainsel.FamilyEVM:
155+
return gethcommon.BytesToAddress(addr).Hex(), nil
156+
case chainsel.FamilySolana:
157+
return strings.ToLower(hex.EncodeToString(addr)), nil
158+
default:
159+
return "", fmt.Errorf("unsupported address-class family %q", family)
160+
}
161+
}

deployment/operations/fetch_signing_keys/fetch_signing_keys.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package fetch_signing_keys
22

33
import (
44
"fmt"
5+
"strings"
56

67
"github.com/Masterminds/semver/v3"
78

@@ -21,6 +22,12 @@ type FetchSigningKeysInput struct {
2122

2223
type FetchSigningKeysOutput struct {
2324
SigningKeysByNOP SigningKeysByNOP
25+
// RawPubKeyByNOP maps NOP alias -> raw uncompressed secp256k1 public key (hex, no
26+
// 0x prefix, lowercased). A standalone node registers one signing key across every
27+
// chain it declares, so this lets callers derive a signer address for a family the
28+
// NOP never declared directly (e.g. a Canton verifier's EVM address), rather than
29+
// only translating between families it already has an OnchainSigningAddress for.
30+
RawPubKeyByNOP map[string]string
2431
}
2532

2633
type FetchSigningKeysDeps struct {
@@ -39,6 +46,7 @@ var FetchNOPSigningKeys = operations.NewOperation(
3946

4047
output := FetchSigningKeysOutput{
4148
SigningKeysByNOP: make(SigningKeysByNOP),
49+
RawPubKeyByNOP: make(map[string]string),
4250
}
4351

4452
if len(input.NOPAliases) == 0 {
@@ -106,6 +114,16 @@ var FetchNOPSigningKeys = operations.NewOperation(
106114
}
107115
output.SigningKeysByNOP[nopAlias][chainFamily] = addr
108116

117+
// Capture the raw public key too, so callers can bridge this NOP's identity
118+
// into a family it never declared directly (see RawPubKeyByNOP).
119+
if rawPubKey := chainConfig.Ocr2Config.OcrKeyBundle.OnchainSigningPubKey; rawPubKey != "" {
120+
normalized := strings.ToLower(strings.TrimPrefix(rawPubKey, "0x"))
121+
if existing, ok := output.RawPubKeyByNOP[nopAlias]; ok && existing != normalized {
122+
return output, fmt.Errorf("NOP %q has conflicting raw public keys across chain configs: %s vs %s — a standalone node registers one key for every chain it declares", nopAlias, existing, normalized)
123+
}
124+
output.RawPubKeyByNOP[nopAlias] = normalized
125+
}
126+
109127
lggr.Debugw("Found signing address",
110128
"nopAlias", nopAlias,
111129
"nodeId", chainConfig.NodeId,

0 commit comments

Comments
 (0)