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
465 changes: 465 additions & 0 deletions GLAMSTERDAM_GAS_UPDATE_RUNBOOK.md

Large diffs are not rendered by default.

175 changes: 175 additions & 0 deletions chains/evm/deployment/utils/glamsterdam/glamsterdam.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Package glamsterdam holds the version-independent primitives shared by the v1.6 and v2.0
// "update gas config for Glamsterdam" changesets: comparing an on-chain value against its
// expected Prague baseline, falling back to a derived value on mismatch, and formatting the
// resulting per-chain report.
package glamsterdam

import (
"fmt"
"math"
"strings"

"golang.org/x/exp/constraints"
)

// FieldSpec describes one on-chain gas-related field that needs to move from its Prague baseline
// to a Glamsterdam value, plus the fallback to compute instead when the current on-chain value
// doesn't match ExpectedPrague.
type FieldSpec[T comparable] struct {
// Name identifies the field for logging/reporting, e.g. "FeeQuoter.DestChainConfig.DestGasOverhead".
Name string
// ExpectedPrague is the baseline value the source doc assumed when deriving GlamsterdamValue.
ExpectedPrague T
// GlamsterdamValue is the literal value to apply when the current on-chain value matches
// ExpectedPrague.
GlamsterdamValue T
// Fallback computes the value to apply when the current on-chain value does not match
// ExpectedPrague.
Fallback func(current T) T
}

// ApplyRatio returns a Fallback function that scales a mismatched current value by the same
// ratio the source doc used to go from its Prague baseline to its Glamsterdam value, rounded to
// the nearest integer. Panics if the scaled result overflows T rather than silently wrapping,
// since a wrapped fallback value would otherwise be written on-chain with no indication anything
// went wrong.
func ApplyRatio[T constraints.Integer](prague, glamsterdam T) func(current T) T {
return func(current T) T {
scaled := math.Round(float64(current) * float64(glamsterdam) / float64(prague))

result := T(scaled)
if float64(result) != scaled {
panic(fmt.Sprintf(
"ApplyRatio: scaled value %v overflows the target type (got %v after conversion)",
scaled, result,
))
}

return result
}
}

// FieldResult is the outcome of resolving one FieldSpec against a chain's current on-chain value.
type FieldResult[T comparable] struct {
Spec FieldSpec[T]
Current T
Matched bool
// AlreadyApplied is true when current already equals spec.GlamsterdamValue, meaning a prior
// run (or partial retry of this same run) already migrated this field. AppliedValue is set to
// current unchanged in this case, so re-running is idempotent rather than compounding the
// fallback ratio on top of an already-migrated value.
AlreadyApplied bool
AppliedValue T
}

// Resolve compares current against spec.GlamsterdamValue and spec.ExpectedPrague, in that order.
// If current already equals GlamsterdamValue, this field was already migrated by a prior run (or
// an earlier step of the same batch); resolving it again is a no-op. Otherwise, if current
// matches ExpectedPrague, the literal GlamsterdamValue is applied; if it matches neither,
// spec.Fallback(current) is applied instead.
func Resolve[T comparable](spec FieldSpec[T], current T) FieldResult[T] {
if current == spec.GlamsterdamValue {
return FieldResult[T]{Spec: spec, Current: current, AlreadyApplied: true, AppliedValue: current}
}
if current == spec.ExpectedPrague {
return FieldResult[T]{Spec: spec, Current: current, Matched: true, AppliedValue: spec.GlamsterdamValue}
}
return FieldResult[T]{Spec: spec, Current: current, Matched: false, AppliedValue: spec.Fallback(current)}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

On a rerun after this proposal executes, current is already the applied Glamsterdam value, so it enters this mismatch branch and gets multiplied again. For example, v1.6 DestGasOverhead 500,000 becomes 833,333 and v2 BaseExecutionGasCost 400,000 becomes 800,000. That makes overlapping or partial retries unsafe. Treat already-applied values as no-ops, and make the desired result explicit for custom-baseline fallback values so they cannot compound either.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is tricky but a very good point. On a run how can the changeset know if a value has already been multiplied? And even if the changeset does check and see "hey this value already matches what is needed for glamsterdam" how does it know that really that wasn't some pre-set custom value and to actually make it glamsterdam safe it should be multiplied by some factor...

I mean we could do something where the changeset distinguishes between expected values for some chain family, and if a chain is currently using those expected values then it applies the default glamsterdam change to them. But if not then the user has to specify what the new value should be for that chain. So there is no automatic multiply if the gas values aren't matching defaults used.

}

