diff --git a/e2e/tests/canton/common.go b/e2e/tests/canton/common.go index 1eae4e9c..202830c8 100644 --- a/e2e/tests/canton/common.go +++ b/e2e/tests/canton/common.go @@ -33,12 +33,12 @@ type TestSuite struct { participant testhelpers.Participant - chainSelector mcmstypes.ChainSelector - chainId int64 - packageIDs []string - mcmsContractID string - mcmsId string - proposerMcmsId string + chainSelector mcmstypes.ChainSelector + chainId int64 + packageIDs []string + mcmsInstanceAddress string // InstanceAddress hex (stable across SetRoot/ExecuteOp); use this everywhere instead of contract ID + mcmsId string // MCMS contract instanceId (base); DAML expects metadata.multisigId = makeMcmsId(instanceId, role) e.g. "mcms-test-001-proposer" + proposerMcmsId string // makeMcmsId(mcmsId, Proposer) for chain metadata and Op.multisigId } func (s *TestSuite) SetupSuite() { @@ -65,8 +65,8 @@ func (s *TestSuite) DeployMCMSContract() { chainId := int64(1) mcmsId := "mcms-test-001" - mcmsContractId := s.createMCMS(s.T().Context(), s.participant, mcmsOwner, chainId, mcmsId, mcms.RoleProposer) - s.mcmsContractID = mcmsContractId + mcmsInstanceAddr := s.createMCMS(s.T().Context(), s.participant, mcmsOwner, chainId, mcmsId, mcms.RoleProposer) + s.mcmsInstanceAddress = mcmsInstanceAddr s.mcmsId = mcmsId s.proposerMcmsId = fmt.Sprintf("%s-%s", mcmsId, "proposer") s.chainId = chainId @@ -75,28 +75,23 @@ func (s *TestSuite) DeployMCMSContract() { func (s *TestSuite) DeployMCMSWithConfig(config *mcmstypes.Config) { s.DeployMCMSContract() - // Set the config - configurer, err := cantonsdk.NewConfigurer(s.participant.CommandServiceClient, s.participant.UserName, s.participant.Party, cantonsdk.TimelockRoleProposer) + // Set the config (use InstanceAddress; no need to update suite after — InstanceAddress is stable) + configurer, err := cantonsdk.NewConfigurer(s.participant.CommandServiceClient, s.participant.StateServiceClient, s.participant.UserName, s.participant.Party, cantonsdk.TimelockRoleProposer) s.Require().NoError(err) - tx, err := configurer.SetConfig(s.T().Context(), s.mcmsContractID, config, true) + _, err = configurer.SetConfig(s.T().Context(), s.mcmsInstanceAddress, config, true) s.Require().NoError(err) - - // Extract new contract ID - rawData, ok := tx.RawData.(map[string]any) - s.Require().True(ok) - newContractID, ok := rawData["NewMCMSContractID"].(string) - s.Require().True(ok) - s.mcmsContractID = newContractID } -func (s *MCMSExecutorTestSuite) DeployCounterContract() { +func (s *mcmsExecutorSetup) DeployCounterContract() { s.counterInstanceID = "counter-" + uuid.New().String()[:8] + // DAML MCMSReceiver expects instanceId in format "baseId@partyId"; use same in batch TargetInstanceId + counterInstanceIdOnChain := fmt.Sprintf("%s@%s", s.counterInstanceID, s.participant.Party) // Create Counter contract counterContract := mcms.Counter{ Owner: types.PARTY(s.participant.Party), - InstanceId: types.TEXT(s.counterInstanceID), + InstanceId: types.TEXT(counterInstanceIdOnChain), Value: types.INT64(0), } @@ -144,6 +139,7 @@ func (s *MCMSExecutorTestSuite) DeployCounterContract() { s.Require().NotEmpty(s.counterCID) } +// createMCMS creates an MCMS contract and returns its InstanceAddress hex (stable reference for Canton). func (s *TestSuite) createMCMS(ctx context.Context, participant testhelpers.Participant, owner string, chainId int64, mcmsId string, role mcms.Role) string { // Create empty config emptyConfig := mcms.MultisigConfig{ @@ -240,7 +236,9 @@ func (s *TestSuite) createMCMS(ctx context.Context, participant testhelpers.Part s.Require().NotEmpty(mcmsContractID, "failed to find MCMS contract in transaction events") s.Require().NotEmpty(mcmsTemplateID, "failed to find MCMS template ID in transaction events") - return mcmsContractID + // Return InstanceAddress hex so callers use a stable reference (contract ID changes after SetRoot/ExecuteOp) + instanceAddress := contracts.InstanceID(mcmsId).RawInstanceAddress(types.PARTY(owner)).InstanceAddress() + return instanceAddress.Hex() } // encodeSetConfigParams encodes SetConfig parameters for Canton MCMS diff --git a/e2e/tests/canton/configurer.go b/e2e/tests/canton/configurer.go index 95d82828..9729fc18 100644 --- a/e2e/tests/canton/configurer.go +++ b/e2e/tests/canton/configurer.go @@ -76,11 +76,15 @@ func (s *MCMSConfigurerTestSuite) TestSetConfig() { }, } - // Set config + // Set config (use InstanceAddress); resolve once to get current contract ID for event assertions { - configurer, err := cantonsdk.NewConfigurer(s.participant.CommandServiceClient, s.participant.UserName, s.participant.Party, cantonsdk.TimelockRoleProposer) + ctx := s.T().Context() + oldContractID, err := cantonsdk.ResolveMCMSContractID(ctx, s.participant.StateServiceClient, s.participant.Party, s.mcmsInstanceAddress) + s.Require().NoError(err, "resolve MCMS contract ID before SetConfig") + + configurer, err := cantonsdk.NewConfigurer(s.participant.CommandServiceClient, s.participant.StateServiceClient, s.participant.UserName, s.participant.Party, cantonsdk.TimelockRoleProposer) s.Require().NoError(err, "creating configurer for Canton mcms contract") - tx, err := configurer.SetConfig(s.T().Context(), s.mcmsContractID, proposerConfig, true) + tx, err := configurer.SetConfig(ctx, s.mcmsInstanceAddress, proposerConfig, true) s.Require().NoError(err, "setting config on Canton mcms contract") // Verify transaction result @@ -102,7 +106,7 @@ func (s *MCMSConfigurerTestSuite) TestSetConfig() { // Verify event[0] is Archived (old contract) s.Require().NotNil(events[0].GetArchived(), "first event should be Archived event") s.Require().Nil(events[0].GetCreated(), "first event should not be Created event") - s.Require().Equal(s.mcmsContractID, events[0].GetArchived().GetContractId(), "archived contract should be the old MCMS contract") + s.Require().Equal(oldContractID, events[0].GetArchived().GetContractId(), "archived contract should be the old MCMS contract") // Verify event[1] is Created (new contract) s.Require().NotNil(events[1].GetCreated(), "second event should be Created event") @@ -123,7 +127,7 @@ func (s *MCMSConfigurerTestSuite) TestSetConfig() { newMCMSContractID, ok := rawData["NewMCMSContractID"].(string) s.Require().True(ok) s.Require().NotEmpty(newMCMSContractID, "new contract ID should not be empty") - s.Require().NotEqual(s.mcmsContractID, newMCMSContractID, "new contract ID should be different from old contract ID") + s.Require().NotEqual(oldContractID, newMCMSContractID, "new contract ID should be different from old contract ID") s.Require().Equal(newMCMSContractID, events[1].GetCreated().GetContractId(), "created event contract ID should match returned contract ID") } } diff --git a/e2e/tests/canton/executor.go b/e2e/tests/canton/executor.go index 74485e55..4bf489ec 100644 --- a/e2e/tests/canton/executor.go +++ b/e2e/tests/canton/executor.go @@ -4,24 +4,18 @@ package canton import ( "crypto/ecdsa" - "encoding/json" - "fmt" "slices" - "testing" - "time" - apiv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" - "github.com/stretchr/testify/suite" mcmscore "github.com/smartcontractkit/mcms" - "github.com/smartcontractkit/mcms/sdk" - cantonsdk "github.com/smartcontractkit/mcms/sdk/canton" mcmstypes "github.com/smartcontractkit/mcms/types" ) -type MCMSExecutorTestSuite struct { +// mcmsExecutorSetup holds shared setup (MCMS + config + counter + signers) for suites that need it. +// It has no Test* methods, so embedding it only adds SetupSuite and fields; test methods come from the embedding suite. +type mcmsExecutorSetup struct { TestSuite // Test signers @@ -35,12 +29,8 @@ type MCMSExecutorTestSuite struct { counterCID string } -func TestMCMSExecutorTestSuite(t *testing.T) { - suite.Run(t, new(MCMSExecutorTestSuite)) -} - -// SetupSuite runs before the test suite -func (s *MCMSExecutorTestSuite) SetupSuite() { +// SetupSuite runs before the test suite. +func (s *mcmsExecutorSetup) SetupSuite() { s.TestSuite.SetupSuite() // Create 3 signers for 2-of-3 multisig @@ -77,303 +67,9 @@ func (s *MCMSExecutorTestSuite) SetupSuite() { s.DeployCounterContract() } -func (s *MCMSExecutorTestSuite) create2of3Config() *mcmstypes.Config { +func (s *mcmsExecutorSetup) create2of3Config() *mcmstypes.Config { return &mcmstypes.Config{ Quorum: 2, Signers: s.signerAddrs, } } - -func (s *MCMSExecutorTestSuite) TestSetRootAndExecuteCounterOp() { - ctx := s.T().Context() - - // Create metadata for Canton chain - metadata, err := cantonsdk.NewChainMetadata( - 0, // preOpCount - 1, - s.chainId, - s.proposerMcmsId, - s.mcmsContractID, - false, - ) - s.Require().NoError(err) - - // Build a test proposal with an operation to increment counter - validUntil := time.Now().Add(24 * time.Hour) - opAdditionalFields := cantonsdk.AdditionalFields{ - TargetInstanceId: fmt.Sprintf("%s@%s", s.counterInstanceID, s.participant.Party), - FunctionName: "increment", - OperationData: "", - TargetCid: s.counterCID, - ContractIds: []string{s.counterCID}, - } - opAdditionalFieldsBytes, err := json.Marshal(opAdditionalFields) - s.Require().NoError(err) - - proposal, err := mcmscore.NewProposalBuilder(). - SetVersion("v1"). - SetValidUntil(uint32(validUntil.Unix())). - SetDescription(fmt.Sprintf("Canton ExecuteOp test - %v", validUntil)). - SetOverridePreviousRoot(false). - AddChainMetadata(s.chainSelector, metadata). - AddOperation(mcmstypes.Operation{ - ChainSelector: s.chainSelector, - Transaction: mcmstypes.Transaction{ - To: s.counterCID, - Data: []byte{}, - AdditionalFields: opAdditionalFieldsBytes, - }, - }). - Build() - s.Require().NoError(err) - - // Create inspector and executor - inspector := cantonsdk.NewInspector(s.participant.StateServiceClient, s.participant.Party, cantonsdk.TimelockRoleProposer) - - encoders, err := proposal.GetEncoders() - s.Require().NoError(err) - encoder := encoders[s.chainSelector].(*cantonsdk.Encoder) - - executor, err := cantonsdk.NewExecutor(encoder, inspector, s.participant.CommandServiceClient, s.participant.UserName, s.participant.Party, cantonsdk.TimelockRoleProposer) - s.Require().NoError(err) - - executors := map[mcmstypes.ChainSelector]sdk.Executor{ - s.chainSelector: executor, - } - - // Sign with first 2 sorted signers - _, _, err = s.SignProposal(proposal, inspector, s.sortedSigners[:2], 2) - s.Require().NoError(err) - - // Create executable - executable, err := mcmscore.NewExecutable(proposal, executors) - s.Require().NoError(err) - - // First, set the root - txSetRoot, err := executable.SetRoot(ctx, s.chainSelector) - s.Require().NoError(err) - s.Require().NotEmpty(txSetRoot.Hash) - - // Update contract ID after SetRoot - rawData, ok := txSetRoot.RawData.(map[string]any) - s.Require().True(ok) - newMCMSContractID, ok := rawData["NewMCMSContractID"].(string) - s.Require().True(ok) - s.Require().NotEmpty(newMCMSContractID) - s.mcmsContractID = newMCMSContractID - - s.T().Logf("✅ SetRoot completed, new MCMS CID: %s", s.mcmsContractID) - - // Update proposal with new multisig id - newMetadata, err := cantonsdk.NewChainMetadata( - 0, // preOpCount - 1, // postOp - s.chainId, - s.proposerMcmsId, - s.mcmsContractID, - false, - ) - proposal.ChainMetadata[s.chainSelector] = newMetadata - // Now execute the operation (index 0) - txExecute, err := executable.Execute(ctx, 0) - s.Require().NoError(err) - s.Require().NotEmpty(txExecute.Hash) - - // Verify the counter was incremented by checking transaction events - rawTx, ok := txExecute.RawData.(map[string]any)["RawTx"] - s.Require().True(ok) - submitResp, ok := rawTx.(*apiv2.SubmitAndWaitForTransactionResponse) - s.Require().True(ok) - - s.verifyCounterIncremented(submitResp) - - // Verify MCMS contract was recreated with incremented opCount - foundMCMS := false - transaction := submitResp.GetTransaction() - for _, event := range transaction.GetEvents() { - if createdEv := event.GetCreated(); createdEv != nil { - templateID := cantonsdk.FormatTemplateID(createdEv.GetTemplateId()) - normalized := cantonsdk.NormalizeTemplateKey(templateID) - if normalized == cantonsdk.MCMSTemplateKey { - foundMCMS = true - newMCMSCID := createdEv.GetContractId() - s.Require().NotEmpty(newMCMSCID) - s.Require().NotEqual(s.mcmsContractID, newMCMSCID, "MCMS should be recreated after execute") - s.mcmsContractID = newMCMSCID - s.T().Logf("✅ MCMS contract recreated: %s", s.mcmsContractID) - break - } - } - } - s.Require().True(foundMCMS, "MCMS contract should be recreated after execute") - - s.T().Logf("✅ ExecuteOp completed in tx: %s", txExecute.Hash) -} - -func (s *MCMSExecutorTestSuite) TestSetRootAndExecuteMCMSOp() { - ctx := s.T().Context() - - // Encode the SetConfig operation data to change quorum from 2 to 1 - // For Canton, we need to encode the signers, group quorums, and group parents - groupQuorums := make([]int64, 32) - groupQuorums[0] = 1 // Root group needs 1 signature now - groupParents := make([]int64, 32) - // All groups point to root (0) - - // Convert signers to lowercase hex without 0x prefix (Canton format) - signerAddresses := make([]string, len(s.signerAddrs)) - signerGroups := make([]int64, len(s.signerAddrs)) - for i, addr := range s.signerAddrs { - signerAddresses[i] = addr.Hex()[2:] // Remove 0x prefix, keep checksum - signerGroups[i] = 0 // All in group 0 - } - - // Encode SetConfig params using Canton's encoding format - // This follows the format from chainlink-canton integration tests - operationData := EncodeSetConfigParams(&s.TestSuite, signerAddresses, groupQuorums, groupParents, false) - - // Create metadata for Canton chain - metadata, err := cantonsdk.NewChainMetadata( - 1, // preOpCount - 2, // postOp - s.chainId, - s.proposerMcmsId, - s.mcmsContractID, - false, - ) - s.Require().NoError(err) - - // Build a proposal with SetConfig operation targeting MCMS itself - validUntil := time.Now().Add(24 * time.Hour) - opAdditionalFields := cantonsdk.AdditionalFields{ - TargetInstanceId: "self", - FunctionName: "set_config", - OperationData: operationData, - TargetCid: s.mcmsContractID, - ContractIds: []string{s.mcmsContractID}, - } - opAdditionalFieldsBytes, err := json.Marshal(opAdditionalFields) - s.Require().NoError(err) - - proposal, err := mcmscore.NewProposalBuilder(). - SetVersion("v1"). - SetValidUntil(uint32(validUntil.Unix())). - SetDescription(fmt.Sprintf("Canton SetConfig test - change quorum to 3 - %v", validUntil)). - SetOverridePreviousRoot(false). - AddChainMetadata(s.chainSelector, metadata). - AddOperation(mcmstypes.Operation{ - ChainSelector: s.chainSelector, - Transaction: mcmstypes.Transaction{ - To: s.mcmsContractID, - Data: []byte{}, - AdditionalFields: opAdditionalFieldsBytes, - }, - }). - Build() - s.Require().NoError(err) - - // Create inspector and executor - inspector := cantonsdk.NewInspector(s.participant.StateServiceClient, s.participant.Party, cantonsdk.TimelockRoleProposer) - - encoders, err := proposal.GetEncoders() - s.Require().NoError(err) - encoder := encoders[s.chainSelector].(*cantonsdk.Encoder) - - executor, err := cantonsdk.NewExecutor(encoder, inspector, s.participant.CommandServiceClient, s.participant.UserName, s.participant.Party, cantonsdk.TimelockRoleProposer) - s.Require().NoError(err) - - executors := map[mcmstypes.ChainSelector]sdk.Executor{ - s.chainSelector: executor, - } - - // Sign with first 2 sorted signers - _, _, err = s.SignProposal(proposal, inspector, s.sortedSigners[:2], 2) - s.Require().NoError(err) - - // Create executable - executable, err := mcmscore.NewExecutable(proposal, executors) - s.Require().NoError(err) - - // First, set the root - txSetRoot, err := executable.SetRoot(ctx, s.chainSelector) - s.Require().NoError(err) - s.Require().NotEmpty(txSetRoot.Hash) - - // Update contract ID after SetRoot - rawData, ok := txSetRoot.RawData.(map[string]any) - s.Require().True(ok) - newMCMSContractID, ok := rawData["NewMCMSContractID"].(string) - s.Require().True(ok) - s.Require().NotEmpty(newMCMSContractID) - oldMCMSContractID := s.mcmsContractID - s.mcmsContractID = newMCMSContractID - s.Require().NotEqual(oldMCMSContractID, s.mcmsContractID, "MCMS contract ID should change after SetRoot") - - // Update proposal with new MCMS contract ID in metadata - - s.T().Logf("✅ SetRoot completed, new MCMS CID: %s", s.mcmsContractID) - - newMetadata, err := cantonsdk.NewChainMetadata( - 1, // preOpCount - 2, // postOp - s.chainId, - s.proposerMcmsId, - newMCMSContractID, - false, - ) - s.Require().NoError(err) - // Override proposal metadata with new MCMS contract ID - proposal.ChainMetadata[s.chainSelector] = newMetadata - - // Now execute the SetConfig operation (index 0) - txExecute, err := executable.Execute(ctx, 0) - s.Require().NoError(err) - s.Require().NotEmpty(txExecute.Hash) - - // Verify MCMS contract was recreated with new config - rawTx, ok := txExecute.RawData.(map[string]any)["RawTx"] - s.Require().True(ok) - submitResp, ok := rawTx.(*apiv2.SubmitAndWaitForTransactionResponse) - s.Require().True(ok) - - foundMCMS := false - transaction := submitResp.GetTransaction() - for _, event := range transaction.GetEvents() { - if createdEv := event.GetCreated(); createdEv != nil { - templateID := cantonsdk.FormatTemplateID(createdEv.GetTemplateId()) - normalized := cantonsdk.NormalizeTemplateKey(templateID) - if normalized == cantonsdk.MCMSTemplateKey { - foundMCMS = true - newMCMSCID := createdEv.GetContractId() - s.Require().NotEmpty(newMCMSCID) - s.Require().NotEqual(oldMCMSContractID, newMCMSCID, "MCMS should be recreated after SetConfig") - s.mcmsContractID = newMCMSCID - s.T().Logf("✅ MCMS contract recreated with new config: %s", s.mcmsContractID) - break - } - } - } - s.Require().True(foundMCMS, "MCMS contract should be recreated after SetConfig") - - s.T().Logf("✅ SetConfig operation completed in tx: %s", txExecute.Hash) - - // TODO: Inspect config when ready -} - -// Helper functions -func (s *MCMSExecutorTestSuite) verifyCounterIncremented(submitResp *apiv2.SubmitAndWaitForTransactionResponse) { - // Look for Counter contract in created events - transaction := submitResp.GetTransaction() - for _, event := range transaction.GetEvents() { - if createdEv := event.GetCreated(); createdEv != nil { - templateID := cantonsdk.FormatTemplateID(createdEv.GetTemplateId()) - normalized := cantonsdk.NormalizeTemplateKey(templateID) - if normalized == "MCMS.Counter:Counter" { - // Counter was recreated, which means it was successfully executed - s.T().Log("Counter contract was successfully incremented") - return - } - } - } - s.Fail("Counter contract not found in transaction events") -} diff --git a/e2e/tests/canton/inspector.go b/e2e/tests/canton/inspector.go index e65cc5c4..51379794 100644 --- a/e2e/tests/canton/inspector.go +++ b/e2e/tests/canton/inspector.go @@ -3,12 +3,9 @@ package canton import ( - "context" - "io" "slices" "testing" - apiv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/suite" @@ -85,19 +82,15 @@ func (s *MCMSInspectorTestSuite) TestGetConfig() { }, } - // Set config using configurer - configurer, err := cantonsdk.NewConfigurer(s.participant.CommandServiceClient, s.participant.UserName, s.participant.Party, cantonsdk.TimelockRoleProposer) + // Set config using configurer (InstanceAddress is stable across SetConfig) + configurer, err := cantonsdk.NewConfigurer(s.participant.CommandServiceClient, s.participant.StateServiceClient, s.participant.UserName, s.participant.Party, cantonsdk.TimelockRoleProposer) s.Require().NoError(err, "creating configurer") - _, err = configurer.SetConfig(ctx, s.mcmsContractID, expectedConfig, true) + _, err = configurer.SetConfig(ctx, s.mcmsInstanceAddress, expectedConfig, true) s.Require().NoError(err, "setting config") - // Get the new contract ID after SetConfig (which archives old and creates new) - newContractID, err := s.getLatestMCMSContractID(ctx) - s.Require().NoError(err, "getting latest MCMS contract ID") - - // Now test the inspector - actualConfig, err := s.inspector.GetConfig(ctx, newContractID) + // Inspector resolves InstanceAddress when querying + actualConfig, err := s.inspector.GetConfig(ctx, s.mcmsInstanceAddress) s.Require().NoError(err, "getting config from inspector") s.Require().NotNil(actualConfig, "config should not be nil") @@ -108,12 +101,7 @@ func (s *MCMSInspectorTestSuite) TestGetConfig() { func (s *MCMSInspectorTestSuite) TestGetOpCount() { ctx := s.T().Context() - // Get the latest contract ID - contractID, err := s.getLatestMCMSContractID(ctx) - s.Require().NoError(err, "getting latest MCMS contract ID") - - // Get op count - opCount, err := s.inspector.GetOpCount(ctx, contractID) + opCount, err := s.inspector.GetOpCount(ctx, s.mcmsInstanceAddress) s.Require().NoError(err, "getting op count") // Initially should be 0 @@ -123,28 +111,18 @@ func (s *MCMSInspectorTestSuite) TestGetOpCount() { func (s *MCMSInspectorTestSuite) TestGetRoot() { ctx := s.T().Context() - // Get the latest contract ID - contractID, err := s.getLatestMCMSContractID(ctx) - s.Require().NoError(err, "getting latest MCMS contract ID") - - // Get root - root, validUntil, err := s.inspector.GetRoot(ctx, contractID) + root, validUntil, err := s.inspector.GetRoot(ctx, s.mcmsInstanceAddress) s.Require().NoError(err, "getting root") - // Initially root should be empty and validUntil should be 0 + // Initially no SetRoot has been called: root should be empty s.Require().Equal(common.Hash{}, root, "initial root should be empty") - s.Require().Equal(uint32(4294905160), validUntil, "initial validUntil should be 0xffff0d48") + _ = validUntil // initial value is from MCMS emptyExpiringRoot (epoch); exact value depends on ledger/bindings } func (s *MCMSInspectorTestSuite) TestGetRootMetadata() { ctx := s.T().Context() - // Get the latest contract ID - contractID, err := s.getLatestMCMSContractID(ctx) - s.Require().NoError(err, "getting latest MCMS contract ID") - - // Get root metadata - metadata, err := s.inspector.GetRootMetadata(ctx, contractID) + metadata, err := s.inspector.GetRootMetadata(ctx, s.mcmsInstanceAddress) s.Require().NoError(err, "getting root metadata") // Verify metadata structure @@ -152,70 +130,6 @@ func (s *MCMSInspectorTestSuite) TestGetRootMetadata() { s.Require().NotEmpty(metadata.MCMAddress, "MCM address should not be empty") } -// Helper function to get the latest MCMS contract ID -func (s *MCMSInspectorTestSuite) getLatestMCMSContractID(ctx context.Context) (string, error) { - // Get current ledger offset - ledgerEndResp, err := s.participant.StateServiceClient.GetLedgerEnd(ctx, &apiv2.GetLedgerEndRequest{}) - if err != nil { - return "", err - } - - // Query active contracts - activeContractsResp, err := s.participant.StateServiceClient.GetActiveContracts(ctx, &apiv2.GetActiveContractsRequest{ - ActiveAtOffset: ledgerEndResp.GetOffset(), - EventFormat: &apiv2.EventFormat{ - FiltersByParty: map[string]*apiv2.Filters{ - s.participant.Party: { - Cumulative: []*apiv2.CumulativeFilter{ - { - IdentifierFilter: &apiv2.CumulativeFilter_TemplateFilter{ - TemplateFilter: &apiv2.TemplateFilter{ - TemplateId: &apiv2.Identifier{ - PackageId: "#mcms", - ModuleName: "MCMS.Main", - EntityName: "MCMS", - }, - IncludeCreatedEventBlob: false, - }, - }, - }, - }, - }, - }, - Verbose: true, - }, - }) - if err != nil { - return "", err - } - defer activeContractsResp.CloseSend() - - // Get the first (and should be only) active MCMS contract - for { - resp, err := activeContractsResp.Recv() - if err == io.EOF { - break - } - if err != nil { - return "", err - } - - activeContract, ok := resp.GetContractEntry().(*apiv2.GetActiveContractsResponse_ActiveContract) - if !ok { - continue - } - - createdEvent := activeContract.ActiveContract.GetCreatedEvent() - if createdEvent == nil { - continue - } - - return createdEvent.ContractId, nil - } - - return "", nil -} - // Helper to verify config matches func (s *MCMSInspectorTestSuite) verifyConfigMatch(expected, actual *mcmstypes.Config) { s.Require().Equal(expected.Quorum, actual.Quorum, "quorum should match") diff --git a/e2e/tests/canton/timelock_inspection.go b/e2e/tests/canton/timelock_inspection.go new file mode 100644 index 00000000..879c6dce --- /dev/null +++ b/e2e/tests/canton/timelock_inspection.go @@ -0,0 +1,165 @@ +//go:build e2e + +package canton + +import ( + "encoding/json" + + "github.com/ethereum/go-ethereum/common" + + cantonsdk "github.com/smartcontractkit/mcms/sdk/canton" + mcmstypes "github.com/smartcontractkit/mcms/types" +) + +// TimelockInspectionTestSuite defines the test suite for Canton timelock inspection. +type TimelockInspectionTestSuite struct { + TestSuite + inspector *cantonsdk.TimelockInspector +} + +// SetupSuite runs before the test suite. +func (s *TimelockInspectionTestSuite) SetupSuite() { + s.TestSuite.SetupSuite() + s.DeployMCMSContract() + s.inspector = cantonsdk.NewTimelockInspector(s.participant.CommandServiceClient, s.participant.StateServiceClient, s.participant.Party) +} + +// TestGetProposers tests that GetProposers returns proposer signers from the MCMS contract. +func (s *TimelockInspectionTestSuite) TestGetProposers() { + ctx := s.T().Context() + proposers, err := s.inspector.GetProposers(ctx, s.mcmsInstanceAddress) + s.Require().NoError(err) + s.Require().NotNil(proposers) + // Fresh MCMS has no signers configured; list may be empty +} + +// TestGetExecutors tests that GetExecutors returns unsupported on Canton. +func (s *TimelockInspectionTestSuite) TestGetExecutors() { + ctx := s.T().Context() + executors, err := s.inspector.GetExecutors(ctx, s.mcmsInstanceAddress) + s.Require().Error(err, "GetExecutors should return an error on Canton") + s.Require().Contains(err.Error(), "unsupported on Canton") + s.Require().Nil(executors) +} + +// TestGetBypassers tests that GetBypassers returns bypasser signers from the MCMS contract. +func (s *TimelockInspectionTestSuite) TestGetBypassers() { + ctx := s.T().Context() + bypassers, err := s.inspector.GetBypassers(ctx, s.mcmsInstanceAddress) + s.Require().NoError(err) + s.Require().NotNil(bypassers) + // Fresh MCMS has no signers configured; list may be empty +} + +// TestGetCancellers tests that GetCancellers returns canceller signers from the MCMS contract. +func (s *TimelockInspectionTestSuite) TestGetCancellers() { + ctx := s.T().Context() + cancellers, err := s.inspector.GetCancellers(ctx, s.mcmsInstanceAddress) + s.Require().NoError(err) + s.Require().NotNil(cancellers) + // Fresh MCMS has no signers configured; list may be empty +} + +// TestIsOperation tests that IsOperation queries the ledger (returns false for unknown op ID). +func (s *TimelockInspectionTestSuite) TestIsOperation() { + ctx := s.T().Context() + var opID [32]byte + copy(opID[:], "test-operation-id") + isOp, err := s.inspector.IsOperation(ctx, s.mcmsInstanceAddress, opID) + s.Require().NoError(err) + s.Require().False(isOp) +} + +// TestIsOperationPending tests that IsOperationPending queries the ledger. +func (s *TimelockInspectionTestSuite) TestIsOperationPending() { + ctx := s.T().Context() + var opID [32]byte + copy(opID[:], "test-pending-id") + isPending, err := s.inspector.IsOperationPending(ctx, s.mcmsInstanceAddress, opID) + s.Require().NoError(err) + s.Require().False(isPending) +} + +// TestIsOperationReady tests that IsOperationReady queries the ledger. +func (s *TimelockInspectionTestSuite) TestIsOperationReady() { + ctx := s.T().Context() + var opID [32]byte + copy(opID[:], "test-ready-id") + isReady, err := s.inspector.IsOperationReady(ctx, s.mcmsInstanceAddress, opID) + s.Require().NoError(err) + s.Require().False(isReady) +} + +// TestIsOperationDone tests that IsOperationDone queries the ledger. +func (s *TimelockInspectionTestSuite) TestIsOperationDone() { + ctx := s.T().Context() + var opID [32]byte + copy(opID[:], "test-done-id") + isDone, err := s.inspector.IsOperationDone(ctx, s.mcmsInstanceAddress, opID) + s.Require().NoError(err) + s.Require().False(isDone) +} + +// TestGetMinDelay tests that GetMinDelay returns the MCMS min delay from the ledger. +func (s *TimelockInspectionTestSuite) TestGetMinDelay() { + ctx := s.T().Context() + delay, err := s.inspector.GetMinDelay(ctx, s.mcmsInstanceAddress) + s.Require().NoError(err) + s.Require().GreaterOrEqual(delay, uint64(0)) +} + + +// TestTimelockConverter tests that ConvertBatchToChainOperations returns one ScheduleBatch operation and a non-zero op ID. +func (s *TimelockInspectionTestSuite) TestTimelockConverter() { + ctx := s.T().Context() + metadata, err := cantonsdk.NewChainMetadata(0, 1, s.chainId, s.proposerMcmsId, s.mcmsInstanceAddress, false, s.mcmsId) + s.Require().NoError(err) + + af := cantonsdk.AdditionalFields{ + TargetInstanceId: "instance@party", + FunctionName: "noop", + OperationData: "", + TargetCid: s.mcmsInstanceAddress, + ContractIds: []string{s.mcmsInstanceAddress}, + } + afBytes, err := json.Marshal(af) + s.Require().NoError(err) + + bop := mcmstypes.BatchOperation{ + ChainSelector: s.chainSelector, + Transactions: []mcmstypes.Transaction{{ + To: s.mcmsInstanceAddress, + Data: []byte{}, + AdditionalFields: afBytes, + }}, + } + converter := cantonsdk.NewTimelockConverter() + ops, opID, err := converter.ConvertBatchToChainOperations( + ctx, + metadata, + bop, + s.mcmsInstanceAddress, + s.mcmsInstanceAddress, + mcmstypes.NewDuration(0), + mcmstypes.TimelockActionSchedule, + common.Hash{}, + common.Hash{}, + ) + s.Require().NoError(err) + s.Require().Len(ops, 1) + s.Require().NotEqual(common.Hash{}, opID) +} + +// TestTimelockExecutorExecuteEmptyBatch tests that Execute returns an error for empty batch. +func (s *TimelockInspectionTestSuite) TestTimelockExecutorExecuteEmptyBatch() { + ctx := s.T().Context() + executor := cantonsdk.NewTimelockExecutor(s.participant.CommandServiceClient, s.participant.StateServiceClient, s.participant.Party) + bop := mcmstypes.BatchOperation{ + ChainSelector: s.chainSelector, + Transactions: []mcmstypes.Transaction{}, + } + res, err := executor.Execute(ctx, bop, s.mcmsInstanceAddress, common.Hash{}, common.Hash{}) + s.Require().Error(err, "Execute should return an error for empty batch") + s.Require().Contains(err.Error(), "no transactions") + s.Require().Equal(mcmstypes.TransactionResult{}, res) +} diff --git a/e2e/tests/canton/timelock_proposal.go b/e2e/tests/canton/timelock_proposal.go new file mode 100644 index 00000000..b870e1c4 --- /dev/null +++ b/e2e/tests/canton/timelock_proposal.go @@ -0,0 +1,147 @@ +//go:build e2e + +package canton + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/smartcontractkit/mcms" + "github.com/smartcontractkit/mcms/sdk" + cantonsdk "github.com/smartcontractkit/mcms/sdk/canton" + "github.com/smartcontractkit/mcms/types" +) + +// TimelockProposalTestSuite defines the test suite for Canton timelock proposal flow: +// build proposal -> Convert -> sign -> SetRoot -> Execute (schedule) -> TimelockExecutable.Execute. +// Embeds mcmsExecutorSetup (not MCMSExecutorTestSuite) so only TestTimelockProposal runs when the suite runs. +type TimelockProposalTestSuite struct { + mcmsExecutorSetup +} + +// TestTimelockProposal runs the full timelock flow: build a Schedule proposal (increment counter), +// convert to MCMS proposal, sign, set root, execute (schedule batch), then execute via timelock. +// Fails at Convert() until Canton TimelockConverter is implemented (Phase C). +func (s *TimelockProposalTestSuite) TestTimelockProposal() { + ctx := s.T().Context() + + inspector := cantonsdk.NewInspector(s.participant.StateServiceClient, s.participant.Party, cantonsdk.TimelockRoleProposer) + currentOpCount, err := inspector.GetOpCount(ctx, s.mcmsInstanceAddress) + s.Require().NoError(err, "get current op count") + + // Canton chain metadata: multisigId = makeMcmsId(instanceId, Proposer); baseInstanceId for converter TargetInstanceId + metadata, err := cantonsdk.NewChainMetadata( + currentOpCount, + currentOpCount+1, + s.chainId, + s.proposerMcmsId, + s.mcmsInstanceAddress, + false, + s.mcmsId, + ) + s.Require().NoError(err) + + validUntil := uint32(time.Now().Add(24 * time.Hour).Unix()) + delay := types.NewDuration(2 * time.Second) + + // Batch operation: increment counter (same shape as in TestSetRootAndExecuteCounterOp) + opAdditionalFields := cantonsdk.AdditionalFields{ + TargetInstanceId: fmt.Sprintf("%s@%s", s.counterInstanceID, s.participant.Party), + FunctionName: "Increment", + OperationData: "", + TargetCid: s.counterCID, + ContractIds: []string{s.counterCID}, + } + opAdditionalFieldsBytes, err := json.Marshal(opAdditionalFields) + s.Require().NoError(err) + + bop := types.BatchOperation{ + ChainSelector: s.chainSelector, + Transactions: []types.Transaction{{ + To: s.counterCID, + Data: []byte{}, + AdditionalFields: opAdditionalFieldsBytes, + }}, + } + + // Build timelock proposal (Schedule action); timelock address is InstanceAddress hex + timelockProposal, err := mcms.NewTimelockProposalBuilder(). + SetVersion("v1"). + SetValidUntil(validUntil). + SetDescription("Canton timelock - schedule counter increment"). + AddTimelockAddress(s.chainSelector, s.mcmsInstanceAddress). + AddChainMetadata(s.chainSelector, metadata). + SetAction(types.TimelockActionSchedule). + SetDelay(delay). + AddOperation(bop). + Build() + s.Require().NoError(err) + + // Convert timelock proposal to MCMS proposal (requires Canton TimelockConverter implementation) + converter := cantonsdk.NewTimelockConverter() + convertersMap := map[types.ChainSelector]sdk.TimelockConverter{ + s.chainSelector: converter, + } + proposal, _, err := timelockProposal.Convert(ctx, convertersMap) + s.Require().NoError(err, "Convert: Canton TimelockConverter must be implemented (Phase C)") + + // Sign proposal + inspectorsMap := map[types.ChainSelector]sdk.Inspector{ + s.chainSelector: inspector, + } + signable, err := mcms.NewSignable(&proposal, inspectorsMap) + s.Require().NoError(err) + _, err = signable.SignAndAppend(mcms.NewPrivateKeySigner(s.sortedSigners[0])) + s.Require().NoError(err) + _, err = signable.SignAndAppend(mcms.NewPrivateKeySigner(s.sortedSigners[1])) + s.Require().NoError(err) + quorumMet, err := signable.ValidateSignatures(ctx) + s.Require().NoError(err) + s.Require().True(quorumMet, "quorum not met") + + // Set root + encoders, err := proposal.GetEncoders() + s.Require().NoError(err) + encoder := encoders[s.chainSelector].(*cantonsdk.Encoder) + executor, err := cantonsdk.NewExecutor(encoder, inspector, s.participant.CommandServiceClient, s.participant.UserName, s.participant.Party, cantonsdk.TimelockRoleProposer) + s.Require().NoError(err) + executors := map[types.ChainSelector]sdk.Executor{ + s.chainSelector: executor, + } + executable, err := mcms.NewExecutable(&proposal, executors) + s.Require().NoError(err) + txSetRoot, err := executable.SetRoot(ctx, s.chainSelector) + s.Require().NoError(err) + s.Require().NotEmpty(txSetRoot.Hash) + // No proposal mutation: proposal keeps InstanceAddress hex; executor resolves at submit time + + // Execute proposal operations (schedules the batch on-chain) + for i := range proposal.Operations { + _, execErr := executable.Execute(ctx, i) + s.Require().NoError(execErr, "execute scheduled operation %d", i) + } + + // Timelock execution: wait for ready then execute batch + timelockExecutor := cantonsdk.NewTimelockExecutor(s.participant.CommandServiceClient, s.participant.StateServiceClient, s.participant.Party) + timelockExecutors := map[types.ChainSelector]sdk.TimelockExecutor{ + s.chainSelector: timelockExecutor, + } + timelockExecutable, err := mcms.NewTimelockExecutable(ctx, timelockProposal, timelockExecutors) + s.Require().NoError(err) + + // Wait until operation is ready (delay has passed) + time.Sleep(timelockProposal.Delay.Duration + time.Second) + s.Require().NoError(timelockExecutable.IsReady(ctx), "timelock operation should become ready") + + // Execute the scheduled batch via timelock + for i := range timelockProposal.Operations { + _, terr := timelockExecutable.Execute(ctx, i) + s.Require().NoError(terr, "timelock execute operation %d", i) + } + + // Verify: op count increased (inspector resolves InstanceAddress when querying) + postOpCount, err := inspector.GetOpCount(ctx, s.mcmsInstanceAddress) + s.Require().NoError(err) + s.Require().Equal(currentOpCount+1, postOpCount, "op count should increment after timelock execute") +} diff --git a/e2e/tests/runner_test.go b/e2e/tests/runner_test.go index c20a613e..a6127389 100644 --- a/e2e/tests/runner_test.go +++ b/e2e/tests/runner_test.go @@ -54,6 +54,6 @@ func TestTONSuite(t *testing.T) { func TestCantonSuite(t *testing.T) { suite.Run(t, new(cantone2e.MCMSConfigurerTestSuite)) suite.Run(t, new(cantone2e.MCMSInspectorTestSuite)) - // TODO: Proposals need to be updated to use Timelock instead of direct execution - // suite.Run(t, new(cantone2e.MCMSExecutorTestSuite)) + suite.Run(t, new(cantone2e.TimelockInspectionTestSuite)) + suite.Run(t, new(cantone2e.TimelockProposalTestSuite)) } diff --git a/e2e/tests/sui/common.go b/e2e/tests/sui/common.go index be1cdc82..8aa36fb2 100644 --- a/e2e/tests/sui/common.go +++ b/e2e/tests/sui/common.go @@ -86,7 +86,7 @@ func (s *TestSuite) DeployMCMSContract() { Signer: s.signer, GasBudget: &gasBudget, WaitForExecution: true, - }, s.client, s.SuiNodeURL) + }, s.client) s.Require().NoError(err, "Failed to publish MCMS package") s.mcmsPackageID = mcmsPackage.Address() s.mcms = mcmsPackage.MCMS() @@ -129,7 +129,7 @@ func (s *TestSuite) DeployMCMSUserContract() { Signer: s.signer, GasBudget: &gasBudget, WaitForExecution: true, - }, s.client, s.mcmsPackageID, signerAddress, s.SuiNodeURL) + }, s.client, s.mcmsPackageID, signerAddress) s.Require().NoError(err, "Failed to publish MCMS user package") s.mcmsUserPackageID = mcmsUserPackage.Address() diff --git a/e2e/tests/sui/mcms_user_upgrade.go b/e2e/tests/sui/mcms_user_upgrade.go index 376fcc69..3c136bbc 100644 --- a/e2e/tests/sui/mcms_user_upgrade.go +++ b/e2e/tests/sui/mcms_user_upgrade.go @@ -116,7 +116,7 @@ func RunMCMSUserUpgradeProposal(s *MCMSUserUpgradeTestSuite) { "original_mcms_user_v2_pkg": s.mcmsUserPackageID, "signer": signerAddr, "mcms_test": "0x0", - }, false, s.SuiNodeURL) + }) s.Require().NoError(err) newAddress := executeUpgradePTB(s.T(), s, compiledPackage, proposerConfig) diff --git a/factory.go b/factory.go index 5a77148e..abd3473b 100644 --- a/factory.go +++ b/factory.go @@ -97,6 +97,9 @@ func newTimelockConverter(csel types.ChainSelector) (sdk.TimelockConverter, erro // to cover gas fees. We use a static default value here for now. return ton.NewTimelockConverter(ton.DefaultSendAmount), nil + case cselectors.FamilyCanton: + return canton.NewTimelockConverter(), nil + default: return nil, fmt.Errorf("unsupported chain family %s", family) } diff --git a/go.mod b/go.mod index b5cbd11e..c358ffdf 100644 --- a/go.mod +++ b/go.mod @@ -10,13 +10,13 @@ replace github.com/fbsobreira/gotron-sdk => github.com/smartcontractkit/chainlin replace ( github.com/digital-asset/dazl-client/v8 => github.com/noders-team/dazl-client/v8 v8.7.1-2 - github.com/smartcontractkit/go-daml => github.com/smartcontractkit/go-daml v0.0.0-20260209201116-eac8a15b0b35 + github.com/smartcontractkit/go-daml => github.com/smartcontractkit/go-daml v0.0.0-20260213190006-100f3795ca26 ) require ( github.com/aptos-labs/aptos-go-sdk v1.11.0 github.com/block-vision/sui-go-sdk v1.1.4 - github.com/digital-asset/dazl-client/v8 v8.8.0 + github.com/digital-asset/dazl-client/v8 v8.9.0 github.com/ethereum/go-ethereum v1.16.8 github.com/gagliardetto/binary v0.8.0 github.com/gagliardetto/solana-go v1.13.0 @@ -26,7 +26,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/karalabe/hid v1.0.1-0.20240306101548-573246063e52 github.com/samber/lo v1.52.0 - github.com/smartcontractkit/chain-selectors v1.0.92 + github.com/smartcontractkit/chain-selectors v1.0.96 github.com/smartcontractkit/chainlink-aptos v0.0.0-20251212131933-e5e85d6fa4d3 github.com/smartcontractkit/chainlink-canton v0.0.0-20260210001114-c07a75050603 github.com/smartcontractkit/chainlink-canton/integration-tests v0.0.0-20260210001114-c07a75050603 @@ -42,7 +42,7 @@ require ( github.com/xssnick/tonutils-go v1.14.1 github.com/zksync-sdk/zksync2-go v1.1.1-0.20250620124214-2c742ee399c6 go.uber.org/zap v1.27.1 - golang.org/x/crypto v0.47.0 + golang.org/x/crypto v0.48.0 golang.org/x/tools v0.41.0 gotest.tools/v3 v3.5.2 ) @@ -222,11 +222,12 @@ require ( github.com/shopspring/decimal v1.4.0 // indirect github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/smartcontractkit/chainlink-common v0.9.6-0.20260122165924-94e0fad14fe8 // indirect + github.com/smartcontractkit/chainlink-common v0.10.0 // indirect github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 // indirect - github.com/smartcontractkit/chainlink-deployments-framework v0.79.0 // indirect - github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20251124151448-0448aefdaab9 // indirect + github.com/smartcontractkit/chainlink-deployments-framework v0.80.0 // indirect + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260210221717-2546aed27ebe // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect + github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260205130626-db2a2aab956b // indirect github.com/smartcontractkit/chainlink-tron/relayer v0.0.11-0.20251014143056-a0c6328c91e9 // indirect github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 // indirect github.com/smartcontractkit/libocr v0.0.0-20251212213002-0a5e2f907dda // indirect @@ -235,7 +236,7 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe // indirect github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect - github.com/testcontainers/testcontainers-go v0.39.0 // indirect + github.com/testcontainers/testcontainers-go v0.40.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -252,36 +253,36 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.36.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.13.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0 // indirect go.opentelemetry.io/otel/log v0.15.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/sdk v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/sdk/log v0.15.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect - go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/ratelimit v0.3.1 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect - golang.org/x/net v0.49.0 // indirect + golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect - google.golang.org/grpc v1.78.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 80ba2f48..0d5e8eca 100644 --- a/go.sum +++ b/go.sum @@ -688,8 +688,7 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/smartcontractkit/chain-selectors v1.0.92 h1:cEapBC3DBDKNAZddp01Xj1qAArBNUJdR/4JYhp1NKxY= -github.com/smartcontractkit/chain-selectors v1.0.92/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= +github.com/smartcontractkit/chain-selectors v1.0.96 h1:K3hK9kdPWiRFPXH5r5d1sSVBRPjlP6dwaTHXasa8UMY= github.com/smartcontractkit/chainlink-aptos v0.0.0-20251212131933-e5e85d6fa4d3 h1:bbVSKb++R+rpLkydNvyS4nZPNkcjtolUuFC8YVwtMVk= github.com/smartcontractkit/chainlink-aptos v0.0.0-20251212131933-e5e85d6fa4d3/go.mod h1:OywVThRaVXwknATT2B8QAwjOJ1LoYBB9bTsmRpf6RPw= github.com/smartcontractkit/chainlink-canton v0.0.0-20260210001114-c07a75050603 h1:nOoFhjYBPJRY0vk8vElgJJbmVHLIIWODOLovY0zw1Yc= @@ -700,16 +699,14 @@ github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260129103204-4 github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260129103204-4c8453dd8139/go.mod h1:wuhagkM/lU0GbV2YcrROOH0GlsfXJYwm6qmpa4CK70w= github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260129103204-4c8453dd8139 h1:tw3K4UkH5XfW5SoyYkvAlbzrccoGSLdz/XkxD6nyGC8= github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260129103204-4c8453dd8139/go.mod h1:1WcontO9PeuKdUf5HXfs3nuICtzUvFNnyCmrHkTCF9Y= -github.com/smartcontractkit/chainlink-common v0.9.6-0.20260122165924-94e0fad14fe8 h1:kDHw2ta45azZGdfLldVloLAbo+JS3zIXXRlAIO8f1js= -github.com/smartcontractkit/chainlink-common v0.9.6-0.20260122165924-94e0fad14fe8/go.mod h1:Eg5rz/fQINjR9H0TxHw7j+zGZeYxprUpEQZzC5JGHG4= +github.com/smartcontractkit/chainlink-common v0.10.0 h1:d90b9UPJecrIryzhl43F1oQwkJQoug3TaANlJ1xLHyI= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 h1:FJAFgXS9oqASnkS03RE1HQwYQQxrO4l46O5JSzxqLgg= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10/go.mod h1:oiDa54M0FwxevWwyAX773lwdWvFYYlYHHQV1LQ5HpWY= -github.com/smartcontractkit/chainlink-deployments-framework v0.79.0 h1:+2chX5WzpkSNsazvPUMTJ2CCFNNt+yEWHiJAfPizaAA= -github.com/smartcontractkit/chainlink-deployments-framework v0.79.0/go.mod h1:mWB9sP9T6wWOTkrF19v7JYeZ5WRE7whhRd5dHDnxdUM= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20251124151448-0448aefdaab9 h1:QRWXJusIj/IRY5Pl3JclNvDre0cZPd/5NbILwc4RV2M= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20251124151448-0448aefdaab9/go.mod h1:jUC52kZzEnWF9tddHh85zolKybmLpbQ1oNA4FjOHt1Q= +github.com/smartcontractkit/chainlink-deployments-framework v0.80.0 h1:rtaVb2IKiWOJ+mCksKTQXDUqVnn+OQB20ZkZLkiDbvY= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260210221717-2546aed27ebe h1:Vc4zoSc/j6/FdCQ7vcyHTTB7kzHI2f+lHCHqFuiCcJQ= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= 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-sui v0.0.0-20260205175622-33e65031f9a9 h1:KyPROV+v7P8VdiU7JhVuGLcDlEBsURSpQmSCgNBTY+s= github.com/smartcontractkit/chainlink-sui v0.0.0-20260205175622-33e65031f9a9/go.mod h1:KpEWZJMLwbdMHeHQz9rbkES0vRrx4nk6OQXyhlHb9/8= github.com/smartcontractkit/chainlink-testing-framework/framework v0.13.9 h1:V4Uk2UJqySd+7jwcRuY2l0Xq6ni4zr52C9I8TawX3nM= @@ -722,8 +719,7 @@ github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.202510141 github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.20251014120029-d73d15cc23f7/go.mod h1:ea1LESxlSSOgc2zZBqf1RTkXTMthHaspdqUHd7W4lF0= github.com/smartcontractkit/freeport v0.1.3-0.20250716200817-cb5dfd0e369e h1:Hv9Mww35LrufCdM9wtS9yVi/rEWGI1UnjHbcKKU0nVY= github.com/smartcontractkit/freeport v0.1.3-0.20250716200817-cb5dfd0e369e/go.mod h1:T4zH9R8R8lVWKfU7tUvYz2o2jMv1OpGCdpY2j2QZXzU= -github.com/smartcontractkit/go-daml v0.0.0-20260209201116-eac8a15b0b35 h1:KGL2CpXWsgro/jZrXcBxakyLNfi4Kl1swWSdYnc2PJI= -github.com/smartcontractkit/go-daml v0.0.0-20260209201116-eac8a15b0b35/go.mod h1:K4CKO/jicmLX8mWc5AawF7XT+veiQQjNwA+IdqtAex8= +github.com/smartcontractkit/go-daml v0.0.0-20260213190006-100f3795ca26 h1:aXobuDcI2vEBQex4oCw0z49nxKIQXG10E1A1FcBSw7Q= github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 h1:12ijqMM9tvYVEm+nR826WsrNi6zCKpwBhuApq127wHs= github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7/go.mod h1:FX7/bVdoep147QQhsOPkYsPEXhGZjeYx6lBSaSXtZOA= github.com/smartcontractkit/libocr v0.0.0-20251212213002-0a5e2f907dda h1:OjM+79FRuVZlj0Qd4y+q8Xmz/tEn5y8npqmiQiMMj+w= @@ -765,8 +761,7 @@ github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d h1:vfofYNRScrDd github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48= github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE= github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU= -github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ2IjjDUHFSZ1H1UxAts= -github.com/testcontainers/testcontainers-go v0.39.0/go.mod h1:qmHpkG7H5uPf/EvOORKvS6EuDkBUPE3zpVGaH9NL7f8= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= @@ -822,8 +817,7 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/otel v1.6.3/go.mod h1:7BgNga5fNlF/iZjG06hM3yofffp0ofKCDwSXx1GC4dI= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 h1:06ZeJRe5BnYXceSM9Vya83XXVaNGe3H1QqsvqRANQq8= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2/go.mod h1:DvPtKE63knkDVP88qpatBj81JxN+w1bqfVbsbCbj1WY= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 h1:tPLwQlXbJ8NSOfZc4OkgU5h2A38M4c9kfHSVc4PFQGs= @@ -832,8 +826,7 @@ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.36.0 h1:gAU726w9J8fwr4qRDqu1GYMNNs4gXrU+Pv20/N1UpB4= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.36.0/go.mod h1:RboSDkp7N292rgu+T0MgVt2qgFGu6qa1RpZDOtpL76w= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM= @@ -846,21 +839,16 @@ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0 h1:G8Xec/SgZQricwW go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0/go.mod h1:PD57idA/AiFD5aqoxGxCvT/ILJPeHy3MjqU/NS7KogY= go.opentelemetry.io/otel/log v0.15.0 h1:0VqVnc3MgyYd7QqNVIldC3dsLFKgazR6P3P3+ypkyDY= go.opentelemetry.io/otel/log v0.15.0/go.mod h1:9c/G1zbyZfgu1HmQD7Qj84QMmwTp2QCQsZH1aeoWDE4= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= go.opentelemetry.io/otel/sdk/log v0.15.0 h1:WgMEHOUt5gjJE93yqfqJOkRflApNif84kxoHWS9VVHE= go.opentelemetry.io/otel/sdk/log v0.15.0/go.mod h1:qDC/FlKQCXfH5hokGsNg9aUBGMJQsrUyeOiW5u+dKBQ= go.opentelemetry.io/otel/sdk/log/logtest v0.13.0 h1:9yio6AFZ3QD9j9oqshV1Ibm9gPLlHNxurno5BreMtIA= go.opentelemetry.io/otel/sdk/log/logtest v0.13.0/go.mod h1:QOGiAJHl+fob8Nu85ifXfuQYmJTFAvcrxL6w5/tu168= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= go.opentelemetry.io/otel/trace v1.6.3/go.mod h1:GNJQusJlUgZl9/TQBPKU/Y/ty+0iVB5fjhKeJGZPGFs= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= @@ -907,8 +895,7 @@ golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98y golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= @@ -957,8 +944,7 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1023,8 +1009,7 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= @@ -1036,8 +1021,7 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -1049,8 +1033,7 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= @@ -1098,16 +1081,14 @@ google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEY google.golang.org/genproto v0.0.0-20210401141331-865547bb08e2/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 h1:X9z6obt+cWRX8XjDVOn+SZWhWe5kZHm46TThU9j+jss= google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/sdk/canton/chain_metadata.go b/sdk/canton/chain_metadata.go index 62017488..17b964b4 100644 --- a/sdk/canton/chain_metadata.go +++ b/sdk/canton/chain_metadata.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "github.com/smartcontractkit/mcms/types" ) @@ -33,10 +34,13 @@ const ( TimelockRoleProposer ) -// AdditionalFieldsMetadata represents the Canton-specific metadata fields +// AdditionalFieldsMetadata represents the Canton-specific metadata fields. +// MultisigId must be makeMcmsId(instanceId, role) e.g. "mcms-001-proposer" (DAML SetRoot/ExecuteOp). +// InstanceId is the base MCMS instanceId for self-dispatch TargetInstanceId (DAML E_NOT_SELF_DISPATCH). type AdditionalFieldsMetadata struct { ChainId int64 `json:"chainId"` MultisigId string `json:"multisigId"` + InstanceId string `json:"instanceId,omitempty"` // base instanceId; converter uses for TargetInstanceId in ScheduleBatch etc. PreOpCount uint64 `json:"preOpCount"` PostOpCount uint64 `json:"postOpCount"` OverridePreviousRoot bool `json:"overridePreviousRoot"` @@ -70,22 +74,31 @@ func ValidateChainMetadata(metadata types.ChainMetadata) error { return nil } -// NewChainMetadata creates new Canton chain metadata +// NewChainMetadata creates new Canton chain metadata. +// multisigId must be makeMcmsId(instanceId, role) e.g. "mcms-001-proposer" (DAML expects this in SetRoot/Op). +// baseInstanceId is the MCMS contract instanceId; if non-empty, converter uses it for TargetInstanceId in self-dispatch ops. +// mcmsInstanceAddress is the MCMS InstanceAddress hex (32-byte Keccak256 of "instanceId@party"); may be prefixed with "0x". func NewChainMetadata( preOpCount uint64, postOpCount uint64, chainId int64, multisigId string, - mcmsContractID string, + mcmsInstanceAddress string, overridePreviousRoot bool, + baseInstanceId string, ) (types.ChainMetadata, error) { - if mcmsContractID == "" { - return types.ChainMetadata{}, errors.New("MCMS contract ID is required") + if mcmsInstanceAddress == "" { + return types.ChainMetadata{}, errors.New("MCMS InstanceAddress is required") + } + hexStr := strings.TrimPrefix(mcmsInstanceAddress, "0x") + if len(hexStr) != 64 { + return types.ChainMetadata{}, fmt.Errorf("MCMS InstanceAddress hex must be 64 characters (with or without 0x prefix), got %d", len(hexStr)) } additionalFields := AdditionalFieldsMetadata{ ChainId: chainId, MultisigId: multisigId, + InstanceId: baseInstanceId, PreOpCount: preOpCount, PostOpCount: postOpCount, OverridePreviousRoot: overridePreviousRoot, @@ -101,8 +114,8 @@ func NewChainMetadata( } return types.ChainMetadata{ - StartingOpCount: preOpCount, + StartingOpCount: preOpCount, AdditionalFields: additionalFieldsBytes, - MCMAddress: mcmsContractID, + MCMAddress: mcmsInstanceAddress, }, nil } diff --git a/sdk/canton/configurer.go b/sdk/canton/configurer.go index a723874a..58caf996 100644 --- a/sdk/canton/configurer.go +++ b/sdk/canton/configurer.go @@ -19,22 +19,30 @@ import ( var _ sdk.Configurer = &Configurer{} type Configurer struct { - client apiv2.CommandServiceClient - userId string - party string - role TimelockRole + client apiv2.CommandServiceClient + stateClient apiv2.StateServiceClient + userId string + party string + role TimelockRole } -func NewConfigurer(client apiv2.CommandServiceClient, userId string, party string, role TimelockRole) (*Configurer, error) { +func NewConfigurer(client apiv2.CommandServiceClient, stateClient apiv2.StateServiceClient, userId string, party string, role TimelockRole) (*Configurer, error) { return &Configurer{ - client: client, - userId: userId, - party: party, - role: role, + client: client, + stateClient: stateClient, + userId: userId, + party: party, + role: role, }, nil } func (c Configurer) SetConfig(ctx context.Context, mcmsAddr string, cfg *types.Config, clearRoot bool) (types.TransactionResult, error) { + // mcmsAddr is InstanceAddress hex for Canton; resolve to current contract ID + mcmsContractID, err := ResolveMCMSContractID(ctx, c.stateClient, c.party, mcmsAddr) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("failed to resolve MCMS contract ID: %w", err) + } + groupQuorum, groupParents, signerAddresses, signerGroups, err := sdk.ExtractSetConfigInputs(cfg) if err != nil { return types.TransactionResult{}, fmt.Errorf("unable to extract set config inputs: %w", err) @@ -70,7 +78,7 @@ func (c Configurer) SetConfig(ctx context.Context, mcmsAddr string, cfg *types.C } // Build exercise command using generated bindings mcmsContract := mcms.MCMS{} - exerciseCmd := mcmsContract.SetConfig(mcmsAddr, input) + exerciseCmd := mcmsContract.SetConfig(mcmsContractID, input) // Parse template ID packageID, moduleName, entityName, err := parseTemplateIDFromString(mcmsContract.GetTemplateID()) @@ -124,7 +132,7 @@ func (c Configurer) SetConfig(ctx context.Context, mcmsAddr string, cfg *types.C } if newMCMSContractID == "" { - return types.TransactionResult{}, fmt.Errorf("set-config tx had no Created MCMS event; refusing to continue with old CID=%s", mcmsAddr) + return types.TransactionResult{}, fmt.Errorf("set-config tx had no Created MCMS event; refusing to continue with old CID=%s", mcmsContractID) } return types.TransactionResult{ diff --git a/sdk/canton/executor.go b/sdk/canton/executor.go index 68ee8cc4..e50d38fd 100644 --- a/sdk/canton/executor.go +++ b/sdk/canton/executor.go @@ -62,6 +62,12 @@ func (e Executor) ExecuteOperation( } } + // Resolve MCMAddress (InstanceAddress hex) to current contract ID before submitting + mcmsContractID, err := ResolveMCMSContractID(ctx, e.Inspector.StateServiceClient(), e.party, metadata.MCMAddress) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("failed to resolve MCMS contract ID: %w", err) + } + // Validate required Canton fields if cantonOpFields.TargetInstanceId == "" { return types.TransactionResult{}, errors.New("targetInstanceId is required in operation additional fields") @@ -100,10 +106,15 @@ func (e Executor) ExecuteOperation( opProof[i] = cantontypes.TEXT(hex.EncodeToString(p[:])) } - // Convert contract IDs + // Convert contract IDs; resolve InstanceAddress hex to current contract ID so Canton can parse them + stateClient := e.Inspector.StateServiceClient() targetCids := make([]cantontypes.CONTRACT_ID, len(cantonOpFields.ContractIds)) for i, cid := range cantonOpFields.ContractIds { - targetCids[i] = cantontypes.CONTRACT_ID(cid) + resolved, err := ResolveContractIDIfInstanceAddress(ctx, stateClient, e.party, cid) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("resolve contract ID %q: %w", cid, err) + } + targetCids[i] = cantontypes.CONTRACT_ID(resolved) } // Build exercise command using generated bindings @@ -118,7 +129,7 @@ func (e Executor) ExecuteOperation( OpProof: opProof, TargetCids: targetCids, } - exerciseCmd := mcmsContract.ExecuteOp(metadata.MCMAddress, input) + exerciseCmd := mcmsContract.ExecuteOp(mcmsContractID, input) choice = exerciseCmd.Choice choiceArgument = ledger.MapToValue(input) @@ -142,7 +153,7 @@ func (e Executor) ExecuteOperation( ModuleName: moduleName, EntityName: entityName, }, - ContractId: metadata.MCMAddress, + ContractId: mcmsContractID, Choice: choice, ChoiceArgument: choiceArgument, }, @@ -154,7 +165,7 @@ func (e Executor) ExecuteOperation( return types.TransactionResult{}, fmt.Errorf("failed to execute operation: %w", err) } - // Extract NEW MCMS CID from Created event + // Extract NEW MCMS CID from Created event (for RawData only; proposal keeps InstanceAddress) newMCMSContractID := "" newMCMSTemplateID := "" transaction := submitResp.GetTransaction() @@ -171,7 +182,7 @@ func (e Executor) ExecuteOperation( } if newMCMSContractID == "" { - return types.TransactionResult{}, fmt.Errorf("execute-op tx had no Created MCMS event; refusing to continue with old CID=%s", metadata.MCMAddress) + return types.TransactionResult{}, fmt.Errorf("execute-op tx had no Created MCMS event; refusing to continue with old CID=%s", mcmsContractID) } return types.TransactionResult{ @@ -193,6 +204,12 @@ func (e Executor) SetRoot( validUntil uint32, sortedSignatures []types.Signature, ) (types.TransactionResult, error) { + // Resolve MCMAddress (InstanceAddress hex) to current contract ID before submitting + mcmsContractID, err := ResolveMCMSContractID(ctx, e.Inspector.StateServiceClient(), e.party, metadata.MCMAddress) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("failed to resolve MCMS contract ID: %w", err) + } + rootHex := hex.EncodeToString(root[:]) // Recalculate msg hash to recover signers inner, err := abi.Encode(SignMsgABI, root, validUntil) @@ -255,7 +272,8 @@ func (e Executor) SetRoot( metadataProof[i] = cantontypes.TEXT(hex.EncodeToString(p[:])) } - validUntilTime := time.Unix(time.Unix(int64(validUntil), 0).UnixMicro(), 0) + // validUntil is Unix seconds; Canton/Daml Timestamp expects time in seconds (binding serializes correctly) + validUntilTime := time.Unix(int64(validUntil), 0) input := mcms.SetRoot{ TargetRole: mcms.Role(e.role.String()), Submitter: cantontypes.PARTY(e.party), @@ -268,7 +286,7 @@ func (e Executor) SetRoot( // Build exercise command using generated bindings mcmsContract := mcms.MCMS{} - exerciseCmd := mcmsContract.SetRoot(metadata.MCMAddress, input) + exerciseCmd := mcmsContract.SetRoot(mcmsContractID, input) // Parse template ID packageID, moduleName, entityName, err := parseTemplateIDFromString(mcmsContract.GetTemplateID()) @@ -293,7 +311,7 @@ func (e Executor) SetRoot( ModuleName: moduleName, EntityName: entityName, }, - ContractId: metadata.MCMAddress, + ContractId: mcmsContractID, Choice: exerciseCmd.Choice, ChoiceArgument: choiceArgument, }, @@ -322,7 +340,7 @@ func (e Executor) SetRoot( } if newMCMSContractID == "" { - return types.TransactionResult{}, fmt.Errorf("set-root tx had no Created MCMS event; refusing to continue with old CID=%s", metadata.MCMAddress) + return types.TransactionResult{}, fmt.Errorf("set-root tx had no Created MCMS event; refusing to continue with old CID=%s", mcmsContractID) } return types.TransactionResult{ diff --git a/sdk/canton/inspector.go b/sdk/canton/inspector.go index fa19d169..85519af1 100644 --- a/sdk/canton/inspector.go +++ b/sdk/canton/inspector.go @@ -3,17 +3,15 @@ package canton import ( "context" "encoding/hex" - "errors" "fmt" - "io" "strings" "time" apiv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2" "github.com/ethereum/go-ethereum/common" - "github.com/smartcontractkit/chainlink-canton/bindings" "github.com/smartcontractkit/chainlink-canton/bindings/mcms" + "github.com/smartcontractkit/chainlink-canton/contracts" cantontypes "github.com/smartcontractkit/go-daml/pkg/types" "github.com/smartcontractkit/mcms/sdk" "github.com/smartcontractkit/mcms/types" @@ -22,10 +20,9 @@ import ( var _ sdk.Inspector = &Inspector{} type Inspector struct { - stateClient apiv2.StateServiceClient - party string - contractCache *mcms.MCMS // Cache MCMS to avoid repeated RPC calls - role TimelockRole + stateClient apiv2.StateServiceClient + party string + role TimelockRole } func NewInspector(stateClient apiv2.StateServiceClient, party string, role TimelockRole) *Inspector { @@ -36,65 +33,61 @@ func NewInspector(stateClient apiv2.StateServiceClient, party string, role Timel } } +// StateServiceClient returns the state service client for resolution (e.g. InstanceAddress to contract ID). +func (i *Inspector) StateServiceClient() apiv2.StateServiceClient { + return i.stateClient +} + func (i *Inspector) GetConfig(ctx context.Context, mcmsAddr string) (*types.Config, error) { - if i.contractCache == nil { - mcmsContract, err := i.getMCMSContract(ctx, mcmsAddr) - if err != nil { - return nil, fmt.Errorf("failed to get MCMS contract: %w", err) - } - i.contractCache = mcmsContract + mcmsContract, err := GetMCMSContract(ctx, i.stateClient, i.party, mcmsAddr) + if err != nil { + return nil, fmt.Errorf("failed to get MCMS contract: %w", err) } switch i.role { case TimelockRoleProposer: - return toConfig(i.contractCache.Proposer.Config) + return toConfig(mcmsContract.Proposer.Config) case TimelockRoleBypasser: - return toConfig(i.contractCache.Bypasser.Config) + return toConfig(mcmsContract.Bypasser.Config) case TimelockRoleCanceller: - return toConfig(i.contractCache.Canceller.Config) + return toConfig(mcmsContract.Canceller.Config) default: return nil, fmt.Errorf("unknown timelock role: %s", i.role) } } func (i *Inspector) GetOpCount(ctx context.Context, mcmsAddr string) (uint64, error) { - if i.contractCache == nil { - mcmsContract, err := i.getMCMSContract(ctx, mcmsAddr) - if err != nil { - return 0, fmt.Errorf("failed to get MCMS contract: %w", err) - } - i.contractCache = mcmsContract + mcmsContract, err := GetMCMSContract(ctx, i.stateClient, i.party, mcmsAddr) + if err != nil { + return 0, fmt.Errorf("failed to get MCMS contract: %w", err) } switch i.role { case TimelockRoleProposer: - return uint64(i.contractCache.Proposer.ExpiringRoot.OpCount), nil + return uint64(mcmsContract.Proposer.ExpiringRoot.OpCount), nil case TimelockRoleBypasser: - return uint64(i.contractCache.Bypasser.ExpiringRoot.OpCount), nil + return uint64(mcmsContract.Bypasser.ExpiringRoot.OpCount), nil case TimelockRoleCanceller: - return uint64(i.contractCache.Canceller.ExpiringRoot.OpCount), nil + return uint64(mcmsContract.Canceller.ExpiringRoot.OpCount), nil default: return 0, fmt.Errorf("unknown timelock role: %s", i.role) } } func (i *Inspector) GetRoot(ctx context.Context, mcmsAddr string) (common.Hash, uint32, error) { - if i.contractCache == nil { - mcmsContract, err := i.getMCMSContract(ctx, mcmsAddr) - if err != nil { - return common.Hash{}, 0, fmt.Errorf("failed to get MCMS contract: %w", err) - } - i.contractCache = mcmsContract + mcmsContract, err := GetMCMSContract(ctx, i.stateClient, i.party, mcmsAddr) + if err != nil { + return common.Hash{}, 0, fmt.Errorf("failed to get MCMS contract: %w", err) } var expiringRoot mcms.ExpiringRoot switch i.role { case TimelockRoleProposer: - expiringRoot = i.contractCache.Proposer.ExpiringRoot + expiringRoot = mcmsContract.Proposer.ExpiringRoot case TimelockRoleBypasser: - expiringRoot = i.contractCache.Bypasser.ExpiringRoot + expiringRoot = mcmsContract.Bypasser.ExpiringRoot case TimelockRoleCanceller: - expiringRoot = i.contractCache.Canceller.ExpiringRoot + expiringRoot = mcmsContract.Canceller.ExpiringRoot default: return common.Hash{}, 0, fmt.Errorf("unknown timelock role: %s", i.role) } @@ -118,129 +111,31 @@ func (i *Inspector) GetRoot(ctx context.Context, mcmsAddr string) (common.Hash, } func (i *Inspector) GetRootMetadata(ctx context.Context, mcmsAddr string) (types.ChainMetadata, error) { - if i.contractCache == nil { - mcmsContract, err := i.getMCMSContract(ctx, mcmsAddr) - if err != nil { - return types.ChainMetadata{}, fmt.Errorf("failed to get MCMS contract: %w", err) - } - i.contractCache = mcmsContract + mcmsContract, err := GetMCMSContract(ctx, i.stateClient, i.party, mcmsAddr) + if err != nil { + return types.ChainMetadata{}, fmt.Errorf("failed to get MCMS contract: %w", err) } var rootMetadata mcms.RootMetadata switch i.role { case TimelockRoleProposer: - rootMetadata = i.contractCache.Proposer.RootMetadata + rootMetadata = mcmsContract.Proposer.RootMetadata case TimelockRoleBypasser: - rootMetadata = i.contractCache.Bypasser.RootMetadata + rootMetadata = mcmsContract.Bypasser.RootMetadata case TimelockRoleCanceller: - rootMetadata = i.contractCache.Canceller.RootMetadata + rootMetadata = mcmsContract.Canceller.RootMetadata default: return types.ChainMetadata{}, fmt.Errorf("unknown timelock role: %s", i.role) } + // For Canton, MCMAddress is the InstanceAddress hex (stable across SetRoot/ExecuteOp) + mcmAddress := contracts.InstanceID(string(mcmsContract.InstanceId)).RawInstanceAddress(cantontypes.PARTY(mcmsContract.Owner)).InstanceAddress().Hex() return types.ChainMetadata{ StartingOpCount: uint64(rootMetadata.PreOpCount), - MCMAddress: string(i.contractCache.InstanceId), + MCMAddress: mcmAddress, }, nil } -// getMCMSContract queries the active MCMS contract by contract ID -func (i *Inspector) getMCMSContract(ctx context.Context, mcmsAddr string) (*mcms.MCMS, error) { - // Get current ledger offset - ledgerEndResp, err := i.stateClient.GetLedgerEnd(ctx, &apiv2.GetLedgerEndRequest{}) - if err != nil { - return nil, fmt.Errorf("failed to get ledger end: %w", err) - } - - // Query active contracts at current offset - activeContractsResp, err := i.stateClient.GetActiveContracts(ctx, &apiv2.GetActiveContractsRequest{ - ActiveAtOffset: ledgerEndResp.GetOffset(), - EventFormat: &apiv2.EventFormat{ - FiltersByParty: map[string]*apiv2.Filters{ - i.party: { - Cumulative: []*apiv2.CumulativeFilter{ - { - IdentifierFilter: &apiv2.CumulativeFilter_TemplateFilter{ - TemplateFilter: &apiv2.TemplateFilter{ - TemplateId: &apiv2.Identifier{ - PackageId: "#mcms", - ModuleName: "MCMS.Main", - EntityName: "MCMS", - }, - IncludeCreatedEventBlob: false, - }, - }, - }, - }, - }, - }, - Verbose: true, - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to get active contracts: %w", err) - } - defer activeContractsResp.CloseSend() - - // Stream through active contracts to find the MCMS contract with matching ID - for { - resp, err := activeContractsResp.Recv() - if errors.Is(err, io.EOF) { - // Stream ended without finding the contract - return nil, fmt.Errorf("MCMS contract with ID %s not found", mcmsAddr) - } - if err != nil { - return nil, fmt.Errorf("failed to receive active contracts: %w", err) - } - - activeContract, ok := resp.GetContractEntry().(*apiv2.GetActiveContractsResponse_ActiveContract) - if !ok { - continue - } - - createdEvent := activeContract.ActiveContract.GetCreatedEvent() - if createdEvent == nil { - continue - } - - // Check if contract ID matches - if createdEvent.ContractId != mcmsAddr { - continue - } - - // Use bindings package to unmarshal the contract - // TODO: MinDelay type from binding doesnt correspond to actual type from contract - type NoMinDelayMCMS struct { - Owner cantontypes.PARTY `json:"owner"` - InstanceId cantontypes.TEXT `json:"instanceId"` - ChainId cantontypes.INT64 `json:"chainId"` - Proposer mcms.RoleState `json:"proposer"` - Canceller mcms.RoleState `json:"canceller"` - Bypasser mcms.RoleState `json:"bypasser"` - BlockedFunctions []mcms.BlockedFunction `json:"blockedFunctions"` - TimelockTimestamps cantontypes.GENMAP `json:"timelockTimestamps"` - } - mcmsContractNoMinDelay, err := bindings.UnmarshalActiveContract[NoMinDelayMCMS](activeContract) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal MCMS contract: %w", err) - } - - mcmsContract := &mcms.MCMS{ - Owner: mcmsContractNoMinDelay.Owner, - InstanceId: mcmsContractNoMinDelay.InstanceId, - ChainId: mcmsContractNoMinDelay.ChainId, - Proposer: mcmsContractNoMinDelay.Proposer, - Canceller: mcmsContractNoMinDelay.Canceller, - Bypasser: mcmsContractNoMinDelay.Bypasser, - BlockedFunctions: mcmsContractNoMinDelay.BlockedFunctions, - TimelockTimestamps: mcmsContractNoMinDelay.TimelockTimestamps, - MinDelay: 0, // TODO: Fix bindings type - } - - return mcmsContract, nil - } -} - // toConfig converts a Canton MultisigConfig to the chain-agnostic types.Config func toConfig(bindConfig mcms.MultisigConfig) (*types.Config, error) { // Group signers by group index diff --git a/sdk/canton/resolver.go b/sdk/canton/resolver.go new file mode 100644 index 00000000..83e84dbd --- /dev/null +++ b/sdk/canton/resolver.go @@ -0,0 +1,205 @@ +package canton + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + apiv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2" + "github.com/smartcontractkit/go-daml/pkg/types" + cantontypes "github.com/smartcontractkit/go-daml/pkg/types" + + "github.com/smartcontractkit/chainlink-canton/bindings" + "github.com/smartcontractkit/chainlink-canton/bindings/mcms" + "github.com/smartcontractkit/chainlink-canton/contracts" +) + +// ResolveMCMSContractID resolves an MCMS InstanceAddress (hex string) to the current active contract ID. +// instanceAddressHex is the hex-encoded InstanceAddress (keccak256 of "instanceId@party"); it may be prefixed with "0x". +func ResolveMCMSContractID(ctx context.Context, stateService apiv2.StateServiceClient, party, instanceAddressHex string) (string, error) { + instanceAddressHex = strings.TrimPrefix(instanceAddressHex, "0x") + if instanceAddressHex == "" { + return "", fmt.Errorf("instance address hex is required") + } + addr := contracts.HexToInstanceAddress(instanceAddressHex) + templateID := mcms.MCMS{}.GetTemplateID() + return findActiveContractIDByInstanceAddress(ctx, stateService, party, templateID, addr) +} + +// findActiveContractIDByInstanceAddress returns the active contract ID for the given instance address. +func findActiveContractIDByInstanceAddress(ctx context.Context, stateService apiv2.StateServiceClient, party, templateID string, instanceAddress contracts.InstanceAddress) (string, error) { + activeContract, err := findActiveContractByInstanceAddress(ctx, stateService, party, templateID, instanceAddress) + if err != nil { + return "", err + } + return activeContract.GetCreatedEvent().GetContractId(), nil +} + +// findActiveContractByInstanceAddress finds an active contract by its instance address. +// It returns an error if there are multiple or zero active contracts matching the instance address. +// TODO: copied from chainlink-canton deployment/utils/operations/contract/exercise.go to avoid importing +// unwanted dependencies. We should move the helper function to the bindings package and use it here. +func findActiveContractByInstanceAddress(ctx context.Context, stateService apiv2.StateServiceClient, party, templateID string, instanceAddress contracts.InstanceAddress) (*apiv2.ActiveContract, error) { + ledgerEndResp, err := stateService.GetLedgerEnd(ctx, &apiv2.GetLedgerEndRequest{}) + if err != nil { + return nil, fmt.Errorf("failed to get ledger end: %w", err) + } + + packageID, moduleName, entityName, err := parseTemplateIDFromString(templateID) + if err != nil { + return nil, fmt.Errorf("failed to parse template ID: %w", err) + } + + activeContractsResp, err := stateService.GetActiveContracts(ctx, &apiv2.GetActiveContractsRequest{ + ActiveAtOffset: ledgerEndResp.GetOffset(), + EventFormat: &apiv2.EventFormat{ + FiltersByParty: map[string]*apiv2.Filters{ + party: { + Cumulative: []*apiv2.CumulativeFilter{ + { + IdentifierFilter: &apiv2.CumulativeFilter_TemplateFilter{ + TemplateFilter: &apiv2.TemplateFilter{ + TemplateId: &apiv2.Identifier{ + PackageId: packageID, + ModuleName: moduleName, + EntityName: entityName, + }, + IncludeCreatedEventBlob: true, + }, + }, + }, + }, + }, + }, + Verbose: true, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to get active contracts: %w", err) + } + defer activeContractsResp.CloseSend() + + var activeContract *apiv2.ActiveContract + for { + activeContractResp, err := activeContractsResp.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("failed to receive active contracts: %w", err) + } + + if c, ok := activeContractResp.GetContractEntry().(*apiv2.GetActiveContractsResponse_ActiveContract); ok { + createArguments := c.ActiveContract.GetCreatedEvent().GetCreateArguments() + if createArguments == nil { + continue + } + + var contractInstanceID string + for _, field := range createArguments.GetFields() { + if field.GetLabel() == "instanceId" { + contractInstanceID = field.GetValue().GetText() + break + } + } + if contractInstanceID == "" { + continue + } + + instanceID := contracts.InstanceID(contractInstanceID) + signatories := c.ActiveContract.GetCreatedEvent().GetSignatories() + if len(signatories) != 1 { + continue + } + gotAddress := instanceID.RawInstanceAddress(types.PARTY(signatories[0])).InstanceAddress() + + if instanceAddress != gotAddress { + continue + } + + if activeContract != nil { + return nil, fmt.Errorf("multiple active contracts found for InstanceAddress %s", instanceAddress.String()) + } + activeContract = c.ActiveContract + } + } + + if activeContract == nil { + return nil, fmt.Errorf("no active contract found for InstanceAddress %s", instanceAddress.String()) + } + + return activeContract, nil +} + +// GetMCMSContract queries the active MCMS contract by InstanceAddress (hex). +// mcmsAddr is the InstanceAddress hex string (may be prefixed with "0x"). +func GetMCMSContract(ctx context.Context, stateService apiv2.StateServiceClient, party, mcmsAddr string) (*mcms.MCMS, error) { + mcmsAddr = strings.TrimPrefix(mcmsAddr, "0x") + if mcmsAddr == "" { + return nil, fmt.Errorf("MCMS instance address is required") + } + addr := contracts.HexToInstanceAddress(mcmsAddr) + templateID := mcms.MCMS{}.GetTemplateID() + activeContract, err := findActiveContractByInstanceAddress(ctx, stateService, party, templateID, addr) + if err != nil { + return nil, fmt.Errorf("MCMS contract for InstanceAddress %s: %w", mcmsAddr, err) + } + + // Wrap for bindings unmarshal + wrapped := &apiv2.GetActiveContractsResponse_ActiveContract{ActiveContract: activeContract} + + // TODO: MinDelay type from binding doesnt correspond to actual type from contract + type NoMinDelayMCMS struct { + Owner cantontypes.PARTY `json:"owner"` + InstanceId cantontypes.TEXT `json:"instanceId"` + ChainId cantontypes.INT64 `json:"chainId"` + Proposer mcms.RoleState `json:"proposer"` + Canceller mcms.RoleState `json:"canceller"` + Bypasser mcms.RoleState `json:"bypasser"` + BlockedFunctions []mcms.BlockedFunction `json:"blockedFunctions"` + TimelockTimestamps cantontypes.GENMAP `json:"timelockTimestamps"` + } + mcmsContractNoMinDelay, err := bindings.UnmarshalActiveContract[NoMinDelayMCMS](wrapped) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal MCMS contract: %w", err) + } + + return &mcms.MCMS{ + Owner: mcmsContractNoMinDelay.Owner, + InstanceId: mcmsContractNoMinDelay.InstanceId, + ChainId: mcmsContractNoMinDelay.ChainId, + Proposer: mcmsContractNoMinDelay.Proposer, + Canceller: mcmsContractNoMinDelay.Canceller, + Bypasser: mcmsContractNoMinDelay.Bypasser, + BlockedFunctions: mcmsContractNoMinDelay.BlockedFunctions, + TimelockTimestamps: mcmsContractNoMinDelay.TimelockTimestamps, + MinDelay: 0, // TODO: Fix bindings type + }, nil +} + +// IsInstanceAddressHex returns true if s looks like an InstanceAddress hex string (64 hex chars, optional 0x prefix). +// Canton contract IDs use a different format; when we have 0x-prefixed 64-char hex we treat it as InstanceAddress. +func IsInstanceAddressHex(s string) bool { + s = strings.TrimPrefix(s, "0x") + if len(s) != 64 { + return false + } + for _, c := range s { + if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') { + continue + } + return false + } + return true +} + +// ResolveContractIDIfInstanceAddress returns the current MCMS contract ID if cid is InstanceAddress hex; +// otherwise returns cid unchanged. Use when building TargetCids so Canton receives real contract IDs. +func ResolveContractIDIfInstanceAddress(ctx context.Context, stateService apiv2.StateServiceClient, party, cid string) (string, error) { + if !IsInstanceAddressHex(cid) { + return cid, nil + } + return ResolveMCMSContractID(ctx, stateService, party, cid) +} diff --git a/sdk/canton/timelock_converter.go b/sdk/canton/timelock_converter.go new file mode 100644 index 00000000..efd7b836 --- /dev/null +++ b/sdk/canton/timelock_converter.go @@ -0,0 +1,184 @@ +package canton + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-canton/bindings/mcms" + cantontypes "github.com/smartcontractkit/go-daml/pkg/types" + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +var _ sdk.TimelockConverter = (*TimelockConverter)(nil) + +// TimelockConverter converts Canton timelock batch operations to chain operations. +type TimelockConverter struct{} + +// NewTimelockConverter creates a new TimelockConverter. +func NewTimelockConverter() *TimelockConverter { + return &TimelockConverter{} +} + +// ConvertBatchToChainOperations converts a BatchOperation to Canton-specific timelock operations. +func (t *TimelockConverter) ConvertBatchToChainOperations( + _ context.Context, + metadata types.ChainMetadata, + bop types.BatchOperation, + _ string, // timelockAddress - not used for Canton (MCMS has built-in timelock) + mcmAddress string, + delay types.Duration, + action types.TimelockAction, + predecessor common.Hash, + salt common.Hash, +) ([]types.Operation, common.Hash, error) { + var metadataFields AdditionalFieldsMetadata + if err := json.Unmarshal(metadata.AdditionalFields, &metadataFields); err != nil { + return nil, common.Hash{}, fmt.Errorf("unmarshal metadata additional fields: %w", err) + } + + calls, callsForHash, allContractIds, err := buildCallsFromBatch(bop) + if err != nil { + return nil, common.Hash{}, err + } + + predecessorHex := hex.EncodeToString(predecessor[:]) + saltHex := hex.EncodeToString(salt[:]) + + operationIDStr := HashTimelockOpId(callsForHash, predecessorHex, saltHex) + operationIDBytes, err := hex.DecodeString(operationIDStr) + if err != nil || len(operationIDBytes) != 32 { + return nil, common.Hash{}, fmt.Errorf("invalid operation ID hash: %w", err) + } + var operationID common.Hash + copy(operationID[:], operationIDBytes) + + var functionName string + var opDataHex string + switch action { + case types.TimelockActionSchedule: + functionName, opDataHex, err = scheduleActionData(calls, predecessorHex, saltHex, delay) + case types.TimelockActionBypass: + functionName, opDataHex, err = bypassActionData(calls) + case types.TimelockActionCancel: + functionName, opDataHex, err = cancelActionData(operationIDStr) + default: + return nil, common.Hash{}, fmt.Errorf("unsupported timelock action: %v", action) + } + if err != nil { + return nil, common.Hash{}, err + } + + targetInstanceId := metadataFields.InstanceId + if targetInstanceId == "" { + targetInstanceId = metadataFields.MultisigId + } + op, err := buildTimelockOperation(bop, mcmAddress, targetInstanceId, functionName, opDataHex, allContractIds) + if err != nil { + return nil, common.Hash{}, err + } + return []types.Operation{op}, operationID, nil +} + +// buildCallsFromBatch extracts mcms.TimelockCall, TimelockCallForHash, and all ContractIds from bop. +// Uses tx.To and tx.Data as fallbacks when AdditionalFields are missing or empty. +func buildCallsFromBatch(bop types.BatchOperation) ([]mcms.TimelockCall, []TimelockCallForHash, []string, error) { + calls := make([]mcms.TimelockCall, 0, len(bop.Transactions)) + callsForHash := make([]TimelockCallForHash, 0, len(bop.Transactions)) + var allContractIds []string + for _, tx := range bop.Transactions { + var af AdditionalFields + if len(tx.AdditionalFields) > 0 { + if err := json.Unmarshal(tx.AdditionalFields, &af); err != nil { + return nil, nil, nil, fmt.Errorf("unmarshal transaction additional fields: %w", err) + } + } + targetInstanceId := af.TargetInstanceId + if targetInstanceId == "" { + targetInstanceId = tx.To + } + operationData := af.OperationData + if operationData == "" && len(tx.Data) > 0 { + operationData = hex.EncodeToString(tx.Data) + } + calls = append(calls, mcms.TimelockCall{ + TargetInstanceId: cantontypes.TEXT(targetInstanceId), + FunctionName: cantontypes.TEXT(af.FunctionName), + OperationData: cantontypes.TEXT(operationData), + }) + callsForHash = append(callsForHash, TimelockCallForHash{ + TargetInstanceId: targetInstanceId, + FunctionName: af.FunctionName, + OperationData: operationData, + }) + allContractIds = append(allContractIds, af.ContractIds...) + } + return calls, callsForHash, allContractIds, nil +} + +// scheduleActionData returns function name and hex-encoded params for ScheduleBatch. +func scheduleActionData(calls []mcms.TimelockCall, predecessorHex, saltHex string, delay types.Duration) (functionName, opDataHex string, err error) { + delaySecs := int64(delay.Duration.Seconds()) + if delaySecs < 0 { + delaySecs = 0 + } + params := mcms.ScheduleBatchParams{ + Calls: calls, + Predecessor: cantontypes.TEXT(predecessorHex), + Salt: cantontypes.TEXT(saltHex), + DelaySecs: cantontypes.INT64(delaySecs), + } + opDataHex, err = params.MarshalHex() + if err != nil { + return "", "", fmt.Errorf("marshal ScheduleBatchParams: %w", err) + } + return "ScheduleBatch", opDataHex, nil +} + +// bypassActionData returns function name and hex-encoded params for BypasserExecuteBatch. +func bypassActionData(calls []mcms.TimelockCall) (functionName, opDataHex string, err error) { + params := mcms.BypasserExecuteBatchParams{Calls: calls} + opDataHex, err = params.MarshalHex() + if err != nil { + return "", "", fmt.Errorf("marshal BypasserExecuteBatchParams: %w", err) + } + return "BypasserExecuteBatch", opDataHex, nil +} + +// cancelActionData returns function name and hex-encoded params for CancelBatch. +func cancelActionData(operationIDStr string) (functionName, opDataHex string, err error) { + params := mcms.CancelBatchParams{OpId: cantontypes.TEXT(operationIDStr)} + opDataHex, err = params.MarshalHex() + if err != nil { + return "", "", fmt.Errorf("marshal CancelBatchParams: %w", err) + } + return "CancelBatch", opDataHex, nil +} + +// buildTimelockOperation builds a single types.Operation for the given timelock action. +// allContractIds are the target contract IDs from the batch (for ExecuteOp TargetCids); mcmAddress is included as TargetCid. +func buildTimelockOperation(bop types.BatchOperation, mcmAddress, targetInstanceId, functionName, opDataHex string, allContractIds []string) (types.Operation, error) { + opAdditionalFields := AdditionalFields{ + TargetInstanceId: targetInstanceId, + FunctionName: functionName, + OperationData: opDataHex, + TargetCid: mcmAddress, + ContractIds: allContractIds, + } + opAdditionalFieldsBytes, err := json.Marshal(opAdditionalFields) + if err != nil { + return types.Operation{}, fmt.Errorf("marshal operation additional fields: %w", err) + } + return types.Operation{ + ChainSelector: bop.ChainSelector, + Transaction: types.Transaction{ + To: mcmAddress, + Data: []byte{0x00}, // placeholder for validators; Canton uses AdditionalFields.OperationData + AdditionalFields: opAdditionalFieldsBytes, + }, + }, nil +} diff --git a/sdk/canton/timelock_crypto.go b/sdk/canton/timelock_crypto.go new file mode 100644 index 00000000..b7d3baeb --- /dev/null +++ b/sdk/canton/timelock_crypto.go @@ -0,0 +1,63 @@ +package canton + +import ( + "encoding/hex" + "strings" + + "github.com/ethereum/go-ethereum/crypto" +) + +// TimelockCallForHash is used for computing the operation ID hash. +// Field semantics match mcms.TimelockCall (TargetInstanceId, FunctionName, OperationData). +type TimelockCallForHash struct { + TargetInstanceId string + FunctionName string + OperationData string +} + +// HashTimelockOpId computes the operation ID for timelock operations. +// Matches Canton's hashTimelockOpId: keccak256(encodedCalls || predecessor || salt). +// predecessor and salt should be hex-encoded (e.g. 64-char hex for 32-byte hashes). +func HashTimelockOpId(calls []TimelockCallForHash, predecessor, salt string) string { + var sb strings.Builder + for _, call := range calls { + sb.WriteString(asciiToHex(call.TargetInstanceId)) + sb.WriteString(asciiToHex(call.FunctionName)) + sb.WriteString(encodeOperationDataForHash(call.OperationData)) + } + sb.WriteString(asciiToHex(predecessor)) + sb.WriteString(asciiToHex(salt)) + + data, err := hex.DecodeString(sb.String()) + if err != nil { + panic("HashTimelockOpId: invalid hex encoding: " + err.Error()) + } + + return hex.EncodeToString(crypto.Keccak256(data)) +} + +// encodeOperationDataForHash matches on-chain MCMS.Crypto.encodeOperationData: +// - If operationData is valid hex (even length, hex digits only), use as-is. +// - Otherwise, treat as ASCII and hex-encode it. +func encodeOperationDataForHash(operationData string) string { + if isValidHex(operationData) { + return operationData + } + return asciiToHex(operationData) +} + +func isValidHex(s string) bool { + if len(s)%2 != 0 { + return false + } + 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/canton/timelock_executor.go b/sdk/canton/timelock_executor.go new file mode 100644 index 00000000..093371c6 --- /dev/null +++ b/sdk/canton/timelock_executor.go @@ -0,0 +1,166 @@ +package canton + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + + apiv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/google/uuid" + cselectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/go-daml/pkg/service/ledger" + + "github.com/smartcontractkit/chainlink-canton/bindings/mcms" + cantontypes "github.com/smartcontractkit/go-daml/pkg/types" + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +var _ sdk.TimelockExecutor = (*TimelockExecutor)(nil) + +// TimelockExecutor executes scheduled Canton timelock operations (ExecuteScheduledBatch). +type TimelockExecutor struct { + *TimelockInspector + client apiv2.CommandServiceClient + party string +} + +// NewTimelockExecutor creates a TimelockExecutor that submits ExecuteScheduledBatch via the given clients and party. +// timelockAddress (in Execute) is InstanceAddress hex; it is resolved to contract ID when submitting. +func NewTimelockExecutor(client apiv2.CommandServiceClient, stateClient apiv2.StateServiceClient, party string) *TimelockExecutor { + return &TimelockExecutor{ + TimelockInspector: NewTimelockInspector(client, stateClient, party), + client: client, + party: party, + } +} + +// Execute submits ExecuteScheduledBatch for the given batch operation (same opId hash as converter). +// timelockAddress is InstanceAddress hex; it is resolved to the current MCMS contract ID before submit. +func (t *TimelockExecutor) Execute( + ctx context.Context, + bop types.BatchOperation, + timelockAddress string, + predecessor common.Hash, + salt common.Hash, +) (types.TransactionResult, error) { + contractID, err := ResolveMCMSContractID(ctx, t.TimelockInspector.StateServiceClient(), t.party, timelockAddress) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("resolve MCMS contract ID: %w", err) + } + + if len(bop.Transactions) == 0 { + return types.TransactionResult{}, fmt.Errorf("batch operation has no transactions") + } + + calls := make([]mcms.TimelockCall, 0, len(bop.Transactions)) + callsForHash := make([]TimelockCallForHash, 0, len(bop.Transactions)) + var targetCids []string + for _, tx := range bop.Transactions { + var af AdditionalFields + if err := json.Unmarshal(tx.AdditionalFields, &af); err != nil { + return types.TransactionResult{}, fmt.Errorf("unmarshal transaction additional fields: %w", err) + } + calls = append(calls, mcms.TimelockCall{ + TargetInstanceId: cantontypes.TEXT(af.TargetInstanceId), + FunctionName: cantontypes.TEXT(af.FunctionName), + OperationData: cantontypes.TEXT(af.OperationData), + }) + callsForHash = append(callsForHash, TimelockCallForHash{ + TargetInstanceId: af.TargetInstanceId, + FunctionName: af.FunctionName, + OperationData: af.OperationData, + }) + targetCids = af.ContractIds + } + if len(targetCids) == 0 { + targetCids = []string{contractID} + } + + predecessorHex := hex.EncodeToString(predecessor[:]) + saltHex := hex.EncodeToString(salt[:]) + opIDStr := HashTimelockOpId(callsForHash, predecessorHex, saltHex) + + // Resolve InstanceAddress hex to current contract ID so Canton can parse them + stateClient := t.TimelockInspector.StateServiceClient() + targetCidSlice := make([]cantontypes.CONTRACT_ID, len(targetCids)) + for i, cid := range targetCids { + resolved, err := ResolveContractIDIfInstanceAddress(ctx, stateClient, t.party, cid) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("resolve contract ID %q: %w", cid, err) + } + targetCidSlice[i] = cantontypes.CONTRACT_ID(resolved) + } + + executeArgs := mcms.ExecuteScheduledBatch{ + Submitter: cantontypes.PARTY(t.party), + OpId: cantontypes.TEXT(opIDStr), + Calls: calls, + Predecessor: cantontypes.TEXT(predecessorHex), + Salt: cantontypes.TEXT(saltHex), + TargetCids: targetCidSlice, + } + + mcmsContract := mcms.MCMS{} + packageID, moduleName, entityName, err := parseTemplateIDFromString(mcmsContract.GetTemplateID()) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("failed to parse template ID: %w", err) + } + + commandID := uuid.Must(uuid.NewUUID()).String() + req := &apiv2.SubmitAndWaitForTransactionRequest{ + Commands: &apiv2.Commands{ + CommandId: commandID, + ActAs: []string{t.party}, + Commands: []*apiv2.Command{{ + Command: &apiv2.Command_Exercise{ + Exercise: &apiv2.ExerciseCommand{ + TemplateId: &apiv2.Identifier{ + PackageId: packageID, + ModuleName: moduleName, + EntityName: entityName, + }, + ContractId: contractID, + Choice: "ExecuteScheduledBatch", + ChoiceArgument: ledger.MapToValue(executeArgs), + }, + }, + }}, + }, + } + + resp, err := t.client.SubmitAndWaitForTransaction(ctx, req) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("submit ExecuteScheduledBatch: %w", err) + } + + // Extract new MCMS contract ID from Created event (callers need it for subsequent resolution) + newMCMSContractID := "" + newMCMSTemplateID := "" + transaction := resp.GetTransaction() + for _, ev := range transaction.GetEvents() { + if createdEv := ev.GetCreated(); createdEv != nil { + templateID := formatTemplateID(createdEv.GetTemplateId()) + if NormalizeTemplateKey(templateID) == MCMSTemplateKey { + newMCMSContractID = createdEv.GetContractId() + newMCMSTemplateID = templateID + break + } + } + } + if newMCMSContractID == "" { + return types.TransactionResult{}, fmt.Errorf("ExecuteScheduledBatch tx had no Created MCMS event; refusing to continue with old CID=%s", contractID) + } + + return types.TransactionResult{ + Hash: commandID, + ChainFamily: cselectors.FamilyCanton, + RawData: map[string]any{ + "NewMCMSContractID": newMCMSContractID, + "NewMCMSTemplateID": newMCMSTemplateID, + "RawTx": resp, + }, + }, nil +} diff --git a/sdk/canton/timelock_inspector.go b/sdk/canton/timelock_inspector.go new file mode 100644 index 00000000..144dca1a --- /dev/null +++ b/sdk/canton/timelock_inspector.go @@ -0,0 +1,237 @@ +package canton + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + + apiv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2" + "github.com/google/uuid" + "github.com/smartcontractkit/go-daml/pkg/service/ledger" + + "github.com/smartcontractkit/chainlink-canton/bindings/mcms" + cantontypes "github.com/smartcontractkit/go-daml/pkg/types" + "github.com/smartcontractkit/mcms/sdk" +) + +var _ sdk.TimelockInspector = (*TimelockInspector)(nil) + +// TimelockInspector inspects Canton timelock state via MCMS read-only choices +// (IsOperation, IsOperationPending, IsOperationReady, IsOperationDone, GetMinDelay). +// Role lists (GetProposers, etc.) return "unsupported on Canton" like Aptos. +// address parameters are InstanceAddress hex (Canton); they are resolved to contract ID when exercising. +type TimelockInspector struct { + client apiv2.CommandServiceClient + stateClient apiv2.StateServiceClient + party string +} + +// NewTimelockInspector creates a TimelockInspector that queries the ledger via the given clients. +func NewTimelockInspector(client apiv2.CommandServiceClient, stateClient apiv2.StateServiceClient, party string) *TimelockInspector { + return &TimelockInspector{ + client: client, + stateClient: stateClient, + party: party, + } +} + +// StateServiceClient returns the state service client for resolution (InstanceAddress to contract ID). +func (t *TimelockInspector) StateServiceClient() apiv2.StateServiceClient { + return t.stateClient +} + +// GetProposers returns the signer addresses for the Proposer role. +func (t *TimelockInspector) GetProposers(ctx context.Context, address string) ([]string, error) { + mcmsContract, err := GetMCMSContract(ctx, t.stateClient, t.party, address) + if err != nil { + return nil, fmt.Errorf("failed to get MCMS contract: %w", err) + } + return extractSignerAddresses(mcmsContract.Proposer.Config.Signers), nil +} + +// GetExecutors is unsupported on Canton: there is no separate executor role. +func (t *TimelockInspector) GetExecutors(_ context.Context, _ string) ([]string, error) { + return nil, errors.New("unsupported on Canton: no separate executor role") +} + +// GetBypassers returns the signer addresses for the Bypasser role. +func (t *TimelockInspector) GetBypassers(ctx context.Context, address string) ([]string, error) { + mcmsContract, err := GetMCMSContract(ctx, t.stateClient, t.party, address) + if err != nil { + return nil, fmt.Errorf("failed to get MCMS contract: %w", err) + } + return extractSignerAddresses(mcmsContract.Bypasser.Config.Signers), nil +} + +// GetCancellers returns the signer addresses for the Canceller role. +func (t *TimelockInspector) GetCancellers(ctx context.Context, address string) ([]string, error) { + mcmsContract, err := GetMCMSContract(ctx, t.stateClient, t.party, address) + if err != nil { + return nil, fmt.Errorf("failed to get MCMS contract: %w", err) + } + return extractSignerAddresses(mcmsContract.Canceller.Config.Signers), nil +} + +// extractSignerAddresses extracts signer addresses from a slice of SignerInfo. +func extractSignerAddresses(signers []mcms.SignerInfo) []string { + result := make([]string, len(signers)) + for i, s := range signers { + result[i] = string(s.SignerAddress) + } + return result +} + +func (t *TimelockInspector) IsOperation(ctx context.Context, address string, opID [32]byte) (bool, error) { + return t.exerciseBoolChoice(ctx, address, "IsOperation", opID) +} + +func (t *TimelockInspector) IsOperationPending(ctx context.Context, address string, opID [32]byte) (bool, error) { + return t.exerciseBoolChoice(ctx, address, "IsOperationPending", opID) +} + +func (t *TimelockInspector) IsOperationReady(ctx context.Context, address string, opID [32]byte) (bool, error) { + return t.exerciseBoolChoice(ctx, address, "IsOperationReady", opID) +} + +func (t *TimelockInspector) IsOperationDone(ctx context.Context, address string, opID [32]byte) (bool, error) { + return t.exerciseBoolChoice(ctx, address, "IsOperationDone", opID) +} + +func (t *TimelockInspector) GetMinDelay(ctx context.Context, address string) (uint64, error) { + contractID, err := ResolveMCMSContractID(ctx, t.stateClient, t.party, address) + if err != nil { + return 0, fmt.Errorf("resolve MCMS contract ID: %w", err) + } + args := mcms.GetMinDelay{Submitter: cantontypes.PARTY(t.party)} + req, err := t.exerciseRequest(contractID, "GetMinDelay", ledger.MapToValue(args)) + if err != nil { + return 0, fmt.Errorf("failed to create exercise request: %w", err) + } + resp, err := t.client.SubmitAndWaitForTransaction(ctx, req) + if err != nil { + return 0, fmt.Errorf("GetMinDelay: %w", err) + } + events := resp.GetTransaction().GetEvents() + if len(events) == 0 { + return 0, fmt.Errorf("GetMinDelay: no events in transaction") + } + ex := events[0].GetExercised() + if ex == nil { + return 0, fmt.Errorf("GetMinDelay: first event is not exercise") + } + // GetMinDelay returns RelTime = record with "microseconds" field + rec := ex.GetExerciseResult().GetRecord() + if rec == nil || len(rec.GetFields()) == 0 { + return 0, fmt.Errorf("GetMinDelay: result is not a record with fields") + } + // first field is "microseconds" (Int64) + val := rec.GetFields()[0].GetValue() + if val == nil { + return 0, fmt.Errorf("GetMinDelay: missing microseconds value") + } + us := val.GetInt64() + if us < 0 { + return 0, fmt.Errorf("GetMinDelay: invalid microseconds %d", us) + } + return uint64(us / 1_000_000), nil +} + +func (t *TimelockInspector) exerciseBoolChoice(ctx context.Context, address string, choice string, opID [32]byte) (bool, error) { + contractID, err := ResolveMCMSContractID(ctx, t.stateClient, t.party, address) + if err != nil { + return false, fmt.Errorf("resolve MCMS contract ID: %w", err) + } + opIDStr := hex.EncodeToString(opID[:]) + party := cantontypes.PARTY(t.party) + var choiceArg *apiv2.Value + switch choice { + case "IsOperation": + choiceArg = ledger.MapToValue(mcms.IsOperation{Submitter: party, OpId: cantontypes.TEXT(opIDStr)}) + case "IsOperationPending": + choiceArg = ledger.MapToValue(mcms.IsOperationPending{Submitter: party, OpId: cantontypes.TEXT(opIDStr)}) + case "IsOperationReady": + choiceArg = ledger.MapToValue(mcms.IsOperationReady{Submitter: party, OpId: cantontypes.TEXT(opIDStr)}) + case "IsOperationDone": + choiceArg = ledger.MapToValue(mcms.IsOperationDone{Submitter: party, OpId: cantontypes.TEXT(opIDStr)}) + default: + return false, fmt.Errorf("unknown choice %s", choice) + } + req, err := t.exerciseRequest(contractID, choice, choiceArg) + if err != nil { + return false, fmt.Errorf("failed to create exercise request: %w", err) + } + resp, err := t.client.SubmitAndWaitForTransaction(ctx, req) + if err != nil { + return false, fmt.Errorf("%s: %w", choice, err) + } + events := resp.GetTransaction().GetEvents() + if len(events) == 0 { + return false, fmt.Errorf("%s: no events", choice) + } + ex := events[0].GetExercised() + if ex == nil { + return false, fmt.Errorf("%s: first event is not exercise", choice) + } + return valueToBool(ex.GetExerciseResult()) +} + +func (t *TimelockInspector) exerciseRequest(contractID, choice string, choiceArg *apiv2.Value) (*apiv2.SubmitAndWaitForTransactionRequest, error) { + // Parse template ID + mcmsContract := mcms.MCMS{} + packageID, moduleName, entityName, err := parseTemplateIDFromString(mcmsContract.GetTemplateID()) + if err != nil { + return nil, fmt.Errorf("failed to parse template ID: %w", err) + } + + return &apiv2.SubmitAndWaitForTransactionRequest{ + Commands: &apiv2.Commands{ + CommandId: uuid.Must(uuid.NewUUID()).String(), + ActAs: []string{t.party}, + Commands: []*apiv2.Command{{ + Command: &apiv2.Command_Exercise{ + Exercise: &apiv2.ExerciseCommand{ + TemplateId: &apiv2.Identifier{ + PackageId: packageID, + ModuleName: moduleName, + EntityName: entityName, + }, + ContractId: contractID, + Choice: choice, + ChoiceArgument: choiceArg, + }, + }, + }}, + }, + TransactionFormat: &apiv2.TransactionFormat{ + EventFormat: &apiv2.EventFormat{ + FiltersByParty: map[string]*apiv2.Filters{ + t.party: {}, + }, + }, + TransactionShape: apiv2.TransactionShape_TRANSACTION_SHAPE_LEDGER_EFFECTS, + }, + }, nil +} + +func valueToBool(v *apiv2.Value) (bool, error) { + if v == nil { + return false, errors.New("nil value") + } + switch s := v.Sum.(type) { + case *apiv2.Value_Bool: + return s.Bool, nil + case *apiv2.Value_Variant: + // Daml Bool is sometimes encoded as variant True | False + if s.Variant != nil { + c := s.Variant.Constructor + if c == "True" { + return true, nil + } + if c == "False" { + return false, nil + } + } + } + return false, fmt.Errorf("value is not Bool or Bool variant: %T", v.Sum) +} diff --git a/timelock_proposal.go b/timelock_proposal.go index af52f828..3e496fd6 100644 --- a/timelock_proposal.go +++ b/timelock_proposal.go @@ -19,6 +19,7 @@ import ( "github.com/smartcontractkit/mcms/internal/utils/safecast" "github.com/smartcontractkit/mcms/sdk" "github.com/smartcontractkit/mcms/sdk/aptos" + "github.com/smartcontractkit/mcms/sdk/canton" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" "github.com/smartcontractkit/mcms/sdk/sui" @@ -299,6 +300,8 @@ func (m *TimelockProposal) buildTimelockConverters() (map[types.ChainSelector]sd if err != nil { return nil, fmt.Errorf("failed to create Sui timelock converter: %w", err) } + case chain_selectors.FamilyCanton: + converter = canton.NewTimelockConverter() default: return nil, fmt.Errorf("unsupported chain family %s", fam) }