0.1.0
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
# file: kb_execution_solana/Cargo.toml
|
||||
# version: 3
|
||||
|
||||
[package]
|
||||
name = "kb_execution_solana"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
kb_core = { path = "../kb_core" }
|
||||
kb_execution_api = { path = "../kb_execution_api" }
|
||||
kb_execution_safety = { path = "../kb_execution_safety" }
|
||||
kb_model = { path = "../kb_model" }
|
||||
solana-hash.workspace = true
|
||||
solana-instruction.workspace = true
|
||||
solana-keypair.workspace = true
|
||||
solana-message.workspace = true
|
||||
solana-nonce.workspace = true
|
||||
solana-pubkey.workspace = true
|
||||
solana-sdk-ids.workspace = true
|
||||
solana-signer.workspace = true
|
||||
solana-system-interface.workspace = true
|
||||
solana-transaction.workspace = true
|
||||
tracing.workspace = true
|
||||
wincode.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
kb_executor_solana_core = { path = "../kb_executor_solana_core" }
|
||||
kb_wallet = { path = "../kb_wallet" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,151 @@
|
||||
<!-- file: kb_execution_solana/README.md -->
|
||||
<!-- version: 4 -->
|
||||
|
||||
# kb_execution_solana
|
||||
|
||||
`kb_execution_solana` transforme un `PreparedExecutionPlan` commun en message et transaction Solana, puis résout les signataires via l’interface Solana `Signer` et signe uniquement après une simulation autorisée par `kb_execution_safety`.
|
||||
|
||||
La crate est indépendante des exécuteurs concrets et des transports RPC. Elle assemble aussi bien les plans à recent blockhash que les transactions consommant réellement un durable nonce.
|
||||
|
||||
## API publique
|
||||
|
||||
| Export | Usage |
|
||||
|----------------------------------------|-------------------------------------------------------------------------------------------------------|
|
||||
| `build_legacy_transaction(...)` | Compile un plan `Latest` avec un recent blockhash explicite. |
|
||||
| `build_durable_nonce_transaction(...)` | Compile un plan `DurableNonce` avec un état nonce validé et injecte l’avance en première instruction. |
|
||||
| `durable_nonce_account_data_length()` | Fournit la longueur exacte à demander au RPC pour un compte nonce. |
|
||||
| `parse_durable_nonce_account(...)` | Valide et décode un snapshot complet de compte nonce System. |
|
||||
| `DurableNonceAccountState` | État validé exposant compte, autorité, nonce/blockhash et coût par signature. |
|
||||
| `UnsignedSolanaTransaction` | Message compilé non signé, destiné aux frais, à la simulation exacte et à la signature contrôlée. |
|
||||
| `SolanaSimulationEvidence` | Simulation liée au hash et au blockhash/nonce exacts du message. |
|
||||
| `SignedSolanaTransaction` | Transaction signée, vérifiable localement et sérialisable pour `sendTransaction`. |
|
||||
|
||||
### Méthodes des types
|
||||
|
||||
| Type | Méthodes publiques |
|
||||
|-----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `DurableNonceAccountState` | `account()`, `authority()`, `blockhash()`, `lamports_per_signature()`. |
|
||||
| `UnsignedSolanaTransaction` | `plan()`, `message_hash()`, `blockhash()`, `required_signer_pubkeys()`, `message_base64()`, `transaction_base64()`, `bind_simulation(...)`, `sign_after_simulation(...)`. |
|
||||
| `SolanaSimulationEvidence` | `message_hash()`, `blockhash()`, `simulation()`. |
|
||||
| `SignedSolanaTransaction` | `message_hash()`, `primary_signature()`, `signer_pubkeys()`, `wire_length()`, `transaction_base64()`, `verify_signatures()`. |
|
||||
|
||||
## Exemple de flux
|
||||
|
||||
```rust
|
||||
let unsigned = kb_execution_solana::build_legacy_transaction(&plan, recent_blockhash)?;
|
||||
let simulation = rpc_simulation.to_execution_result(
|
||||
unsigned.message_hash(),
|
||||
unsigned.blockhash(),
|
||||
observed_cluster,
|
||||
)?;
|
||||
let evidence = unsigned.bind_simulation(simulation)?;
|
||||
let signed = unsigned.sign_after_simulation(&evidence, signers.as_slice())?;
|
||||
signed.verify_signatures()?;
|
||||
```
|
||||
|
||||
Le transport RPC reste extérieur : cette crate ne récupère pas le blockhash, ne charge pas le compte nonce et n’envoie pas le payload signé.
|
||||
|
||||
## Assemblage à recent blockhash
|
||||
|
||||
### `build_legacy_transaction(plan, recent_blockhash)`
|
||||
|
||||
Construit une transaction legacy non signée à partir d’un blockhash base58 obtenu par `getLatestBlockhash`.
|
||||
|
||||
La fonction vérifie :
|
||||
|
||||
- la politique `ExecutionBlockhashKind::Latest` ;
|
||||
- les public keys et le blockhash ;
|
||||
- la présence d’au moins une instruction ;
|
||||
- l’égalité exacte entre signataires déclarés et signataires compilés ;
|
||||
- la limite wire Solana de 1 232 octets.
|
||||
|
||||
Un plan `DurableNonce` est refusé et doit utiliser le chemin dédié.
|
||||
|
||||
## Lecture et validation d’un compte nonce
|
||||
|
||||
### `durable_nonce_account_data_length()`
|
||||
|
||||
Retourne la taille wire officielle d’un compte nonce, actuellement 80 octets. Cette valeur doit être utilisée comme limite exacte d’un appel `getAccountInfo` avec données complètes.
|
||||
|
||||
### `parse_durable_nonce_account(account, owner, executable, space, data)`
|
||||
|
||||
Décode l’état officiel `solana_nonce::versions::Versions` avec `wincode::deserialize_exact` et produit un `DurableNonceAccountState` uniquement lorsque toutes les conditions suivantes sont satisfaites :
|
||||
|
||||
- adresse du compte valide ;
|
||||
- owner égal au System Program ;
|
||||
- compte non exécutable ;
|
||||
- `space` et longueur décodée égaux à la taille officielle ;
|
||||
- version `Current` ;
|
||||
- état `Initialized` ;
|
||||
- autorité et valeur nonce extraites sans approximation.
|
||||
|
||||
Les états `Legacy`, `Uninitialized`, tronqués, suffixés ou possédés par un autre programme sont refusés. `DurableNonceAccountState` expose uniquement l’adresse, l’autorité, la valeur nonce utilisée comme blockhash et le coût par signature stocké dans l’état.
|
||||
|
||||
Le chargement réseau reste extérieur à cette crate. Le contrat RPC prévu est :
|
||||
|
||||
```rust
|
||||
let config = kb_rpc::GetAccountInfoConfig::confirmed_with_data(
|
||||
kb_execution_solana::durable_nonce_account_data_length(),
|
||||
)?;
|
||||
```
|
||||
|
||||
## Assemblage durable nonce
|
||||
|
||||
### `build_durable_nonce_transaction(plan, nonce_state)`
|
||||
|
||||
Construit une transaction non signée qui consomme l’état nonce validé.
|
||||
|
||||
La fonction exige :
|
||||
|
||||
- une politique `ExecutionBlockhashKind::DurableNonce` sans âge de blockhash récent ;
|
||||
- le même compte nonce dans la politique et dans l’état validé ;
|
||||
- la même autorité dans la politique et dans l’état validé ;
|
||||
- l’autorité nonce dans `required_signers` ;
|
||||
- au moins une instruction métier à exécuter après l’avance nonce.
|
||||
|
||||
L’assemblage utilise `Message::new_with_nonce`, place l’instruction officielle `AdvanceNonceAccount` en position zéro, puis remplace le champ `recent_blockhash` par la valeur nonce extraite de l’état. Le plan d’origine n’est pas muté : `UnsignedSolanaTransaction::plan()` expose un plan effectif cloné contenant l’instruction injectée.
|
||||
|
||||
Après compilation, la crate vérifie :
|
||||
|
||||
- la reconnaissance de la transaction par `solana_transaction::uses_durable_nonce` ;
|
||||
- l’égalité exacte entre signataires déclarés et signataires compilés ;
|
||||
- la taille wire ;
|
||||
- le hash du message et la valeur nonce effectivement compilée.
|
||||
|
||||
Les opérations de gestion du compte nonce elles-mêmes, telles que `initialize`, `authorize`, `withdraw` ou `upgrade`, restent des transactions ordinaires à recent blockhash. Elles ne passent pas par ce chemin.
|
||||
|
||||
## Simulation et signature
|
||||
|
||||
### `UnsignedSolanaTransaction`
|
||||
|
||||
Expose :
|
||||
|
||||
- le plan effectif ;
|
||||
- le hash exact du message ;
|
||||
- le recent blockhash ou la valeur nonce compilée ;
|
||||
- l’ordre exact des signataires ;
|
||||
- le message base64 pour `getFeeForMessage` ;
|
||||
- la transaction non signée base64 pour `simulateTransaction` avec `sigVerify = false` et `replaceRecentBlockhash = false`.
|
||||
|
||||
### `bind_simulation(result)`
|
||||
|
||||
Lie le résultat typé de simulation au hash du message **et** au blockhash/nonce compilé. Une simulation utilisant un autre message, une autre source de blockhash ou un blockhash de remplacement ne peut pas autoriser la signature.
|
||||
|
||||
### `sign_after_simulation(evidence, signers)`
|
||||
|
||||
Réévalue `kb_execution_safety`, exige une décision `Allow`, résout exactement tous les signataires via `&dyn solana_signer::Signer`, signe puis vérifie que la transaction est complète.
|
||||
|
||||
Les signataires manquants, supplémentaires ou dupliqués sont refusés. La fonction ne contacte aucun endpoint RPC et n’envoie rien.
|
||||
|
||||
### `SignedSolanaTransaction`
|
||||
|
||||
Expose uniquement des données non secrètes :
|
||||
|
||||
- hash du message ;
|
||||
- signature primaire ;
|
||||
- public keys des signataires ;
|
||||
- taille wire ;
|
||||
- payload base64 signé ;
|
||||
- vérification locale des signatures.
|
||||
|
||||
Le payload signé reste côté backend. Il ne doit pas être transféré automatiquement à Tauri. `kb_rpc::HttpClient::send_transaction` et sa variante pool imposent le preflight et refusent une signature RPC différente de la signature primaire locale.
|
||||
@@ -0,0 +1,11 @@
|
||||
// file: kb_execution_solana/src/constants.rs
|
||||
// version: 5
|
||||
|
||||
//! Local constants for the `kb_execution_solana` crate.
|
||||
|
||||
/// Maximum over-the-wire size of a Solana transaction packet.
|
||||
pub(crate) const MAX_TRANSACTION_WIRE_BYTES: usize = 1_232;
|
||||
/// Stable operation code for the nonce advance injected by durable transaction assembly.
|
||||
pub(crate) const DURABLE_NONCE_ADVANCE_OPERATION: &str = "solana_core.system.nonce.advance";
|
||||
/// Canonical tracing target for this crate.
|
||||
pub(crate) const TRACING_TARGET: &str = "kb_execution_solana";
|
||||
@@ -0,0 +1,35 @@
|
||||
// file: kb_execution_solana/src/lib.rs
|
||||
// version: 5
|
||||
|
||||
//! Solana transaction assembly and signing for prepared execution plans.
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod constants;
|
||||
mod nonce;
|
||||
mod transaction;
|
||||
|
||||
/// Stable operation code for the nonce advance injected by durable transaction assembly.
|
||||
pub(crate) use crate::constants::DURABLE_NONCE_ADVANCE_OPERATION;
|
||||
/// Maximum over-the-wire size of a Solana transaction packet.
|
||||
pub(crate) use crate::constants::MAX_TRANSACTION_WIRE_BYTES;
|
||||
/// Canonical tracing target for this crate.
|
||||
pub(crate) use crate::constants::TRACING_TARGET;
|
||||
|
||||
/// Validated current durable nonce account state used for transaction assembly.
|
||||
pub use crate::nonce::DurableNonceAccountState;
|
||||
/// Returns the exact serialized byte length of a Solana nonce account state.
|
||||
pub use crate::nonce::durable_nonce_account_data_length;
|
||||
/// Parses and validates a complete System Program-owned durable nonce account snapshot.
|
||||
pub use crate::nonce::parse_durable_nonce_account;
|
||||
/// Signed Solana transaction ready for RPC submission.
|
||||
pub use crate::transaction::SignedSolanaTransaction;
|
||||
/// Simulation result bound to the exact assembled message that was simulated.
|
||||
pub use crate::transaction::SolanaSimulationEvidence;
|
||||
/// Unsigned assembled Solana transaction used for fee estimation and simulation.
|
||||
pub use crate::transaction::UnsignedSolanaTransaction;
|
||||
/// Builds an unsigned durable nonce transaction from a prepared execution plan and validated nonce state.
|
||||
pub use crate::transaction::build_durable_nonce_transaction;
|
||||
/// Builds an unsigned legacy Solana transaction from a prepared execution plan.
|
||||
pub use crate::transaction::build_legacy_transaction;
|
||||
@@ -0,0 +1,276 @@
|
||||
// file: kb_execution_solana/src/nonce.rs
|
||||
// version: 5
|
||||
|
||||
//! Durable nonce account state parsing and validation.
|
||||
|
||||
use std::str::FromStr; // rust-rules: trait-import
|
||||
|
||||
/// Validated current durable nonce account state used for transaction assembly.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct DurableNonceAccountState {
|
||||
account: kb_model::Pubkey,
|
||||
authority: kb_model::Pubkey,
|
||||
blockhash: std::string::String,
|
||||
lamports_per_signature: u64,
|
||||
}
|
||||
|
||||
impl crate::DurableNonceAccountState {
|
||||
/// Returns the nonce account address.
|
||||
pub fn account(&self) -> &kb_model::Pubkey {
|
||||
return &self.account;
|
||||
}
|
||||
|
||||
/// Returns the authority that must sign the nonce advance instruction.
|
||||
pub fn authority(&self) -> &kb_model::Pubkey {
|
||||
return &self.authority;
|
||||
}
|
||||
|
||||
/// Returns the durable nonce value used as the transaction message blockhash.
|
||||
pub fn blockhash(&self) -> &str {
|
||||
return self.blockhash.as_str();
|
||||
}
|
||||
|
||||
/// Returns the fee rate stored when the nonce was last advanced.
|
||||
pub fn lamports_per_signature(&self) -> u64 {
|
||||
return self.lamports_per_signature;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the exact serialized byte length of a Solana nonce account state.
|
||||
pub const fn durable_nonce_account_data_length() -> usize {
|
||||
return solana_nonce::state::State::size();
|
||||
}
|
||||
|
||||
/// Parses and validates a complete System Program-owned durable nonce account snapshot.
|
||||
pub fn parse_durable_nonce_account(
|
||||
account: &kb_model::Pubkey,
|
||||
owner: &kb_model::ProgramId,
|
||||
executable: bool,
|
||||
space: u64,
|
||||
data: &[u8],
|
||||
) -> kb_core::Result<crate::DurableNonceAccountState> {
|
||||
let account_address = match solana_pubkey::Pubkey::from_str(account.0.as_str()) {
|
||||
std::result::Result::Ok(account_address) => account_address,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_nonce_account_address_invalid",
|
||||
format!("invalid durable nonce account address {}: {error}", account.0),
|
||||
));
|
||||
},
|
||||
};
|
||||
let owner_address = match solana_pubkey::Pubkey::from_str(owner.0.as_str()) {
|
||||
std::result::Result::Ok(owner_address) => owner_address,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_nonce_account_owner_invalid",
|
||||
format!("invalid durable nonce account owner {}: {error}", owner.0),
|
||||
));
|
||||
},
|
||||
};
|
||||
if owner_address != solana_sdk_ids::system_program::id() {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_nonce_account_owner_mismatch",
|
||||
format!(
|
||||
"durable nonce account {account_address} is owned by {owner_address} instead of the System Program"
|
||||
),
|
||||
));
|
||||
}
|
||||
if executable {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_nonce_account_executable",
|
||||
format!("durable nonce account {account_address} must not be executable"),
|
||||
));
|
||||
}
|
||||
let expected_length = crate::durable_nonce_account_data_length();
|
||||
if space != expected_length as u64 {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_nonce_account_space_invalid",
|
||||
format!(
|
||||
"durable nonce account {account_address} reports {space} bytes instead of {expected_length}"
|
||||
),
|
||||
));
|
||||
}
|
||||
if data.len() != expected_length {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_nonce_account_data_length_invalid",
|
||||
format!(
|
||||
"durable nonce account {account_address} contains {} decoded bytes instead of {expected_length}",
|
||||
data.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
let versions = match wincode::deserialize_exact::<solana_nonce::versions::Versions>(data) {
|
||||
std::result::Result::Ok(versions) => versions,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_nonce_account_state_invalid",
|
||||
format!("cannot decode durable nonce account {account_address}: {error}"),
|
||||
));
|
||||
},
|
||||
};
|
||||
let state = match versions {
|
||||
solana_nonce::versions::Versions::Legacy(_) => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_nonce_account_legacy_version",
|
||||
format!(
|
||||
"durable nonce account {account_address} uses the legacy nonce domain and must be upgraded before use"
|
||||
),
|
||||
));
|
||||
},
|
||||
solana_nonce::versions::Versions::Current(state) => state,
|
||||
};
|
||||
let initialized = match *state {
|
||||
solana_nonce::state::State::Uninitialized => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_nonce_account_uninitialized",
|
||||
format!("durable nonce account {account_address} is not initialized"),
|
||||
));
|
||||
},
|
||||
solana_nonce::state::State::Initialized(initialized) => initialized,
|
||||
};
|
||||
let authority = kb_model::Pubkey(initialized.authority.to_string());
|
||||
let blockhash = initialized.blockhash().to_string();
|
||||
let lamports_per_signature = initialized.get_lamports_per_signature();
|
||||
return std::result::Result::Ok(crate::DurableNonceAccountState {
|
||||
account: account.clone(),
|
||||
authority,
|
||||
blockhash,
|
||||
lamports_per_signature,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn initialized_bytes(
|
||||
authority: solana_pubkey::Pubkey,
|
||||
) -> (std::vec::Vec<u8>, std::string::String) {
|
||||
let source_blockhash = solana_hash::Hash::new_unique();
|
||||
let durable_nonce = solana_nonce::state::DurableNonce::from_blockhash(&source_blockhash);
|
||||
let expected_blockhash = durable_nonce.as_hash().to_string();
|
||||
let state = solana_nonce::versions::Versions::new(
|
||||
solana_nonce::state::State::new_initialized(&authority, durable_nonce, 5_000),
|
||||
);
|
||||
let bytes = wincode::serialize(&state)
|
||||
.unwrap_or_else(|error| panic!("unexpected nonce serialization error: {error}"));
|
||||
return (bytes, expected_blockhash);
|
||||
}
|
||||
|
||||
fn account(value: solana_pubkey::Pubkey) -> kb_model::Pubkey {
|
||||
return kb_model::Pubkey(value.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_initialized_nonce_state_is_decoded_exactly() {
|
||||
let account = account(solana_pubkey::Pubkey::new_unique());
|
||||
let authority = solana_pubkey::Pubkey::new_unique();
|
||||
let (bytes, expected_blockhash) = initialized_bytes(authority);
|
||||
assert_eq!(bytes.len(), crate::durable_nonce_account_data_length());
|
||||
let parsed = crate::parse_durable_nonce_account(
|
||||
&account,
|
||||
&kb_model::ProgramId(solana_sdk_ids::system_program::id().to_string()),
|
||||
false,
|
||||
bytes.len() as u64,
|
||||
bytes.as_slice(),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("unexpected nonce parse error: {error}"));
|
||||
assert_eq!(parsed.account(), &account);
|
||||
assert_eq!(parsed.authority().0, authority.to_string());
|
||||
assert_eq!(parsed.blockhash(), expected_blockhash);
|
||||
assert_eq!(parsed.lamports_per_signature(), 5_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_owner_shape_and_state_are_rejected() {
|
||||
let saccount = account(solana_pubkey::Pubkey::new_unique());
|
||||
let authority = solana_pubkey::Pubkey::new_unique();
|
||||
let (bytes, _blockhash) = initialized_bytes(authority);
|
||||
let owner = kb_model::ProgramId(solana_sdk_ids::system_program::id().to_string());
|
||||
assert!(
|
||||
crate::parse_durable_nonce_account(
|
||||
&saccount,
|
||||
&kb_model::ProgramId(solana_pubkey::Pubkey::new_unique().to_string()),
|
||||
false,
|
||||
bytes.len() as u64,
|
||||
bytes.as_slice(),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
crate::parse_durable_nonce_account(
|
||||
&saccount,
|
||||
&owner,
|
||||
true,
|
||||
bytes.len() as u64,
|
||||
bytes.as_slice(),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
crate::parse_durable_nonce_account(
|
||||
&saccount,
|
||||
&owner,
|
||||
false,
|
||||
(bytes.len() + 1) as u64,
|
||||
bytes.as_slice(),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
crate::parse_durable_nonce_account(
|
||||
&saccount,
|
||||
&owner,
|
||||
false,
|
||||
bytes.len() as u64,
|
||||
&bytes[..bytes.len() - 1],
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut malformed = bytes.clone();
|
||||
malformed[0] = 255;
|
||||
assert!(
|
||||
crate::parse_durable_nonce_account(
|
||||
&saccount,
|
||||
&owner,
|
||||
false,
|
||||
malformed.len() as u64,
|
||||
malformed.as_slice(),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_and_uninitialized_nonce_states_are_rejected() {
|
||||
let saccount = account(solana_pubkey::Pubkey::new_unique());
|
||||
let owner = kb_model::ProgramId(solana_sdk_ids::system_program::id().to_string());
|
||||
let legacy = solana_nonce::versions::Versions::Legacy(std::boxed::Box::new(
|
||||
solana_nonce::state::State::Initialized(solana_nonce::state::Data::default()),
|
||||
));
|
||||
let legacy_bytes = wincode::serialize(&legacy)
|
||||
.unwrap_or_else(|error| panic!("unexpected legacy serialization error: {error}"));
|
||||
assert!(
|
||||
crate::parse_durable_nonce_account(
|
||||
&saccount,
|
||||
&owner,
|
||||
false,
|
||||
legacy_bytes.len() as u64,
|
||||
legacy_bytes.as_slice(),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let uninitialized =
|
||||
solana_nonce::versions::Versions::new(solana_nonce::state::State::Uninitialized);
|
||||
let uninitialized_bytes = wincode::serialize(&uninitialized)
|
||||
.unwrap_or_else(|error| panic!("unexpected state serialization error: {error}"));
|
||||
assert!(
|
||||
crate::parse_durable_nonce_account(
|
||||
&saccount,
|
||||
&owner,
|
||||
false,
|
||||
uninitialized_bytes.len() as u64,
|
||||
uninitialized_bytes.as_slice(),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,982 @@
|
||||
// file: kb_execution_solana/src/transaction.rs
|
||||
// version: 7
|
||||
|
||||
//! Legacy and durable nonce Solana transaction assembly, simulation binding and signing.
|
||||
|
||||
use base64::Engine; // rust-rules: trait-import
|
||||
use std::str::FromStr; // rust-rules: trait-import
|
||||
|
||||
/// Unsigned assembled Solana transaction used for fee estimation and simulation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UnsignedSolanaTransaction {
|
||||
plan: kb_execution_api::PreparedExecutionPlan,
|
||||
transaction: solana_transaction::Transaction,
|
||||
message_hash: std::string::String,
|
||||
blockhash: std::string::String,
|
||||
required_signer_pubkeys: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
impl crate::UnsignedSolanaTransaction {
|
||||
/// Returns the effective immutable plan represented by this transaction.
|
||||
///
|
||||
/// Durable nonce transactions include the injected nonce-advance instruction.
|
||||
pub fn plan(&self) -> &kb_execution_api::PreparedExecutionPlan {
|
||||
return &self.plan;
|
||||
}
|
||||
|
||||
/// Returns the base58 hash of the exact compiled transaction message.
|
||||
pub fn message_hash(&self) -> &str {
|
||||
return self.message_hash.as_str();
|
||||
}
|
||||
|
||||
/// Returns the recent blockhash or durable nonce value compiled into the message.
|
||||
pub fn blockhash(&self) -> &str {
|
||||
return self.blockhash.as_str();
|
||||
}
|
||||
|
||||
/// Returns required signer public keys in compiled message order.
|
||||
pub fn required_signer_pubkeys(&self) -> &[std::string::String] {
|
||||
return self.required_signer_pubkeys.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the serialized message in base64 for `getFeeForMessage`.
|
||||
pub fn message_base64(&self) -> std::string::String {
|
||||
return base64::engine::general_purpose::STANDARD.encode(self.transaction.message_data());
|
||||
}
|
||||
|
||||
/// Returns the unsigned serialized transaction in base64 for `simulateTransaction` with `sigVerify = false`.
|
||||
pub fn transaction_base64(&self) -> kb_core::Result<std::string::String> {
|
||||
let wire_bytes = match serialize_transaction(&self.transaction) {
|
||||
std::result::Result::Ok(wire_bytes) => wire_bytes,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(
|
||||
base64::engine::general_purpose::STANDARD.encode(wire_bytes),
|
||||
);
|
||||
}
|
||||
|
||||
/// Binds a typed RPC simulation result to this exact compiled message.
|
||||
pub fn bind_simulation(
|
||||
&self,
|
||||
simulation: kb_execution_api::ExecutionSimulationResult,
|
||||
) -> crate::SolanaSimulationEvidence {
|
||||
return crate::SolanaSimulationEvidence {
|
||||
message_hash: self.message_hash.clone(),
|
||||
blockhash: self.blockhash.clone(),
|
||||
simulation,
|
||||
};
|
||||
}
|
||||
|
||||
/// Signs the transaction after safety validation of a matching simulation result.
|
||||
pub fn sign_after_simulation(
|
||||
mut self,
|
||||
evidence: &crate::SolanaSimulationEvidence,
|
||||
signers: &[&dyn solana_signer::Signer],
|
||||
) -> kb_core::Result<crate::SignedSolanaTransaction> {
|
||||
if evidence.message_hash != self.message_hash {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_simulation_message_mismatch",
|
||||
"the simulation result is not bound to the transaction message being signed",
|
||||
));
|
||||
}
|
||||
if evidence.blockhash != self.blockhash {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_simulation_blockhash_mismatch",
|
||||
"the simulation result is not bound to the blockhash or durable nonce value being signed",
|
||||
));
|
||||
}
|
||||
let safety_evaluation = match kb_execution_safety::ExecutionSafetyChecker
|
||||
.evaluate_send(&self.plan, &evidence.simulation)
|
||||
{
|
||||
std::result::Result::Ok(evaluation) => evaluation,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if safety_evaluation.decision != kb_execution_safety::ExecutionSafetyDecision::Allow {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_signing_not_authorized",
|
||||
format!(
|
||||
"execution safety decision {:?} forbids signing: {}",
|
||||
safety_evaluation.decision,
|
||||
violation_codes(&safety_evaluation.violations)
|
||||
),
|
||||
));
|
||||
}
|
||||
let signers = match resolve_signers(self.required_signer_pubkeys.as_slice(), signers) {
|
||||
std::result::Result::Ok(signers) => signers,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let recent_blockhash = self.transaction.message.recent_blockhash;
|
||||
if let std::result::Result::Err(error) =
|
||||
self.transaction.try_sign(signers.as_slice(), recent_blockhash)
|
||||
{
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_transaction_sign_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
}
|
||||
if !self.transaction.is_signed() {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_transaction_not_fully_signed",
|
||||
"the transaction is not fully signed after signer resolution",
|
||||
));
|
||||
}
|
||||
let wire_bytes = match serialize_transaction(&self.transaction) {
|
||||
std::result::Result::Ok(wire_bytes) => wire_bytes,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let primary_signature = match self.transaction.signatures.first() {
|
||||
std::option::Option::Some(signature) => signature.to_string(),
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_primary_signature_missing",
|
||||
"the signed transaction contains no primary signature",
|
||||
));
|
||||
},
|
||||
};
|
||||
tracing::info!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "sign_transaction",
|
||||
intent_id = %self.plan.intent_id,
|
||||
operation_code = %self.plan.operation_code,
|
||||
message_hash = %self.message_hash,
|
||||
signer_count = self.required_signer_pubkeys.len(),
|
||||
wire_length = wire_bytes.len(),
|
||||
"signed prepared Solana transaction"
|
||||
);
|
||||
return std::result::Result::Ok(crate::SignedSolanaTransaction {
|
||||
transaction: self.transaction,
|
||||
message_hash: self.message_hash,
|
||||
primary_signature: kb_model::Signature(primary_signature),
|
||||
signer_pubkeys: self.required_signer_pubkeys,
|
||||
wire_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulation result bound to the exact assembled message that was simulated.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaSimulationEvidence {
|
||||
message_hash: std::string::String,
|
||||
blockhash: std::string::String,
|
||||
simulation: kb_execution_api::ExecutionSimulationResult,
|
||||
}
|
||||
|
||||
impl crate::SolanaSimulationEvidence {
|
||||
/// Returns the base58 hash of the simulated compiled message.
|
||||
pub fn message_hash(&self) -> &str {
|
||||
return self.message_hash.as_str();
|
||||
}
|
||||
|
||||
/// Returns the recent blockhash or durable nonce value used by the simulated message.
|
||||
pub fn blockhash(&self) -> &str {
|
||||
return self.blockhash.as_str();
|
||||
}
|
||||
|
||||
/// Returns the typed simulation result.
|
||||
pub fn simulation(&self) -> &kb_execution_api::ExecutionSimulationResult {
|
||||
return &self.simulation;
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed Solana transaction ready for RPC submission.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SignedSolanaTransaction {
|
||||
transaction: solana_transaction::Transaction,
|
||||
message_hash: std::string::String,
|
||||
primary_signature: kb_model::Signature,
|
||||
signer_pubkeys: std::vec::Vec<std::string::String>,
|
||||
wire_bytes: std::vec::Vec<u8>,
|
||||
}
|
||||
|
||||
impl crate::SignedSolanaTransaction {
|
||||
/// Returns the base58 hash of the signed compiled message.
|
||||
pub fn message_hash(&self) -> &str {
|
||||
return self.message_hash.as_str();
|
||||
}
|
||||
|
||||
/// Returns the transaction's primary signature.
|
||||
pub fn primary_signature(&self) -> &kb_model::Signature {
|
||||
return &self.primary_signature;
|
||||
}
|
||||
|
||||
/// Returns signer public keys in signature order.
|
||||
pub fn signer_pubkeys(&self) -> &[std::string::String] {
|
||||
return self.signer_pubkeys.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the signed transaction wire length.
|
||||
pub fn wire_length(&self) -> usize {
|
||||
return self.wire_bytes.len();
|
||||
}
|
||||
|
||||
/// Returns the signed transaction in base64 for future RPC submission.
|
||||
pub fn transaction_base64(&self) -> std::string::String {
|
||||
return base64::engine::general_purpose::STANDARD.encode(self.wire_bytes.as_slice());
|
||||
}
|
||||
|
||||
/// Verifies every signature against the compiled message.
|
||||
pub fn verify_signatures(&self) -> kb_core::Result<()> {
|
||||
return match self.transaction.verify() {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_transaction_signature_verification_failed",
|
||||
error.to_string(),
|
||||
)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an unsigned legacy Solana transaction from a prepared execution plan.
|
||||
pub fn build_legacy_transaction(
|
||||
plan: &kb_execution_api::PreparedExecutionPlan,
|
||||
recent_blockhash: &str,
|
||||
) -> kb_core::Result<crate::UnsignedSolanaTransaction> {
|
||||
if plan.policy.blockhash.kind != kb_execution_api::ExecutionBlockhashKind::Latest {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_durable_nonce_requires_dedicated_assembly",
|
||||
"durable nonce plans require build_durable_nonce_transaction",
|
||||
));
|
||||
}
|
||||
if plan.instructions.is_empty() {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_plan_has_no_instructions",
|
||||
"cannot assemble a Solana transaction without instructions",
|
||||
));
|
||||
}
|
||||
let fee_payer = match parse_pubkey(plan.fee_payer.0.as_str(), "execution fee payer") {
|
||||
std::result::Result::Ok(fee_payer) => fee_payer,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let blockhash = match solana_hash::Hash::from_str(recent_blockhash) {
|
||||
std::result::Result::Ok(blockhash) => blockhash,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_recent_blockhash_invalid",
|
||||
format!("invalid recent blockhash: {error}"),
|
||||
));
|
||||
},
|
||||
};
|
||||
let mut instructions = std::vec::Vec::with_capacity(plan.instructions.len());
|
||||
for planned_instruction in &plan.instructions {
|
||||
let instruction = match convert_instruction(planned_instruction) {
|
||||
std::result::Result::Ok(instruction) => instruction,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
instructions.push(instruction);
|
||||
}
|
||||
let message = solana_message::Message::new_with_blockhash(
|
||||
instructions.as_slice(),
|
||||
std::option::Option::Some(&fee_payer),
|
||||
&blockhash,
|
||||
);
|
||||
let compiled_signers = message
|
||||
.signer_keys()
|
||||
.iter()
|
||||
.map(|pubkey| return pubkey.to_string())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
match validate_compiled_signers(plan, compiled_signers.as_slice()) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let transaction = solana_transaction::Transaction::new_unsigned(message);
|
||||
let wire_bytes = match serialize_transaction(&transaction) {
|
||||
std::result::Result::Ok(wire_bytes) => wire_bytes,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let message_hash = transaction.message.hash().to_string();
|
||||
tracing::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "assemble_transaction",
|
||||
intent_id = %plan.intent_id,
|
||||
operation_code = %plan.operation_code,
|
||||
instruction_count = plan.instructions.len(),
|
||||
signer_count = compiled_signers.len(),
|
||||
message_hash = %message_hash,
|
||||
wire_length = wire_bytes.len(),
|
||||
"assembled unsigned legacy Solana transaction"
|
||||
);
|
||||
return std::result::Result::Ok(crate::UnsignedSolanaTransaction {
|
||||
plan: plan.clone(),
|
||||
transaction,
|
||||
message_hash,
|
||||
blockhash: blockhash.to_string(),
|
||||
required_signer_pubkeys: compiled_signers,
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds an unsigned transaction that consumes a validated durable nonce state.
|
||||
pub fn build_durable_nonce_transaction(
|
||||
plan: &kb_execution_api::PreparedExecutionPlan,
|
||||
nonce_state: &crate::DurableNonceAccountState,
|
||||
) -> kb_core::Result<crate::UnsignedSolanaTransaction> {
|
||||
if plan.policy.blockhash.kind != kb_execution_api::ExecutionBlockhashKind::DurableNonce {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_durable_nonce_policy_required",
|
||||
"the dedicated durable nonce assembly path requires a DurableNonce blockhash policy",
|
||||
));
|
||||
}
|
||||
if plan.policy.blockhash.max_age_slots.is_some() {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_durable_nonce_has_blockhash_age",
|
||||
"durable nonce assembly cannot use a recent blockhash age limit",
|
||||
));
|
||||
}
|
||||
if plan.instructions.is_empty() {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_plan_has_no_instructions",
|
||||
"cannot assemble a durable nonce transaction without operation instructions",
|
||||
));
|
||||
}
|
||||
let policy_nonce_account = match &plan.policy.blockhash.nonce_account {
|
||||
std::option::Option::Some(account) => account,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_durable_nonce_account_missing",
|
||||
"durable nonce assembly requires the nonce account in the execution policy",
|
||||
));
|
||||
},
|
||||
};
|
||||
let policy_nonce_authority = match &plan.policy.blockhash.nonce_authority {
|
||||
std::option::Option::Some(authority) => authority,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_durable_nonce_authority_missing",
|
||||
"durable nonce assembly requires the nonce authority in the execution policy",
|
||||
));
|
||||
},
|
||||
};
|
||||
if policy_nonce_account != nonce_state.account() {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_durable_nonce_account_mismatch",
|
||||
format!(
|
||||
"execution policy nonce account {} differs from validated account {}",
|
||||
policy_nonce_account.0,
|
||||
nonce_state.account().0
|
||||
),
|
||||
));
|
||||
}
|
||||
if policy_nonce_authority != nonce_state.authority() {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_durable_nonce_authority_mismatch",
|
||||
format!(
|
||||
"execution policy nonce authority {} differs from validated authority {}",
|
||||
policy_nonce_authority.0,
|
||||
nonce_state.authority().0
|
||||
),
|
||||
));
|
||||
}
|
||||
if !plan
|
||||
.required_signers
|
||||
.iter()
|
||||
.any(|required| return &required.pubkey == policy_nonce_authority)
|
||||
{
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_durable_nonce_authority_not_declared",
|
||||
format!(
|
||||
"durable nonce authority {} is missing from required signers",
|
||||
policy_nonce_authority.0
|
||||
),
|
||||
));
|
||||
}
|
||||
let fee_payer = match parse_pubkey(plan.fee_payer.0.as_str(), "execution fee payer") {
|
||||
std::result::Result::Ok(fee_payer) => fee_payer,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let nonce_account = match parse_pubkey(policy_nonce_account.0.as_str(), "durable nonce account")
|
||||
{
|
||||
std::result::Result::Ok(nonce_account) => nonce_account,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let nonce_authority =
|
||||
match parse_pubkey(policy_nonce_authority.0.as_str(), "durable nonce authority") {
|
||||
std::result::Result::Ok(nonce_authority) => nonce_authority,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let nonce_blockhash = match solana_hash::Hash::from_str(nonce_state.blockhash()) {
|
||||
std::result::Result::Ok(blockhash) => blockhash,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_durable_nonce_blockhash_invalid",
|
||||
format!("invalid durable nonce value: {error}"),
|
||||
));
|
||||
},
|
||||
};
|
||||
let advance_instruction = solana_system_interface::instruction::advance_nonce_account(
|
||||
&nonce_account,
|
||||
&nonce_authority,
|
||||
);
|
||||
let mut effective_plan = plan.clone();
|
||||
effective_plan.instructions.insert(
|
||||
0,
|
||||
planned_instruction(crate::DURABLE_NONCE_ADVANCE_OPERATION, &advance_instruction),
|
||||
);
|
||||
let mut operation_instructions = std::vec::Vec::with_capacity(plan.instructions.len());
|
||||
for planned_instruction in &plan.instructions {
|
||||
let instruction = match convert_instruction(planned_instruction) {
|
||||
std::result::Result::Ok(instruction) => instruction,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
operation_instructions.push(instruction);
|
||||
}
|
||||
let mut message = solana_message::Message::new_with_nonce(
|
||||
operation_instructions,
|
||||
std::option::Option::Some(&fee_payer),
|
||||
&nonce_account,
|
||||
&nonce_authority,
|
||||
);
|
||||
message.recent_blockhash = nonce_blockhash;
|
||||
let compiled_signers = message
|
||||
.signer_keys()
|
||||
.iter()
|
||||
.map(|pubkey| return pubkey.to_string())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
match validate_compiled_signers(&effective_plan, compiled_signers.as_slice()) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let transaction = solana_transaction::Transaction::new_unsigned(message);
|
||||
if solana_transaction::uses_durable_nonce(&transaction).is_none() {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_durable_nonce_marker_missing",
|
||||
"the assembled transaction is not recognized as a durable nonce transaction",
|
||||
));
|
||||
}
|
||||
let wire_bytes = match serialize_transaction(&transaction) {
|
||||
std::result::Result::Ok(wire_bytes) => wire_bytes,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let message_hash = transaction.message.hash().to_string();
|
||||
tracing::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "assemble_durable_nonce_transaction",
|
||||
intent_id = %effective_plan.intent_id,
|
||||
operation_code = %effective_plan.operation_code,
|
||||
nonce_account = %policy_nonce_account.0,
|
||||
nonce_authority = %policy_nonce_authority.0,
|
||||
nonce_blockhash = %nonce_state.blockhash(),
|
||||
instruction_count = effective_plan.instructions.len(),
|
||||
signer_count = compiled_signers.len(),
|
||||
message_hash = %message_hash,
|
||||
wire_length = wire_bytes.len(),
|
||||
"assembled unsigned durable nonce Solana transaction"
|
||||
);
|
||||
return std::result::Result::Ok(crate::UnsignedSolanaTransaction {
|
||||
plan: effective_plan,
|
||||
transaction,
|
||||
message_hash,
|
||||
blockhash: nonce_state.blockhash().to_string(),
|
||||
required_signer_pubkeys: compiled_signers,
|
||||
});
|
||||
}
|
||||
|
||||
fn convert_instruction(
|
||||
planned: &kb_execution_api::PlannedInstruction,
|
||||
) -> kb_core::Result<solana_instruction::Instruction> {
|
||||
let program_id =
|
||||
match parse_pubkey(planned.program_id.0.as_str(), "planned instruction program id") {
|
||||
std::result::Result::Ok(program_id) => program_id,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut accounts = std::vec::Vec::with_capacity(planned.accounts.len());
|
||||
for planned_account in &planned.accounts {
|
||||
let pubkey =
|
||||
match parse_pubkey(planned_account.pubkey.0.as_str(), "planned instruction account") {
|
||||
std::result::Result::Ok(pubkey) => pubkey,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
accounts.push(solana_instruction::AccountMeta {
|
||||
pubkey,
|
||||
is_signer: planned_account.is_signer,
|
||||
is_writable: planned_account.is_writable,
|
||||
});
|
||||
}
|
||||
return std::result::Result::Ok(solana_instruction::Instruction {
|
||||
program_id,
|
||||
accounts,
|
||||
data: planned.data.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
fn planned_instruction(
|
||||
operation_code: &str,
|
||||
instruction: &solana_instruction::Instruction,
|
||||
) -> kb_execution_api::PlannedInstruction {
|
||||
return kb_execution_api::PlannedInstruction {
|
||||
program_id: kb_model::ProgramId(instruction.program_id.to_string()),
|
||||
operation_code: operation_code.to_string(),
|
||||
accounts: instruction
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|account| {
|
||||
return kb_execution_api::PlannedAccount {
|
||||
pubkey: kb_model::Pubkey(account.pubkey.to_string()),
|
||||
is_signer: account.is_signer,
|
||||
is_writable: account.is_writable,
|
||||
};
|
||||
})
|
||||
.collect(),
|
||||
data: instruction.data.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_pubkey(value: &str, field_name: &str) -> kb_core::Result<solana_pubkey::Pubkey> {
|
||||
return match solana_pubkey::Pubkey::from_str(value) {
|
||||
std::result::Result::Ok(pubkey) => std::result::Result::Ok(pubkey),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_pubkey_invalid",
|
||||
format!("invalid {field_name} {value}: {error}"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_compiled_signers(
|
||||
plan: &kb_execution_api::PreparedExecutionPlan,
|
||||
compiled_signers: &[std::string::String],
|
||||
) -> kb_core::Result<()> {
|
||||
let declared_signers = plan
|
||||
.required_signers
|
||||
.iter()
|
||||
.map(|required| return required.pubkey.0.as_str())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
let compiled_signer_set = compiled_signers
|
||||
.iter()
|
||||
.map(std::string::String::as_str)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
if declared_signers != compiled_signer_set {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_compiled_signer_contract_mismatch",
|
||||
format!(
|
||||
"declared signers [{}] differ from compiled signers [{}]",
|
||||
declared_signers.into_iter().collect::<std::vec::Vec<_>>().join(","),
|
||||
compiled_signer_set.into_iter().collect::<std::vec::Vec<_>>().join(",")
|
||||
),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn resolve_signers<'a>(
|
||||
required_signers: &[std::string::String],
|
||||
signers: &'a [&'a dyn solana_signer::Signer],
|
||||
) -> kb_core::Result<std::vec::Vec<&'a dyn solana_signer::Signer>> {
|
||||
let mut signer_by_pubkey = std::collections::BTreeMap::new();
|
||||
for signer in signers {
|
||||
let public_key = signer.pubkey().to_string();
|
||||
if signer_by_pubkey.insert(public_key.clone(), *signer).is_some() {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_signer_duplicate",
|
||||
format!("signer {public_key} was supplied more than once"),
|
||||
));
|
||||
}
|
||||
}
|
||||
if signer_by_pubkey.len() != required_signers.len() {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_signer_count_mismatch",
|
||||
format!(
|
||||
"{} signers were supplied for {} required signers",
|
||||
signer_by_pubkey.len(),
|
||||
required_signers.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
let mut resolved = std::vec::Vec::with_capacity(required_signers.len());
|
||||
for required_signer in required_signers {
|
||||
let signer = match signer_by_pubkey.get(required_signer) {
|
||||
std::option::Option::Some(signer) => *signer,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_required_signer_missing",
|
||||
format!("required signer {required_signer} was not supplied"),
|
||||
));
|
||||
},
|
||||
};
|
||||
resolved.push(signer);
|
||||
}
|
||||
return std::result::Result::Ok(resolved);
|
||||
}
|
||||
|
||||
fn serialize_transaction(
|
||||
transaction: &solana_transaction::Transaction,
|
||||
) -> kb_core::Result<std::vec::Vec<u8>> {
|
||||
let wire_bytes = match wincode::serialize(transaction) {
|
||||
std::result::Result::Ok(wire_bytes) => wire_bytes,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_transaction_serialize_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
if wire_bytes.len() > crate::MAX_TRANSACTION_WIRE_BYTES {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_transaction_wire_size_exceeded",
|
||||
format!(
|
||||
"serialized transaction length {} exceeds the Solana packet limit {}",
|
||||
wire_bytes.len(),
|
||||
crate::MAX_TRANSACTION_WIRE_BYTES
|
||||
),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(wire_bytes);
|
||||
}
|
||||
|
||||
fn violation_codes(
|
||||
violations: &[kb_execution_safety::ExecutionSafetyViolation],
|
||||
) -> std::string::String {
|
||||
return violations
|
||||
.iter()
|
||||
.map(|violation| return violation.code.as_str())
|
||||
.collect::<std::vec::Vec<_>>()
|
||||
.join(",");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use solana_signer::Signer; // rust-rules: trait-import
|
||||
use std::str::FromStr; // rust-rules: trait-import
|
||||
|
||||
fn wallet(alias: &str) -> kb_wallet::TemporaryWallet {
|
||||
let alias = kb_wallet::WalletAlias::parse(alias)
|
||||
.unwrap_or_else(|error| panic!("unexpected alias error: {error}"));
|
||||
return kb_wallet::TemporaryWallet::generate(alias);
|
||||
}
|
||||
|
||||
fn transfer_plan(
|
||||
wallet: &kb_wallet::TemporaryWallet,
|
||||
dry_run: bool,
|
||||
lamports: u64,
|
||||
) -> kb_execution_api::PreparedExecutionPlan {
|
||||
let payer = kb_model::Pubkey(wallet.public_key());
|
||||
let destination = kb_model::Pubkey(solana_keypair::Keypair::new().pubkey().to_string());
|
||||
let intent = kb_executor_solana_core::SolanaCoreExecutionIntent {
|
||||
intent_id: std::string::String::from("transfer-test"),
|
||||
fee_payer: payer.clone(),
|
||||
operation: kb_executor_solana_core::SolanaCoreOperation::SystemTransfer {
|
||||
from: payer.clone(),
|
||||
to: destination,
|
||||
lamports,
|
||||
},
|
||||
policy: kb_execution_api::ExecutionPolicy {
|
||||
dry_run,
|
||||
authorized_signers: vec![payer],
|
||||
cost_limit: kb_execution_api::ExecutionCostLimit {
|
||||
max_spend_lamports: std::option::Option::Some(lamports),
|
||||
max_fee_lamports: std::option::Option::Some(10_000),
|
||||
max_compute_unit_price_micro_lamports: std::option::Option::Some(100),
|
||||
},
|
||||
..kb_execution_api::ExecutionPolicy::default()
|
||||
},
|
||||
};
|
||||
return kb_execution_api::TypedInstructionExecutor::build_prepared_plan(
|
||||
&kb_executor_solana_core::SolanaCoreExecutor,
|
||||
&intent,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("unexpected plan error: {error}"));
|
||||
}
|
||||
|
||||
fn durable_transfer_plan(
|
||||
payer: &kb_wallet::TemporaryWallet,
|
||||
nonce_authority: &kb_wallet::TemporaryWallet,
|
||||
nonce_account: &kb_model::Pubkey,
|
||||
) -> kb_execution_api::PreparedExecutionPlan {
|
||||
let payer_pubkey = kb_model::Pubkey(payer.public_key());
|
||||
let authority_pubkey = kb_model::Pubkey(nonce_authority.public_key());
|
||||
let destination = kb_model::Pubkey(solana_keypair::Keypair::new().pubkey().to_string());
|
||||
let intent = kb_executor_solana_core::SolanaCoreExecutionIntent {
|
||||
intent_id: std::string::String::from("durable-transfer-test"),
|
||||
fee_payer: payer_pubkey.clone(),
|
||||
operation: kb_executor_solana_core::SolanaCoreOperation::SystemTransfer {
|
||||
from: payer_pubkey.clone(),
|
||||
to: destination,
|
||||
lamports: 100,
|
||||
},
|
||||
policy: kb_execution_api::ExecutionPolicy {
|
||||
dry_run: false,
|
||||
authorized_signers: vec![payer_pubkey, authority_pubkey.clone()],
|
||||
blockhash: kb_execution_api::ExecutionBlockhashPolicy {
|
||||
kind: kb_execution_api::ExecutionBlockhashKind::DurableNonce,
|
||||
max_age_slots: std::option::Option::None,
|
||||
nonce_account: std::option::Option::Some(nonce_account.clone()),
|
||||
nonce_authority: std::option::Option::Some(authority_pubkey),
|
||||
},
|
||||
cost_limit: kb_execution_api::ExecutionCostLimit {
|
||||
max_spend_lamports: std::option::Option::Some(100),
|
||||
max_fee_lamports: std::option::Option::Some(10_000),
|
||||
max_compute_unit_price_micro_lamports: std::option::Option::Some(100),
|
||||
},
|
||||
..kb_execution_api::ExecutionPolicy::default()
|
||||
},
|
||||
};
|
||||
return kb_execution_api::TypedInstructionExecutor::build_prepared_plan(
|
||||
&kb_executor_solana_core::SolanaCoreExecutor,
|
||||
&intent,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("unexpected durable plan error: {error}"));
|
||||
}
|
||||
|
||||
fn nonce_state(
|
||||
nonce_account: &kb_model::Pubkey,
|
||||
nonce_authority: &kb_wallet::TemporaryWallet,
|
||||
) -> crate::DurableNonceAccountState {
|
||||
let source_blockhash = solana_hash::Hash::new_unique();
|
||||
let durable_nonce = solana_nonce::state::DurableNonce::from_blockhash(&source_blockhash);
|
||||
let authority = solana_pubkey::Pubkey::from_str(nonce_authority.public_key().as_str())
|
||||
.unwrap_or_else(|error| panic!("invalid authority fixture: {error}"));
|
||||
let versions = solana_nonce::versions::Versions::new(
|
||||
solana_nonce::state::State::new_initialized(&authority, durable_nonce, 5_000),
|
||||
);
|
||||
let bytes = wincode::serialize(&versions)
|
||||
.unwrap_or_else(|error| panic!("unexpected nonce serialization error: {error}"));
|
||||
return crate::parse_durable_nonce_account(
|
||||
nonce_account,
|
||||
&kb_model::ProgramId(solana_sdk_ids::system_program::id().to_string()),
|
||||
false,
|
||||
bytes.len() as u64,
|
||||
bytes.as_slice(),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("unexpected nonce parse error: {error}"));
|
||||
}
|
||||
|
||||
fn successful_simulation() -> kb_execution_api::ExecutionSimulationResult {
|
||||
return kb_execution_api::ExecutionSimulationResult {
|
||||
simulated: true,
|
||||
success: true,
|
||||
cluster: kb_execution_api::ExecutionCluster::Devnet,
|
||||
blockhash_kind: kb_execution_api::ExecutionBlockhashKind::Latest,
|
||||
blockhash_age_slots: std::option::Option::Some(1),
|
||||
replacement_blockhash: std::option::Option::None,
|
||||
replacement_last_valid_block_height: std::option::Option::None,
|
||||
nonce_account: std::option::Option::None,
|
||||
nonce_authority: std::option::Option::None,
|
||||
units_consumed: std::option::Option::Some(500),
|
||||
estimated_fee_lamports: std::option::Option::Some(5_000),
|
||||
logs: std::vec::Vec::new(),
|
||||
error: std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn successful_nonce_simulation(
|
||||
nonce_account: &kb_model::Pubkey,
|
||||
nonce_authority: &kb_wallet::TemporaryWallet,
|
||||
) -> kb_execution_api::ExecutionSimulationResult {
|
||||
return kb_execution_api::ExecutionSimulationResult {
|
||||
simulated: true,
|
||||
success: true,
|
||||
cluster: kb_execution_api::ExecutionCluster::Devnet,
|
||||
blockhash_kind: kb_execution_api::ExecutionBlockhashKind::DurableNonce,
|
||||
blockhash_age_slots: std::option::Option::None,
|
||||
replacement_blockhash: std::option::Option::None,
|
||||
replacement_last_valid_block_height: std::option::Option::None,
|
||||
nonce_account: std::option::Option::Some(nonce_account.clone()),
|
||||
nonce_authority: std::option::Option::Some(kb_model::Pubkey(
|
||||
nonce_authority.public_key(),
|
||||
)),
|
||||
units_consumed: std::option::Option::Some(700),
|
||||
estimated_fee_lamports: std::option::Option::Some(10_000),
|
||||
logs: std::vec::Vec::new(),
|
||||
error: std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_transaction_preserves_plan_and_serializes_for_rpc() {
|
||||
let wallet = wallet("payer");
|
||||
let plan = transfer_plan(&wallet, false, 100);
|
||||
let blockhash = solana_hash::Hash::new_unique().to_string();
|
||||
let unsigned = crate::build_legacy_transaction(&plan, blockhash.as_str())
|
||||
.unwrap_or_else(|error| panic!("unexpected assembly error: {error}"));
|
||||
assert_eq!(unsigned.plan().intent_id, plan.intent_id);
|
||||
assert_eq!(unsigned.required_signer_pubkeys(), &[wallet.public_key()]);
|
||||
assert!(!unsigned.message_hash().is_empty());
|
||||
assert!(!unsigned.message_base64().is_empty());
|
||||
assert!(
|
||||
!unsigned
|
||||
.transaction_base64()
|
||||
.unwrap_or_else(|error| panic!("unexpected serialization error: {error}"))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_simulation_and_signer_produce_verified_signature() {
|
||||
let wallet = wallet("payer");
|
||||
let plan = transfer_plan(&wallet, false, 100);
|
||||
let blockhash = solana_hash::Hash::new_unique().to_string();
|
||||
let unsigned = crate::build_legacy_transaction(&plan, blockhash.as_str())
|
||||
.unwrap_or_else(|error| panic!("unexpected assembly error: {error}"));
|
||||
let evidence = unsigned.bind_simulation(successful_simulation());
|
||||
let signed = unsigned
|
||||
.sign_after_simulation(&evidence, &[wallet.as_signer()])
|
||||
.unwrap_or_else(|error| panic!("unexpected signing error: {error}"));
|
||||
signed
|
||||
.verify_signatures()
|
||||
.unwrap_or_else(|error| panic!("unexpected verification error: {error}"));
|
||||
assert!(!signed.primary_signature().0.is_empty());
|
||||
assert_eq!(signed.signer_pubkeys(), &[wallet.public_key()]);
|
||||
assert!(signed.wire_length() <= crate::MAX_TRANSACTION_WIRE_BYTES);
|
||||
assert!(!signed.transaction_base64().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_policy_blocks_signing_after_successful_simulation() {
|
||||
let wallet = wallet("payer");
|
||||
let plan = transfer_plan(&wallet, true, 100);
|
||||
let blockhash = solana_hash::Hash::new_unique().to_string();
|
||||
let unsigned = crate::build_legacy_transaction(&plan, blockhash.as_str())
|
||||
.unwrap_or_else(|error| panic!("unexpected assembly error: {error}"));
|
||||
let evidence = unsigned.bind_simulation(successful_simulation());
|
||||
let result = unsigned.sign_after_simulation(&evidence, &[wallet.as_signer()]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simulation_evidence_from_another_message_is_rejected() {
|
||||
let wallet = wallet("payer");
|
||||
let plan = transfer_plan(&wallet, false, 100);
|
||||
let first_hash = solana_hash::Hash::new_unique().to_string();
|
||||
let second_hash = solana_hash::Hash::new_unique().to_string();
|
||||
let first = crate::build_legacy_transaction(&plan, first_hash.as_str())
|
||||
.unwrap_or_else(|error| panic!("unexpected first assembly error: {error}"));
|
||||
let second = crate::build_legacy_transaction(&plan, second_hash.as_str())
|
||||
.unwrap_or_else(|error| panic!("unexpected second assembly error: {error}"));
|
||||
let evidence = first.bind_simulation(successful_simulation());
|
||||
let result = second.sign_after_simulation(&evidence, &[wallet.as_signer()]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_extra_and_duplicate_signers_are_rejected() {
|
||||
let tmpwallet = wallet("payer");
|
||||
let extra = wallet("extra");
|
||||
let plan = transfer_plan(&tmpwallet, false, 100);
|
||||
let blockhash = solana_hash::Hash::new_unique().to_string();
|
||||
let unsigned = crate::build_legacy_transaction(&plan, blockhash.as_str())
|
||||
.unwrap_or_else(|error| panic!("unexpected assembly error: {error}"));
|
||||
let evidence = unsigned.bind_simulation(successful_simulation());
|
||||
assert!(unsigned.clone().sign_after_simulation(&evidence, &[]).is_err());
|
||||
assert!(
|
||||
unsigned
|
||||
.clone()
|
||||
.sign_after_simulation(&evidence, &[tmpwallet.as_signer(), extra.as_signer()],)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
unsigned
|
||||
.sign_after_simulation(&evidence, &[tmpwallet.as_signer(), tmpwallet.as_signer()],)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_nonce_transaction_injects_exact_advance_and_signs() {
|
||||
let payer = wallet("durable-payer");
|
||||
let authority = wallet("durable-authority");
|
||||
let nonce_account = kb_model::Pubkey(solana_pubkey::Pubkey::new_unique().to_string());
|
||||
let plan = durable_transfer_plan(&payer, &authority, &nonce_account);
|
||||
assert_eq!(plan.instructions.len(), 1);
|
||||
assert_eq!(plan.required_signers.len(), 2);
|
||||
let nonce_state = nonce_state(&nonce_account, &authority);
|
||||
let unsigned = crate::build_durable_nonce_transaction(&plan, &nonce_state)
|
||||
.unwrap_or_else(|error| panic!("unexpected durable assembly error: {error}"));
|
||||
assert_eq!(plan.instructions.len(), 1);
|
||||
assert_eq!(unsigned.plan().instructions.len(), 2);
|
||||
assert_eq!(unsigned.blockhash(), nonce_state.blockhash());
|
||||
let authority_address = solana_pubkey::Pubkey::from_str(authority.public_key().as_str())
|
||||
.unwrap_or_else(|error| panic!("invalid authority fixture: {error}"));
|
||||
let nonce_address = solana_pubkey::Pubkey::from_str(nonce_account.0.as_str())
|
||||
.unwrap_or_else(|error| panic!("invalid nonce fixture: {error}"));
|
||||
let expected_advance = solana_system_interface::instruction::advance_nonce_account(
|
||||
&nonce_address,
|
||||
&authority_address,
|
||||
);
|
||||
let actual_advance = &unsigned.plan().instructions[0];
|
||||
assert_eq!(actual_advance.operation_code, crate::DURABLE_NONCE_ADVANCE_OPERATION);
|
||||
assert_eq!(actual_advance.program_id.0, expected_advance.program_id.to_string());
|
||||
assert_eq!(actual_advance.data, expected_advance.data);
|
||||
assert_eq!(actual_advance.accounts.len(), expected_advance.accounts.len());
|
||||
for (actual, expected) in
|
||||
actual_advance.accounts.iter().zip(expected_advance.accounts.iter())
|
||||
{
|
||||
assert_eq!(actual.pubkey.0, expected.pubkey.to_string());
|
||||
assert_eq!(actual.is_signer, expected.is_signer);
|
||||
assert_eq!(actual.is_writable, expected.is_writable);
|
||||
}
|
||||
assert!(solana_transaction::uses_durable_nonce(&unsigned.transaction).is_some());
|
||||
let compiled_first = &unsigned.transaction.message.instructions[0];
|
||||
assert_eq!(
|
||||
unsigned.transaction.message.account_keys[compiled_first.program_id_index as usize],
|
||||
expected_advance.program_id
|
||||
);
|
||||
assert_eq!(compiled_first.data, expected_advance.data);
|
||||
let signer_set = unsigned
|
||||
.required_signer_pubkeys()
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert_eq!(
|
||||
signer_set,
|
||||
std::collections::BTreeSet::from([payer.public_key(), authority.public_key()])
|
||||
);
|
||||
let evidence =
|
||||
unsigned.bind_simulation(successful_nonce_simulation(&nonce_account, &authority));
|
||||
assert_eq!(evidence.blockhash(), nonce_state.blockhash());
|
||||
let signed = unsigned
|
||||
.sign_after_simulation(&evidence, &[payer.as_signer(), authority.as_signer()])
|
||||
.unwrap_or_else(|error| panic!("unexpected durable signing error: {error}"));
|
||||
signed
|
||||
.verify_signatures()
|
||||
.unwrap_or_else(|error| panic!("unexpected signature verification error: {error}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_nonce_assembly_rejects_mismatched_state_and_simulation() {
|
||||
let payer = wallet("mismatch-payer");
|
||||
let authority = wallet("mismatch-authority");
|
||||
let wrong_authority = wallet("wrong-authority");
|
||||
let nonce_account = kb_model::Pubkey(solana_pubkey::Pubkey::new_unique().to_string());
|
||||
let plan = durable_transfer_plan(&payer, &authority, &nonce_account);
|
||||
let wrong_state = nonce_state(&nonce_account, &wrong_authority);
|
||||
assert!(crate::build_durable_nonce_transaction(&plan, &wrong_state).is_err());
|
||||
let nonce_state = nonce_state(&nonce_account, &authority);
|
||||
let unsigned = crate::build_durable_nonce_transaction(&plan, &nonce_state)
|
||||
.unwrap_or_else(|error| panic!("unexpected durable assembly error: {error}"));
|
||||
let latest_evidence = unsigned.bind_simulation(successful_simulation());
|
||||
assert!(
|
||||
unsigned
|
||||
.sign_after_simulation(
|
||||
&latest_evidence,
|
||||
&[payer.as_signer(), authority.as_signer()],
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut undeclared = plan.clone();
|
||||
undeclared
|
||||
.required_signers
|
||||
.retain(|required| return required.pubkey.0 != authority.public_key());
|
||||
assert!(crate::build_durable_nonce_transaction(&undeclared, &nonce_state).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_nonce_plan_requires_dedicated_assembly() {
|
||||
let wallet = wallet("payer");
|
||||
let mut plan = transfer_plan(&wallet, false, 100);
|
||||
plan.policy.blockhash.kind = kb_execution_api::ExecutionBlockhashKind::DurableNonce;
|
||||
plan.policy.blockhash.max_age_slots = std::option::Option::None;
|
||||
plan.policy.blockhash.nonce_account =
|
||||
std::option::Option::Some(kb_model::Pubkey(wallet.public_key()));
|
||||
plan.policy.blockhash.nonce_authority =
|
||||
std::option::Option::Some(kb_model::Pubkey(wallet.public_key()));
|
||||
let nonce = solana_hash::Hash::new_unique().to_string();
|
||||
assert!(crate::build_legacy_transaction(&plan, nonce.as_str()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_blockhash_and_signer_contract_are_rejected() {
|
||||
let wallet = wallet("payer");
|
||||
let mut plan = transfer_plan(&wallet, false, 100);
|
||||
assert!(crate::build_legacy_transaction(&plan, "invalid").is_err());
|
||||
plan.required_signers.clear();
|
||||
let blockhash = solana_hash::Hash::new_unique().to_string();
|
||||
assert!(crate::build_legacy_transaction(&plan, blockhash.as_str()).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user