// FieldResultString renders a FieldResult as a single human-readable report line for a given
// chain selector.
func FieldResultString[T comparable](chainSelector uint64, result FieldResult[T]) string {
if result.AlreadyApplied {
return fmt.Sprintf(
"chain %d: %s already matches Glamsterdam value %v (no-op, e.g. re-run after this "+
"proposal already executed)",
chainSelector, result.Spec.Name, result.AppliedValue,
)
}
if result.Matched {
return fmt.Sprintf(
"chain %d: %s matched expected Prague value %v, applying Glamsterdam value %v",
chainSelector, result.Spec.Name, result.Spec.ExpectedPrague, result.AppliedValue,
)
}
return fmt.Sprintf(
"chain %d: %s MISMATCH - current value %v does not match expected Prague value %v, "+
"applying fallback value %v instead of literal Glamsterdam value %v",
chainSelector, result.Spec.Name, result.Current, result.Spec.ExpectedPrague,
result.AppliedValue, result.Spec.GlamsterdamValue,
)
}

// Report accumulates the human-readable summary of a Glamsterdam gas-update run: which chains
// were skipped, which had no lane to the target, which fields matched or mismatched their
// expected Prague baseline, and which chains had a contract that couldn't be resolved. Intended
// for inclusion in the resulting MCMS proposal's Description and/or logs.
type Report struct {
// Lines are the accumulated human-readable report lines, in the order they were recorded.
Lines []string
}

// NewReport returns an empty Report.
func NewReport() *Report {
return &Report{}
}

// AddSkipped records a chain that was unconditionally skipped via SkipChainSelectors.
func (r *Report) AddSkipped(chainSelector uint64) {
r.Lines = append(r.Lines, fmt.Sprintf("chain %d: skipped (explicit SkipChainSelectors entry)", chainSelector))
}

// AddNoLane records a chain that was scanned but has no lane pointed at the target chain.
func (r *Report) AddNoLane(chainSelector uint64) {
r.Lines = append(r.Lines, fmt.Sprintf("chain %d: no lane to target chain, skipped", chainSelector))
}

// AddUnresolvedContract records a chain where an expected contract could not be resolved from
// the datastore. This chain is excluded from further processing, but the run continues for
// every other chain.
func (r *Report) AddUnresolvedContract(chainSelector uint64, contractName string) {
r.Lines = append(r.Lines, fmt.Sprintf(
"chain %d: ERROR - could not resolve %s address, skipping this chain", chainSelector, contractName,
))
}

// AddReadError records a chain where a required on-chain read failed partway through discovery
// or update (e.g. a stale datastore address with no contract code at that address anymore). This
// chain is excluded from further processing, but the run continues for every other chain, per the
// "never block the batch on a single chain" design principle.
func (r *Report) AddReadError(chainSelector uint64, description string, err error) {
r.Lines = append(r.Lines, fmt.Sprintf(
"chain %d: ERROR - failed to %s: %v, skipping this chain", chainSelector, description, err,
))
}

// AddDisabledLane records a chain/contract where the on-chain config for the target destination
// is not actually configured (router == address(0), the contract's own convention for "this
// remote chain is paused/not supported" — see e.g. OnRamp.sol's getFee check and
// BaseVerifier.sol's "_applyRemoteChainConfigUpdates" comment "The router can be zero to pause
// the remote chain"). No write is made for this contract on this chain: writing to a disabled
// lane would either be meaningless (zero-value fallback writes) or risk silently re-enabling a
// lane that was deliberately paused.
func (r *Report) AddDisabledLane(chainSelector uint64, contractName string) {
r.Lines = append(r.Lines, fmt.Sprintf(
"chain %d: %s has no router configured for the target chain (router == address(0)) - "+
"lane is disabled/not configured, skipping this contract's write", chainSelector, contractName,
))
}

// AddLine appends an arbitrary pre-formatted line to the report, e.g. the output of
// FieldResultString.
func (r *Report) AddLine(line string) {
r.Lines = append(r.Lines, line)
}

// AddField records the outcome of resolving a single FieldSpec on a given chain.
func AddField[T comparable](r *Report, chainSelector uint64, result FieldResult[T]) {
r.AddLine(FieldResultString(chainSelector, result))
}

// String joins all recorded lines into a single newline-separated report.
func (r *Report) String() string {
return strings.Join(r.Lines, "\n")
}
145 changes: 145 additions & 0 deletions chains/evm/deployment/utils/glamsterdam/glamsterdam_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package glamsterdam_test

import (
"testing"

"github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/glamsterdam"
"github.com/stretchr/testify/require"
)

