diff --git a/deployment/changesets/cs_curse_uncurse.go b/deployment/changesets/cs_curse_uncurse.go new file mode 100644 index 00000000..8929e292 --- /dev/null +++ b/deployment/changesets/cs_curse_uncurse.go @@ -0,0 +1,135 @@ +package changesets + +import ( + "encoding/binary" + "errors" + "fmt" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink-sui/bindings/bind" + "github.com/smartcontractkit/chainlink-sui/deployment" + sui_ops "github.com/smartcontractkit/chainlink-sui/deployment/ops" + rmn_ops "github.com/smartcontractkit/chainlink-sui/deployment/ops/rmn" +) + +type CurseUncurseOperationType string + +const ( + CurseOperationType CurseUncurseOperationType = "curse" + UncurseOperationType CurseUncurseOperationType = "uncurse" +) + +type CurseUncurseChainsConfig struct { + SuiChainSelector uint64 `yaml:"suiChainSelector"` + OperationType string `yaml:"operationType"` + IsGlobalCurse bool `yaml:"isGlobalCurse"` + DestChainSelectors []uint64 `yaml:"destChainSelectors"` +} + +var _ cldf.ChangeSetV2[CurseUncurseChainsConfig] = CurseUncurseChains{} + +type CurseUncurseChains struct{} + +var globalCurseSubjectBytes = [16]byte{0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01} + +func (c CurseUncurseChains) VerifyPreconditions(e cldf.Environment, cfg CurseUncurseChainsConfig) error { + if cfg.OperationType != string(CurseOperationType) && cfg.OperationType != string(UncurseOperationType) { + return fmt.Errorf("invalid operation type %s", cfg.OperationType) + } + if cfg.IsGlobalCurse { + if len(cfg.DestChainSelectors) > 0 { + return errors.New("global curse config must not include destination selectors") + } + return nil + } + if len(cfg.DestChainSelectors) == 0 { + return errors.New("no destination chain selectors provided") + } + return nil +} + +func (c CurseUncurseChains) Apply(e cldf.Environment, cfg CurseUncurseChainsConfig) (cldf.ChangesetOutput, error) { + state, err := deployment.LoadOnchainStatesui(e) + if err != nil { + return cldf.ChangesetOutput{}, err + } + + chainState, ok := state[cfg.SuiChainSelector] + if !ok { + return cldf.ChangesetOutput{}, fmt.Errorf("no Sui chain state for selector %d", cfg.SuiChainSelector) + } + if chainState.CCIPAddress == "" { + return cldf.ChangesetOutput{}, fmt.Errorf("missing CCIP package address for chain %d", cfg.SuiChainSelector) + } + if chainState.CCIPObjectRef == "" { + return cldf.ChangesetOutput{}, fmt.Errorf("missing CCIP object ref for chain %d", cfg.SuiChainSelector) + } + if chainState.CCIPOwnerCapObjectId == "" { + return cldf.ChangesetOutput{}, fmt.Errorf("missing CCIP owner cap object id for chain %d", cfg.SuiChainSelector) + } + + suiChain, ok := e.BlockChains.SuiChains()[cfg.SuiChainSelector] + if !ok { + return cldf.ChangesetOutput{}, fmt.Errorf("no Sui chain client for selector %d", cfg.SuiChainSelector) + } + + subjects, err := buildCurseSubjects(cfg) + if err != nil { + return cldf.ChangesetOutput{}, err + } + + deps := sui_ops.OpTxDeps{ + Client: suiChain.Client, + Signer: suiChain.Signer, + GetCallOpts: func() *bind.CallOpts { + gasBudget := uint64(400_000_000) + return &bind.CallOpts{WaitForExecution: true, GasBudget: &gasBudget} + }, + SuiRPC: suiChain.URL, + } + + input := rmn_ops.CurseUncurseChainInput{ + CCIPPackageId: chainState.CCIPAddress, + StateObjectId: chainState.CCIPObjectRef, + OwnerCapObjectId: chainState.CCIPOwnerCapObjectId, + Subjects: subjects, + } + + var genericReport operations.Report[any, any] + if cfg.OperationType == string(UncurseOperationType) { + report, execErr := operations.ExecuteOperation(e.OperationsBundle, rmn_ops.UncurseChainOp, deps, input) + if execErr != nil { + return cldf.ChangesetOutput{}, execErr + } + genericReport = report.ToGenericReport() + } else { + report, execErr := operations.ExecuteOperation(e.OperationsBundle, rmn_ops.CurseChainOp, deps, input) + if execErr != nil { + return cldf.ChangesetOutput{}, execErr + } + genericReport = report.ToGenericReport() + } + + return cldf.ChangesetOutput{Reports: []operations.Report[any, any]{genericReport}}, nil +} + +func buildCurseSubjects(cfg CurseUncurseChainsConfig) ([][]byte, error) { + if cfg.IsGlobalCurse { + subject := make([]byte, len(globalCurseSubjectBytes)) + copy(subject, globalCurseSubjectBytes[:]) + return [][]byte{subject}, nil + } + subjects := make([][]byte, 0, len(cfg.DestChainSelectors)) + for _, selector := range cfg.DestChainSelectors { + subjects = append(subjects, selectorToSubject(selector)) + } + return subjects, nil +} + +func selectorToSubject(selector uint64) []byte { + subject := make([]byte, 16) + binary.BigEndian.PutUint64(subject[8:], selector) + return subject +} diff --git a/deployment/ops/rmn/op_curse_uncurse.go b/deployment/ops/rmn/op_curse_uncurse.go new file mode 100644 index 00000000..79f57590 --- /dev/null +++ b/deployment/ops/rmn/op_curse_uncurse.go @@ -0,0 +1,146 @@ +package rmn + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + + cld_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink-sui/bindings/bind" + module_rmn_remote "github.com/smartcontractkit/chainlink-sui/bindings/generated/ccip/ccip/rmn_remote" + sui_ops "github.com/smartcontractkit/chainlink-sui/deployment/ops" +) + +type NoObjects struct{} + +type CurseUncurseChainInput struct { + CCIPPackageId string + StateObjectId string + OwnerCapObjectId string + Subjects [][]byte +} + +var CurseChainOp = cld_ops.NewOperation( + sui_ops.NewSuiOperationName("ccip", "rmn_remote", "curse_chain"), + semver.MustParse("0.1.0"), + "Curse a chain selector in the CCIP RMN Remote contract", + curseChainHandler, +) + +func curseChainHandler(b cld_ops.Bundle, deps sui_ops.OpTxDeps, input CurseUncurseChainInput) (output sui_ops.OpTxResult[NoObjects], err error) { + if len(input.Subjects) == 0 { + return sui_ops.OpTxResult[NoObjects]{}, fmt.Errorf("at least one subject is required to curse") + } + + contract, err := module_rmn_remote.NewRmnRemote(input.CCIPPackageId, deps.Client) + if err != nil { + return sui_ops.OpTxResult[NoObjects]{}, fmt.Errorf("failed to create RMN Remote contract: %w", err) + } + + encodedCall, err := contract.Encoder().CurseMultiple( + bind.Object{Id: input.StateObjectId}, + bind.Object{Id: input.OwnerCapObjectId}, + input.Subjects, + ) + if err != nil { + return sui_ops.OpTxResult[NoObjects]{}, fmt.Errorf("failed to encode curse call: %w", err) + } + + call, err := sui_ops.ToTransactionCall(encodedCall, input.StateObjectId) + if err != nil { + return sui_ops.OpTxResult[NoObjects]{}, fmt.Errorf("failed to build transaction call for curse: %w", err) + } + + if deps.Signer == nil { + b.Logger.Infow("Skipping execution of curse_chain on RMN Remote as no signer provided") + return sui_ops.OpTxResult[NoObjects]{ + Digest: "", + PackageId: input.CCIPPackageId, + Objects: NoObjects{}, + Call: call, + }, nil + } + + opts := deps.GetCallOpts() + opts.Signer = deps.Signer + tx, err := contract.Bound().ExecuteTransaction( + b.GetContext(), + opts, + encodedCall, + ) + if err != nil { + return sui_ops.OpTxResult[NoObjects]{}, fmt.Errorf("failed to execute curse_chain on RMN Remote: %w", err) + } + + b.Logger.Infow("Chains cursed on RMN Remote", "digest", tx.Digest, "count", len(input.Subjects)) + + return sui_ops.OpTxResult[NoObjects]{ + Digest: tx.Digest, + PackageId: input.CCIPPackageId, + Objects: NoObjects{}, + Call: call, + }, nil +} + +var UncurseChainOp = cld_ops.NewOperation( + sui_ops.NewSuiOperationName("ccip", "rmn_remote", "uncurse_chain"), + semver.MustParse("0.1.0"), + "Uncurse a chain selector in the CCIP RMN Remote contract", + uncurseChainHandler, +) + +func uncurseChainHandler(b cld_ops.Bundle, deps sui_ops.OpTxDeps, input CurseUncurseChainInput) (output sui_ops.OpTxResult[NoObjects], err error) { + if len(input.Subjects) == 0 { + return sui_ops.OpTxResult[NoObjects]{}, fmt.Errorf("at least one subject is required to uncurse") + } + + contract, err := module_rmn_remote.NewRmnRemote(input.CCIPPackageId, deps.Client) + if err != nil { + return sui_ops.OpTxResult[NoObjects]{}, fmt.Errorf("failed to create RMN Remote contract: %w", err) + } + + encodedCall, err := contract.Encoder().UncurseMultiple( + bind.Object{Id: input.StateObjectId}, + bind.Object{Id: input.OwnerCapObjectId}, + input.Subjects, + ) + if err != nil { + return sui_ops.OpTxResult[NoObjects]{}, fmt.Errorf("failed to encode uncurse call: %w", err) + } + + call, err := sui_ops.ToTransactionCall(encodedCall, input.StateObjectId) + if err != nil { + return sui_ops.OpTxResult[NoObjects]{}, fmt.Errorf("failed to build transaction call for uncurse: %w", err) + } + + if deps.Signer == nil { + b.Logger.Infow("Skipping execution of uncurse_chain on RMN Remote as no signer provided") + return sui_ops.OpTxResult[NoObjects]{ + Digest: "", + PackageId: input.CCIPPackageId, + Objects: NoObjects{}, + Call: call, + }, nil + } + + opts := deps.GetCallOpts() + opts.Signer = deps.Signer + tx, err := contract.Bound().ExecuteTransaction( + b.GetContext(), + opts, + encodedCall, + ) + if err != nil { + return sui_ops.OpTxResult[NoObjects]{}, fmt.Errorf("failed to execute uncurse_chain on RMN Remote: %w", err) + } + + b.Logger.Infow("Chains uncursed on RMN Remote", "digest", tx.Digest, "count", len(input.Subjects)) + + return sui_ops.OpTxResult[NoObjects]{ + Digest: tx.Digest, + PackageId: input.CCIPPackageId, + Objects: NoObjects{}, + Call: call, + }, nil +} diff --git a/integration-tests/deploy/deploy_test.go b/integration-tests/deploy/deploy_test.go index e8604522..b2f448be 100644 --- a/integration-tests/deploy/deploy_test.go +++ b/integration-tests/deploy/deploy_test.go @@ -3,6 +3,8 @@ package deploy import ( + "bytes" + "encoding/binary" "fmt" "testing" @@ -11,6 +13,8 @@ import ( cselectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-sui/bindings/bind" + module_rmn_remote "github.com/smartcontractkit/chainlink-sui/bindings/generated/ccip/ccip/rmn_remote" "github.com/smartcontractkit/chainlink-sui/deployment" "github.com/smartcontractkit/chainlink-sui/deployment/changesets" burnminttokenpoolops "github.com/smartcontractkit/chainlink-sui/deployment/ops/ccip_burn_mint_token_pool" @@ -48,6 +52,28 @@ func (s *DeployTestSuite) TestDeployAndConfigureSuiChain() { // Phase 10: Deploy Managed Token Pool s.DeployManagedTokenPool() + // Phase 11: Test curse/uncurse RMN subjects + // this test is placed here since we have a full deployment to test against + curseCfg := changesets.CurseUncurseChainsConfig{ + SuiChainSelector: SuiChainSelector, + OperationType: string(changesets.CurseOperationType), + IsGlobalCurse: false, + DestChainSelectors: []uint64{EVMChainSelector}, + } + curseOut, err := changesets.CurseUncurseChains{}.Apply(s.env, curseCfg) + s.Require().NoError(err, "failed to curse RMN subjects") + s.Require().Len(curseOut.Reports, 1, "expected single curse report") + + s.assertRMNCurseSubjects(EVMChainSelector, true) + + uncurseCfg := curseCfg + uncurseCfg.OperationType = string(changesets.UncurseOperationType) + uncurseOut, err := changesets.CurseUncurseChains{}.Apply(s.env, uncurseCfg) + s.Require().NoError(err, "failed to uncurse RMN subjects") + s.Require().Len(uncurseOut.Reports, 1, "expected single uncurse report") + + s.assertRMNCurseSubjects(EVMChainSelector, false) + // Load view and check deployments states, err := deployment.LoadOnchainStatesui(s.env) state := states[cselectors.SUI_LOCALNET.Selector] @@ -459,3 +485,34 @@ func (s *DeployTestSuite) DeployManagedTokenPool() { err = s.env.ExistingAddresses.Merge(tokenPoolOut.AddressBook) s.Require().NoError(err, "failed to merge managed token pool addresses") } + +func (s *DeployTestSuite) assertRMNCurseSubjects(selector uint64, expectCursed bool) { + s.T().Helper() + + s.Require().NotEmpty(s.ccipPackageID, "CCIP package ID not set for RMN assertions") + s.Require().NotEmpty(s.ccipObjectRef, "CCIP object ref not set for RMN assertions") + + contract, err := module_rmn_remote.NewRmnRemote(s.ccipPackageID, s.client) + s.Require().NoError(err, "failed to create RMN remote binding") + + callOpts := &bind.CallOpts{Signer: s.signer} + subjects, err := contract.DevInspect().GetCursedSubjects(s.T().Context(), callOpts, bind.Object{Id: s.ccipObjectRef}) + s.Require().NoError(err, "failed to fetch cursed subjects") + + target := make([]byte, 16) + binary.BigEndian.PutUint64(target[8:], selector) + found := false + for _, subj := range subjects { + if bytes.Equal(subj, target) { + found = true + break + } + } + + if expectCursed { + s.Require().True(found, "expected selector to be cursed") + return + } + + s.Require().False(found, "expected selector to be uncursed") +}