diff --git a/AGENTS.md b/AGENTS.md index ec913bf8..a2ee4eda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,8 @@ Every agent-facing command should be treated as an envelope. With `--json` this Errors use the same shape with `status: "error"` and an `error` object. Do not parse prose diagnostics. Check `error.code`, `error.recoverable`, `error.http.status`, `error.request_id`, and `next_actions`. +Some commands add non-fatal warnings. On successful `pcl deploy` envelopes they appear in `data.warnings`; if deploy later fails, they appear in the top-level `warnings` array alongside `status: "error"`. `pcl auth login` also reports warnings in a top-level `warnings` array. Each entry has `code` and `message`. `assertion_spec.v2_unsupported` means the target platform or chain runs the V1 assertion spec, so V2 triggers and precompiles are not supported there. + Output mode rules: - default: human-readable output for people diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b63c3d..e1858f61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable user-facing changes should be recorded here. ## Unreleased +### Added + +- `pcl deploy` warns when the assertions it is about to release use the V2 spec but the target does not support it (the `app.phylax.systems` platform, or a Linea chain). The warning names the files and the V2 triggers/precompiles found in them, prints before the protocol-manager step and again at the end, and appears in `--json` output as `data.warnings`. It never blocks a deploy. +- `pcl auth login` warns when logging in to `app.phylax.systems` that the platform runs the V1 assertion spec and assertions must not be written against V2. Machine output carries it as a `warnings` array on the login envelope. + ### Breaking changes - Removed TOON output entirely: the `--toon` flag, the `--format toon` alias, and the TOON envelope renderer are gone. `--json` is the only machine output mode; agent guidance, manifest examples, and `next_actions` hints now use `--json`. diff --git a/crates/pcl/cli/src/main.rs b/crates/pcl/cli/src/main.rs index aab70b20..8c47f256 100644 --- a/crates/pcl/cli/src/main.rs +++ b/crates/pcl/cli/src/main.rs @@ -576,6 +576,13 @@ fn phoundry_error_envelope(err: &PhoundryError) -> Value { #[allow(clippy::too_many_lines)] fn deploy_error_envelope(err: &DeployError) -> Value { match err { + DeployError::WithWarnings { source, warnings } => { + let mut envelope = deploy_error_envelope(source); + if !warnings.is_empty() { + envelope["warnings"] = json!(warnings); + } + envelope + } // Delegate wrapped errors to the mappers that know their structure — // notably ConfirmAfterTx, whose envelope carries the landed tx hash // and the nested API provenance. @@ -764,7 +771,9 @@ fn deploy_error_envelope(err: &DeployError) -> Value { DeployError::Json(_) | DeployError::Output(_) => { ("deploy.output_failed", false, Vec::new()) } - DeployError::Api(_) | DeployError::Apply(_) => unreachable!("delegated above"), + DeployError::Api(_) + | DeployError::Apply(_) + | DeployError::WithWarnings { .. } => unreachable!("delegated above"), }; let mut error = serde_json::Map::new(); error.insert("code".to_string(), json!(code)); @@ -1396,4 +1405,29 @@ mod tests { assert_eq!(envelope["next_actions"][0], "pcl auth refresh"); assert_eq!(envelope["next_actions"][1], "pcl auth login --force"); } + + #[test] + fn deploy_api_failure_keeps_post_scan_spec_warnings() { + let warning = json!({ + "code": "assertion_spec.v2_unsupported", + "message": "production runs V1", + }); + let err = DeployError::WithWarnings { + source: Box::new(DeployError::Apply(ApplyError::Api { + endpoint: "/projects/id/releases".to_string(), + status: Some(400), + body: "unsupported assertion spec".to_string(), + })), + warnings: vec![warning], + }; + + let envelope = deploy_error_envelope(&err); + + assert_eq!(envelope["status"], "error"); + assert_eq!( + envelope["warnings"][0]["code"], + "assertion_spec.v2_unsupported" + ); + assert_eq!(envelope["error"]["code"], "apply.failed"); + } } diff --git a/crates/pcl/core/src/assertion_spec.rs b/crates/pcl/core/src/assertion_spec.rs new file mode 100644 index 00000000..9ddfd8b8 --- /dev/null +++ b/crates/pcl/core/src/assertion_spec.rs @@ -0,0 +1,966 @@ +//! Which assertion spec a target platform runs, and whether a project's +//! assertions are written against the V2 spec. +//! +//! The production platform (`app.phylax.systems`, Linea) runs the V1 assertion +//! spec. Assertions written against the V2 triggers and precompiles +//! (`registerTxEndTrigger`, `ph.staticcallAt`, the fork-id reads, the +//! protection-suite precompiles, the cumulative-flow circuit breakers) do not +//! run there, so `pcl deploy` and `pcl auth login` warn instead of letting the +//! mismatch surface as a failed release or an assertion that never triggers. +//! +//! Detection is a source-level heuristic over the assertion files listed in +//! `credible.toml`: V2-only identifiers are matched in the project's own +//! sources with comments stripped. Vendored `credible-std` code is never +//! scanned, because a V2-capable library declares those identifiers whether or +//! not an assertion uses them. The result only ever produces a warning, never +//! a hard failure, so a miss in either direction is recoverable. + +use crate::credible_config::CredibleToml; +use serde_json::{ + Value, + json, +}; +use std::{ + collections::HashSet, + fmt::Write as _, + path::Path, +}; +use url::Url; + +/// Linea Mainnet. +pub const LINEA_MAINNET_CHAIN_ID: u64 = 59144; +/// Linea Sepolia. +pub const LINEA_SEPOLIA_CHAIN_ID: u64 = 59141; + +/// Chains served by a platform that runs the V1 assertion spec. +const V1_ONLY_CHAIN_IDS: [u64; 2] = [LINEA_MAINNET_CHAIN_ID, LINEA_SEPOLIA_CHAIN_ID]; + +/// Platform hosts that run the V1 assertion spec. +const V1_ONLY_PLATFORM_HOSTS: [&str; 1] = ["app.phylax.systems"]; + +/// Warning code used in machine output. +pub const V2_SPEC_UNSUPPORTED_CODE: &str = "assertion_spec.v2_unsupported"; + +/// V2-only identifiers called as functions: triggers, precompiles, and the +/// fork helpers on `Assertion`. This is the whole surface `credible-std` +/// declares under its `V2` section headers, plus the experimental flow-rate +/// reads that only exist alongside it; `tests::v2_surface_is_covered` fails +/// when the two drift apart. +/// +/// A needle containing `.` (`ph.context`) is matched with the prefix, because +/// the bare identifier is too common to be evidence of anything. `ph.load` is +/// deliberately absent: the V1 `load(address, bytes32)` and the V2 +/// `load(bytes32)` share a name, and telling them apart needs argument +/// parsing this heuristic does not do. +const V2_CALL_MARKERS: [&str; 46] = [ + // Triggers + "registerTxEndTrigger", + "registerFnCallTrigger", + "registerErc20ChangeTrigger", + "watchCumulativeInflow", + "watchCumulativeOutflow", + "watchAnomaly", + // Fork helpers + "_preTx", + "_postTx", + "_preCall", + "_postCall", + // Fork-aware reads and call inspection + "staticcallAt", + "loadStateAt", + "callinputAt", + "callOutputAt", + "_matchingCalls", + "matchingCalls", + "_successOnlyFilter", + "getLogsForCall", + "getLogsQuery", + "getErc20TransfersForTokens", + "getErc20Transfers", + "changedErc20BalanceDeltas", + "reduceErc20BalanceDeltas", + "getTxObject", + "forbidChangeForSlot", + "forbidChangeForSlots", + // Trigger context + "ph.context", + "inflowContext", + "outflowContext", + "inflowRate", + "outflowRate", + // Persistent assertion storage + "ph.store", + "ph.exists", + "ph.values_left", + // Mapping tracing + "changedMappingKeys", + "mappingValueDiff", + // Anomaly detection + "anomalyContext", + // Protection-suite precompiles + "assetsMatchSharePriceAt", + "assetsMatchSharePrice", + "conserveBalance", + "oracleSanityAt", + "oracleSanity", + "normalizeDecimals", + "ratioGe", + // Math precompiles + "ph.mulDivDown", + "ph.mulDivUp", +]; + +/// V2-only types, used in declarations rather than calls. +const V2_TYPE_MARKERS: [&str; 14] = [ + // Explicit non-Legacy spec registration + "AssertionSpec.Reshiram", + "AssertionSpec.Experimental", + "PhEvm.ForkId", + "PhEvm.TriggerContext", + "PhEvm.TriggerCall", + "PhEvm.CallFilter", + "PhEvm.AnomalyContext", + "PhEvm.InflowContext", + "PhEvm.OutflowContext", + "PhEvm.FlowRateContext", + "PhEvm.Erc20TransferData", + "PhEvm.StaticCallResult", + "PhEvm.LogQuery", + "PhEvm.TxObject", +]; + +/// One assertion source that uses the V2 spec. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2SpecFinding { + /// Path as written in `credible.toml`, relative to the project root. + pub file: String, + /// V2-only identifiers found in that file, in canonical order. + pub markers: Vec, +} + +/// Whether `platform_url` runs a platform that supports the V2 spec. +pub fn platform_supports_v2(platform_url: &Url) -> bool { + platform_url.host_str().is_none_or(|host| { + let host = host.trim_start_matches("www.").to_ascii_lowercase(); + !V1_ONLY_PLATFORM_HOSTS.contains(&host.as_str()) + }) +} + +/// Whether `chain_id` is served by a platform that supports the V2 spec. +pub fn chain_supports_v2(chain_id: u64) -> bool { + !V1_ONLY_CHAIN_IDS.contains(&chain_id) +} + +/// Human name for the chains that only run V1, for warning text. +fn chain_label(chain_id: u64) -> String { + match chain_id { + LINEA_MAINNET_CHAIN_ID => format!("Linea Mainnet, chain {chain_id}"), + LINEA_SEPOLIA_CHAIN_ID => format!("Linea Sepolia, chain {chain_id}"), + other => format!("chain {other}"), + } +} + +/// V2-only identifiers used by `source`, in the order they are declared in the +/// marker lists. +pub fn detect_v2_markers(source: &str) -> Vec { + let code = strip_comments_and_literals(source); + let mut found: Vec = Vec::new(); + + for marker in V2_CALL_MARKERS { + if contains_call(&code, marker) { + found.push(marker.to_string()); + } + } + for marker in V2_TYPE_MARKERS { + if contains_identifier(&code, marker) { + found.push(marker.to_string()); + } + } + + found +} + +/// Scans the assertion sources declared in `credible.toml` for V2 spec usage. +/// Unreadable files are skipped: a warning check must never be the reason a +/// deploy fails, and the build step reports missing sources with a real error. +pub fn scan_assertion_sources(root: &Path, credible: &CredibleToml) -> Vec { + let mut findings: Vec = Vec::new(); + + for relative in unique_assertion_paths(credible) { + let Ok(source) = std::fs::read_to_string(root.join(relative)) else { + continue; + }; + let markers = detect_v2_markers(&source); + if !markers.is_empty() { + findings.push(V2SpecFinding { + file: relative.to_string(), + markers, + }); + } + } + + findings +} + +/// Distinct assertion source paths declared in `credible.toml`, in declaration +/// order. A `file` entry may carry a `path:Contract` suffix, and one source is +/// commonly referenced by several contracts; deduplicating before reading keeps +/// deploy startup scaling with unique files rather than with references. +fn unique_assertion_paths(credible: &CredibleToml) -> Vec<&str> { + let mut seen: HashSet<&str> = HashSet::new(); + credible + .contracts + .values() + .flat_map(|contract| &contract.assertions) + .map(|assertion| { + assertion + .file + .split_once(':') + .map_or(assertion.file.as_str(), |(path, _)| path) + }) + .filter(|relative| seen.insert(relative)) + .collect() +} + +/// The V2-unsupported warning for a deploy, or `None` when the target platform +/// runs V2 or the project's assertions do not use it. +/// +/// `chain_id` is `None` when the target chain is not known yet (a `--dry-run` +/// without `--chain-id`); the platform check alone still decides. +pub fn deploy_warning( + platform_url: &Url, + chain_id: Option, + findings: &[V2SpecFinding], +) -> Option { + if findings.is_empty() { + return None; + } + let platform_only_v1 = !platform_supports_v2(platform_url); + let chain_only_v1 = chain_id.is_some_and(|chain_id| !chain_supports_v2(chain_id)); + if !platform_only_v1 && !chain_only_v1 { + return None; + } + + let platform = platform_url.as_str().trim_end_matches('/'); + let target = match chain_id.filter(|chain_id| !chain_supports_v2(*chain_id)) { + Some(chain_id) => format!("{platform} ({})", chain_label(chain_id)), + None => platform.to_string(), + }; + + let mut message = + format!("{target} runs the V1 assertion spec, but these assertions use V2:\n"); + for finding in findings { + let _ = writeln!( + message, + " {} — {}", + finding.file, + finding.markers.join(", ") + ); + } + message.push_str( + "V2 triggers and precompiles do not run there: the release can be rejected, or the assertion can deploy and never trigger. Rewrite these assertions against the V1 spec, or deploy to a platform that runs V2.", + ); + Some(message) +} + +/// Machine-output form of a warning message. +pub fn warning_json( + message: &str, + platform_url: &Url, + chain_id: Option, + findings: &[V2SpecFinding], +) -> Value { + json!({ + "code": V2_SPEC_UNSUPPORTED_CODE, + "message": message, + "platform_url": platform_url.as_str(), + "chain_id": chain_id, + "assertion_spec": "v1", + "files": findings + .iter() + .map(|finding| json!({ "file": finding.file, "markers": finding.markers })) + .collect::>(), + }) +} + +/// The notice shown after logging in to a platform that only runs V1, or +/// `None` for a platform that runs V2. No project is in scope at login time, +/// so this is advice rather than a finding. +pub fn login_notice(platform_url: &Url) -> Option { + if platform_supports_v2(platform_url) { + return None; + } + Some(format!( + "{} (Linea) runs the V1 assertion spec. Do not write assertions against the V2 spec: V2 triggers and precompiles (registerTxEndTrigger, registerFnCallTrigger, ph.staticcallAt, the cumulative-flow circuit breakers) are not supported on this platform.", + platform_url.as_str().trim_end_matches('/'), + )) +} + +/// Machine-output form of [`login_notice`]. +pub fn login_warning_json(message: &str, platform_url: &Url) -> Value { + json!({ + "code": V2_SPEC_UNSUPPORTED_CODE, + "message": message, + "platform_url": platform_url.as_str(), + "assertion_spec": "v1", + }) +} + +/// Replaces Solidity comments and string-literal contents with spaces, so a +/// commented-out V2 call is not read as usage and neither is one spelled out +/// inside a string. Byte positions are preserved, but only presence matters. +/// +/// String state matters in both directions: a literal holding `/*` would +/// otherwise blank the rest of the file and hide a real V2 call, and a literal +/// holding `registerTxEndTrigger(` would otherwise be reported as usage. +fn strip_comments_and_literals(source: &str) -> String { + let bytes = source.as_bytes(); + let mut out = String::with_capacity(source.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'"' || bytes[index] == b'\'' { + let quote = bytes[index]; + out.push(char::from(quote)); + index += 1; + // Blank the contents, keeping the quotes so the surrounding code + // stays separated. An escape consumes the next byte, so `\"` does + // not end the literal; an unterminated one runs to end of file, + // matching what solc would reject anyway. + while index < bytes.len() && bytes[index] != quote { + if bytes[index] == b'\\' && index + 1 < bytes.len() { + out.push(' '); + index += 1; + } + let char_len = source[index..].chars().next().map_or(1, char::len_utf8); + for _ in 0..char_len { + out.push(if bytes[index] == b'\n' { '\n' } else { ' ' }); + } + index += char_len; + } + if index < bytes.len() { + out.push(char::from(quote)); + index += 1; + } + } else if bytes[index..].starts_with(b"//") { + while index < bytes.len() && bytes[index] != b'\n' { + out.push(' '); + index += 1; + } + } else if bytes[index..].starts_with(b"/*") { + while index < bytes.len() && !bytes[index..].starts_with(b"*/") { + out.push(if bytes[index] == b'\n' { '\n' } else { ' ' }); + index += 1; + } + // Consume the closing `*/` when present; an unterminated block + // comment runs to end of file. + for _ in 0..2.min(bytes.len().saturating_sub(index)) { + out.push(' '); + index += 1; + } + } else { + // Multi-byte characters only appear inside comments and string + // literals in practice; copy them whole to keep the output valid + // UTF-8. + let char_len = source[index..].chars().next().map_or(1, char::len_utf8); + out.push_str(&source[index..index + char_len]); + index += char_len; + } + } + out +} + +/// Whether `code` calls `marker`: the name appears with an identifier boundary +/// in front of it and an opening parenthesis after it. +fn contains_call(code: &str, marker: &str) -> bool { + matches_with_boundary(code, marker, |rest| rest.trim_start().starts_with('(')) +} + +/// Whether `code` mentions `marker` as an identifier (a type, not a call). +fn contains_identifier(code: &str, marker: &str) -> bool { + matches_with_boundary(code, marker, |rest| !rest.starts_with(is_identifier_char)) +} + +fn matches_with_boundary(code: &str, marker: &str, accept_rest: impl Fn(&str) -> bool) -> bool { + let mut offset = 0; + while let Some(position) = code[offset..].find(marker) { + let start = offset + position; + let end = start + marker.len(); + let preceded_by_identifier = code[..start] + .chars() + .next_back() + .is_some_and(is_identifier_char); + if !preceded_by_identifier && accept_rest(&code[end..]) { + return true; + } + offset = end; + } + false +} + +fn is_identifier_char(character: char) -> bool { + character.is_ascii_alphanumeric() || character == '_' || character == '$' +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn url(value: &str) -> Url { + value.parse().expect("valid url") + } + + #[test] + fn production_and_linea_are_v1_only() { + assert!(!platform_supports_v2(&url("https://app.phylax.systems"))); + assert!(!platform_supports_v2(&url( + "https://APP.Phylax.Systems/dashboard" + ))); + assert!(!chain_supports_v2(LINEA_MAINNET_CHAIN_ID)); + assert!(!chain_supports_v2(LINEA_SEPOLIA_CHAIN_ID)); + + assert!(platform_supports_v2(&url("https://dev.phylax.systems"))); + assert!(platform_supports_v2(&url("http://localhost:3000"))); + assert!(chain_supports_v2(84532)); + } + + #[test] + fn detects_v2_triggers_precompiles_and_types() { + let source = r" + contract VaultAssertion is Assertion { + function triggers() external view override { + registerTxEndTrigger(this.assertSolvency.selector); + watchCumulativeOutflow(token, 500, 1 hours, this.assertFlow.selector); + } + + function assertSolvency() external { + PhEvm.ForkId memory pre = _preTx(); + bytes memory data = ph.staticcallAt(vault, payload, 100000, pre); + require(ph.assetsMatchSharePriceAt(vault, 10, pre, _postTx())); + } + } + "; + + assert_eq!( + detect_v2_markers(source), + vec![ + "registerTxEndTrigger", + "watchCumulativeOutflow", + "_preTx", + "_postTx", + "staticcallAt", + "assetsMatchSharePriceAt", + "PhEvm.ForkId", + ] + ); + } + + #[test] + fn v1_assertions_report_no_markers() { + let source = r" + contract OwnableAssertion is Assertion { + function triggers() external view override { + registerCallTrigger(this.assertionOwnershipChange.selector); + } + + function assertionOwnershipChange() external { + ph.forkPreState(); + address pre = ownable.owner(); + ph.forkPostState(); + require(pre == ownable.owner(), 'owner changed'); + } + } + "; + + assert!(detect_v2_markers(source).is_empty()); + } + + #[test] + fn ignores_commented_out_and_look_alike_identifiers() { + let source = r" + contract Legacy is Assertion { + // registerTxEndTrigger(this.check.selector); + /* ph.staticcallAt(target, data, 100, pre); */ + function triggers() external view override { + myRegisterTxEndTrigger(this.check.selector); + address context = msg.sender; + uint256 conserveBalanceLimit = 1; + } + } + "; + + assert!( + detect_v2_markers(source).is_empty(), + "{:?}", + detect_v2_markers(source) + ); + } + + #[test] + fn unterminated_block_comment_is_stripped_to_end_of_file() { + let source = "contract A { /* registerTxEndTrigger(x);"; + assert!(detect_v2_markers(source).is_empty()); + } + + #[test] + fn non_ascii_source_does_not_panic_and_still_matches() { + let source = "contract A { string s = \"↯ vault\"; function t() external { registerTxEndTrigger(this.c.selector); } }"; + assert_eq!(detect_v2_markers(source), vec!["registerTxEndTrigger"]); + } + + #[test] + fn a_v2_call_inside_a_string_literal_is_not_usage() { + let source = r#"contract A { string s = "registerTxEndTrigger(x)"; }"#; + assert!( + detect_v2_markers(source).is_empty(), + "{:?}", + detect_v2_markers(source) + ); + let escaped = r#"contract A { string s = "say \" registerTxEndTrigger(x)"; }"#; + assert!(detect_v2_markers(escaped).is_empty()); + let single = "contract A { string s = 'registerTxEndTrigger(x)'; }"; + assert!(detect_v2_markers(single).is_empty()); + } + + #[test] + fn a_comment_opener_inside_a_string_literal_does_not_blank_the_file() { + let source = r#"contract A { string s = "/*"; function t() external { registerTxEndTrigger(this.c.selector); } }"#; + assert_eq!(detect_v2_markers(source), vec!["registerTxEndTrigger"]); + let line_comment = r#"contract A { string s = "//"; function t() external { registerTxEndTrigger(this.c.selector); } }"#; + assert_eq!( + detect_v2_markers(line_comment), + vec!["registerTxEndTrigger"] + ); + } + + #[test] + fn a_quote_inside_a_comment_does_not_open_a_literal() { + let source = "contract A { // it's fine\n function t() external { registerTxEndTrigger(this.c.selector); } }"; + assert_eq!(detect_v2_markers(source), vec!["registerTxEndTrigger"]); + } + + fn credible_with(files: &[&str]) -> CredibleToml { + let assertions = files.iter().fold(String::new(), |mut acc, file| { + let _ = writeln!(acc, "[[contracts.mock.assertions]]\nfile = \"{file}\""); + acc + }); + toml::from_str(&format!( + "environment = \"production\"\n\ + [contracts.mock]\n\ + address = \"0x0101010101010101010101010101010101010101\"\n\ + name = \"Mock\"\n\ + {assertions}" + )) + .expect("valid credible.toml") + } + + fn project_with(files: &[(&str, &str)]) -> TempDir { + let temp = TempDir::new().expect("temp dir"); + for (path, source) in files { + let full = temp.path().join(path); + fs::create_dir_all(full.parent().expect("parent")).expect("create dirs"); + fs::write(full, source).expect("write source"); + } + temp + } + + /// Every V2 identifier `credible-std` declares, with a representative use. + /// This is the regression net for the marker lists: adding a V2 API to + /// `credible-std` means adding it here and to `V2_CALL_MARKERS` / + /// `V2_TYPE_MARKERS`, and `v2_surface_is_covered` fails when only one of + /// the two happens. + const V2_SURFACE: [(&str, &str); 60] = [ + ( + "AssertionSpec.Reshiram", + "registerAssertionSpec(AssertionSpec.Reshiram);", + ), + ( + "AssertionSpec.Experimental", + "registerAssertionSpec(AssertionSpec.Experimental);", + ), + // TriggerRecorder / Assertion — V2 trigger registration + ( + "registerTxEndTrigger", + "registerTxEndTrigger(this.c.selector);", + ), + ( + "registerFnCallTrigger", + "registerFnCallTrigger(this.c.selector, ITarget.swap.selector);", + ), + ( + "registerErc20ChangeTrigger", + "registerErc20ChangeTrigger(this.c.selector, token);", + ), + ( + "watchCumulativeInflow", + "watchCumulativeInflow(token, 500, 1 hours, this.c.selector);", + ), + ( + "watchCumulativeOutflow", + "watchCumulativeOutflow(token, 500, 1 hours, this.c.selector);", + ), + ("watchAnomaly", "watchAnomaly(target, this.c.selector);"), + // Assertion — ForkId constructors + ("_preTx", "_preTx();"), + ("_postTx", "_postTx();"), + ("_preCall", "_preCall(callId);"), + ("_postCall", "_postCall(callId);"), + // Assertion — V2 call matching helpers + ("_matchingCalls", "_matchingCalls(target, sel, 10);"), + ("_successOnlyFilter", "_successOnlyFilter();"), + // PhEvm — fork-aware reads + ( + "staticcallAt", + "ph.staticcallAt(target, data, 100000, fork);", + ), + ("loadStateAt", "ph.loadStateAt(target, slot, fork);"), + ("getLogsQuery", "ph.getLogsQuery(query, fork);"), + ("getErc20Transfers", "ph.getErc20Transfers(token, fork);"), + ( + "getErc20TransfersForTokens", + "ph.getErc20TransfersForTokens(tokens, fork);", + ), + ( + "changedErc20BalanceDeltas", + "ph.changedErc20BalanceDeltas(token, fork);", + ), + ( + "reduceErc20BalanceDeltas", + "ph.reduceErc20BalanceDeltas(token, fork);", + ), + ("getTxObject", "ph.getTxObject();"), + ("forbidChangeForSlot", "ph.forbidChangeForSlot(slot);"), + ("forbidChangeForSlots", "ph.forbidChangeForSlots(slots);"), + // PhEvm — call inspection + ("callinputAt", "ph.callinputAt(callId);"), + ("callOutputAt", "ph.callOutputAt(callId);"), + ( + "matchingCalls", + "ph.matchingCalls(target, sel, filter, 10);", + ), + ("getLogsForCall", "ph.getLogsForCall(query, callId);"), + // PhEvm — trigger and flow context + ("ph.context", "ph.context();"), + ("inflowContext", "ph.inflowContext();"), + ("outflowContext", "ph.outflowContext();"), + ("inflowRate", "ph.inflowRate();"), + ("outflowRate", "ph.outflowRate();"), + // PhEvm — persistent assertion storage + ("ph.store", "ph.store(key, value);"), + ("ph.exists", "ph.exists(key);"), + ("ph.values_left", "ph.values_left();"), + // PhEvm — mapping tracing + ( + "changedMappingKeys", + "ph.changedMappingKeys(target, baseSlot);", + ), + ( + "mappingValueDiff", + "ph.mappingValueDiff(target, baseSlot, key, 0);", + ), + // PhEvm — anomaly detection + ("anomalyContext", "ph.anomalyContext(target);"), + // PhEvm — protection suite + ( + "assetsMatchSharePrice", + "ph.assetsMatchSharePrice(vault, 10);", + ), + ( + "assetsMatchSharePriceAt", + "ph.assetsMatchSharePriceAt(vault, 10, fork0, fork1);", + ), + ( + "conserveBalance", + "ph.conserveBalance(fork0, fork1, token, account);", + ), + ("oracleSanity", "ph.oracleSanity(oracle, data, 100);"), + ( + "oracleSanityAt", + "ph.oracleSanityAt(oracle, data, 100, fork0, fork1);", + ), + ("normalizeDecimals", "ph.normalizeDecimals(amount, 6, 18);"), + ("ratioGe", "ph.ratioGe(n1, d1, n2, d2, 10);"), + // PhEvm — math precompiles + ("ph.mulDivDown", "ph.mulDivDown(x, y, denominator);"), + ("ph.mulDivUp", "ph.mulDivUp(x, y, denominator);"), + // PhEvm — V2-only types + ("PhEvm.ForkId", "PhEvm.ForkId memory fork;"), + ("PhEvm.TriggerContext", "PhEvm.TriggerContext memory ctx;"), + ("PhEvm.TriggerCall", "PhEvm.TriggerCall[] memory calls;"), + ("PhEvm.CallFilter", "PhEvm.CallFilter memory filter;"), + ("PhEvm.AnomalyContext", "PhEvm.AnomalyContext memory ctx;"), + ("PhEvm.InflowContext", "PhEvm.InflowContext memory ctx;"), + ("PhEvm.OutflowContext", "PhEvm.OutflowContext memory ctx;"), + ("PhEvm.FlowRateContext", "PhEvm.FlowRateContext memory ctx;"), + ( + "PhEvm.Erc20TransferData", + "PhEvm.Erc20TransferData[] memory transfers;", + ), + ( + "PhEvm.StaticCallResult", + "PhEvm.StaticCallResult memory result;", + ), + ("PhEvm.LogQuery", "PhEvm.LogQuery memory query;"), + ("PhEvm.TxObject", "PhEvm.TxObject memory txObject;"), + ]; + + /// The marker lists and [`V2_SURFACE`] describe the same set, and each + /// entry is detected on its own. A V2 API that reaches `credible-std` + /// without reaching the marker lists is exactly the miss this guards: the + /// assertion deploys to a V1 platform and never fires, unwarned. + #[test] + #[allow(clippy::too_many_lines)] + fn v2_surface_is_covered() { + let declared: std::collections::BTreeSet<&str> = + V2_CALL_MARKERS.into_iter().chain(V2_TYPE_MARKERS).collect(); + let exercised: std::collections::BTreeSet<&str> = + V2_SURFACE.into_iter().map(|(marker, _)| marker).collect(); + assert_eq!( + declared, + exercised, + "marker lists and V2_SURFACE disagree; missing from V2_SURFACE: {:?}, missing from the marker lists: {:?}", + declared.difference(&exercised).collect::>(), + exercised.difference(&declared).collect::>(), + ); + + for (marker, usage) in V2_SURFACE { + let source = + format!("contract A is Assertion {{ function f() external {{ {usage} }} }}"); + assert_eq!( + detect_v2_markers(&source), + vec![marker.to_string()], + "`{usage}` should be detected as exactly {marker}" + ); + } + + // The executor dependency is the authority for the spec gate. Walk + // every selector in its generated PhEvm interface, ask Legacy whether + // it is allowed, and require this marker mapping to cover the exact + // rejected set. This fails when the pinned executor adds a non-Legacy + // precompile without a corresponding source marker. + #[cfg(feature = "credible")] + { + use alloy_sol_types::SolCall; + use assertion_executor::{ + phevm::sol_abi::PhEvm, + types::AssertionSpec, + }; + + let covered = [ + ("getTxObject", PhEvm::getTxObjectCall::SELECTOR), + ("ph.context", PhEvm::contextCall::SELECTOR), + ("outflowContext", PhEvm::outflowContextCall::SELECTOR), + ("inflowContext", PhEvm::inflowContextCall::SELECTOR), + ("anomalyContext", PhEvm::anomalyContextCall::SELECTOR), + ("callinputAt", PhEvm::callinputAtCall::SELECTOR), + ("callOutputAt", PhEvm::callOutputAtCall::SELECTOR), + ("matchingCalls", PhEvm::matchingCallsCall::SELECTOR), + ("loadStateAt", PhEvm::loadStateAt_0Call::SELECTOR), + ("loadStateAt", PhEvm::loadStateAt_1Call::SELECTOR), + ("getLogsQuery", PhEvm::getLogsQueryCall::SELECTOR), + ("getLogsForCall", PhEvm::getLogsForCallCall::SELECTOR), + ( + "forbidChangeForSlot", + PhEvm::forbidChangeForSlotCall::SELECTOR, + ), + ( + "forbidChangeForSlots", + PhEvm::forbidChangeForSlotsCall::SELECTOR, + ), + ("staticcallAt", PhEvm::staticcallAtCall::SELECTOR), + ("conserveBalance", PhEvm::conserveBalanceCall::SELECTOR), + ("getErc20Transfers", PhEvm::getErc20TransfersCall::SELECTOR), + ( + "getErc20TransfersForTokens", + PhEvm::getErc20TransfersForTokensCall::SELECTOR, + ), + ( + "changedErc20BalanceDeltas", + PhEvm::changedErc20BalanceDeltasCall::SELECTOR, + ), + ( + "reduceErc20BalanceDeltas", + PhEvm::reduceErc20BalanceDeltasCall::SELECTOR, + ), + ( + "changedMappingKeys", + PhEvm::changedMappingKeysCall::SELECTOR, + ), + ("mappingValueDiff", PhEvm::mappingValueDiffCall::SELECTOR), + ("ph.mulDivDown", PhEvm::mulDivDownCall::SELECTOR), + ("ph.mulDivUp", PhEvm::mulDivUpCall::SELECTOR), + ("normalizeDecimals", PhEvm::normalizeDecimalsCall::SELECTOR), + ("ratioGe", PhEvm::ratioGeCall::SELECTOR), + ("oracleSanity", PhEvm::oracleSanityCall::SELECTOR), + ("oracleSanityAt", PhEvm::oracleSanityAtCall::SELECTOR), + ( + "assetsMatchSharePrice", + PhEvm::assetsMatchSharePriceCall::SELECTOR, + ), + ( + "assetsMatchSharePriceAt", + PhEvm::assetsMatchSharePriceAtCall::SELECTOR, + ), + ("outflowRate", PhEvm::outflowRateCall::SELECTOR), + ("inflowRate", PhEvm::inflowRateCall::SELECTOR), + ]; + let executor_non_legacy: std::collections::BTreeSet<[u8; 4]> = + PhEvm::PhEvmCalls::SELECTORS + .iter() + .copied() + .filter(|selector| !AssertionSpec::Legacy.allows_selector(*selector)) + .collect(); + let covered_selectors: std::collections::BTreeSet<[u8; 4]> = + covered.iter().map(|(_, selector)| *selector).collect(); + + assert_eq!(covered_selectors, executor_non_legacy); + for (marker, _) in covered { + assert!( + declared.contains(marker), + "executor-gated precompile `{marker}` has no source marker" + ); + } + } + } + + #[test] + fn overlapping_call_names_are_all_reported_when_both_are_called() { + let markers = detect_v2_markers( + "ph.assetsMatchSharePrice(vault, 10); + ph.assetsMatchSharePriceAt(vault, 10, pre, post); + ph.oracleSanity(oracle, data, 10); + ph.oracleSanityAt(oracle, data, 10, pre, post);", + ); + + assert!(markers.contains(&"assetsMatchSharePrice".to_string())); + assert!(markers.contains(&"assetsMatchSharePriceAt".to_string())); + assert!(markers.contains(&"oracleSanity".to_string())); + assert!(markers.contains(&"oracleSanityAt".to_string())); + } + + #[test] + fn shared_sources_are_read_once_per_unique_path() { + let credible = credible_with(&[ + "assertions/src/Shared.a.sol:First", + "assertions/src/Shared.a.sol:Second", + "assertions/src/Shared.a.sol", + "assertions/src/Other.a.sol", + ]); + + assert_eq!( + unique_assertion_paths(&credible), + vec!["assertions/src/Shared.a.sol", "assertions/src/Other.a.sol"] + ); + } + + #[test] + fn scans_declared_sources_once_and_skips_missing_files() { + let temp = project_with(&[( + "assertions/src/V2.a.sol", + "contract V2 is Assertion { function t() external { registerTxEndTrigger(this.c.selector); } }", + )]); + let credible = credible_with(&[ + "assertions/src/V2.a.sol:V2", + "assertions/src/V2.a.sol", + "assertions/src/Missing.a.sol", + ]); + + let findings = scan_assertion_sources(temp.path(), &credible); + + assert_eq!( + findings, + vec![V2SpecFinding { + file: "assertions/src/V2.a.sol".to_string(), + markers: vec!["registerTxEndTrigger".to_string()], + }] + ); + } + + #[test] + fn deploy_warning_fires_on_v1_platform_and_names_files() { + let findings = vec![V2SpecFinding { + file: "assertions/src/V2.a.sol".to_string(), + markers: vec![ + "registerTxEndTrigger".to_string(), + "staticcallAt".to_string(), + ], + }]; + + let warning = deploy_warning(&url("https://app.phylax.systems"), None, &findings) + .expect("warning on the V1-only platform"); + assert!(warning.contains("V1 assertion spec"), "{warning}"); + assert!(warning.contains("assertions/src/V2.a.sol"), "{warning}"); + assert!( + warning.contains("registerTxEndTrigger, staticcallAt"), + "{warning}" + ); + + let with_chain = deploy_warning( + &url("https://app.phylax.systems"), + Some(LINEA_MAINNET_CHAIN_ID), + &findings, + ) + .expect("warning names the chain"); + assert!( + with_chain.contains("(Linea Mainnet, chain 59144)"), + "{with_chain}" + ); + } + + #[test] + fn deploy_warning_fires_for_a_linea_chain_on_another_platform() { + let findings = vec![V2SpecFinding { + file: "a.sol".to_string(), + markers: vec!["registerTxEndTrigger".to_string()], + }]; + + assert!( + deploy_warning( + &url("https://dev.phylax.systems"), + Some(LINEA_SEPOLIA_CHAIN_ID), + &findings + ) + .is_some() + ); + } + + #[test] + fn deploy_warning_stays_silent_without_v2_usage_or_on_a_v2_platform() { + let findings = vec![V2SpecFinding { + file: "a.sol".to_string(), + markers: vec!["registerTxEndTrigger".to_string()], + }]; + + assert!(deploy_warning(&url("https://app.phylax.systems"), None, &[]).is_none()); + assert!( + deploy_warning(&url("https://dev.phylax.systems"), Some(84532), &findings).is_none() + ); + } + + #[test] + fn login_notice_only_targets_v1_platforms() { + let notice = login_notice(&url("https://app.phylax.systems")).expect("notice"); + assert!(notice.contains("V1 assertion spec"), "{notice}"); + assert!(notice.contains("V2"), "{notice}"); + assert!(login_notice(&url("https://dev.phylax.systems")).is_none()); + } + + #[test] + fn warning_json_carries_the_files_and_target() { + let findings = vec![V2SpecFinding { + file: "a.sol".to_string(), + markers: vec!["staticcallAt".to_string()], + }]; + let platform = url("https://app.phylax.systems"); + let message = + deploy_warning(&platform, Some(LINEA_MAINNET_CHAIN_ID), &findings).expect("warning"); + + let value = warning_json(&message, &platform, Some(LINEA_MAINNET_CHAIN_ID), &findings); + + assert_eq!(value["code"], V2_SPEC_UNSUPPORTED_CODE); + assert_eq!(value["chain_id"], 59144); + assert_eq!(value["files"][0]["file"], "a.sol"); + assert_eq!(value["files"][0]["markers"][0], "staticcallAt"); + } +} diff --git a/crates/pcl/core/src/auth.rs b/crates/pcl/core/src/auth.rs index b1e30e99..b0443c96 100644 --- a/crates/pcl/core/src/auth.rs +++ b/crates/pcl/core/src/auth.rs @@ -6,6 +6,7 @@ use crate::{ request_id_from_headers, with_envelope_metadata, }, + assertion_spec, config::{ AUTH_EXPIRES_SOON_SECONDS, CliConfig, @@ -437,6 +438,42 @@ impl AuthCommand { ) } + /// The assertion-spec notice for the platform being logged in to: the + /// production platform runs the V1 spec, so assertions must not be written + /// against V2 there. Advice, not a finding — no project is in scope yet. + fn v2_spec_notice(&self) -> Option { + assertion_spec::login_notice(&self.effective_auth_url()) + } + + /// Attaches the assertion-spec notice to a login envelope so machine + /// callers see it in the same envelope as the login result. + fn with_v2_spec_warning(&self, mut envelope: Value) -> Value { + let Some(message) = self.v2_spec_notice() else { + return envelope; + }; + if let Some(object) = envelope.as_object_mut() { + object.insert( + "warnings".to_string(), + json!([assertion_spec::login_warning_json( + &message, + &self.effective_auth_url() + )]), + ); + } + envelope + } + + /// Prints the assertion-spec notice for humans; machine output carries it + /// in the envelope instead. + fn print_v2_spec_notice(&self, json_output: bool) { + if json_output || current_output_mode() != OutputMode::Human { + return; + } + if let Some(message) = self.v2_spec_notice() { + eprintln!("{} {message}", "Warning:".yellow().bold()); + } + } + /// Initiate the login process and wait for user authentication async fn login( &self, @@ -452,7 +489,11 @@ impl AuthCommand { let mut expired_auth = None; if let Some(auth) = &config.auth { if auth.expires_at > chrono::Utc::now() && !force { - Self::print_output(&self.status_envelope(config), json_output)?; + Self::print_output( + &self.with_v2_spec_warning(self.status_envelope(config)), + json_output, + )?; + self.print_v2_spec_notice(json_output); return Ok(()); } if auth.expires_at <= chrono::Utc::now() { @@ -474,7 +515,7 @@ impl AuthCommand { let auth_response = Self::request_auth_code(&client).await?; if no_wait { Self::print_output( - &self.login_challenge_envelope( + &self.with_v2_spec_warning(self.login_challenge_envelope( &auth_response, if force { AuthChallengeReason::Forced @@ -482,9 +523,10 @@ impl AuthCommand { auth_challenge_reason(config, false) }, json_output, - ), + )), json_output, )?; + self.print_v2_spec_notice(json_output); return Ok(()); } if json_output { @@ -493,7 +535,7 @@ impl AuthCommand { )?; self.wait_for_verification(config, &client, &auth_response, true) .await?; - let mut output = self.status_envelope(config); + let mut output = self.with_v2_spec_warning(self.status_envelope(config)); if let Some(object) = output.as_object_mut() { object.insert("event".to_string(), json!("auth.login_complete")); object.insert("terminal".to_string(), json!(true)); @@ -505,7 +547,9 @@ impl AuthCommand { self.display_login_instructions(&auth_response); self.wait_for_verification(config, &client, &auth_response, json_output) - .await + .await?; + self.print_v2_spec_notice(json_output); + Ok(()) } // Helper to create a new API client with the base URL set @@ -824,12 +868,13 @@ impl AuthCommand { expires_at.unwrap_or_else(default_poll_fallback_expires_at), )?; self.remember_platform_url(config); - let mut output = self.status_envelope(config); + let mut output = self.with_v2_spec_warning(self.status_envelope(config)); if let Some(object) = output.as_object_mut() { object.insert("event".to_string(), json!("auth.login_complete")); object.insert("terminal".to_string(), json!(true)); } Self::print_output(&output, json_output)?; + self.print_v2_spec_notice(json_output); return Ok(()); } @@ -1664,6 +1709,40 @@ mod tests { assert!(config.platform_url.is_none()); } + #[test] + fn login_warns_about_the_v2_spec_only_on_a_v1_platform() { + let login = |auth_url: &str| { + AuthCommand { + command: AuthSubcommands::Login { + force: false, + no_wait: false, + }, + auth_url: Some(auth_url.parse().unwrap()), + } + }; + + let production = login(DEFAULT_PLATFORM_URL); + let notice = production.v2_spec_notice().expect("notice on production"); + assert!(notice.contains("V1 assertion spec"), "{notice}"); + + // Machine callers get the same notice in the login envelope. + let envelope = production.with_v2_spec_warning(json!({"status": "ok"})); + assert_eq!( + envelope["warnings"][0]["code"], + crate::assertion_spec::V2_SPEC_UNSUPPORTED_CODE + ); + assert_eq!(envelope["warnings"][0]["assertion_spec"], "v1"); + + let custom = login("https://custom.phylax.example"); + assert!(custom.v2_spec_notice().is_none()); + assert!( + custom + .with_v2_spec_warning(json!({"status": "ok"})) + .get("warnings") + .is_none() + ); + } + #[test] fn logout_clears_remembered_platform_url() { let mut config = create_test_config(); diff --git a/crates/pcl/core/src/deploy.rs b/crates/pcl/core/src/deploy.rs index 96f39c63..00cd6ac9 100644 --- a/crates/pcl/core/src/deploy.rs +++ b/crates/pcl/core/src/deploy.rs @@ -21,6 +21,10 @@ use crate::{ canonicalize_root, confirm_apply, }, + assertion_spec::{ + self, + V2SpecFinding, + }, config::CliConfig, credible_config::CredibleToml, error::DeployError, @@ -814,6 +818,24 @@ fn progress(human: bool, message: &str) { } } +fn print_warning(message: &str) { + eprintln!("{} {message}", "Warning:".yellow().bold()); +} + +/// Prints the warnings carried by a result payload for humans; machine output +/// keeps them in the envelope instead. +fn print_human_warnings(data: &Value) { + for message in data + .get("warnings") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|warning| warning.get("message").and_then(Value::as_str)) + { + print_warning(message); + } +} + #[cfg(feature = "credible")] fn insert_field(data: &mut Value, key: &str, value: Value) { if let Some(object) = data.as_object_mut() { @@ -821,6 +843,17 @@ fn insert_field(data: &mut Value, key: &str, value: Value) { } } +fn attach_warnings(source: DeployError, warnings: Vec) -> DeployError { + if warnings.is_empty() { + source + } else { + DeployError::WithWarnings { + source: Box::new(source), + warnings, + } + } +} + impl DeployArgs { #[allow(clippy::too_many_lines)] pub async fn run(&self, cli_args: &CliArgs, config: &mut CliConfig) -> Result<(), DeployError> { @@ -843,9 +876,18 @@ impl DeployArgs { Some(self.wallet.signer(human).await?) }; + // Assertions written against the V2 spec do not run on a platform that + // serves V1 (production/Linea). That is a warning, not a gate: the + // platform decides what it accepts, and a stale marker list must never + // be the reason a deploy stops. + let v2_findings = assertion_spec::scan_assertion_sources(&root, &credible); + if self.dry_run { - // Plan only: build and verify locally, touch nothing remote. - let plan = Self::dry_run_plan(&credible, &root, output_mode, signer.as_ref())?; + // Plan only: build and verify locally, touch nothing remote. The + // chain is only known here when it was passed as the create input. + let warnings = self.spec_warnings(false, self.chain_id, &v2_findings); + let plan = + Self::dry_run_plan(&credible, &root, output_mode, signer.as_ref(), &warnings)?; return Self::finish_dry_run(plan, output_mode); } // Past the dry-run return a signer was always resolved above. @@ -854,6 +896,15 @@ impl DeployArgs { let api = ApiArgs::headless(self.api_url.clone()); ApplyArgs::ensure_fresh_auth(config, cli_args, &self.api_url).await?; + // Warn before step 1: creating a project POSTs and rewrites + // credible.toml, so a warning printed after it would arrive once + // cancelling is no longer free. The platform is always known here; the + // chain is too whenever it was passed as the create input, and + // otherwise comes from the project record below. + let warned_early = !self + .spec_warnings(human, self.chain_id, &v2_findings) + .is_empty(); + // ------------------------------------------------------------------ // Step 1: resolve or create the project // ------------------------------------------------------------------ @@ -1011,214 +1062,229 @@ impl DeployArgs { let project_chain_id = resolve_chain_id(self.chain_id, created_chain_id, &project)?; - // ------------------------------------------------------------------ - // Step 2: protocol manager - // ------------------------------------------------------------------ - let current_manager = project_manager_address(&project)?; - let wallet_address = signer.address(); - - let manager_action = - match manager_step(self.skip_protocol_manager, current_manager, wallet_address) { - ManagerStep::Skipped => "skipped".to_string(), - ManagerStep::AlreadySet => { - progress(human, "Protocol manager already set to this wallet"); - "already_set".to_string() - } - ManagerStep::Mismatch { current } => { - return Err(DeployError::ManagerMismatch { - project_id, - current: current.to_string(), - wallet: wallet_address.to_string(), - }); - } - ManagerStep::NeedsSet => { - self.confirm_step( + // Re-derive with the resolved chain: an existing project's chain is + // only known after the fetch, and this still lands before the + // protocol-manager step and before any release exists. Printing is + // suppressed when the pre-step-1 warning already said it. + let spec_warnings = + self.spec_warnings(human && !warned_early, Some(project_chain_id), &v2_findings); + let warnings_for_errors = spec_warnings.clone(); + + let result: Result<(), DeployError> = async { + // ------------------------------------------------------------------ + // Step 2: protocol manager + // ------------------------------------------------------------------ + let current_manager = project_manager_address(&project)?; + let wallet_address = signer.address(); + + let manager_action = + match manager_step(self.skip_protocol_manager, current_manager, wallet_address) { + ManagerStep::Skipped => "skipped".to_string(), + ManagerStep::AlreadySet => { + progress(human, "Protocol manager already set to this wallet"); + "already_set".to_string() + } + ManagerStep::Mismatch { current } => { + return Err(DeployError::ManagerMismatch { + project_id, + current: current.to_string(), + wallet: wallet_address.to_string(), + }); + } + ManagerStep::NeedsSet => { + self.confirm_step( human, &format!( "Set the protocol manager of project {project_id} to {wallet_address}?" ), )?; - progress( - human, - "Setting protocol manager (signed challenge, no transaction)", - ); - api.protocol_manager_set_signed_flow( + progress( + human, + "Setting protocol manager (signed challenge, no transaction)", + ); + api.protocol_manager_set_signed_flow( + config, + cli_args, + &project_id.to_string(), + project_chain_id, + self.broadcast_args(&signer), + ) + .await?; + "set".to_string() + } + }; + + // ------------------------------------------------------------------ + // Step 3: build + verify + preview + create/resume release + // ------------------------------------------------------------------ + progress(human, "Building and verifying assertions"); + let (payload, verification_inputs) = ApplyArgs::build_payload(&credible, &root)?; + #[cfg(feature = "credible")] + let verification = ApplyArgs::verify_all_assertions(&verification_inputs, output_mode)?; + #[cfg(not(feature = "credible"))] + let _ = verification_inputs; + + let client = self.typed_client(config)?; + progress(human, "Previewing release changes"); + let preview = ApplyArgs::call_preview(&client, &project_id, &payload).await?; + + let project_ref = project_id.to_string(); + let summaries = inactive_release_summaries( + &api, + config, + cli_args, + project_ref.as_str(), + &credible.environment, + ) + .await?; + // Every inactive release for this environment is a potential resume + // candidate, not just the newest: an interrupted earlier run can leave + // a matching inactive release behind a later, non-matching one. The + // preview cannot vouch for any of them (it diffs against the active + // release), so fetch each detail and compare its stored snapshot to the + // payload whether or not the preview reports changes. + let preview_value = serde_json::to_value(&preview)?; + let mut matched = Vec::new(); + for candidate in &summaries { + let detail = api + .workflow_json( config, cli_args, - &project_id.to_string(), - project_chain_id, - self.broadcast_args(&signer), + HttpMethod::Get, + "get_projects_project_id_releases_release_id", + &[ + ("project_id", project_ref.as_str()), + ("release_id", candidate.id.as_str()), + ], + None, ) .await?; - "set".to_string() + if snapshot_matches_preview(&detail, &preview_value) { + matched.push(candidate.clone()); + } + } + let resume = match scan_inactive_matches(matched) { + ResumeScan::None => None, + ResumeScan::One(candidate) => Some(candidate), + ResumeScan::Ambiguous(release_ids) => { + return Err(DeployError::AmbiguousInactiveRelease { + project_id, + environment: credible.environment.clone(), + release_ids, + }); + } + }; + let step = release_step(preview.has_changes(), resume.as_ref()); + + let (release_id, release_number, resumed) = match step { + ReleaseStep::UpToDate => { + progress(human, "Everything already released and deployed"); + let data = json!({ + "outcome": "up_to_date", + "project_id": project_id, + "project_created": project_created, + "protocol_manager": { "action": manager_action, "address": wallet_address }, + "release": Value::Null, + "tx": Value::Null, + "warnings": spec_warnings, + }); + #[cfg(feature = "credible")] + let data = { + let mut data = data; + insert_field(&mut data, "verification", json!(verification)); + data + }; + return Self::finish( + output_mode, + data, + vec![ + format!("pcl releases list {project_id}"), + format!("pcl projects show {project_id}"), + ], + ); + } + ReleaseStep::Resume { + release_id, + release_number, + } => { + progress( + human, + &format!("Resuming existing inactive release {release_id}"), + ); + (release_id, release_number, true) + } + ReleaseStep::Create => { + if human { + print!("{}", preview.render_plan()); + if !self.yes && !confirm_apply()? { + return Err(DeployError::Cancelled); + } + } + progress(human, "Creating release"); + let release = + ApplyArgs::call_create_release(&client, &project_id, &payload).await?; + ( + release.id.to_string(), + Some(u64::from(release.release_number)), + false, + ) } }; - // ------------------------------------------------------------------ - // Step 3: build + verify + preview + create/resume release - // ------------------------------------------------------------------ - progress(human, "Building and verifying assertions"); - let (payload, verification_inputs) = ApplyArgs::build_payload(&credible, &root)?; - #[cfg(feature = "credible")] - let verification = ApplyArgs::verify_all_assertions(&verification_inputs, output_mode)?; - #[cfg(not(feature = "credible"))] - let _ = verification_inputs; - - let client = self.typed_client(config)?; - progress(human, "Previewing release changes"); - let preview = ApplyArgs::call_preview(&client, &project_id, &payload).await?; - - let project_ref = project_id.to_string(); - let summaries = inactive_release_summaries( - &api, - config, - cli_args, - project_ref.as_str(), - &credible.environment, - ) - .await?; - // Every inactive release for this environment is a potential resume - // candidate, not just the newest: an interrupted earlier run can leave - // a matching inactive release behind a later, non-matching one. The - // preview cannot vouch for any of them (it diffs against the active - // release), so fetch each detail and compare its stored snapshot to the - // payload whether or not the preview reports changes. - let preview_value = serde_json::to_value(&preview)?; - let mut matched = Vec::new(); - for candidate in &summaries { - let detail = api - .workflow_json( + // ------------------------------------------------------------------ + // Step 4: wait for deploy-gating checks + // ------------------------------------------------------------------ + let check_state = self + .wait_for_checks(&api, config, cli_args, &project_ref, &release_id, human) + .await?; + + // ------------------------------------------------------------------ + // Step 5+6: on-chain deploy (noop-aware) + platform confirmation + // ------------------------------------------------------------------ + progress(human, "Deploying release on-chain"); + let deploy_envelope = api + .release_deploy_broadcast_flow( config, cli_args, - HttpMethod::Get, - "get_projects_project_id_releases_release_id", - &[ - ("project_id", project_ref.as_str()), - ("release_id", candidate.id.as_str()), - ], - None, + &project_ref, + &release_id, + self.broadcast_args(&signer), ) .await?; - if snapshot_matches_preview(&detail, &preview_value) { - matched.push(candidate.clone()); - } - } - let resume = match scan_inactive_matches(matched) { - ResumeScan::None => None, - ResumeScan::One(candidate) => Some(candidate), - ResumeScan::Ambiguous(release_ids) => { - return Err(DeployError::AmbiguousInactiveRelease { - project_id, - environment: credible.environment.clone(), - release_ids, - }); - } - }; - let step = release_step(preview.has_changes(), resume.as_ref()); - - let (release_id, release_number, resumed) = match step { - ReleaseStep::UpToDate => { - progress(human, "Everything already released and deployed"); - let data = json!({ - "outcome": "up_to_date", - "project_id": project_id, - "project_created": project_created, - "protocol_manager": { "action": manager_action, "address": wallet_address }, - "release": Value::Null, - "tx": Value::Null, - }); - #[cfg(feature = "credible")] - let data = { - let mut data = data; - insert_field(&mut data, "verification", json!(verification)); - data - }; - return Self::finish( - output_mode, - data, - vec![ - format!("pcl releases list {project_id}"), - format!("pcl projects show {project_id}"), - ], - ); - } - ReleaseStep::Resume { - release_id, - release_number, - } => { - progress( - human, - &format!("Resuming existing inactive release {release_id}"), - ); - (release_id, release_number, true) - } - ReleaseStep::Create => { - if human { - print!("{}", preview.render_plan()); - if !self.yes && !confirm_apply()? { - return Err(DeployError::Cancelled); - } - } - progress(human, "Creating release"); - let release = - ApplyArgs::call_create_release(&client, &project_id, &payload).await?; - ( - release.id.to_string(), - Some(u64::from(release.release_number)), - false, - ) - } - }; - - // ------------------------------------------------------------------ - // Step 4: wait for deploy-gating checks - // ------------------------------------------------------------------ - let check_state = self - .wait_for_checks(&api, config, cli_args, &project_ref, &release_id, human) - .await?; - - // ------------------------------------------------------------------ - // Step 5+6: on-chain deploy (noop-aware) + platform confirmation - // ------------------------------------------------------------------ - progress(human, "Deploying release on-chain"); - let deploy_envelope = api - .release_deploy_broadcast_flow( - config, - cli_args, - &project_ref, - &release_id, - self.broadcast_args(&signer), + let deploy_data = deploy_envelope.get("data").cloned().unwrap_or(Value::Null); + + let data = json!({ + "outcome": if resumed { "resumed_and_deployed" } else { "released_and_deployed" }, + "project_id": project_id, + "project_created": project_created, + "protocol_manager": { "action": manager_action, "address": wallet_address }, + "release": { + "id": release_id, + "release_number": release_number, + "resumed": resumed, + }, + "checks": check_state, + "deploy": deploy_data, + "warnings": spec_warnings, + }); + #[cfg(feature = "credible")] + let data = { + let mut data = data; + insert_field(&mut data, "verification", json!(verification)); + data + }; + Self::finish( + output_mode, + data, + vec![ + format!("pcl releases show {project_id} {release_id}"), + format!("pcl deployments --project {project_id}"), + ], ) - .await?; - let deploy_data = deploy_envelope.get("data").cloned().unwrap_or(Value::Null); + } + .await; - let data = json!({ - "outcome": if resumed { "resumed_and_deployed" } else { "released_and_deployed" }, - "project_id": project_id, - "project_created": project_created, - "protocol_manager": { "action": manager_action, "address": wallet_address }, - "release": { - "id": release_id, - "release_number": release_number, - "resumed": resumed, - }, - "checks": check_state, - "deploy": deploy_data, - }); - #[cfg(feature = "credible")] - let data = { - let mut data = data; - insert_field(&mut data, "verification", json!(verification)); - data - }; - Self::finish( - output_mode, - data, - vec![ - format!("pcl releases show {project_id} {release_id}"), - format!("pcl deployments --project {project_id}"), - ], - ) + result.map_err(|source| attach_warnings(source, warnings_for_errors)) } /// Resolves a surviving create intent against the platform: returns the @@ -1378,11 +1444,40 @@ impl DeployArgs { } } + /// The V2-spec mismatch warning for this run, in the machine-output form + /// carried by the result envelope. Empty when the target platform runs V2 + /// or the assertions do not use it. + /// + /// `print_now` prints it for humans immediately, before anything is + /// mutated. The terminal output prints it again from the payload, so a run + /// that mutates nothing (`--dry-run`) passes `false` and reports once. + fn spec_warnings( + &self, + print_now: bool, + chain_id: Option, + findings: &[V2SpecFinding], + ) -> Vec { + let Some(message) = assertion_spec::deploy_warning(&self.api_url, chain_id, findings) + else { + return Vec::new(); + }; + if print_now { + print_warning(&message); + } + vec![assertion_spec::warning_json( + &message, + &self.api_url, + chain_id, + findings, + )] + } + fn dry_run_plan( credible: &CredibleToml, root: &Path, output_mode: OutputMode, signer: Option<&alloy_signer_local::PrivateKeySigner>, + warnings: &[Value], ) -> Result { let (payload, verification_inputs) = ApplyArgs::build_payload(credible, root)?; #[cfg(feature = "credible")] @@ -1397,6 +1492,7 @@ impl DeployArgs { "would_create_project": credible.project_id.is_none(), "wallet_address": signer.map(|s| s.address().to_string()), "payload": payload, + "warnings": warnings, }); #[cfg(feature = "credible")] let data = { @@ -1412,6 +1508,7 @@ impl DeployArgs { println!( "Dry run complete. Built and verified the release payload; no project or release was created." ); + print_human_warnings(&data); return Ok(()); } let envelope = ok_envelope(data, vec!["pcl deploy --yes".to_string()]); @@ -1449,6 +1546,9 @@ impl DeployArgs { for action in &next_actions { println!(" next: {action}"); } + // Repeated at the end: the same warning was printed before the + // release existed, far enough back to have scrolled away. + print_human_warnings(&data); return Ok(()); } let envelope = ok_envelope(data, next_actions); @@ -1471,6 +1571,72 @@ mod tests { const WALLET: Address = address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); const OTHER: Address = address!("0101010101010101010101010101010101010101"); + /// `--api-url` is the target platform, so it decides whether V2 assertions + /// are supported; machine output carries the warning in the envelope. + #[test] + fn spec_warnings_follow_the_target_platform_and_chain() { + use clap::Parser as _; + + let deploy = |api_url: &str| { + DeployArgs::try_parse_from(["deploy", "--api-url", api_url]).expect("parse deploy args") + }; + let findings = [V2SpecFinding { + file: "assertions/src/V2.a.sol".to_string(), + markers: vec!["registerTxEndTrigger".to_string()], + }]; + + let production = deploy("https://app.phylax.systems"); + let warnings = production.spec_warnings(false, Some(8453), &findings); + assert_eq!(warnings.len(), 1); + assert_eq!( + warnings[0]["code"], + crate::assertion_spec::V2_SPEC_UNSUPPORTED_CODE + ); + assert_eq!(warnings[0]["files"][0]["file"], "assertions/src/V2.a.sol"); + + // A Linea chain warns even when the platform is not production. + let custom = deploy("https://custom.phylax.example"); + assert_eq!( + custom + .spec_warnings( + false, + Some(crate::assertion_spec::LINEA_MAINNET_CHAIN_ID), + &findings + ) + .len(), + 1 + ); + + // V2-capable target, or no V2 usage: nothing to warn about. + assert!( + custom + .spec_warnings(false, Some(8453), &findings) + .is_empty() + ); + assert!(production.spec_warnings(false, Some(8453), &[]).is_empty()); + } + + #[test] + fn errors_are_only_wrapped_when_warnings_exist() { + assert!(matches!( + attach_warnings(DeployError::Cancelled, Vec::new()), + DeployError::Cancelled + )); + + let warning = json!({ + "code": crate::assertion_spec::V2_SPEC_UNSUPPORTED_CODE, + "message": "production runs V1", + }); + let error = attach_warnings(DeployError::Cancelled, vec![warning.clone()]); + match error { + DeployError::WithWarnings { source, warnings } => { + assert!(matches!(*source, DeployError::Cancelled)); + assert_eq!(warnings, vec![warning]); + } + other => panic!("expected warning wrapper, got {other:?}"), + } + } + #[test] fn manager_step_decision_table() { assert_eq!(manager_step(true, None, WALLET), ManagerStep::Skipped); diff --git a/crates/pcl/core/src/error.rs b/crates/pcl/core/src/error.rs index 7bebf982..a7143a42 100644 --- a/crates/pcl/core/src/error.rs +++ b/crates/pcl/core/src/error.rs @@ -123,6 +123,13 @@ pub enum VerifyError { /// Errors that can occur during the end-to-end `pcl deploy` orchestration. #[derive(Error, Debug)] pub enum DeployError { + #[error("{source}")] + WithWarnings { + #[source] + source: Box, + warnings: Vec, + }, + #[error(transparent)] Apply(#[from] ApplyError), diff --git a/crates/pcl/core/src/lib.rs b/crates/pcl/core/src/lib.rs index 32a4f26c..f030bd6f 100644 --- a/crates/pcl/core/src/lib.rs +++ b/crates/pcl/core/src/lib.rs @@ -7,6 +7,7 @@ pub mod abi; pub mod api; pub mod apply; +pub mod assertion_spec; pub mod auth; pub mod client; pub mod config;