Skip to content
Merged
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
14 changes: 14 additions & 0 deletions deployment/adapters/chain_family_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,15 @@ func (a *CantonChainFamilyAdapter) configureChainForLanes(
localCommitteeVerifiers := convertCommitteeVerifierConfigs(input.CommitteeVerifiers)
var out ccipseq.OnChainOutput

chain, ok := chains.CantonChains()[input.ChainSelector]
if !ok || len(chain.Participants) == 0 {
return ccipseq.OnChainOutput{}, fmt.Errorf("canton chain %d not found or has no participants", input.ChainSelector)
}
nativeInstrument, err := lookupNativeInstrumentID(b.GetContext(), chain.Participants[0])
if err != nil {
return ccipseq.OnChainOutput{}, fmt.Errorf("resolve Canton native fee token instrument: %w", err)
}

for remoteSelector, remoteCfg := range input.RemoteChains {
localExecutor, err := resolveContractRefByAddress(
ds,
Expand Down Expand Up @@ -199,6 +208,11 @@ func (a *CantonChainFamilyAdapter) configureChainForLanes(
if err != nil {
return out, err
}
tokenPrices, err := resolveTokenPricesForRemoteDest(ds, input, remoteSelector, &nativeInstrument)
if err != nil {
return out, fmt.Errorf("resolve token prices for remote chain %d: %w", remoteSelector, err)
}
remoteChain.TokenPrices = tokenPrices

out, err = ccipseq.RunAndMergeSequence(
b,
Expand Down
200 changes: 200 additions & 0 deletions deployment/adapters/configure_lanes_token_prices.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package adapters

import (
"fmt"
"maps"
"math"
"math/big"
"strconv"
"strings"

ccipadapters "github.com/smartcontractkit/chainlink-ccip/deployment/v2_0_0/adapters"
"github.com/smartcontractkit/chainlink-deployments-framework/datastore"

"github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1"
feequoterop "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/fee_quoter"
)

// CantonRemoteTokenPricesFamilyExtraKey is an optional ConfigureChainForLanesInput.FamilyExtras
// entry for per-remote fee-token USD prices pushed via FeeQuoter::UpdatePrices during lane configure.
//
// Shape (YAML-friendly):
//
// cantonRemoteTokenPrices:
// "<remoteChainSelector>":
// "ccipOwner::1220...:link-token": "10"
//
// Keys use "<adminParty>:<instrumentId>"; values are USD per whole token (e.g. "10" for $10 LINK).
// 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

// defaultNativeUsdPerTokenDollars is the nominal Amulet/USD spot used when FamilyExtras omit a price.
const defaultNativeUsdPerTokenDollars int64 = 1

// cantonUsdPerTokenScale is the internal fixed-point scale (USD * 1e8) before formatting to Decimal.
const cantonUsdPerTokenScale int64 = 100_000_000

func resolveTokenPricesForRemoteDest(
ds datastore.DataStore,
input ccipadapters.ConfigureChainForLanesInput,
remoteSelector uint64,
nativeInstrument *splice_api_token_holding_v1.InstrumentId,
) (map[string]*big.Int, error) {
ccipOwner, err := resolveCcipOwnerParty(ds, input.ChainSelector)
if err != nil {
return nil, err
}

prices := map[string]*big.Int{
fmt.Sprintf("%s:%s", ccipOwner, defaultLinkTokenInstrumentID): usdPerTokenToScaled(defaultLinkUsdPerTokenDollars),
}
if nativeInstrument != nil && nativeInstrument.Admin != "" && nativeInstrument.Id != "" {
key := instrumentPriceKey(nativeInstrument.Admin, nativeInstrument.Id)
prices[key] = usdPerTokenToScaled(defaultNativeUsdPerTokenDollars)
}

extras, err := tokenPricesFromFamilyExtras(input.FamilyExtras, remoteSelector)
if err != nil {
return nil, err
}
maps.Copy(prices, extras)

return prices, nil
}

func usdPerTokenToScaled(usdDollars int64) *big.Int {
return new(big.Int).Mul(big.NewInt(usdDollars), big.NewInt(cantonUsdPerTokenScale))
}

func tokenPricesFromFamilyExtras(extras map[string]any, remoteSelector uint64) (map[string]*big.Int, error) {
if extras == nil {
return map[string]*big.Int{}, nil
}
raw, ok := extras[CantonRemoteTokenPricesFamilyExtraKey]
if !ok || raw == nil {
return map[string]*big.Int{}, nil
}

byRemote, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("%q must be a map keyed by remote chain selector", CantonRemoteTokenPricesFamilyExtraKey)
}

remoteKey := strconv.FormatUint(remoteSelector, 10)
instruments, ok := byRemote[remoteKey]
if !ok {
return map[string]*big.Int{}, nil
}

instrumentMap, ok := instruments.(map[string]any)
if !ok {
return nil, fmt.Errorf("%q entry for remote %s must be a map of instrument to price", CantonRemoteTokenPricesFamilyExtraKey, remoteKey)
}

out := make(map[string]*big.Int, len(instrumentMap))
for instrument, priceRaw := range instrumentMap {
price, err := parseUsdPerTokenPrice(priceRaw)
if err != nil {
return nil, fmt.Errorf("%q remote %s instrument %q: %w", CantonRemoteTokenPricesFamilyExtraKey, remoteKey, instrument, err)
}
out[instrument] = price
}

return out, nil
}

func parseUsdPerTokenPrice(raw any) (*big.Int, error) {
switch v := raw.(type) {
case string:
return parseUsdPerTokenPriceString(strings.TrimSpace(v))
case int:
if v <= 0 {
return nil, fmt.Errorf("price must be positive")
}

return usdPerTokenToScaled(int64(v)), nil
case int64:
if v <= 0 {
return nil, fmt.Errorf("price must be positive")
}

return usdPerTokenToScaled(v), nil
case uint64:
if v == 0 {
return nil, fmt.Errorf("price must be positive")
}
if v > math.MaxInt64 {
return nil, fmt.Errorf("price exceeds supported range")
}

return usdPerTokenToScaled(int64(v)), nil
case float64:
if v <= 0 {
return nil, fmt.Errorf("price must be positive")
}

return parseUsdPerTokenPriceString(strconv.FormatFloat(v, 'f', -1, 64))
default:
return nil, fmt.Errorf("unsupported price type %T", raw)
}
}

func parseUsdPerTokenPriceString(raw string) (*big.Int, error) {
if raw == "" {
return nil, fmt.Errorf("price must be non-empty")
}

r, ok := new(big.Rat).SetString(raw)
if !ok {
return nil, fmt.Errorf("invalid USD price %q", raw)
}
if r.Sign() <= 0 {
return nil, fmt.Errorf("price must be positive")
}

scaled := new(big.Rat).Mul(r, new(big.Rat).SetInt64(cantonUsdPerTokenScale))
if !scaled.IsInt() {
return nil, fmt.Errorf("price %q exceeds supported precision", raw)
}

return scaled.Num(), nil
}

func resolveCcipOwnerParty(ds datastore.DataStore, chainSelector uint64) (string, error) {
feeQuoterRef, err := findContractRef(
ds,
chainSelector,
datastore.ContractType(feequoterop.ContractType),
feequoterop.Version,
"",
)
if err != nil {
return "", fmt.Errorf("resolve fee quoter for ccipOwner party: %w", err)
}

if party, ok := partyFromDeployLabels(feeQuoterRef.Labels); ok {
return party, nil
}

return "", fmt.Errorf("ccipOwner party not found in FeeQuoter labels on chain %d", chainSelector)
}

func partyFromDeployLabels(labels datastore.LabelSet) (string, bool) {
for _, label := range labels.List() {
at := strings.LastIndex(label, "@")
if at < 0 || at+1 >= len(label) {
continue
}
party := label[at+1:]
if strings.Contains(party, "::") {
return party, true
}
}

return "", false
}
94 changes: 94 additions & 0 deletions deployment/adapters/configure_lanes_token_prices_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package adapters

import (
"math/big"
"testing"

ccipadapters "github.com/smartcontractkit/chainlink-ccip/deployment/v2_0_0/adapters"
"github.com/smartcontractkit/chainlink-deployments-framework/datastore"
"github.com/smartcontractkit/go-daml/pkg/types"
"github.com/stretchr/testify/require"

"github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1"
feequoterop "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/fee_quoter"
)

func TestResolveTokenPricesForRemoteDest_DefaultLinkToken(t *testing.T) {
t.Parallel()

const chainSelector uint64 = 9268731218649498074
ccipOwner := "ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551"

ds := datastore.NewMemoryDataStore()
require.NoError(t, ds.Addresses().Add(datastore.AddressRef{
ChainSelector: chainSelector,
Type: datastore.ContractType(feequoterop.ContractType),
Version: feequoterop.Version,
Qualifier: "",
Address: "0xabc",
Labels: datastore.NewLabelSet("feequoter-scxln@" + ccipOwner),
}))

prices, err := resolveTokenPricesForRemoteDest(ds.Seal(), ccipadapters.ConfigureChainForLanesInput{
ChainSelector: chainSelector,
}, 16015286601757825753, nil)
require.NoError(t, err)
require.Equal(t, map[string]*big.Int{
ccipOwner + ":link-token": usdPerTokenToScaled(defaultLinkUsdPerTokenDollars),
}, prices)
}

func TestResolveTokenPricesForRemoteDest_IncludesNativeToken(t *testing.T) {
t.Parallel()

const chainSelector uint64 = 9268731218649498074
ccipOwner := "ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551"
dso := "DSO::1220be58c29e65de40bf273be1dc2b266d43a9a002ea5b18955aeef7aac881bb471a"

ds := datastore.NewMemoryDataStore()
require.NoError(t, ds.Addresses().Add(datastore.AddressRef{
ChainSelector: chainSelector,
Type: datastore.ContractType(feequoterop.ContractType),
Version: feequoterop.Version,
Qualifier: "",
Address: "0xabc",
Labels: datastore.NewLabelSet("feequoter-scxln@" + ccipOwner),
}))

native := splice_api_token_holding_v1.InstrumentId{
Admin: types.PARTY(dso),
Id: types.TEXT("Amulet"),
}
prices, err := resolveTokenPricesForRemoteDest(ds.Seal(), ccipadapters.ConfigureChainForLanesInput{
ChainSelector: chainSelector,
}, 16015286601757825753, &native)
require.NoError(t, err)
require.Equal(t, map[string]*big.Int{
ccipOwner + ":link-token": usdPerTokenToScaled(defaultLinkUsdPerTokenDollars),
dso + ":Amulet": usdPerTokenToScaled(defaultNativeUsdPerTokenDollars),
}, prices)
}

func TestTokenPricesFromFamilyExtras_Override(t *testing.T) {
t.Parallel()

prices, err := tokenPricesFromFamilyExtras(map[string]any{
CantonRemoteTokenPricesFamilyExtraKey: map[string]any{
"16015286601757825753": map[string]any{
"ccipOwner::1220:link-token": "15",
},
},
}, 16015286601757825753)
require.NoError(t, err)
require.Equal(t, usdPerTokenToScaled(15), prices["ccipOwner::1220:link-token"])
}

func TestPartyFromDeployLabels(t *testing.T) {
t.Parallel()

party, ok := partyFromDeployLabels(datastore.NewLabelSet(
"feequoter-scxln@ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551",
))
require.True(t, ok)
require.Equal(t, "ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551", party)
}
37 changes: 0 additions & 37 deletions deployment/adapters/deploy_chain_contracts_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package adapters
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"

Expand All @@ -22,10 +21,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/sequences"
dsutils "github.com/smartcontractkit/chainlink-canton/deployment/utils/datastore"
"github.com/smartcontractkit/chainlink-canton/openapi/gen/tokenMetadataV1"
)

var _ ccipadapters.DeployChainContractsAdapter = (*CantonDeployChainContractsAdapter)(nil)
Expand Down Expand Up @@ -176,40 +173,6 @@ func deployerPartyID(deployerContract string, participant canton.Participant) st
return participant.PartyID
}

func lookupNativeInstrumentID(ctx context.Context, participant canton.Participant) (splice_api_token_holding_v1.InstrumentId, error) {
tokenSource := participant.TokenSource
interceptor := func(ctx context.Context, req *http.Request) error {
token, err := tokenSource.Token()
if err != nil {
return fmt.Errorf("failed to retrieve token: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))

return nil
}

client, err := tokenMetadataV1.NewClientWithResponses(
fmt.Sprintf("%s/v0/scan-proxy", participant.Endpoints.ValidatorAPIURL),
tokenMetadataV1.WithRequestEditorFn(interceptor),
)
if err != nil {
return splice_api_token_holding_v1.InstrumentId{}, fmt.Errorf("failed to create token metadata client: %w", err)
}

info, err := client.GetRegistryInfoWithResponse(ctx)
if err != nil {
return splice_api_token_holding_v1.InstrumentId{}, fmt.Errorf("error getting registry info: %w", err)
}
if info.StatusCode() != http.StatusOK {
return splice_api_token_holding_v1.InstrumentId{}, fmt.Errorf("unexpected status code from token metadata client: %d: %v", info.StatusCode(), info.Body)
}

return splice_api_token_holding_v1.InstrumentId{
Admin: types.PARTY(info.JSON200.AdminId),
Id: types.TEXT("Amulet"),
}, nil
}

func committeeVerifierParams(ownerParty string, verifiers []ccipadapters.CommitteeVerifierDeployParams) []sequences.CommitteeVerifierParams {
params := make([]sequences.CommitteeVerifierParams, 0, len(verifiers))
for _, verifier := range verifiers {
Expand Down
Loading
Loading