Skip to content
Merged
9 changes: 6 additions & 3 deletions contracts/contracts/ccip/fee_quoter/contract.tolk
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,9 @@ fun getTokenTransferCost(st: Storage, destChainConfig: DestChainConfig, mutate t

get fun tokenPrice(token: address): TimestampedPrice {
val st = lazy Storage.load();
return st.usdPerToken.mustGet(token, Error.TokenNotSupported as int);
val entry = st.usdPerToken.get(token);
Copy link
Contributor Author

@huangzhen1997 huangzhen1997 Oct 31, 2025

Choose a reason for hiding this comment

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

assert (entry.isFound, Error.TokenNotSupported);
return entry.loadValue();
}

// vec<TimestampedPrice>
Expand All @@ -475,8 +477,9 @@ get fun tokenPrices(tokens: tuple): tuple {

get fun destinationChainGasPrice(destChainSelector: uint64): cell {
val st = lazy Storage.load();
val config = st.destChainConfigs.mustGet(destChainSelector, Error.UnknownDestChainSelector as int);
return config.usdPerUnitGas
var entry = st.destChainConfigs.get(destChainSelector);
assert (entry.isFound, Error.UnknownDestChainSelector);
return entry.loadValue().usdPerUnitGas
}

get fun tokenAndGasPrices(token: address, destChainSelector: uint64) {
Expand Down
41 changes: 39 additions & 2 deletions integration-tests/deployment/cs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import (
"math/big"
"testing"

chainselectors "github.com/smartcontractkit/chain-selectors"
"github.com/stretchr/testify/require"
"github.com/xssnick/tonutils-go/address"
"github.com/xssnick/tonutils-go/ton"

chainselectors "github.com/smartcontractkit/chain-selectors"

"github.com/smartcontractkit/chainlink-ccip/pkg/consts"
"github.com/smartcontractkit/chainlink-common/pkg/logger"
"github.com/smartcontractkit/chainlink-common/pkg/types/ccipocr3"
Expand Down Expand Up @@ -265,6 +265,43 @@ func TestDeploy(t *testing.T) {
err = accessor.Sync(ctx, consts.ContractNameFeeQuoter, rawFeeQuoterAddr)
require.NoError(t, err)

t.Run("FetchTokenPrice", func(t *testing.T) {
// known token address, price updated during changeset execution
addr := address.MustParseAddr("EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAd99")
tonAddrBytes, err := addrCodec.AddressStringToBytes(addr.String())
require.NoError(t, err)
updates, err := accessor.GetFeeQuoterTokenUpdates(ctx, []ccipocr3.UnknownAddress{tonAddrBytes})
if err != nil {
return
}

require.NoError(t, err)
require.NotNil(t, updates[ccipocr3.UnknownEncodedAddress(tonAddrBytes)])
require.Equal(t, int64(99), updates[ccipocr3.UnknownEncodedAddress(tonAddrBytes)].Value.Int64())

//random address, should return empty token price
addr = address.MustParseAddr("kQDpbpFeXR2DGPQcAY_Fr8b1owx_K6LbvRoz9Ct-JJv4JkPH")
tonAddrBytes, err = addrCodec.AddressStringToBytes(addr.String())
require.NoError(t, err)
updates, err = accessor.GetFeeQuoterTokenUpdates(ctx, []ccipocr3.UnknownAddress{tonAddrBytes})

require.NoError(t, err)
require.NotNil(t, updates[ccipocr3.UnknownEncodedAddress(tonAddrBytes)])
require.Equal(t, int64(0), updates[ccipocr3.UnknownEncodedAddress(tonAddrBytes)].Value.Int64())
})

t.Run("GetChainFeePriceUpdate", func(t *testing.T) {
// evm chain selector
feePriceUpdate, err := accessor.GetChainFeePriceUpdate(ctx, []ccipocr3.ChainSelector{ccipocr3.ChainSelector(evmSelector)})
require.NoError(t, err)
require.NotEqual(t, "0", feePriceUpdate[ccipocr3.ChainSelector(evmSelector)].Value.String())

// unknown chain selector, returns default values
feePriceUpdate, err = accessor.GetChainFeePriceUpdate(ctx, []ccipocr3.ChainSelector{ccipocr3.ChainSelector(1)})
require.NoError(t, err)
require.Equal(t, "0", feePriceUpdate[ccipocr3.ChainSelector(1)].Value.String())
})

t.Run("GetConfig", func(t *testing.T) {
// destination
config, sourceChainConfigs, err := accessor.GetAllConfigsLegacy(ctx, ccipocr3.ChainSelector(chainSelector), []ccipocr3.ChainSelector{ccipocr3.ChainSelector(evmSelector)})
Expand Down
6 changes: 4 additions & 2 deletions pkg/ccip/bindings/common/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,11 +480,13 @@ func FetchResultHelper(
} else {
result, err = client.RunGetMethod(ctx, block, contractAddr, method, opts...)
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

return raw error so downstream can parse the error code

if err != nil {
return fmt.Errorf("error getting %s: %w", method, err)
return err
}

if err = fromResult(result); err != nil {
return fmt.Errorf("failed to parse %s: %w", method, err)
return err
}
return nil
}
13 changes: 9 additions & 4 deletions pkg/ccip/bindings/feequoter/fee_quoter.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import (
"github.com/smartcontractkit/chainlink-ton/pkg/ton/tvm"
)

const (
tokenPriceGetter = "tokenPrice"
StaticConfigGetter = "staticConfig"
)

// Fee Quoter opcodes
const (
OpcodeUpdatePrices = 0x20000001
Expand All @@ -36,10 +41,6 @@ const (
ErrorMsgDataTooLarge tvm.ExitCode = tvm.ExitCode(1009)
)

const (
StaticConfigGetter = "staticConfig"
)

type Storage struct {
ID uint32 `tlb:"## 32"`
Ownable ccipcommon.Ownable2Step `tlb:"."`
Expand Down Expand Up @@ -219,6 +220,10 @@ func (p *TimestampedPrice) FromResult(result *ton.ExecutionResult) error {
return nil
}

func (p *TimestampedPrice) FetchResult(ctx context.Context, client ton.APIClientWrapped, block *ton.BlockIDExt, contractAddr *address.Address, opts []interface{}) error {
return ccipcommon.FetchResultHelper(ctx, client, block, contractAddr, tokenPriceGetter, opts, p.FromResult)
}

type TokenPriceUpdate struct {
SourceToken *address.Address `tlb:"addr"`
UsdPerToken *big.Int `tlb:"## 224"`
Expand Down
62 changes: 25 additions & 37 deletions pkg/ccip/chainaccessor/ton_accessor.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import (
"github.com/smartcontractkit/chainlink-common/pkg/types/ccipocr3"
"github.com/smartcontractkit/chainlink-common/pkg/types/query/primitives"

"github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/common"
"github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/feequoter"
"github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/ocr"
"github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/offramp"
Expand Down Expand Up @@ -263,7 +262,7 @@ func (a *TONAccessor) LatestMessageTo(ctx context.Context, dest ccipocr3.ChainSe
SkipBytes(40). // Skip to DestChainSelector
FilterBytes(8, query.EQ(binary.BigEndian.AppendUint64(nil, uint64(dest)))).
OrderBy(query.SortByTxLT, query.DESC). // sort by transaction LT new to old
Limit(1). // only get the last one
Limit(1). // only get the last one
Execute(ctx, a.logPoller.GetStore())

if err != nil {
Expand Down Expand Up @@ -648,7 +647,8 @@ func (a *TONAccessor) GetChainFeePriceUpdate(ctx context.Context, selectors []cc
for _, selector := range selectors {
result, err := a.client.RunGetMethod(ctx, block, addr, "destinationChainGasPrice", uint64(selector))
// The plugin is built with EVM behaviour in mind: if a value doesn't exist the zero value is returned
if execError, ok := err.(ton.ContractExecError); ok && execError.Code == common.ErrUnknownDestChainSelector { //nolint:errorlint // we're guaranteed to get unwrapped error here
if execError, ok := err.(ton.ContractExecError); ok && execError.Code == 24814 { //nolint:errorlint // we're guaranteed to get unwrapped error here
Copy link
Contributor Author

@huangzhen1997 huangzhen1997 Oct 31, 2025

Choose a reason for hiding this comment

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

added todo. The error hard-coded here in order to match on-chain definition. There's a discrepancy of the error code definition

// TODO remove hard coded error code for UnknownDestChainSelector, right now common.UnknownDestChainSelector doesn't match with on-chain
prices[selector] = ccipocr3.TimestampedUnixBig{
Timestamp: 0,
Value: big.NewInt(0),
Expand Down Expand Up @@ -720,6 +720,8 @@ func (a *TONAccessor) GetFeeQuoterTokenUpdates(
ctx context.Context,
tokens []ccipocr3.UnknownAddress,
) (map[ccipocr3.UnknownEncodedAddress]ccipocr3.TimestampedUnixBig, error) {
// NOTE: Currently, input tokens are mostly LINK and the native token, so batching is not implemented
// to keep the TON accessor simple. Batching can be added later if needed, such as for performance bottlenecks.
addr, err := a.getBinding(consts.ContractNameFeeQuoter)
if err != nil {
return nil, err
Expand All @@ -730,8 +732,7 @@ func (a *TONAccessor) GetFeeQuoterTokenUpdates(
}

// TODO: decode token addresses here according to chain selector

encodedTokens := make([]any, 0, len(tokens))
prices := make(map[ccipocr3.UnknownEncodedAddress]ccipocr3.TimestampedUnixBig, len(tokens))
for _, token := range tokens {
strAddr, err2 := a.addrCodec.AddressBytesToString(token)
if err2 != nil {
Expand All @@ -741,40 +742,27 @@ func (a *TONAccessor) GetFeeQuoterTokenUpdates(
if err2 != nil {
return nil, fmt.Errorf("failed to ParseAddr %s for encodedTokens: %w", strAddr, err2)
}
encodedTokens = append(encodedTokens, addrParsed)
}
result, err := a.client.RunGetMethod(ctx, block, addr, "tokenPrices", encodedTokens...)
// result is a list of TimestampedPrice
if err != nil {
return nil, err
}
results := result.AsTuple()
if len(tokens) != len(results) {
return nil, fmt.Errorf("length mismatch: expected %d prices but received %d", len(tokens), len(results))
}
prices := make(map[ccipocr3.UnknownEncodedAddress]ccipocr3.TimestampedUnixBig, len(tokens))
for i, priceResult := range results {
token := tokens[i]
var price ccipocr3.TimestampedUnixBig
switch priceResult := priceResult.(type) {
case nil:
// return zero value
price = ccipocr3.TimestampedUnixBig{
Value: big.NewInt(0),
Timestamp: 0,
}
case cell.Cell:
Comment on lines -760 to -766
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we not need to explicitly handle this case anymore?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes I'm adding that but I notice the contact mustGet is not retuning the right error code but returned stack underflow, checking what's going on

var timestampedPrice feequoter.TimestampedPrice
if err := tlb.LoadFromCell(&timestampedPrice, priceResult.BeginParse()); err != nil {
return nil, err
}
price = ccipocr3.TimestampedUnixBig{
Value: timestampedPrice.Value,
Timestamp: timestampedPrice.Timestamp,

var tokenPrice feequoter.TimestampedPrice
err = tokenPrice.FetchResult(ctx, a.client, block, addr, []interface{}{cell.BeginCell().MustStoreAddr(addrParsed).EndCell().BeginParse()})
Comment on lines +746 to +747
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

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

This implementation makes a separate RPC call for each token in the loop, which could cause significant performance degradation when fetching prices for multiple tokens. The original batch implementation was more efficient. Consider if this sequential approach is necessary or if batch fetching can be preserved.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

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

This was my first reaction too from the slack discussion 🤔 Wouldn't this be a fair amount slower or cause additional strain on the endpoint? This GetFeeQuoterTokenUpdates() function can be queried pretty often

Copy link
Contributor Author

Choose a reason for hiding this comment

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

IIRC I had discuss this with @archseer at some point, that it's better to fetch individual config/price so that TVM will not allocate a huge stack space to store them as this map could become very big

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For speed, yeah I'm going to add a errGroup to parallelize the loop

Copy link
Contributor Author

@huangzhen1997 huangzhen1997 Oct 31, 2025

Choose a reason for hiding this comment

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

This was the discussion. But it was slightly different as DestChainConfig is much bigger than timestampPrice. We are less likely hit the 255 stack limit for fetching token prices, so maybe it's better to do batching if GetFeeQuoterTokenUpdates() gets called very often.

Copy link
Contributor Author

@huangzhen1997 huangzhen1997 Oct 31, 2025

Choose a reason for hiding this comment

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

Discussed with @ogtownsend offline, only LINK and the native token are used most of the time as input tokens for GetFeeQuoterTokenUpdates, so batching is not implemented for now to keep everything simple and clean. Added a comment to mention that batching can be added later if needed.

Copy link
Collaborator

Choose a reason for hiding this comment

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

If we are planing to merge this as-is and leave the parallelization for later, should we add a TODO here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

right now parallelization is not needed for just two tokens. if we going to support batching it would just be one rpc call

if err != nil {
// The plugin is built with EVM behaviour in mind: if a value doesn't exist the zero value is returned
if execError, ok := err.(ton.ContractExecError); ok && execError.Code == 24813 { // nolint:errorlint // we're guaranteed to get unwrapped error here
// TODO remove hard coded error code for TokenNotSupported, right now common.TokenNotSupported doesn't match with on-chain
prices[ccipocr3.UnknownEncodedAddress(token)] = ccipocr3.TimestampedUnixBig{
Timestamp: 0,
Value: big.NewInt(0),
}
continue
}
default:
return nil, fmt.Errorf("expected either cell or nil, received %T", priceResult)
return nil, fmt.Errorf("failed to FetchResult for encodedTokens: %w", err)
}

price := ccipocr3.TimestampedUnixBig{
Value: tokenPrice.Value,
Timestamp: tokenPrice.Timestamp,
}

if !utf8.ValidString(token.String()) {
return nil, fmt.Errorf("gRPC can't handle non-UTF8 strings: %x", token)
}
Expand Down
Loading