Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
693 changes: 693 additions & 0 deletions contracts/bindings/generated/ccip/feetreasury/feetreasury.go

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions contracts/ccip/fee-treasury/daml.yaml
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: 1.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
172 changes: 172 additions & 0 deletions contracts/ccip/fee-treasury/daml/CCIP/FeeTreasury.daml
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
-- 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, WithdrawAuthorization, or
-- CleanupExpiredAuthorization.
nonconsuming choice AuthorizeFeeWithdrawal : ContractId FeeWithdrawalAuthorization
with
params : AuthorizeFeeWithdrawalParams
controller feeOwner
do
now <- getTime
let expiresAt = now `addRelTime` seconds params.validitySecs
create FeeWithdrawalAuthorization with
instanceId = params.authorizationId
feeOwner
mcmsController
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.
-- instanceId makes this authorization independently addressable by MCMS for cancellation.
template FeeWithdrawalAuthorization
with
instanceId : Text
feeOwner : Party
mcmsController : Party
recipient : Party
instrumentId : InstrumentId
maxAmount : Decimal
expiresAt : Time
where
signatory feeOwner
observer recipient, mcmsController

ensure assertValidInstanceId instanceId

-- | 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feeOwner == ccipOwner, right? In this case this would need an MCMSEntrypoint as well, in order to be able to call it via MCMS

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

@JohnChangUK JohnChangUK Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

  • Added FeeWithdrawalAuthorization now has a signed authorizationId and implements MCMSReceiver, so MCMS can target and cancel a specific authorization through WithdrawAuthorization
  • Added CleanupExpiredAuthorization, which any disclosed submitter can exercise once now >= expiresAt


-- | Archive an expired authorization without requiring feeOwner authority.
choice CleanupExpiredAuthorization : ()
with
submitter : Party
controller submitter
do
now <- getTime
assertMsg "feetreasury: withdrawal authorization not expired" (now >= expiresAt)

interface instance MCMSReceiver for FeeWithdrawalAuthorization 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 FeeWithdrawalAuthorization)

case functionName of
"WithdrawAuthorization" -> do
assertMsg "E_TRAILING_BYTES: WithdrawAuthorization" (byteCount operationData == 0)
_ <- exercise selfCid WithdrawAuthorization
pure (Map.delete instAddr contractIds)
_ -> abort $ "E_UNKNOWN_FUNCTION: " <> functionName
45 changes: 45 additions & 0 deletions contracts/ccip/fee-treasury/daml/CCIP/FeeTreasuryCodecGen.daml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
-- 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,
decodeTextAt,
encodeDecimal,
encodeInt64,
encodeParty,
encodeText
)

import CCIP.CodecV2 (decodeInstrumentId, encodeInstrumentId)


-- ===============================================
-- | AuthorizeFeeWithdrawalParams
-- ===============================================

encodeAuthorizeFeeWithdrawalParams : AuthorizeFeeWithdrawalParams -> BytesHex
encodeAuthorizeFeeWithdrawalParams params =
encodeText params.authorizationId
<> encodeParty params.recipient
<> encodeInstrumentId params.instrumentId
<> encodeDecimal params.maxAmount
<> fromSome (encodeInt64 params.validitySecs)

decodeAuthorizeFeeWithdrawalParamsAt : BytesHex -> Int -> Optional (AuthorizeFeeWithdrawalParams, Int)
decodeAuthorizeFeeWithdrawalParamsAt encoded offset = do
(authorizationId, offset) <- decodeTextAt encoded offset
(recipient, offset) <- decodePartyAt encoded offset
(instrumentId, offset) <- decodeInstrumentId encoded offset
(maxAmount, offset) <- decodeDecimalAt encoded offset
(validitySecs, offset) <- decodeInt64At encoded offset
Some (AuthorizeFeeWithdrawalParams{..}, offset)



20 changes: 20 additions & 0 deletions contracts/ccip/fee-treasury/daml/CCIP/FeeTreasuryTypes.daml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- | 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.
-- authorizationId becomes the authorization contract's stable MCMS instanceId,
-- allowing a later governed operation to cancel that specific authorization.
-- 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
authorizationId : Text
recipient : Party
instrumentId : InstrumentId
maxAmount : Decimal
validitySecs : Int
deriving (Eq, Show)
1 change: 1 addition & 0 deletions contracts/ccip/test/daml.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ data-dependencies:
- ../sender/.daml/dist/ccip-sender-v2-2.1.0.dar
- ../receiver/.daml/dist/ccip-receiver-v2-2.1.0.dar
- ../factory/.daml/dist/ccip-factory-v2-2.1.1.dar
- ../fee-treasury/.daml/dist/ccip-fee-treasury-1.0.0.dar
dependencies:
- daml-prim
- daml-stdlib
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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
authorizationId = "fee-withdrawal-codec-test"
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
Loading
Loading