Skip to content

Commit 9ecc297

Browse files
committed
tests(cargo-kani): add solana agent escrow example
1 parent a431aaa commit 9ecc297

11 files changed

+279
-0
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Copyright Kani Contributors
2+
# SPDX-License-Identifier: Apache-2.0 OR MIT
3+
4+
[package]
5+
name = "solana-agent-escrow"
6+
version = "0.1.0"
7+
edition = "2018"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Solana Agent Escrow Example
2+
3+
This is a self-contained example that models a few safety-critical rules common in
4+
Solana-style agent payment flows:
5+
6+
- **Timelock authorization**: only the agent can release funds before expiry; the API
7+
can release only after expiry.
8+
- **Value conservation**: settlement splits always conserve value (refund + payment == amount).
9+
- **Oracle tiering**: required oracle count is tiered by value-at-risk and is monotonic.
10+
- **Escrow FSM**: only legal state transitions are possible.
11+
12+
The model is intentionally minimal (no Solana runtime / Anchor modeling) and exists
13+
primarily to demonstrate Kani verification on agent-style programs.
14+
15+
## Run
16+
17+
```bash
18+
cargo kani --harness timelock_policy_matches_release_rule
19+
cargo kani --harness settlement_splits_conserve_value
20+
cargo kani --harness required_oracle_count_is_monotonic_and_bounded
21+
cargo kani --harness escrow_fsm_actions_respect_transition_table
22+
```
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
VERIFICATION:- SUCCESSFUL
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
VERIFICATION:- SUCCESSFUL
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
VERIFICATION:- SUCCESSFUL
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright Kani Contributors
2+
// SPDX-License-Identifier: Apache-2.0 OR MIT
3+
4+
pub type Key = [u8; 32];
5+
6+
pub fn can_release_funds(caller: Key, agent: Key, api: Key, now: i64, expires_at: i64) -> bool {
7+
let is_agent = caller == agent;
8+
let is_api = caller == api;
9+
let time_lock_expired = now >= expires_at;
10+
11+
is_agent || (is_api && time_lock_expired)
12+
}
13+
14+
pub fn dispute_settlement(amount: u64, refund_percentage: u8) -> (u64, u64) {
15+
let refund_amount = (amount as u128 * refund_percentage as u128 / 100) as u64;
16+
let payment_amount = amount - refund_amount;
17+
(refund_amount, payment_amount)
18+
}
19+
20+
pub fn inference_settlement(amount: u64, quality_threshold: u8, quality_score: u8) -> (u64, u64) {
21+
if quality_score >= quality_threshold {
22+
return (0, amount);
23+
}
24+
25+
if quality_score >= 50 {
26+
let provider_share = (amount as u128 * quality_score as u128 / 100) as u64;
27+
let user_refund = amount - provider_share;
28+
return (user_refund, provider_share);
29+
}
30+
31+
(amount, 0)
32+
}
33+
34+
pub fn expired_escrow_settlement(amount: u64, was_disputed: bool) -> (u64, u64) {
35+
if !was_disputed {
36+
return (amount, 0);
37+
}
38+
39+
let half = amount / 2;
40+
(half, amount - half)
41+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright Kani Contributors
2+
// SPDX-License-Identifier: Apache-2.0 OR MIT
3+
4+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5+
pub enum EscrowState {
6+
Active,
7+
Disputed,
8+
Released,
9+
Resolved,
10+
}
11+
12+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13+
pub enum EscrowAction {
14+
Release,
15+
MarkDisputed,
16+
Resolve,
17+
ClaimExpired,
18+
}
19+
20+
pub fn is_terminal(state: EscrowState) -> bool {
21+
matches!(state, EscrowState::Released | EscrowState::Resolved)
22+
}
23+
24+
pub fn valid_transition(from: EscrowState, to: EscrowState) -> bool {
25+
matches!(
26+
(from, to),
27+
(EscrowState::Active, EscrowState::Disputed)
28+
| (EscrowState::Active, EscrowState::Released)
29+
| (EscrowState::Active, EscrowState::Resolved)
30+
| (EscrowState::Disputed, EscrowState::Resolved)
31+
)
32+
}
33+
34+
pub fn step(state: EscrowState, action: EscrowAction) -> Option<EscrowState> {
35+
if is_terminal(state) {
36+
return None;
37+
}
38+
39+
match (state, action) {
40+
(EscrowState::Active, EscrowAction::Release) => Some(EscrowState::Released),
41+
(EscrowState::Active, EscrowAction::MarkDisputed) => Some(EscrowState::Disputed),
42+
(EscrowState::Active, EscrowAction::Resolve) => Some(EscrowState::Resolved),
43+
(EscrowState::Active, EscrowAction::ClaimExpired) => Some(EscrowState::Resolved),
44+
(EscrowState::Disputed, EscrowAction::Resolve) => Some(EscrowState::Resolved),
45+
(EscrowState::Disputed, EscrowAction::ClaimExpired) => Some(EscrowState::Resolved),
46+
_ => None,
47+
}
48+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Copyright Kani Contributors
2+
// SPDX-License-Identifier: Apache-2.0 OR MIT
3+
4+
pub mod escrow;
5+
pub mod fsm;
6+
pub mod oracle;
7+
8+
#[cfg(kani)]
9+
mod proofs;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright Kani Contributors
2+
// SPDX-License-Identifier: Apache-2.0 OR MIT
3+
4+
pub const MIN_ORACLES: u8 = 3;
5+
pub const TIER2_ESCROW_THRESHOLD: u64 = 10;
6+
pub const TIER3_ESCROW_THRESHOLD: u64 = 100;
7+
8+
pub fn required_oracle_count(escrow_amount: u64) -> u8 {
9+
if escrow_amount >= TIER3_ESCROW_THRESHOLD {
10+
5
11+
} else if escrow_amount >= TIER2_ESCROW_THRESHOLD {
12+
4
13+
} else {
14+
MIN_ORACLES
15+
}
16+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright Kani Contributors
2+
// SPDX-License-Identifier: Apache-2.0 OR MIT
3+
4+
use crate::escrow::*;
5+
use crate::fsm::*;
6+
use crate::oracle::*;
7+
8+
#[kani::proof]
9+
fn timelock_policy_matches_release_rule() {
10+
let caller: Key = kani::any();
11+
let agent: Key = kani::any();
12+
let api: Key = kani::any();
13+
let now: i64 = kani::any();
14+
let expires_at: i64 = kani::any();
15+
16+
let allowed = can_release_funds(caller, agent, api, now, expires_at);
17+
18+
if now < expires_at {
19+
kani::assert(
20+
!allowed || caller == agent,
21+
"only agent can release before expiry",
22+
);
23+
} else {
24+
kani::assert(
25+
!allowed || (caller == agent || caller == api),
26+
"only agent/api can release after expiry",
27+
);
28+
}
29+
}
30+
31+
#[kani::proof]
32+
fn settlement_splits_conserve_value() {
33+
let amount: u64 = kani::any::<u32>() as u64;
34+
35+
let refund_percentage: u8 = kani::any();
36+
kani::assume(refund_percentage <= 100);
37+
38+
let (refund, payment) = dispute_settlement(amount, refund_percentage);
39+
kani::assert(
40+
refund as u128 + payment as u128 == amount as u128,
41+
"dispute settlement must conserve value",
42+
);
43+
44+
let quality_threshold: u8 = kani::any();
45+
let quality_score: u8 = kani::any();
46+
kani::assume(quality_threshold <= 100);
47+
kani::assume(quality_score <= 100);
48+
49+
let (user_refund, provider_payment) =
50+
inference_settlement(amount, quality_threshold, quality_score);
51+
kani::assert(
52+
user_refund as u128 + provider_payment as u128 == amount as u128,
53+
"inference settlement must conserve value",
54+
);
55+
56+
let was_disputed: bool = kani::any();
57+
let (agent_amount, api_amount) = expired_escrow_settlement(amount, was_disputed);
58+
kani::assert(
59+
agent_amount as u128 + api_amount as u128 == amount as u128,
60+
"expired escrow claim must conserve value",
61+
);
62+
}
63+
64+
#[kani::proof]
65+
fn required_oracle_count_is_monotonic_and_bounded() {
66+
let amount: u64 = kani::any();
67+
let r = required_oracle_count(amount);
68+
kani::assert(
69+
r == MIN_ORACLES || r == 4 || r == 5,
70+
"required oracle count must be in {3,4,5}",
71+
);
72+
73+
let a1: u64 = kani::any();
74+
let a2: u64 = kani::any();
75+
kani::assume(a1 <= a2);
76+
77+
let r1 = required_oracle_count(a1);
78+
let r2 = required_oracle_count(a2);
79+
kani::assert(r1 <= r2, "oracle requirement must be monotonic");
80+
81+
kani::assert(
82+
required_oracle_count(TIER2_ESCROW_THRESHOLD - 1) == MIN_ORACLES,
83+
"below tier2 threshold must use minimum",
84+
);
85+
kani::assert(
86+
required_oracle_count(TIER2_ESCROW_THRESHOLD) == 4,
87+
"tier2 threshold must require 4 oracles",
88+
);
89+
kani::assert(
90+
required_oracle_count(TIER3_ESCROW_THRESHOLD - 1) == 4,
91+
"just below tier3 threshold must still be tier2",
92+
);
93+
kani::assert(
94+
required_oracle_count(TIER3_ESCROW_THRESHOLD) == 5,
95+
"tier3 threshold must require 5 oracles",
96+
);
97+
}
98+
99+
fn any_state() -> EscrowState {
100+
match kani::any::<u8>() % 4 {
101+
0 => EscrowState::Active,
102+
1 => EscrowState::Disputed,
103+
2 => EscrowState::Released,
104+
_ => EscrowState::Resolved,
105+
}
106+
}
107+
108+
fn any_action() -> EscrowAction {
109+
match kani::any::<u8>() % 4 {
110+
0 => EscrowAction::Release,
111+
1 => EscrowAction::MarkDisputed,
112+
2 => EscrowAction::Resolve,
113+
_ => EscrowAction::ClaimExpired,
114+
}
115+
}
116+
117+
#[kani::proof]
118+
fn escrow_fsm_actions_respect_transition_table() {
119+
let state = any_state();
120+
let action = any_action();
121+
122+
let next = step(state, action);
123+
124+
if is_terminal(state) {
125+
kani::assert(next.is_none(), "terminal states must not transition");
126+
return;
127+
}
128+
129+
if let Some(s2) = next {
130+
kani::assert(valid_transition(state, s2), "transition must be valid");
131+
}
132+
}

0 commit comments

Comments
 (0)