A Solana Program Change Can Be Compatible with One Consumer but Not Another
Testing Provider Changes Against Consumer-Declared Requirements
Two compiled implementations accepted the same CPI. Both transactions and both
CPIs succeeded. One left post-state 3; the other left 6.
That result is not yet a compatibility verdict. It becomes one only after the post-state is compared with a consumer’s declared requirement. In these local LiteSVM fixtures, Caller A requires an exact delta and Caller C requires only an increase. The candidate violates A’s requirement and satisfies C’s.
So the useful question is not simply whether a replacement runs. It is: compatible for which consumer?
Here, “compatible” means that observed behavior conforms to a requirement declared for that consumer and invocation.
For consumer C_i, provider implementation B_t, invocation context x,
and declared requirement R_i, this prototype evaluates:
[ \operatorname{conforms}(C_i, B_t, x, R_i) = R_i(\operatorname{observe}(C_i \to B_t, x)) ]
The verdict therefore belongs to the consumer-provider dependency under that context, not to the provider binary alone.
This follows Testing Compatibility Across Solana Program Changes, which showed that successful execution can miss one caller-level postcondition. This article asks what changes when consumers rely on different requirements.
before + delta; B1 computes before + 2 × delta at the same designated Program ID in separate fresh local runs. Both CPIs succeed. Exact delta fails on B1; a monotonic increase passes.
One address, two requirements
The counter uses two compiled callers and two compiled callees. B0 writes
after = before + delta; B1 writes after = before + 2 × delta. For each
comparison, the harness holds the caller binary, caller Program ID, callee
Program ID, state address, initial state bytes, instruction bytes, and CPI
privileges fixed. The changing control is the callee implementation installed
at the designated address.
With before = 0 and delta = 3, B0 leaves 3 and B1 leaves 6. Caller A
declares after == before + delta. Caller C declares after > before. Four
top-level transactions succeed; the harness also classifies four successful
CPIs from LiteSVM logs. Across the two successful candidate runs, the provider
leaves the same relevant post-state, while A and C apply different declared
requirements. A and C are separate caller fixtures. Provider-side invocation
controls are held fixed within each comparison.
That separates three facts that are easy to collapse:
- Reachable: the designated provider is present at the expected Program ID and the CPI can reach it.
- Executable: the transaction succeeds and the CPI completes.
- Compatible for a consumer: the observed behavior satisfies that consumer’s declared requirement for this invocation.
In this harness, reachable and executable are prerequisites for evaluating the third. That prevents a successful transaction from being treated as its own compatibility result.
Two application-shaped witnesses
The counter is deliberately small. The same evaluation pattern is easier to use when the requirement has an application-shaped meaning.
Reservation: exact amount versus sufficient capacity
For a request of 100, the baseline leaves provider reservation 100; the
candidate leaves 200. Caller A declares that the result must match the
requested amount exactly. Caller C declares only that it must be at least the
requested amount. The candidate therefore fails A’s accounting requirement and
passes C’s capacity requirement.
This fixture uses before = 0 and delta = 100. AtLeastRequested is
before-relative: after >= before + delta, so here it means an increase of at
least 100. The 100 is a declared expectation, not caller-owned persisted
state. This fixture compares that expectation with provider state; it does not
model caller-owned accounting.
Bounded cost: one realized value, three decisions
The cost witness uses a different compiled candidate from the counter and
reservation B1. The baseline realizes 100 for a quote of 100; the
surcharge candidate realizes 103. Instruction shape, state width, Program
ID, and CPI path stay fixed for the witness. Only the candidate arithmetic
changes.
Caller A accepts exactly 100. Caller C accepts values no greater than
105. Caller D accepts any positive value. For this quote, 103 is outside
A’s declared region and inside C’s and D’s. Transaction and CPI succeed in
all six runs.
For a fixed call, each declared requirement defines a region of acceptable
observed behavior. At 103, the surcharge candidate is outside A’s region and
inside C’s and D’s. Change the starting state or quote and those regions may
change. The relevant context includes the requested amount, quote, and
starting state. These regions are external test requirements, not constraints
enforced by Solana. This is declared-contract conformance: observed behavior
meets the external requirement supplied for that consumer and invocation.
From an observation to a contract outcome
The prototype keeps the semantic judgment small. Once a compiled CPI produces an observation, the evaluator checks that the contract names the same consumer-provider dependency and that execution succeeded. It then applies the declared postcondition.
pub fn evaluate_contract(
contract: &DependencyContract,
observation: &DependencyObservation,
) -> ContractOutcome {
if contract.consumer != observation.consumer || contract.dependency != observation.dependency {
return ContractOutcome::Mismatch;
}
if !observation.tx_success || !observation.cpi_success {
return ContractOutcome::NotEvaluated;
}
let holds = match contract.postcondition {
Postcondition::ExactDelta => observation.after == observation.before + observation.delta,
Postcondition::MonotonicIncrease => observation.after > observation.before,
Postcondition::AtLeastRequested => {
observation.after >= observation.before + observation.delta
}
Postcondition::MaximumValue { max } => observation.after <= max,
Postcondition::Positive => observation.after > 0,
};
if holds {
ContractOutcome::Pass
} else {
ContractOutcome::Fail
}
}
MaximumValue { max } is an absolute cap on observed after; in the
bounded-cost fixture, max = 105.
For the counter candidate, before = 0, delta = 3, and after = 6.
ExactDelta rejects the observation, while MonotonicIncrease accepts it.
The observed value is the same across the two candidate runs; the declared
predicate differs.
That is declared-contract conformance, not proof that the declaration captures every behavior the consumer actually relies on.
A consumer-associated contract.toml names the consumer, dependency, and
postcondition. It is an external test specification, not a Solana account or a
runtime-enforced contract.
A separate mutation harness also exercises controlled structural, wire, state, and semantic failures; that evidence is supporting context rather than the main experiment here.
These fresh local LiteSVM fixtures with pinned SBF artifacts establish a controlled counterexample and an executable testing pattern, not prevalence, production error rates, or behavior under a deployed loader upgrade.
The current prototype evaluates scalar postconditions over one recorded observation. It does not yet express multi-account, relational, or temporal dependency requirements.
Closing
A candidate substitution behind the same Program ID can preserve successful CPI execution while violating a consumer’s declared requirement. The useful pre-change question is therefore not only whether the candidate runs, but which tested consumer-provider requirements still hold under the candidate.
This is demonstrated here in controlled local fixtures.