From 323bbe71cca94e1ac354be7c9ab7e1d99eafdcb9 Mon Sep 17 00:00:00 2001 From: JohnChangUK Date: Tue, 30 Jun 2026 23:18:29 +0100 Subject: [PATCH] deploy: LINK BurnMint token pools + shared instrument IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add shared LINK/Amulet instrument IDs and wire real BurnMint token pool deployment, LinkRegistry burn/mint routing, and EDS burn_mint_factory config so Canton→EVM LINK lanes can be provisioned. --- .../adapters/configure_lanes_token_prices.go | 5 +- .../deploy_chain_contracts_adapter.go | 6 + deployment/cciptokens/instruments.go | 7 + .../operations/ccip/factory/factory_test.go | 179 ++++++++++++++++++ .../token_admin_registry_test.go | 51 +++++ .../operations/linkregistry/linkregistry.go | 21 +- .../linkregistry/linkregistry_test.go | 22 +++ deployment/operations/services/eds/config.go | 23 +++ deployment/sequences/token_pools.go | 6 +- 9 files changed, 312 insertions(+), 8 deletions(-) create mode 100644 deployment/cciptokens/instruments.go create mode 100644 deployment/operations/ccip/factory/factory_test.go create mode 100644 deployment/operations/ccip/token_admin_registry/token_admin_registry_test.go create mode 100644 deployment/operations/linkregistry/linkregistry_test.go diff --git a/deployment/adapters/configure_lanes_token_prices.go b/deployment/adapters/configure_lanes_token_prices.go index 5315e636d..cb51ef101 100644 --- a/deployment/adapters/configure_lanes_token_prices.go +++ b/deployment/adapters/configure_lanes_token_prices.go @@ -12,6 +12,7 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1" + "github.com/smartcontractkit/chainlink-canton/deployment/cciptokens" feequoterop "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/fee_quoter" ) @@ -28,8 +29,6 @@ import ( // Canton FeeQuoter stores usdPerToken as DAML Decimal (see FeeQuoter.daml tests: 20.0 = $20/LINK). const CantonRemoteTokenPricesFamilyExtraKey = "cantonRemoteTokenPrices" -const defaultLinkTokenInstrumentID = "link-token" - // defaultLinkUsdPerTokenDollars is the nominal LINK/USD spot used when FamilyExtras omit a price. const defaultLinkUsdPerTokenDollars int64 = 10 @@ -51,7 +50,7 @@ func ResolveTokenPricesForRemoteDest( } prices := map[string]*big.Int{ - fmt.Sprintf("%s:%s", ccipOwner, defaultLinkTokenInstrumentID): usdPerTokenToScaled(defaultLinkUsdPerTokenDollars), + fmt.Sprintf("%s:%s", ccipOwner, cciptokens.LinkTokenInstrumentID): usdPerTokenToScaled(defaultLinkUsdPerTokenDollars), } if nativeInstrument != nil && nativeInstrument.Admin != "" && nativeInstrument.Id != "" { key := instrumentPriceKey(nativeInstrument.Admin, nativeInstrument.Id) diff --git a/deployment/adapters/deploy_chain_contracts_adapter.go b/deployment/adapters/deploy_chain_contracts_adapter.go index ef0f56468..3cb25b787 100644 --- a/deployment/adapters/deploy_chain_contracts_adapter.go +++ b/deployment/adapters/deploy_chain_contracts_adapter.go @@ -22,6 +22,8 @@ import ( "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/committeeverifier" "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/core" "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/executor" + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1" + "github.com/smartcontractkit/chainlink-canton/deployment/cciptokens" "github.com/smartcontractkit/chainlink-canton/deployment/sequences" dsutils "github.com/smartcontractkit/chainlink-canton/deployment/utils/datastore" ) @@ -130,6 +132,10 @@ func DeployCantonChainContracts(ctx context.Context, bundle cldf_ops.Bundle, cha FeeQuoterConfig: sequences.FeeQuoterParams{ Template: core.FeeQuoter{ PriceUpdaters: []types.PARTY{types.PARTY(ownerParty)}, + LinkTokenInstrumentId: splice_api_token_holding_v1.InstrumentId{ + Admin: types.PARTY(ownerParty), + Id: types.TEXT(cciptokens.LinkTokenInstrumentID), + }, }, }, RMNRemote: sequences.RMNRemoteParams{ diff --git a/deployment/cciptokens/instruments.go b/deployment/cciptokens/instruments.go new file mode 100644 index 000000000..623b64ff1 --- /dev/null +++ b/deployment/cciptokens/instruments.go @@ -0,0 +1,7 @@ +// Package cciptokens holds Canton instrument ID strings shared by devenv and deployment. +package cciptokens + +const ( + LinkTokenInstrumentID = "link-token" + AmuletInstrumentID = "Amulet" +) diff --git a/deployment/operations/ccip/factory/factory_test.go b/deployment/operations/ccip/factory/factory_test.go new file mode 100644 index 000000000..8c372612b --- /dev/null +++ b/deployment/operations/ccip/factory/factory_test.go @@ -0,0 +1,179 @@ +package factory + +import ( + "encoding/hex" + "testing" + + "github.com/smartcontractkit/go-daml/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/ccipcodec" + executorbindings "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/executor" + factorybindings "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/factory" + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/ratelimiter" +) + +// Byte-for-byte the DeployExecutor operationData from the prod_testnet proposal +// 1781126292119391259-...-from-factory_mcms_timelock_proposal_0.json, which was produced +// by the previous hand-rolled encoder: +// instanceId "executor-wwbhq" + owner + int64(10) + finality tag 0x00 + bool false. +const deployExecutorProposalHex = "0e6578656375746f722d77776268714f636369704f776e65723a3a3132323065333832663465353762303831356536626537333730303665333831653662376465343438653036626430333365636536646634393830313738373966353531000000000000000a0000" + +// Wire format reference: encodeRequestedFinality/decodeRequestedFinalityAt in CCIP/Codec.daml +// (finality variant = uint8 tag 0x00/0x01, or 0x02 + int64 block depth). +func TestEncodeDeployExecutor(t *testing.T) { + t.Parallel() + + blockDepth := types.INT64(12) + prefix := deployExecutorProposalHex[:len(deployExecutorProposalHex)-4] + + tests := []struct { + name string + finality ccipcodec.FinalityConfig + enabled types.BOOL + wantHex string + }{ + { + name: "wait for finality matches previously emitted proposal bytes", + finality: ccipcodec.FinalityConfig{WaitForFinality: &types.UNIT{}}, + enabled: false, + wantHex: deployExecutorProposalHex, + }, + { + name: "wait for safe", + finality: ccipcodec.FinalityConfig{WaitForSafe: &types.UNIT{}}, + enabled: true, + wantHex: prefix + "0101", + }, + { + name: "block depth carries int64 payload", + finality: ccipcodec.FinalityConfig{BlockDepth: &blockDepth}, + enabled: false, + wantHex: prefix + "02000000000000000c00", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + encoded, err := encodeDeployExecutor(factorybindings.DeployExecutor{ + Contract: executorbindings.Executor{ + InstanceId: "executor-wwbhq", + Owner: "ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551", + MaxCCVsPerMsg: 10, + DynamicConfig: executorbindings.DynamicConfig{ + AllowedFinalityConfig: tt.finality, + CcvAllowlistEnabled: tt.enabled, + }, + }, + }) + require.NoError(t, err) + assert.Equal(t, "DeployExecutor", encoded.Choice) + assert.Equal(t, tt.wantHex, encoded.OperationData) + }) + } +} + +// The generated params struct must round-trip its own encoding so MCMS proposal +// decoding (mcms/sdk/canton) can verify candidates by re-encoding. +func TestDeployExecutorParamsHexRoundTrip(t *testing.T) { + t.Parallel() + + original := factorybindings.DeployExecutorParams{ + InstanceId: "executor-wwbhq", + Owner: "ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551", + MaxCCVsPerMsg: 10, + AllowedFinalityConfig: ccipcodec.FinalityConfig{WaitForFinality: &types.UNIT{}}, + CcvAllowlistEnabled: false, + } + + encoded, err := encodeDeployExecutor(factorybindings.DeployExecutor{ + Contract: executorbindings.Executor{ + InstanceId: original.InstanceId, + Owner: original.Owner, + MaxCCVsPerMsg: original.MaxCCVsPerMsg, + DynamicConfig: executorbindings.DynamicConfig{ + AllowedFinalityConfig: original.AllowedFinalityConfig, + CcvAllowlistEnabled: original.CcvAllowlistEnabled, + }, + }, + }) + require.NoError(t, err) + + var decoded factorybindings.DeployExecutorParams + require.NoError(t, decoded.UnmarshalHex(encoded.OperationData)) + assert.Equal(t, original, decoded) +} + +// TestDeployRateLimiterParams_EnumEncoding verifies that RateLimitDirection and RateLimitMode +// are encoded as single ordinal bytes (0x00/0x01), matching the Daml MCMS codec wire format +// (decodeRateLimitDirectionAt / decodeRateLimitModeAt in contracts/ccip/factory/daml/CCIP/Factory.daml). +// Before the fix, go-daml encoded enums as length-prefixed constructor-name strings (e.g. 28 bytes +// for "RateLimitDirection_Outbound"), which the ledger's decodeUint8 step rejects. +func TestDeployRateLimiterParams_EnumEncoding(t *testing.T) { + t.Parallel() + + params := factorybindings.DeployRateLimiterParams{ + InstanceId: "rl-out-default", + PoolInstanceId: "pool-1", + PoolOwner: "owner::abc123", + RemoteChainSelector: "16015286601757825753", + Direction: ratelimiter.RateLimitDirectionRateLimitDirection_Outbound, + Mode: ratelimiter.RateLimitModeRateLimitMode_DefaultFinality, + IsEnabled: true, + Capacity: "1000", + Rate: "10", + } + + encoded, err := params.MarshalHex() + require.NoError(t, err) + + raw := []byte(encoded) + // Walk the encoded bytes to find Direction and Mode positions: + // TEXT instanceId: 1 + 14 = 15 + // TEXT poolInstanceId: 1 + 6 = 7 → offset 22 + // PARTY poolOwner: 1 + 13 = 14 → offset 36 + // NUMERIC selector: 1 + 20 = 21 → offset 57 + // Direction: offset 57 must be 0x00 (Outbound) + // Mode: offset 58 must be 0x00 (DefaultFinality) + // Byte layout: 1+14 instanceId | 1+6 poolInstanceId | 1+13 poolOwner | 1+20 chainSelector | dir | mode | ... + // Direction at offset 57 (15+7+14+21), Mode at offset 58. + require.Greater(t, len(raw), 59, "encoded too short") + assert.Equal(t, byte(0x00), raw[57], "Direction Outbound must be byte 0x00") + assert.Equal(t, byte(0x00), raw[58], "Mode DefaultFinality must be byte 0x00") + + // Inbound / CustomFinality → 0x01 + params.Direction = ratelimiter.RateLimitDirectionRateLimitDirection_Inbound + params.Mode = ratelimiter.RateLimitModeRateLimitMode_CustomFinality + encoded, err = params.MarshalHex() + require.NoError(t, err) + raw = []byte(encoded) + assert.Equal(t, byte(0x01), raw[57], "Direction Inbound must be byte 0x01") + assert.Equal(t, byte(0x01), raw[58], "Mode CustomFinality must be byte 0x01") +} + +func TestDeployRateLimiterParams_HexRoundTrip(t *testing.T) { + t.Parallel() + + original := factorybindings.DeployRateLimiterParams{ + InstanceId: "rl-inbound", + PoolInstanceId: "pool-2", + PoolOwner: "owner::def456", + RemoteChainSelector: "16015286601757825753", + Direction: ratelimiter.RateLimitDirectionRateLimitDirection_Inbound, + Mode: ratelimiter.RateLimitModeRateLimitMode_CustomFinality, + IsEnabled: false, + Capacity: "500", + Rate: "5", + } + + // MarshalHex returns raw bytes (not a hex string); hex-encode for UnmarshalHex (same as executor test). + encoded, err := original.MarshalHex() + require.NoError(t, err) + + var decoded factorybindings.DeployRateLimiterParams + require.NoError(t, decoded.UnmarshalHex(hex.EncodeToString([]byte(encoded)))) + assert.Equal(t, original, decoded) +} diff --git a/deployment/operations/ccip/token_admin_registry/token_admin_registry_test.go b/deployment/operations/ccip/token_admin_registry/token_admin_registry_test.go new file mode 100644 index 000000000..741322979 --- /dev/null +++ b/deployment/operations/ccip/token_admin_registry/token_admin_registry_test.go @@ -0,0 +1,51 @@ +package token_admin_registry + +import ( + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/go-daml/pkg/types" + + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/core" + splice "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1" +) + +func TestTokenAdminRegistryMCMSEncodingUsesParamsTypes(t *testing.T) { + t.Parallel() + + inst := splice.InstrumentId{ + Admin: types.PARTY("party::alice"), + Id: types.TEXT("TOKEN"), + } + admin := types.PARTY("party::bob") + pool := &core.PoolRegistration2{ + PoolOwner: types.PARTY("party::pool"), + PoolInstanceId: types.TEXT("pool-1"), + } + + proposeParams := core.ProposeAdminParams{InstrumentId: inst, NewAdmin: admin} + proposeEncoded, err := tarEncoder.ProposeAdministrator(proposeParams) + require.NoError(t, err) + proposeWire, err := proposeParams.MarshalHex() + require.NoError(t, err) + require.Equal(t, hex.EncodeToString([]byte(proposeWire)), proposeEncoded.OperationData) + require.Equal(t, "ProposeAdministrator", proposeEncoded.Choice) + + acceptParams := core.AcceptAdminParams{InstrumentId: inst} + acceptEncoded, err := tarEncoder.AcceptAdminRole(acceptParams) + require.NoError(t, err) + acceptWire, err := acceptParams.MarshalHex() + require.NoError(t, err) + require.Equal(t, hex.EncodeToString([]byte(acceptWire)), acceptEncoded.OperationData) + require.Equal(t, "AcceptAdminRole", acceptEncoded.Choice) + + setPoolParams := core.SetPoolParams{InstrumentId: inst, TokenPool: pool} + setPoolEncoded, err := tarEncoder.SetPoolParams(setPoolParams) + require.NoError(t, err) + setPoolWire, err := setPoolParams.MarshalHex() + require.NoError(t, err) + require.Equal(t, hex.EncodeToString([]byte(setPoolWire)), setPoolEncoded.OperationData) + require.Equal(t, "SetPool", setPoolEncoded.Choice) +} diff --git a/deployment/operations/linkregistry/linkregistry.go b/deployment/operations/linkregistry/linkregistry.go index 536c94bf7..3276efc20 100644 --- a/deployment/operations/linkregistry/linkregistry.go +++ b/deployment/operations/linkregistry/linkregistry.go @@ -2,6 +2,7 @@ package linkregistry import ( "github.com/Masterminds/semver/v3" + "github.com/smartcontractkit/go-daml/pkg/model" "github.com/smartcontractkit/chainlink-deployments-framework/deployment" @@ -34,7 +35,7 @@ var BurnMintFactory_BurnMint = contract.NewExercise(contract.ExerciseParams[spli Description: "Burns/Mints Holdings on Canton", ContractType: ContractType, Template: link.LinkRegistry{}, - Method: link.LinkRegistry{}.BurnMintFactoryBurnMint, + Method: exerciseLinkRegistryBurnMint, }) var TransferFactory_Transfer = contract.NewExercise(contract.ExerciseParams[splice_api_token_transfer_instruction_v1.TransferFactoryTransfer]{ @@ -43,7 +44,7 @@ var TransferFactory_Transfer = contract.NewExercise(contract.ExerciseParams[spli Description: "Transfers Holdings on Canton", ContractType: ContractType, Template: link.LinkRegistry{}, - Method: link.LinkRegistry{}.TransferFactoryTransfer, + Method: exerciseLinkRegistryTransfer, }) var CreateTransferPreapproval = contract.NewExercise(contract.ExerciseParams[link.CreateTransferPreapproval]{ @@ -54,3 +55,19 @@ var CreateTransferPreapproval = contract.NewExercise(contract.ExerciseParams[lin Template: link.LinkRegistry{}, Method: link.LinkRegistry{}.CreateTransferPreapproval, }) + +// Splice token interface choices must be exercised via the interface package on the ledger +// (see integration-tests/ccip/ccip_send_with_token_bnm_test.go), not Link.Token's local view. +func exerciseLinkRegistryBurnMint(contractID string, args splice_api_token_burn_mint_v1.BurnMintFactoryBurnMint) *model.ExerciseCommand { + cmd := link.LinkRegistry{}.BurnMintFactoryBurnMint(contractID, args) + cmd.TemplateID = splice_api_token_burn_mint_v1.IBurnMintFactoryInterfaceID() + + return cmd +} + +func exerciseLinkRegistryTransfer(contractID string, args splice_api_token_transfer_instruction_v1.TransferFactoryTransfer) *model.ExerciseCommand { + cmd := link.LinkRegistry{}.TransferFactoryTransfer(contractID, args) + cmd.TemplateID = splice_api_token_transfer_instruction_v1.ITransferFactoryInterfaceID() + + return cmd +} diff --git a/deployment/operations/linkregistry/linkregistry_test.go b/deployment/operations/linkregistry/linkregistry_test.go new file mode 100644 index 000000000..ecf2f56ca --- /dev/null +++ b/deployment/operations/linkregistry/linkregistry_test.go @@ -0,0 +1,22 @@ +package linkregistry + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_burn_mint_v1" + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_transfer_instruction_v1" +) + +func TestExerciseLinkRegistryUsesSpliceInterfaceTemplateIDs(t *testing.T) { + t.Parallel() + + burnMintCmd := exerciseLinkRegistryBurnMint("cid", splice_api_token_burn_mint_v1.BurnMintFactoryBurnMint{}) + require.Equal(t, splice_api_token_burn_mint_v1.IBurnMintFactoryInterfaceID(), burnMintCmd.TemplateID) + require.Equal(t, "BurnMintFactory_BurnMint", burnMintCmd.Choice) + + transferCmd := exerciseLinkRegistryTransfer("cid", splice_api_token_transfer_instruction_v1.TransferFactoryTransfer{}) + require.Equal(t, splice_api_token_transfer_instruction_v1.ITransferFactoryInterfaceID(), transferCmd.TemplateID) + require.Equal(t, "TransferFactory_Transfer", transferCmd.Choice) +} diff --git a/deployment/operations/services/eds/config.go b/deployment/operations/services/eds/config.go index c01607719..243d3debc 100644 --- a/deployment/operations/services/eds/config.go +++ b/deployment/operations/services/eds/config.go @@ -9,6 +9,7 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/link" "github.com/smartcontractkit/chainlink-canton/commonconfig" "github.com/smartcontractkit/chainlink-canton/contracts" "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/burn_mint_token_pool" @@ -22,6 +23,7 @@ import ( "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/per_party_router_factory" "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/rmn_remote" "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/token_admin_registry" + "github.com/smartcontractkit/chainlink-canton/deployment/operations/linkregistry" edsConfig "github.com/smartcontractkit/chainlink-canton/eds/config" ) @@ -183,6 +185,27 @@ var BuildConfig = operations.NewOperation( pool.TransferPreapproval = input.LockReleaseTransferPreapproval } } + // BnM send needs burn-mint-factory in pool choice context; LinkRegistry implements BurnMintFactory. + if tokenPoolType == edsConfig.TokenPoolTypeBurnMint { + linkRegistryRef, err := env.DataStore.Addresses().Get(datastore.NewAddressRefKey( + input.ChainSelector, + datastore.ContractType(linkregistry.ContractType), + linkregistry.Version, + "", + )) + if err != nil { + return GenerateEDSConfigOutput{}, fmt.Errorf("failed to get LinkRegistry for burn-mint pool EDS config: %w", err) + } + templateID := link.LinkRegistry{}.GetTemplateID() + partyID := participant.PartyID + linkRegistryAddress := contracts.HexToInstanceAddress(linkRegistryRef.Address) + pool.BurnMintFactory = &edsConfig.BurnMintFactory{ + Type: edsConfig.FactoryTypeAddress, + TemplateId: &templateID, + Party: &partyID, + InstanceAddress: &linkRegistryAddress, + } + } tokenPools[instanceAddress.Hex()] = pool } diff --git a/deployment/sequences/token_pools.go b/deployment/sequences/token_pools.go index 582916259..5fddb2e98 100644 --- a/deployment/sequences/token_pools.go +++ b/deployment/sequences/token_pools.go @@ -592,12 +592,12 @@ var DeployTokenPoolForToken = operations.NewSequence( return ccipsequences.OnChainOutput{}, fmt.Errorf("tokenRef.address is required") } tokenRef := datastore.AddressRef{ - Address: tokenAddress, - Type: datastore.ContractType("Token"), - // TODO: what should this be set to? + Address: tokenAddress, + Type: datastore.ContractType("Token"), Version: input.TokenPoolVersion, Qualifier: qualifier, ChainSelector: input.ChainSelector, + Labels: input.TokenRef.Labels, // preserve instrument-id:* for lane lookup (e.g. link-token) } if mcmsEnabled && len(proposalOutputs) > 0 {