Skip to content

Conversation

@patrickhuie19
Copy link
Contributor

@patrickhuie19 patrickhuie19 commented Jun 24, 2025

Description

CRE-468

Implement metering for the write targets. Will require chain specific implementations of Target Strategy to be updated for GetEstimateFee and GetTargetStrategy.

A few things need to happen:

  • Parse spend limits from the capability request if present
  • If limits are present, perform an estimation of the gas cost. If the estimate is greater than the limit, error out with an InsufficientFunds error
  • After the transaction has been included in a block, convert the inclusion fee from the native gas unit to the native chain currency
  • Return this native chain currency fee to the capability response

This PR cannot break current df workflows. I've removed the estimation check against limits to keep things operationally simple until we have a good reason to start supplying those limits.

Requires Dependencies

Resolves Dependencies

@patrickhuie19 patrickhuie19 force-pushed the feature/CRE-468/EVM_WT_Metering branch from ebb60b4 to 986a9a2 Compare June 24, 2025 00:56
return nil, 0, fmt.Errorf("invalid gas spend limit format: %s", spendLimit)
}

// Multiply by 10^decimals to convert from ETH to wei
Copy link
Contributor

Choose a reason for hiding this comment

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

Since it's a generalized component we shouldn't mention chain-specific terms (ETH, wei) here. It's a conversion from native unit to small unit (or something like this)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Mm, I agree. Should this conversion logic also live with the specific target strategy implementer? If so, we need to find a way to plumb that through

@Unheilbar
Copy link
Contributor

Unheilbar commented Jun 25, 2025

