From 96067a1386859901b53e4967d506560137bae858 Mon Sep 17 00:00:00 2001 From: FelixFan1992 Date: Tue, 5 May 2026 10:59:35 -0400 Subject: [PATCH 1/9] NONEVM-4285: implement stellar encoder --- go.mod | 2 + go.sum | 8 +- sdk/stellar/address.go | 57 ++++++++++++++ sdk/stellar/chain.go | 31 ++++++++ sdk/stellar/constants.go | 17 +++++ sdk/stellar/encode.go | 119 +++++++++++++++++++++++++++++ sdk/stellar/encode_test.go | 126 +++++++++++++++++++++++++++++++ sdk/stellar/encoder.go | 144 ++++++++++++++++++++++++++++++++++++ sdk/stellar/encoder_test.go | 93 +++++++++++++++++++++++ types/chain_selector.go | 1 + 10 files changed, 596 insertions(+), 2 deletions(-) create mode 100644 sdk/stellar/address.go create mode 100644 sdk/stellar/chain.go create mode 100644 sdk/stellar/constants.go create mode 100644 sdk/stellar/encode.go create mode 100644 sdk/stellar/encode_test.go create mode 100644 sdk/stellar/encoder.go create mode 100644 sdk/stellar/encoder_test.go diff --git a/go.mod b/go.mod index f634f7c9..dedb0c40 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/smartcontractkit/chainlink-ton v0.0.0-20260219201907-054376f21418 github.com/smartcontractkit/freeport v0.1.3-0.20250828155247-add56fa28aad github.com/spf13/cast v1.10.0 + github.com/stellar/go v0.0.0-20251210100531-aab2ea4aca88 github.com/stretchr/testify v1.11.1 github.com/xssnick/tonutils-go v1.14.1 github.com/zksync-sdk/zksync2-go v1.1.1-0.20250620124214-2c742ee399c6 @@ -221,6 +222,7 @@ require ( github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 // indirect github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/stellar/go-xdr v0.0.0-20260312225820-cc2b0611aabf // indirect github.com/stephenlacy/go-ethereum-hdwallet v0.0.0-20230913225845-a4fa94429863 // indirect github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect github.com/stretchr/objx v0.5.2 // indirect diff --git a/go.sum b/go.sum index 83480e94..b7ac01a6 100644 --- a/go.sum +++ b/go.sum @@ -227,8 +227,8 @@ github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BN github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-snaps v0.5.19 h1:hUJlCQOpTt1M+kSisMwioDWZDWpDtdAvUhvWCx1YGW0= github.com/gkampitakis/go-snaps v0.5.19/go.mod h1:gC3YqxQTPyIXvQrw/Vpt3a8VqR1MO8sVpZFWN4DGwNs= -github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= -github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY= github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -708,6 +708,10 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stellar/go v0.0.0-20251210100531-aab2ea4aca88 h1:T7CDnX+NSQlu9pxLlxZN0qt6SeUoQ6lxwZjY+Y9Ky54= +github.com/stellar/go v0.0.0-20251210100531-aab2ea4aca88/go.mod h1:pcoYvfcsyFzzSut3RBWF9Ts8g4Z7SWbkb8Hitu7k4BU= +github.com/stellar/go-xdr v0.0.0-20260312225820-cc2b0611aabf h1:GY1RVbX3Hg7poPXEf6yojjP0hyypvgUgZmCqQU9D0xg= +github.com/stellar/go-xdr v0.0.0-20260312225820-cc2b0611aabf/go.mod h1:If+U9Z1W5xU97VrOgJandQT+2dN7/iOpkCrxBJEyF80= github.com/stephenlacy/go-ethereum-hdwallet v0.0.0-20230913225845-a4fa94429863 h1:ba4VRWSkRzgdP5hB5OxexIzBXZbSwgcw8bEu06ivGQI= github.com/stephenlacy/go-ethereum-hdwallet v0.0.0-20230913225845-a4fa94429863/go.mod h1:oPTjPNrRucLv9mU27iNPj6n0CWWcNFhoXFOLVGJwHCA= github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo= diff --git a/sdk/stellar/address.go b/sdk/stellar/address.go new file mode 100644 index 00000000..74d69152 --- /dev/null +++ b/sdk/stellar/address.go @@ -0,0 +1,57 @@ +package stellar + +import ( + "encoding/hex" + "fmt" + "strings" + + "github.com/stellar/go/strkey" +) + +// ParseContractID parses a Stellar contract identifier as either: +// - A contract strkey (base32, typically starting with 'C'), or +// - 64 hex characters (optional 0x prefix) representing the raw 32-byte contract id. +func ParseContractID(s string) ([32]byte, error) { + s = strings.TrimSpace(s) + if s == "" { + return [32]byte{}, fmt.Errorf("empty contract id") + } + + if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { + s = s[2:] + } + + if len(s) == 64 && isHex(s) { + raw, err := hex.DecodeString(s) + if err != nil { + return [32]byte{}, fmt.Errorf("decode hex contract id: %w", err) + } + var out [32]byte + copy(out[:], raw) + return out, nil + } + + raw, err := strkey.Decode(strkey.VersionByteContract, s) + if err != nil { + return [32]byte{}, fmt.Errorf("decode contract strkey: %w", err) + } + if len(raw) != 32 { + return [32]byte{}, fmt.Errorf("contract id must be 32 bytes, got %d", len(raw)) + } + var out [32]byte + copy(out[:], raw) + return out, nil +} + +func isHex(s string) bool { + for _, c := range s { + switch { + case c >= '0' && c <= '9': + case c >= 'a' && c <= 'f': + case c >= 'A' && c <= 'F': + default: + return false + } + } + return true +} diff --git a/sdk/stellar/chain.go b/sdk/stellar/chain.go new file mode 100644 index 00000000..10dbc5a4 --- /dev/null +++ b/sdk/stellar/chain.go @@ -0,0 +1,31 @@ +package stellar + +import ( + "encoding/hex" + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/common" + + chainsel "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/mcms/types" +) + +// ChainNetworkID returns the 32-byte Stellar network id (SHA-256 of passphrase, hex-encoded in chain-selectors) +// for the given MCMS chain selector. +func ChainNetworkID(sel types.ChainSelector) (common.Hash, error) { + chainIDHex, err := chainsel.StellarChainIdFromSelector(uint64(sel)) + if err != nil { + return common.Hash{}, fmt.Errorf("stellar chain id for selector %d: %w", sel, err) + } + chainIDHex = strings.TrimPrefix(strings.TrimPrefix(chainIDHex, "0x"), "0X") + if len(chainIDHex) != 64 { + return common.Hash{}, fmt.Errorf("unexpected stellar chain id length %d (want 64 hex chars)", len(chainIDHex)) + } + raw, err := hex.DecodeString(chainIDHex) + if err != nil { + return common.Hash{}, fmt.Errorf("decode stellar chain id hex: %w", err) + } + return common.BytesToHash(raw), nil +} diff --git a/sdk/stellar/constants.go b/sdk/stellar/constants.go new file mode 100644 index 00000000..480d49eb --- /dev/null +++ b/sdk/stellar/constants.go @@ -0,0 +1,17 @@ +package stellar + +// Domain separators — must match chainlink-stellar contracts/mcms/src/constants.rs +// (keccak256 of the ASCII strings below). + +var ( + // domainOpStellar = keccak256("MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_OP_STELLAR") + domainOpStellar = [32]byte{ + 0x12, 0xcd, 0xc8, 0x8e, 0x33, 0xb5, 0x9a, 0x3a, 0x5a, 0x9f, 0xe3, 0x07, 0x2e, 0x0b, 0xab, 0x63, + 0xee, 0x3d, 0xb8, 0x88, 0xaf, 0x2c, 0xdb, 0x10, 0xbc, 0x93, 0x34, 0x56, 0x88, 0x05, 0x8d, 0x16, + } + // domainMetaStellar = keccak256("MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_METADATA_STELLAR") + domainMetaStellar = [32]byte{ + 0xde, 0x51, 0xf2, 0xd6, 0x7b, 0xb4, 0x89, 0x5d, 0x0d, 0xd1, 0xf3, 0x6a, 0xdb, 0x04, 0x42, 0x27, + 0xaa, 0x7b, 0x76, 0x4d, 0x4e, 0x52, 0x4d, 0x6b, 0x0d, 0x70, 0x04, 0x72, 0x27, 0x28, 0xfd, 0xa0, + } +) diff --git a/sdk/stellar/encode.go b/sdk/stellar/encode.go new file mode 100644 index 00000000..a56485c1 --- /dev/null +++ b/sdk/stellar/encode.go @@ -0,0 +1,119 @@ +package stellar + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +var ErrUint40Overflow = errors.New("value exceeds uint40 (2^40-1)") + +func appendWord32(buf *[]byte, word [32]byte) { + *buf = append(*buf, word[:]...) +} + +func appendUint256FromBytes(buf *[]byte, word [32]byte) { + appendWord32(buf, word) +} + +func appendUint40(buf *[]byte, v uint64) error { + if v >= (1 << 40) { + return fmt.Errorf("%w: %d", ErrUint40Overflow, v) + } + var w [32]byte + be := make([]byte, 8) + be[0] = byte(v >> 56) + be[1] = byte(v >> 48) + be[2] = byte(v >> 40) + be[3] = byte(v >> 32) + be[4] = byte(v >> 24) + be[5] = byte(v >> 16) + be[6] = byte(v >> 8) + be[7] = byte(v) + copy(w[27:32], be[3:8]) + appendWord32(buf, w) + return nil +} + +func appendBool(buf *[]byte, v bool) { + var w [32]byte + if v { + w[31] = 1 + } + appendWord32(buf, w) +} + +// appendABIBytes implements Solidity ABI encoding for `bytes`: length word + payload + right pad. +func appendABIBytes(buf *[]byte, data []byte) { + n := uint64(len(data)) + var lenWord [32]byte + lb := make([]byte, 8) + lb[0] = byte(n >> 56) + lb[1] = byte(n >> 48) + lb[2] = byte(n >> 40) + lb[3] = byte(n >> 32) + lb[4] = byte(n >> 24) + lb[5] = byte(n >> 16) + lb[6] = byte(n >> 8) + lb[7] = byte(n) + copy(lenWord[24:32], lb) + appendWord32(buf, lenWord) + *buf = append(*buf, data...) + pad := (32 - (len(data) % 32)) % 32 + for i := 0; i < pad; i++ { + *buf = append(*buf, 0) + } +} + +// HashRootMetadata is keccak256(abi.encode(domain, StellarRootMetadata)) matching +// contracts/mcms/src/abi_encoding.rs hash_root_metadata. +func HashRootMetadata( + domain [32]byte, + chainID [32]byte, + multisig [32]byte, + preOpCount, postOpCount uint64, + overridePreviousRoot bool, +) (common.Hash, error) { + var buf []byte + appendWord32(&buf, domain) + appendUint256FromBytes(&buf, chainID) + appendUint256FromBytes(&buf, multisig) + if err := appendUint40(&buf, preOpCount); err != nil { + return common.Hash{}, err + } + if err := appendUint40(&buf, postOpCount); err != nil { + return common.Hash{}, err + } + appendBool(&buf, overridePreviousRoot) + return crypto.Keccak256Hash(buf), nil +} + +// HashStellarOp is keccak256(abi.encode(domain, StellarOp)) matching +// contracts/mcms/src/abi_encoding.rs hash_stellar_op. +func HashStellarOp( + domain [32]byte, + chainID [32]byte, + multisig [32]byte, + nonce uint64, + to [32]byte, + value [32]byte, + data []byte, +) (common.Hash, error) { + var buf []byte + appendWord32(&buf, domain) + appendUint256FromBytes(&buf, chainID) + appendUint256FromBytes(&buf, multisig) + if err := appendUint40(&buf, nonce); err != nil { + return common.Hash{}, err + } + appendUint256FromBytes(&buf, to) + appendUint256FromBytes(&buf, value) + // offset of dynamic `data` from start of inner tuple = 6 * 32 = 192 + var off [32]byte + off[31] = 192 + appendWord32(&buf, off) + appendABIBytes(&buf, data) + return crypto.Keccak256Hash(buf), nil +} diff --git a/sdk/stellar/encode_test.go b/sdk/stellar/encode_test.go new file mode 100644 index 00000000..a59604fa --- /dev/null +++ b/sdk/stellar/encode_test.go @@ -0,0 +1,126 @@ +package stellar + +import ( + "encoding/binary" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/mcms/internal/utils/abi" +) + +func TestDomainConstantsMatchKeccak256Literals(t *testing.T) { + t.Parallel() + require.Equal(t, + crypto.Keccak256Hash([]byte("MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_OP_STELLAR")), + common.Hash(domainOpStellar)) + require.Equal(t, + crypto.Keccak256Hash([]byte("MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_METADATA_STELLAR")), + common.Hash(domainMetaStellar)) +} + +func TestHashRootMetadataMatchesABIEncoder(t *testing.T) { + t.Parallel() + domain := domainMetaStellar + chainID := hashBytes(t, "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20") + multisig := hashBytes(t, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + pre := uint64(7) + post := uint64(9) + override := true + + got, err := HashRootMetadata(domain, chainID, multisig, pre, post, override) + require.NoError(t, err) + + metaABI := `[{"type":"bytes32"},{"type":"tuple","components":[ +{"name":"chainId","type":"uint256"}, +{"name":"multisig","type":"uint256"}, +{"name":"preOpCount","type":"uint40"}, +{"name":"postOpCount","type":"uint40"}, +{"name":"overridePreviousRoot","type":"bool"} +]}]` + metaTuple := struct { + ChainID *big.Int `abi:"chainId"` + Multisig *big.Int `abi:"multisig"` + PreOpCount *big.Int `abi:"preOpCount"` + PostOpCount *big.Int `abi:"postOpCount"` + OverridePreviousRoot bool `abi:"overridePreviousRoot"` + }{ + ChainID: new(big.Int).SetBytes(chainID[:]), + Multisig: new(big.Int).SetBytes(multisig[:]), + PreOpCount: big.NewInt(int64(pre)), + PostOpCount: big.NewInt(int64(post)), + OverridePreviousRoot: override, + } + encoded, err := abi.Encode(metaABI, common.Hash(domain), metaTuple) + require.NoError(t, err) + want := crypto.Keccak256Hash(encoded) + require.Equal(t, want, got) +} + +func TestHashStellarOpGoldenVector(t *testing.T) { + t.Parallel() + domain := domainOpStellar + chainID := hashBytes(t, "1111111111111111111111111111111111111111111111111111111111111111") + multisig := hashBytes(t, "2222222222222222222222222222222222222222222222222222222222222222") + nonce := uint64(42) + to := hashBytes(t, "3333333333333333333333333333333333333333333333333333333333333333") + value := hashBytes(t, "4444444444444444444444444444444444444444444444444444444444444444") + data := []byte{1, 2, 3, 4, 5, 6, 7} + + got, err := HashStellarOp(domain, chainID, multisig, nonce, to, value, data) + require.NoError(t, err) + + // Golden: keccak256 preimage must match Soroban contracts/mcms/src/abi_encoding.rs (layout is + // domain || head fields || offset 192 || abi.bytes(data); not Solidity abi.encode(bytes32,tuple), + // which inserts an extra dynamic offset after domain). + want := common.HexToHash("0x6b0c3185f2fdaa391319dd36722b18b6d8d7566c4afaf70aaff50e18557f126b") + require.Equal(t, want, got) + + var buf []byte + appendWord32(&buf, domain) + appendUint256FromBytes(&buf, chainID) + appendUint256FromBytes(&buf, multisig) + require.NoError(t, appendUint40(&buf, nonce)) + appendUint256FromBytes(&buf, to) + appendUint256FromBytes(&buf, value) + var off [32]byte + off[31] = 192 + appendWord32(&buf, off) + appendABIBytes(&buf, data) + require.Equal(t, want, crypto.Keccak256Hash(buf), "manual preimage matches HashStellarOp") +} + +func TestHashRootMetadataUint40Overflow(t *testing.T) { + t.Parallel() + bad := uint64(1 << 40) + _, err := HashRootMetadata(domainMetaStellar, [32]byte{}, [32]byte{}, 0, bad, false) + require.ErrorIs(t, err, ErrUint40Overflow) +} + +func TestHashStellarOpUint40Overflow(t *testing.T) { + t.Parallel() + _, err := HashStellarOp(domainOpStellar, [32]byte{}, [32]byte{}, 1<<40, [32]byte{}, [32]byte{}, nil) + require.ErrorIs(t, err, ErrUint40Overflow) +} + +// Golden vectors from chainlink-stellar contracts/mcms/src/abi_encoding.rs tests. +func TestHashSetRootInnerGoldenVector(t *testing.T) { + t.Parallel() + root := [32]byte{} + var buf []byte + appendWord32(&buf, root) + var vu [32]byte + binary.BigEndian.PutUint32(vu[28:32], 0) + appendWord32(&buf, vu) + want := common.HexToHash("0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5") + require.Equal(t, want, crypto.Keccak256Hash(buf)) +} + +func hashBytes(t *testing.T, hexNoPrefix string) [32]byte { + t.Helper() + h := common.HexToHash("0x" + hexNoPrefix) + return h +} diff --git a/sdk/stellar/encoder.go b/sdk/stellar/encoder.go new file mode 100644 index 00000000..f18c831b --- /dev/null +++ b/sdk/stellar/encoder.go @@ -0,0 +1,144 @@ +package stellar + +import ( + "encoding/json" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +var _ sdk.Encoder = (*Encoder)(nil) + +// Encoder implements sdk.Encoder for the Soroban MCMS contract (Stellar), matching +// chainlink-stellar contracts/mcms ABI leaf hashing. +type Encoder struct { + ChainSelector types.ChainSelector + TxCount uint64 + OverridePreviousRoot bool +} + +// NewEncoder returns a new Stellar MCMS encoder. +func NewEncoder(chainSelector types.ChainSelector, txCount uint64, overridePreviousRoot bool) *Encoder { + return &Encoder{ + ChainSelector: chainSelector, + TxCount: txCount, + OverridePreviousRoot: overridePreviousRoot, + } +} + +// HashOperation implements sdk.Encoder. +func (e *Encoder) HashOperation( + opCount uint32, + metadata types.ChainMetadata, + op types.Operation, +) (common.Hash, error) { + if uint64(opCount) >= (1 << 40) { + return common.Hash{}, fmt.Errorf("%w: opCount %d", ErrUint40Overflow, opCount) + } + + chainID, err := ChainNetworkID(e.ChainSelector) + if err != nil { + return common.Hash{}, err + } + + multisig, err := ParseContractID(metadata.MCMAddress) + if err != nil { + return common.Hash{}, fmt.Errorf("mcmAddress: %w", err) + } + + to, err := ParseContractID(op.Transaction.To) + if err != nil { + return common.Hash{}, fmt.Errorf("transaction.to: %w", err) + } + + valueWord, err := parseValueWord(op.Transaction.AdditionalFields) + if err != nil { + return common.Hash{}, err + } + + h, err := HashStellarOp( + domainOpStellar, + chainID, + multisig, + uint64(opCount), + to, + valueWord, + op.Transaction.Data, + ) + if err != nil { + return common.Hash{}, err + } + return h, nil +} + +// HashMetadata implements sdk.Encoder. +func (e *Encoder) HashMetadata(metadata types.ChainMetadata) (common.Hash, error) { + if metadata.StartingOpCount >= (1 << 40) { + return common.Hash{}, fmt.Errorf("%w: startingOpCount %d", ErrUint40Overflow, metadata.StartingOpCount) + } + post := metadata.StartingOpCount + e.TxCount + if post >= (1 << 40) { + return common.Hash{}, fmt.Errorf("%w: postOpCount (starting+txCount) %d", ErrUint40Overflow, post) + } + + chainID, err := ChainNetworkID(e.ChainSelector) + if err != nil { + return common.Hash{}, err + } + + multisig, err := ParseContractID(metadata.MCMAddress) + if err != nil { + return common.Hash{}, fmt.Errorf("mcmAddress: %w", err) + } + + return HashRootMetadata( + domainMetaStellar, + chainID, + multisig, + metadata.StartingOpCount, + post, + e.OverridePreviousRoot, + ) +} + +// parseValueWord reads optional transaction.additionalFields JSON for StellarOp.value (uint256). +// V1 on-chain requires zero; omit additionalFields or use "{}" unless supplying non-zero value as hex. +func parseValueWord(raw json.RawMessage) ([32]byte, error) { + var zero [32]byte + if len(raw) == 0 { + return zero, nil + } + + var af struct { + Value *string `json:"value,omitempty"` + } + if err := json.Unmarshal(raw, &af); err != nil { + return zero, fmt.Errorf("unmarshal stellar additionalFields: %w", err) + } + if af.Value == nil || *af.Value == "" { + return zero, nil + } + + s := *af.Value + if len(s) >= 2 && (s[0:2] == "0x" || s[0:2] == "0X") { + s = s[2:] + } + if len(s) != 64 { + return zero, fmt.Errorf("value must be 32-byte hex (64 chars), got length %d", len(s)) + } + n := new(big.Int) + _, ok := n.SetString(s, 16) + if !ok { + return zero, fmt.Errorf("invalid value hex") + } + if n.Sign() < 0 || n.BitLen() > 256 { + return zero, fmt.Errorf("value out of uint256 range") + } + var out [32]byte + n.FillBytes(out[:]) + return out, nil +} diff --git a/sdk/stellar/encoder_test.go b/sdk/stellar/encoder_test.go new file mode 100644 index 00000000..ff29989e --- /dev/null +++ b/sdk/stellar/encoder_test.go @@ -0,0 +1,93 @@ +package stellar + +import ( + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/mcms/types" +) + +// stellar-testnet selector from chain-selectors selectors_stellar.yml +const stellarTestnetSelector types.ChainSelector = 4894814558906953166 + +func TestEncoder_HashMetadataAndOperation(t *testing.T) { + t.Parallel() + enc := NewEncoder(stellarTestnetSelector, 1, false) + + chainNet, err := ChainNetworkID(stellarTestnetSelector) + require.NoError(t, err) + + mcm := "cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472" + require.Equal(t, chainNet.Hex()[2:], mcm, "sanity: selector maps to expected network id hex") + + metaAddr := "00000000000000000000000000000000000000000000000000000000000000aa" + toAddr := "00000000000000000000000000000000000000000000000000000000000000bb" + + metaHashManual, err := HashRootMetadata( + domainMetaStellar, + chainNet, + hashBytes(t, metaAddr), + 0, + 1, + false, + ) + require.NoError(t, err) + + md := types.ChainMetadata{ + StartingOpCount: 0, + MCMAddress: "0x" + metaAddr, + AdditionalFields: nil, + } + metaHashEnc, err := enc.HashMetadata(md) + require.NoError(t, err) + require.Equal(t, metaHashManual, metaHashEnc) + + op := types.Operation{ + ChainSelector: stellarTestnetSelector, + Transaction: types.Transaction{ + To: "0x" + toAddr, + Data: []byte{0xde, 0xad}, + AdditionalFields: nil, + }, + } + opHashManual, err := HashStellarOp( + domainOpStellar, + chainNet, + hashBytes(t, metaAddr), + 0, + hashBytes(t, toAddr), + [32]byte{}, + op.Transaction.Data, + ) + require.NoError(t, err) + + opHashEnc, err := enc.HashOperation(0, md, op) + require.NoError(t, err) + require.Equal(t, opHashManual, opHashEnc) +} + +func TestParseContractID_Strkey(t *testing.T) { + t.Parallel() + // Vector from github.com/stellar/go/strkey decode_test ("Contract" case). + const sample = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA" + want := common.HexToHash("0x3f0c34bf93ad0d9971d04ccc90f705511c838aad9734a4a2fb0d7a03fc7fe89a") + got, err := ParseContractID(sample) + require.NoError(t, err) + require.Equal(t, want, common.Hash(got)) + round, err := ParseContractID("0x" + common.Bytes2Hex(got[:])) + require.NoError(t, err) + require.Equal(t, got, round) +} + +func TestEncoder_PostOpCountOverflow(t *testing.T) { + t.Parallel() + enc := NewEncoder(stellarTestnetSelector, 1<<40, false) + _, err := enc.HashMetadata(types.ChainMetadata{ + StartingOpCount: 0, + MCMAddress: "0x" + strings.Repeat("00", 32), + }) + require.ErrorIs(t, err, ErrUint40Overflow) +} diff --git a/types/chain_selector.go b/types/chain_selector.go index 56f652f1..0851e635 100644 --- a/types/chain_selector.go +++ b/types/chain_selector.go @@ -31,6 +31,7 @@ var supportedFamilies = []string{ chainsel.FamilyAptos, chainsel.FamilySui, chainsel.FamilyTon, + chainsel.FamilyStellar, } // GetChainSelectorFamily returns the family of the chain selector. From cadbac7340dea2989f8d9bf1beb2f855771bbcf3 Mon Sep 17 00:00:00 2001 From: FelixFan1992 Date: Tue, 5 May 2026 11:14:26 -0400 Subject: [PATCH 2/9] lint and changeset --- .changeset/free-peaches-itch.md | 5 +++++ sdk/stellar/address.go | 7 ++++-- sdk/stellar/chain.go | 3 ++- sdk/stellar/constants.go | 27 +++++++++++++++++++++++ sdk/stellar/encode.go | 38 +++++++++++---------------------- sdk/stellar/encode_test.go | 9 ++++---- sdk/stellar/encoder.go | 18 +++++++++------- sdk/stellar/encoder_test.go | 2 +- 8 files changed, 68 insertions(+), 41 deletions(-) create mode 100644 .changeset/free-peaches-itch.md diff --git a/.changeset/free-peaches-itch.md b/.changeset/free-peaches-itch.md new file mode 100644 index 00000000..632c7a71 --- /dev/null +++ b/.changeset/free-peaches-itch.md @@ -0,0 +1,5 @@ +--- +"@smartcontractkit/mcms": patch +--- + +add stellar encoder support diff --git a/sdk/stellar/address.go b/sdk/stellar/address.go index 74d69152..ddf7f1ba 100644 --- a/sdk/stellar/address.go +++ b/sdk/stellar/address.go @@ -21,13 +21,14 @@ func ParseContractID(s string) ([32]byte, error) { s = s[2:] } - if len(s) == 64 && isHex(s) { + if len(s) == stellarChainHexCharLen && isHex(s) { raw, err := hex.DecodeString(s) if err != nil { return [32]byte{}, fmt.Errorf("decode hex contract id: %w", err) } var out [32]byte copy(out[:], raw) + return out, nil } @@ -35,11 +36,12 @@ func ParseContractID(s string) ([32]byte, error) { if err != nil { return [32]byte{}, fmt.Errorf("decode contract strkey: %w", err) } - if len(raw) != 32 { + if len(raw) != stellarContractIDBytes { return [32]byte{}, fmt.Errorf("contract id must be 32 bytes, got %d", len(raw)) } var out [32]byte copy(out[:], raw) + return out, nil } @@ -53,5 +55,6 @@ func isHex(s string) bool { return false } } + return true } diff --git a/sdk/stellar/chain.go b/sdk/stellar/chain.go index 10dbc5a4..530ab083 100644 --- a/sdk/stellar/chain.go +++ b/sdk/stellar/chain.go @@ -20,12 +20,13 @@ func ChainNetworkID(sel types.ChainSelector) (common.Hash, error) { return common.Hash{}, fmt.Errorf("stellar chain id for selector %d: %w", sel, err) } chainIDHex = strings.TrimPrefix(strings.TrimPrefix(chainIDHex, "0x"), "0X") - if len(chainIDHex) != 64 { + if len(chainIDHex) != stellarChainHexCharLen { return common.Hash{}, fmt.Errorf("unexpected stellar chain id length %d (want 64 hex chars)", len(chainIDHex)) } raw, err := hex.DecodeString(chainIDHex) if err != nil { return common.Hash{}, fmt.Errorf("decode stellar chain id hex: %w", err) } + return common.BytesToHash(raw), nil } diff --git a/sdk/stellar/constants.go b/sdk/stellar/constants.go index 480d49eb..7b74ab32 100644 --- a/sdk/stellar/constants.go +++ b/sdk/stellar/constants.go @@ -1,5 +1,32 @@ package stellar +// MCMS-Stellar numeric constants (ABI packing; see chainlink-stellar contracts/mcms/src/abi_encoding.rs). +const ( + abiWordBytes = 32 + + uint40BitWidth = 40 + uint40MaxExclusive = uint64(1) << uint40BitWidth + // Low 40 bits of a uint64 in big-endian occupy the last 5 bytes. + uint40TailBytes = 5 + uint64ByteLen = 8 + + stellarContractIDBytes = abiWordBytes + // Network / contract ids are 32-byte hashes; hex form without 0x is 64 characters. + stellarChainHexCharLen = stellarContractIDBytes * 2 + + stellarOpStaticWordCount = 6 + // Byte offset of dynamic `data` from start of StellarOp head (6 words × 32 bytes). + stellarOpDataABIByteOffset = stellarOpStaticWordCount * abiWordBytes + + hexRadix = 16 + // Bits representable in StellarOp.value / metadata words as uint256. + uint256BitWidth = 256 + + hexPrefixLen = 2 // "0x" / "0X" + + uint32ByteLen = 4 +) + // Domain separators — must match chainlink-stellar contracts/mcms/src/constants.rs // (keccak256 of the ASCII strings below). diff --git a/sdk/stellar/encode.go b/sdk/stellar/encode.go index a56485c1..bc8b928f 100644 --- a/sdk/stellar/encode.go +++ b/sdk/stellar/encode.go @@ -1,6 +1,7 @@ package stellar import ( + "encoding/binary" "errors" "fmt" @@ -19,28 +20,22 @@ func appendUint256FromBytes(buf *[]byte, word [32]byte) { } func appendUint40(buf *[]byte, v uint64) error { - if v >= (1 << 40) { + if v >= uint40MaxExclusive { return fmt.Errorf("%w: %d", ErrUint40Overflow, v) } var w [32]byte - be := make([]byte, 8) - be[0] = byte(v >> 56) - be[1] = byte(v >> 48) - be[2] = byte(v >> 40) - be[3] = byte(v >> 32) - be[4] = byte(v >> 24) - be[5] = byte(v >> 16) - be[6] = byte(v >> 8) - be[7] = byte(v) - copy(w[27:32], be[3:8]) + var be [uint64ByteLen]byte + binary.BigEndian.PutUint64(be[:], v) + copy(w[abiWordBytes-uint40TailBytes:], be[uint64ByteLen-uint40TailBytes:]) appendWord32(buf, w) + return nil } func appendBool(buf *[]byte, v bool) { var w [32]byte if v { - w[31] = 1 + w[abiWordBytes-1] = 1 } appendWord32(buf, w) } @@ -49,20 +44,11 @@ func appendBool(buf *[]byte, v bool) { func appendABIBytes(buf *[]byte, data []byte) { n := uint64(len(data)) var lenWord [32]byte - lb := make([]byte, 8) - lb[0] = byte(n >> 56) - lb[1] = byte(n >> 48) - lb[2] = byte(n >> 40) - lb[3] = byte(n >> 32) - lb[4] = byte(n >> 24) - lb[5] = byte(n >> 16) - lb[6] = byte(n >> 8) - lb[7] = byte(n) - copy(lenWord[24:32], lb) + binary.BigEndian.PutUint64(lenWord[abiWordBytes-uint64ByteLen:], n) appendWord32(buf, lenWord) *buf = append(*buf, data...) - pad := (32 - (len(data) % 32)) % 32 - for i := 0; i < pad; i++ { + pad := (abiWordBytes - (len(data) % abiWordBytes)) % abiWordBytes + for range pad { *buf = append(*buf, 0) } } @@ -87,6 +73,7 @@ func HashRootMetadata( return common.Hash{}, err } appendBool(&buf, overridePreviousRoot) + return crypto.Keccak256Hash(buf), nil } @@ -112,8 +99,9 @@ func HashStellarOp( appendUint256FromBytes(&buf, value) // offset of dynamic `data` from start of inner tuple = 6 * 32 = 192 var off [32]byte - off[31] = 192 + binary.BigEndian.PutUint64(off[abiWordBytes-uint64ByteLen:], stellarOpDataABIByteOffset) appendWord32(&buf, off) appendABIBytes(&buf, data) + return crypto.Keccak256Hash(buf), nil } diff --git a/sdk/stellar/encode_test.go b/sdk/stellar/encode_test.go index a59604fa..c3ac8125 100644 --- a/sdk/stellar/encode_test.go +++ b/sdk/stellar/encode_test.go @@ -87,7 +87,7 @@ func TestHashStellarOpGoldenVector(t *testing.T) { appendUint256FromBytes(&buf, to) appendUint256FromBytes(&buf, value) var off [32]byte - off[31] = 192 + binary.BigEndian.PutUint64(off[abiWordBytes-uint64ByteLen:], stellarOpDataABIByteOffset) appendWord32(&buf, off) appendABIBytes(&buf, data) require.Equal(t, want, crypto.Keccak256Hash(buf), "manual preimage matches HashStellarOp") @@ -95,14 +95,14 @@ func TestHashStellarOpGoldenVector(t *testing.T) { func TestHashRootMetadataUint40Overflow(t *testing.T) { t.Parallel() - bad := uint64(1 << 40) + bad := uint40MaxExclusive _, err := HashRootMetadata(domainMetaStellar, [32]byte{}, [32]byte{}, 0, bad, false) require.ErrorIs(t, err, ErrUint40Overflow) } func TestHashStellarOpUint40Overflow(t *testing.T) { t.Parallel() - _, err := HashStellarOp(domainOpStellar, [32]byte{}, [32]byte{}, 1<<40, [32]byte{}, [32]byte{}, nil) + _, err := HashStellarOp(domainOpStellar, [32]byte{}, [32]byte{}, uint40MaxExclusive, [32]byte{}, [32]byte{}, nil) require.ErrorIs(t, err, ErrUint40Overflow) } @@ -113,7 +113,7 @@ func TestHashSetRootInnerGoldenVector(t *testing.T) { var buf []byte appendWord32(&buf, root) var vu [32]byte - binary.BigEndian.PutUint32(vu[28:32], 0) + binary.BigEndian.PutUint32(vu[abiWordBytes-uint32ByteLen:], 0) appendWord32(&buf, vu) want := common.HexToHash("0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5") require.Equal(t, want, crypto.Keccak256Hash(buf)) @@ -122,5 +122,6 @@ func TestHashSetRootInnerGoldenVector(t *testing.T) { func hashBytes(t *testing.T, hexNoPrefix string) [32]byte { t.Helper() h := common.HexToHash("0x" + hexNoPrefix) + return h } diff --git a/sdk/stellar/encoder.go b/sdk/stellar/encoder.go index f18c831b..b043267f 100644 --- a/sdk/stellar/encoder.go +++ b/sdk/stellar/encoder.go @@ -36,7 +36,7 @@ func (e *Encoder) HashOperation( metadata types.ChainMetadata, op types.Operation, ) (common.Hash, error) { - if uint64(opCount) >= (1 << 40) { + if uint64(opCount) >= uint40MaxExclusive { return common.Hash{}, fmt.Errorf("%w: opCount %d", ErrUint40Overflow, opCount) } @@ -72,16 +72,17 @@ func (e *Encoder) HashOperation( if err != nil { return common.Hash{}, err } + return h, nil } // HashMetadata implements sdk.Encoder. func (e *Encoder) HashMetadata(metadata types.ChainMetadata) (common.Hash, error) { - if metadata.StartingOpCount >= (1 << 40) { + if metadata.StartingOpCount >= uint40MaxExclusive { return common.Hash{}, fmt.Errorf("%w: startingOpCount %d", ErrUint40Overflow, metadata.StartingOpCount) } post := metadata.StartingOpCount + e.TxCount - if post >= (1 << 40) { + if post >= uint40MaxExclusive { return common.Hash{}, fmt.Errorf("%w: postOpCount (starting+txCount) %d", ErrUint40Overflow, post) } @@ -124,21 +125,22 @@ func parseValueWord(raw json.RawMessage) ([32]byte, error) { } s := *af.Value - if len(s) >= 2 && (s[0:2] == "0x" || s[0:2] == "0X") { - s = s[2:] + if len(s) >= hexPrefixLen && (s[0:hexPrefixLen] == "0x" || s[0:hexPrefixLen] == "0X") { + s = s[hexPrefixLen:] } - if len(s) != 64 { + if len(s) != stellarChainHexCharLen { return zero, fmt.Errorf("value must be 32-byte hex (64 chars), got length %d", len(s)) } n := new(big.Int) - _, ok := n.SetString(s, 16) + _, ok := n.SetString(s, hexRadix) if !ok { return zero, fmt.Errorf("invalid value hex") } - if n.Sign() < 0 || n.BitLen() > 256 { + if n.Sign() < 0 || n.BitLen() > uint256BitWidth { return zero, fmt.Errorf("value out of uint256 range") } var out [32]byte n.FillBytes(out[:]) + return out, nil } diff --git a/sdk/stellar/encoder_test.go b/sdk/stellar/encoder_test.go index ff29989e..e865cc8a 100644 --- a/sdk/stellar/encoder_test.go +++ b/sdk/stellar/encoder_test.go @@ -87,7 +87,7 @@ func TestEncoder_PostOpCountOverflow(t *testing.T) { enc := NewEncoder(stellarTestnetSelector, 1<<40, false) _, err := enc.HashMetadata(types.ChainMetadata{ StartingOpCount: 0, - MCMAddress: "0x" + strings.Repeat("00", 32), + MCMAddress: "0x" + strings.Repeat("00", stellarContractIDBytes), }) require.ErrorIs(t, err, ErrUint40Overflow) } From 44679662f38253cfdeb6fa43fe3851eb77e67df2 Mon Sep 17 00:00:00 2001 From: FelixFan1992 Date: Tue, 5 May 2026 11:23:15 -0400 Subject: [PATCH 3/9] replace import and add dependabot --- .github/dependabot.yml | 1 + go.mod | 2 +- go.sum | 4 ++-- sdk/stellar/address.go | 2 +- sdk/stellar/encoder_test.go | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 55119bcd..0980f730 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -23,6 +23,7 @@ updates: - dependency-name: "github.com/block-vision/sui-go-sdk" - dependency-name: "github.com/ethereum/go-ethereum" - dependency-name: "github.com/gagliardetto/solana-go" + - dependency-name: "github.com/stellar/go-stellar-sdk" - package-ecosystem: npm directory: "/" schedule: diff --git a/go.mod b/go.mod index dedb0c40..8966aac9 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/smartcontractkit/chainlink-ton v0.0.0-20260219201907-054376f21418 github.com/smartcontractkit/freeport v0.1.3-0.20250828155247-add56fa28aad github.com/spf13/cast v1.10.0 - github.com/stellar/go v0.0.0-20251210100531-aab2ea4aca88 + github.com/stellar/go-stellar-sdk v0.5.0 github.com/stretchr/testify v1.11.1 github.com/xssnick/tonutils-go v1.14.1 github.com/zksync-sdk/zksync2-go v1.1.1-0.20250620124214-2c742ee399c6 diff --git a/go.sum b/go.sum index b7ac01a6..de09e749 100644 --- a/go.sum +++ b/go.sum @@ -708,8 +708,8 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stellar/go v0.0.0-20251210100531-aab2ea4aca88 h1:T7CDnX+NSQlu9pxLlxZN0qt6SeUoQ6lxwZjY+Y9Ky54= -github.com/stellar/go v0.0.0-20251210100531-aab2ea4aca88/go.mod h1:pcoYvfcsyFzzSut3RBWF9Ts8g4Z7SWbkb8Hitu7k4BU= +github.com/stellar/go-stellar-sdk v0.5.0 h1:xpOO+ZTyvGz54wTm7pwl2Gf1e6lZl0ExrJ/tKb+Roj4= +github.com/stellar/go-stellar-sdk v0.5.0/go.mod h1:tLKAQPxa2I5UvGMabBbUXcY3fmgYnfDudrMeK7CDX4w= github.com/stellar/go-xdr v0.0.0-20260312225820-cc2b0611aabf h1:GY1RVbX3Hg7poPXEf6yojjP0hyypvgUgZmCqQU9D0xg= github.com/stellar/go-xdr v0.0.0-20260312225820-cc2b0611aabf/go.mod h1:If+U9Z1W5xU97VrOgJandQT+2dN7/iOpkCrxBJEyF80= github.com/stephenlacy/go-ethereum-hdwallet v0.0.0-20230913225845-a4fa94429863 h1:ba4VRWSkRzgdP5hB5OxexIzBXZbSwgcw8bEu06ivGQI= diff --git a/sdk/stellar/address.go b/sdk/stellar/address.go index ddf7f1ba..8ef51c48 100644 --- a/sdk/stellar/address.go +++ b/sdk/stellar/address.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/stellar/go/strkey" + "github.com/stellar/go-stellar-sdk/strkey" ) // ParseContractID parses a Stellar contract identifier as either: diff --git a/sdk/stellar/encoder_test.go b/sdk/stellar/encoder_test.go index e865cc8a..c54af8d6 100644 --- a/sdk/stellar/encoder_test.go +++ b/sdk/stellar/encoder_test.go @@ -71,7 +71,7 @@ func TestEncoder_HashMetadataAndOperation(t *testing.T) { func TestParseContractID_Strkey(t *testing.T) { t.Parallel() - // Vector from github.com/stellar/go/strkey decode_test ("Contract" case). + // Vector from github.com/stellar/go-stellar-sdk/strkey decode_test ("Contract" case). const sample = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA" want := common.HexToHash("0x3f0c34bf93ad0d9971d04ccc90f705511c838aad9734a4a2fb0d7a03fc7fe89a") got, err := ParseContractID(sample) From 5260ef3ba20a792785a6564e997c5f2771b013e2 Mon Sep 17 00:00:00 2001 From: FelixFan1992 Date: Tue, 5 May 2026 11:38:40 -0400 Subject: [PATCH 4/9] implement stellar inspector, upgrade go versions --- .github/dependabot.yml | 1 + go.mod | 3 +- go.sum | 4 + sdk/stellar/config_transformer.go | 116 +++++++++++++++++++++++ sdk/stellar/constants.go | 3 + sdk/stellar/inspector.go | 116 +++++++++++++++++++++++ sdk/stellar/inspector_test.go | 151 ++++++++++++++++++++++++++++++ 7 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 sdk/stellar/config_transformer.go create mode 100644 sdk/stellar/inspector.go create mode 100644 sdk/stellar/inspector_test.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0980f730..d1c3897c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,6 +24,7 @@ updates: - dependency-name: "github.com/ethereum/go-ethereum" - dependency-name: "github.com/gagliardetto/solana-go" - dependency-name: "github.com/stellar/go-stellar-sdk" + - dependency-name: "github.com/smartcontractkit/chainlink-stellar/bindings" - package-ecosystem: npm directory: "/" schedule: diff --git a/go.mod b/go.mod index 8966aac9..f69bfb44 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/smartcontractkit/mcms -go 1.25.7 +go 1.26.2 //nolint:gomoddirectives // allow replace directive replace github.com/gogo/protobuf => github.com/regen-network/protobuf v1.3.3-alpha.regen.1 @@ -20,6 +20,7 @@ require ( github.com/smartcontractkit/chainlink-aptos v0.0.0-20260428085939-5c70de12dbfc github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260121163256-85accaf3d28d github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20250912190424-fd2e35d7deb5 + github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260504184804-19e54479f03a github.com/smartcontractkit/chainlink-sui v0.0.0-20260428231901-a394dd724761 github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.19 github.com/smartcontractkit/chainlink-ton v0.0.0-20260219201907-054376f21418 diff --git a/go.sum b/go.sum index de09e749..943bf0ca 100644 --- a/go.sum +++ b/go.sum @@ -692,6 +692,8 @@ github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-202510021 github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b/go.mod h1:qSTSwX3cBP3FKQwQacdjArqv0g6QnukjV4XuzO6UyoY= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260205130626-db2a2aab956b h1:36knUpKHHAZ86K4FGWXtx8i/EQftGdk2bqCoEu/Cha8= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260205130626-db2a2aab956b/go.mod h1:dkR2uYg9XYJuT1JASkPzWE51jjFkVb86P7a/yXe5/GM= +github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260504184804-19e54479f03a h1:aBPOnGVtz3s6YULnRnSIcCTN+ckANqhOQch9s5HiwoE= +github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260504184804-19e54479f03a/go.mod h1:ruZV1bxfKC0nCO4sFtvyuXwgww33dW2AmugephhkQJ0= github.com/smartcontractkit/chainlink-sui v0.0.0-20260428231901-a394dd724761 h1:YwdUzW6/xCEa2Y5tGlLVlg+FICQPIKx1tIg1MHPFNOk= github.com/smartcontractkit/chainlink-sui v0.0.0-20260428231901-a394dd724761/go.mod h1:xJ1UT4DKu1znbsm4ehkrfr92rgn8Hxgcp3Z9rgfXRjM= github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.19 h1:inTH0/PrEaVv4iLdGsdcrP/rX7KMrq/Roosr5nIA8io= @@ -772,6 +774,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+x github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xdrpp/goxdr v0.1.1 h1:E1B2c6E8eYhOVyd7yEpOyopzTPirUeF6mVOfXfGyJyc= +github.com/xdrpp/goxdr v0.1.1/go.mod h1:dXo1scL/l6s7iME1gxHWo2XCppbHEKZS7m/KyYWkNzA= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xssnick/tonutils-go v1.14.1 h1:zV/iVYl/h3hArS+tPsd9XrSFfGert3r21caMltPSeHg= diff --git a/sdk/stellar/config_transformer.go b/sdk/stellar/config_transformer.go new file mode 100644 index 00000000..2e448dfc --- /dev/null +++ b/sdk/stellar/config_transformer.go @@ -0,0 +1,116 @@ +package stellar + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/mcms/sdk" + sdkerrors "github.com/smartcontractkit/mcms/sdk/errors" + "github.com/smartcontractkit/mcms/types" + + stellarmcms "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/mcms" +) + +var _ sdk.ConfigTransformer[*stellarmcms.Config, any] = (*ConfigTransformer)(nil) + +const maxUint8Value = 255 + +// ConfigTransformer maps Stellar MCMS on-chain config (get_config) to chain-agnostic types.Config. +type ConfigTransformer struct{} + +// NewConfigTransformer returns a new Stellar config transformer. +func NewConfigTransformer() *ConfigTransformer { + return &ConfigTransformer{} +} + +// ToConfig converts a Stellar ManyChainMultiSig-style config to chain-agnostic types.Config. +func (e *ConfigTransformer) ToConfig(onchainConfig *stellarmcms.Config) (*types.Config, error) { + if onchainConfig == nil { + return nil, fmt.Errorf("nil config") + } + + bindConfig := onchainConfig + + groupToSigners := make([][]common.Address, len(bindConfig.GroupQuorums)) + for _, signer := range bindConfig.Signers { + addr := paddedBytes32ToCommonAddress(signer.Addr) + groupToSigners[signer.Group] = append(groupToSigners[signer.Group], addr) + } + + groups := make([]types.Config, len(bindConfig.GroupQuorums)) + for i := range bindConfig.GroupQuorums { + quorum := bindConfig.GroupQuorums[i] + + signers := groupToSigners[i] + if signers == nil { + signers = []common.Address{} + } + + groups[i] = types.Config{ + Signers: signers, + GroupSigners: []types.Config{}, + Quorum: quorum, + } + } + + // Link nested groups; assumes each group's parent index is lower than the child index. + for i := 31; i >= 0; i-- { + parent := bindConfig.GroupParents[i] + if i > 0 && groups[i].Quorum > 0 { + groups[parent].GroupSigners = append([]types.Config{groups[i]}, groups[parent].GroupSigners...) + } + } + + if err := groups[0].Validate(); err != nil { + return nil, err + } + + return &groups[0], nil +} + +// ToChainConfig converts chain-agnostic types.Config into the Stellar contract config shape. +func (e *ConfigTransformer) ToChainConfig(cfg types.Config, _ any) (*stellarmcms.Config, error) { + groupQuorums, groupParents, signerAddrs, signerGroups, err := sdk.ExtractSetConfigInputs(&cfg) + if err != nil { + return nil, err + } + + if len(signerAddrs) > maxUint8Value { + return nil, sdkerrors.NewTooManySignersError(uint64(len(signerAddrs))) + } + + out := &stellarmcms.Config{} + copy(out.GroupQuorums[:], groupQuorums[:]) + copy(out.GroupParents[:], groupParents[:]) + + out.Signers = make([]stellarmcms.Signer, len(signerAddrs)) + + var idx uint32 + + for i, signerAddr := range signerAddrs { + out.Signers[i] = stellarmcms.Signer{ + Addr: commonAddressToPaddedBytes32(signerAddr), + Group: uint32(signerGroups[i]), + Index: idx, + } + + idx++ + } + + return out, nil +} + +func commonAddressToPaddedBytes32(a common.Address) [32]byte { + var out [32]byte + copy(out[evmAddressABIWordLeadingZeroBytes:], a[:]) + + return out +} + +func paddedBytes32ToCommonAddress(b [32]byte) common.Address { + var a common.Address + copy(a[:], b[evmAddressABIWordLeadingZeroBytes:]) + + return a +} diff --git a/sdk/stellar/constants.go b/sdk/stellar/constants.go index 7b74ab32..ff2fd2d0 100644 --- a/sdk/stellar/constants.go +++ b/sdk/stellar/constants.go @@ -25,6 +25,9 @@ const ( hexPrefixLen = 2 // "0x" / "0X" uint32ByteLen = 4 + + // Solidity ABI: address is 20 bytes right-aligned in a 32-byte word (same padding as Stellar MCMS signers). + evmAddressABIWordLeadingZeroBytes = 12 ) // Domain separators — must match chainlink-stellar contracts/mcms/src/constants.rs diff --git a/sdk/stellar/inspector.go b/sdk/stellar/inspector.go new file mode 100644 index 00000000..f032addb --- /dev/null +++ b/sdk/stellar/inspector.go @@ -0,0 +1,116 @@ +package stellar + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/stellar/go-stellar-sdk/strkey" + + "github.com/smartcontractkit/chainlink-stellar/bindings" + stellarmcms "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/mcms" + + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +var _ sdk.Inspector = (*Inspector)(nil) + +// Inspector reads MCMS contract state on Stellar via Soroban simulation (bindings.Invoker). +type Inspector struct { + ConfigTransformer + invoker bindings.Invoker +} + +// NewInspector constructs an Inspector that uses invoker for read-only SimulateContract calls. +func NewInspector(invoker bindings.Invoker) *Inspector { + return &Inspector{ + invoker: invoker, + } +} + +func (i *Inspector) contractClient(mcmAddr string) (*stellarmcms.McmsClient, error) { + id, err := normalizeContractIDStrkey(mcmAddr) + if err != nil { + return nil, err + } + + return stellarmcms.NewMcmsClient(i.invoker, id), nil +} + +// GetConfig returns the live multisig configuration from the contract. +func (i *Inspector) GetConfig(ctx context.Context, mcmAddr string) (*types.Config, error) { + client, err := i.contractClient(mcmAddr) + if err != nil { + return nil, err + } + + cfg, err := client.GetConfig(ctx) + if err != nil { + return nil, err + } + + return i.ToConfig(cfg) +} + +// GetOpCount returns the executed operation counter from the contract. +func (i *Inspector) GetOpCount(ctx context.Context, mcmAddr string) (uint64, error) { + client, err := i.contractClient(mcmAddr) + if err != nil { + return 0, err + } + + return client.GetOpCount(ctx) +} + +// GetRoot returns the current expiring Merkle root and its valid-until ledger/time bound. +func (i *Inspector) GetRoot(ctx context.Context, mcmAddr string) (common.Hash, uint32, error) { + client, err := i.contractClient(mcmAddr) + if err != nil { + return common.Hash{}, 0, err + } + + root, validUntil, err := client.GetRoot(ctx) + if err != nil { + return common.Hash{}, 0, err + } + + return common.BytesToHash(root[:]), validUntil, nil +} + +// GetRootMetadata returns proposal metadata aligned with MCMS (starting op count + MCM address). +func (i *Inspector) GetRootMetadata(ctx context.Context, mcmAddr string) (types.ChainMetadata, error) { + client, err := i.contractClient(mcmAddr) + if err != nil { + return types.ChainMetadata{}, err + } + + meta, err := client.GetRootMetadata(ctx) + if err != nil { + return types.ChainMetadata{}, err + } + + if meta == nil { + return types.ChainMetadata{}, fmt.Errorf("nil root metadata from contract") + } + + return types.ChainMetadata{ + StartingOpCount: meta.PreOpCount, + MCMAddress: mcmAddr, + }, nil +} + +// normalizeContractIDStrkey accepts contract id hex or strkey and returns canonical contract strkey (C…). +func normalizeContractIDStrkey(s string) (string, error) { + raw, err := ParseContractID(s) + if err != nil { + return "", err + } + + encoded, err := strkey.Encode(strkey.VersionByteContract, raw[:]) + if err != nil { + return "", fmt.Errorf("encode contract id: %w", err) + } + + return encoded, nil +} diff --git a/sdk/stellar/inspector_test.go b/sdk/stellar/inspector_test.go new file mode 100644 index 00000000..2e224ff2 --- /dev/null +++ b/sdk/stellar/inspector_test.go @@ -0,0 +1,151 @@ +package stellar + +import ( + "context" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + stellarmcms "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/mcms" + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" + protocolrpc "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/xdr" + + "github.com/smartcontractkit/mcms/types" +) + +func TestConfigTransformer_ToConfig_ToChainConfig_roundTrip(t *testing.T) { + t.Parallel() + + tr := NewConfigTransformer() + + want := types.Config{ + Quorum: 1, + Signers: []common.Address{{1}}, + } + + chainCfg, err := tr.ToChainConfig(want, nil) + require.NoError(t, err) + + got, err := tr.ToConfig(chainCfg) + require.NoError(t, err) + + require.True(t, got.Equals(&want)) +} + +type mockInvoker struct { + cfg *stellarmcms.Config + root [32]byte + valid uint32 + opCount uint64 + rootMeta *stellarmcms.StellarRootMetadata +} + +func (m *mockInvoker) InvokeContract(context.Context, string, string, []xdr.ScVal) (*xdr.ScVal, error) { + return nil, invokerNotImplementedError{} +} + +func (m *mockInvoker) GetEvents(context.Context, string, uint32, []string) ([]protocolrpc.EventInfo, error) { + return nil, invokerNotImplementedError{} +} + +func (m *mockInvoker) SimulateContract(_ context.Context, _ string, fn string, _ []xdr.ScVal) (*xdr.ScVal, error) { + switch fn { + case "get_config": + v, err := m.cfg.ToScVal() + if err != nil { + return nil, err + } + + return &v, nil + + case "get_root": + v := scval.VecToScVal([]xdr.ScVal{ + scval.Bytes32ToScVal(m.root), + scval.Uint32ToScVal(m.valid), + }) + + return &v, nil + + case "get_op_count": + v := scval.Uint64ToScVal(m.opCount) + + return &v, nil + + case "get_root_metadata": + v, err := m.rootMeta.ToScVal() + if err != nil { + return nil, err + } + + return &v, nil + + default: + return nil, invokerNotImplementedError{} + } +} + +type invokerNotImplementedError struct{} + +func (invokerNotImplementedError) Error() string { + return "mock invoker: not implemented" +} + +func TestInspector_readsViaInvoker(t *testing.T) { + t.Parallel() + + var paddedSigner [32]byte + copy(paddedSigner[evmAddressABIWordLeadingZeroBytes:], []byte{0xab, 0xcd}) + + cfg := &stellarmcms.Config{ + GroupQuorums: func() (out [32]byte) { + out[0] = 1 + + return out + }(), + GroupParents: [32]byte{}, + Signers: []stellarmcms.Signer{ + {Addr: paddedSigner, Group: 0, Index: 0}, + }, + } + + meta := &stellarmcms.StellarRootMetadata{ + PreOpCount: 7, + PostOpCount: 8, + } + + inv := &mockInvoker{ + cfg: cfg, + root: [32]byte{9}, + valid: 42, + opCount: 100, + rootMeta: meta, + } + + insp := NewInspector(inv) + + ctx := context.Background() + + const contractHex = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + + gotCfg, err := insp.GetConfig(ctx, contractHex) + require.NoError(t, err) + require.Equal(t, uint8(1), gotCfg.Quorum) + require.Len(t, gotCfg.Signers, 1) + require.Equal(t, common.Address{0xab, 0xcd}, gotCfg.Signers[0]) + + opCount, err := insp.GetOpCount(ctx, contractHex) + require.NoError(t, err) + require.Equal(t, uint64(100), opCount) + + root, validUntil, err := insp.GetRoot(ctx, contractHex) + require.NoError(t, err) + require.Equal(t, common.Hash([32]byte{9}), root) + require.Equal(t, uint32(42), validUntil) + + md, err := insp.GetRootMetadata(ctx, contractHex) + require.NoError(t, err) + require.Equal(t, uint64(7), md.StartingOpCount) + require.Equal(t, contractHex, md.MCMAddress) +} From 765b11f5a6b0bae093b9fd45ce0d03f3718b9a0e Mon Sep 17 00:00:00 2001 From: FelixFan1992 Date: Tue, 5 May 2026 13:54:29 -0400 Subject: [PATCH 5/9] implement stellar executor --- go.mod | 5 +- go.sum | 2 - sdk/mocks/operation_id.go | 98 ++++++++++++++ sdk/mocks/timelock_configurer.go | 96 ++++++++++++++ sdk/stellar/executor.go | 208 ++++++++++++++++++++++++++++++ sdk/stellar/executor_test.go | 117 +++++++++++++++++ sdk/stellar/timelock_converter.go | 19 +++ 7 files changed, 542 insertions(+), 3 deletions(-) create mode 100644 sdk/mocks/operation_id.go create mode 100644 sdk/mocks/timelock_configurer.go create mode 100644 sdk/stellar/executor.go create mode 100644 sdk/stellar/executor_test.go create mode 100644 sdk/stellar/timelock_converter.go diff --git a/go.mod b/go.mod index f69bfb44..a386d512 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.26.2 //nolint:gomoddirectives // allow replace directive replace github.com/gogo/protobuf => github.com/regen-network/protobuf v1.3.3-alpha.regen.1 +// Local chainlink-stellar bindings (private module); adjust path if repos are not siblings of mcms. +replace github.com/smartcontractkit/chainlink-stellar/bindings => ../chainlink-stellar/bindings + require ( github.com/aptos-labs/aptos-go-sdk v1.12.1 github.com/block-vision/sui-go-sdk v1.2.1 @@ -20,7 +23,7 @@ require ( github.com/smartcontractkit/chainlink-aptos v0.0.0-20260428085939-5c70de12dbfc github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260121163256-85accaf3d28d github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20250912190424-fd2e35d7deb5 - github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260504184804-19e54479f03a + github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260505155616-666b86bf48b9 github.com/smartcontractkit/chainlink-sui v0.0.0-20260428231901-a394dd724761 github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.19 github.com/smartcontractkit/chainlink-ton v0.0.0-20260219201907-054376f21418 diff --git a/go.sum b/go.sum index 943bf0ca..84378a20 100644 --- a/go.sum +++ b/go.sum @@ -692,8 +692,6 @@ github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-202510021 github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b/go.mod h1:qSTSwX3cBP3FKQwQacdjArqv0g6QnukjV4XuzO6UyoY= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260205130626-db2a2aab956b h1:36knUpKHHAZ86K4FGWXtx8i/EQftGdk2bqCoEu/Cha8= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260205130626-db2a2aab956b/go.mod h1:dkR2uYg9XYJuT1JASkPzWE51jjFkVb86P7a/yXe5/GM= -github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260504184804-19e54479f03a h1:aBPOnGVtz3s6YULnRnSIcCTN+ckANqhOQch9s5HiwoE= -github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260504184804-19e54479f03a/go.mod h1:ruZV1bxfKC0nCO4sFtvyuXwgww33dW2AmugephhkQJ0= github.com/smartcontractkit/chainlink-sui v0.0.0-20260428231901-a394dd724761 h1:YwdUzW6/xCEa2Y5tGlLVlg+FICQPIKx1tIg1MHPFNOk= github.com/smartcontractkit/chainlink-sui v0.0.0-20260428231901-a394dd724761/go.mod h1:xJ1UT4DKu1znbsm4ehkrfr92rgn8Hxgcp3Z9rgfXRjM= github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.19 h1:inTH0/PrEaVv4iLdGsdcrP/rX7KMrq/Roosr5nIA8io= diff --git a/sdk/mocks/operation_id.go b/sdk/mocks/operation_id.go new file mode 100644 index 00000000..62470214 --- /dev/null +++ b/sdk/mocks/operation_id.go @@ -0,0 +1,98 @@ +// Code generated by mockery v2.53.0. DO NOT EDIT. + +package mocks + +import ( + common "github.com/ethereum/go-ethereum/common" + mock "github.com/stretchr/testify/mock" + + types "github.com/smartcontractkit/mcms/types" +) + +// OperationID is an autogenerated mock type for the OperationID type +type OperationID struct { + mock.Mock +} + +type OperationID_Expecter struct { + mock *mock.Mock +} + +func (_m *OperationID) EXPECT() *OperationID_Expecter { + return &OperationID_Expecter{mock: &_m.Mock} +} + +// Execute provides a mock function with given fields: _a0, _a1, _a2, _a3 +func (_m *OperationID) Execute(_a0 types.BatchOperation, _a1 types.TimelockAction, _a2 common.Hash, _a3 common.Hash) (common.Hash, error) { + ret := _m.Called(_a0, _a1, _a2, _a3) + + if len(ret) == 0 { + panic("no return value specified for Execute") + } + + var r0 common.Hash + var r1 error + if rf, ok := ret.Get(0).(func(types.BatchOperation, types.TimelockAction, common.Hash, common.Hash) (common.Hash, error)); ok { + return rf(_a0, _a1, _a2, _a3) + } + if rf, ok := ret.Get(0).(func(types.BatchOperation, types.TimelockAction, common.Hash, common.Hash) common.Hash); ok { + r0 = rf(_a0, _a1, _a2, _a3) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(common.Hash) + } + } + + if rf, ok := ret.Get(1).(func(types.BatchOperation, types.TimelockAction, common.Hash, common.Hash) error); ok { + r1 = rf(_a0, _a1, _a2, _a3) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// OperationID_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute' +type OperationID_Execute_Call struct { + *mock.Call +} + +// Execute is a helper method to define mock.On call +// - _a0 types.BatchOperation +// - _a1 types.TimelockAction +// - _a2 common.Hash +// - _a3 common.Hash +func (_e *OperationID_Expecter) Execute(_a0 interface{}, _a1 interface{}, _a2 interface{}, _a3 interface{}) *OperationID_Execute_Call { + return &OperationID_Execute_Call{Call: _e.mock.On("Execute", _a0, _a1, _a2, _a3)} +} + +func (_c *OperationID_Execute_Call) Run(run func(_a0 types.BatchOperation, _a1 types.TimelockAction, _a2 common.Hash, _a3 common.Hash)) *OperationID_Execute_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(types.BatchOperation), args[1].(types.TimelockAction), args[2].(common.Hash), args[3].(common.Hash)) + }) + return _c +} + +func (_c *OperationID_Execute_Call) Return(_a0 common.Hash, _a1 error) *OperationID_Execute_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *OperationID_Execute_Call) RunAndReturn(run func(types.BatchOperation, types.TimelockAction, common.Hash, common.Hash) (common.Hash, error)) *OperationID_Execute_Call { + _c.Call.Return(run) + return _c +} + +// NewOperationID creates a new instance of OperationID. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewOperationID(t interface { + mock.TestingT + Cleanup(func()) +}) *OperationID { + mock := &OperationID{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/sdk/mocks/timelock_configurer.go b/sdk/mocks/timelock_configurer.go new file mode 100644 index 00000000..b61db885 --- /dev/null +++ b/sdk/mocks/timelock_configurer.go @@ -0,0 +1,96 @@ +// Code generated by mockery v2.53.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + types "github.com/smartcontractkit/mcms/types" +) + +// TimelockConfigurer is an autogenerated mock type for the TimelockConfigurer type +type TimelockConfigurer struct { + mock.Mock +} + +type TimelockConfigurer_Expecter struct { + mock *mock.Mock +} + +func (_m *TimelockConfigurer) EXPECT() *TimelockConfigurer_Expecter { + return &TimelockConfigurer_Expecter{mock: &_m.Mock} +} + +// UpdateDelay provides a mock function with given fields: ctx, timelockAddress, newDelay +func (_m *TimelockConfigurer) UpdateDelay(ctx context.Context, timelockAddress string, newDelay uint64) (types.TransactionResult, error) { + ret := _m.Called(ctx, timelockAddress, newDelay) + + if len(ret) == 0 { + panic("no return value specified for UpdateDelay") + } + + var r0 types.TransactionResult + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, uint64) (types.TransactionResult, error)); ok { + return rf(ctx, timelockAddress, newDelay) + } + if rf, ok := ret.Get(0).(func(context.Context, string, uint64) types.TransactionResult); ok { + r0 = rf(ctx, timelockAddress, newDelay) + } else { + r0 = ret.Get(0).(types.TransactionResult) + } + + if rf, ok := ret.Get(1).(func(context.Context, string, uint64) error); ok { + r1 = rf(ctx, timelockAddress, newDelay) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// TimelockConfigurer_UpdateDelay_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateDelay' +type TimelockConfigurer_UpdateDelay_Call struct { + *mock.Call +} + +// UpdateDelay is a helper method to define mock.On call +// - ctx context.Context +// - timelockAddress string +// - newDelay uint64 +func (_e *TimelockConfigurer_Expecter) UpdateDelay(ctx interface{}, timelockAddress interface{}, newDelay interface{}) *TimelockConfigurer_UpdateDelay_Call { + return &TimelockConfigurer_UpdateDelay_Call{Call: _e.mock.On("UpdateDelay", ctx, timelockAddress, newDelay)} +} + +func (_c *TimelockConfigurer_UpdateDelay_Call) Run(run func(ctx context.Context, timelockAddress string, newDelay uint64)) *TimelockConfigurer_UpdateDelay_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(uint64)) + }) + return _c +} + +func (_c *TimelockConfigurer_UpdateDelay_Call) Return(_a0 types.TransactionResult, _a1 error) *TimelockConfigurer_UpdateDelay_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *TimelockConfigurer_UpdateDelay_Call) RunAndReturn(run func(context.Context, string, uint64) (types.TransactionResult, error)) *TimelockConfigurer_UpdateDelay_Call { + _c.Call.Return(run) + return _c +} + +// NewTimelockConfigurer creates a new instance of TimelockConfigurer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewTimelockConfigurer(t interface { + mock.TestingT + Cleanup(func()) +}) *TimelockConfigurer { + mock := &TimelockConfigurer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/sdk/stellar/executor.go b/sdk/stellar/executor.go new file mode 100644 index 00000000..e66c40c6 --- /dev/null +++ b/sdk/stellar/executor.go @@ -0,0 +1,208 @@ +package stellar + +import ( + "context" + "errors" + "fmt" + "math" + + "github.com/ethereum/go-ethereum/common" + chainsel "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-stellar/bindings" + stellarmcms "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/mcms" + + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +var _ sdk.Executor = (*Executor)(nil) + +// Executor submits MCMS execute/set_root calls via [bindings.Invoker] (e.g. chainlink-stellar Deployer). +type Executor struct { + *Encoder + *Inspector + invoker bindings.Invoker +} + +// NewExecutor builds an Executor sharing invoker with read and write paths. +func NewExecutor(encoder *Encoder, invoker bindings.Invoker) *Executor { + return &Executor{ + Encoder: encoder, + Inspector: NewInspector(invoker), + invoker: invoker, + } +} + +// ExecuteOperation invokes Soroban `execute` with a [stellarmcms.StellarOp] and Merkle proof. +func (e *Executor) ExecuteOperation( + ctx context.Context, + metadata types.ChainMetadata, + nonce uint32, + proof []common.Hash, + op types.Operation, +) (types.TransactionResult, error) { + if e.Encoder == nil { + return types.TransactionResult{}, errors.New("failed to create sdk.Executor - encoder (sdk.Encoder) is nil") + } + + if uint64(nonce) >= uint40MaxExclusive { + return types.TransactionResult{}, fmt.Errorf("%w: nonce %d", ErrUint40Overflow, nonce) + } + + chainID, err := ChainNetworkID(e.ChainSelector) + if err != nil { + return types.TransactionResult{}, err + } + + multisig, err := ParseContractID(metadata.MCMAddress) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("mcmAddress: %w", err) + } + + to, err := ParseContractID(op.Transaction.To) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("transaction.to: %w", err) + } + + valueWord, err := parseValueWord(op.Transaction.AdditionalFields) + if err != nil { + return types.TransactionResult{}, err + } + + stellarOp := stellarmcms.StellarOp{ + ChainId: [32]byte(chainID), + Data: op.Transaction.Data, + Multisig: multisig, + Nonce: uint64(nonce), + To: to, + Value: valueWord, + } + + mcmsClient, err := e.mcmsClient(metadata.MCMAddress) + if err != nil { + return types.TransactionResult{}, err + } + + mp := merkleProofFromHashes(proof) + + if err := mcmsClient.Execute(ctx, stellarOp, mp); err != nil { + return types.TransactionResult{ChainFamily: chainsel.FamilyStellar}, err + } + + return e.txResult(), nil +} + +// SetRoot invokes Soroban `set_root` with metadata and ECDSA signatures (contract ABI layout). +func (e *Executor) SetRoot( + ctx context.Context, + metadata types.ChainMetadata, + proof []common.Hash, + root [32]byte, + validUntil uint32, + sortedSignatures []types.Signature, +) (types.TransactionResult, error) { + if e.Encoder == nil { + return types.TransactionResult{}, errors.New("failed to create sdk.Executor - encoder (sdk.Encoder) is nil") + } + + if len(sortedSignatures) > math.MaxUint8 { + return types.TransactionResult{}, fmt.Errorf("too many signatures (max %d)", math.MaxUint8) + } + + rootMeta, err := e.stellarRootMetadata(metadata) + if err != nil { + return types.TransactionResult{}, err + } + + mcmsClient, err := e.mcmsClient(metadata.MCMAddress) + if err != nil { + return types.TransactionResult{}, err + } + + sigVec := signatureVecFrom(sortedSignatures) + mp := merkleProofFromHashes(proof) + + if err := mcmsClient.SetRoot(ctx, root, validUntil, rootMeta, mp, sigVec); err != nil { + return types.TransactionResult{ChainFamily: chainsel.FamilyStellar}, err + } + + return e.txResult(), nil +} + +func (e *Executor) mcmsClient(mcmAddr string) (*stellarmcms.McmsClient, error) { + id, err := normalizeContractIDStrkey(mcmAddr) + if err != nil { + return nil, err + } + + return stellarmcms.NewMcmsClient(e.invoker, id), nil +} + +func (e *Executor) stellarRootMetadata(metadata types.ChainMetadata) (stellarmcms.StellarRootMetadata, error) { + var zero stellarmcms.StellarRootMetadata + + if metadata.StartingOpCount >= uint40MaxExclusive { + return zero, fmt.Errorf("%w: startingOpCount %d", ErrUint40Overflow, metadata.StartingOpCount) + } + + post := metadata.StartingOpCount + e.TxCount + if post >= uint40MaxExclusive { + return zero, fmt.Errorf("%w: postOpCount (starting+txCount) %d", ErrUint40Overflow, post) + } + + chainID, err := ChainNetworkID(e.ChainSelector) + if err != nil { + return zero, err + } + + multisig, err := ParseContractID(metadata.MCMAddress) + if err != nil { + return zero, fmt.Errorf("mcmAddress: %w", err) + } + + return stellarmcms.StellarRootMetadata{ + ChainId: [32]byte(chainID), + Multisig: multisig, + OverridePreviousRoot: e.OverridePreviousRoot, + PreOpCount: metadata.StartingOpCount, + PostOpCount: post, + }, nil +} + +func merkleProofFromHashes(proof []common.Hash) stellarmcms.MerkleProof { + inner := make([][32]byte, len(proof)) + for i, p := range proof { + inner[i] = p + } + + return stellarmcms.MerkleProof{Inner: inner} +} + +func signatureVecFrom(sorted []types.Signature) stellarmcms.SignatureVec { + inner := make([]stellarmcms.Signature, len(sorted)) + for i, s := range sorted { + inner[i] = stellarmcms.Signature{ + R: s.R, + S: s.S, + V: uint32(s.V), + } + } + + return stellarmcms.SignatureVec{Inner: inner} +} + +// txHashInvoker is optionally implemented by invokers that expose the last submitted Soroban tx hash. +type txHashInvoker interface { + LastSubmittedTransactionHash() string +} + +func (e *Executor) txResult() types.TransactionResult { + hash := "" + + if th, ok := e.invoker.(txHashInvoker); ok { + hash = th.LastSubmittedTransactionHash() + } + + return types.NewTransactionResult(hash, nil, chainsel.FamilyStellar) +} diff --git a/sdk/stellar/executor_test.go b/sdk/stellar/executor_test.go new file mode 100644 index 00000000..241afec5 --- /dev/null +++ b/sdk/stellar/executor_test.go @@ -0,0 +1,117 @@ +package stellar + +import ( + "context" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + stellarmcms "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/mcms" + protocolrpc "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/xdr" + + chainsel "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/mcms/types" +) + +type recordingInvoker struct { + lastFn string +} + +func (r *recordingInvoker) InvokeContract(_ context.Context, _ string, fn string, _ []xdr.ScVal) (*xdr.ScVal, error) { + r.lastFn = fn + + v := xdr.ScVal{} + + return &v, nil +} + +func (r *recordingInvoker) SimulateContract(context.Context, string, string, []xdr.ScVal) (*xdr.ScVal, error) { + v := xdr.ScVal{} + + return &v, nil +} + +func (r *recordingInvoker) GetEvents(context.Context, string, uint32, []string) ([]protocolrpc.EventInfo, error) { + return nil, nil +} + +func TestExecutor_ExecuteOperation_routesToExecute(t *testing.T) { + t.Parallel() + + sel := types.ChainSelector(chainsel.STELLAR_TESTNET.Selector) + + enc := NewEncoder(sel, 0, false) + inv := &recordingInvoker{} + ex := NewExecutor(enc, inv) + + ctx := context.Background() + + md := types.ChainMetadata{ + StartingOpCount: 0, + MCMAddress: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + } + + op := types.Operation{ + Transaction: types.Transaction{ + To: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + Data: []byte{1, 2, 3}, + }, + } + + res, err := ex.ExecuteOperation(ctx, md, 0, []common.Hash{{}}, op) + require.NoError(t, err) + require.Equal(t, chainsel.FamilyStellar, res.ChainFamily) + require.Equal(t, "execute", inv.lastFn) +} + +func TestExecutor_SetRoot_routesToSetRoot(t *testing.T) { + t.Parallel() + + sel := types.ChainSelector(chainsel.STELLAR_TESTNET.Selector) + + enc := NewEncoder(sel, 1, false) + inv := &recordingInvoker{} + ex := NewExecutor(enc, inv) + + ctx := context.Background() + + md := types.ChainMetadata{ + StartingOpCount: 1, + MCMAddress: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + } + + sig := types.Signature{ + R: common.Hash{1}, + S: common.Hash{2}, + V: 27, + } + + res, err := ex.SetRoot(ctx, md, []common.Hash{{}}, [32]byte{9}, 100, []types.Signature{sig}) + require.NoError(t, err) + require.Equal(t, chainsel.FamilyStellar, res.ChainFamily) + require.Equal(t, "set_root", inv.lastFn) +} + +func TestMerkleProofFromHashes_roundTrip(t *testing.T) { + t.Parallel() + + proof := []common.Hash{{1}, {2}} + mp := merkleProofFromHashes(proof) + require.Len(t, mp.Inner, 2) + require.Equal(t, proof[0], common.Hash(mp.Inner[0])) + require.Equal(t, proof[1], common.Hash(mp.Inner[1])) +} + +func TestSignatureVecFrom_preservesComponents(t *testing.T) { + t.Parallel() + + sigs := []types.Signature{ + {R: common.Hash{1}, S: common.Hash{2}, V: 28}, + } + vec := signatureVecFrom(sigs) + require.Len(t, vec.Inner, 1) + require.Equal(t, stellarmcms.Signature{R: sigs[0].R, S: sigs[0].S, V: 28}, vec.Inner[0]) +} diff --git a/sdk/stellar/timelock_converter.go b/sdk/stellar/timelock_converter.go new file mode 100644 index 00000000..a612ae08 --- /dev/null +++ b/sdk/stellar/timelock_converter.go @@ -0,0 +1,19 @@ +package stellar + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/mcms/types" +) + +// OperationID is not implemented for Stellar timelock flows until timelock parity exists on Soroban. +func OperationID( + _ types.BatchOperation, + _ types.TimelockAction, + _ common.Hash, + _ common.Hash, +) (common.Hash, error) { + return common.Hash{}, fmt.Errorf("stellar timelock OperationID is not implemented") +} From 14e4743a8721d4c877ab312d3f10054dc90a9cee Mon Sep 17 00:00:00 2001 From: FelixFan1992 Date: Tue, 5 May 2026 15:30:45 -0400 Subject: [PATCH 6/9] implement stellar executor, and timelock components --- chainwrappers/chainaccessor.go | 3 + chainwrappers/converters.go | 3 + chainwrappers/converters_test.go | 3 + chainwrappers/executors.go | 13 ++ chainwrappers/executors_test.go | 40 +++- chainwrappers/inspectors.go | 10 + chainwrappers/inspectors_test.go | 7 +- chainwrappers/mocks/chain_accessor.go | 76 ++++++- chainwrappers/timelock_configurers.go | 17 ++ chainwrappers/timelock_configurers_test.go | 44 ++++ chainwrappers/timelock_executors.go | 17 ++ chainwrappers/timelock_executors_test.go | 44 +++- .../integrating-new-chain-guide.md | 23 +- factory.go | 9 + factory_test.go | 11 + internal/testutils/chaintest/testchain.go | 4 + sdk/aptos/mocks/aptos/rpcclient.go | 7 +- sdk/aptos/mocks/aptos/transactionsigner.go | 3 +- sdk/aptos/mocks/mcms/mcms.go | 9 +- sdk/aptos/mocks/mcms/mcms/mcms.go | 11 +- sdk/aptos/mocks/mcms/mcms/mcms_encoder.go | 7 +- .../mocks/mcms/mcms_executor/mcms_executor.go | 11 +- sdk/evm/bindings/mocks/abigen_log.go | 2 +- .../bindings/mocks/call_proxy_interface.go | 10 +- .../mocks/many_chain_multi_sig_interface.go | 10 +- .../bindings/mocks/rbac_timelock_interface.go | 10 +- sdk/evm/mocks/contract_deploy_backend.go | 8 +- sdk/mocks/config_transformer.go | 2 +- sdk/mocks/configurer.go | 2 +- sdk/mocks/decoded_operation.go | 2 +- sdk/mocks/decoder.go | 5 +- sdk/mocks/encoder.go | 2 +- sdk/mocks/executor.go | 3 +- sdk/mocks/inspector.go | 3 +- sdk/mocks/logger.go | 2 +- sdk/mocks/simulator.go | 3 +- sdk/mocks/timelock_converter.go | 3 +- sdk/mocks/timelock_executor.go | 3 +- sdk/mocks/timelock_inspector.go | 2 +- sdk/solana/mocks/jsonrpcclient.go | 3 +- sdk/stellar/configurer.go | 66 ++++++ sdk/stellar/configurer_test.go | 41 ++++ sdk/stellar/executor.go | 32 +-- sdk/stellar/inspector.go | 7 +- sdk/stellar/mcms_client.go | 16 ++ sdk/stellar/timelock_configurer.go | 52 +++++ sdk/stellar/timelock_configurer_test.go | 14 ++ sdk/stellar/timelock_converter.go | 210 ++++++++++++++++- sdk/stellar/timelock_converter_test.go | 92 ++++++++ sdk/stellar/timelock_executor.go | 65 ++++++ sdk/stellar/timelock_hash.go | 43 ++++ sdk/stellar/timelock_hash_test.go | 84 +++++++ sdk/stellar/timelock_inspector.go | 134 +++++++++++ sdk/stellar/timelock_inspector_test.go | 184 +++++++++++++++ sdk/stellar/timelock_invoke.go | 25 ++ sdk/stellar/transaction_result.go | 24 ++ sdk/stellar/validation.go | 113 ++++++++++ sdk/stellar/validation_test.go | 213 ++++++++++++++++++ sdk/sui/mocks/bindutils/iboundcontract.go | 9 +- sdk/sui/mocks/bindutils/suisigner.go | 2 +- sdk/sui/mocks/feequoter/feequoterencoder.go | 5 +- sdk/sui/mocks/mcms/imcms.go | 9 +- sdk/sui/mocks/mcms/imcmsdevinspect.go | 6 +- sdk/sui/mocks/mcms/mcmsencoder.go | 5 +- sdk/sui/mocks/mcmsdeployer/imcmsdeployer.go | 9 +- .../mocks/mcmsdeployer/mcmsdeployerencoder.go | 2 +- sdk/sui/mocks/sui/isuiapi.go | 2 +- sdk/ton/mocks/api.go | 15 +- sdk/ton/mocks/wallet.go | 7 +- timelock_executable_test.go | 4 +- timelock_proposal.go | 15 ++ timelock_proposal_test.go | 66 ++++++ validation.go | 6 + validation_test.go | 51 +++++ 74 files changed, 1884 insertions(+), 191 deletions(-) create mode 100644 sdk/stellar/configurer.go create mode 100644 sdk/stellar/configurer_test.go create mode 100644 sdk/stellar/mcms_client.go create mode 100644 sdk/stellar/timelock_configurer.go create mode 100644 sdk/stellar/timelock_configurer_test.go create mode 100644 sdk/stellar/timelock_converter_test.go create mode 100644 sdk/stellar/timelock_executor.go create mode 100644 sdk/stellar/timelock_hash.go create mode 100644 sdk/stellar/timelock_hash_test.go create mode 100644 sdk/stellar/timelock_inspector.go create mode 100644 sdk/stellar/timelock_inspector_test.go create mode 100644 sdk/stellar/timelock_invoke.go create mode 100644 sdk/stellar/transaction_result.go create mode 100644 sdk/stellar/validation.go create mode 100644 sdk/stellar/validation_test.go diff --git a/chainwrappers/chainaccessor.go b/chainwrappers/chainaccessor.go index ca1fed01..ce8a9bbf 100644 --- a/chainwrappers/chainaccessor.go +++ b/chainwrappers/chainaccessor.go @@ -8,6 +8,8 @@ import ( "github.com/xssnick/tonutils-go/ton" tonwallet "github.com/xssnick/tonutils-go/ton/wallet" + stellarbindings "github.com/smartcontractkit/chainlink-stellar/bindings" + evmsdk "github.com/smartcontractkit/mcms/sdk/evm" suisuisdk "github.com/smartcontractkit/mcms/sdk/sui" ) @@ -24,4 +26,5 @@ type ChainAccessor interface { SuiSigner(selector uint64) (suisuisdk.SuiSigner, bool) TonClient(selector uint64) (ton.APIClientWrapped, bool) TonSigner(selector uint64) (*tonwallet.Wallet, bool) + StellarInvoker(selector uint64) (stellarbindings.Invoker, bool) } diff --git a/chainwrappers/converters.go b/chainwrappers/converters.go index 0ccaa18d..8f73643c 100644 --- a/chainwrappers/converters.go +++ b/chainwrappers/converters.go @@ -10,6 +10,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" "github.com/smartcontractkit/mcms/types" @@ -51,6 +52,8 @@ func BuildConverter(selector types.ChainSelector, metadata types.ChainMetadata) converter, _ = sui.NewTimelockConverter() case chainsel.FamilyTon: converter = ton.NewTimelockConverter(ton.DefaultSendAmount) + case chainsel.FamilyStellar: + converter = stellar.NewTimelockConverter() default: return nil, fmt.Errorf("unsupported chain family %s", fam) } diff --git a/chainwrappers/converters_test.go b/chainwrappers/converters_test.go index 512dfbfb..d8770446 100644 --- a/chainwrappers/converters_test.go +++ b/chainwrappers/converters_test.go @@ -9,6 +9,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" "github.com/smartcontractkit/mcms/types" @@ -31,6 +32,7 @@ func TestBuildConverters(t *testing.T) { chaintest.Chain5Selector: {}, chaintest.Chain6Selector: {}, chaintest.Chain7Selector: {}, + chaintest.Chain9Selector: {}, }, expectTypes: map[types.ChainSelector]any{ chaintest.Chain2Selector: (*evm.TimelockConverter)(nil), @@ -38,6 +40,7 @@ func TestBuildConverters(t *testing.T) { chaintest.Chain5Selector: (*aptos.TimelockConverter)(nil), chaintest.Chain6Selector: (*sui.TimelockConverter)(nil), chaintest.Chain7Selector: (*ton.TimelockConverter)(nil), + chaintest.Chain9Selector: (*stellar.TimelockConverter)(nil), }, }, { diff --git a/chainwrappers/executors.go b/chainwrappers/executors.go index 1b1b1261..c8bd8a56 100644 --- a/chainwrappers/executors.go +++ b/chainwrappers/executors.go @@ -10,6 +10,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" "github.com/smartcontractkit/mcms/types" @@ -167,6 +168,18 @@ func BuildExecutor( Amount: ton.DefaultSendAmount, }) + case chainsel.FamilyStellar: + stellarEncoder, ok := encoder.(*stellar.Encoder) + if !ok { + return nil, fmt.Errorf("invalid encoder type for selector %d: %T", chainSelector, encoder) + } + invoker, ok := chains.StellarInvoker(rawSelector) + if !ok { + return nil, fmt.Errorf("missing stellar invoker for selector %d", chainSelector) + } + + return stellar.NewExecutor(stellarEncoder, invoker), nil + default: return nil, fmt.Errorf("unsupported chain family %s", family) } diff --git a/chainwrappers/executors_test.go b/chainwrappers/executors_test.go index 626930cf..82f978c5 100644 --- a/chainwrappers/executors_test.go +++ b/chainwrappers/executors_test.go @@ -7,31 +7,37 @@ import ( sol "github.com/gagliardetto/solana-go" solrpc "github.com/gagliardetto/solana-go/rpc" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" chainsel "github.com/smartcontractkit/chain-selectors" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" tonwallet "github.com/xssnick/tonutils-go/ton/wallet" "github.com/smartcontractkit/mcms/chainwrappers/mocks" + "github.com/smartcontractkit/mcms/internal/testutils/chaintest" mcmssdk "github.com/smartcontractkit/mcms/sdk" "github.com/smartcontractkit/mcms/sdk/aptos" aptosmocks "github.com/smartcontractkit/mcms/sdk/aptos/mocks/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" suibindmocks "github.com/smartcontractkit/mcms/sdk/sui/mocks/bindutils" suimocks "github.com/smartcontractkit/mcms/sdk/sui/mocks/sui" "github.com/smartcontractkit/mcms/sdk/ton" tonmocks "github.com/smartcontractkit/mcms/sdk/ton/mocks" mcmstypes "github.com/smartcontractkit/mcms/types" + + stellarbindings "github.com/smartcontractkit/chainlink-stellar/bindings" ) var ( - evmSelector = mcmstypes.ChainSelector(chainsel.ETHEREUM_TESTNET_SEPOLIA.Selector) - solSelector = mcmstypes.ChainSelector(chainsel.SOLANA_DEVNET.Selector) - aptosSelector = mcmstypes.ChainSelector(chainsel.APTOS_TESTNET.Selector) - suiSelector = mcmstypes.ChainSelector(chainsel.SUI_TESTNET.Selector) - tonSelector = mcmstypes.ChainSelector(chainsel.TON_TESTNET.Selector) + evmSelector = mcmstypes.ChainSelector(chainsel.ETHEREUM_TESTNET_SEPOLIA.Selector) + solSelector = mcmstypes.ChainSelector(chainsel.SOLANA_DEVNET.Selector) + aptosSelector = mcmstypes.ChainSelector(chainsel.APTOS_TESTNET.Selector) + suiSelector = mcmstypes.ChainSelector(chainsel.SUI_TESTNET.Selector) + tonSelector = mcmstypes.ChainSelector(chainsel.TON_TESTNET.Selector) + stellarSelector = chaintest.Chain9Selector ) func TestBuildExecutors(t *testing.T) { @@ -66,6 +72,10 @@ func TestBuildExecutors(t *testing.T) { tonExecutor, err := ton.NewExecutor(tonExecOpts) require.NoError(t, err) + stellarEncoder := stellar.NewEncoder(stellarSelector, 0, false) + var stellarInvoker stellarbindings.Invoker + stellarExecutor := stellar.NewExecutor(stellarEncoder, stellarInvoker) + tests := []struct { name string encoders map[mcmstypes.ChainSelector]mcmssdk.Encoder @@ -154,6 +164,24 @@ func TestBuildExecutors(t *testing.T) { aptosSelector: aptosCurseExecutor, }, }, + { + name: "success with stellar", + encoders: map[mcmstypes.ChainSelector]mcmssdk.Encoder{ + stellarSelector: stellarEncoder, + }, + chainMetadata: map[mcmstypes.ChainSelector]mcmstypes.ChainMetadata{ + stellarSelector: { + MCMAddress: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + StartingOpCount: 0, + }, + }, + setup: func(accessor *mocks.ChainAccessor) { + accessor.EXPECT().StellarInvoker(mock.Anything).Return(stellarInvoker, true) + }, + want: map[mcmstypes.ChainSelector]mcmssdk.Executor{ + stellarSelector: stellarExecutor, + }, + }, } for _, tt := range tests { @@ -166,7 +194,7 @@ func TestBuildExecutors(t *testing.T) { got, err := BuildExecutors(chainAccessor, tt.chainMetadata, tt.encoders, mcmstypes.TimelockActionSchedule) if tt.wantErr == "" { require.NoError(t, err) - require.Empty(t, cmp.Diff(tt.want, got)) + require.Empty(t, cmp.Diff(tt.want, got, cmpopts.IgnoreUnexported(stellar.Executor{}, stellar.Inspector{}))) } else { require.ErrorContains(t, err, tt.wantErr) } diff --git a/chainwrappers/inspectors.go b/chainwrappers/inspectors.go index 2df8b657..79a792a7 100644 --- a/chainwrappers/inspectors.go +++ b/chainwrappers/inspectors.go @@ -10,6 +10,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" "github.com/smartcontractkit/mcms/types" @@ -103,6 +104,15 @@ func BuildInspector( } return ton.NewInspector(client), nil + + case chainsel.FamilyStellar: + invoker, ok := chains.StellarInvoker(rawSelector) + if !ok { + return nil, fmt.Errorf("missing stellar invoker for selector %d", rawSelector) + } + + return stellar.NewInspector(invoker), nil + default: return nil, fmt.Errorf("unsupported chain family %s", family) } diff --git a/chainwrappers/inspectors_test.go b/chainwrappers/inspectors_test.go index ed47ad20..d65dd7b8 100644 --- a/chainwrappers/inspectors_test.go +++ b/chainwrappers/inspectors_test.go @@ -48,6 +48,10 @@ func TestMCMInspectorBuilder_BuildInspectors(t *testing.T) { mcmsTypes.ChainSelector(chainsel.SOLANA_DEVNET.Selector): {MCMAddress: "0xsolana", StartingOpCount: 0}, mcmsTypes.ChainSelector(chainsel.APTOS_TESTNET.Selector): {MCMAddress: "0xaptos", StartingOpCount: 0}, mcmsTypes.ChainSelector(chainsel.TON_TESTNET.Selector): {MCMAddress: "0xton", StartingOpCount: 0}, + mcmsTypes.ChainSelector(chainsel.STELLAR_TESTNET.Selector): { + MCMAddress: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + StartingOpCount: 0, + }, mcmsTypes.ChainSelector(chainsel.SUI_TESTNET.Selector): { MCMAddress: "0xsui", StartingOpCount: 0, @@ -70,8 +74,9 @@ func TestMCMInspectorBuilder_BuildInspectors(t *testing.T) { access.EXPECT().SuiClient(mock.Anything).Return(nil, true) access.EXPECT().SuiSigner(mock.Anything).Return(nil, true) access.EXPECT().TonClient(mock.Anything).Return(nil, true) + access.EXPECT().StellarInvoker(mock.Anything).Return(nil, true) }, - expectedInspectorsCount: 5, + expectedInspectorsCount: 6, }, { name: "aptos curse mcms from metadata", diff --git a/chainwrappers/mocks/chain_accessor.go b/chainwrappers/mocks/chain_accessor.go index a0bbdbb6..4ac0f3ce 100644 --- a/chainwrappers/mocks/chain_accessor.go +++ b/chainwrappers/mocks/chain_accessor.go @@ -1,25 +1,19 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks import ( aptos "github.com/aptos-labs/aptos-go-sdk" bind "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + bindings "github.com/smartcontractkit/chainlink-stellar/bindings" - evm "github.com/smartcontractkit/mcms/sdk/evm" - - mock "github.com/stretchr/testify/mock" - + sui "github.com/block-vision/sui-go-sdk/sui" + solana "github.com/gagliardetto/solana-go" rpc "github.com/gagliardetto/solana-go/rpc" - + evm "github.com/smartcontractkit/mcms/sdk/evm" sdksui "github.com/smartcontractkit/mcms/sdk/sui" - - solana "github.com/gagliardetto/solana-go" - - sui "github.com/block-vision/sui-go-sdk/sui" - + mock "github.com/stretchr/testify/mock" ton "github.com/xssnick/tonutils-go/ton" - wallet "github.com/xssnick/tonutils-go/ton/wallet" ) @@ -431,6 +425,64 @@ func (_c *ChainAccessor_SolanaSigner_Call) RunAndReturn(run func(uint64) (*solan return _c } +// StellarInvoker provides a mock function with given fields: selector +func (_m *ChainAccessor) StellarInvoker(selector uint64) (bindings.Invoker, bool) { + ret := _m.Called(selector) + + if len(ret) == 0 { + panic("no return value specified for StellarInvoker") + } + + var r0 bindings.Invoker + var r1 bool + if rf, ok := ret.Get(0).(func(uint64) (bindings.Invoker, bool)); ok { + return rf(selector) + } + if rf, ok := ret.Get(0).(func(uint64) bindings.Invoker); ok { + r0 = rf(selector) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(bindings.Invoker) + } + } + + if rf, ok := ret.Get(1).(func(uint64) bool); ok { + r1 = rf(selector) + } else { + r1 = ret.Get(1).(bool) + } + + return r0, r1 +} + +// ChainAccessor_StellarInvoker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StellarInvoker' +type ChainAccessor_StellarInvoker_Call struct { + *mock.Call +} + +// StellarInvoker is a helper method to define mock.On call +// - selector uint64 +func (_e *ChainAccessor_Expecter) StellarInvoker(selector interface{}) *ChainAccessor_StellarInvoker_Call { + return &ChainAccessor_StellarInvoker_Call{Call: _e.mock.On("StellarInvoker", selector)} +} + +func (_c *ChainAccessor_StellarInvoker_Call) Run(run func(selector uint64)) *ChainAccessor_StellarInvoker_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(uint64)) + }) + return _c +} + +func (_c *ChainAccessor_StellarInvoker_Call) Return(_a0 bindings.Invoker, _a1 bool) *ChainAccessor_StellarInvoker_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ChainAccessor_StellarInvoker_Call) RunAndReturn(run func(uint64) (bindings.Invoker, bool)) *ChainAccessor_StellarInvoker_Call { + _c.Call.Return(run) + return _c +} + // SuiClient provides a mock function with given fields: selector func (_m *ChainAccessor) SuiClient(selector uint64) (sui.ISuiAPI, bool) { ret := _m.Called(selector) diff --git a/chainwrappers/timelock_configurers.go b/chainwrappers/timelock_configurers.go index cc28ffec..d3bf58f0 100644 --- a/chainwrappers/timelock_configurers.go +++ b/chainwrappers/timelock_configurers.go @@ -10,6 +10,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" "github.com/smartcontractkit/mcms/types" @@ -109,6 +110,22 @@ func BuildTimelockConfigurer( return ton.NewTimelockConfigurer(w, ton.DefaultSendAmount), nil + case chainsel.FamilyStellar: + inv, ok := chains.StellarInvoker(rawSelector) + if !ok { + return nil, fmt.Errorf("missing stellar invoker for selector %d", rawSelector) + } + + af, err := stellar.ParseTimelockProposalAdditionalFields(metadata.AdditionalFields) + if err != nil { + return nil, fmt.Errorf("stellar timelock metadata for selector %d: %w", rawSelector, err) + } + if af.TimelockAdmin == "" { + return nil, fmt.Errorf("stellar timelock: timelockAdmin is required in metadata.additionalFields for selector %d", rawSelector) + } + + return stellar.NewTimelockConfigurer(inv, af.TimelockAdmin), nil + default: return nil, fmt.Errorf("unsupported chain family %s", family) } diff --git a/chainwrappers/timelock_configurers_test.go b/chainwrappers/timelock_configurers_test.go index 598b3dfe..223a2232 100644 --- a/chainwrappers/timelock_configurers_test.go +++ b/chainwrappers/timelock_configurers_test.go @@ -2,6 +2,7 @@ package chainwrappers import ( "encoding/json" + "strings" "testing" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -15,6 +16,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" solanasdk "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" mcmsTypes "github.com/smartcontractkit/mcms/types" @@ -23,6 +25,11 @@ import ( func TestBuildTimelockConfigurers(t *testing.T) { t.Parallel() + stellarAdmin := "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H" + stellarSel := mcmsTypes.ChainSelector(chainsel.STELLAR_TESTNET.Selector) + stellarAdditional, err := json.Marshal(map[string]string{"timelockAdmin": stellarAdmin}) + require.NoError(t, err) + tests := []struct { name string chainMetadata map[mcmsTypes.ChainSelector]mcmsTypes.ChainMetadata @@ -62,6 +69,10 @@ func TestBuildTimelockConfigurers(t *testing.T) { "deployer_state_obj":"0xdeployer" }`), }, + stellarSel: { + MCMAddress: strings.Repeat("f", 64), + AdditionalFields: stellarAdditional, + }, }, setup: func(t *testing.T, access *mocks.ChainAccessor, metadata map[mcmsTypes.ChainSelector]mcmsTypes.ChainMetadata) { t.Helper() @@ -77,6 +88,8 @@ func TestBuildTimelockConfigurers(t *testing.T) { access.EXPECT().AptosClient(mock.Anything).Return(nil, true) access.EXPECT().TonSigner(mock.Anything).Return(&wallet.Wallet{}, true) + + access.EXPECT().StellarInvoker(mock.Anything).Return(nil, true) }, expectTypes: map[mcmsTypes.ChainSelector]any{ mcmsTypes.ChainSelector(chainsel.ETHEREUM_TESTNET_SEPOLIA.Selector): (*evm.TimelockConfigurer)(nil), @@ -84,6 +97,7 @@ func TestBuildTimelockConfigurers(t *testing.T) { mcmsTypes.ChainSelector(chainsel.APTOS_TESTNET.Selector): (*aptos.TimelockConfigurer)(nil), mcmsTypes.ChainSelector(chainsel.SUI_TESTNET.Selector): (*sui.TimelockConfigurer)(nil), mcmsTypes.ChainSelector(chainsel.TON_TESTNET.Selector): (*ton.TimelockConfigurer)(nil), + stellarSel: (*stellar.TimelockConfigurer)(nil), }, }, { @@ -152,6 +166,36 @@ func TestBuildTimelockConfigurers(t *testing.T) { expectErr: true, errContains: "missing TON chain wallet", }, + { + name: "missing stellar invoker", + chainMetadata: map[mcmsTypes.ChainSelector]mcmsTypes.ChainMetadata{ + stellarSel: { + MCMAddress: strings.Repeat("f", 64), + AdditionalFields: stellarAdditional, + }, + }, + setup: func(t *testing.T, access *mocks.ChainAccessor, _ map[mcmsTypes.ChainSelector]mcmsTypes.ChainMetadata) { + t.Helper() + access.EXPECT().StellarInvoker(mock.Anything).Return(nil, false) + }, + expectErr: true, + errContains: "missing stellar invoker", + }, + { + name: "missing timelockAdmin", + chainMetadata: map[mcmsTypes.ChainSelector]mcmsTypes.ChainMetadata{ + stellarSel: { + MCMAddress: strings.Repeat("f", 64), + AdditionalFields: []byte(`{}`), + }, + }, + setup: func(t *testing.T, access *mocks.ChainAccessor, _ map[mcmsTypes.ChainSelector]mcmsTypes.ChainMetadata) { + t.Helper() + access.EXPECT().StellarInvoker(mock.Anything).Return(nil, true) + }, + expectErr: true, + errContains: "timelockAdmin", + }, } for _, tc := range tests { diff --git a/chainwrappers/timelock_executors.go b/chainwrappers/timelock_executors.go index 86515211..7a1fed44 100644 --- a/chainwrappers/timelock_executors.go +++ b/chainwrappers/timelock_executors.go @@ -10,6 +10,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" "github.com/smartcontractkit/mcms/types" @@ -135,6 +136,22 @@ func BuildTimelockExecutor( Amount: ton.DefaultSendAmount, }) + case chainsel.FamilyStellar: + inv, ok := chains.StellarInvoker(rawSelector) + if !ok { + return nil, fmt.Errorf("missing stellar invoker for selector %d", chainSelector) + } + + af, err := stellar.ParseTimelockProposalAdditionalFields(metadata.AdditionalFields) + if err != nil { + return nil, fmt.Errorf("stellar timelock metadata for selector %d: %w", chainSelector, err) + } + if af.TimelockExecutor == "" { + return nil, fmt.Errorf("stellar timelock: timelockExecutor is required in metadata.additionalFields for selector %d", chainSelector) + } + + return stellar.NewTimelockExecutor(inv, af.TimelockExecutor), nil + default: return nil, fmt.Errorf("unsupported chain family %s", family) } diff --git a/chainwrappers/timelock_executors_test.go b/chainwrappers/timelock_executors_test.go index eb9d07a7..64d72d33 100644 --- a/chainwrappers/timelock_executors_test.go +++ b/chainwrappers/timelock_executors_test.go @@ -1,6 +1,8 @@ package chainwrappers import ( + "encoding/json" + "strings" "testing" gethbind "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -18,6 +20,7 @@ import ( aptosmocks "github.com/smartcontractkit/mcms/sdk/aptos/mocks/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" suibindmocks "github.com/smartcontractkit/mcms/sdk/sui/mocks/bindutils" suimocks "github.com/smartcontractkit/mcms/sdk/sui/mocks/sui" @@ -48,6 +51,12 @@ func TestBuildTimelockExecutors(t *testing.T) { ton.TimelockExecutorOpts{Client: tonClient, Wallet: tonSigner, Amount: ton.DefaultSendAmount}) require.NoError(t, err) + stellarExecutorCaller := "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H" + stellarExecutor := stellar.NewTimelockExecutor(nil, stellarExecutorCaller) + stellarSel := mcmstypes.ChainSelector(chainsel.STELLAR_TESTNET.Selector) + stellarAdditional, err := json.Marshal(map[string]string{"timelockExecutor": stellarExecutorCaller}) + require.NoError(t, err) + tests := []struct { name string chainMetadata map[mcmstypes.ChainSelector]mcmstypes.ChainMetadata @@ -87,6 +96,11 @@ func TestBuildTimelockExecutors(t *testing.T) { MCMAddress: "0xton", StartingOpCount: 0, }, + stellarSel: { + MCMAddress: strings.Repeat("e", 64), + StartingOpCount: 0, + AdditionalFields: stellarAdditional, + }, }, setup: func(accessor *mocks.ChainAccessor) { accessor.EXPECT().EVMClient(mock.Anything).Return(nil, true) @@ -99,6 +113,7 @@ func TestBuildTimelockExecutors(t *testing.T) { accessor.EXPECT().SuiSigner(mock.Anything).Return(nil, true) accessor.EXPECT().TonClient(mock.Anything).Return(tonClient, true) accessor.EXPECT().TonSigner(mock.Anything).Return(tonSigner, true) + accessor.EXPECT().StellarInvoker(mock.Anything).Return(nil, true) }, want: map[mcmstypes.ChainSelector]mcmssdk.TimelockExecutor{ evmSelector: evmExecutor, @@ -106,6 +121,7 @@ func TestBuildTimelockExecutors(t *testing.T) { aptosSelector: aptosExecutor, suiSelector: suiExecutor, tonSelector: tonExecutor, + stellarSel: stellarExecutor, }, }, } @@ -120,10 +136,36 @@ func TestBuildTimelockExecutors(t *testing.T) { got, err := BuildTimelockExecutors(chainAccessor, tt.chainMetadata, mcmstypes.TimelockActionSchedule) if tt.wantErr == "" { require.NoError(t, err) - require.Empty(t, cmp.Diff(tt.want, got)) + require.Empty(t, cmp.Diff(tt.want, got, + cmp.AllowUnexported(stellar.TimelockExecutor{}, stellar.TimelockInspector{}), + )) } else { require.ErrorContains(t, err, tt.wantErr) } }) } } + +func TestBuildTimelockExecutors_StellarMissingInvoker(t *testing.T) { + t.Parallel() + sel := mcmstypes.ChainSelector(chainsel.STELLAR_TESTNET.Selector) + additional, err := json.Marshal(map[string]string{"timelockExecutor": "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"}) + require.NoError(t, err) + access := mocks.NewChainAccessor(t) + access.EXPECT().StellarInvoker(mock.Anything).Return(nil, false) + _, err = BuildTimelockExecutors(access, map[mcmstypes.ChainSelector]mcmstypes.ChainMetadata{ + sel: {MCMAddress: strings.Repeat("a", 64), AdditionalFields: additional}, + }, mcmstypes.TimelockActionSchedule) + require.ErrorContains(t, err, "missing stellar invoker") +} + +func TestBuildTimelockExecutors_StellarMissingExecutorInMetadata(t *testing.T) { + t.Parallel() + sel := mcmstypes.ChainSelector(chainsel.STELLAR_TESTNET.Selector) + access := mocks.NewChainAccessor(t) + access.EXPECT().StellarInvoker(mock.Anything).Return(nil, true) + _, err := BuildTimelockExecutors(access, map[mcmstypes.ChainSelector]mcmstypes.ChainMetadata{ + sel: {MCMAddress: strings.Repeat("a", 64), AdditionalFields: []byte(`{}`)}, + }, mcmstypes.TimelockActionSchedule) + require.ErrorContains(t, err, "timelockExecutor") +} diff --git a/docs/docs/contributing/integrating-new-chain-guide.md b/docs/docs/contributing/integrating-new-chain-guide.md index fe8ac5da..94b10d47 100644 --- a/docs/docs/contributing/integrating-new-chain-guide.md +++ b/docs/docs/contributing/integrating-new-chain-guide.md @@ -45,12 +45,12 @@ All chain family integrations must implement interfaces defined in the `/sdk` fo ### ChainAccess Registry Adapter -The MCMS SDK intentionally avoids importing chain-registry implementations (for example, the Chainlink Deployments Framework). Instead, shared tooling must expose the `ChainAccess` interface defined in [`sdk/chainclient.go`](https://github.com/smartcontractkit/mcms/blob/main/sdk/chainclient.go) so inspectors and proposal tooling can fetch RPC clients without pulling in external dependencies. +The MCMS SDK intentionally avoids importing chain-registry implementations (for example, the Chainlink Deployments Framework). Instead, shared tooling must expose the `ChainAccessor` interface defined in [`chainwrappers/chainaccessor.go`](https://github.com/smartcontractkit/mcms/blob/main/chainwrappers/chainaccessor.go) so inspectors and proposal tooling can fetch RPC clients without pulling in external dependencies. Your adapter should: -- Implement `Selectors() []uint64` and the per-family lookup helpers (`EVMClient`, `SolanaClient`, `AptosClient`, `Sui`) by delegating to your registry. -- Return chain clients that satisfy `bind.ContractBackend`/`bind.DeployBackend` for EVM, `*solrpc.Client` for Solana, `aptoslib.AptosRpcClient` for Aptos, and `(sui.ISuiAPI, SuiSigner)` for Sui, etc. +- Implement `Selectors() []uint64` and the per-family lookup helpers (`EVMClient`, `SolanaClient`, `AptosClient`, `Sui`, `TonClient` / `TonSigner`, `StellarInvoker`, …) by delegating to your registry. +- Return chain clients that satisfy `bind.ContractBackend`/`bind.DeployBackend` for EVM, `*solrpc.Client` for Solana, `aptoslib.AptosRpcClient` for Aptos, `(sui.ISuiAPI, SuiSigner)` for Sui, and for Stellar a [`github.com/smartcontractkit/chainlink-stellar/bindings.Invoker`](https://github.com/smartcontractkit/chainlink-stellar/tree/main/bindings) used by `sdk/stellar` to simulate and submit Soroban calls. - Live in the repository that already depends on your registry (e.g., CLDF or deployment tooling) so `mcms` itself stays agnostic. This boundary keeps MCMS reusable across environments while still allowing downstream systems to map their chain catalogs into MCMS inspectors. @@ -74,6 +74,7 @@ The `Executor` is the primary interface for executing MCMS operations on your ch - [Solana Executor](https://github.com/smartcontractkit/mcms/blob/main/sdk/solana/executor.go) - Uses Solana program instructions - [Aptos Executor](https://github.com/smartcontractkit/mcms/blob/main/sdk/aptos/executor.go) - Uses Move entry functions - [Sui Executor](https://github.com/smartcontractkit/mcms/blob/main/sdk/sui/executor.go) - Uses Sui Move transactions +- [Stellar Executor](https://github.com/smartcontractkit/mcms/blob/main/sdk/stellar/executor.go) - Uses Soroban `execute` / `set_root` via `chainlink-stellar` `bindings.Invoker` **Key Considerations:** @@ -100,6 +101,7 @@ The `Inspector` queries on-chain state of MCMS contracts. - [Solana Inspector](https://github.com/smartcontractkit/mcms/blob/main/sdk/solana/inspector.go) - [Aptos Inspector](https://github.com/smartcontractkit/mcms/blob/main/sdk/aptos/inspector.go) - [Sui Inspector](https://github.com/smartcontractkit/mcms/blob/main/sdk/sui/inspector.go) +- [Stellar Inspector](https://github.com/smartcontractkit/mcms/blob/main/sdk/stellar/inspector.go) **Key Considerations:** @@ -124,6 +126,7 @@ The `Encoder` creates chain-specific hashes for operations and metadata. - [Solana Encoder](https://github.com/smartcontractkit/mcms/blob/main/sdk/solana/encoder.go) - Uses Borsh serialization + SHA256 - [Aptos Encoder](https://github.com/smartcontractkit/mcms/blob/main/sdk/aptos/encoder.go) - Uses BCS serialization + SHA3-256 - [Sui Encoder](https://github.com/smartcontractkit/mcms/blob/main/sdk/sui/encoder.go) - Uses BCS serialization + Blake2b +- [Stellar Encoder](https://github.com/smartcontractkit/mcms/blob/main/sdk/stellar/encoder.go) - Uses keccak256 over Solidity-ABI-shaped `StellarOp` / metadata (see on-chain `abi_encoding.rs` in chainlink-stellar) **Key Considerations:** @@ -148,6 +151,7 @@ The `ConfigTransformer` converts between chain-agnostic `types.Config` and chain - [Solana ConfigTransformer](https://github.com/smartcontractkit/mcms/blob/main/sdk/solana/config_transformer.go) - [Aptos ConfigTransformer](https://github.com/smartcontractkit/mcms/blob/main/sdk/aptos/config_transformer.go) - [Sui ConfigTransformer](https://github.com/smartcontractkit/mcms/blob/main/sdk/sui/config_transformer.go) +- [Stellar ConfigTransformer](https://github.com/smartcontractkit/mcms/blob/main/sdk/stellar/config_transformer.go) **Key Considerations:** @@ -171,6 +175,7 @@ The `Configurer` updates MCMS contract configuration on-chain. - [Solana Configurer](https://github.com/smartcontractkit/mcms/blob/main/sdk/solana/configurer.go) - [Aptos Configurer](https://github.com/smartcontractkit/mcms/blob/main/sdk/aptos/configurer.go) - [Sui Configurer](https://github.com/smartcontractkit/mcms/blob/main/sdk/sui/configurer.go) +- [Stellar Configurer](https://github.com/smartcontractkit/mcms/blob/main/sdk/stellar/configurer.go) **Key Considerations:** @@ -282,6 +287,8 @@ Converts batch operations into chain-specific timelock operations. - [Aptos TimelockConverter](https://github.com/smartcontractkit/mcms/blob/main/sdk/aptos/timelock_converter.go) - [Sui TimelockConverter](https://github.com/smartcontractkit/mcms/blob/main/sdk/sui/timelock_converter.go) +**Note:** Stellar does not yet ship `TimelockExecutor` / `TimelockInspector` / `TimelockConverter` in mcms; `chainwrappers.BuildConverter` returns an error for Stellar selectors until those implementations exist. + ## Implementation Guidelines ### Package Structure @@ -311,6 +318,8 @@ sdk/ ├── timelock_inspector_test.go ├── timelock_converter.go # TimelockConverter (if supported) ├── timelock_converter_test.go + ├── validation.go # ValidateChainMetadata / ValidateAdditionalFields (optional) + ├── validation_test.go ├── transaction.go # Transaction utilities ├── transaction_test.go ├── utils.go # Chain-specific helpers @@ -350,7 +359,7 @@ sdk/ #### Additional Fields in Operations - Use `types.Transaction.AdditionalFields` (JSON) for chain-specific data -- Examples: Solana account lists, Aptos type arguments, Sui object references +- Examples: Solana account lists, Aptos type arguments, Sui object references, Stellar optional `value` (32-byte hex string for `StellarOp.value` when non-zero) - Document expected structure for your chain ### Error Handling @@ -365,6 +374,10 @@ Use the `/sdk/errors/` package for standardized error handling: - Return specific errors for common failure cases (insufficient signatures, invalid proof, etc.) - Use typed errors for cases that callers may need to handle specifically +### Proposal validation (`validation.go`) + +Root-level [`validation.go`](https://github.com/smartcontractkit/mcms/blob/main/validation.go) dispatches `ValidateChainMetadata` / `ValidateAdditionalFields` to your SDK package for families that need extra checks (for example Solana access-controller JSON, Sui object IDs, Stellar MCMS contract id parsing and optional `value` JSON). Wire new `case` branches when you add a chain family. + ## Testing Requirements ### Unit Tests @@ -376,6 +389,7 @@ Each interface implementation needs a corresponding `_test.go` file with compreh - [EVM Tests](https://github.com/smartcontractkit/mcms/blob/main/sdk/evm/executor_test.go) | [Mock Examples](https://github.com/smartcontractkit/mcms/tree/main/sdk/evm/mocks) - [Solana Tests](https://github.com/smartcontractkit/mcms/blob/main/sdk/solana/encoder_test.go) | [Mocks](https://github.com/smartcontractkit/mcms/tree/main/sdk/solana/mocks) - [Aptos Tests](https://github.com/smartcontractkit/mcms/blob/main/sdk/aptos/inspector_test.go) | [Mocks](https://github.com/smartcontractkit/mcms/tree/main/sdk/aptos/mocks) +- [Stellar Tests](https://github.com/smartcontractkit/mcms/blob/main/sdk/stellar/encoder_test.go) — table-driven tests alongside `chainwrappers` build tests (`executors_test.go`, `inspectors_test.go`) ### E2E Tests @@ -408,4 +422,5 @@ When implementing your integration, refer to these existing implementations: 2. **Solana**: [sdk/solana/](https://github.com/smartcontractkit/mcms/tree/main/sdk/solana) - Excellent example of chain-specific complexity 3. **Aptos**: [sdk/aptos/](https://github.com/smartcontractkit/mcms/tree/main/sdk/aptos) - Move-based chain without simulation 4. **Sui**: [sdk/sui/](https://github.com/smartcontractkit/mcms/tree/main/sdk/sui) - Recent addition with good patterns +5. **Stellar**: [sdk/stellar/](https://github.com/smartcontractkit/mcms/tree/main/sdk/stellar) - Soroban MCMS (encoder, inspector, executor, configurer); timelock parity and e2e still evolving diff --git a/factory.go b/factory.go index 5d7a6a90..954fd9d7 100644 --- a/factory.go +++ b/factory.go @@ -11,6 +11,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" @@ -61,6 +62,12 @@ func newEncoder( txCount, overridePreviousRoot, ) + case chainsel.FamilyStellar: + encoder = stellar.NewEncoder( + csel, + txCount, + overridePreviousRoot, + ) } return encoder, nil @@ -90,6 +97,8 @@ func operationIDFn(_ context.Context, csel types.ChainSelector) (sdk.OperationID return sui.OperationID, nil case chainsel.FamilyTon: return ton.OperationID, nil + case chainsel.FamilyStellar: + return stellar.OperationID, nil default: return nil, fmt.Errorf("unsupported chain family %s", family) } diff --git a/factory_test.go b/factory_test.go index 9893473f..4f21d61c 100644 --- a/factory_test.go +++ b/factory_test.go @@ -13,6 +13,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" ) @@ -95,6 +96,16 @@ func TestNewEncoder(t *testing.T) { OverridePreviousRoot: false, }, }, + { + name: "success: returns a Stellar encoder", + giveSelector: chaintest.Chain9Selector, + giveIsSim: false, + want: &stellar.Encoder{ + TxCount: giveTxCount, + ChainSelector: chaintest.Chain9Selector, + OverridePreviousRoot: false, + }, + }, { name: "failure: chain not found for selector", giveSelector: chaintest.ChainInvalidSelector, diff --git a/internal/testutils/chaintest/testchain.go b/internal/testutils/chaintest/testchain.go index 669da837..8b9cdf9a 100644 --- a/internal/testutils/chaintest/testchain.go +++ b/internal/testutils/chaintest/testchain.go @@ -39,6 +39,10 @@ var ( Chain8Selector = types.ChainSelector(Chain8RawSelector) Chain8StarknetID = chainsel.ETHEREUM_MAINNET_STARKNET_1.ChainID + Chain9RawSelector = chainsel.STELLAR_TESTNET.Selector + Chain9Selector = types.ChainSelector(Chain9RawSelector) + Chain9StellarID = chainsel.STELLAR_TESTNET.ChainID + // ChainInvalidSelector is a chain selector that doesn't exist. ChainInvalidSelector = types.ChainSelector(0) ) diff --git a/sdk/aptos/mocks/aptos/rpcclient.go b/sdk/aptos/mocks/aptos/rpcclient.go index 6331b508..a89480fa 100644 --- a/sdk/aptos/mocks/aptos/rpcclient.go +++ b/sdk/aptos/mocks/aptos/rpcclient.go @@ -1,14 +1,13 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_aptossdk import ( + time "time" + aptos "github.com/aptos-labs/aptos-go-sdk" api "github.com/aptos-labs/aptos-go-sdk/api" - mock "github.com/stretchr/testify/mock" - - time "time" ) // AptosRpcClient is an autogenerated mock type for the AptosRpcClient type diff --git a/sdk/aptos/mocks/aptos/transactionsigner.go b/sdk/aptos/mocks/aptos/transactionsigner.go index ee880ac1..2da49386 100644 --- a/sdk/aptos/mocks/aptos/transactionsigner.go +++ b/sdk/aptos/mocks/aptos/transactionsigner.go @@ -1,11 +1,10 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_aptossdk import ( aptos "github.com/aptos-labs/aptos-go-sdk" crypto "github.com/aptos-labs/aptos-go-sdk/crypto" - mock "github.com/stretchr/testify/mock" ) diff --git a/sdk/aptos/mocks/mcms/mcms.go b/sdk/aptos/mocks/mcms/mcms.go index 60ef397e..d6820124 100644 --- a/sdk/aptos/mocks/mcms/mcms.go +++ b/sdk/aptos/mocks/mcms/mcms.go @@ -1,21 +1,16 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_mcms import ( aptos "github.com/aptos-labs/aptos-go-sdk" - mock "github.com/stretchr/testify/mock" - module_mcms "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms" - module_mcms_account "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms_account" - module_mcms_deployer "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms_deployer" - module_mcms_executor "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms_executor" - module_mcms_registry "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms_registry" + mock "github.com/stretchr/testify/mock" ) // MCMS is an autogenerated mock type for the MCMS type diff --git a/sdk/aptos/mocks/mcms/mcms/mcms.go b/sdk/aptos/mocks/mcms/mcms/mcms.go index 78205145..53f3d189 100644 --- a/sdk/aptos/mocks/mcms/mcms/mcms.go +++ b/sdk/aptos/mocks/mcms/mcms/mcms.go @@ -1,18 +1,15 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_module_mcms import ( - aptos "github.com/aptos-labs/aptos-go-sdk" - api "github.com/aptos-labs/aptos-go-sdk/api" - big "math/big" + aptos "github.com/aptos-labs/aptos-go-sdk" + api "github.com/aptos-labs/aptos-go-sdk/api" bind "github.com/smartcontractkit/chainlink-aptos/bindings/bind" - - mock "github.com/stretchr/testify/mock" - module_mcms "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms" + mock "github.com/stretchr/testify/mock" ) // MCMSInterface is an autogenerated mock type for the MCMSInterface type diff --git a/sdk/aptos/mocks/mcms/mcms/mcms_encoder.go b/sdk/aptos/mocks/mcms/mcms/mcms_encoder.go index 87e1c8e1..35b770af 100644 --- a/sdk/aptos/mocks/mcms/mcms/mcms_encoder.go +++ b/sdk/aptos/mocks/mcms/mcms/mcms_encoder.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_module_mcms @@ -6,12 +6,9 @@ import ( big "math/big" aptos "github.com/aptos-labs/aptos-go-sdk" - bind "github.com/smartcontractkit/chainlink-aptos/bindings/bind" - - mock "github.com/stretchr/testify/mock" - module_mcms "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms" + mock "github.com/stretchr/testify/mock" ) // MCMSEncoder is an autogenerated mock type for the MCMSEncoder type diff --git a/sdk/aptos/mocks/mcms/mcms_executor/mcms_executor.go b/sdk/aptos/mocks/mcms/mcms_executor/mcms_executor.go index f84548aa..80e8bc1b 100644 --- a/sdk/aptos/mocks/mcms/mcms_executor/mcms_executor.go +++ b/sdk/aptos/mocks/mcms/mcms_executor/mcms_executor.go @@ -1,18 +1,15 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_module_mcms_executor import ( - aptos "github.com/aptos-labs/aptos-go-sdk" - api "github.com/aptos-labs/aptos-go-sdk/api" - big "math/big" + aptos "github.com/aptos-labs/aptos-go-sdk" + api "github.com/aptos-labs/aptos-go-sdk/api" bind "github.com/smartcontractkit/chainlink-aptos/bindings/bind" - - mock "github.com/stretchr/testify/mock" - module_mcms_executor "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms_executor" + mock "github.com/stretchr/testify/mock" ) // MCMSExecutorInterface is an autogenerated mock type for the MCMSExecutorInterface type diff --git a/sdk/evm/bindings/mocks/abigen_log.go b/sdk/evm/bindings/mocks/abigen_log.go index a04a697f..ea69b316 100644 --- a/sdk/evm/bindings/mocks/abigen_log.go +++ b/sdk/evm/bindings/mocks/abigen_log.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks diff --git a/sdk/evm/bindings/mocks/call_proxy_interface.go b/sdk/evm/bindings/mocks/call_proxy_interface.go index 47699ab0..526a251c 100644 --- a/sdk/evm/bindings/mocks/call_proxy_interface.go +++ b/sdk/evm/bindings/mocks/call_proxy_interface.go @@ -1,18 +1,14 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks import ( bind "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" - bindings "github.com/smartcontractkit/mcms/sdk/evm/bindings" - common "github.com/ethereum/go-ethereum/common" - + types "github.com/ethereum/go-ethereum/core/types" event "github.com/ethereum/go-ethereum/event" - + bindings "github.com/smartcontractkit/mcms/sdk/evm/bindings" mock "github.com/stretchr/testify/mock" - - types "github.com/ethereum/go-ethereum/core/types" ) // CallProxyInterface is an autogenerated mock type for the CallProxyInterface type diff --git a/sdk/evm/bindings/mocks/many_chain_multi_sig_interface.go b/sdk/evm/bindings/mocks/many_chain_multi_sig_interface.go index 1b824824..a93b7738 100644 --- a/sdk/evm/bindings/mocks/many_chain_multi_sig_interface.go +++ b/sdk/evm/bindings/mocks/many_chain_multi_sig_interface.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks @@ -6,15 +6,11 @@ import ( big "math/big" bind "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" - bindings "github.com/smartcontractkit/mcms/sdk/evm/bindings" - common "github.com/ethereum/go-ethereum/common" - + types "github.com/ethereum/go-ethereum/core/types" event "github.com/ethereum/go-ethereum/event" - + bindings "github.com/smartcontractkit/mcms/sdk/evm/bindings" mock "github.com/stretchr/testify/mock" - - types "github.com/ethereum/go-ethereum/core/types" ) // ManyChainMultiSigInterface is an autogenerated mock type for the ManyChainMultiSigInterface type diff --git a/sdk/evm/bindings/mocks/rbac_timelock_interface.go b/sdk/evm/bindings/mocks/rbac_timelock_interface.go index 511fa3ae..44952496 100644 --- a/sdk/evm/bindings/mocks/rbac_timelock_interface.go +++ b/sdk/evm/bindings/mocks/rbac_timelock_interface.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks @@ -6,15 +6,11 @@ import ( big "math/big" bind "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" - bindings "github.com/smartcontractkit/mcms/sdk/evm/bindings" - common "github.com/ethereum/go-ethereum/common" - + types "github.com/ethereum/go-ethereum/core/types" event "github.com/ethereum/go-ethereum/event" - + bindings "github.com/smartcontractkit/mcms/sdk/evm/bindings" mock "github.com/stretchr/testify/mock" - - types "github.com/ethereum/go-ethereum/core/types" ) // RBACTimelockInterface is an autogenerated mock type for the RBACTimelockInterface type diff --git a/sdk/evm/mocks/contract_deploy_backend.go b/sdk/evm/mocks/contract_deploy_backend.go index fd248d1b..8cabf8b3 100644 --- a/sdk/evm/mocks/contract_deploy_backend.go +++ b/sdk/evm/mocks/contract_deploy_backend.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks @@ -6,13 +6,11 @@ import ( context "context" big "math/big" - common "github.com/ethereum/go-ethereum/common" - ethereum "github.com/ethereum/go-ethereum" - - mock "github.com/stretchr/testify/mock" + common "github.com/ethereum/go-ethereum/common" types "github.com/ethereum/go-ethereum/core/types" + mock "github.com/stretchr/testify/mock" ) // ContractDeployBackend is an autogenerated mock type for the ContractDeployBackend type diff --git a/sdk/mocks/config_transformer.go b/sdk/mocks/config_transformer.go index 7e5cca2b..18f12c0d 100644 --- a/sdk/mocks/config_transformer.go +++ b/sdk/mocks/config_transformer.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks diff --git a/sdk/mocks/configurer.go b/sdk/mocks/configurer.go index fa344b6c..c1cfa06e 100644 --- a/sdk/mocks/configurer.go +++ b/sdk/mocks/configurer.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks diff --git a/sdk/mocks/decoded_operation.go b/sdk/mocks/decoded_operation.go index eaa94ee8..4aec0806 100644 --- a/sdk/mocks/decoded_operation.go +++ b/sdk/mocks/decoded_operation.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks diff --git a/sdk/mocks/decoder.go b/sdk/mocks/decoder.go index 1df1d42c..3967c8b1 100644 --- a/sdk/mocks/decoder.go +++ b/sdk/mocks/decoder.go @@ -1,12 +1,11 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks import ( sdk "github.com/smartcontractkit/mcms/sdk" - mock "github.com/stretchr/testify/mock" - types "github.com/smartcontractkit/mcms/types" + mock "github.com/stretchr/testify/mock" ) // Decoder is an autogenerated mock type for the Decoder type diff --git a/sdk/mocks/encoder.go b/sdk/mocks/encoder.go index 6232d36a..48ca2a2a 100644 --- a/sdk/mocks/encoder.go +++ b/sdk/mocks/encoder.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks diff --git a/sdk/mocks/executor.go b/sdk/mocks/executor.go index cd4f2699..634536cd 100644 --- a/sdk/mocks/executor.go +++ b/sdk/mocks/executor.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks @@ -6,7 +6,6 @@ import ( context "context" common "github.com/ethereum/go-ethereum/common" - mock "github.com/stretchr/testify/mock" types "github.com/smartcontractkit/mcms/types" diff --git a/sdk/mocks/inspector.go b/sdk/mocks/inspector.go index 68632be6..cfd68f1f 100644 --- a/sdk/mocks/inspector.go +++ b/sdk/mocks/inspector.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks @@ -6,7 +6,6 @@ import ( context "context" common "github.com/ethereum/go-ethereum/common" - mock "github.com/stretchr/testify/mock" types "github.com/smartcontractkit/mcms/types" diff --git a/sdk/mocks/logger.go b/sdk/mocks/logger.go index 378e8c46..ed23316a 100644 --- a/sdk/mocks/logger.go +++ b/sdk/mocks/logger.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks diff --git a/sdk/mocks/simulator.go b/sdk/mocks/simulator.go index 9f3dda3e..a76cbc00 100644 --- a/sdk/mocks/simulator.go +++ b/sdk/mocks/simulator.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks @@ -6,7 +6,6 @@ import ( context "context" common "github.com/ethereum/go-ethereum/common" - mock "github.com/stretchr/testify/mock" types "github.com/smartcontractkit/mcms/types" diff --git a/sdk/mocks/timelock_converter.go b/sdk/mocks/timelock_converter.go index 0689c0bd..a2a66d03 100644 --- a/sdk/mocks/timelock_converter.go +++ b/sdk/mocks/timelock_converter.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks @@ -6,7 +6,6 @@ import ( context "context" common "github.com/ethereum/go-ethereum/common" - mock "github.com/stretchr/testify/mock" types "github.com/smartcontractkit/mcms/types" diff --git a/sdk/mocks/timelock_executor.go b/sdk/mocks/timelock_executor.go index bd509ac7..4cd7e9e6 100644 --- a/sdk/mocks/timelock_executor.go +++ b/sdk/mocks/timelock_executor.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks @@ -6,7 +6,6 @@ import ( context "context" common "github.com/ethereum/go-ethereum/common" - mock "github.com/stretchr/testify/mock" types "github.com/smartcontractkit/mcms/types" diff --git a/sdk/mocks/timelock_inspector.go b/sdk/mocks/timelock_inspector.go index 00070c7b..748636f6 100644 --- a/sdk/mocks/timelock_inspector.go +++ b/sdk/mocks/timelock_inspector.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks diff --git a/sdk/solana/mocks/jsonrpcclient.go b/sdk/solana/mocks/jsonrpcclient.go index 2523cca1..f0a7b3f7 100644 --- a/sdk/solana/mocks/jsonrpcclient.go +++ b/sdk/solana/mocks/jsonrpcclient.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mocks @@ -7,7 +7,6 @@ import ( http "net/http" jsonrpc "github.com/gagliardetto/solana-go/rpc/jsonrpc" - mock "github.com/stretchr/testify/mock" ) diff --git a/sdk/stellar/configurer.go b/sdk/stellar/configurer.go new file mode 100644 index 00000000..9c4ddd61 --- /dev/null +++ b/sdk/stellar/configurer.go @@ -0,0 +1,66 @@ +package stellar + +import ( + "context" + "fmt" + + chainsel "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-stellar/bindings" + stellarmcms "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/mcms" + + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +var _ sdk.Configurer = (*Configurer)(nil) + +// Configurer applies MCMS signer configuration on Stellar via Soroban set_config. +type Configurer struct { + ConfigTransformer + invoker bindings.Invoker +} + +// NewConfigurer returns a Configurer that submits set_config through invoker. +func NewConfigurer(invoker bindings.Invoker) *Configurer { + return &Configurer{invoker: invoker} +} + +// SetConfig invokes set_config with signer address vec, group vec, and group tree bytes32 words. +func (c *Configurer) SetConfig(ctx context.Context, mcmAddr string, cfg *types.Config, clearRoot bool) (types.TransactionResult, error) { + if cfg == nil { + return types.TransactionResult{}, fmt.Errorf("nil config") + } + + chainCfg, err := c.ToChainConfig(*cfg, nil) + if err != nil { + return types.TransactionResult{}, err + } + + signerAddresses, signerGroups := setConfigVecsFromChainConfig(chainCfg) + + client, err := newMCMSClient(c.invoker, mcmAddr) + if err != nil { + return types.TransactionResult{}, err + } + + if err := client.SetConfig(ctx, signerAddresses, signerGroups, chainCfg.GroupQuorums, chainCfg.GroupParents, clearRoot); err != nil { + return types.TransactionResult{ChainFamily: chainsel.FamilyStellar}, err + } + + return stellarTransactionResult(c.invoker), nil +} + +func setConfigVecsFromChainConfig(chainCfg *stellarmcms.Config) (stellarmcms.SignerAddresses, stellarmcms.SignerGroups) { + n := len(chainCfg.Signers) + + addrs := stellarmcms.SignerAddresses{Inner: make([][32]byte, n)} + grps := stellarmcms.SignerGroups{Inner: make([]uint32, n)} + + for i := range chainCfg.Signers { + addrs.Inner[i] = chainCfg.Signers[i].Addr + grps.Inner[i] = chainCfg.Signers[i].Group + } + + return addrs, grps +} diff --git a/sdk/stellar/configurer_test.go b/sdk/stellar/configurer_test.go new file mode 100644 index 00000000..3af79900 --- /dev/null +++ b/sdk/stellar/configurer_test.go @@ -0,0 +1,41 @@ +package stellar + +import ( + "context" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + chainsel "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/mcms/types" +) + +func TestConfigurer_SetConfig_nilConfig(t *testing.T) { + t.Parallel() + + c := NewConfigurer(&recordingInvoker{}) + ctx := context.Background() + + _, err := c.SetConfig(ctx, "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", nil, false) + require.Error(t, err) +} + +func TestConfigurer_SetConfig_routesToSetConfig(t *testing.T) { + t.Parallel() + + inv := &recordingInvoker{} + c := NewConfigurer(inv) + ctx := context.Background() + + cfg := &types.Config{ + Quorum: 1, + Signers: []common.Address{{1}}, + } + + res, err := c.SetConfig(ctx, "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", cfg, true) + require.NoError(t, err) + require.Equal(t, chainsel.FamilyStellar, res.ChainFamily) + require.Equal(t, "set_config", inv.lastFn) +} diff --git a/sdk/stellar/executor.go b/sdk/stellar/executor.go index e66c40c6..9f3038d2 100644 --- a/sdk/stellar/executor.go +++ b/sdk/stellar/executor.go @@ -79,7 +79,7 @@ func (e *Executor) ExecuteOperation( Value: valueWord, } - mcmsClient, err := e.mcmsClient(metadata.MCMAddress) + mcmsClient, err := newMCMSClient(e.invoker, metadata.MCMAddress) if err != nil { return types.TransactionResult{}, err } @@ -90,7 +90,7 @@ func (e *Executor) ExecuteOperation( return types.TransactionResult{ChainFamily: chainsel.FamilyStellar}, err } - return e.txResult(), nil + return stellarTransactionResult(e.invoker), nil } // SetRoot invokes Soroban `set_root` with metadata and ECDSA signatures (contract ABI layout). @@ -115,7 +115,7 @@ func (e *Executor) SetRoot( return types.TransactionResult{}, err } - mcmsClient, err := e.mcmsClient(metadata.MCMAddress) + mcmsClient, err := newMCMSClient(e.invoker, metadata.MCMAddress) if err != nil { return types.TransactionResult{}, err } @@ -127,16 +127,7 @@ func (e *Executor) SetRoot( return types.TransactionResult{ChainFamily: chainsel.FamilyStellar}, err } - return e.txResult(), nil -} - -func (e *Executor) mcmsClient(mcmAddr string) (*stellarmcms.McmsClient, error) { - id, err := normalizeContractIDStrkey(mcmAddr) - if err != nil { - return nil, err - } - - return stellarmcms.NewMcmsClient(e.invoker, id), nil + return stellarTransactionResult(e.invoker), nil } func (e *Executor) stellarRootMetadata(metadata types.ChainMetadata) (stellarmcms.StellarRootMetadata, error) { @@ -191,18 +182,3 @@ func signatureVecFrom(sorted []types.Signature) stellarmcms.SignatureVec { return stellarmcms.SignatureVec{Inner: inner} } - -// txHashInvoker is optionally implemented by invokers that expose the last submitted Soroban tx hash. -type txHashInvoker interface { - LastSubmittedTransactionHash() string -} - -func (e *Executor) txResult() types.TransactionResult { - hash := "" - - if th, ok := e.invoker.(txHashInvoker); ok { - hash = th.LastSubmittedTransactionHash() - } - - return types.NewTransactionResult(hash, nil, chainsel.FamilyStellar) -} diff --git a/sdk/stellar/inspector.go b/sdk/stellar/inspector.go index f032addb..324b8b63 100644 --- a/sdk/stellar/inspector.go +++ b/sdk/stellar/inspector.go @@ -30,12 +30,7 @@ func NewInspector(invoker bindings.Invoker) *Inspector { } func (i *Inspector) contractClient(mcmAddr string) (*stellarmcms.McmsClient, error) { - id, err := normalizeContractIDStrkey(mcmAddr) - if err != nil { - return nil, err - } - - return stellarmcms.NewMcmsClient(i.invoker, id), nil + return newMCMSClient(i.invoker, mcmAddr) } // GetConfig returns the live multisig configuration from the contract. diff --git a/sdk/stellar/mcms_client.go b/sdk/stellar/mcms_client.go new file mode 100644 index 00000000..290f93e0 --- /dev/null +++ b/sdk/stellar/mcms_client.go @@ -0,0 +1,16 @@ +package stellar + +import ( + "github.com/smartcontractkit/chainlink-stellar/bindings" + stellarmcms "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/mcms" +) + +// newMCMSClient returns a McmsClient for mcmAddr (hex or contract strkey), using invoker for RPC. +func newMCMSClient(invoker bindings.Invoker, mcmAddr string) (*stellarmcms.McmsClient, error) { + id, err := normalizeContractIDStrkey(mcmAddr) + if err != nil { + return nil, err + } + + return stellarmcms.NewMcmsClient(invoker, id), nil +} diff --git a/sdk/stellar/timelock_configurer.go b/sdk/stellar/timelock_configurer.go new file mode 100644 index 00000000..25f42c28 --- /dev/null +++ b/sdk/stellar/timelock_configurer.go @@ -0,0 +1,52 @@ +package stellar + +import ( + "context" + "fmt" + + "github.com/smartcontractkit/chainlink-stellar/bindings" + timelockbindings "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/timelock" + + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +var _ sdk.TimelockConfigurer = (*TimelockConfigurer)(nil) + +// TimelockConfigurer updates timelock parameters on Soroban RBACTimelock. +type TimelockConfigurer struct { + TimelockInspector + invoker bindings.Invoker + adminCaller string +} + +// NewTimelockConfigurer returns a configurer that invokes update_delay as adminCaller (must hold +// ADMIN on the timelock contract). +func NewTimelockConfigurer(invoker bindings.Invoker, adminCaller string) *TimelockConfigurer { + return &TimelockConfigurer{ + TimelockInspector: *NewTimelockInspector(invoker), + invoker: invoker, + adminCaller: adminCaller, + } +} + +// UpdateDelay calls update_delay on the timelock contract. +func (c *TimelockConfigurer) UpdateDelay( + ctx context.Context, timelockAddress string, newDelay uint64, +) (types.TransactionResult, error) { + if c.adminCaller == "" { + return types.TransactionResult{}, fmt.Errorf("stellar timelock: admin caller address is empty") + } + + id, err := normalizeContractIDStrkey(timelockAddress) + if err != nil { + return types.TransactionResult{}, err + } + + client := timelockbindings.NewTimelockClient(c.invoker, id) + if err := client.UpdateDelay(ctx, c.adminCaller, newDelay); err != nil { + return types.TransactionResult{}, err + } + + return stellarTransactionResult(c.invoker), nil +} diff --git a/sdk/stellar/timelock_configurer_test.go b/sdk/stellar/timelock_configurer_test.go new file mode 100644 index 00000000..f96cdda3 --- /dev/null +++ b/sdk/stellar/timelock_configurer_test.go @@ -0,0 +1,14 @@ +package stellar + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTimelockConfigurer_UpdateDelayRequiresAdmin(t *testing.T) { + t.Parallel() + c := NewTimelockConfigurer(&timelockSimInvoker{}, "") + _, err := c.UpdateDelay(t.Context(), stringsRepeatHexAddr('c'), 10) + require.ErrorContains(t, err, "admin caller") +} diff --git a/sdk/stellar/timelock_converter.go b/sdk/stellar/timelock_converter.go index a612ae08..e1310ac1 100644 --- a/sdk/stellar/timelock_converter.go +++ b/sdk/stellar/timelock_converter.go @@ -1,19 +1,217 @@ package stellar import ( + "context" + "encoding/json" "fmt" "github.com/ethereum/go-ethereum/common" + timelockbindings "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/timelock" + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" + + "github.com/smartcontractkit/mcms/sdk" "github.com/smartcontractkit/mcms/types" ) -// OperationID is not implemented for Stellar timelock flows until timelock parity exists on Soroban. +var _ sdk.TimelockConverter = (*TimelockConverter)(nil) + +// TimelockConverter converts timelock proposal batches into Soroban MCMS operations whose +// transaction.to is the timelock contract and transaction.data is Soroban invoke payload bytes +// (ScVec of Symbol + args) as consumed by MCMS execute / timelock decode_invoke_payload. +type TimelockConverter struct{} + +// NewTimelockConverter returns a TimelockConverter for Stellar / Soroban RBACTimelock. +func NewTimelockConverter() *TimelockConverter { + return &TimelockConverter{} +} + +// TimelockProposalAdditionalFields are JSON fields on types.ChainMetadata.AdditionalFields for +// Stellar timelock proposals. The address must hold the corresponding on-chain role when the +// timelock entrypoint runs (first argument to schedule_batch / cancel / bypasser_execute_batch). +// +// For [sdk.TimelockExecutor] / [sdk.TimelockConfigurer] wiring, set timelockExecutor (execute_batch +// caller) and timelockAdmin (update_delay caller) respectively. +type TimelockProposalAdditionalFields struct { + TimelockProposer string `json:"timelockProposer,omitempty"` + TimelockCanceller string `json:"timelockCanceller,omitempty"` + TimelockBypasser string `json:"timelockBypasser,omitempty"` + TimelockExecutor string `json:"timelockExecutor,omitempty"` + TimelockAdmin string `json:"timelockAdmin,omitempty"` +} + +// ParseTimelockProposalAdditionalFields unmarshals Stellar timelock-related additional metadata. +func ParseTimelockProposalAdditionalFields(raw json.RawMessage) (TimelockProposalAdditionalFields, error) { + var z TimelockProposalAdditionalFields + if len(raw) == 0 { + return z, fmt.Errorf("stellar timelock: chain metadata additionalFields is required") + } + if err := json.Unmarshal(raw, &z); err != nil { + return z, fmt.Errorf("stellar timelock: additionalFields: %w", err) + } + + return z, nil +} + +func callsFromBatchOperation(bop types.BatchOperation) ([]timelockbindings.Call, error) { + out := make([]timelockbindings.Call, 0, len(bop.Transactions)) + for _, tx := range bop.Transactions { + to, err := ParseContractID(tx.To) + if err != nil { + return nil, fmt.Errorf("batch transaction to: %w", err) + } + + out = append(out, timelockbindings.Call{ + To: to, + Data: tx.Data, + }) + } + + return out, nil +} + +func (t TimelockConverter) ConvertBatchToChainOperations( + ctx context.Context, + metadata types.ChainMetadata, + batchOp types.BatchOperation, + timelockAddress string, + mcmAddress string, + delay types.Duration, + action types.TimelockAction, + predecessor common.Hash, + salt common.Hash, +) ([]types.Operation, common.Hash, error) { + _ = ctx + _ = mcmAddress + + if _, err := ParseContractID(timelockAddress); err != nil { + return nil, common.Hash{}, fmt.Errorf("timelock address: %w", err) + } + + if _, err := ParseContractID(mcmAddress); err != nil { + return nil, common.Hash{}, fmt.Errorf("mcm address: %w", err) + } + + af, err := ParseTimelockProposalAdditionalFields(metadata.AdditionalFields) + if err != nil { + return nil, common.Hash{}, err + } + + pred := predecessor + if action == types.TimelockActionBypass { + pred = common.Hash{} + } + + calls, err := callsFromBatchOperation(batchOp) + if err != nil { + return nil, common.Hash{}, err + } + + operationID := HashOperationBatch(calls, pred, salt) + + tags := make([]string, 0) + for _, tx := range batchOp.Transactions { + tags = append(tags, tx.Tags...) + } + + var data []byte + + switch action { + case types.TimelockActionSchedule: + caller := af.TimelockProposer + if caller == "" { + return nil, common.Hash{}, fmt.Errorf("stellar timelock: timelockProposer is required in metadata.additionalFields") + } + + callsVal, err := timelockbindings.Calls{Inner: calls}.ToScVal() + if err != nil { + return nil, common.Hash{}, fmt.Errorf("calls to ScVal: %w", err) + } + + data, err = sorobanInvokePayloadBytes("schedule_batch", + scval.AddressToScVal(caller), + callsVal, + scval.Bytes32ToScVal(pred), + scval.Bytes32ToScVal(salt), + scval.Uint64ToScVal(uint64(delay.Seconds())), + ) + if err != nil { + return nil, common.Hash{}, err + } + + case types.TimelockActionCancel: + caller := af.TimelockCanceller + if caller == "" { + return nil, common.Hash{}, fmt.Errorf("stellar timelock: timelockCanceller is required in metadata.additionalFields") + } + + data, err = sorobanInvokePayloadBytes("cancel", + scval.AddressToScVal(caller), + scval.Bytes32ToScVal(operationID), + ) + if err != nil { + return nil, common.Hash{}, err + } + + case types.TimelockActionBypass: + caller := af.TimelockBypasser + if caller == "" { + return nil, common.Hash{}, fmt.Errorf("stellar timelock: timelockBypasser is required in metadata.additionalFields") + } + + callsVal, err := timelockbindings.Calls{Inner: calls}.ToScVal() + if err != nil { + return nil, common.Hash{}, fmt.Errorf("calls to ScVal: %w", err) + } + + data, err = sorobanInvokePayloadBytes("bypasser_execute_batch", + scval.AddressToScVal(caller), + callsVal, + ) + if err != nil { + return nil, common.Hash{}, err + } + + default: + return nil, common.Hash{}, fmt.Errorf("invalid timelock action: %s", action) + } + + additional := json.RawMessage([]byte("{}")) + + op := types.Operation{ + ChainSelector: batchOp.ChainSelector, + Transaction: types.Transaction{ + OperationMetadata: types.OperationMetadata{ + ContractType: "RBACTimelock", + Tags: tags, + }, + To: timelockAddress, + Data: data, + AdditionalFields: additional, + }, + } + + return []types.Operation{op}, operationID, nil +} + +// OperationID returns the Soroban timelock operation id for the batch (same as on-chain +// hash_operation_batch). For bypass actions predecessor is treated as zero before hashing, +// matching schedule vs bypass semantics on-chain. func OperationID( - _ types.BatchOperation, - _ types.TimelockAction, - _ common.Hash, - _ common.Hash, + batchOp types.BatchOperation, + action types.TimelockAction, + predecessor common.Hash, + salt common.Hash, ) (common.Hash, error) { - return common.Hash{}, fmt.Errorf("stellar timelock OperationID is not implemented") + calls, err := callsFromBatchOperation(batchOp) + if err != nil { + return common.Hash{}, err + } + + pred := predecessor + if action == types.TimelockActionBypass { + pred = common.Hash{} + } + + return HashOperationBatch(calls, pred, salt), nil } diff --git a/sdk/stellar/timelock_converter_test.go b/sdk/stellar/timelock_converter_test.go new file mode 100644 index 00000000..54201c50 --- /dev/null +++ b/sdk/stellar/timelock_converter_test.go @@ -0,0 +1,92 @@ +package stellar + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/mcms/types" +) + +func TestTimelockConverter_ScheduleAndOperationID(t *testing.T) { + t.Parallel() + tl := strings.Repeat("c", 64) + mcm := strings.Repeat("b", 64) + md := types.ChainMetadata{ + MCMAddress: mcm, + StartingOpCount: 0, + AdditionalFields: mustJSON(t, map[string]string{ + "timelockProposer": "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + }), + } + bop := types.BatchOperation{ + ChainSelector: 1, + Transactions: []types.Transaction{ + { + OperationMetadata: types.OperationMetadata{Tags: []string{"t1"}}, + To: strings.Repeat("a", 64), + Data: []byte{0xde, 0xad}, + AdditionalFields: []byte("{}"), + }, + }, + } + + conv := NewTimelockConverter() + opIDWant, err := OperationID(bop, types.TimelockActionSchedule, common.Hash{}, common.Hash{31: 3}) + require.NoError(t, err) + + ops, opIDGot, err := conv.ConvertBatchToChainOperations( + t.Context(), + md, + bop, + tl, + mcm, + types.NewDuration(100*time.Second), + types.TimelockActionSchedule, + common.Hash{}, + common.Hash{31: 3}, + ) + require.NoError(t, err) + require.Equal(t, opIDWant, opIDGot) + require.Len(t, ops, 1) + require.Equal(t, tl, ops[0].Transaction.To) + require.NotEmpty(t, ops[0].Transaction.Data) + require.Equal(t, "RBACTimelock", ops[0].Transaction.ContractType) + require.Equal(t, []string{"t1"}, ops[0].Transaction.Tags) +} + +func TestTimelockConverter_MissingProposer(t *testing.T) { + t.Parallel() + conv := NewTimelockConverter() + md := types.ChainMetadata{ + MCMAddress: strings.Repeat("b", 64), + AdditionalFields: mustJSON(t, map[string]string{}), + } + bop := types.BatchOperation{ + ChainSelector: 1, + Transactions: []types.Transaction{ + {To: strings.Repeat("a", 64), Data: []byte{1}, AdditionalFields: []byte("{}")}, + }, + } + _, _, err := conv.ConvertBatchToChainOperations( + t.Context(), md, bop, + strings.Repeat("c", 64), + strings.Repeat("b", 64), + types.NewDuration(time.Second), + types.TimelockActionSchedule, + common.Hash{}, common.Hash{}, + ) + require.ErrorContains(t, err, "timelockProposer") +} + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + + return b +} diff --git a/sdk/stellar/timelock_executor.go b/sdk/stellar/timelock_executor.go new file mode 100644 index 00000000..34f73670 --- /dev/null +++ b/sdk/stellar/timelock_executor.go @@ -0,0 +1,65 @@ +package stellar + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-stellar/bindings" + timelockbindings "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/timelock" + + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +var _ sdk.TimelockExecutor = (*TimelockExecutor)(nil) + +// TimelockExecutor submits execute_batch on Soroban RBACTimelock via [bindings.Invoker]. +type TimelockExecutor struct { + *TimelockInspector + invoker bindings.Invoker + executorCaller string +} + +// NewTimelockExecutor builds an executor that invokes execute_batch as executorCaller (must hold +// EXECUTOR on the timelock contract). +func NewTimelockExecutor(invoker bindings.Invoker, executorCaller string) *TimelockExecutor { + return &TimelockExecutor{ + TimelockInspector: NewTimelockInspector(invoker), + invoker: invoker, + executorCaller: executorCaller, + } +} + +// Execute invokes execute_batch with the batch calls, predecessor, and salt (operation id must match). +func (e *TimelockExecutor) Execute( + ctx context.Context, + bop types.BatchOperation, + timelockAddress string, + predecessor common.Hash, + salt common.Hash, +) (types.TransactionResult, error) { + if e.executorCaller == "" { + return types.TransactionResult{}, fmt.Errorf("stellar timelock: executor caller address is empty") + } + + id, err := normalizeContractIDStrkey(timelockAddress) + if err != nil { + return types.TransactionResult{}, err + } + + calls, err := callsFromBatchOperation(bop) + if err != nil { + return types.TransactionResult{}, err + } + + client := timelockbindings.NewTimelockClient(e.invoker, id) + + tbCalls := timelockbindings.Calls{Inner: calls} + if err := client.ExecuteBatch(ctx, e.executorCaller, tbCalls, predecessor, salt); err != nil { + return types.TransactionResult{}, err + } + + return stellarTransactionResult(e.invoker), nil +} diff --git a/sdk/stellar/timelock_hash.go b/sdk/stellar/timelock_hash.go new file mode 100644 index 00000000..614daf52 --- /dev/null +++ b/sdk/stellar/timelock_hash.go @@ -0,0 +1,43 @@ +package stellar + +import ( + "encoding/binary" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + timelockbindings "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/timelock" +) + +// HashOperationBatch matches Soroban RBACTimelock +// hash_operation_batch_internal in chainlink-stellar/contracts/timelock/src/lib.rs: +// +// call_hash_i = keccak256(to_i || keccak256(data_i)) +// id = keccak256(n_calls_u256_be || call_hash_0 || … || call_hash_n || predecessor || salt) +func HashOperationBatch(calls []timelockbindings.Call, predecessor, salt common.Hash) common.Hash { + var buf []byte + + n := uint64(len(calls)) + var nWord [32]byte + binary.BigEndian.PutUint64(nWord[24:32], n) + buf = append(buf, nWord[:]...) + + for _, c := range calls { + h := hashSingleCall(c) + buf = append(buf, h[:]...) + } + + buf = append(buf, predecessor[:]...) + buf = append(buf, salt[:]...) + + return crypto.Keccak256Hash(buf) +} + +func hashSingleCall(c timelockbindings.Call) common.Hash { + dataHash := crypto.Keccak256Hash(c.Data) + var concat [64]byte + copy(concat[:32], c.To[:]) + copy(concat[32:], dataHash[:]) + + return crypto.Keccak256Hash(concat[:]) +} diff --git a/sdk/stellar/timelock_hash_test.go b/sdk/stellar/timelock_hash_test.go new file mode 100644 index 00000000..8c44b863 --- /dev/null +++ b/sdk/stellar/timelock_hash_test.go @@ -0,0 +1,84 @@ +package stellar + +import ( + "encoding/binary" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + timelockbindings "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/timelock" + + "github.com/smartcontractkit/mcms/types" +) + +func TestHashOperationBatchGolden_EmptyCalls(t *testing.T) { + t.Parallel() + var pred common.Hash + var salt common.Hash + salt[31] = 1 + + got := HashOperationBatch(nil, pred, salt) + want := common.HexToHash("0xcbfe4baa920060fc34aa65135b74b83fa81df36f6e21d90c8301c8810d2c89d9") + require.Equal(t, want, got, "must match contracts/timelock hash_operation_batch_internal (n=0, zero pred, salt[31]=1)") +} + +func TestHashOperationBatchGolden_OneCallEmptyData(t *testing.T) { + t.Parallel() + var to [32]byte + for i := range to { + to[i] = 0x11 + } + calls := []timelockbindings.Call{{To: to, Data: nil}} + var pred common.Hash + var salt common.Hash + salt[31] = 2 + + got := HashOperationBatch(calls, pred, salt) + want := common.HexToHash("0x6ead1e78e7912c0a67d23eba158933299324df465db1c2e9d5ee89aa37dea436") + require.Equal(t, want, got) + + var concat [64]byte + copy(concat[:32], to[:]) + copy(concat[32:], crypto.Keccak256Hash([]byte{}).Bytes()) + callH := crypto.Keccak256Hash(concat[:]) + require.Equal(t, common.HexToHash("0x0323fdea0b67062f39f74437ee69f91108a863c0a6c49271c0ff5684e4cc2c34"), callH) + + var buf []byte + var nWord [32]byte + binary.BigEndian.PutUint64(nWord[24:32], 1) + buf = append(buf, nWord[:]...) + buf = append(buf, callH[:]...) + buf = append(buf, pred[:]...) + buf = append(buf, salt[:]...) + require.Equal(t, want, crypto.Keccak256Hash(buf)) +} + +func TestHashOperationBatchBypassZeroesPredecessor(t *testing.T) { + t.Parallel() + var pred common.Hash + pred[0] = 0xab + var salt common.Hash + var to [32]byte + for i := range to { + to[i] = 1 + } + calls := []timelockbindings.Call{ + {To: to, Data: []byte{1}}, + } + + gotSchedule := HashOperationBatch(calls, pred, salt) + gotBypass := HashOperationBatch(calls, common.Hash{}, salt) + require.NotEqual(t, gotSchedule, gotBypass) + + gotBypass2, err := OperationID(types.BatchOperation{ + ChainSelector: 1, + Transactions: []types.Transaction{ + {To: strings.Repeat("01", 32), Data: []byte{1}, AdditionalFields: []byte("{}")}, + }, + }, types.TimelockActionBypass, pred, salt) + require.NoError(t, err) + require.Equal(t, gotBypass, gotBypass2) +} diff --git a/sdk/stellar/timelock_inspector.go b/sdk/stellar/timelock_inspector.go new file mode 100644 index 00000000..c7655a0d --- /dev/null +++ b/sdk/stellar/timelock_inspector.go @@ -0,0 +1,134 @@ +package stellar + +import ( + "context" + "fmt" + + "github.com/smartcontractkit/chainlink-stellar/bindings" + timelockbindings "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/timelock" + + "github.com/smartcontractkit/mcms/sdk" +) + +// Soroban timelock role symbols match contracts/timelock/src/types.rs (symbol_short). +const ( + timelockRoleProposer = "PROPOSER" + timelockRoleExecutor = "EXECUTOR" + timelockRoleBypasser = "BYPASSER" + timelockRoleCanceller = "CANCELLER" +) + +var _ sdk.TimelockInspector = (*TimelockInspector)(nil) + +// TimelockInspector reads Soroban RBACTimelock state via SimulateContract on [bindings.Invoker]. +type TimelockInspector struct { + invoker bindings.Invoker +} + +// NewTimelockInspector constructs a TimelockInspector for the given invoker (RPC / deployer). +func NewTimelockInspector(invoker bindings.Invoker) *TimelockInspector { + return &TimelockInspector{invoker: invoker} +} + +func (t *TimelockInspector) clientFor(_ context.Context, address string) (*timelockbindings.TimelockClient, error) { + id, err := normalizeContractIDStrkey(address) + if err != nil { + return nil, err + } + + return timelockbindings.NewTimelockClient(t.invoker, id), nil +} + +func (t *TimelockInspector) roleMembers(ctx context.Context, address string, role string) ([]string, error) { + c, err := t.clientFor(ctx, address) + if err != nil { + return nil, err + } + + n, err := c.GetRoleMemberCount(ctx, role) + if err != nil { + return nil, fmt.Errorf("get_role_member_count %s: %w", role, err) + } + + out := make([]string, 0, n) + for i := range n { + member, err := c.GetRoleMember(ctx, role, i) + if err != nil { + return nil, fmt.Errorf("get_role_member %s index %d: %w", role, i, err) + } + + out = append(out, member) + } + + return out, nil +} + +// GetProposers returns addresses with the PROPOSER role. +func (t *TimelockInspector) GetProposers(ctx context.Context, address string) ([]string, error) { + return t.roleMembers(ctx, address, timelockRoleProposer) +} + +// GetExecutors returns addresses with the EXECUTOR role. +func (t *TimelockInspector) GetExecutors(ctx context.Context, address string) ([]string, error) { + return t.roleMembers(ctx, address, timelockRoleExecutor) +} + +// GetBypassers returns addresses with the BYPASSER role. +func (t *TimelockInspector) GetBypassers(ctx context.Context, address string) ([]string, error) { + return t.roleMembers(ctx, address, timelockRoleBypasser) +} + +// GetCancellers returns addresses with the CANCELLER role. +func (t *TimelockInspector) GetCancellers(ctx context.Context, address string) ([]string, error) { + return t.roleMembers(ctx, address, timelockRoleCanceller) +} + +// IsOperation returns true if the operation id exists (any non-zero timestamp entry). +func (t *TimelockInspector) IsOperation(ctx context.Context, address string, opID [32]byte) (bool, error) { + c, err := t.clientFor(ctx, address) + if err != nil { + return false, err + } + + return c.IsOperation(ctx, opID) +} + +// IsOperationPending returns true if the operation is scheduled but not yet ready or done. +func (t *TimelockInspector) IsOperationPending(ctx context.Context, address string, opID [32]byte) (bool, error) { + c, err := t.clientFor(ctx, address) + if err != nil { + return false, err + } + + return c.IsOperationPending(ctx, opID) +} + +// IsOperationReady returns true if the operation is scheduled and the delay has elapsed. +func (t *TimelockInspector) IsOperationReady(ctx context.Context, address string, opID [32]byte) (bool, error) { + c, err := t.clientFor(ctx, address) + if err != nil { + return false, err + } + + return c.IsOperationReady(ctx, opID) +} + +// IsOperationDone returns true if the operation has been executed. +func (t *TimelockInspector) IsOperationDone(ctx context.Context, address string, opID [32]byte) (bool, error) { + c, err := t.clientFor(ctx, address) + if err != nil { + return false, err + } + + return c.IsOperationDone(ctx, opID) +} + +// GetMinDelay returns the timelock minimum delay in seconds. +func (t *TimelockInspector) GetMinDelay(ctx context.Context, address string) (uint64, error) { + c, err := t.clientFor(ctx, address) + if err != nil { + return 0, err + } + + return c.GetMinDelay(ctx) +} diff --git a/sdk/stellar/timelock_inspector_test.go b/sdk/stellar/timelock_inspector_test.go new file mode 100644 index 00000000..e7d4d5d3 --- /dev/null +++ b/sdk/stellar/timelock_inspector_test.go @@ -0,0 +1,184 @@ +package stellar + +import ( + "context" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" + protocolrpc "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/xdr" + + "github.com/smartcontractkit/mcms/types" +) + +// timelockSimInvoker stubs Soroban simulation for timelock read methods used by TimelockInspector. +type timelockSimInvoker struct { + minDelay uint64 + roleCounts map[string]uint32 + roleMember map[string]map[uint32]string + opExists map[[32]byte]bool + opPending map[[32]byte]bool + opReady map[[32]byte]bool + opDone map[[32]byte]bool +} + +func (m *timelockSimInvoker) InvokeContract(context.Context, string, string, []xdr.ScVal) (*xdr.ScVal, error) { + return nil, invokerNotImplementedError{} +} + +func (m *timelockSimInvoker) GetEvents(context.Context, string, uint32, []string) ([]protocolrpc.EventInfo, error) { + return nil, invokerNotImplementedError{} +} + +func (m *timelockSimInvoker) SimulateContract(_ context.Context, _ string, fn string, args []xdr.ScVal) (*xdr.ScVal, error) { + switch fn { + case "get_min_delay": + v := scval.Uint64ToScVal(m.minDelay) + + return &v, nil + + case "get_role_member_count": + role, err := scval.SymbolFromScVal(args[0]) + if err != nil { + return nil, err + } + + n := m.roleCounts[role] + v := scval.Uint32ToScVal(n) + + return &v, nil + + case "get_role_member": + role, err := scval.SymbolFromScVal(args[0]) + if err != nil { + return nil, err + } + + idx, err := scval.Uint32FromScVal(args[1]) + if err != nil { + return nil, err + } + + addr := m.roleMember[role][idx] + v := scval.AddressToScVal(addr) + + return &v, nil + + case "is_operation": + id, err := scval.Bytes32FromScVal(args[0]) + if err != nil { + return nil, err + } + + b := m.opExists[id] + v := scval.BoolToScVal(b) + + return &v, nil + + case "is_operation_pending": + id, err := scval.Bytes32FromScVal(args[0]) + if err != nil { + return nil, err + } + + b := m.opPending[id] + v := scval.BoolToScVal(b) + + return &v, nil + + case "is_operation_ready": + id, err := scval.Bytes32FromScVal(args[0]) + if err != nil { + return nil, err + } + + b := m.opReady[id] + v := scval.BoolToScVal(b) + + return &v, nil + + case "is_operation_done": + id, err := scval.Bytes32FromScVal(args[0]) + if err != nil { + return nil, err + } + + b := m.opDone[id] + v := scval.BoolToScVal(b) + + return &v, nil + + default: + return nil, invokerNotImplementedError{} + } +} + +func TestTimelockInspector_rolesAndOps(t *testing.T) { + t.Parallel() + + execAddr := "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H" + inv := &timelockSimInvoker{ + minDelay: 42, + roleCounts: map[string]uint32{ + timelockRoleProposer: 1, + }, + roleMember: map[string]map[uint32]string{ + timelockRoleProposer: {0: execAddr}, + }, + } + var opKey [32]byte + opKey[0] = 1 + inv.opExists = map[[32]byte]bool{opKey: true} + inv.opPending = map[[32]byte]bool{opKey: true} + inv.opReady = map[[32]byte]bool{} + inv.opDone = map[[32]byte]bool{} + + tl := stringsRepeatHexAddr('c') + ins := NewTimelockInspector(inv) + + ctx := t.Context() + delay, err := ins.GetMinDelay(ctx, tl) + require.NoError(t, err) + require.Equal(t, uint64(42), delay) + + proposers, err := ins.GetProposers(ctx, tl) + require.NoError(t, err) + require.Equal(t, []string{execAddr}, proposers) + + _, err = ins.GetExecutors(ctx, tl) + require.NoError(t, err) + + opID := [32]byte{1} + ok, err := ins.IsOperation(ctx, tl, opID) + require.NoError(t, err) + require.True(t, ok) + + pending, err := ins.IsOperationPending(ctx, tl, opID) + require.NoError(t, err) + require.True(t, pending) +} + +func TestTimelockExecutor_ExecuteRequiresCaller(t *testing.T) { + t.Parallel() + e := NewTimelockExecutor(&timelockSimInvoker{}, "") + _, err := e.Execute(t.Context(), types.BatchOperation{ + ChainSelector: 1, + Transactions: []types.Transaction{ + {To: stringsRepeatHexAddr('a'), Data: []byte{1}, AdditionalFields: []byte("{}")}, + }, + }, stringsRepeatHexAddr('b'), common.Hash{}, common.Hash{}) + require.ErrorContains(t, err, "executor caller") +} + +func stringsRepeatHexAddr(c byte) string { + const n = 64 + b := make([]byte, n) + for i := range b { + b[i] = c + } + + return string(b) +} diff --git a/sdk/stellar/timelock_invoke.go b/sdk/stellar/timelock_invoke.go new file mode 100644 index 00000000..74deddab --- /dev/null +++ b/sdk/stellar/timelock_invoke.go @@ -0,0 +1,25 @@ +package stellar + +import ( + "fmt" + + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" + "github.com/stellar/go-stellar-sdk/xdr" +) + +// sorobanInvokePayloadBytes encodes MCMS/timelock inner call data as XDR for +// ScVec([Symbol(fnName), ...args]), matching decode_invoke_payload in +// chainlink-stellar/contracts/common/helpers/src/soroban_invoke.rs. +func sorobanInvokePayloadBytes(fnName string, args ...xdr.ScVal) ([]byte, error) { + items := make([]xdr.ScVal, 0, 1+len(args)) + items = append(items, scval.SymbolToScVal(fnName)) + items = append(items, args...) + val := scval.VecToScVal(items) + + b, err := val.MarshalBinary() + if err != nil { + return nil, fmt.Errorf("marshal soroban invoke payload: %w", err) + } + + return b, nil +} diff --git a/sdk/stellar/transaction_result.go b/sdk/stellar/transaction_result.go new file mode 100644 index 00000000..79951452 --- /dev/null +++ b/sdk/stellar/transaction_result.go @@ -0,0 +1,24 @@ +package stellar + +import ( + chainsel "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-stellar/bindings" + + "github.com/smartcontractkit/mcms/types" +) + +// txHashInvoker is optionally implemented by invokers that expose the last submitted Soroban tx hash. +type txHashInvoker interface { + LastSubmittedTransactionHash() string +} + +func stellarTransactionResult(invoker bindings.Invoker) types.TransactionResult { + hash := "" + + if th, ok := invoker.(txHashInvoker); ok { + hash = th.LastSubmittedTransactionHash() + } + + return types.NewTransactionResult(hash, nil, chainsel.FamilyStellar) +} diff --git a/sdk/stellar/validation.go b/sdk/stellar/validation.go new file mode 100644 index 00000000..222d05b4 --- /dev/null +++ b/sdk/stellar/validation.go @@ -0,0 +1,113 @@ +package stellar + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" + + "github.com/smartcontractkit/mcms/types" +) + +// ValidateAdditionalFields validates JSON in types.Transaction.AdditionalFields +// (optional StellarOp.value as 32-byte hex; see [Encoder] parseValueWord). +func ValidateAdditionalFields(additionalFields json.RawMessage) error { + _, err := parseValueWord(additionalFields) + if err != nil { + return fmt.Errorf("stellar additional fields: %w", err) + } + + return nil +} + +// ValidateChainMetadata ensures MCMAddress parses as a Stellar contract id (strkey or 32-byte hex). +func ValidateChainMetadata(metadata types.ChainMetadata) error { + if strings.TrimSpace(metadata.MCMAddress) == "" { + return fmt.Errorf("mcm address is required") + } + + if _, err := ParseContractID(metadata.MCMAddress); err != nil { + return fmt.Errorf("mcmAddress: %w", err) + } + + return nil +} + +// ValidateTimelockChainMetadata validates Stellar chain metadata for a timelock proposal: +// MCM contract id, timelock role JSON in AdditionalFields, and action-specific required callers. +// timelockExecutor is always required (see chainwrappers.BuildTimelockExecutor). Optional role +// fields are checked when non-empty. +func ValidateTimelockChainMetadata(metadata types.ChainMetadata, action types.TimelockAction) error { + if err := ValidateChainMetadata(metadata); err != nil { + return err + } + + af, err := ParseTimelockProposalAdditionalFields(metadata.AdditionalFields) + if err != nil { + return err + } + + if err := validateTimelockRoleAddress("timelockExecutor", af.TimelockExecutor, true); err != nil { + return err + } + + switch action { + case types.TimelockActionSchedule: + if err := validateTimelockRoleAddress("timelockProposer", af.TimelockProposer, true); err != nil { + return err + } + case types.TimelockActionCancel: + if err := validateTimelockRoleAddress("timelockCanceller", af.TimelockCanceller, true); err != nil { + return err + } + case types.TimelockActionBypass: + if err := validateTimelockRoleAddress("timelockBypasser", af.TimelockBypasser, true); err != nil { + return err + } + default: + return fmt.Errorf("stellar timelock: invalid timelock action: %s", action) + } + + if err := validateTimelockRoleAddress("timelockAdmin", af.TimelockAdmin, false); err != nil { + return err + } + switch action { + case types.TimelockActionSchedule: + if err := validateTimelockRoleAddress("timelockCanceller", af.TimelockCanceller, false); err != nil { + return err + } + if err := validateTimelockRoleAddress("timelockBypasser", af.TimelockBypasser, false); err != nil { + return err + } + case types.TimelockActionCancel: + if err := validateTimelockRoleAddress("timelockProposer", af.TimelockProposer, false); err != nil { + return err + } + if err := validateTimelockRoleAddress("timelockBypasser", af.TimelockBypasser, false); err != nil { + return err + } + case types.TimelockActionBypass: + if err := validateTimelockRoleAddress("timelockProposer", af.TimelockProposer, false); err != nil { + return err + } + if err := validateTimelockRoleAddress("timelockCanceller", af.TimelockCanceller, false); err != nil { + return err + } + } + + return nil +} + +func validateTimelockRoleAddress(field, addr string, required bool) error { + if strings.TrimSpace(addr) == "" { + if required { + return fmt.Errorf("stellar timelock: %s is required in chain metadata additionalFields", field) + } + return nil + } + if scval.ParseAddress(addr) == nil { + return fmt.Errorf("stellar timelock: %s: invalid Stellar address (expect G... account or C... contract)", field) + } + return nil +} diff --git a/sdk/stellar/validation_test.go b/sdk/stellar/validation_test.go new file mode 100644 index 00000000..b860fdae --- /dev/null +++ b/sdk/stellar/validation_test.go @@ -0,0 +1,213 @@ +package stellar + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/mcms/types" +) + +func TestValidateChainMetadata(t *testing.T) { + t.Parallel() + + validAddr := "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + + tests := []struct { + name string + metadata types.ChainMetadata + wantErr string + }{ + { + name: "valid hex contract id", + metadata: types.ChainMetadata{ + MCMAddress: validAddr, + StartingOpCount: 0, + }, + }, + { + name: "valid strkey", + metadata: types.ChainMetadata{ + MCMAddress: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA", + StartingOpCount: 0, + }, + }, + { + name: "empty mcm address", + metadata: types.ChainMetadata{ + MCMAddress: "", + }, + wantErr: "mcm address is required", + }, + { + name: "invalid address", + metadata: types.ChainMetadata{ + MCMAddress: "not-an-address", + }, + wantErr: "mcmAddress:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateChainMetadata(tt.metadata) + if tt.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateTimelockChainMetadata(t *testing.T) { + t.Parallel() + + const ( + validMCM = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA" + validAccount = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H" + ) + + meta := func(raw string) types.ChainMetadata { + return types.ChainMetadata{ + StartingOpCount: 1, + MCMAddress: validMCM, + AdditionalFields: json.RawMessage(raw), + } + } + + fullSchedule := fmt.Sprintf(`{ + "timelockExecutor": %q, + "timelockProposer": %q + }`, validAccount, validAccount) + + tests := []struct { + name string + meta types.ChainMetadata + action types.TimelockAction + wantErr string + }{ + { + name: "schedule valid", + meta: meta(fullSchedule), + action: types.TimelockActionSchedule, + wantErr: "", + }, + { + name: "missing additionalFields", + meta: types.ChainMetadata{StartingOpCount: 1, MCMAddress: validMCM}, + action: types.TimelockActionSchedule, + wantErr: "additionalFields is required", + }, + { + name: "invalid additionalFields json", + meta: meta(`{`), + action: types.TimelockActionSchedule, + wantErr: "additionalFields:", + }, + { + name: "schedule missing executor", + meta: meta(fmt.Sprintf(`{"timelockProposer": %q}`, validAccount)), + action: types.TimelockActionSchedule, + wantErr: "timelockExecutor is required", + }, + { + name: "schedule missing proposer", + meta: meta(fmt.Sprintf(`{"timelockExecutor": %q}`, validAccount)), + action: types.TimelockActionSchedule, + wantErr: "timelockProposer is required", + }, + { + name: "cancel valid", + meta: meta(fmt.Sprintf(`{ + "timelockExecutor": %q, + "timelockCanceller": %q + }`, validAccount, validAccount)), + action: types.TimelockActionCancel, + }, + { + name: "cancel missing canceller", + meta: meta(fmt.Sprintf(`{"timelockExecutor": %q}`, validAccount)), + action: types.TimelockActionCancel, + wantErr: "timelockCanceller is required", + }, + { + name: "bypass valid", + meta: meta(fmt.Sprintf(`{ + "timelockExecutor": %q, + "timelockBypasser": %q + }`, validAccount, validAccount)), + action: types.TimelockActionBypass, + }, + { + name: "bypass missing bypasser", + meta: meta(fmt.Sprintf(`{"timelockExecutor": %q}`, validAccount)), + action: types.TimelockActionBypass, + wantErr: "timelockBypasser is required", + }, + { + name: "invalid proposer strkey", + meta: meta(fmt.Sprintf(`{"timelockExecutor": %q, "timelockProposer": "not-an-address"}`, validAccount)), + action: types.TimelockActionSchedule, + wantErr: "timelockProposer:", + }, + { + name: "optional admin invalid", + meta: meta(fmt.Sprintf(`{ + "timelockExecutor": %q, + "timelockProposer": %q, + "timelockAdmin": "bad" + }`, validAccount, validAccount)), + action: types.TimelockActionSchedule, + wantErr: "timelockAdmin:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateTimelockChainMetadata(tt.meta, tt.action) + if tt.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateAdditionalFields(t *testing.T) { + t.Parallel() + + valid64 := `{"value":"0x` + strings.Repeat("00", 32) + `"}` + + tests := []struct { + name string + raw json.RawMessage + wantOK bool + }{ + {name: "nil", raw: nil, wantOK: true}, + {name: "empty object", raw: json.RawMessage(`{}`), wantOK: true}, + {name: "valid value word", raw: json.RawMessage(valid64), wantOK: true}, + {name: "invalid json", raw: json.RawMessage(`{`), wantOK: false}, + {name: "value wrong length", raw: json.RawMessage(`{"value":"0x01"}`), wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateAdditionalFields(tt.raw) + if tt.wantOK { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} diff --git a/sdk/sui/mocks/bindutils/iboundcontract.go b/sdk/sui/mocks/bindutils/iboundcontract.go index 9e271ab3..eb65c7c1 100644 --- a/sdk/sui/mocks/bindutils/iboundcontract.go +++ b/sdk/sui/mocks/bindutils/iboundcontract.go @@ -1,17 +1,14 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_bindutils import ( context "context" - bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" - - mock "github.com/stretchr/testify/mock" - models "github.com/block-vision/sui-go-sdk/models" - transaction "github.com/block-vision/sui-go-sdk/transaction" + bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" + mock "github.com/stretchr/testify/mock" ) // IBoundContract is an autogenerated mock type for the IBoundContract type diff --git a/sdk/sui/mocks/bindutils/suisigner.go b/sdk/sui/mocks/bindutils/suisigner.go index 6294d562..538c07fe 100644 --- a/sdk/sui/mocks/bindutils/suisigner.go +++ b/sdk/sui/mocks/bindutils/suisigner.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_bindutils diff --git a/sdk/sui/mocks/feequoter/feequoterencoder.go b/sdk/sui/mocks/feequoter/feequoterencoder.go index 4b0d1472..ead4895a 100644 --- a/sdk/sui/mocks/feequoter/feequoterencoder.go +++ b/sdk/sui/mocks/feequoter/feequoterencoder.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_module_feequoter @@ -6,9 +6,8 @@ import ( big "math/big" bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" - mock "github.com/stretchr/testify/mock" - module_fee_quoter "github.com/smartcontractkit/chainlink-sui/bindings/generated/ccip/ccip/fee_quoter" + mock "github.com/stretchr/testify/mock" ) // FeeQuoterEncoder is an autogenerated mock type for the FeeQuoterEncoder type diff --git a/sdk/sui/mocks/mcms/imcms.go b/sdk/sui/mocks/mcms/imcms.go index 55ff6629..9292eab8 100644 --- a/sdk/sui/mocks/mcms/imcms.go +++ b/sdk/sui/mocks/mcms/imcms.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_module_mcms @@ -6,13 +6,10 @@ import ( context "context" big "math/big" - bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" - - mock "github.com/stretchr/testify/mock" - models "github.com/block-vision/sui-go-sdk/models" - + bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" module_mcms "github.com/smartcontractkit/chainlink-sui/bindings/generated/mcms/mcms" + mock "github.com/stretchr/testify/mock" ) // IMcms is an autogenerated mock type for the IMcms type diff --git a/sdk/sui/mocks/mcms/imcmsdevinspect.go b/sdk/sui/mocks/mcms/imcmsdevinspect.go index 59190724..e69161f1 100644 --- a/sdk/sui/mocks/mcms/imcmsdevinspect.go +++ b/sdk/sui/mocks/mcms/imcmsdevinspect.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_module_mcms @@ -7,10 +7,8 @@ import ( big "math/big" bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" - - mock "github.com/stretchr/testify/mock" - module_mcms "github.com/smartcontractkit/chainlink-sui/bindings/generated/mcms/mcms" + mock "github.com/stretchr/testify/mock" ) // IMcmsDevInspect is an autogenerated mock type for the IMcmsDevInspect type diff --git a/sdk/sui/mocks/mcms/mcmsencoder.go b/sdk/sui/mocks/mcms/mcmsencoder.go index dcf2be7e..b8aab1ac 100644 --- a/sdk/sui/mocks/mcms/mcmsencoder.go +++ b/sdk/sui/mocks/mcms/mcmsencoder.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_module_mcms @@ -6,9 +6,8 @@ import ( big "math/big" bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" - mock "github.com/stretchr/testify/mock" - module_mcms "github.com/smartcontractkit/chainlink-sui/bindings/generated/mcms/mcms" + mock "github.com/stretchr/testify/mock" ) // McmsEncoder is an autogenerated mock type for the McmsEncoder type diff --git a/sdk/sui/mocks/mcmsdeployer/imcmsdeployer.go b/sdk/sui/mocks/mcmsdeployer/imcmsdeployer.go index 40e6274a..85f93cf6 100644 --- a/sdk/sui/mocks/mcmsdeployer/imcmsdeployer.go +++ b/sdk/sui/mocks/mcmsdeployer/imcmsdeployer.go @@ -1,17 +1,14 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_module_mcmsdeployer import ( context "context" - bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" - - mock "github.com/stretchr/testify/mock" - models "github.com/block-vision/sui-go-sdk/models" - + bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" module_mcms_deployer "github.com/smartcontractkit/chainlink-sui/bindings/generated/mcms/mcms_deployer" + mock "github.com/stretchr/testify/mock" ) // IMcmsDeployer is an autogenerated mock type for the IMcmsDeployer type diff --git a/sdk/sui/mocks/mcmsdeployer/mcmsdeployerencoder.go b/sdk/sui/mocks/mcmsdeployer/mcmsdeployerencoder.go index 784a25b7..9c06b1d8 100644 --- a/sdk/sui/mocks/mcmsdeployer/mcmsdeployerencoder.go +++ b/sdk/sui/mocks/mcmsdeployer/mcmsdeployerencoder.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_module_mcmsdeployer diff --git a/sdk/sui/mocks/sui/isuiapi.go b/sdk/sui/mocks/sui/isuiapi.go index 434d05a5..5fce7b61 100644 --- a/sdk/sui/mocks/sui/isuiapi.go +++ b/sdk/sui/mocks/sui/isuiapi.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_sui diff --git a/sdk/ton/mocks/api.go b/sdk/ton/mocks/api.go index f37411f1..e047d7ed 100644 --- a/sdk/ton/mocks/api.go +++ b/sdk/ton/mocks/api.go @@ -1,22 +1,17 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_ton import ( - address "github.com/xssnick/tonutils-go/address" - cell "github.com/xssnick/tonutils-go/tvm/cell" - context "context" - - liteclient "github.com/xssnick/tonutils-go/liteclient" - - mock "github.com/stretchr/testify/mock" - time "time" + mock "github.com/stretchr/testify/mock" + address "github.com/xssnick/tonutils-go/address" + liteclient "github.com/xssnick/tonutils-go/liteclient" tlb "github.com/xssnick/tonutils-go/tlb" - ton "github.com/xssnick/tonutils-go/ton" + cell "github.com/xssnick/tonutils-go/tvm/cell" ) // APIClientWrapped is an autogenerated mock type for the APIClientWrapped type diff --git a/sdk/ton/mocks/wallet.go b/sdk/ton/mocks/wallet.go index f70937e2..f5948bf7 100644 --- a/sdk/ton/mocks/wallet.go +++ b/sdk/ton/mocks/wallet.go @@ -1,16 +1,13 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. +// Code generated by mockery v2.53.0. DO NOT EDIT. package mock_ton import ( context "context" - address "github.com/xssnick/tonutils-go/address" - mock "github.com/stretchr/testify/mock" - + address "github.com/xssnick/tonutils-go/address" tlb "github.com/xssnick/tonutils-go/tlb" - ton "github.com/xssnick/tonutils-go/ton" ) diff --git a/timelock_executable_test.go b/timelock_executable_test.go index fc186da9..dc3b43b5 100644 --- a/timelock_executable_test.go +++ b/timelock_executable_test.go @@ -606,7 +606,9 @@ func scheduleAndExecuteGrantRolesProposal(t *testing.T, targetRoles []common.Has requireOperationNotReady(t, tExecutable, &proposal, opIdx) requireOperationNotDone(t, tExecutable, &proposal, opIdx) - // sleep for 5 seconds and then mine a block + // Advance chain time past the timelock delay. geth's simulated beacon requires an + // empty tx pool before AdjustTime (see SimulatedBeacon.AdjustTime). + sim.Backend.Rollback() require.NoError(t, sim.Backend.AdjustTime(5*time.Second)) sim.Backend.Commit() // Note < 1.14 geth needs a commit after adjusting time. diff --git a/timelock_proposal.go b/timelock_proposal.go index 4c130a55..68854ab6 100644 --- a/timelock_proposal.go +++ b/timelock_proposal.go @@ -12,9 +12,12 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/go-playground/validator/v10" + chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/mcms/chainwrappers" "github.com/smartcontractkit/mcms/internal/utils/safecast" "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/types" ) @@ -95,6 +98,18 @@ func (m *TimelockProposal) Validate() error { return NewInvalidProposalKindError(m.Kind, types.KindTimelockProposal) } + for chainSelector, metadata := range m.ChainMetadata { + fam, err := types.GetChainSelectorFamily(chainSelector) + if err != nil { + return fmt.Errorf("chain metadata %d: %w", chainSelector, err) + } + if fam == chainsel.FamilyStellar { + if err := stellar.ValidateTimelockChainMetadata(metadata, m.Action); err != nil { + return fmt.Errorf("chain metadata %d: %w", chainSelector, err) + } + } + } + // Validate all chains in transactions have an entry in chain metadata for _, op := range m.Operations { if _, ok := m.ChainMetadata[op.ChainSelector]; !ok { diff --git a/timelock_proposal_test.go b/timelock_proposal_test.go index bf500a56..db36efbf 100644 --- a/timelock_proposal_test.go +++ b/timelock_proposal_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "io" + "maps" "math/big" "strings" "testing" @@ -717,6 +718,71 @@ func TestTimelockProposal_Validate(t *testing.T) { } } +func TestTimelockProposal_Validate_StellarTimelockMetadata(t *testing.T) { + t.Parallel() + + sel := chaintest.Chain9Selector + stellarMCM := "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA" + acc := "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H" + + base := TimelockProposal{ + BaseProposal: BaseProposal{ + Version: "v1", + Kind: types.KindTimelockProposal, + ValidUntil: 2004259681, + ChainMetadata: map[types.ChainSelector]types.ChainMetadata{ + sel: { + StartingOpCount: 1, + MCMAddress: stellarMCM, + AdditionalFields: json.RawMessage([]byte(`{}`)), + }, + }, + }, + Action: types.TimelockActionSchedule, + Delay: types.MustParseDuration("1h"), + TimelockAddresses: map[types.ChainSelector]string{ + sel: stellarMCM, + }, + Operations: []types.BatchOperation{ + { + ChainSelector: sel, + Transactions: []types.Transaction{ + { + To: stellarMCM, + AdditionalFields: json.RawMessage([]byte(`{}`)), + Data: []byte{}, + }, + }, + }, + }, + } + + t.Run("rejects empty timelock role metadata", func(t *testing.T) { + t.Parallel() + p := base + p.ChainMetadata = maps.Clone(base.ChainMetadata) + err := p.Validate() + require.Error(t, err) + require.Contains(t, err.Error(), "chain metadata") + require.Contains(t, err.Error(), "timelockExecutor is required") + }) + + t.Run("accepts valid stellar timelock metadata", func(t *testing.T) { + t.Parallel() + p := base + p.ChainMetadata = maps.Clone(base.ChainMetadata) + p.ChainMetadata[sel] = types.ChainMetadata{ + StartingOpCount: 1, + MCMAddress: stellarMCM, + AdditionalFields: json.RawMessage([]byte(`{ + "timelockExecutor": "` + acc + `", + "timelockProposer": "` + acc + `" + }`)), + } + require.NoError(t, p.Validate()) + }) +} + func TestTimelockProposal_Convert(t *testing.T) { t.Parallel() diff --git a/validation.go b/validation.go index 4901edf7..c4a2677f 100644 --- a/validation.go +++ b/validation.go @@ -11,6 +11,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" ) @@ -36,6 +37,9 @@ func validateAdditionalFields(additionalFields json.RawMessage, csel types.Chain case chainsel.FamilyTon: return ton.ValidateAdditionalFields(additionalFields) + + case chainsel.FamilyStellar: + return stellar.ValidateAdditionalFields(additionalFields) } return nil @@ -61,6 +65,8 @@ func validateChainMetadata(metadata types.ChainMetadata, csel types.ChainSelecto // TODO (ton): do we need special chain metadata for TON? // Yes! We could attach MCMS -> Timelock value here which is now hardcoded default in timelock converter return nil + case chainsel.FamilyStellar: + return stellar.ValidateChainMetadata(metadata) default: return fmt.Errorf("unsupported chain family: %s", chainFamily) } diff --git a/validation_test.go b/validation_test.go index b2e164f9..bc048407 100644 --- a/validation_test.go +++ b/validation_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "math/big" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -101,6 +102,24 @@ func TestValidateChainMetadata(t *testing.T) { additionalFields: types.ChainMetadata{AdditionalFields: invalidSuiMetadataJSON}, expectedErr: errors.New("mcms package ID is required"), }, + { + name: "valid Stellar metadata", + chainSelector: chaintest.Chain9Selector, + additionalFields: types.ChainMetadata{MCMAddress: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", StartingOpCount: 0}, + expectedErr: nil, + }, + { + name: "empty Stellar mcm address", + chainSelector: chaintest.Chain9Selector, + additionalFields: types.ChainMetadata{MCMAddress: "", StartingOpCount: 0}, + expectedErr: errors.New("mcm address is required"), + }, + { + name: "invalid Stellar mcm address", + chainSelector: chaintest.Chain9Selector, + additionalFields: types.ChainMetadata{MCMAddress: "not-a-contract", StartingOpCount: 0}, + expectedErr: errors.New("mcmAddress:"), + }, { name: "unknown chain family", chainSelector: types.ChainSelector(999), @@ -186,6 +205,8 @@ func TestValidateAdditionalFields(t *testing.T) { invalidSuiFieldsJSON, err := json.Marshal(invalidSuiFields) require.NoError(t, err) + validStellarFieldsJSON := json.RawMessage(`{"value":"0x` + strings.Repeat("00", 32) + `"}`) + tests := []struct { name string operation types.Operation @@ -262,6 +283,36 @@ func TestValidateAdditionalFields(t *testing.T) { }, expectedErr: errors.New("module name length must be between 1 and 64 characters"), }, + { + name: "valid Stellar fields (empty)", + operation: types.Operation{ + ChainSelector: chaintest.Chain9Selector, + Transaction: types.Transaction{ + AdditionalFields: nil, + }, + }, + expectedErr: nil, + }, + { + name: "valid Stellar value word", + operation: types.Operation{ + ChainSelector: chaintest.Chain9Selector, + Transaction: types.Transaction{ + AdditionalFields: validStellarFieldsJSON, + }, + }, + expectedErr: nil, + }, + { + name: "invalid Stellar additional fields JSON", + operation: types.Operation{ + ChainSelector: chaintest.Chain9Selector, + Transaction: types.Transaction{ + AdditionalFields: []byte("{"), + }, + }, + expectedErr: errors.New("stellar additional fields"), + }, { name: "unknown chain family", operation: types.Operation{ From 35d7c1f85e1cacb45a62749712b129c8825574c1 Mon Sep 17 00:00:00 2001 From: FelixFan1992 Date: Tue, 5 May 2026 16:04:03 -0400 Subject: [PATCH 7/9] set up e2e tests --- .github/workflows/pull-request-main.yml | 7 +- .github/workflows/push-main.yml | 7 +- e2e/config.stellar.toml | 10 +++ e2e/tests/runner_test.go | 5 ++ e2e/tests/setup.go | 90 ++++++++++++++++--------- e2e/tests/stellar/smoke.go | 50 ++++++++++++++ 6 files changed, 136 insertions(+), 33 deletions(-) create mode 100644 e2e/config.stellar.toml create mode 100644 e2e/tests/stellar/smoke.go diff --git a/.github/workflows/pull-request-main.yml b/.github/workflows/pull-request-main.yml index 655231ae..eb7fd69c 100644 --- a/.github/workflows/pull-request-main.yml +++ b/.github/workflows/pull-request-main.yml @@ -140,11 +140,16 @@ jobs: CTF_CONFIGS=../config.sui.toml go test -p=1 -tags=e2e -v ./e2e/tests/... -run=TestSuiSuite || sui_failure=true echo "::endgroup::" + echo "::group::Stellar" + CTF_CONFIGS=../config.stellar.toml go test -p=1 -tags=e2e -v ./e2e/tests/... -run=TestStellarSuite || stellar_failure=true + echo "::endgroup::" + [[ -n "${evm_failure}" ]] && echo "🚨 EVM e2e tests failed." [[ -n "${solana_failure}" ]] && echo "🚨 Solana e2e tests failed." [[ -n "${aptos_failure}" ]] && echo "🚨 Aptos e2e tests failed." [[ -n "${sui_failure}" ]] && echo "🚨 Sui e2e tests failed." - [[ -n "${evm_failure}" || -n "${solana_failure}" || -n "${aptos_failure}" || -n "${sui_failure}" ]] && { + [[ -n "${stellar_failure}" ]] && echo "🚨 Stellar e2e tests failed." + [[ -n "${evm_failure}" || -n "${solana_failure}" || -n "${aptos_failure}" || -n "${sui_failure}" || -n "${stellar_failure}" ]] && { exit 1 } || { echo "Exiting" diff --git a/.github/workflows/push-main.yml b/.github/workflows/push-main.yml index 735970fb..9839b627 100644 --- a/.github/workflows/push-main.yml +++ b/.github/workflows/push-main.yml @@ -138,11 +138,16 @@ jobs: CTF_CONFIGS=../config.sui.toml go test -p=1 -tags=e2e -v ./e2e/tests/... -run=TestSuiSuite || sui_failure=true echo "::endgroup::" + echo "::group::Stellar" + CTF_CONFIGS=../config.stellar.toml go test -p=1 -tags=e2e -v ./e2e/tests/... -run=TestStellarSuite || stellar_failure=true + echo "::endgroup::" + [[ -n "${evm_failure}" ]] && echo "🚨 EVM e2e tests failed." [[ -n "${solana_failure}" ]] && echo "🚨 Solana e2e tests failed." [[ -n "${aptos_failure}" ]] && echo "🚨 Aptos e2e tests failed." [[ -n "${sui_failure}" ]] && echo "🚨 Sui e2e tests failed." - [[ -n "${evm_failure}" || -n "${solana_failure}" || -n "${aptos_failure}" || -n "${sui_failure}" ]] && { + [[ -n "${stellar_failure}" ]] && echo "🚨 Stellar e2e tests failed." + [[ -n "${evm_failure}" || -n "${solana_failure}" || -n "${aptos_failure}" || -n "${sui_failure}" || -n "${stellar_failure}" ]] && { exit 1 } || { echo "Exiting" diff --git a/e2e/config.stellar.toml b/e2e/config.stellar.toml new file mode 100644 index 00000000..8c954887 --- /dev/null +++ b/e2e/config.stellar.toml @@ -0,0 +1,10 @@ +# Soroban RPC via chainlink-testing-framework Stellar stack (Docker stellar/quickstart). +# Port is assigned at runtime in e2e/tests/setup.go (freeport) before starting the network. +# +# Run from repo root (go test cwd is e2e/tests for this package): +# CTF_CONFIGS=../config.stellar.toml go test -tags=e2e -run=TestStellarSuite ./e2e/tests +# +# CI uses the same relative path from the e2e job working directory. + +[stellar_config] +type = "stellar" diff --git a/e2e/tests/runner_test.go b/e2e/tests/runner_test.go index a04ff50e..7fa9ddc2 100644 --- a/e2e/tests/runner_test.go +++ b/e2e/tests/runner_test.go @@ -10,6 +10,7 @@ import ( aptose2e "github.com/smartcontractkit/mcms/e2e/tests/aptos" evme2e "github.com/smartcontractkit/mcms/e2e/tests/evm" solanae2e "github.com/smartcontractkit/mcms/e2e/tests/solana" + stellare2e "github.com/smartcontractkit/mcms/e2e/tests/stellar" suie2e "github.com/smartcontractkit/mcms/e2e/tests/sui" tone2e "github.com/smartcontractkit/mcms/e2e/tests/ton" ) @@ -41,6 +42,10 @@ func TestSuiSuite(t *testing.T) { suite.Run(t, new(suie2e.MCMSUserUpgradeTestSuite)) } +func TestStellarSuite(t *testing.T) { + suite.Run(t, new(stellare2e.SmokeSuite)) +} + func TestTONSuite(t *testing.T) { suite.Run(t, new(tone2e.SigningTestSuite)) suite.Run(t, new(tone2e.SetConfigTestSuite)) diff --git a/e2e/tests/setup.go b/e2e/tests/setup.go index 65adf90f..7f3f5a52 100644 --- a/e2e/tests/setup.go +++ b/e2e/tests/setup.go @@ -37,12 +37,13 @@ var ( // Config defines the blockchain configuration type Config struct { - BlockchainA *blockchain.Input `toml:"evm_config_a"` - BlockchainB *blockchain.Input `toml:"evm_config_b"` - SolanaChain *blockchain.Input `toml:"solana_config"` - AptosChain *blockchain.Input `toml:"aptos_config"` - SuiChain *blockchain.Input `toml:"sui_config"` - TonChain *blockchain.Input `toml:"ton_config"` + BlockchainA *blockchain.Input `toml:"evm_config_a"` + BlockchainB *blockchain.Input `toml:"evm_config_b"` + SolanaChain *blockchain.Input `toml:"solana_config"` + AptosChain *blockchain.Input `toml:"aptos_config"` + SuiChain *blockchain.Input `toml:"sui_config"` + TonChain *blockchain.Input `toml:"ton_config"` + StellarChain *blockchain.Input `toml:"stellar_config"` Settings struct { PrivateKeys []string `toml:"private_keys"` @@ -52,18 +53,20 @@ type Config struct { // TestSetup holds common setup for E2E test suites type TestSetup struct { - ClientA *ethclient.Client - ClientB *ethclient.Client - SolanaClient *rpc.Client - SolanaWSClient *ws.Client - AptosRPCClient *aptos.NodeClient - SolanaBlockchain *blockchain.Output - AptosBlockchain *blockchain.Output - SuiClient sui.ISuiAPI - SuiBlockchain *blockchain.Output - SuiNodeURL string - TonClient *ton.APIClient - TonBlockchain *blockchain.Output + ClientA *ethclient.Client + ClientB *ethclient.Client + SolanaClient *rpc.Client + SolanaWSClient *ws.Client + AptosRPCClient *aptos.NodeClient + SolanaBlockchain *blockchain.Output + AptosBlockchain *blockchain.Output + SuiClient sui.ISuiAPI + SuiBlockchain *blockchain.Output + SuiNodeURL string + TonClient *ton.APIClient + TonBlockchain *blockchain.Output + StellarRPCURL string + StellarBlockchain *blockchain.Output Config } @@ -236,20 +239,45 @@ func InitializeSharedTestSetup(t *testing.T) *TestSetup { t.Logf("Initialized TON RPC client @ %s", nodeURL) } + var ( + stellarBlockchainOutput *blockchain.Output + stellarRPCURL string + ) + if in.StellarChain != nil { + ports := freeport.GetN(t, 1) + in.StellarChain.Port = strconv.Itoa(ports[0]) + + stellarBlockchainOutput, err = blockchain.NewBlockchainNetwork(in.StellarChain) + require.NoError(t, err, "Failed to initialize Stellar blockchain") + + stellarRPCURL = stellarBlockchainOutput.Nodes[0].ExternalHTTPUrl + t.Logf("Initialized Stellar Soroban RPC @ %s", stellarRPCURL) + if stellarBlockchainOutput.NetworkSpecificData != nil && + stellarBlockchainOutput.NetworkSpecificData.StellarNetwork != nil { + sn := stellarBlockchainOutput.NetworkSpecificData.StellarNetwork + t.Logf("Stellar network passphrase: %s", sn.NetworkPassphrase) + if sn.FriendbotURL != "" { + t.Logf("Stellar friendbot @ %s", sn.FriendbotURL) + } + } + } + sharedSetup = &TestSetup{ - ClientA: ethClientA, - ClientB: ethClientB, - SolanaClient: solanaClient, - SolanaWSClient: solanaWsClient, - AptosRPCClient: aptosClient, - SolanaBlockchain: solanaBlockChainOutput, - AptosBlockchain: aptosBlockchainOutput, - SuiClient: suiClient, - SuiBlockchain: suiBlockchainOutput, - SuiNodeURL: suiNodeURL, - TonClient: tonClient, - TonBlockchain: tonBlockchainOutput, - Config: *in, + ClientA: ethClientA, + ClientB: ethClientB, + SolanaClient: solanaClient, + SolanaWSClient: solanaWsClient, + AptosRPCClient: aptosClient, + SolanaBlockchain: solanaBlockChainOutput, + AptosBlockchain: aptosBlockchainOutput, + SuiClient: suiClient, + SuiBlockchain: suiBlockchainOutput, + SuiNodeURL: suiNodeURL, + TonClient: tonClient, + TonBlockchain: tonBlockchainOutput, + StellarRPCURL: stellarRPCURL, + StellarBlockchain: stellarBlockchainOutput, + Config: *in, } }) diff --git a/e2e/tests/stellar/smoke.go b/e2e/tests/stellar/smoke.go new file mode 100644 index 00000000..61d18d3a --- /dev/null +++ b/e2e/tests/stellar/smoke.go @@ -0,0 +1,50 @@ +//go:build e2e + +package stellare2e + +import ( + "bytes" + "io" + "net/http" + "strings" + + "github.com/stretchr/testify/suite" + + e2e "github.com/smartcontractkit/mcms/e2e/tests" +) + +// SmokeSuite is iteration-1 Stellar e2e: Soroban RPC reachability only (no MCMS deploy). +// Extend with contract deploy + MCMS flows in a later iteration. +type SmokeSuite struct { + suite.Suite + e2e.TestSetup +} + +func (s *SmokeSuite) SetupSuite() { + s.TestSetup = *e2e.InitializeSharedTestSetup(s.T()) + s.Require().NotEmpty(s.StellarRPCURL, "CTF_CONFIGS must include stellar_config (see e2e/config.stellar.toml)") +} + +func (s *SmokeSuite) TestSorobanRPCGetHealth() { + const body = `{"jsonrpc":"2.0","id":1,"method":"getHealth"}` + resp, err := http.Post(s.StellarRPCURL, "application/json", bytes.NewReader([]byte(body))) + s.Require().NoError(err) + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, resp.StatusCode, "body=%s", string(raw)) + s.Require().Contains(string(raw), `"result"`, "body=%s", string(raw)) +} + +func (s *SmokeSuite) TestSorobanRPCGetLatestLedger() { + body := `{"jsonrpc":"2.0","id":2,"method":"getLatestLedger","params":{}}` + resp, err := http.Post(s.StellarRPCURL, "application/json", strings.NewReader(body)) + s.Require().NoError(err) + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, resp.StatusCode, "body=%s", string(raw)) + s.Require().Contains(string(raw), `"result"`, "body=%s", string(raw)) +} From 1b824184dcd21a83c314cd24b48a818646c963b9 Mon Sep 17 00:00:00 2001 From: FelixFan1992 Date: Wed, 6 May 2026 20:03:10 -0400 Subject: [PATCH 8/9] disable for now --- .github/workflows/go-mod-validation.yml | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 .github/workflows/go-mod-validation.yml diff --git a/.github/workflows/go-mod-validation.yml b/.github/workflows/go-mod-validation.yml deleted file mode 100644 index a7d9d7f9..00000000 --- a/.github/workflows/go-mod-validation.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Validate Go Mod Files -permissions: - contents: read - -on: - pull_request: - merge_group: - -jobs: - go-mod-validation: - name: Validate go.mod dependencies - runs-on: ubuntu-latest - if: ${{ github.event_name == 'pull_request' }} - steps: - - uses: actions/checkout@v4 - - - name: Validate go.mod - uses: smartcontractkit/.github/apps/go-mod-validator@4864172d998c12cefa9c2552b36d6e9842261816 # go-mod-validator@1.3.0 From 9d82c12e050f910887c8074ceca964b7150247a8 Mon Sep 17 00:00:00 2001 From: FelixFan1992 Date: Thu, 7 May 2026 12:39:31 -0400 Subject: [PATCH 9/9] update --- chainwrappers/mocks/chain_accessor.go | 2 +- sdk/aptos/mocks/aptos/rpcclient.go | 2 +- sdk/aptos/mocks/aptos/transactionsigner.go | 2 +- sdk/aptos/mocks/mcms/mcms.go | 2 +- sdk/aptos/mocks/mcms/mcms/mcms.go | 2 +- sdk/aptos/mocks/mcms/mcms/mcms_encoder.go | 2 +- sdk/aptos/mocks/mcms/mcms_executor/mcms_executor.go | 2 +- sdk/evm/bindings/mocks/abigen_log.go | 2 +- sdk/evm/bindings/mocks/call_proxy_interface.go | 2 +- sdk/evm/bindings/mocks/many_chain_multi_sig_interface.go | 2 +- sdk/evm/bindings/mocks/rbac_timelock_interface.go | 2 +- sdk/evm/mocks/contract_deploy_backend.go | 2 +- sdk/mocks/config_transformer.go | 2 +- sdk/mocks/configurer.go | 2 +- sdk/mocks/decoded_operation.go | 2 +- sdk/mocks/decoder.go | 2 +- sdk/mocks/encoder.go | 2 +- sdk/mocks/executor.go | 2 +- sdk/mocks/inspector.go | 2 +- sdk/mocks/logger.go | 2 +- sdk/mocks/operation_id.go | 2 +- sdk/mocks/simulator.go | 2 +- sdk/mocks/timelock_configurer.go | 2 +- sdk/mocks/timelock_converter.go | 2 +- sdk/mocks/timelock_executor.go | 2 +- sdk/mocks/timelock_inspector.go | 2 +- sdk/solana/mocks/jsonrpcclient.go | 2 +- sdk/sui/mocks/bindutils/iboundcontract.go | 2 +- sdk/sui/mocks/bindutils/suisigner.go | 2 +- sdk/sui/mocks/feequoter/feequoterencoder.go | 2 +- sdk/sui/mocks/mcms/imcms.go | 2 +- sdk/sui/mocks/mcms/imcmsdevinspect.go | 2 +- sdk/sui/mocks/mcms/mcmsencoder.go | 2 +- sdk/sui/mocks/mcmsdeployer/imcmsdeployer.go | 2 +- sdk/sui/mocks/mcmsdeployer/mcmsdeployerencoder.go | 2 +- sdk/sui/mocks/sui/isuiapi.go | 2 +- sdk/ton/mocks/api.go | 2 +- sdk/ton/mocks/wallet.go | 2 +- 38 files changed, 38 insertions(+), 38 deletions(-) diff --git a/chainwrappers/mocks/chain_accessor.go b/chainwrappers/mocks/chain_accessor.go index 4ac0f3ce..ac63477d 100644 --- a/chainwrappers/mocks/chain_accessor.go +++ b/chainwrappers/mocks/chain_accessor.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/aptos/mocks/aptos/rpcclient.go b/sdk/aptos/mocks/aptos/rpcclient.go index a89480fa..e018a1e1 100644 --- a/sdk/aptos/mocks/aptos/rpcclient.go +++ b/sdk/aptos/mocks/aptos/rpcclient.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_aptossdk diff --git a/sdk/aptos/mocks/aptos/transactionsigner.go b/sdk/aptos/mocks/aptos/transactionsigner.go index 2da49386..03d7532b 100644 --- a/sdk/aptos/mocks/aptos/transactionsigner.go +++ b/sdk/aptos/mocks/aptos/transactionsigner.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_aptossdk diff --git a/sdk/aptos/mocks/mcms/mcms.go b/sdk/aptos/mocks/mcms/mcms.go index d6820124..c081a43b 100644 --- a/sdk/aptos/mocks/mcms/mcms.go +++ b/sdk/aptos/mocks/mcms/mcms.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_mcms diff --git a/sdk/aptos/mocks/mcms/mcms/mcms.go b/sdk/aptos/mocks/mcms/mcms/mcms.go index 53f3d189..b37edfab 100644 --- a/sdk/aptos/mocks/mcms/mcms/mcms.go +++ b/sdk/aptos/mocks/mcms/mcms/mcms.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_module_mcms diff --git a/sdk/aptos/mocks/mcms/mcms/mcms_encoder.go b/sdk/aptos/mocks/mcms/mcms/mcms_encoder.go index 35b770af..cff30474 100644 --- a/sdk/aptos/mocks/mcms/mcms/mcms_encoder.go +++ b/sdk/aptos/mocks/mcms/mcms/mcms_encoder.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_module_mcms diff --git a/sdk/aptos/mocks/mcms/mcms_executor/mcms_executor.go b/sdk/aptos/mocks/mcms/mcms_executor/mcms_executor.go index 80e8bc1b..6f6ffdf1 100644 --- a/sdk/aptos/mocks/mcms/mcms_executor/mcms_executor.go +++ b/sdk/aptos/mocks/mcms/mcms_executor/mcms_executor.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_module_mcms_executor diff --git a/sdk/evm/bindings/mocks/abigen_log.go b/sdk/evm/bindings/mocks/abigen_log.go index ea69b316..a04a697f 100644 --- a/sdk/evm/bindings/mocks/abigen_log.go +++ b/sdk/evm/bindings/mocks/abigen_log.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/evm/bindings/mocks/call_proxy_interface.go b/sdk/evm/bindings/mocks/call_proxy_interface.go index 526a251c..980a62cc 100644 --- a/sdk/evm/bindings/mocks/call_proxy_interface.go +++ b/sdk/evm/bindings/mocks/call_proxy_interface.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/evm/bindings/mocks/many_chain_multi_sig_interface.go b/sdk/evm/bindings/mocks/many_chain_multi_sig_interface.go index a93b7738..903ba406 100644 --- a/sdk/evm/bindings/mocks/many_chain_multi_sig_interface.go +++ b/sdk/evm/bindings/mocks/many_chain_multi_sig_interface.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/evm/bindings/mocks/rbac_timelock_interface.go b/sdk/evm/bindings/mocks/rbac_timelock_interface.go index 44952496..e8905385 100644 --- a/sdk/evm/bindings/mocks/rbac_timelock_interface.go +++ b/sdk/evm/bindings/mocks/rbac_timelock_interface.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/evm/mocks/contract_deploy_backend.go b/sdk/evm/mocks/contract_deploy_backend.go index 8cabf8b3..d189137f 100644 --- a/sdk/evm/mocks/contract_deploy_backend.go +++ b/sdk/evm/mocks/contract_deploy_backend.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/mocks/config_transformer.go b/sdk/mocks/config_transformer.go index 18f12c0d..7e5cca2b 100644 --- a/sdk/mocks/config_transformer.go +++ b/sdk/mocks/config_transformer.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/mocks/configurer.go b/sdk/mocks/configurer.go index c1cfa06e..fa344b6c 100644 --- a/sdk/mocks/configurer.go +++ b/sdk/mocks/configurer.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/mocks/decoded_operation.go b/sdk/mocks/decoded_operation.go index 4aec0806..eaa94ee8 100644 --- a/sdk/mocks/decoded_operation.go +++ b/sdk/mocks/decoded_operation.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/mocks/decoder.go b/sdk/mocks/decoder.go index 3967c8b1..0a627a8a 100644 --- a/sdk/mocks/decoder.go +++ b/sdk/mocks/decoder.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/mocks/encoder.go b/sdk/mocks/encoder.go index 48ca2a2a..6232d36a 100644 --- a/sdk/mocks/encoder.go +++ b/sdk/mocks/encoder.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/mocks/executor.go b/sdk/mocks/executor.go index 634536cd..596cf7ee 100644 --- a/sdk/mocks/executor.go +++ b/sdk/mocks/executor.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/mocks/inspector.go b/sdk/mocks/inspector.go index cfd68f1f..a20598f4 100644 --- a/sdk/mocks/inspector.go +++ b/sdk/mocks/inspector.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/mocks/logger.go b/sdk/mocks/logger.go index ed23316a..378e8c46 100644 --- a/sdk/mocks/logger.go +++ b/sdk/mocks/logger.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/mocks/operation_id.go b/sdk/mocks/operation_id.go index 62470214..8a54a3b9 100644 --- a/sdk/mocks/operation_id.go +++ b/sdk/mocks/operation_id.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/mocks/simulator.go b/sdk/mocks/simulator.go index a76cbc00..866898dd 100644 --- a/sdk/mocks/simulator.go +++ b/sdk/mocks/simulator.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/mocks/timelock_configurer.go b/sdk/mocks/timelock_configurer.go index b61db885..feed7dc3 100644 --- a/sdk/mocks/timelock_configurer.go +++ b/sdk/mocks/timelock_configurer.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/mocks/timelock_converter.go b/sdk/mocks/timelock_converter.go index a2a66d03..341bcedb 100644 --- a/sdk/mocks/timelock_converter.go +++ b/sdk/mocks/timelock_converter.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/mocks/timelock_executor.go b/sdk/mocks/timelock_executor.go index 4cd7e9e6..69dc95f9 100644 --- a/sdk/mocks/timelock_executor.go +++ b/sdk/mocks/timelock_executor.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/mocks/timelock_inspector.go b/sdk/mocks/timelock_inspector.go index 748636f6..00070c7b 100644 --- a/sdk/mocks/timelock_inspector.go +++ b/sdk/mocks/timelock_inspector.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/solana/mocks/jsonrpcclient.go b/sdk/solana/mocks/jsonrpcclient.go index f0a7b3f7..d7e02b8a 100644 --- a/sdk/solana/mocks/jsonrpcclient.go +++ b/sdk/solana/mocks/jsonrpcclient.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mocks diff --git a/sdk/sui/mocks/bindutils/iboundcontract.go b/sdk/sui/mocks/bindutils/iboundcontract.go index eb65c7c1..24c68622 100644 --- a/sdk/sui/mocks/bindutils/iboundcontract.go +++ b/sdk/sui/mocks/bindutils/iboundcontract.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_bindutils diff --git a/sdk/sui/mocks/bindutils/suisigner.go b/sdk/sui/mocks/bindutils/suisigner.go index 538c07fe..6294d562 100644 --- a/sdk/sui/mocks/bindutils/suisigner.go +++ b/sdk/sui/mocks/bindutils/suisigner.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_bindutils diff --git a/sdk/sui/mocks/feequoter/feequoterencoder.go b/sdk/sui/mocks/feequoter/feequoterencoder.go index ead4895a..5eba3f50 100644 --- a/sdk/sui/mocks/feequoter/feequoterencoder.go +++ b/sdk/sui/mocks/feequoter/feequoterencoder.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_module_feequoter diff --git a/sdk/sui/mocks/mcms/imcms.go b/sdk/sui/mocks/mcms/imcms.go index 9292eab8..2d1bb5c0 100644 --- a/sdk/sui/mocks/mcms/imcms.go +++ b/sdk/sui/mocks/mcms/imcms.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_module_mcms diff --git a/sdk/sui/mocks/mcms/imcmsdevinspect.go b/sdk/sui/mocks/mcms/imcmsdevinspect.go index e69161f1..aabd6019 100644 --- a/sdk/sui/mocks/mcms/imcmsdevinspect.go +++ b/sdk/sui/mocks/mcms/imcmsdevinspect.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_module_mcms diff --git a/sdk/sui/mocks/mcms/mcmsencoder.go b/sdk/sui/mocks/mcms/mcmsencoder.go index b8aab1ac..3d637b2e 100644 --- a/sdk/sui/mocks/mcms/mcmsencoder.go +++ b/sdk/sui/mocks/mcms/mcmsencoder.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_module_mcms diff --git a/sdk/sui/mocks/mcmsdeployer/imcmsdeployer.go b/sdk/sui/mocks/mcmsdeployer/imcmsdeployer.go index 85f93cf6..69a3c76f 100644 --- a/sdk/sui/mocks/mcmsdeployer/imcmsdeployer.go +++ b/sdk/sui/mocks/mcmsdeployer/imcmsdeployer.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_module_mcmsdeployer diff --git a/sdk/sui/mocks/mcmsdeployer/mcmsdeployerencoder.go b/sdk/sui/mocks/mcmsdeployer/mcmsdeployerencoder.go index 9c06b1d8..784a25b7 100644 --- a/sdk/sui/mocks/mcmsdeployer/mcmsdeployerencoder.go +++ b/sdk/sui/mocks/mcmsdeployer/mcmsdeployerencoder.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_module_mcmsdeployer diff --git a/sdk/sui/mocks/sui/isuiapi.go b/sdk/sui/mocks/sui/isuiapi.go index 5fce7b61..434d05a5 100644 --- a/sdk/sui/mocks/sui/isuiapi.go +++ b/sdk/sui/mocks/sui/isuiapi.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_sui diff --git a/sdk/ton/mocks/api.go b/sdk/ton/mocks/api.go index e047d7ed..85b8fef1 100644 --- a/sdk/ton/mocks/api.go +++ b/sdk/ton/mocks/api.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_ton diff --git a/sdk/ton/mocks/wallet.go b/sdk/ton/mocks/wallet.go index f5948bf7..a870e87e 100644 --- a/sdk/ton/mocks/wallet.go +++ b/sdk/ton/mocks/wallet.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.0. DO NOT EDIT. +// Code generated by mockery v2.53.5. DO NOT EDIT. package mock_ton