Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable user-facing changes should be recorded here.

## Unreleased

### Fixed

- Broadcasting no longer fails on a brief `assertions are unavailable` refusal. The transaction is signed once and resubmitted byte for byte, so a retry is deduplicated by hash rather than becoming a second transaction on the same nonce. `--with-credible-rpc` waits out a Credible RPC's full alignment window; without it an unavailable endpoint still gets a short retry. If the retries run out, `onchain.assertions_unavailable` means nothing was submitted, while `onchain.tx_submission_unconfirmed` carries the signed hash to check first.

### 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.
Expand Down
21 changes: 16 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ checks, broadcast `StateOracle.batch`, and confirm — use `pcl deploy`:
# one-time: store an RPC endpoint for the chain
pcl config set-rpc <chain-id> <rpc-url> [--confirmations N]

# a Credible RPC endpoint can refuse briefly while its assertion state catches
# up with the chain; this waits that window out instead of failing
pcl deploy --with-credible-rpc

pcl deploy --dry-run
pcl deploy --private-key $PCL_PRIVATE_KEY # or --account <foundry-keystore>
pcl deploy --project-name my-protocol --chain-id <id> --private-key ... --yes --json
Expand Down
4 changes: 2 additions & 2 deletions crates/pcl/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,6 @@ credible = ["dep:assertion-executor", "dep:assertion-verification"]
[dev-dependencies]
mockito = "1.2"
rand = "0.8"
# `test-util` lets the config-lock timeout be asserted on a paused clock instead
# of waiting out the real 30-second budget.
# `test-util` lets the config-lock timeout and the submission backoff be
# asserted on a paused clock instead of waiting out real time.
tokio = { workspace = true, features = ["test-util"] }
44 changes: 27 additions & 17 deletions crates/pcl/core/src/api/broadcast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ use alloy_primitives::{
Address,
Bytes,
};
use alloy_rpc_types_eth::TransactionRequest;
use alloy_signer_local::PrivateKeySigner;
use colored::Colorize;
use pcl_common::args::CliArgs;
Expand Down Expand Up @@ -240,24 +241,27 @@ fn progress(cli_args: &CliArgs, message: &str) {
}
}

/// `platform_required` is the confirmation requirement stated by the platform
/// in the calldata response, when it carries one; it overrides the static
/// per-chain fallback (the platform's chain profiles differ per deployment).
async fn send_tx(
config: &CliConfig,
cli_args: &CliArgs,
tx_args: &TxArgs,
signer: PrivateKeySigner,
chain_id: u64,
to: Address,
data: Bytes,
request: TransactionRequest,
// The platform's requirement when the calldata response carries one; it
// overrides the static per-chain fallback.
platform_required: Option<u64>,
) -> Result<TxOutcome, ApiCommandError> {
let rpc = tx_args.resolve_rpc(config, chain_id)?;
let confirmations = tx_args.resolve_confirmations(config, chain_id, platform_required)?;
let sender = TxSender::connect(rpc, signer, chain_id).await?;
Ok(sender
.send_and_confirm(to, data, confirmations, tx_args.timeout())
.await?)
let sender = TxSender::connect(rpc, signer, chain_id, tx_args.with_credible_rpc).await?;
Ok(Box::pin(sender.send_and_confirm(
request,
confirmations,
tx_args.timeout(),
&|message: &str| progress(cli_args, message),
))
.await?)
}

