diff --git a/bindings/generated/latest/ccip/feetreasury/feetreasury.go b/bindings/generated/latest/ccip/feetreasury/feetreasury.go new file mode 100644 index 000000000..0c87e6ed7 --- /dev/null +++ b/bindings/generated/latest/ccip/feetreasury/feetreasury.go @@ -0,0 +1,588 @@ +package feetreasury + +import ( + "errors" + "fmt" + "math/big" + "strings" + + api "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/mcms/api" + splice_api_token_holding_v1 "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1" + splice_api_token_metadata_v1 "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_metadata_v1" + "github.com/smartcontractkit/go-daml/pkg/bind" + "github.com/smartcontractkit/go-daml/pkg/codec" + "github.com/smartcontractkit/go-daml/pkg/model" + "github.com/smartcontractkit/go-daml/pkg/types" +) + +var ( + _ = fmt.Sprintf + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = model.Command{} + _ bind.BoundTemplate +) + +const ( + PackageName = "ccip-fee-treasury" + PackageID = "d12d9826bbcd19de67ada3d3dbfb11000377f1301e678e8e050714a4b29eedea" + SDKVersion = "3.4.11" +) + +type Template interface { + CreateCommand() *model.CreateCommand + GetTemplateID() string +} + +func argsToMap(args any) map[string]any { + if args == nil { + return map[string]any{} + } + + if m, ok := args.(map[string]any); ok { + return m + } + + type mapper interface { + ToMap() map[string]any + } + if mapper, ok := args.(mapper); ok { + return mapper.ToMap() + } + + return map[string]any{"args": args} +} + +// AuthorizeFeeWithdrawal is a Record type +type AuthorizeFeeWithdrawal struct { + Params AuthorizeFeeWithdrawalParams `json:"params"` +} + +// ToMap converts AuthorizeFeeWithdrawal to a map for DAML arguments +func (t AuthorizeFeeWithdrawal) ToMap() map[string]any { + m := make(map[string]any) + + m["params"] = model.NestedToDAMLValue(t.Params) + + return m +} + +func (t AuthorizeFeeWithdrawal) MarshalJSON() ([]byte, error) { + jsonCodec := codec.NewJsonCodec() + return jsonCodec.Marshal(t) +} + +func (t *AuthorizeFeeWithdrawal) UnmarshalJSON(data []byte) error { + jsonCodec := codec.NewJsonCodec() + return jsonCodec.Unmarshal(data, t) +} + +// MarshalHex encodes AuthorizeFeeWithdrawal to hex string (Canton MCMS format) +func (t AuthorizeFeeWithdrawal) MarshalHex() (string, error) { + hexCodec := codec.NewHexCodec() + return hexCodec.Marshal(t) +} + +// UnmarshalHex decodes AuthorizeFeeWithdrawal from hex string (Canton MCMS format) +func (t *AuthorizeFeeWithdrawal) UnmarshalHex(data string) error { + hexCodec := codec.NewHexCodec() + return hexCodec.Unmarshal(data, t) +} + +// AuthorizeFeeWithdrawalParams is a Record type +type AuthorizeFeeWithdrawalParams struct { + Recipient types.PARTY `json:"recipient"` + InstrumentId splice_api_token_holding_v1.InstrumentId `json:"instrumentId"` + MaxAmount types.NUMERIC `json:"maxAmount" hex:"decimal"` + ValiditySecs types.INT64 `json:"validitySecs"` +} + +// ToMap converts AuthorizeFeeWithdrawalParams to a map for DAML arguments +func (t AuthorizeFeeWithdrawalParams) ToMap() map[string]any { + m := make(map[string]any) + + m["recipient"] = t.Recipient.ToMap() + + m["instrumentId"] = model.NestedToDAMLValue(t.InstrumentId) + + m["maxAmount"] = t.MaxAmount + + m["validitySecs"] = int64(t.ValiditySecs) + + return m +} + +func (t AuthorizeFeeWithdrawalParams) MarshalJSON() ([]byte, error) { + jsonCodec := codec.NewJsonCodec() + return jsonCodec.Marshal(t) +} + +func (t *AuthorizeFeeWithdrawalParams) UnmarshalJSON(data []byte) error { + jsonCodec := codec.NewJsonCodec() + return jsonCodec.Unmarshal(data, t) +} + +// MarshalHex encodes AuthorizeFeeWithdrawalParams to hex string (Canton MCMS format) +func (t AuthorizeFeeWithdrawalParams) MarshalHex() (string, error) { + hexCodec := codec.NewHexCodec() + return hexCodec.Marshal(t) +} + +// UnmarshalHex decodes AuthorizeFeeWithdrawalParams from hex string (Canton MCMS format) +func (t *AuthorizeFeeWithdrawalParams) UnmarshalHex(data string) error { + hexCodec := codec.NewHexCodec() + return hexCodec.Unmarshal(data, t) +} + +// ExecuteFeeWithdrawal is a Record type +type ExecuteFeeWithdrawal struct { + Submitter types.PARTY `json:"submitter"` + TransferFactoryCid types.CONTRACT_ID `json:"transferFactoryCid"` + InputHoldingCids []types.CONTRACT_ID `json:"inputHoldingCids"` + Amount types.NUMERIC `json:"amount"` + ExtraArgs splice_api_token_metadata_v1.ExtraArgs `json:"extraArgs"` + RequestedAt types.TIMESTAMP `json:"requestedAt"` +} + +// ToMap converts ExecuteFeeWithdrawal to a map for DAML arguments +func (t ExecuteFeeWithdrawal) ToMap() map[string]any { + m := make(map[string]any) + + m["submitter"] = t.Submitter.ToMap() + + m["transferFactoryCid"] = model.NestedToDAMLValue(t.TransferFactoryCid) + + m["inputHoldingCids"] = func() []any { + res := make([]any, 0, len(t.InputHoldingCids)) + for _, e := range t.InputHoldingCids { + res = append(res, e) + } + return res + }() + + m["amount"] = t.Amount + + m["extraArgs"] = model.NestedToDAMLValue(t.ExtraArgs) + + m["requestedAt"] = t.RequestedAt + + return m +} + +func (t ExecuteFeeWithdrawal) MarshalJSON() ([]byte, error) { + jsonCodec := codec.NewJsonCodec() + return jsonCodec.Marshal(t) +} + +func (t *ExecuteFeeWithdrawal) UnmarshalJSON(data []byte) error { + jsonCodec := codec.NewJsonCodec() + return jsonCodec.Unmarshal(data, t) +} + +// MarshalHex encodes ExecuteFeeWithdrawal to hex string (Canton MCMS format) +func (t ExecuteFeeWithdrawal) MarshalHex() (string, error) { + hexCodec := codec.NewHexCodec() + return hexCodec.Marshal(t) +} + +// UnmarshalHex decodes ExecuteFeeWithdrawal from hex string (Canton MCMS format) +func (t *ExecuteFeeWithdrawal) UnmarshalHex(data string) error { + hexCodec := codec.NewHexCodec() + return hexCodec.Unmarshal(data, t) +} + +// FeeWithdrawalAuthorization is a Template type +type FeeWithdrawalAuthorization struct { + FeeOwner types.PARTY `json:"feeOwner"` + Recipient types.PARTY `json:"recipient"` + InstrumentId splice_api_token_holding_v1.InstrumentId `json:"instrumentId"` + MaxAmount types.NUMERIC `json:"maxAmount" hex:"decimal"` + ExpiresAt types.TIMESTAMP `json:"expiresAt"` +} + +// GetTemplateID returns the template ID for this template using the package name +func (t FeeWithdrawalAuthorization) GetTemplateID() string { + return fmt.Sprintf("#%s:%s:%s", PackageName, "CCIP.FeeTreasury", "FeeWithdrawalAuthorization") +} + +// GetTemplateIDWithPackageID returns the template ID using the provided package ID instead of package name +func (t FeeWithdrawalAuthorization) GetTemplateIDWithPackageID(packageID string) string { + return fmt.Sprintf("%s:%s:%s", packageID, "CCIP.FeeTreasury", "FeeWithdrawalAuthorization") +} + +// CreateCommand returns a CreateCommand for this template using the package name +func (t FeeWithdrawalAuthorization) CreateCommand() *model.CreateCommand { + args := make(map[string]any) + + // IMPORTANT: always include non-optional fields (GENMAP/MAP/LIST/[] etc), even if empty + args["feeOwner"] = t.FeeOwner.ToMap() + + // IMPORTANT: always include non-optional fields (GENMAP/MAP/LIST/[] etc), even if empty + args["recipient"] = t.Recipient.ToMap() + + // IMPORTANT: always include non-optional fields (GENMAP/MAP/LIST/[] etc), even if empty + args["instrumentId"] = model.NestedToDAMLValue(t.InstrumentId) + + if t.MaxAmount != "" { + args["maxAmount"] = t.MaxAmount + } + + // IMPORTANT: always include non-optional fields (GENMAP/MAP/LIST/[] etc), even if empty + args["expiresAt"] = t.ExpiresAt + + return &model.CreateCommand{ + TemplateID: t.GetTemplateID(), + Arguments: args, + } +} + +// CreateCommandWithPackageID returns a CreateCommand using the provided package ID instead of package name +func (t FeeWithdrawalAuthorization) CreateCommandWithPackageID(packageID string) *model.CreateCommand { + args := make(map[string]any) + + // IMPORTANT: always include non-optional fields (GENMAP/MAP/LIST/[] etc), even if empty + args["feeOwner"] = t.FeeOwner.ToMap() + + // IMPORTANT: always include non-optional fields (GENMAP/MAP/LIST/[] etc), even if empty + args["recipient"] = t.Recipient.ToMap() + + // IMPORTANT: always include non-optional fields (GENMAP/MAP/LIST/[] etc), even if empty + args["instrumentId"] = model.NestedToDAMLValue(t.InstrumentId) + + if t.MaxAmount != "" { + args["maxAmount"] = t.MaxAmount + } + + // IMPORTANT: always include non-optional fields (GENMAP/MAP/LIST/[] etc), even if empty + args["expiresAt"] = t.ExpiresAt + + return &model.CreateCommand{ + TemplateID: t.GetTemplateIDWithPackageID(packageID), + Arguments: args, + } +} + +func (t FeeWithdrawalAuthorization) MarshalJSON() ([]byte, error) { + jsonCodec := codec.NewJsonCodec() + return jsonCodec.Marshal(t) +} + +func (t *FeeWithdrawalAuthorization) UnmarshalJSON(data []byte) error { + jsonCodec := codec.NewJsonCodec() + return jsonCodec.Unmarshal(data, t) +} + +// MarshalHex encodes FeeWithdrawalAuthorization to hex string (Canton MCMS format) +func (t FeeWithdrawalAuthorization) MarshalHex() (string, error) { + hexCodec := codec.NewHexCodec() + return hexCodec.Marshal(t) +} + +// UnmarshalHex decodes FeeWithdrawalAuthorization from hex string (Canton MCMS format) +func (t *FeeWithdrawalAuthorization) UnmarshalHex(data string) error { + hexCodec := codec.NewHexCodec() + return hexCodec.Unmarshal(data, t) +} + +// Choice methods for FeeWithdrawalAuthorization + +// ExecuteFeeWithdrawal exercises the ExecuteFeeWithdrawal choice on this FeeWithdrawalAuthorization contract +// This method uses the package name in the template ID +func (t FeeWithdrawalAuthorization) ExecuteFeeWithdrawal(contractID string, args ExecuteFeeWithdrawal) *model.ExerciseCommand { + return &model.ExerciseCommand{ + TemplateID: fmt.Sprintf("#%s:%s:%s", PackageName, "CCIP.FeeTreasury", "FeeWithdrawalAuthorization"), + ContractID: contractID, + Choice: "ExecuteFeeWithdrawal", + Arguments: argsToMap(args), + } +} + +// ExecuteFeeWithdrawalWithPackageID exercises the ExecuteFeeWithdrawal choice using the provided package ID instead of package name +func (t FeeWithdrawalAuthorization) ExecuteFeeWithdrawalWithPackageID(contractID string, packageID string, args ExecuteFeeWithdrawal) *model.ExerciseCommand { + return &model.ExerciseCommand{ + TemplateID: fmt.Sprintf("#%s:%s:%s", packageID, "CCIP.FeeTreasury", "FeeWithdrawalAuthorization"), + ContractID: contractID, + Choice: "ExecuteFeeWithdrawal", + Arguments: argsToMap(args), + } +} + +// Archive exercises the Archive choice on this FeeWithdrawalAuthorization contract +// This method uses the package name in the template ID +func (t FeeWithdrawalAuthorization) Archive(contractID string) *model.ExerciseCommand { + return &model.ExerciseCommand{ + TemplateID: fmt.Sprintf("#%s:%s:%s", PackageName, "CCIP.FeeTreasury", "FeeWithdrawalAuthorization"), + ContractID: contractID, + Choice: "Archive", + Arguments: map[string]any{}, + } +} + +// ArchiveWithPackageID exercises the Archive choice using the provided package ID instead of package name +func (t FeeWithdrawalAuthorization) ArchiveWithPackageID(contractID string, packageID string) *model.ExerciseCommand { + return &model.ExerciseCommand{ + TemplateID: fmt.Sprintf("#%s:%s:%s", packageID, "CCIP.FeeTreasury", "FeeWithdrawalAuthorization"), + ContractID: contractID, + Choice: "Archive", + Arguments: map[string]any{}, + } +} + +// WithdrawAuthorization exercises the WithdrawAuthorization choice on this FeeWithdrawalAuthorization contract +// This method uses the package name in the template ID +func (t FeeWithdrawalAuthorization) WithdrawAuthorization(contractID string, args WithdrawAuthorization) *model.ExerciseCommand { + return &model.ExerciseCommand{ + TemplateID: fmt.Sprintf("#%s:%s:%s", PackageName, "CCIP.FeeTreasury", "FeeWithdrawalAuthorization"), + ContractID: contractID, + Choice: "WithdrawAuthorization", + Arguments: argsToMap(args), + } +} + +// WithdrawAuthorizationWithPackageID exercises the WithdrawAuthorization choice using the provided package ID instead of package name +func (t FeeWithdrawalAuthorization) WithdrawAuthorizationWithPackageID(contractID string, packageID string, args WithdrawAuthorization) *model.ExerciseCommand { + return &model.ExerciseCommand{ + TemplateID: fmt.Sprintf("#%s:%s:%s", packageID, "CCIP.FeeTreasury", "FeeWithdrawalAuthorization"), + ContractID: contractID, + Choice: "WithdrawAuthorization", + Arguments: argsToMap(args), + } +} + +// MCMSFeeTreasury is a Template type +type MCMSFeeTreasury struct { + InstanceId types.TEXT `json:"instanceId"` + FeeOwner types.PARTY `json:"feeOwner"` + McmsController types.PARTY `json:"mcmsController"` +} + +// GetTemplateID returns the template ID for this template using the package name +func (t MCMSFeeTreasury) GetTemplateID() string { + return fmt.Sprintf("#%s:%s:%s", PackageName, "CCIP.FeeTreasury", "MCMSFeeTreasury") +} + +// GetTemplateIDWithPackageID returns the template ID using the provided package ID instead of package name +func (t MCMSFeeTreasury) GetTemplateIDWithPackageID(packageID string) string { + return fmt.Sprintf("%s:%s:%s", packageID, "CCIP.FeeTreasury", "MCMSFeeTreasury") +} + +// CreateCommand returns a CreateCommand for this template using the package name +func (t MCMSFeeTreasury) CreateCommand() *model.CreateCommand { + args := make(map[string]any) + + // IMPORTANT: always include non-optional fields (GENMAP/MAP/LIST/[] etc), even if empty + args["instanceId"] = string(t.InstanceId) + + // IMPORTANT: always include non-optional fields (GENMAP/MAP/LIST/[] etc), even if empty + args["feeOwner"] = t.FeeOwner.ToMap() + + // IMPORTANT: always include non-optional fields (GENMAP/MAP/LIST/[] etc), even if empty + args["mcmsController"] = t.McmsController.ToMap() + + return &model.CreateCommand{ + TemplateID: t.GetTemplateID(), + Arguments: args, + } +} + +// CreateCommandWithPackageID returns a CreateCommand using the provided package ID instead of package name +func (t MCMSFeeTreasury) CreateCommandWithPackageID(packageID string) *model.CreateCommand { + args := make(map[string]any) + + // IMPORTANT: always include non-optional fields (GENMAP/MAP/LIST/[] etc), even if empty + args["instanceId"] = string(t.InstanceId) + + // IMPORTANT: always include non-optional fields (GENMAP/MAP/LIST/[] etc), even if empty + args["feeOwner"] = t.FeeOwner.ToMap() + + // IMPORTANT: always include non-optional fields (GENMAP/MAP/LIST/[] etc), even if empty + args["mcmsController"] = t.McmsController.ToMap() + + return &model.CreateCommand{ + TemplateID: t.GetTemplateIDWithPackageID(packageID), + Arguments: args, + } +} + +func (t MCMSFeeTreasury) MarshalJSON() ([]byte, error) { + jsonCodec := codec.NewJsonCodec() + return jsonCodec.Marshal(t) +} + +func (t *MCMSFeeTreasury) UnmarshalJSON(data []byte) error { + jsonCodec := codec.NewJsonCodec() + return jsonCodec.Unmarshal(data, t) +} + +// MarshalHex encodes MCMSFeeTreasury to hex string (Canton MCMS format) +func (t MCMSFeeTreasury) MarshalHex() (string, error) { + hexCodec := codec.NewHexCodec() + return hexCodec.Marshal(t) +} + +// UnmarshalHex decodes MCMSFeeTreasury from hex string (Canton MCMS format) +func (t *MCMSFeeTreasury) UnmarshalHex(data string) error { + hexCodec := codec.NewHexCodec() + return hexCodec.Unmarshal(data, t) +} + +// Choice methods for MCMSFeeTreasury + +// AuthorizeFeeWithdrawal exercises the AuthorizeFeeWithdrawal choice on this MCMSFeeTreasury contract +// This method uses the package name in the template ID +func (t MCMSFeeTreasury) AuthorizeFeeWithdrawal(contractID string, args AuthorizeFeeWithdrawal) *model.ExerciseCommand { + return &model.ExerciseCommand{ + TemplateID: fmt.Sprintf("#%s:%s:%s", PackageName, "CCIP.FeeTreasury", "MCMSFeeTreasury"), + ContractID: contractID, + Choice: "AuthorizeFeeWithdrawal", + Arguments: argsToMap(args), + } +} + +// AuthorizeFeeWithdrawalWithPackageID exercises the AuthorizeFeeWithdrawal choice using the provided package ID instead of package name +func (t MCMSFeeTreasury) AuthorizeFeeWithdrawalWithPackageID(contractID string, packageID string, args AuthorizeFeeWithdrawal) *model.ExerciseCommand { + return &model.ExerciseCommand{ + TemplateID: fmt.Sprintf("#%s:%s:%s", packageID, "CCIP.FeeTreasury", "MCMSFeeTreasury"), + ContractID: contractID, + Choice: "AuthorizeFeeWithdrawal", + Arguments: argsToMap(args), + } +} + +// Archive exercises the Archive choice on this MCMSFeeTreasury contract via the IMCMSReceiver interface +// This method uses the package name in the template ID +func (t MCMSFeeTreasury) Archive(contractID string) *model.ExerciseCommand { + return &model.ExerciseCommand{ + TemplateID: fmt.Sprintf("#%s:%s:%s", PackageName, "CCIP.FeeTreasury", "MCMSReceiver"), + ContractID: contractID, + Choice: "Archive", + Arguments: map[string]any{}, + } +} + +// ArchiveWithPackageID exercises the Archive choice using the provided package ID instead of package name +func (t MCMSFeeTreasury) ArchiveWithPackageID(contractID string, packageID string) *model.ExerciseCommand { + return &model.ExerciseCommand{ + TemplateID: fmt.Sprintf("#%s:%s:%s", packageID, "CCIP.FeeTreasury", "MCMSReceiver"), + ContractID: contractID, + Choice: "Archive", + Arguments: map[string]any{}, + } +} + +// MCMSReceiverEntrypoint exercises the MCMSReceiver_Entrypoint choice on this MCMSFeeTreasury contract via the IMCMSReceiver interface +// This method uses the package name in the template ID +func (t MCMSFeeTreasury) MCMSReceiverEntrypoint(contractID string, args api.MCMSReceiverEntrypoint) *model.ExerciseCommand { + return &model.ExerciseCommand{ + TemplateID: fmt.Sprintf("#%s:%s:%s", PackageName, "CCIP.FeeTreasury", "MCMSReceiver"), + ContractID: contractID, + Choice: "MCMSReceiver_Entrypoint", + Arguments: argsToMap(args), + } +} + +// MCMSReceiverEntrypointWithPackageID exercises the MCMSReceiver_Entrypoint choice using the provided package ID instead of package name +func (t MCMSFeeTreasury) MCMSReceiverEntrypointWithPackageID(contractID string, packageID string, args api.MCMSReceiverEntrypoint) *model.ExerciseCommand { + return &model.ExerciseCommand{ + TemplateID: fmt.Sprintf("#%s:%s:%s", packageID, "CCIP.FeeTreasury", "MCMSReceiver"), + ContractID: contractID, + Choice: "MCMSReceiver_Entrypoint", + Arguments: argsToMap(args), + } +} + +// Verify interface implementations for MCMSFeeTreasury + +var _ api.IMCMSReceiver = (*MCMSFeeTreasury)(nil) + +// WithdrawAuthorization is a Record type +type WithdrawAuthorization struct { +} + +// ToMap converts WithdrawAuthorization to a map for DAML arguments +func (t WithdrawAuthorization) ToMap() map[string]any { + m := make(map[string]any) + return m +} + +func (t WithdrawAuthorization) MarshalJSON() ([]byte, error) { + jsonCodec := codec.NewJsonCodec() + return jsonCodec.Marshal(t) +} + +func (t *WithdrawAuthorization) UnmarshalJSON(data []byte) error { + jsonCodec := codec.NewJsonCodec() + return jsonCodec.Unmarshal(data, t) +} + +// MarshalHex encodes WithdrawAuthorization to hex string (Canton MCMS format) +func (t WithdrawAuthorization) MarshalHex() (string, error) { + hexCodec := codec.NewHexCodec() + return hexCodec.Marshal(t) +} + +// UnmarshalHex decodes WithdrawAuthorization from hex string (Canton MCMS format) +func (t *WithdrawAuthorization) UnmarshalHex(data string) error { + hexCodec := codec.NewHexCodec() + return hexCodec.Unmarshal(data, t) +} + +// MCMSEncoder interface for typed encoding methods. +// Implemented by Encoder for method-based encoding. +type MCMSEncoder interface { + AuthorizeFeeWithdrawal(args AuthorizeFeeWithdrawal) (*bind.EncodedChoice, error) + AuthorizeFeeWithdrawalParams(args AuthorizeFeeWithdrawalParams) (*bind.EncodedChoice, error) + ExecuteFeeWithdrawal(args ExecuteFeeWithdrawal) (*bind.EncodedChoice, error) + WithdrawAuthorization(args WithdrawAuthorization) (*bind.EncodedChoice, error) +} + +// encoder provides typed encoding methods for choice parameters (unexported). +// It wraps bind.BoundTemplate to encode parameters to hex-encoded operation data. +type encoder struct { + *bind.BoundTemplate +} + +// Contract wraps template operations with Sui-style API access. +// Use NewContract to create instances, then call Encoder() for encoding methods. +type Contract struct { + enc *encoder +} + +// NewContract creates a Contract with encoder for the given template. +// This provides Sui-style API: contract.Encoder().Method(args) +func NewContract(packageID, moduleName, templateName string) *Contract { + return &Contract{ + enc: &encoder{ + BoundTemplate: bind.NewBoundTemplate(packageID, moduleName, templateName), + }, + } +} + +// Encoder returns the encoder for Sui-style contract.Encoder().Method() usage. +func (c *Contract) Encoder() MCMSEncoder { + return c.enc +} + +// AuthorizeFeeWithdrawal encodes parameters for the AuthorizeFeeWithdrawal choice. +func (e *encoder) AuthorizeFeeWithdrawal(args AuthorizeFeeWithdrawal) (*bind.EncodedChoice, error) { + return e.EncodeChoiceArgs("AuthorizeFeeWithdrawal", args) +} + +// AuthorizeFeeWithdrawalParams encodes parameters for the AuthorizeFeeWithdrawal choice. +func (e *encoder) AuthorizeFeeWithdrawalParams(args AuthorizeFeeWithdrawalParams) (*bind.EncodedChoice, error) { + return e.EncodeChoiceArgs("AuthorizeFeeWithdrawal", args) +} + +// ExecuteFeeWithdrawal encodes parameters for the ExecuteFeeWithdrawal choice. +func (e *encoder) ExecuteFeeWithdrawal(args ExecuteFeeWithdrawal) (*bind.EncodedChoice, error) { + return e.EncodeChoiceArgs("ExecuteFeeWithdrawal", args) +} + +// WithdrawAuthorization encodes parameters for the WithdrawAuthorization choice. +func (e *encoder) WithdrawAuthorization(args WithdrawAuthorization) (*bind.EncodedChoice, error) { + return e.EncodeChoiceArgs("WithdrawAuthorization", args) +} + +// Verify MCMSEncoder interface implementation +var _ MCMSEncoder = (*encoder)(nil) diff --git a/contracts/ccip/fee-treasury/daml.yaml b/contracts/ccip/fee-treasury/daml.yaml new file mode 100644 index 000000000..6f0f63dfb --- /dev/null +++ b/contracts/ccip/fee-treasury/daml.yaml @@ -0,0 +1,17 @@ +sdk-version: 3.4.11 +name: ccip-fee-treasury +source: daml +version: 2.0.0 +build-options: + - "-Wno-crypto-text-is-alpha" + - "-Werror=unused-dependency" +data-dependencies: + - ../../dependencies/splice/splice-api-token-metadata-v1-1.0.0.dar + - ../../dependencies/splice/splice-api-token-holding-v1-1.0.0.dar + - ../../dependencies/splice/splice-api-token-transfer-instruction-v1-1.0.0.dar +dependencies: + - daml-prim + - daml-stdlib + - ../../chainlink/api/.daml/dist/chainlink-api-2.0.0.dar + - ../../mcms/api/.daml/dist/mcms-api-1.0.0.dar + - ../codec/.daml/dist/ccip-codec-v2-2.0.0.dar diff --git a/contracts/ccip/fee-treasury/daml/CCIP/FeeTreasury.daml b/contracts/ccip/fee-treasury/daml/CCIP/FeeTreasury.daml new file mode 100644 index 000000000..b083baee7 --- /dev/null +++ b/contracts/ccip/fee-treasury/daml/CCIP/FeeTreasury.daml @@ -0,0 +1,137 @@ +-- Two-phase design: +-- 1. feeOwner -> MCMSFeeTreasury.AuthorizeFeeWithdrawal +-- Creates a FeeWithdrawalAuthorization capturing the approved policy (recipient, +-- instrument, amount cap, expiry). +-- 2. anyone -> FeeWithdrawalAuthorization.ExecuteFeeWithdrawal +-- Supplies live ContractIds (holdings, TransferFactory) + ExtraArgs (disclosed +-- mining-round context). Not subject to MCMS validateTargetCidsSize cap. +module CCIP.FeeTreasury where + +import DA.Crypto.Text (byteCount) +import DA.Map qualified as Map +import DA.Time (addRelTime, seconds) + +import Splice.Api.Token.HoldingV1 (Holding, InstrumentId(..)) +import Splice.Api.Token.MetadataV1 (ExtraArgs, emptyMetadata) +import Splice.Api.Token.TransferInstructionV1 ( + Transfer(..), + TransferFactory, + TransferFactory_Transfer(..), + TransferInstructionResult, + TransferInstructionResult_Output(..), + ) + +import Chainlink.InstanceAddress qualified as RawInstanceAddress +import Chainlink.InstanceAddress (assertValidInstanceId) + +import MCMS.MCMSReceiver (MCMSReceiver(..), MCMSReceiverView(..)) + +import CCIP.FeeTreasuryTypes (AuthorizeFeeWithdrawalParams(..)) +import CCIP.FeeTreasuryCodecGen (decodeAuthorizeFeeWithdrawalParamsAt) + +template MCMSFeeTreasury + with + instanceId : Text + feeOwner : Party + mcmsController : Party + where + signatory feeOwner + observer mcmsController + + ensure assertValidInstanceId instanceId + + -- Nonconsuming: authorizing does not mutate the treasury. The single-use + -- capability is the created FeeWithdrawalAuthorization contract itself (its + -- ContractId), consumed by ExecuteFeeWithdrawal or WithdrawAuthorization. + nonconsuming choice AuthorizeFeeWithdrawal : ContractId FeeWithdrawalAuthorization + with + params : AuthorizeFeeWithdrawalParams + controller feeOwner + do + now <- getTime + let expiresAt = now `addRelTime` seconds params.validitySecs + create FeeWithdrawalAuthorization with + feeOwner + recipient = params.recipient + instrumentId = params.instrumentId + maxAmount = params.maxAmount + expiresAt + + interface instance MCMSReceiver for MCMSFeeTreasury where + view = MCMSReceiverView with + mcmsController + instanceId + + mcmsEntrypoint functionName operationData contractIds = do + let instAddr = RawInstanceAddress.makeText instanceId feeOwner + selfCid <- case Map.lookup instAddr contractIds of + None -> abort $ "E_SELF_CID_NOT_IN_MAP: " <> instAddr + Some cid -> pure (coerceContractId cid : ContractId MCMSFeeTreasury) + + case functionName of + "AuthorizeFeeWithdrawal" -> do + case decodeAuthorizeFeeWithdrawalParamsAt operationData 0 of + None -> abort "E_INVALID_PARAMS: AuthorizeFeeWithdrawal" + Some (params, finalOffset) -> do + assertMsg "E_TRAILING_BYTES: AuthorizeFeeWithdrawal" (finalOffset == byteCount operationData) + _ <- exercise selfCid AuthorizeFeeWithdrawal with params + -- AuthorizeFeeWithdrawal is nonconsuming: the treasury CID is + -- unchanged, so the contractIds map is returned as-is. + pure contractIds + _ -> abort $ "E_UNKNOWN_FUNCTION: " <> functionName + + +-- | Single-use MCMS-approved authorization to withdraw fees from feeOwner. +-- Anyone can submit ExecuteFeeWithdrawal — security comes from the locked policy +-- (recipient, instrument, amount cap, expiry), not from restricting who calls it. +template FeeWithdrawalAuthorization + with + feeOwner : Party + recipient : Party + instrumentId : InstrumentId + maxAmount : Decimal + expiresAt : Time + where + signatory feeOwner + observer recipient + + -- | Execute the MCMS-approved withdrawal with live CIDs and disclosed context. + choice ExecuteFeeWithdrawal : TransferInstructionResult + with + submitter : Party + transferFactoryCid : ContractId TransferFactory + inputHoldingCids : [ContractId Holding] + amount : Decimal + extraArgs : ExtraArgs + requestedAt : Time + controller submitter + do + now <- getTime + assertMsg "feetreasury: requestedAt is in the future" (requestedAt <= now) + assertMsg "feetreasury: withdrawal authorization expired" (now < expiresAt) + assertMsg "feetreasury: amount exceeds authorized cap" (amount <= maxAmount) + assertMsg "feetreasury: amount must be positive" (amount > 0.0) + assertMsg "feetreasury: input holdings must not be empty" (not (null inputHoldingCids)) + + result <- exercise transferFactoryCid TransferFactory_Transfer with + expectedAdmin = instrumentId.admin + transfer = Transfer with + sender = feeOwner + receiver = recipient + amount + instrumentId + requestedAt + executeBefore = expiresAt + inputHoldingCids + meta = emptyMetadata + extraArgs + + case result.output of + TransferInstructionResult_Failed -> + abort "feetreasury: token transfer failed" + _ -> pure result + + -- | Cancel an unused authorization (e.g. after expiry or policy change). + choice WithdrawAuthorization : () + controller feeOwner + do pure () diff --git a/contracts/ccip/fee-treasury/daml/CCIP/FeeTreasuryCodecGen.daml b/contracts/ccip/fee-treasury/daml/CCIP/FeeTreasuryCodecGen.daml new file mode 100644 index 000000000..84df47a21 --- /dev/null +++ b/contracts/ccip/fee-treasury/daml/CCIP/FeeTreasuryCodecGen.daml @@ -0,0 +1,41 @@ +-- AUTO-GENERATED by go-daml codegen. DO NOT EDIT. +module CCIP.FeeTreasuryCodecGen where + +import DA.Crypto.Text (BytesHex) +import DA.Optional (fromSome) + +import CCIP.FeeTreasuryTypes (AuthorizeFeeWithdrawalParams(..)) + +import MCMS.Codec ( + decodeDecimalAt, + decodeInt64At, + decodePartyAt, + encodeDecimal, + encodeInt64, + encodeParty + ) + +import CCIP.CodecV2 (decodeInstrumentId, encodeInstrumentId) + + +-- =============================================== +-- | AuthorizeFeeWithdrawalParams +-- =============================================== + +encodeAuthorizeFeeWithdrawalParams : AuthorizeFeeWithdrawalParams -> BytesHex +encodeAuthorizeFeeWithdrawalParams params = + encodeParty params.recipient + <> encodeInstrumentId params.instrumentId + <> encodeDecimal params.maxAmount + <> fromSome (encodeInt64 params.validitySecs) + +decodeAuthorizeFeeWithdrawalParamsAt : BytesHex -> Int -> Optional (AuthorizeFeeWithdrawalParams, Int) +decodeAuthorizeFeeWithdrawalParamsAt encoded offset = do + (recipient, offset) <- decodePartyAt encoded offset + (instrumentId, offset) <- decodeInstrumentId encoded offset + (maxAmount, offset) <- decodeDecimalAt encoded offset + (validitySecs, offset) <- decodeInt64At encoded offset + Some (AuthorizeFeeWithdrawalParams{..}, offset) + + + diff --git a/contracts/ccip/fee-treasury/daml/CCIP/FeeTreasuryTypes.daml b/contracts/ccip/fee-treasury/daml/CCIP/FeeTreasuryTypes.daml new file mode 100644 index 000000000..7def72bb8 --- /dev/null +++ b/contracts/ccip/fee-treasury/daml/CCIP/FeeTreasuryTypes.daml @@ -0,0 +1,17 @@ +-- | Parameter types for MCMSFeeTreasury MCMS entrypoint functions. +-- AUTO-GENERATED codecs are derived from this file by `make generate-daml-codecs`. +module CCIP.FeeTreasuryTypes where + +import Splice.Api.Token.HoldingV1 (InstrumentId) + +-- | Params for the "AuthorizeFeeWithdrawal" MCMS function. +-- All fields are scalars so they can be safely encoded in the signed merkle leaf. +-- ContractIds (holdings, factory, mining-round context) are NOT included here — +-- they must be supplied at execution time via ExecuteFeeWithdrawal choice args. +data AuthorizeFeeWithdrawalParams = AuthorizeFeeWithdrawalParams + with + recipient : Party + instrumentId : InstrumentId + maxAmount : Decimal + validitySecs : Int + deriving (Eq, Show) diff --git a/contracts/ccip/test/daml.yaml b/contracts/ccip/test/daml.yaml index ccba04f0c..faa9c13ec 100644 --- a/contracts/ccip/test/daml.yaml +++ b/contracts/ccip/test/daml.yaml @@ -35,6 +35,7 @@ data-dependencies: - ../sender/.daml/dist/ccip-sender-v2-2.0.0.dar - ../receiver/.daml/dist/ccip-receiver-v2-2.0.0.dar - ../factory/.daml/dist/ccip-factory-v2-2.0.0.dar + - ../fee-treasury/.daml/dist/ccip-fee-treasury-2.0.0.dar dependencies: - daml-prim - daml-stdlib diff --git a/contracts/ccip/test/daml/CCIP/FeeTreasuryTest/FeeTreasuryCodecTest.daml b/contracts/ccip/test/daml/CCIP/FeeTreasuryTest/FeeTreasuryCodecTest.daml new file mode 100644 index 000000000..12eb5caa4 --- /dev/null +++ b/contracts/ccip/test/daml/CCIP/FeeTreasuryTest/FeeTreasuryCodecTest.daml @@ -0,0 +1,33 @@ +module CCIP.FeeTreasuryTest.FeeTreasuryCodecTest where + +import Daml.Script +import DA.Assert ((===)) +import DA.Optional (fromSome) + +import Splice.Api.Token.HoldingV1 (InstrumentId(..)) + +import CCIP.FeeTreasuryTypes (AuthorizeFeeWithdrawalParams(..)) +import CCIP.FeeTreasuryCodecGen ( + encodeAuthorizeFeeWithdrawalParams, + decodeAuthorizeFeeWithdrawalParamsAt, + ) + +testAuthorizeFeeWithdrawalParamsRoundtrip : Script () +testAuthorizeFeeWithdrawalParamsRoundtrip = script do + recipient <- allocateParty "recipient" + admin <- allocateParty "admin" + + let instrumentId = InstrumentId admin "FeeToken" + params = AuthorizeFeeWithdrawalParams with + recipient + instrumentId + maxAmount = 1000.0 + validitySecs = 3600 + + encoded = encodeAuthorizeFeeWithdrawalParams params + decoded = fst (fromSome (decodeAuthorizeFeeWithdrawalParamsAt encoded 0)) + + decoded === params + + -- Trailing-bytes check: decoding at wrong offset returns None + decodeAuthorizeFeeWithdrawalParamsAt encoded 1 === None diff --git a/contracts/ccip/test/daml/CCIP/FeeTreasuryTest/FeeTreasuryMCMSTest.daml b/contracts/ccip/test/daml/CCIP/FeeTreasuryTest/FeeTreasuryMCMSTest.daml new file mode 100644 index 000000000..2efc4c3e8 --- /dev/null +++ b/contracts/ccip/test/daml/CCIP/FeeTreasuryTest/FeeTreasuryMCMSTest.daml @@ -0,0 +1,475 @@ +-- | MCMS entrypoint tests for MCMSFeeTreasury. +-- Tests the MCMSReceiver interface implementation and the two-phase +-- authorize-then-execute withdrawal flow using TestToken helpers. +module CCIP.FeeTreasuryTest.FeeTreasuryMCMSTest where + +import Daml.Script +import DA.Assert ((===)) +import DA.List (head) +import DA.Time (addRelTime, seconds) +import DA.Map qualified as Map +import DA.Optional (fromSome) +import DA.TextMap qualified as TextMap +import Splice.Api.Token.HoldingV1 (InstrumentId(..)) +import Splice.Api.Token.MetadataV1 (AnyValue(..), ChoiceContext(..), ExtraArgs(..), emptyChoiceContext, emptyMetadata) +import Splice.Api.Token.TransferInstructionV1 ( + TransferInstructionResult_Output(..), + TransferFactory, + ) + +import MCMS.MCMSReceiver (MCMSReceiver(..), MCMSReceiver_Entrypoint(..)) +import Chainlink.InstanceAddress qualified as RawInstanceAddress + +import CCIP.FeeTreasury ( + MCMSFeeTreasury(..), + FeeWithdrawalAuthorization(..), + ExecuteFeeWithdrawal(..), + WithdrawAuthorization(..), + ) +import CCIP.FeeTreasuryTypes (AuthorizeFeeWithdrawalParams(..)) +import CCIP.FeeTreasuryCodecGen (encodeAuthorizeFeeWithdrawalParams) + +import TestToken qualified + +-- | Run the authorize phase the way MCMS does in production: through the +-- MCMSReceiver_Entrypoint interface (codec decode + trailing-bytes + self-CID +-- lookup + dispatch), rather than exercising the AuthorizeFeeWithdrawal choice +-- directly. Returns the newly created authorization. +authorizeViaEntrypoint + : Party -> Text -> ContractId MCMSFeeTreasury + -> AuthorizeFeeWithdrawalParams -> Script (ContractId FeeWithdrawalAuthorization) +authorizeViaEntrypoint feeOwner instanceId treasuryCid params = do + let instAddr = RawInstanceAddress.makeText instanceId feeOwner + _ <- submit feeOwner do + exerciseCmd (toInterfaceContractId @MCMSReceiver treasuryCid) MCMSReceiver_Entrypoint with + functionName = "AuthorizeFeeWithdrawal" + operationData = encodeAuthorizeFeeWithdrawalParams params + contractIds = Map.fromList [(instAddr, coerceContractId treasuryCid)] + auths <- query @FeeWithdrawalAuthorization feeOwner + pure (fst (head auths)) + +-- ======================================================== +-- | MCMS Entrypoint - AuthorizeFeeWithdrawal | +-- ======================================================== + +testFeeTreasuryMCMSEntrypoint_AuthorizeFeeWithdrawal : Script () +testFeeTreasuryMCMSEntrypoint_AuthorizeFeeWithdrawal = script do + feeOwner <- allocateParty "fee_owner" + recipient <- allocateParty "recipient" + + let instanceId = "fee-treasury-test" + instrumentId = InstrumentId feeOwner "FeeToken" + instAddr = RawInstanceAddress.makeText instanceId feeOwner + + treasuryCid <- submit feeOwner do + createCmd MCMSFeeTreasury with + instanceId + feeOwner + mcmsController = feeOwner + + let params = AuthorizeFeeWithdrawalParams with + recipient + instrumentId + maxAmount = 500.0 + validitySecs = 3600 + encoded = encodeAuthorizeFeeWithdrawalParams params + + -- Call via MCMSReceiver_Entrypoint (as MCMS would in production) + updatedMap <- submit feeOwner do + exerciseCmd (toInterfaceContractId @MCMSReceiver treasuryCid) MCMSReceiver_Entrypoint with + functionName = "AuthorizeFeeWithdrawal" + operationData = encoded + contractIds = Map.fromList [(instAddr, coerceContractId treasuryCid)] + + -- AuthorizeFeeWithdrawal is nonconsuming: the entrypoint returns the map unchanged, + -- so the treasury CID still resolves to the (still-active) treasury. + let Some selfCidBack = Map.lookup instAddr updatedMap + Some _ <- queryContractId feeOwner (coerceContractId selfCidBack : ContractId MCMSFeeTreasury) + + auths <- query @FeeWithdrawalAuthorization feeOwner + length auths === 1 + let (_, auth) = head auths + auth.recipient === recipient + auth.instrumentId === instrumentId + auth.maxAmount === 500.0 + +-- ======================================================== +-- | ExecuteFeeWithdrawal: Completed (with preapproval) | +-- ======================================================== + +testFeeTreasury_ExecuteFeeWithdrawal_Completed : Script () +testFeeTreasury_ExecuteFeeWithdrawal_Completed = script do + feeOwner <- allocateParty "fee_owner" + recipient <- allocateParty "recipient" + anyParty <- allocateParty "any_party" -- proves anyone can submit + + let instrumentId = InstrumentId feeOwner "FeeToken" + + holdingCid <- submit (actAs feeOwner) do + createCmd TestToken.TestHolding with + owner = feeOwner; admin = feeOwner; instrumentId; amount = 300.0 + + factoryCid <- submit feeOwner do + createCmd TestToken.TestTransferFactory with admin = feeOwner + + -- Preapproval lets TestTransferFactory complete the transfer directly + preapprovalCid <- submit (actAs recipient <> actAs feeOwner) do + createCmd TestToken.TestTransferPreapproval with + admin = feeOwner; receiver = recipient + + treasuryCid <- submit feeOwner do + createCmd MCMSFeeTreasury with + instanceId = "fee-treasury-completed-test" + feeOwner; mcmsController = feeOwner + + authCid <- authorizeViaEntrypoint feeOwner "fee-treasury-completed-test" treasuryCid + AuthorizeFeeWithdrawalParams with + recipient; instrumentId; maxAmount = 300.0; validitySecs = 3600 + + now <- getTime + disclosedAuth <- fromSome <$> queryDisclosure feeOwner authCid + disclosedFactory <- fromSome <$> queryDisclosure feeOwner factoryCid + disclosedHolding <- fromSome <$> queryDisclosure feeOwner holdingCid + disclosedPreapproval <- fromSome <$> queryDisclosure feeOwner preapprovalCid + + -- Pass preapproval in context keyed by show(recipient) — TestTransferFactory uses + -- TextMap.lookup (show transfer.receiver) to find it (TestToken.daml:183) + let preapprovalContext = ChoiceContext with + values = TextMap.fromList [(show recipient, AV_ContractId (coerceContractId preapprovalCid))] + + -- anyParty submits with disclosed auth — proving open access (any party can trigger) + result <- submit + (actAs anyParty + <> disclose disclosedAuth + <> disclose disclosedFactory + <> disclose disclosedHolding + <> disclose disclosedPreapproval) do + exerciseCmd authCid ExecuteFeeWithdrawal with + submitter = anyParty + transferFactoryCid = toInterfaceContractId @TransferFactory factoryCid + inputHoldingCids = [toInterfaceContractId holdingCid] + amount = 200.0 + extraArgs = ExtraArgs with + context = preapprovalContext + meta = emptyMetadata + requestedAt = now + + case result.output of + TransferInstructionResult_Completed receiverHoldings -> + length receiverHoldings === 1 + _ -> abort "Expected TransferInstructionResult_Completed" + +-- ======================================================== +-- | ExecuteFeeWithdrawal: Pending (no preapproval) | +-- ======================================================== + +testFeeTreasury_ExecuteFeeWithdrawal_Pending : Script () +testFeeTreasury_ExecuteFeeWithdrawal_Pending = script do + feeOwner <- allocateParty "fee_owner" + recipient <- allocateParty "recipient" + + let instrumentId = InstrumentId feeOwner "FeeToken" + + holdingCid <- submit (actAs feeOwner) do + createCmd TestToken.TestHolding with + owner = feeOwner; admin = feeOwner; instrumentId; amount = 100.0 + + factoryCid <- submit feeOwner do + createCmd TestToken.TestTransferFactory with admin = feeOwner + + treasuryCid <- submit feeOwner do + createCmd MCMSFeeTreasury with + instanceId = "fee-treasury-pending-test" + feeOwner; mcmsController = feeOwner + + authCid <- authorizeViaEntrypoint feeOwner "fee-treasury-pending-test" treasuryCid + AuthorizeFeeWithdrawalParams with + recipient; instrumentId; maxAmount = 100.0; validitySecs = 3600 + + now <- getTime + disclosedFactory <- fromSome <$> queryDisclosure feeOwner factoryCid + disclosedHolding <- fromSome <$> queryDisclosure feeOwner holdingCid + + result <- submit (actAs feeOwner <> disclose disclosedFactory <> disclose disclosedHolding) do + exerciseCmd authCid ExecuteFeeWithdrawal with + submitter = feeOwner + transferFactoryCid = toInterfaceContractId @TransferFactory factoryCid + inputHoldingCids = [toInterfaceContractId holdingCid] + amount = 50.0 + extraArgs = ExtraArgs emptyChoiceContext emptyMetadata + requestedAt = now + + case result.output of + TransferInstructionResult_Pending _ -> pure () + _ -> abort "Expected TransferInstructionResult_Pending" + +-- ======================================================== +-- | Negative: amount exceeds cap | +-- ======================================================== + +testFeeTreasury_ExecuteFeeWithdrawal_AmountExceedsCap : Script () +testFeeTreasury_ExecuteFeeWithdrawal_AmountExceedsCap = script do + feeOwner <- allocateParty "fee_owner" + recipient <- allocateParty "recipient" + + let instrumentId = InstrumentId feeOwner "FeeToken" + + holdingCid <- submit (actAs feeOwner) do + createCmd TestToken.TestHolding with + owner = feeOwner; admin = feeOwner; instrumentId; amount = 200.0 + + factoryCid <- submit feeOwner do + createCmd TestToken.TestTransferFactory with admin = feeOwner + + treasuryCid <- submit feeOwner do + createCmd MCMSFeeTreasury with + instanceId = "fee-treasury-cap-test" + feeOwner; mcmsController = feeOwner + + authCid <- authorizeViaEntrypoint feeOwner "fee-treasury-cap-test" treasuryCid + AuthorizeFeeWithdrawalParams with + recipient; instrumentId; maxAmount = 100.0; validitySecs = 3600 + + now <- getTime + submitMustFail feeOwner do + exerciseCmd authCid ExecuteFeeWithdrawal with + submitter = feeOwner + transferFactoryCid = toInterfaceContractId @TransferFactory factoryCid + inputHoldingCids = [toInterfaceContractId holdingCid] + amount = 150.0 + extraArgs = ExtraArgs emptyChoiceContext emptyMetadata + requestedAt = now + +-- ======================================================== +-- | Negative: expired authorization | +-- ======================================================== + +testFeeTreasury_ExecuteFeeWithdrawal_Expired : Script () +testFeeTreasury_ExecuteFeeWithdrawal_Expired = script do + feeOwner <- allocateParty "fee_owner" + recipient <- allocateParty "recipient" + + let instrumentId = InstrumentId feeOwner "FeeToken" + + holdingCid <- submit (actAs feeOwner) do + createCmd TestToken.TestHolding with + owner = feeOwner; admin = feeOwner; instrumentId; amount = 100.0 + + factoryCid <- submit feeOwner do + createCmd TestToken.TestTransferFactory with admin = feeOwner + + treasuryCid <- submit feeOwner do + createCmd MCMSFeeTreasury with + instanceId = "fee-treasury-expired-test" + feeOwner; mcmsController = feeOwner + + -- validitySecs = 1: expires 1 second after authorize + authCid <- authorizeViaEntrypoint feeOwner "fee-treasury-expired-test" treasuryCid + AuthorizeFeeWithdrawalParams with + recipient; instrumentId; maxAmount = 100.0; validitySecs = 1 + + -- Advance time past expiry + passTime (seconds 2) + + now <- getTime + submitMustFail feeOwner do + exerciseCmd authCid ExecuteFeeWithdrawal with + submitter = feeOwner + transferFactoryCid = toInterfaceContractId @TransferFactory factoryCid + inputHoldingCids = [toInterfaceContractId holdingCid] + amount = 50.0 + extraArgs = ExtraArgs emptyChoiceContext emptyMetadata + requestedAt = now + +-- ======================================================== +-- | Negative: unknown function name | +-- ======================================================== + +testFeeTreasuryMCMSEntrypoint_UnknownFunction : Script () +testFeeTreasuryMCMSEntrypoint_UnknownFunction = script do + feeOwner <- allocateParty "fee_owner" + + let instanceId = "fee-treasury-unknown-fn" + instAddr = RawInstanceAddress.makeText instanceId feeOwner + + treasuryCid <- submit feeOwner do + createCmd MCMSFeeTreasury with + instanceId; feeOwner; mcmsController = feeOwner + + submitMustFail feeOwner do + exerciseCmd (toInterfaceContractId @MCMSReceiver treasuryCid) MCMSReceiver_Entrypoint with + functionName = "InvalidFunction" + operationData = "" + contractIds = Map.fromList [(instAddr, coerceContractId treasuryCid)] + +-- ======================================================== +-- | WithdrawAuthorization (cancel) | +-- ======================================================== + +testFeeTreasury_WithdrawAuthorization : Script () +testFeeTreasury_WithdrawAuthorization = script do + feeOwner <- allocateParty "fee_owner" + recipient <- allocateParty "recipient" + + let instrumentId = InstrumentId feeOwner "FeeToken" + + treasuryCid <- submit feeOwner do + createCmd MCMSFeeTreasury with + instanceId = "fee-treasury-cancel-test" + feeOwner; mcmsController = feeOwner + + authCid <- authorizeViaEntrypoint feeOwner "fee-treasury-cancel-test" treasuryCid + AuthorizeFeeWithdrawalParams with + recipient; instrumentId; maxAmount = 100.0; validitySecs = 3600 + + submit feeOwner do + exerciseCmd authCid WithdrawAuthorization + + auths <- query @FeeWithdrawalAuthorization feeOwner + length auths === 0 + +-- ======================================================== +-- | Negative: amount not positive | +-- ======================================================== + +testFeeTreasury_ExecuteFeeWithdrawal_AmountNotPositive : Script () +testFeeTreasury_ExecuteFeeWithdrawal_AmountNotPositive = script do + feeOwner <- allocateParty "fee_owner" + recipient <- allocateParty "recipient" + + let instrumentId = InstrumentId feeOwner "FeeToken" + + holdingCid <- submit (actAs feeOwner) do + createCmd TestToken.TestHolding with + owner = feeOwner; admin = feeOwner; instrumentId; amount = 100.0 + + factoryCid <- submit feeOwner do + createCmd TestToken.TestTransferFactory with admin = feeOwner + + treasuryCid <- submit feeOwner do + createCmd MCMSFeeTreasury with + instanceId = "fee-treasury-nonpositive-test" + feeOwner; mcmsController = feeOwner + + authCid <- authorizeViaEntrypoint feeOwner "fee-treasury-nonpositive-test" treasuryCid + AuthorizeFeeWithdrawalParams with + recipient; instrumentId; maxAmount = 100.0; validitySecs = 3600 + + now <- getTime + submitMustFail feeOwner do + exerciseCmd authCid ExecuteFeeWithdrawal with + submitter = feeOwner + transferFactoryCid = toInterfaceContractId @TransferFactory factoryCid + inputHoldingCids = [toInterfaceContractId holdingCid] + amount = 0.0 + extraArgs = ExtraArgs emptyChoiceContext emptyMetadata + requestedAt = now + +-- ======================================================== +-- | Negative: empty holdings | +-- ======================================================== + +testFeeTreasury_ExecuteFeeWithdrawal_HoldingsEmpty : Script () +testFeeTreasury_ExecuteFeeWithdrawal_HoldingsEmpty = script do + feeOwner <- allocateParty "fee_owner" + recipient <- allocateParty "recipient" + + let instrumentId = InstrumentId feeOwner "FeeToken" + + factoryCid <- submit feeOwner do + createCmd TestToken.TestTransferFactory with admin = feeOwner + + treasuryCid <- submit feeOwner do + createCmd MCMSFeeTreasury with + instanceId = "fee-treasury-emptyholdings-test" + feeOwner; mcmsController = feeOwner + + authCid <- authorizeViaEntrypoint feeOwner "fee-treasury-emptyholdings-test" treasuryCid + AuthorizeFeeWithdrawalParams with + recipient; instrumentId; maxAmount = 100.0; validitySecs = 3600 + + now <- getTime + submitMustFail feeOwner do + exerciseCmd authCid ExecuteFeeWithdrawal with + submitter = feeOwner + transferFactoryCid = toInterfaceContractId @TransferFactory factoryCid + inputHoldingCids = [] + amount = 50.0 + extraArgs = ExtraArgs emptyChoiceContext emptyMetadata + requestedAt = now + +-- ======================================================== +-- | Negative: requestedAt in the future | +-- ======================================================== + +testFeeTreasury_ExecuteFeeWithdrawal_RequestedAtInFuture : Script () +testFeeTreasury_ExecuteFeeWithdrawal_RequestedAtInFuture = script do + feeOwner <- allocateParty "fee_owner" + recipient <- allocateParty "recipient" + + let instrumentId = InstrumentId feeOwner "FeeToken" + + holdingCid <- submit (actAs feeOwner) do + createCmd TestToken.TestHolding with + owner = feeOwner; admin = feeOwner; instrumentId; amount = 100.0 + + factoryCid <- submit feeOwner do + createCmd TestToken.TestTransferFactory with admin = feeOwner + + treasuryCid <- submit feeOwner do + createCmd MCMSFeeTreasury with + instanceId = "fee-treasury-futurereq-test" + feeOwner; mcmsController = feeOwner + + authCid <- authorizeViaEntrypoint feeOwner "fee-treasury-futurereq-test" treasuryCid + AuthorizeFeeWithdrawalParams with + recipient; instrumentId; maxAmount = 100.0; validitySecs = 3600 + + now <- getTime + submitMustFail feeOwner do + exerciseCmd authCid ExecuteFeeWithdrawal with + submitter = feeOwner + transferFactoryCid = toInterfaceContractId @TransferFactory factoryCid + inputHoldingCids = [toInterfaceContractId holdingCid] + amount = 50.0 + extraArgs = ExtraArgs emptyChoiceContext emptyMetadata + requestedAt = now `addRelTime` seconds 60 + +-- ======================================================== +-- | Negative: factory returns Failed | +-- ======================================================== + +testFeeTreasury_ExecuteFeeWithdrawal_TransferFailed : Script () +testFeeTreasury_ExecuteFeeWithdrawal_TransferFailed = script do + feeOwner <- allocateParty "fee_owner" + recipient <- allocateParty "recipient" + + let instrumentId = InstrumentId feeOwner "FeeToken" + + holdingCid <- submit (actAs feeOwner) do + createCmd TestToken.TestHolding with + owner = feeOwner; admin = feeOwner; instrumentId; amount = 100.0 + + -- FailingTransferFactory always returns TransferInstructionResult_Failed, + -- driving ExecuteFeeWithdrawal into its "token transfer failed" abort. + factoryCid <- submit feeOwner do + createCmd TestToken.FailingTransferFactory with admin = feeOwner + + treasuryCid <- submit feeOwner do + createCmd MCMSFeeTreasury with + instanceId = "fee-treasury-transferfailed-test" + feeOwner; mcmsController = feeOwner + + authCid <- authorizeViaEntrypoint feeOwner "fee-treasury-transferfailed-test" treasuryCid + AuthorizeFeeWithdrawalParams with + recipient; instrumentId; maxAmount = 100.0; validitySecs = 3600 + + now <- getTime + submitMustFail feeOwner do + exerciseCmd authCid ExecuteFeeWithdrawal with + submitter = feeOwner + transferFactoryCid = toInterfaceContractId @TransferFactory factoryCid + inputHoldingCids = [toInterfaceContractId holdingCid] + amount = 50.0 + extraArgs = ExtraArgs emptyChoiceContext emptyMetadata + requestedAt = now diff --git a/contracts/ccip/test/daml/TestToken.daml b/contracts/ccip/test/daml/TestToken.daml index 0556a42d2..bcf34fb2f 100644 --- a/contracts/ccip/test/daml/TestToken.daml +++ b/contracts/ccip/test/daml/TestToken.daml @@ -270,6 +270,27 @@ template BadTransferFactory transferFactory_publicFetchImpl _self _arg = pure (view (toInterface @Splice.Api.Token.TransferInstructionV1.TransferFactory this)) +-- | Transfer factory that always returns TransferInstructionResult_Failed. Used to +-- exercise the failure-handling branch of consumers that abort when a transfer fails. +template FailingTransferFactory + with + admin : Party + where + signatory admin + + interface instance Splice.Api.Token.TransferInstructionV1.TransferFactory for FailingTransferFactory where + view = Splice.Api.Token.TransferInstructionV1.TransferFactoryView with admin, meta = Splice.Api.Token.MetadataV1.emptyMetadata + + transferFactory_transferImpl _self arg = do + assertMsg "FailingTransferFactory: expectedAdmin mismatch" (arg.expectedAdmin == admin) + pure Splice.Api.Token.TransferInstructionV1.TransferInstructionResult with + senderChangeCids = [] + output = Splice.Api.Token.TransferInstructionV1.TransferInstructionResult_Failed + meta = Splice.Api.Token.MetadataV1.emptyMetadata + + transferFactory_publicFetchImpl _self _arg = + pure (view (toInterface @Splice.Api.Token.TransferInstructionV1.TransferFactory this)) + data BadBurnMintOutput = BadBurnMintOutput with owner : Party diff --git a/contracts/cmd/bindings/main.go b/contracts/cmd/bindings/main.go index 1df36171a..c795b5016 100644 --- a/contracts/cmd/bindings/main.go +++ b/contracts/cmd/bindings/main.go @@ -101,6 +101,7 @@ func main() { DecimalFields: map[string]bool{ "usdPerUnitGas": true, // FeeQuoter gas price updates "usdPerToken": true, // FeeQuoter token price updates + "maxAmount": true, // MCMSFeeTreasury AuthorizeFeeWithdrawal cap }, VariantTagByteMap: map[string]map[string]byte{ "CCIP.LockReleaseTokenPoolV2Types.TransferTimeout": { diff --git a/contracts/contracts.go b/contracts/contracts.go index 6eefa8292..497f711c4 100644 --- a/contracts/contracts.go +++ b/contracts/contracts.go @@ -47,6 +47,7 @@ const ( CCIPLockReleaseTokenPoolV2 = Package("ccip-lock-release-token-pool-v2") CCIPBurnMintTokenPoolV2 = Package("ccip-burn-mint-token-pool-v2") CCIPFactoryV2 = Package("ccip-factory-v2") + CCIPFeeTreasury = Package("ccip-fee-treasury") // CCIP - Legacy @@ -147,9 +148,10 @@ var Versions map[Package][]string = map[Package][]string{ SpliceApiTokenMetadataV1: []string{"1.0.0"}, SpliceApiTokenTransferInstructionV1: []string{"1.0.0"}, - Coin: []string{DevVersion}, - GlobalConfig: []string{DevVersion}, - MCMSTest: []string{DevVersion}, + Coin: []string{DevVersion}, + GlobalConfig: []string{DevVersion}, + MCMSTest: []string{DevVersion}, + CCIPFeeTreasury: []string{DevVersion}, } // versionDir maps a DAR version string to its artifact subdirectory. @@ -256,6 +258,7 @@ var BindingsOutputDirs = map[Package][]string{ CCIPLockReleaseTokenPoolV2: []string{"ccip", "lockreleasetokenpool"}, CCIPBurnMintTokenPoolV2: []string{"ccip", "burnminttokenpool"}, CCIPFactoryV2: []string{"ccip", "factory"}, + CCIPFeeTreasury: []string{"ccip", "feetreasury"}, SpliceApiTokenBurnMintV1: []string{"splice", "splice_api_token_burn_mint_v1"}, SpliceApiTokenHoldingV1: []string{"splice", "splice_api_token_holding_v1"}, diff --git a/contracts/dars/dev/ccip-fee-treasury-dev.dar b/contracts/dars/dev/ccip-fee-treasury-dev.dar new file mode 100644 index 000000000..f9968092f Binary files /dev/null and b/contracts/dars/dev/ccip-fee-treasury-dev.dar differ diff --git a/contracts/dars/dev/ccip-test-v2-dev.dar b/contracts/dars/dev/ccip-test-v2-dev.dar index e825c67d4..ed5b43c8b 100644 Binary files a/contracts/dars/dev/ccip-test-v2-dev.dar and b/contracts/dars/dev/ccip-test-v2-dev.dar differ diff --git a/contracts/multi-package.yaml b/contracts/multi-package.yaml index ba1504250..f85155d9f 100644 --- a/contracts/multi-package.yaml +++ b/contracts/multi-package.yaml @@ -26,6 +26,7 @@ packages: - ccip/pools/burn-mint-token-pool - ccip/rate-limiter - ccip/factory + - ccip/fee-treasury - ccip/test - ccip/tickets - ccip/utils diff --git a/integration-tests/mcms/fee_treasury_integration_test.go b/integration-tests/mcms/fee_treasury_integration_test.go new file mode 100644 index 000000000..9bff1040e --- /dev/null +++ b/integration-tests/mcms/fee_treasury_integration_test.go @@ -0,0 +1,310 @@ +package tests + +import ( + "fmt" + "math/big" + "testing" + "time" + + apiv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton" + "github.com/smartcontractkit/go-daml/pkg/service/ledger" + "github.com/smartcontractkit/go-daml/pkg/types" + + "github.com/smartcontractkit/chainlink-canton/bindings" + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/feetreasury" + mcmsApi "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/mcms/api" + mcmsCore "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/mcms/core" + splice "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1" + metadatav1 "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_metadata_v1" + transferv1 "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_transfer_instruction_v1" + "github.com/smartcontractkit/chainlink-canton/contracts" + "github.com/smartcontractkit/chainlink-canton/testhelpers" +) + +// TestFeeTreasury_WithdrawFeesEndToEndViaMCMS exercises the full two-phase design end to end +// against a real Splice (Amulet) instrument on localnet: +// +// Phase 1 (MCMS-governed): the full MCMS batch (SetRoot -> ExecuteOp(ScheduleBatch) -> +// ExecuteScheduledBatch -> processCallsLoopInternal -> mcmsEntrypoint "AuthorizeFeeWithdrawal") +// creates a FeeWithdrawalAuthorization with the approved policy. +// Phase 2 (outside MCMS): anyone submits ExecuteFeeWithdrawal with live Amulet holdings, the real +// TransferFactory, and disclosed mining-round context, moving fees from feeOwner to the recipient. +func TestFeeTreasury_WithdrawFeesEndToEndViaMCMS(t *testing.T) { + t.Parallel() + + env := GetSharedFeeTreasuryEnvironment(t) + participant := env.Participant + mcmsEncoder := env.McmsEncoder + ccipOwner := env.CcipOwner // also the feeOwner / MCMS owner + cfg := env.Config + sortedSigners := env.SortedSigners + + // Recipient is hosted on a second participant so its received Amulet balance is readable. + recipientParticipant := env.RecipientParticipant + recipient := env.Recipient + + // Splice validator API clients for the real Amulet token standard. + scanProxyClient, tokenMetadataClient, transferInstructionClient, err := testhelpers.NewValidatorAPIClients(participant) + require.NoError(t, err) + + registryAdmin, err := testhelpers.GetRegistryAdmin(t.Context(), tokenMetadataClient) + require.NoError(t, err) + + // Amulet is the native Splice fee instrument; its admin is the DSO/registry admin. + instrumentID := splice.InstrumentId{Admin: types.PARTY(registryAdmin), Id: types.TEXT("Amulet")} + + chainID := int64(1) + baseMcmsID := "mcms-feetreasury-" + uuid.New().String()[:8] + mcmsInstanceAddr := fmt.Sprintf("%s@%s", baseMcmsID, ccipOwner) + mcmsCid := createMCMSMultiRole(t, participant, ccipOwner, chainID, baseMcmsID, cfg, 0, nil) + + feeInstanceID := "fee-treasury-itest-" + uuid.New().String()[:8] + feeInstanceAddr := fmt.Sprintf("%s@%s", feeInstanceID, ccipOwner) + treasuryCid := createMCMSFeeTreasury(t, participant, ccipOwner, feeInstanceID) + + // Give the feeOwner Amulet fee holdings to later withdraw. + feeHoldingCid, err := testhelpers.MintAMT(t.Context(), participant, tokenMetadataClient, transferInstructionClient, scanProxyClient, ccipOwner, "10000.0") + require.NoError(t, err) + + const withdrawAmount = "100.0" + + // ---- Phase 1: authorize via the full MCMS batch ---- + feeContract := feetreasury.NewContract(fmt.Sprintf("#%s", feetreasury.PackageName), "CCIP.FeeTreasury", "MCMSFeeTreasury") + encoded, err := feeContract.Encoder().AuthorizeFeeWithdrawalParams(feetreasury.AuthorizeFeeWithdrawalParams{ + Recipient: types.PARTY(recipient), + InstrumentId: instrumentID, + MaxAmount: types.NUMERIC("500"), + ValiditySecs: types.INT64(3600), + }) + require.NoError(t, err) + + calls := []mcmsApi.TimelockCall{{ + TargetInstanceAddress: types.TEXT(feeInstanceAddr), + FunctionName: types.TEXT("AuthorizeFeeWithdrawal"), + OperationData: types.TEXT(encoded.OperationData), + }} + salt := uuid.New().String()[:8] + + scheduleChoice := MustEncodeScheduleBatch(t, mcmsEncoder, mcmsApi.ScheduleBatchParams{ + Calls: calls, + Predecessor: types.TEXT(ZeroHash), + Salt: types.TEXT(salt), + DelaySecs: types.INT64(0), + }) + + proposerMultisigID := MakeMcmsId(mcmsInstanceAddr, MCMSRoleProposer) + proposal := NewMCMSProposal(int(chainID), proposerMultisigID, 0, false). + AddOperation(mcmsInstanceAddr, scheduleChoice.Choice, scheduleChoice.OperationData). + Build() + + validUntil := time.Now().Add(1 * time.Hour) + signatures, err := proposal.Sign(validUntil, sortedSigners[:2]) + require.NoError(t, err) + + mcmsCid = setRootWithRole(t, participant, ccipOwner, mcmsCid, "Proposer", proposal, validUntil, signatures) + + opID := HashTimelockOpId(UnwrapTimelockCalls(calls), ZeroHash, salt) + opProof, err := proposal.GetOpProof(0) + require.NoError(t, err) + mcmsCid = scheduleBatch(t, participant, ccipOwner, mcmsCid, proposal.Operations[0], opProof) + + executeScheduledFeeWithdrawal(t, participant, ccipOwner, mcmsCid, opID, calls, salt, map[string]string{ + feeInstanceAddr: treasuryCid, + }) + + authCid, auth := queryFeeWithdrawalAuthorization(t, participant, ccipOwner) + require.NotNil(t, auth, "FeeWithdrawalAuthorization should exist after MCMS execution") + require.Equal(t, recipient, string(auth.Recipient)) + require.Equal(t, instrumentID, auth.InstrumentId) + + // ---- Phase 2: execute the real withdrawal (outside MCMS) ---- + recipientBalanceBefore := holdingsBalance(t, recipientParticipant, &instrumentID, recipient) + + // Probe the registry for the real TransferFactory + disclosed mining-round context. + tf, err := testhelpers.GetTransferFactoryV2(t.Context(), transferInstructionClient, registryAdmin, transferv1.Transfer{ + Sender: types.PARTY(ccipOwner), + Receiver: types.PARTY(recipient), + Amount: types.NUMERIC(withdrawAmount), + InstrumentId: instrumentID, + RequestedAt: types.TIMESTAMP(time.Now().Add(-time.Hour)), + ExecuteBefore: types.TIMESTAMP(time.Now().Add(time.Hour)), + InputHoldingCids: []types.CONTRACT_ID{types.CONTRACT_ID(feeHoldingCid)}, + Meta: metadatav1.Metadata{Values: map[string]types.TEXT{}}, + }) + require.NoError(t, err) + + execRes := exerciseExecuteFeeWithdrawal(t, participant, ccipOwner, authCid, feeHoldingCid, withdrawAmount, tf) + + // Without a receiver preapproval the transfer yields a pending AmuletTransferInstruction + // (plus a change Amulet back to the sender); the recipient must accept it to receive funds. + var pendingTransferInstructionCid string + for _, event := range execRes.GetTransaction().GetEvents() { + if e, ok := event.GetEvent().(*apiv2.Event_Created); ok { + if e.Created.GetTemplateId().GetEntityName() == "AmuletTransferInstruction" { + pendingTransferInstructionCid = e.Created.GetContractId() + } + } + } + require.NotEmpty(t, pendingTransferInstructionCid, "expected a pending AmuletTransferInstruction") + + time.Sleep(500 * time.Millisecond) + require.NoError(t, testhelpers.AcceptPendingTransferInstruction(t.Context(), recipientParticipant, transferInstructionClient, recipient, pendingTransferInstructionCid)) + + recipientBalanceAfter := holdingsBalance(t, recipientParticipant, &instrumentID, recipient) + delta, _ := new(big.Float).SetRat(new(big.Rat).Sub(recipientBalanceAfter, recipientBalanceBefore)).Float64() + require.InDelta(t, 100.0, delta, 0.01, "recipient should receive the withdrawn fee amount") +} + +func holdingsBalance(t *testing.T, participant canton.Participant, instrument *splice.InstrumentId, owner string) *big.Rat { + t.Helper() + bal, err := testhelpers.GetHoldingsBalance(t.Context(), participant, instrument, testhelpers.WithHoldingOwner(owner)) + require.NoError(t, err) + + return bal +} + +func exerciseExecuteFeeWithdrawal( + t *testing.T, + participant canton.Participant, + submitter string, + authCid string, + feeHoldingCid string, + amount string, + tf *testhelpers.TransferFactoryV2, +) *apiv2.SubmitAndWaitForTransactionResponse { + t.Helper() + + choiceContext, err := testhelpers.ChoiceContextFromData(tf.ChoiceContextData) + require.NoError(t, err) + + execArgs := feetreasury.ExecuteFeeWithdrawal{ + Submitter: types.PARTY(submitter), + TransferFactoryCid: types.CONTRACT_ID(tf.FactoryID), + InputHoldingCids: []types.CONTRACT_ID{types.CONTRACT_ID(feeHoldingCid)}, + Amount: types.NUMERIC(amount), + ExtraArgs: metadatav1.ExtraArgs{ + Context: metadatav1.ChoiceContext{Values: testhelpers.ExtractChoiceContextValues(choiceContext)}, + Meta: metadatav1.Metadata{Values: map[string]types.TEXT{}}, + }, + RequestedAt: types.TIMESTAMP(time.Now().Add(-time.Minute)), + } + + res, err := participant.LedgerServices.Command.SubmitAndWaitForTransaction(t.Context(), &apiv2.SubmitAndWaitForTransactionRequest{ + Commands: &apiv2.Commands{ + CommandId: uuid.NewString(), + Commands: []*apiv2.Command{{ + Command: &apiv2.Command_Exercise{ + Exercise: &apiv2.ExerciseCommand{ + TemplateId: contracts.IdentifierFromBinding(feetreasury.FeeWithdrawalAuthorization{}), + ContractId: authCid, + Choice: "ExecuteFeeWithdrawal", + ChoiceArgument: ledger.MapToValue(execArgs), + }, + }, + }}, + ActAs: []string{submitter}, + DisclosedContracts: tf.DisclosedContracts, + }, + }) + require.NoError(t, err) + + return res +} + +func createMCMSFeeTreasury( + t *testing.T, + participant canton.Participant, + owner string, + instanceID string, +) string { + t.Helper() + + treasury := feetreasury.MCMSFeeTreasury{ + InstanceId: types.TEXT(instanceID), + FeeOwner: types.PARTY(owner), + McmsController: types.PARTY(owner), + } + + res, err := participant.LedgerServices.Command.SubmitAndWaitForTransaction(t.Context(), &apiv2.SubmitAndWaitForTransactionRequest{ + Commands: &apiv2.Commands{ + CommandId: uuid.New().String(), + Commands: []*apiv2.Command{{ + Command: &apiv2.Command_Create{ + Create: &apiv2.CreateCommand{ + TemplateId: contracts.IdentifierFromBinding(feetreasury.MCMSFeeTreasury{}), + CreateArguments: ledger.ConvertToRecord(treasury), + }, + }, + }}, + ActAs: []string{owner}, + }, + }) + require.NoError(t, err) + + return res.GetTransaction().GetEvents()[0].GetCreated().GetContractId() +} + +func executeScheduledFeeWithdrawal( + t *testing.T, + participant canton.Participant, + owner string, + mcmsCid string, + opID string, + calls []mcmsApi.TimelockCall, + salt string, + targetCids map[string]string, +) { + t.Helper() + + executeArgs := mcmsCore.ExecuteScheduledBatch{ + Submitter: types.PARTY(owner), + OpId: types.TEXT(opID), + Calls: calls, + Predecessor: types.TEXT(ZeroHash), + Salt: types.TEXT(salt), + TargetCids: toContractIDMap(targetCids), + } + + _, err := participant.LedgerServices.Command.SubmitAndWaitForTransaction(t.Context(), &apiv2.SubmitAndWaitForTransactionRequest{ + Commands: &apiv2.Commands{ + CommandId: uuid.New().String(), + Commands: []*apiv2.Command{{ + Command: &apiv2.Command_Exercise{ + Exercise: &apiv2.ExerciseCommand{ + TemplateId: contracts.IdentifierFromBinding(mcmsCore.MCMS{}), + ContractId: mcmsCid, + Choice: "ExecuteScheduledBatch", + ChoiceArgument: ledger.MapToValue(executeArgs), + }, + }, + }}, + ActAs: []string{owner}, + }, + }) + require.NoError(t, err) +} + +func queryFeeWithdrawalAuthorization( + t *testing.T, + participant canton.Participant, + owner string, +) (string, *feetreasury.FeeWithdrawalAuthorization) { + t.Helper() + + activeContracts, err := testhelpers.ListActiveContractsByTemplateId(t.Context(), participant, contracts.IdentifierFromBinding(feetreasury.FeeWithdrawalAuthorization{})) + require.NoError(t, err) + + for _, c := range activeContracts { + auth, err := bindings.UnmarshalCreatedEvent[feetreasury.FeeWithdrawalAuthorization](c.GetCreatedEvent()) + require.NoError(t, err) + if string(auth.FeeOwner) == owner { + return c.GetCreatedEvent().GetContractId(), auth + } + } + + return "", nil +} diff --git a/integration-tests/mcms/shared_setup_test.go b/integration-tests/mcms/shared_setup_test.go index c8924ae93..758a5e156 100644 --- a/integration-tests/mcms/shared_setup_test.go +++ b/integration-tests/mcms/shared_setup_test.go @@ -66,8 +66,76 @@ var ( sharedCCIPMCMSTwoPartEnv *SharedCCIPMCMSTwoParticipantEnvironment sharedCCIPMCMSTwoPartEnvOnce sync.Once errSharedCCIPMCMSTwoPartEnv error + + sharedFeeTreasuryEnv *SharedFeeTreasuryEnvironment + sharedFeeTreasuryEnvOnce sync.Once + errSharedFeeTreasuryEnv error ) +// SharedFeeTreasuryEnvironment adds a second participant hosting the withdrawal recipient, +// so a real Amulet transfer can be received and its balance read as the recipient party. +type SharedFeeTreasuryEnvironment struct { + SharedCantonEnvironment + RecipientParticipant canton.Participant + Recipient string +} + +// GetSharedFeeTreasuryEnvironment initializes a 2-participant environment with the MCMS core +// and CCIPFeeTreasury packages on participant[0] (feeOwner / MCMS owner). participant[1] hosts +// the fee-withdrawal recipient; the Splice token-standard packages it needs are pre-deployed on localnet. +func GetSharedFeeTreasuryEnvironment(t *testing.T) *SharedFeeTreasuryEnvironment { + t.Helper() + + sharedFeeTreasuryEnvOnce.Do(func() { + env := testhelpers.NewTestEnvironment(t, testhelpers.WithNumberOfParticipants(2)) + participant := env.Chain.Participants[0] + recipientParticipant := env.Chain.Participants[1] + + darPackages := []contracts.Package{ + contracts.MCMSCore, + contracts.CCIPFeeTreasury, + } + + darBytes := make([][]byte, 0, len(darPackages)) + for _, pkg := range darPackages { + dar, err := contracts.GetDar(pkg, contracts.DevVersion) + if err != nil { + errSharedFeeTreasuryEnv = err + return + } + darBytes = append(darBytes, dar) + } + + // Both participants need the fee-treasury package: the recipient (participant[1]) is an + // observer on the FeeWithdrawalAuthorization, so its participant must resolve/host it. + if _, err := testhelpers.UploadDARstoMultipleParticipants(t.Context(), darBytes, participant, recipientParticipant); err != nil { + errSharedFeeTreasuryEnv = err + return + } + + signers := createSigners(t) + sortedSigners := SortSignersByAddress(signers) + + sharedFeeTreasuryEnv = &SharedFeeTreasuryEnvironment{ + SharedCantonEnvironment: SharedCantonEnvironment{ + Participant: participant, + McmsEncoder: NewMCMSEncoder(), + CcipOwner: participant.PartyID, + Signers: signers, + SortedSigners: sortedSigners, + Config: New2of3Config(signers), + }, + RecipientParticipant: recipientParticipant, + Recipient: recipientParticipant.PartyID, + } + }) + + require.NoError(t, errSharedFeeTreasuryEnv, "failed to initialize fee treasury environment") + require.NotNil(t, sharedFeeTreasuryEnv, "fee treasury environment is nil") + + return sharedFeeTreasuryEnv +} + // GetSharedEnvironment initializes the shared test environment once and returns it. // This uses sync.Once to ensure thread-safe initialization even when tests run in parallel. // The environment includes a participant connection, uploaded MCMS DAR, and pre-configured signers.