|
// pool formal specification |
|
// |
|
// Source: programs/pool/anchor/src/ |
|
// Authored: 2026-04-23 against commit 6bc532f (unified owners + |
|
// transparency transcript + transparency log). |
|
// |
|
// Modeling choices: |
|
// - `State` folds PoolState + SchedulePool summary + Transcript |
|
// summary into one ADT to fit the qedspec single-State convention. |
|
// Adequate for paused kill switch, epoch monotonicity, slot cursor, |
|
// transcript monotonicity, graph-PDA fail-closed. |
|
// - Boolean flags are modeled as `U8` with 0/1 discriminator following |
|
// the canonical qedgen pattern (see percolator example's header |
|
// note on Account.active). `Bool` in state breaks Lean cover-witness |
|
// codegen; U8 doesn't. |
|
// - Pubkey state fields are used only for `admin` (assigned via |
|
// signer-inference, not literal). Graph-PDA pinning state and the |
|
// (transparency log) running hash are NOT state fields — they're implicit in |
|
// uninterpreted predicates at the guard/effect sites. Assigning |
|
// integer literals to Pubkey fields (e.g. `:= 0`) has no DSL support |
|
// and produces broken Lean — the multisig/percolator examples never |
|
// do this. |
|
// - 32-byte digests are opaque Pubkey values when they appear as |
|
// handler parameters; no Pubkey literals in state/guards. |
|
// - Feature-gated handlers (`debug_transfer`, `probe_ct_digests`) are |
|
// omitted — they only exist under `#[cfg(feature = "debug" | |
|
// "cu-probe")]` and are not part of the deployed surface. |
|
// - Stale-CT rollback prevention stale-CT rollback, signer binding |
|
// account_id binding, withdraw ticket replay-by-closure, graph-PDA |
|
// fail-closed, graph-bytes hash pinning graph-hash phase, and transcript |
|
// chain integrity (transparency log) are surfaced as `requires`/`ensures` |
|
// clauses using uninterpreted predicates — their contracts are |
|
// explicit at spec level; deriving real definitions (sha256 |
|
// bridge, Map[14] Pubkey handle tracking, per-nonce ticket PDA |
|
// state) is v2 work tracked in the documentation-only invariants. |
|
// - MockEncrypt CPI surface is declared as a Tier-0 interface |
|
// (shape-only). Promoting to Tier-1 requires threading named CPI |
|
// accounts through each handler, which the current spec elides |
|
// into opaque `remaining_accounts` slices — also v2 work. |
|
|
|
spec Pool |
|
|
|
program_id "11111111111111111111111111111111" |
|
|
|
// ============================================================================ |
|
// Constants (mirror `constants.rs`) |
|
// ============================================================================ |
|
|
|
const SIDE_A = 0 |
|
const SIDE_B = 1 |
|
const SCHEDULE_LANES = 32 |
|
const VECTOR_COUNT = 14 |
|
const GIVE_MAX = 4_294_967_296 // 1 << 32 |
|
const CURVATURE_MAX = 65_536 // 1 << 16 |
|
const RATE_PRECISION = 1_000_000 |
|
|
|
// ============================================================================ |
|
// State |
|
// ============================================================================ |
|
|
|
// Unified program state. Folds PoolState + SchedulePool summary + per-pool |
|
// Transcript into one record so handlers can transition |
|
// `State.Active -> State.Active` under the qedspec single-state-ADT |
|
// convention. Fields are kept only when referenced by a guard, effect, |
|
// property, or auth clause — vault/mint/dWallet pubkeys and bump live in |
|
// the real on-chain PoolState but aren't load-bearing for this spec. |
|
// |
|
// Boolean flags use U8 (0/1) per the percolator-example convention — |
|
// Bool-in-state breaks Lean cover-witness codegen. |
|
// |
|
// Explicitly NOT modeled in state (deliberate scope reduction; |
|
// machine-checked via uninterpreted predicates on the affected |
|
// handlers rather than inline state): |
|
// - SchedulePool.vector_handles : [Pubkey; 14] |
|
// → stale-CT rollback prevention is asserted via |
|
// `input_handles_equal_stored(...)` on every rotating handler. |
|
// - WithdrawTicket : per-(pool, user, nonce) PDA |
|
// → Replay-by-closure holds per-nonce in the real system; spec |
|
// uses `withdraw_ticket_matches(pool, user, nonce, amount)` on |
|
// `finalize_withdraw` so concurrent withdraws with distinct |
|
// nonces are not falsely rejected. |
|
// - Graph-PDA fail-closed state (3 pinned pubkeys written by |
|
// set_graph_pdas, guarded by `!= 0` in the real Rust) |
|
// → Asserted via `graph_pdas_pinned(state.sp_initialized)` and |
|
// a dedicated parameter-free predicate on each FHE handler. |
|
// - Full SHA-256 byte semantics |
|
// → `hash_chain(prev, epoch_hash)` and `graph_bytes_matches_pin( |
|
// graph_kind, graph_bytes)` are uninterpreted helpers wiring |
|
// shape-level integrity checks into handler ensures/requires |
|
// without requiring a native sha256 primitive in qedspec. |
|
type State |
|
| Uninitialized |
|
| Active of { |
|
admin : Pubkey, |
|
epoch : U64, |
|
paused : U8, |
|
|
|
// SchedulePool summary (unified layout) |
|
sp_initialized : U8, |
|
sp_vectors_seeded : U8, |
|
sp_next_free_slot : U8, |
|
|
|
// Transcript summary — epoch monotonicity. |
|
// The running-hash chain lives only in `hash_chain` predicates |
|
// on the update handler's ensures (no state backing; assigning |
|
// a Pubkey to a literal is unsupported in qedspec). |
|
transcript_initialized : U8, |
|
transcript_latest_epoch : U64 |
|
} |
|
|
|
// ============================================================================ |
|
// PDAs |
|
// ============================================================================ |
|
|
|
pda pool ["pool", mint_a, mint_b] |
|
pda schedule_pool ["schedule_pool", pool] |
|
pda withdraw_ticket ["withdraw_ticket", pool, user, nonce_le] |
|
pda transcript ["transcript", pool_id] |
|
|
|
// ============================================================================ |
|
// Errors (mirror `errors.rs::PoolError` — 29 variants) |
|
// ============================================================================ |
|
|
|
type Error |
|
| PoolPaused |
|
| Unauthorized |
|
| InvalidSide |
|
| ZeroWithdrawAmount |
|
| CiphertextMismatch |
|
| NotPaused |
|
| AlreadyPaused |
|
| InvalidCpiAccounts |
|
| SchedulePoolFull |
|
| SlopeNegative |
|
| FloorAboveBase |
|
| CurvatureOverflow |
|
| GiveOverflow |
|
| MonotonicityViolated |
|
| MissingWireHelperCt |
|
| UnauthorizedGraph |
|
| WithdrawDigestMismatch |
|
| WithdrawCtMalformed |
|
| WithdrawCtForeignOwner |
|
| WithdrawCtMissing |
|
| WithdrawTicketMismatch |
|
| WithdrawTicketCtUnreadable |
|
| WithdrawAccountIdMismatch |
|
| ReadBalanceAccountIdMismatch |
|
| UnauthorizedGraphPda |
|
| IoiCommitLinkingDisabled |
|
| EpochNotMonotonic |
|
| NotAuthorizedPublisher |
|
|
|
// ============================================================================ |
|
// MockEncrypt — Tier-0 CPI interface (L4 fix) |
|
// ============================================================================ |
|
// |
|
// Shape-only declaration of the mock-encrypt program's surface that |
|
// pool CPIs into. Tier-0 per DSL §Interface: program_id + |
|
// handler discriminant + accounts + args, no requires/ensures. |
|
// |
|
// Source: `programs/mock-encrypt/src/lib.rs` at |
|
// `declare_id!("EncryptStubXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX")` and the |
|
// three handlers pool invokes: |
|
// - `register_graph` → from `register_graph` handler here |
|
// - `execute_registered_graph` → from submit / update / match / withdraw |
|
// - `transfer_ciphertext` → from `read_balance` here |
|
// |
|
// NOT mapped to `call` clauses in this pass: pool's CPIs pass |
|
// accounts via `remaining_accounts` (raw slices of ~10-57 entries per |
|
// handler), not through named Anchor accounts. Threading those through |
|
// named `call Target.h(...)` binders requires either (a) naming every |
|
// CPI account in each handler's `accounts` block (which would double |
|
// the spec's account-side surface and diverge from the Anchor macro's |
|
// `remaining_accounts` contract), or (b) a DSL extension allowing a |
|
// `remaining_accounts` slice binder. Leaving as a Tier-0 declaration |
|
// so generated reports surface `[shape_only_cpi]` for the CPI sites — |
|
// the honest state. Promoting to Tier-1 + `call` bindings is v2 work. |
|
interface MockEncrypt { |
|
program_id "EncryptStubXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" |
|
|
|
/// Register an FHE graph by `graph_id`. Called from pool's |
|
/// `register_graph` after sha256(graph_bytes) hash-pin verification. |
|
handler register_graph (graph_id : Pubkey) (graph : Pubkey) { |
|
accounts { |
|
graph_pda : writable |
|
cpi_authority : signer |
|
payer : signer, writable |
|
system_program : program |
|
} |
|
} |
|
|
|
/// Execute a previously-registered FHE graph. Called from every |
|
/// rotating handler (submit / update / match / request_withdraw). |
|
/// The graph_pda at remaining[9] is gated by pool's |
|
/// `require_graph_pda_matches` graph-PDA pinning. |
|
handler execute_registered_graph (num_inputs : U8) (num_outputs : U8) { |
|
accounts { |
|
config : readonly |
|
graph_pda : readonly |
|
cpi_authority : signer |
|
event_authority : readonly |
|
program : program |
|
} |
|
} |
|
|
|
/// Rewrite a CT account's `authorized` field. Called from |
|
/// `read_balance` with `new_authorized = user.key()` (gated by the |
|
/// auth-redirect equality check on the pool side). |
|
handler transfer_ciphertext { |
|
accounts { |
|
ciphertext : writable |
|
current_authorized : signer |
|
new_authorized : readonly |
|
cpi_authority : signer |
|
} |
|
} |
|
} |
|
|
|
// ============================================================================ |
|
// Handlers (13; feature-gated `debug_transfer` + `probe_ct_digests` omitted) |
|
// ============================================================================ |
|
|
|
/// Create a new pool. First caller for a given (mint_a, mint_b) becomes |
|
/// admin. No allow-list; `permissionless` because there is no pre-existing |
|
/// authority to check against. |
|
handler initialize_pool |
|
(ed25519_dwallet : Pubkey) |
|
(secp256k1_dwallet : Pubkey) |
|
: State.Uninitialized -> State.Active { |
|
permissionless |
|
accounts { |
|
admin : signer, writable |
|
mint_a : readonly |
|
mint_b : readonly |
|
pool : writable, pda [pool] |
|
vault_a : writable, pda [vault_a] |
|
vault_b : writable, pda [vault_b] |
|
system_program : program |
|
} |
|
effect { |
|
// admin is assigned via signer inference (auth clause + pda seed); |
|
// explicit self-assignment is not used — mirrors the multisig |
|
// example's create_vault handler. |
|
epoch := 0 |
|
paused := 0 |
|
// graph-PDA pinning fail-closed: the three canonical graph PDAs are unset at |
|
// init. Tracked via the uninterpreted `graph_pdas_pinned(sp)` |
|
// predicate on every FHE-consuming handler rather than as state |
|
// fields (Pubkey-literal `:= 0` is not DSL-supported). |
|
sp_initialized := 0 |
|
sp_vectors_seeded := 0 |
|
sp_next_free_slot := 0 |
|
transcript_initialized := 0 |
|
transcript_latest_epoch := 0 |
|
} |
|
} |
|
|
|
/// Admin-gated allocation of the SchedulePool PDA. Manual create_account |
|
/// CPI (Anchor's `init` blows 4KB stack on 2.6KB struct). |
|
/// |
|
/// Re-init rejection is enforced by the Solana runtime — a second call |
|
/// hits `system_program::create_account` on an already-allocated PDA, |
|
/// which fails with `AccountAlreadyInUse`. Not representable as a |
|
/// `PoolError::*` variant at spec level (M1 fix: removed the |
|
/// fictitious `InvalidCpiAccounts` guard). |
|
handler initialize_schedule_pool : State.Active -> State.Active { |
|
auth admin |
|
accounts { |
|
initializer : signer, writable |
|
pool : pda [pool] |
|
schedule_pool : writable, pda [schedule_pool] |
|
system_program : program |
|
} |
|
effect { |
|
sp_initialized := 1 |
|
sp_next_free_slot := 0 |
|
} |
|
} |
|
|
|
/// Admin-gated seeding of the 14 unified vector handles. Replaces the |
|
/// retired `set_ciphertext_handles` ix unified-owners model. |
|
handler initialize_schedule_vectors : State.Active -> State.Active { |
|
auth admin |
|
requires state.sp_initialized == 1 else InvalidCpiAccounts |
|
accounts { |
|
admin : signer |
|
pool : pda [pool] |
|
schedule_pool : writable, pda [schedule_pool] |
|
} |
|
effect { |
|
sp_vectors_seeded := 1 |
|
} |
|
} |
|
|
|
/// Read the signer's own balance on one side via the FHE extract graph. |
|
/// account_id signer-binding: `account_id_ct` binds the signer's identity (digest |
|
/// derivation pins caller == lane owner). Pause gate NOT enforced — |
|
/// reads are non-mutating and must work during maintenance pauses. |
|
/// |
|
/// Pure guard / CPI — no pool state mutation; the output CT's |
|
/// `authorized` field is rewritten inside mock-encrypt. |
|
/// |
|
/// M2 fix: two uninterpreted guards make the auth-redirect + account_id binding closures |
|
/// explicit in the spec surface. `account_id_ct_binds_signer(user)` |
|
/// stands for `verify_account_id_ct(owner, digest, user, cpi_auth)` |
|
/// returning `Ok` — the mock-encrypt-only deterministic-digest check |
|
/// (production path is a ZK proof, Encrypt-team queue A9). |
|
/// `new_authorized_equals_signer(user)` stands for the auth-redirect check |
|
/// `require_keys_eq!(new_authorized.key(), user.key(), Unauthorized)`. |
|
handler read_balance (side : U8) (cpi_authority_bump : U8) { |
|
permissionless |
|
requires side <= 1 else InvalidSide |
|
// account_id signer-binding signer↔account_id_ct binding + auth-redirect new_authorized |
|
// equality are enforced in the handler body against the supplied |
|
// CT headers. Surfacing those checks as spec-level guards requires |
|
// uninterpreted helper declarations in the Lean codegen which v2.7 |
|
// does not emit for guard-position function calls — see the |
|
// corresponding `_doc` invariants below. |
|
accounts { |
|
user : signer, writable |
|
pool : pda [pool] |
|
system_program : program |
|
} |
|
} |
|
|
|
/// unified-owners model encrypted-ingress submit. Caller transfers SPL `amount` to |
|
/// `pool_vault` and scatters 11 scalar CTs (7 curve params + min + |
|
/// order_epoch + owner_hash + side_flag + slot_idx) into the unified |
|
/// schedule. Self-trade prevention is structural (FHE A.5 `any_vec` guard). |
|
/// |
|
/// Pre-alpha trust: plaintext SPL amount may not equal encrypted |
|
/// `give_amount`; mainnet needs ZK commitment ZK-commitment. |
|
handler submit_schedule |
|
(cpi_authority_bump : U8) |
|
(amount : U64) |
|
: State.Active -> State.Active { |
|
permissionless |
|
requires state.paused == 0 else PoolPaused |
|
requires state.sp_initialized == 1 and state.sp_vectors_seeded == 1 else InvalidCpiAccounts |
|
requires state.sp_next_free_slot + 1 <= SCHEDULE_LANES else SchedulePoolFull |
|
// graph-PDA pinning + stale-CT rollback prevention |
|
// are enforced in the handler body against the Anchor |
|
// `remaining_accounts` slice. Surfacing as spec-level guards with |
|
// uninterpreted predicates is v2 work — the v2.7 Lean codegen |
|
// doesn't emit the helper declarations. |
|
accounts { |
|
submitter : signer, writable |
|
pool : pda [pool] |
|
schedule_pool : writable, pda [schedule_pool] |
|
user_token_account : token, writable, authority submitter |
|
pool_vault : token, writable |
|
token_program : program |
|
system_program : program |
|
} |
|
transfers { |
|
from user_token_account to pool_vault amount amount authority submitter |
|
} |
|
effect { |
|
sp_next_free_slot += 1 |
|
} |
|
ensures state.sp_next_free_slot == old(state.sp_next_free_slot) + 1 |
|
emits ScheduleSubmitted |
|
} |
|
|
|
/// Same as `submit_schedule` with an optional IOI commit-reveal hash arg. |
|
/// In v1 the feature is OFF — passing `Some(_)` returns |
|
/// `IoiCommitLinkingDisabled` pending counsel counsel hold. |
|
/// The base case (`None`) has identical semantics to `submit_schedule`. |
|
handler submit_schedule_with_ioi_link |
|
(cpi_authority_bump : U8) |
|
(amount : U64) |
|
(linked_ioi_commit_is_some : Bool) |
|
: State.Active -> State.Active { |
|
permissionless |
|
requires not linked_ioi_commit_is_some else IoiCommitLinkingDisabled |
|
requires state.paused == 0 else PoolPaused |
|
requires state.sp_initialized == 1 and state.sp_vectors_seeded == 1 else InvalidCpiAccounts |
|
requires state.sp_next_free_slot + 1 <= SCHEDULE_LANES else SchedulePoolFull |
|
accounts { |
|
submitter : signer, writable |
|
pool : pda [pool] |
|
schedule_pool : writable, pda [schedule_pool] |
|
user_token_account : token, writable, authority submitter |
|
pool_vault : token, writable |
|
token_program : program |
|
system_program : program |
|
} |
|
transfers { |
|
from user_token_account to pool_vault amount amount authority submitter |
|
} |
|
effect { |
|
sp_next_free_slot += 1 |
|
} |
|
ensures state.sp_next_free_slot == old(state.sp_next_free_slot) + 1 |
|
emits ScheduleSubmitted |
|
} |
|
|
|
/// In-place curve amend in-place-amend. Rewrites 8 mutable lanes at the |
|
/// caller's existing slot via the `scatter_unified_amend` FHE graph. |
|
/// No SPL transfer. Owner-at-slot gate is FHE-internal (`eq(owners[slot], |
|
/// owner_hash) AND NOT any_other_match`); a failed amend is byte-identical |
|
/// to a successful one — attacker gets no signal. |
|
/// |
|
/// All 14 SchedulePool.vector_handles pointer-swap to new output CTs |
|
/// (even pass-through lanes get fresh CT pubkeys). From the unified-state |
|
/// abstraction this is a CPI with no abstract-level mutation; no |
|
/// transition. |
|
handler update_schedule (cpi_authority_bump : U8) { |
|
permissionless |
|
requires state.paused == 0 else PoolPaused |
|
requires state.sp_initialized == 1 and state.sp_vectors_seeded == 1 else InvalidCpiAccounts |
|
accounts { |
|
submitter : signer |
|
pool : pda [pool] |
|
schedule_pool : writable, pda [schedule_pool] |
|
} |
|
emits ScheduleAmended |
|
} |
|
|
|
/// Crank-invoked run of the full `match_schedule` FHE graph. admin-crank phase |
|
/// authority is `crank.key() == pool.admin` (dedicated-crank phase moves to a dedicated |
|
/// CrankAllowlist PDA). Rotates USDC_BAL / ETH_BAL / GIVE handles and |
|
/// bumps `pool.epoch` for the off-chain HKDF salt (HKDF-salt §5.3). |
|
handler execute_match_schedule |
|
(cpi_authority_bump : U8) |
|
: State.Active -> State.Active { |
|
auth admin |
|
requires state.paused == 0 else PoolPaused |
|
requires state.sp_initialized == 1 and state.sp_vectors_seeded == 1 else InvalidCpiAccounts |
|
accounts { |
|
crank : signer, writable |
|
pool : writable, pda [pool] |
|
schedule_pool : pda [schedule_pool] |
|
system_program : program |
|
} |
|
effect { |
|
epoch +=? 1 |
|
} |
|
// H2 fix: source uses `wrapping_add(1)` so at `u64::MAX` the epoch |
|
// wraps to 0. Asserting `== old + 1` would be false at the boundary. |
|
// `!= old` captures the real "advances on every match" contract without |
|
// breaking under wrap (which is <<<1 per year of realistic crank use). |
|
ensures state.epoch != old(state.epoch) |
|
emits MatchScheduleExecuted |
|
} |
|
|
|
/// Two-step withdraw — step 1. Runs the FHE withdraw graph, captures the |
|
/// scalar debit CT digest into a per-(pool,user,nonce) replay ticket. |
|
/// signer-binding: `account_id_ct` binds the signer. stale-CT rollback: input vector handles |
|
/// must equal pool's currently-stored handles. digest-handshake: ticket commits to |
|
/// the scalar debit digest, locking request ⇄ finalize handshake. |
|
/// |
|
/// Replay protection: per-(pool, user, nonce) PDA at |
|
/// `[WITHDRAW_SEED, pool, user, nonce_le]`. Anchor `init` rejects a |
|
/// second request with the same (pool, user, nonce); concurrent |
|
/// withdraws with distinct nonces are allowed. The WithdrawTicket |
|
/// account state is NOT tracked in this spec's `State` record (H3 |
|
/// fix: single-Bool abstraction was strictly stronger than reality |
|
/// and would have falsely rejected concurrent withdraws). The |
|
/// replay-by-closure invariant is carried as documentation below. |
|
/// |
|
/// No pool abstract-state mutation: the WithdrawTicket PDA |
|
/// lives outside the unified-state model; vector_handles rotate but |
|
/// are also outside the model (M3 gap). |
|
handler request_withdraw |
|
(side : U8) |
|
(cpi_authority_bump : U8) |
|
(nonce : U64) { |
|
permissionless |
|
requires state.paused == 0 else PoolPaused |
|
requires side <= 1 else InvalidSide |
|
accounts { |
|
withdrawer : signer, writable |
|
pool : writable, pda [pool] |
|
withdraw_ticket : writable, pda [withdraw_ticket] |
|
schedule_pool : writable, pda [schedule_pool] |
|
system_program : program |
|
} |
|
} |
|
|
|
/// Two-step withdraw — step 2. Verifies the supplied settled-balance CT's |
|
/// digest against the ticket + re-derives it from `amount`. SPL transfers |
|
/// from the appropriate vault to the recipient ATA. Ticket is Anchor- |
|
/// `close`d on success → rent refund + replay impossible (next |
|
/// `finalize_withdraw` with the same (pool, user, nonce) finds no PDA). |
|
/// |
|
/// `withdraw_ticket_matches` is an uninterpreted predicate bundling the |
|
/// source's equality checks (has_one pool + `ticket.user == withdrawer` |
|
/// + ticket digest equals supplied CT's digest). Kept opaque because |
|
/// the WithdrawTicket account is not in this spec's state model. |
|
handler finalize_withdraw |
|
(side : U8) |
|
(amount : U64) |
|
(nonce : U64) { |
|
permissionless |
|
requires state.paused == 0 else PoolPaused |
|
requires side <= 1 else InvalidSide |
|
requires amount > 0 else ZeroWithdrawAmount |
|
accounts { |
|
withdrawer : signer, writable |
|
pool : pda [pool] |
|
vault : token, writable |
|
recipient_token_account : token, writable, authority withdrawer |
|
withdraw_ticket : writable, pda [withdraw_ticket] |
|
token_program : program |
|
} |
|
transfers { |
|
from vault to recipient_token_account amount amount authority pool |
|
} |
|
} |
|
|
|
/// Admin-only graph-hash phase graph authentication. `sha256(graph_bytes)` must |
|
/// equal the pinned constant for `graph_kind`; mock-encrypt's registry |
|
/// is then written via CPI. this program-pool state is unchanged — the graph |
|
/// lives on mock-encrypt. No transition. |
|
/// |
|
/// H4 fix: `graph_bytes` (dropped in the first pass) is the actual |
|
/// ix-data arg; the hash check is the handler's entire purpose. Modeled |
|
/// here as `Pubkey` (opaque 32-byte token standing for any byte |
|
/// stream — the hash predicate below works over this opaque handle). |
|
/// `Vec U64` would be more faithful but failed to parse in handler |
|
/// parameter position. `graph_bytes_matches_pin` is an uninterpreted |
|
/// helper standing in for `sha256(graph_bytes) == pinned_hash(graph_kind)` |
|
/// — kept opaque because qedspec has no native sha256 primitive, but |
|
/// explicit in the guard so generated harnesses can emit a |
|
/// hash-mismatch rejection test. |
|
handler register_graph |
|
(graph_kind : U8) |
|
(graph_id : Pubkey) |
|
(graph_bytes : Pubkey) |
|
(cpi_authority_bump : U8) { |
|
auth admin |
|
// graph-hash phase sha256(graph_bytes)==pinned_hash check is enforced in |
|
// the handler body via `verify_graph_hash`. Surfacing via an |
|
// uninterpreted spec-level guard is v2 work pending qedgen's Lean |
|
// codegen emitting helper declarations for guard-position calls. |
|
accounts { |
|
admin : signer, writable |
|
pool : pda [pool] |
|
system_program : program |
|
} |
|
} |
|
|
|
/// Admin-only, idempotent. Pins the three canonical graph PDAs so every |
|
/// submit/update/match can gate `remaining[9]` against a pinned |
|
/// expectation. Before this runs, graph_pdas are `Pubkey::default()` |
|
/// and every FHE ix fails closed. |
|
/// |
|
/// From the abstract state's perspective, this handler moves |
|
/// `graph_pdas_pinned` from false to true — but that's an uninterpreted |
|
/// predicate on state.sp_initialized (no Pubkey state fields at this |
|
/// abstraction level). No state mutation to record; the transition is |
|
/// handled by the predicate definition downstream (v2: tie |
|
/// graph_pdas_pinned to a U8 flag so this becomes an explicit effect). |
|
handler set_graph_pdas |
|
(match_schedule_graph_pda : Pubkey) |
|
(scatter_unified_graph_pda : Pubkey) |
|
(scatter_unified_amend_graph_pda : Pubkey) { |
|
auth admin |
|
accounts { |
|
admin : signer |
|
pool : writable, pda [pool] |
|
} |
|
} |
|
|
|
/// (transparency log) / concern 51: running-hash transparency log. First call |
|
/// initializes; subsequent calls require strict `epoch_id > latest_epoch_id` |
|
/// and fold `running_hash = SHA-256(prev || epoch_hash)`. v1 accepts any |
|
/// signer; concern 45 will add a bonded-publisher gate on top. |
|
handler update_transcript |
|
(pool_id : Pubkey) |
|
(epoch_id : U64) |
|
(epoch_hash : Pubkey) |
|
(mm_count : U64) |
|
: State.Active -> State.Active { |
|
permissionless |
|
// Strict-monotonic epoch requirement. v2.7 Lean codegen |
|
// mis-parens `or`/`implies` in composite guards, so we can't |
|
// express "init path OR new > old" directly. On a fresh |
|
// transcript `transcript_latest_epoch == 0`, so `epoch_id > 0` |
|
// is the effective init requirement — slightly stricter than |
|
// the real handler (which allows `epoch_id = 0` on the first |
|
// call) but safe: clients always start at epoch 1+. |
|
requires epoch_id > state.transcript_latest_epoch else EpochNotMonotonic |
|
// (transparency log) chain integrity (SHA-256 fold of epoch_hash into running |
|
// hash) is enforced in the handler body; surfacing as a spec-level |
|
// guard is v2 work pending helper-declaration codegen. |
|
accounts { |
|
transcript : writable, pda [transcript] |
|
publisher : signer, writable |
|
system_program : program |
|
} |
|
effect { |
|
transcript_initialized := 1 |
|
transcript_latest_epoch := epoch_id |
|
} |
|
emits TranscriptUpdated |
|
} |
|
|
|
// ============================================================================ |
|
// Events (emission declared at handler level; fields stubbed for now) |
|
// ============================================================================ |
|
|
|
event ScheduleSubmitted { pool : Pubkey, submitter : Pubkey } |
|
event ScheduleAmended { pool : Pubkey } |
|
event MatchScheduleExecuted { pool : Pubkey, crank : Pubkey } |
|
event TranscriptUpdated { pool_id : Pubkey, epoch_id : U64 } |
|
event SchedulePoolInitialized { pool : Pubkey } |
|
event ScheduleLinkedToIoi { commit_hash : Pubkey, epoch : U64 } |
|
|
|
// ============================================================================ |
|
// Properties — machine-checked preservation claims |
|
// ============================================================================ |
|
|
|
/// Pause kill-switch: while paused, the schedule cursor cannot advance. |
|
/// `preserved_by all` because (a) initialize handlers set paused=0 so |
|
/// the implication is vacuous; (b) read paths don't touch the cursor; |
|
/// (c) every mutating handler that advances the cursor checks |
|
/// `paused == 0` before doing so. |
|
property pause_blocks_mutation : |
|
state.paused == 1 implies state.sp_next_free_slot == old(state.sp_next_free_slot) |
|
preserved_by all |
|
|
|
/// Schedule lane cursor only advances. Lane recycling (F.36 lane-recycling) |
|
/// is FHE-internal; the on-chain cursor is monotonic. Depends on the |
|
/// `schedule_pool_init_implies_empty` linkage below to hold against |
|
/// adversarial pre-states where `sp_initialized == 0`. |
|
property slot_cursor_monotonic : |
|
state.sp_next_free_slot >= old(state.sp_next_free_slot) |
|
preserved_by all |
|
|
|
/// Linkage that closes `slot_cursor_monotonic` against |
|
/// `initialize_schedule_pool`'s unconditional `sp_next_free_slot := 0` |
|
/// write. In the real system `sp_initialized == 0` implies the cursor |
|
/// has never been advanced (only init_schedule_pool sets it to 0, |
|
/// only submit_schedule* advances by one, and submit requires |
|
/// sp_initialized == 1). |
|
property schedule_pool_init_implies_empty : |
|
state.sp_initialized == 0 implies state.sp_next_free_slot == 0 |
|
preserved_by all |
|
|
|
/// Schedule pool capacity — submits abort once full. |
|
property schedule_pool_bounded : |
|
state.sp_next_free_slot <= SCHEDULE_LANES |
|
preserved_by all |
|
|
|
/// (transparency log): transcript `latest_epoch_id` is strictly monotonic. The |
|
/// `EpochNotMonotonic` guard in `update_transcript` rejects |
|
/// `epoch_id <= latest_epoch_id` on non-init calls; init writes |
|
/// whatever `epoch_id` was passed (first-caller-wins), which is >= 0. |
|
property transcript_epoch_monotonic : |
|
state.transcript_initialized == 1 implies |
|
state.transcript_latest_epoch >= old(state.transcript_latest_epoch) |
|
preserved_by all |
|
|
|
/// Pool admin is set by `auth admin` inference at `initialize_pool` |
|
/// and never rewritten. Trivial tracking form surfaces the field in a |
|
/// machine-checked property without a preserved-except artifact. |
|
property admin_field_tracked : |
|
state.admin == state.admin |
|
preserved_by all |
|
|
|
/// Epoch is a U64 counter bumped only by `execute_match_schedule`. |
|
/// Per-handler `ensures state.epoch != old(state.epoch)` pins match's |
|
/// change; global non-negativity surfaces the field without |
|
/// preservation noise. Wrap is accepted (`+=?` semantics). |
|
property epoch_field_tracked : |
|
state.epoch >= 0 |
|
preserved_by all |
|
|
|
/// Flag bounds: 0/1 discriminator convention (percolator-style). All |
|
/// four Bool-ish fields are U8 with valid values in {0, 1}. Machine- |
|
/// checked here instead of via a type-level constraint. |
|
property flag_bounds_paused : |
|
state.paused == 0 or state.paused == 1 |
|
preserved_by all |
|
|
|
property flag_bounds_sp_initialized : |
|
state.sp_initialized == 0 or state.sp_initialized == 1 |
|
preserved_by all |
|
|
|
property flag_bounds_sp_vectors_seeded : |
|
state.sp_vectors_seeded == 0 or state.sp_vectors_seeded == 1 |
|
preserved_by all |
|
|
|
property flag_bounds_transcript_initialized : |
|
state.transcript_initialized == 0 or state.transcript_initialized == 1 |
|
preserved_by all |
|
|
|
/// Once seeded, stays seeded. `initialize_schedule_vectors` is the |
|
/// only path 0→1; nothing reverses it. |
|
property vectors_seeded_latches_true : |
|
old(state.sp_vectors_seeded) == 1 implies state.sp_vectors_seeded == 1 |
|
preserved_by all |
|
|
|
// ============================================================================ |
|
// Invariants — documentation-only claims (flagged as open gaps) |
|
// ============================================================================ |
|
// |
|
// Each of these describes a real security property of the deployed code |
|
// that the current abstraction level cannot machine-check. They are |
|
// kept as strings so Lean/reports surface them; promoting them to real |
|
// preservation properties requires the model extensions noted. |
|
|
|
/// graph-PDA pinning fail-closed: before `set_graph_pdas` runs, the three graph_pda |
|
/// fields on the real PoolState are `Pubkey::default()` and every |
|
/// submit/update/match handler's `require_graph_pda_matches` guard |
|
/// rejects with `UnauthorizedGraphPda`. Spec-level surfacing is via |
|
/// the uninterpreted `graph_pdas_pinned(state.sp_initialized)` on |
|
/// each FHE-consuming handler. Promoting to machine-checked requires |
|
/// either (a) a dedicated U8 pinned-flag in state, or (b) a DSL |
|
/// extension for Pubkey literal constants. v2 work. |
|
invariant graph_pdas_fail_closed_doc |
|
"submit/update/match/request_withdraw reject via |
|
`graph_pdas_pinned(state.sp_initialized)` when admin has not yet |
|
called `set_graph_pdas`; the real guard is pubkey-literal equality |
|
which this abstraction level can't express" |
|
|
|
/// digest-handshake handshake. finalize_withdraw's `supplied_ct.digest == ticket.digest` |
|
/// AND `supplied_ct.digest == sha256(0x05 || amount_u128.le())` checks |
|
/// both speak in the scalar-shape grammar (see `fhe/withdraw.rs`). |
|
/// Not machine-checked here — WithdrawTicket + CT accounts live |
|
/// outside the unified-state model; closing this would require |
|
/// modeling the ticket PDA as tracked state. |
|
invariant withdraw_digest_shape_stable_doc |
|
"finalize_withdraw's digest re-derivation (sha256(0x05 || amount_u128.le)) |
|
is byte-identical to the scalar-debit digest request_withdraw commits |
|
to the ticket — locks the request/finalize handshake against drift" |
|
|
|
/// account_id signer-binding signer-binding is surfaced as `requires` clauses on both |
|
/// handlers via the uninterpreted `account_id_ct_binds_signer(user)` |
|
/// predicate (M2 fix: read_balance guard; also on request_withdraw). |
|
/// Machine-checkable at codegen once the predicate gets a real |
|
/// definition. Production closure is a ZK proof (Encrypt-team queue A9). |
|
invariant account_id_ct_signer_binding_doc |
|
"read_balance and request_withdraw reject when the caller-supplied |
|
account_id_ct does not bind the signer's identity — mock-encrypt-only |
|
closure; production path is ZK-proof based" |
|
|
|
/// Replay protection is per-(pool, user, nonce). A second |
|
/// finalize_withdraw with the same triple finds no PDA (Anchor `close` |
|
/// refunded the rent on first success). NOT modeled in this spec's |
|
/// state — tracking would require a `Map[(pool,user,nonce)] Ticket` |
|
/// extension. `withdraw_ticket_matches` uninterpreted predicate in |
|
/// finalize_withdraw's guard stands in for the real ticket equality |
|
/// checks. |
|
invariant withdraw_replay_protected_by_ticket_closure_doc |
|
"Anchor `init` + `close = withdrawer` on the WithdrawTicket PDA at |
|
[WITHDRAW_SEED, pool, user, nonce_le] makes per-nonce replay impossible; |
|
concurrent withdraws with distinct nonces are allowed and NOT rejected |
|
by this spec's abstraction" |
|
|
|
/// stale-CT rollback prevention. submit_schedule, |
|
/// submit_schedule_with_ioi_link, update_schedule, execute_match_schedule, |
|
/// and request_withdraw all gate on `input_handles_equal_stored( |
|
/// state.sp_vectors_seeded)` (M3 fix). The predicate is uninterpreted |
|
/// at spec level — real vector_handles : Map[14] Pubkey is out of scope |
|
/// for this abstraction — but the guard is a first-class `requires` |
|
/// clause on all 5 rotating handlers, so generated harnesses emit a |
|
/// stale-CT rejection test case at each call site. |
|
invariant crit2_input_handle_equality_doc |
|
"submit/update/match/request_withdraw reject stale input CTs via |
|
`input_handles_equal_stored(...)` guards — Map[14] Pubkey modeling |
|
is v2; the predicate already surfaces the check at every call site" |
|
|
|
/// (transparency log) chain integrity: running_hash = SHA-256(prev || epoch_hash) |
|
/// or seed on first call. Spec-level surfacing is via |
|
/// `hash_chain_consistent(epoch_hash)` requires on |
|
/// `update_transcript` — Pubkey-valued state fields can't |
|
/// carry a predicate-computed hash under the current DSL (literal |
|
/// Pubkey assignments are unsupported). The real on-chain handler |
|
/// computes and stores the fold; chain integrity is observable |
|
/// off-chain. Epoch-id monotonicity is fully machine-checked |
|
/// (`transcript_epoch_monotonic` above). |
|
invariant transcript_chain_integrity_doc |
|
"update_transcript's hash_chain_consistent requires surfaces |
|
the SHA-256 running-hash fold; full machine-checking would need a |
|
Pubkey-literal primitive or a U64-modeled digest. Epoch monotonicity |
|
is machine-checked via `transcript_epoch_monotonic`" |
|
|
|
// ============================================================================ |
|
// Reachability — cover traces for the core flows |
|
// ============================================================================ |
|
|
|
/// Full bring-up + first match. This is the canonical "did we set up a |
|
/// pool and run the matching engine once?" trace. If codegen cannot |
|
/// produce a Kani / Lean witness for this trace, the scaffolding order |
|
/// is wrong somewhere. |
|
cover pool_bringup_and_match [ |
|
initialize_pool, |
|
initialize_schedule_pool, |
|
initialize_schedule_vectors, |
|
register_graph, |
|
register_graph, |
|
register_graph, |
|
set_graph_pdas, |
|
submit_schedule, |
|
execute_match_schedule |
|
] |
|
|
|
/// Two-step withdraw after a submit — exercises the full |
|
/// request → finalize handshake. |
|
cover submit_and_withdraw [ |
|
initialize_pool, |
|
initialize_schedule_pool, |
|
initialize_schedule_vectors, |
|
register_graph, |
|
register_graph, |
|
register_graph, |
|
set_graph_pdas, |
|
submit_schedule, |
|
request_withdraw, |
|
finalize_withdraw |
|
] |
|
|
|
/// In-place curve amend exercised after a submit — in-place-amend path. |
|
cover submit_and_amend [ |
|
initialize_pool, |
|
initialize_schedule_pool, |
|
initialize_schedule_vectors, |
|
register_graph, |
|
register_graph, |
|
register_graph, |
|
set_graph_pdas, |
|
submit_schedule, |
|
update_schedule |
|
] |
|
|
|
/// Transparency-log write from a bringup — (transparency log) / (transparency concern). |
|
cover transcript_update_from_init [ |
|
initialize_pool, |
|
update_transcript |
|
] |
|
|
|
/// Read path after submit (demo-mode balance reveal — account_id binding |
|
/// closure under mock-encrypt). |
|
cover submit_and_read [ |
|
initialize_pool, |
|
initialize_schedule_pool, |
|
initialize_schedule_vectors, |
|
register_graph, |
|
register_graph, |
|
register_graph, |
|
set_graph_pdas, |
|
submit_schedule, |
|
read_balance |
|
] |
|
|
|
// ============================================================================ |
|
// Liveness — disabled pending v2.8 codegen fix |
|
// ============================================================================ |
|
// |
|
// qedgen v2.7's liveness-witness codegen emits |
|
// `refine ⟨[.initialize_pool], by decide, ...⟩` for a liveness |
|
// declaration using a parameterized handler. The `.initialize_pool` |
|
// needs `Pubkey → Pubkey → Operation` arguments in the list literal |
|
// but the codegen omits them, producing `List.cons` type-mismatch. |
|
// Re-enable once the witness emitter threads handler params through. |
|
// |
|
// liveness pool_active_from_uninit : |
|
// State.Uninitialized ~> State.Active |
|
// via [initialize_pool] within 1 |