fn tx_value(outcome: &TxOutcome) -> Value {
Expand Down Expand Up @@ -561,11 +565,13 @@ impl ApiArgs {
progress(cli_args, "Broadcasting StateOracle.batch transaction");
let outcome = send_tx(
config,
cli_args,
&args.broadcast.tx,
signer,
calldata.chain_id,
calldata.state_oracle_address,
encode_batch(calldata.calldata.clone()),
TransactionRequest::default()
.to(calldata.state_oracle_address)
.input(encode_batch(calldata.calldata.clone()).into()),
calldata.required_confirmations,
)
.await?;
Expand Down Expand Up @@ -660,11 +666,13 @@ impl ApiArgs {
progress(cli_args, "Broadcasting removal transaction");
let outcome = send_tx(
config,
cli_args,
&args.broadcast.tx,
signer,
chain_id,
calldata.to,
calldata.data.clone(),
TransactionRequest::default()
.to(calldata.to)
.input(calldata.data.clone().into()),
calldata.required_confirmations,
)
.await?;
Expand Down Expand Up @@ -913,11 +921,11 @@ impl ApiArgs {
progress(cli_args, "Broadcasting manager transfer transaction");
let outcome = send_tx(
config,
cli_args,
&args.broadcast.tx,
signer,
chain_id,
to,
data,
TransactionRequest::default().to(to).input(data.into()),
calldata.required_confirmations,
)
.await?;
Expand Down Expand Up @@ -985,11 +993,13 @@ impl ApiArgs {
let signer_address = signer.address();
let outcome = send_tx(
config,
cli_args,
&args.broadcast.tx,
signer,
calldata.chain_id,
calldata.to,
calldata.calldata.clone(),
TransactionRequest::default()
.to(calldata.to)
.input(calldata.calldata.clone().into()),
calldata.required_confirmations,
)
.await?;
Expand Down
22 changes: 22 additions & 0 deletions crates/pcl/core/src/api/definitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,28 @@ mod tests {
assert_eq!(broadcast_actions, 4);
}

#[test]
fn broadcast_actions_expose_the_credible_rpc_retry_flag() {
for definition in workflow_definitions() {
for action in definition
.actions
.iter()
.filter(|action| action.name.ends_with("_broadcast"))
{
let manifest = action.manifest_value();
let optional_flags = manifest["optional_flags"].as_array().unwrap();
assert!(
optional_flags
.iter()
.any(|flag| flag == "--with-credible-rpc"),
"{}.{} omits --with-credible-rpc",
definition.name,
action.name
);
}
}
}

/// The regression the metadata exists for: an action anchored to a
/// read-only calldata GET (`transfer_broadcast`) is still mutating, while
/// the plain calldata action on the same endpoint stays read-only.
Expand Down
79 changes: 54 additions & 25 deletions crates/pcl/core/src/api/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ use crate::{
error::AuthError,
output::with_envelope_metadata,
};
use alloy_primitives::{
ChainId,
TxHash,
};
use serde_json::{
Map,
Value,
Expand Down Expand Up @@ -132,6 +136,22 @@ pub enum ApiCommandError {
}

impl ApiCommandError {
// A signed transaction whose outcome is unobserved: the hash exists, the
// result does not, so a retry could double-broadcast.
fn ambiguous_submission(&self) -> Option<(&TxHash, ChainId)> {
match self {
Self::Onchain(
crate::onchain::OnchainError::ConfirmationUnknown {
tx_hash, chain_id, ..
}
| crate::onchain::OnchainError::SubmissionUnconfirmed {
tx_hash, chain_id, ..
},
) => Some((tx_hash, *chain_id)),
_ => None,
}
}

pub fn code(&self) -> &'static str {
match self {
Self::NoAuthToken => "auth.no_token",
Expand Down Expand Up @@ -187,6 +207,12 @@ impl ApiCommandError {
crate::onchain::OnchainError::ConfirmationUnknown { .. } => {
"onchain.tx_submitted_confirmation_unknown"
}
crate::onchain::OnchainError::AssertionsUnavailable { .. } => {
"onchain.assertions_unavailable"
}
crate::onchain::OnchainError::SubmissionUnconfirmed { .. } => {
"onchain.tx_submission_unconfirmed"
}
_ => "onchain.failed",
}
}
Expand All @@ -201,6 +227,18 @@ impl ApiCommandError {
}

pub fn next_actions(&self) -> Vec<String> {
// The transaction is signed and may be in flight; re-running now could
// broadcast a duplicate while the first is still pending.
if let Some((tx_hash, chain_id)) = self.ambiguous_submission() {
return vec![
format!(
"Check transaction {tx_hash} on chain {chain_id} first (e.g. cast receipt {tx_hash} --rpc-url <url>, or a block explorer)"
),
"If it confirmed, re-run the command — landed state reconciles without a new transaction; if it is still pending, wait — do not re-broadcast"
.to_string(),
];
}

match self {
Self::NoAuthToken | Self::ExpiredAuthToken(_) | Self::AuthRefresh(_) => {
vec![
Expand Down Expand Up @@ -376,19 +414,13 @@ impl ApiCommandError {
"Or pass --rpc-url / set PCL_RPC_URL".to_string(),
]
}
Self::Onchain(crate::onchain::OnchainError::ConfirmationUnknown {
tx_hash,
chain_id,
..
}) => {
// The transaction was accepted by the RPC; only the receipt
// poll failed. Re-running immediately could broadcast a
// duplicate mutation while the first is still pending.
Self::Onchain(crate::onchain::OnchainError::AssertionsUnavailable { .. }) => {
// Nothing was signed or submitted, so the same command is the
// recovery: the refusal is transient by construction.
vec![
format!(
"Check transaction {tx_hash} on chain {chain_id} first (e.g. cast receipt {tx_hash} --rpc-url <url>, or a block explorer)"
),
"If it confirmed, re-run the command — landed state reconciles without a new transaction; if it is still pending, wait — do not re-broadcast"
"Re-run the same command; nothing was submitted and the account nonce is unchanged"
.to_string(),
"If it keeps failing, check the Credible RPC endpoint's assertion alignment"
.to_string(),
]
}
Expand Down Expand Up @@ -466,13 +498,16 @@ impl ApiCommandError {
}
Self::HttpStatus { .. } => vec!["inspect_response_body", "retry"],
Self::Wallet(_) => vec!["fix_wallet", "retry"],
Self::Onchain(crate::onchain::OnchainError::ConfirmationUnknown { .. }) => {
_ if self.ambiguous_submission().is_some() => {
vec![
"check_tx_status",
"wait_for_confirmation",
"reconcile_mutation",
]
}
Self::Onchain(crate::onchain::OnchainError::AssertionsUnavailable { .. }) => {
vec!["retry_later", "retry"]
}
Self::Onchain(_) => vec!["check_chain_config", "retry"],
Self::BroadcastCancelled => vec!["rerun_with_yes"],
Self::ConfirmAfterTx { .. } => vec!["confirm_platform_only", "reconcile_mutation"],
Expand Down Expand Up @@ -547,16 +582,7 @@ impl ApiCommandError {
error.insert("source".to_string(), source.json_envelope());
}

// A transaction that was submitted but whose receipt could not be
// observed (timeout, transport failure while polling) is an ambiguous
// mutation: the hash exists, the outcome does not — a retry could
// double-broadcast.
if let Self::Onchain(crate::onchain::OnchainError::ConfirmationUnknown {
tx_hash,
chain_id,
..
}) = self
{
if let Some((tx_hash, chain_id)) = self.ambiguous_submission() {
error.insert("tx_hash".to_string(), json!(tx_hash));
error.insert("chain_id".to_string(), json!(chain_id));
error.insert(
Expand Down Expand Up @@ -595,8 +621,11 @@ impl ApiCommandError {
);
}

if let Self::ConfirmAfterTx { tx_hash, .. }
| Self::Onchain(crate::onchain::OnchainError::ConfirmationUnknown { tx_hash, .. }) = self
let tx_hash = match self {
Self::ConfirmAfterTx { tx_hash, .. } => Some(tx_hash),
_ => self.ambiguous_submission().map(|(tx_hash, _)| tx_hash),
};
if let Some(tx_hash) = tx_hash
&& let Some(object) = envelope.as_object_mut()
{
object.insert("tx_hash".to_string(), json!(tx_hash));
Expand Down
Loading
Loading