func TestApplyRatio(t *testing.T) {
tests := []struct {
desc string
prague uint32
glamsterdam uint32
current uint32
expectedFallback uint32
}{
{
desc: "2x ratio, exact",
prague: 200_000,
glamsterdam: 400_000,
current: 100_000,
expectedFallback: 200_000,
},
{
desc: "3x ratio, exact",
prague: 90_000,
glamsterdam: 270_000,
current: 123_000,
expectedFallback: 369_000,
},
{
desc: "3.2x ratio, rounds to nearest",
prague: 20,
glamsterdam: 64,
current: 25,
expectedFallback: 80, // 25 * 3.2 = 80 exactly
},
{
desc: "ratio requiring rounding",
prague: 75_000,
glamsterdam: 85_000,
current: 100_000,
expectedFallback: 113_333, // 100000 * 85000/75000 = 113333.33 -> rounds to 113333
},
{
desc: "zero current stays zero",
prague: 200_000,
glamsterdam: 400_000,
current: 0,
expectedFallback: 0,
},
}

for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
fallback := glamsterdam.ApplyRatio(test.prague, test.glamsterdam)
require.Equal(t, test.expectedFallback, fallback(test.current))
})
}
}

func TestResolve(t *testing.T) {
spec := glamsterdam.FieldSpec[uint32]{
Name: "TestField",
ExpectedPrague: 200_000,
GlamsterdamValue: 400_000,
Fallback: glamsterdam.ApplyRatio[uint32](200_000, 400_000),
}

t.Run("matched baseline applies literal glamsterdam value", func(t *testing.T) {
result := glamsterdam.Resolve(spec, uint32(200_000))
require.True(t, result.Matched)
require.Equal(t, uint32(200_000), result.Current)
require.Equal(t, uint32(400_000), result.AppliedValue)
})

t.Run("mismatched baseline applies fallback", func(t *testing.T) {
result := glamsterdam.Resolve(spec, uint32(150_000))
require.False(t, result.Matched)
require.Equal(t, uint32(150_000), result.Current)
require.Equal(t, uint32(300_000), result.AppliedValue) // 150000 * 2x
})
}

func TestFieldResultString(t *testing.T) {
spec := glamsterdam.FieldSpec[uint32]{
Name: "TestField",
ExpectedPrague: 200_000,
GlamsterdamValue: 400_000,
Fallback: glamsterdam.ApplyRatio[uint32](200_000, 400_000),
}

t.Run("matched", func(t *testing.T) {
result := glamsterdam.Resolve(spec, uint32(200_000))
line := glamsterdam.FieldResultString(uint64(1), result)
require.Contains(t, line, "chain 1")
require.Contains(t, line, "TestField")
require.Contains(t, line, "matched expected Prague value 200000")
require.Contains(t, line, "applying Glamsterdam value 400000")
})

t.Run("mismatched", func(t *testing.T) {
result := glamsterdam.Resolve(spec, uint32(150_000))
line := glamsterdam.FieldResultString(uint64(2), result)
require.Contains(t, line, "chain 2")
require.Contains(t, line, "MISMATCH")
require.Contains(t, line, "current value 150000")
require.Contains(t, line, "expected Prague value 200000")
require.Contains(t, line, "fallback value 300000")
require.Contains(t, line, "instead of literal Glamsterdam value 400000")
})
}

func TestReport(t *testing.T) {
r := glamsterdam.NewReport()
r.AddSkipped(1)
r.AddNoLane(2)
r.AddUnresolvedContract(3, "FeeQuoter")

spec := glamsterdam.FieldSpec[uint32]{
Name: "TestField",
ExpectedPrague: 200_000,
GlamsterdamValue: 400_000,
Fallback: glamsterdam.ApplyRatio[uint32](200_000, 400_000),
}
glamsterdam.AddField(r, uint64(4), glamsterdam.Resolve(spec, uint32(200_000)))
glamsterdam.AddField(r, uint64(5), glamsterdam.Resolve(spec, uint32(150_000)))

out := r.String()
require.Contains(t, out, "chain 1: skipped (explicit SkipChainSelectors entry)")
require.Contains(t, out, "chain 2: no lane to target chain, skipped")
require.Contains(t, out, "chain 3: ERROR - could not resolve FeeQuoter address, skipping this chain")
require.Contains(t, out, "chain 4: TestField matched expected Prague value 200000")
require.Contains(t, out, "chain 5: TestField MISMATCH")

lines := []string{
"chain 1: skipped (explicit SkipChainSelectors entry)",
"chain 2: no lane to target chain, skipped",
"chain 3: ERROR - could not resolve FeeQuoter address, skipping this chain",
}
for i, want := range lines {
require.Contains(t, out, want, "line %d", i)
}
}
Loading
Loading