Variant Contracts
Postconditions can specify different guarantees for each variant case, letting you express precise relationships between inputs and outputs based on which constructor is matched.
Per-Case Postconditions
Use switch expressions in ensures clauses to specify what each variant case should return:
// Exhaustive matching with variant payloads
persistent actor {
type Color = { #red; #blue : Nat; #green : (Nat, Nat) };
public func colorValue(c : Color) : async Nat
ensures switch (c) {
case (#red) result == 4;
case (#blue x) result == x;
case (#green (a, b)) result == a + b;
};
{
switch (c) {
case (#red) 4;
case (#blue x) x;
case (#green (a, b)) a + b;
}
};
}
The postcondition mirrors the implementation structure. Each case binds the same payload variables and specifies the expected result:
#redhas no payload and returns a constant#blue xbindsxand returns it unchanged#green (a, b)binds a tuple and returns the sum
Result-Style Variants
A common pattern is the Result type with success and error variants. Use implication to specify behavior based on input conditions:
// Variant contracts with Result-style types
persistent actor {
type Result = { #ok : Nat; #err : Text };
public func divide(a : Nat, b : Nat) : async Result
ensures b == 0 ==> result == #err("division by zero");
ensures b != 0 ==> result == #ok(a / b);
{
if (b == 0) { #err("division by zero") } else { #ok(a / b) }
};
}
The ==> operator connects conditions to outcomes:
- When
b == 0, the result must be the error variant - When
b != 0, the result must be the success variant with the computed value
The second implication also protects the division in the postcondition: the non-zero divisor fact is available on the right side of ==>. See division safety for the general rule.
Pure Helper Functions
For complex variant logic, extract predicates into pure functions:
// Pure helper functions for variant predicates
persistent actor {
type Status = { #pending; #approved : Nat; #rejected : Text };
pure func isPending(s : Status) : Bool {
switch (s) {
case (#pending) true;
case (_) false;
}
};
pure func getApprovalId(s : Status) : Nat {
switch (s) {
case (#approved id) id;
case (_) 0;
}
};
public func checkPending(s : Status) : async Bool
ensures result == isPending(s);
{
isPending(s)
};
public func extractId(s : Status) : async Nat
ensures result == getApprovalId(s);
{
getApprovalId(s)
};
}
Pure functions that pattern match on variants can be called in postconditions. The verifier inlines these functions and reasons about each case. This keeps postconditions readable while handling complex variant logic.
State Transitions with Variants
Variant types work well for modeling state machines. Preconditions restrict which transitions are valid, and postconditions specify the target state:
// Variant contracts with state transitions
persistent actor {
type Status = { #pending; #approved : Nat; #rejected };
var status : Status = #pending;
public func approve(id : Nat) : async ()
modifies status
entry_requires status == #pending;
requires status == #pending;
ensures status == #approved(id);
{
status := #approved(id)
};
public func reject() : async ()
modifies status
entry_requires status == #pending;
requires status == #pending;
ensures status == #rejected;
{
status := #rejected
};
public func reset() : async ()
modifies status
ensures status == #pending;
{
status := #pending
};
}
Key patterns:
requiresstatus == #pendingrestricts transitions to valid source statesensuresstatus == #approved(id)specifies the exact target variant with its payload- The
resetfunction can transition from any state, so it has no precondition
For public actor methods, source-state preconditions should also be mirrored with entry_requires when callers control the transition.
Generic Postconditions
Sometimes you need a property that holds regardless of which variant case is matched:
// Generic postcondition that must hold for all variant cases
persistent actor {
type Result = { #ok : Nat; #err : Text };
public func safeValue(r : Result) : async Nat
ensures result >= 0;
{
switch (r) {
case (#ok n) n;
case (#err _) 0;
}
};
public func doubleOrZero(r : Result) : async Nat
ensures (switch (r) {
case (#ok n) result == n * 2;
case (#err _) result == 0;
});
ensures result >= 0;
{
switch (r) {
case (#ok n) n * 2;
case (#err _) 0;
}
};
}
The safeValue function has a single postcondition that the verifier must prove for all variant cases. The doubleOrZero function combines per-case postconditions with a generic one.
How Variant Contracts Work
When you write a switch expression in a postcondition, Sector9 translates it to nested conditionals:
ensures switch (c) {
case (#red) result == 4;
case (#blue x) result == x;
case (#green (a, b)) result == a + b;
}
Becomes:
ensures isColor$red(c) ? (result == 4) :
(isColor$blue(c) ? (result == getColor$blue$0(c)) :
(result == getColor$green$0(c) + getColor$green$1(c)))
The verifier generates:
- Discriminator functions like
isColor$red(c)to test which constructor - Getter functions like
getColor$blue$0(c)to extract payloads - Exhaustiveness and exclusivity axioms ensuring every value has one constructor tag
Detecting Mismatches
The verifier catches when postconditions contradict the implementation:
- Wrong
- Correct
// Wrong postcondition: claims #ok returns value + 1, but code returns value
persistent actor {
type Result = { #ok : Nat; #err : Text };
public func process(r : Result) : async Nat
ensures switch (r) {
case (#ok n) result == n + 1; // WRONG: actual returns n
case (#err _) result == 0;
};
{
switch (r) {
case (#ok n) n; // Returns n, not n + 1
case (#err _) 0;
}
};
}
// Generic postcondition that must hold for all variant cases
persistent actor {
type Result = { #ok : Nat; #err : Text };
public func safeValue(r : Result) : async Nat
ensures result >= 0;
{
switch (r) {
case (#ok n) n;
case (#err _) 0;
}
};
public func doubleOrZero(r : Result) : async Nat
ensures (switch (r) {
case (#ok n) result == n * 2;
case (#err _) result == 0;
});
ensures result >= 0;
{
switch (r) {
case (#ok n) n * 2;
case (#err _) 0;
}
};
}
The wrong version claims #ok n returns n + 1, but the implementation returns n. The verifier detects this and reports a postcondition failure.
Using result with Variants
The result keyword works with variant return types. You can:
- Compare
resultto a specific variant:result == #ok(42) - Match on
resultin a switch:switch (result) { case (#ok n) ... } - Combine
resultwith input in tuple patterns:switch (input, result) { ... }
Example with tuple pattern:
public func increment(opt : ?Nat) : async ?Nat
ensures switch (opt, result) {
case (null, null) true;
case (?n, ?m) m == n + 1;
case (_, _) false;
};
The pattern (opt, result) lets you express the relationship between input and output for each case combination.
Restrictions
Variant contracts have the same restrictions as other pattern matching in verification:
| Pattern | Status |
|---|---|
Simple variants (#tag) | Supported |
Variant payloads (#tag x) | Supported |
Tuple payloads (#tag (a, b)) | Supported |
Wildcards (_) | Supported |
Or-patterns (case (0 or 1)) | Frontend supported, Viper-lane unsupported |
Refutable let and for patterns | Viper-lane unsupported |
See Exhaustiveness Checking for details on pattern restrictions.
Summary
- Use
switchinensuresclauses to specify per-case postconditions - Payload variables bound in patterns can appear in postcondition expressions
- Pure helper functions simplify complex variant logic in contracts
- The
==>operator connects conditions to variant outcomes - Variant fields work with state machine patterns using
requires/ensures - The verifier translates switch expressions to nested conditionals with discriminators
- Mismatches between implementation and postcondition trigger verification failures
Related Topics
- Exhaustiveness checking for supported pattern forms
- State machine protocol patterns for actor-field variants
- Result type for standard success/error modeling
- Contract subtyping for preserving contracts across typed helpers