There are two options to get Transaction Cost:

  1. GetEstimateFee which simulates tx execution and provides estimated value in native currency. (should be compared with user-defined value)
  2. GetTransactionFee which provides inclusion fee in native currency once we have transaction receipt. (should be returned in capability response
    I think both of them should be provided by TargetStrategy interface.
    cc @ilija42

@patrickhuie19
Copy link
Contributor Author

There are two options to get Transaction Cost:

1. [GetEstimateFee](https://github.com/smartcontractkit/chainlink-common/blob/5613187324ad638bb25fb5ce65ddc515bc454c25/pkg/types/contract_writer.go#L34C1-L34C139) which simulates tx execution and provides estimated value in native currency. (should be compared with user-defined value)

2. [GetTransactionFee](https://github.com/smartcontractkit/chainlink-common/blob/5613187324ad638bb25fb5ce65ddc515bc454c25/pkg/types/relayer.go#L142) which provides inclusion fee  in native currency once we have transaction receipt. (should be returned in capability response
   I think both of them should be provided by TargetStrategy interface.
   cc @ilija42

After discussing offline, I agree - 4ef7f71

// Wrapper around the ChainWriter to get the transaction status
GetTransactionStatus(ctx context.Context, transactionID string) (commontypes.TransactionStatus, error)
// Wrapper around the ChainWriter to get the fee esimate
GetEstimateFee(ctx context.Context, contract string, method string, args any, toAddress string, meta *commontypes.TxMeta, val *big.Int) (commontypes.EstimateFee, error)
Copy link
Contributor

Choose a reason for hiding this comment

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

So here signature should be

GetEstimateFee(ctx context.Context, report []byte, reportContext []byte, signatures [][]byte, request capabilities.CapabilityRequest) (commontypes.EstimateFee, error)

since TargetStrategy handles building payload for CW

Copy link
Contributor

Choose a reason for hiding this comment

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

aren't all of these report []byte, reportContext []byte, signatures [][]byte, part of the capability request?

Copy link
Contributor

Choose a reason for hiding this comment

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

type SignedReport struct {
	Report []byte
	// Report context is appended to the report before signing by libOCR.
	// It contains config digest + round/epoch/sequence numbers (currently 96 bytes).
	// Has to be appended to the report before validating signatures.
	Context []byte
	// Always exactly F+1 signatures.
	Signatures [][]byte
	// Report ID defined in the workflow spec (2 bytes).
	ID []byte
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, just checked other arguments are unused by TransmitReport, idk why they are declared there. I guess the only thing is needed is the capability request

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'd recommend we stay consistent for this PR and then migrate all the signatures in one swoop

@patrickhuie19 patrickhuie19 marked this pull request as ready for review June 30, 2025 13:56
@patrickhuie19 patrickhuie19 requested a review from a team as a code owner June 30, 2025 13:56
}
errMsg := c.asEmittedError(ctx, &wt.WriteError{
Code: uint32(TransmissionStateFatal),
Summary: "InsufficientFunds",
Copy link
Contributor

Choose a reason for hiding this comment

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

Summary: "InsufficientFunds",
based on writeTarget.checkGasEstimate there are other possible errors:

	fee, err := c.targetStrategy.GetEstimateFee(ctx, report, reportContext, signatures, request)
	if err != nil {
		return nil, 0, fmt.Errorf("failed to get gas estimate: %w", err)
	}

	// Convert spend limit from chain currency to gas units
	limitFloat, ok := new(big.Float).SetString(spendLimit)
	if !ok {
		return nil, 0, fmt.Errorf("invalid gas spend limit format: %s", spendLimit)
	}

failed to get gas estimate doesnt match Summary:"InsufficientFunds", also it's retryable, so code should be Failed, not Fatal.
I think all this errors should be handled differently

// Get the transaction fee
fee, err := c.targetStrategy.GetTransactionFee(ctx, txID)
if err != nil {
return capabilities.CapabilityResponse{}, fmt.Errorf("failed to get transaction fee: %w", err)
Copy link
Contributor

Choose a reason for hiding this comment

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

hmm, at this point TX was already sent, we collected the receipt and confirmed successful transmission state, but here we return err since we couldn't get transaction fee (and since receipt is already in DB the only case for that, - we lost db connection). Should we return err in this case? Maybe just log and return estimation value from above?

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, for the ST3 use cases, we can do that.

return limit.Limit, nil
}
}
return "", fmt.Errorf("no gas spend limit found for chain %s", c.chainInfo.ChainID)
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this error necessary? Maybe makes more sense just to be a debug log

Copy link
Contributor Author

Choose a reason for hiding this comment

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

getGasSpendLimit is complex enough to demand an error in the response API - its up to the Execute author to handle it properly. Currently, Execute handles it by skipping the check against gas estimation.


// Compare estimate with limit
if fee.Fee.Cmp(limit) > 0 {
return nil, 0, fmt.Errorf("estimated gas fee %s exceeds spend limit %s", fee.Fee.String(), limit.String())
Copy link
Contributor

Choose a reason for hiding this comment

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

Where is this limit specified? Would this be in the workflow? And if so does the workflow do any sort of check to ensure the user actually has those funds?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The engine works with the billing service and a hierarchy of user defined limits to determine what to pass to the capabilities. Once a limit is passed to the capability, we ask that capabilities do their best to estimate the cost of execution upfront and stay within that cost to prevent double spend in the workflow owner account.

info.reportInfo.reportID = binary.BigEndian.Uint16(inputs.ID)

// Get gas spend limit first
spendLimit, err := c.getGasSpendLimit(request)
Copy link
Contributor

Choose a reason for hiding this comment

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

how is this spend limit enforced when sending a tx?

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think it's enforced, it's a rough estimate that doesn't consider that chain state can change/txm can bump tx prority fee.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not enforced; we can't block NOP keys on sending transactions.

@patrickhuie19 patrickhuie19 changed the title WIP CRE-468 Implement metering for write targets Jul 1, 2025
GetEstimateFee(ctx context.Context, report []byte, reportContext []byte, signatures [][]byte, request capabilities.CapabilityRequest) (commontypes.EstimateFee, error)
// GetTransactionFee retrieves the actual transaction fee in native currency from the transaction receipt.
// This method should be implemented by chain-specific services and handle the conversion of gas units to native currency.
GetTransactionFee(ctx context.Context, transactionID string) (decimal.Decimal, error)
Copy link
Contributor

@ilija42 ilija42 Jul 2, 2025

Choose a reason for hiding this comment

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

Gas values are always returned as big.Int, I'm assuming this value will be created like this: decimal.New(val, -18)? Could be annoying converting it back to big.Int

Copy link
Contributor

@ilija42 ilija42 left a comment

Choose a reason for hiding this comment

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

lgtm, lets just wait for TargetStrategy

@patrickhuie19 patrickhuie19 merged commit 3f9ae62 into main Jul 2, 2025
22 checks passed
@patrickhuie19 patrickhuie19 deleted the feature/CRE-468/EVM_WT_Metering branch July 2, 2025 18:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants