-
Notifications
You must be signed in to change notification settings - Fork 3
feat: MCMS Treasury helper contract for fees #786
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 () | ||
|
Comment on lines
+142
to
+145
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lets either update this or add another choice that anyone can call after the authorization has expired. Kind of like a cleanup function callable by anyone to not leave expired authorizations hanging around.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
|
||
|
|
||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lets start on
1.0.0, this also implicitly makes it clearer that this is separate from the other CCIP contracts