mod config;
pub mod feature;
pub use config::Config;
use dusk_core::abi::{ContractError, ContractId, Metadata};
use dusk_core::stake::STAKE_CONTRACT;
use dusk_core::transfer::data::{ContractBytecode, gen_contract_id};
use dusk_core::transfer::withdraw::{
Withdraw, WithdrawReceiver, WithdrawReplayToken,
};
use dusk_core::transfer::{TRANSFER_CONTRACT, Transaction};
use piecrust::{CallReceipt, Session};
use rkyv::Deserialize;
use wasmparser::*;
use crate::ExecutionError;
const DEPLOY_FEATURE_VALIDATION_ERROR: &str =
"failed deployment: bytecode validation rejected";
const PHOENIX_DISABLED_ERROR: &str = "phoenix is not enabled in the VM";
const TRANSFER_WITHDRAWAL_FUNCTIONS: &[&str] = &["mint", "withdraw", "convert"];
pub fn execute(
session: &mut Session,
tx: &Transaction,
config: &Config,
) -> Result<CallReceipt<Result<Vec<u8>, ContractError>>, ExecutionError> {
if config.disable_phoenix && matches!(tx, Transaction::Phoenix(_)) {
return Err(ExecutionError::precondition(PHOENIX_DISABLED_ERROR));
}
tx.phoenix_fee_check()?;
if config.phoenix_refund_check {
tx.phoenix_refund_check()?;
}
tx.deploy_check(
config.gas_per_deploy_byte,
config.min_deploy_gas_price,
config.min_deploy_points,
)?;
if let Some(contract_deploy) = tx.deploy() {
let is_wasm64 = is_wasm64(&contract_deploy.bytecode.bytes);
match (config.disable_wasm32, config.disable_wasm64) {
(true, true) => Err(ExecutionError::precondition(
"contract deployment is not enabled in the VM",
)),
(true, false) if !is_wasm64 => Err(ExecutionError::precondition(
"32-bit wasm is not enabled in the VM",
)),
(false, true) if is_wasm64 => Err(ExecutionError::precondition(
"64-bit wasm is not enabled in the VM",
)),
_ => Ok(()),
}?
}
if config.disable_3rd_party
&& let Some(call) = tx.call()
&& call.contract != TRANSFER_CONTRACT
&& call.contract != STAKE_CONTRACT
{
return Err(ExecutionError::precondition(
"3rd party contracts are not enabled in the VM",
));
}
let blob_min_charge = tx.blob_check(config.gas_per_blob)?;
if blob_min_charge.is_some() && !config.with_blob {
return Err(ExecutionError::precondition(
"Blob processing is not enabled in the VM",
));
}
if config.with_public_sender {
let _ = session
.set_meta(Metadata::PUBLIC_SENDER, tx.moonlight_sender().copied());
}
let stripped_tx = tx.blob_to_memo().or(tx.strip_off_bytecode());
if (config.disable_phoenix || config.withdrawal_nullifier_check)
&& tx.call().is_some()
{
let disable_phoenix = config.disable_phoenix;
let withdrawal_nullifier_check = config.withdrawal_nullifier_check;
let tx_nullifier_count = tx.nullifiers().len();
session.set_call_hook(Box::new(move |callee, fn_name, fn_args| {
if disable_phoenix {
check_phoenix_disabled_call(callee, fn_name, fn_args)?;
}
if withdrawal_nullifier_check {
check_withdrawal_nullifiers(
callee,
fn_name,
fn_args,
tx_nullifier_count,
)?;
}
Ok(())
}));
}
let mut receipt = session
.call::<_, Result<Vec<u8>, ContractError>>(
TRANSFER_CONTRACT,
"spend_and_execute",
stripped_tx.as_ref().unwrap_or(tx),
tx.gas_limit(),
)
.inspect_err(|_| {
clear_session(session, config);
})
.map_err(ExecutionError::from_spend_and_execute)?;
contract_deploy(session, tx, config, &mut receipt);
if let Some(blob_min_charge) = blob_min_charge
&& receipt.gas_spent < blob_min_charge
{
receipt.gas_spent = blob_min_charge;
}
if receipt.data.is_err() {
receipt.gas_spent = receipt.gas_limit;
}
let refund_receipt = session
.call::<_, ()>(
TRANSFER_CONTRACT,
"refund",
&receipt.gas_spent,
u64::MAX,
)
.inspect_err(|_| {
clear_session(session, config);
})
.map_err(ExecutionError::FailedRefund)?;
receipt.events.extend(refund_receipt.events);
clear_session(session, config);
Ok(receipt)
}
fn check_phoenix_disabled_call(
callee: &ContractId,
fn_name: &str,
fn_args: &[u8],
) -> Result<(), String> {
if *callee != TRANSFER_CONTRACT
|| !TRANSFER_WITHDRAWAL_FUNCTIONS.contains(&fn_name)
{
return Ok(());
}
if fn_name == "convert" {
return Err(PHOENIX_DISABLED_ERROR.into());
}
let withdraw = deserialize_withdraw(fn_args)?;
if withdraw_uses_phoenix(&withdraw) {
return Err(PHOENIX_DISABLED_ERROR.into());
}
Ok(())
}
fn withdraw_uses_phoenix(withdraw: &Withdraw) -> bool {
matches!(withdraw.receiver(), WithdrawReceiver::Phoenix(_))
|| matches!(withdraw.token(), WithdrawReplayToken::Phoenix(_))
}
fn deserialize_withdraw(fn_args: &[u8]) -> Result<Withdraw, String> {
let Ok(root) = rkyv::check_archived_root::<Withdraw>(fn_args) else {
return Err("failed to deserialize withdrawal arguments".into());
};
match root.deserialize(&mut rkyv::Infallible) {
Ok(w) => Ok(w),
Err(infallible) => match infallible {},
}
}
fn is_wasm64(bytecode: &[u8]) -> bool {
for payload in Parser::new(0).parse_all(bytecode).flatten() {
if let Payload::MemorySection(section) = payload {
return section
.into_iter()
.any(|memory| memory.is_ok_and(|m| m.memory64));
}
}
false
}
fn clear_session(session: &mut Session, config: &Config) {
if config.with_public_sender {
let _ = session.remove_meta(Metadata::PUBLIC_SENDER);
}
session.clear_call_hook();
}
fn check_withdrawal_nullifiers(
callee: &ContractId,
fn_name: &str,
fn_args: &[u8],
tx_nullifier_count: usize,
) -> Result<(), String> {
if *callee != TRANSFER_CONTRACT || fn_name != "withdraw" {
return Ok(());
}
let withdraw = deserialize_withdraw(fn_args)?;
if let WithdrawReplayToken::Phoenix(nullifiers) = withdraw.token()
&& nullifiers.len() != tx_nullifier_count
{
return Err(format!(
"nullifier count mismatch: withdrawal has {}, transaction has {}",
nullifiers.len(),
tx_nullifier_count,
));
}
Ok(())
}
fn contract_deploy(
session: &mut Session,
tx: &Transaction,
config: &Config,
receipt: &mut CallReceipt<Result<Vec<u8>, ContractError>>,
) {
if let Some(deploy) = tx.deploy() {
let gas_per_deploy_byte = config.gas_per_deploy_byte;
let min_deploy_points = config.min_deploy_points;
if receipt.data.is_ok() {
let Ok(deploy_charge) =
tx.deploy_charge(gas_per_deploy_byte, min_deploy_points)
else {
receipt.data =
Err(ContractError::Panic("deploy charge overflow".into()));
return;
};
if !is_deploy_gas_sufficient(
tx.gas_limit(),
receipt.gas_spent,
deploy_charge,
config.deploy_remaining_gas_check,
) {
receipt.data = Err(ContractError::OutOfGas);
} else if !verify_bytecode_hash(&deploy.bytecode) {
receipt.data = Err(ContractError::Panic(
"failed bytecode hash check".into(),
))
} else if let Err(err) = validate_deploy_bytecode_features(
&deploy.bytecode.bytes,
config.with_reference_types,
) {
receipt.data = Err(ContractError::Panic(err.into()))
} else {
let gas_left = tx.gas_limit().saturating_sub(receipt.gas_spent);
let init_budget = if config.charge_init_gas {
gas_left.saturating_sub(deploy_charge)
} else {
gas_left
};
let result = session.deploy_raw(
Some(gen_contract_id(
&deploy.bytecode.bytes,
deploy.nonce,
&deploy.owner,
)),
deploy.bytecode.bytes.as_slice(),
deploy.init_args.clone(),
deploy.owner.clone(),
init_budget,
);
match result {
Ok((_, init_receipt)) => {
receipt.gas_spent =
receipt.gas_spent.saturating_add(deploy_charge);
apply_deploy_init_receipt(
receipt,
init_receipt,
config.charge_init_gas,
);
}
Err(err) => {
let msg = format!("failed deployment: {err:?}");
receipt.data = Err(ContractError::Panic(msg))
}
}
}
}
}
}
fn validate_deploy_bytecode_features(
bytecode: &[u8],
with_reference_types: bool,
) -> Result<(), &'static str> {
if with_reference_types {
return Ok(());
}
Validator::new_with_features(pre_reference_types_deploy_features())
.validate_all(bytecode)
.map(|_| ())
.map_err(|_| DEPLOY_FEATURE_VALIDATION_ERROR)
}
fn pre_reference_types_deploy_features() -> WasmFeatures {
WasmFeatures::WASM2
.difference(WasmFeatures::REFERENCE_TYPES)
.union(WasmFeatures::RELAXED_SIMD)
.union(WasmFeatures::MULTI_MEMORY)
.union(WasmFeatures::MEMORY64)
}
fn apply_deploy_init_receipt(
receipt: &mut CallReceipt<Result<Vec<u8>, ContractError>>,
init_receipt: Option<CallReceipt<Vec<u8>>>,
charge_init_gas: bool,
) {
if let Some(init_receipt) = init_receipt {
if charge_init_gas {
receipt.gas_spent =
receipt.gas_spent.saturating_add(init_receipt.gas_spent);
}
receipt.events.extend(init_receipt.events);
}
}
fn is_deploy_gas_sufficient(
gas_limit: u64,
gas_spent: u64,
deploy_charge: u64,
deploy_remaining_gas_check: bool,
) -> bool {
let gas_left = gas_limit.saturating_sub(gas_spent);
if deploy_remaining_gas_check {
gas_left >= deploy_charge
} else {
gas_spent
.checked_add(deploy_charge)
.is_some_and(|required| gas_left >= required)
}
}
fn verify_bytecode_hash(bytecode: &ContractBytecode) -> bool {
let computed: [u8; 32] = blake3::hash(bytecode.bytes.as_slice()).into();
bytecode.hash == computed
}
#[cfg(test)]
mod tests {
use alloc::vec;
use dusk_core::BlsScalar;
use dusk_core::abi::{ContractId, Event};
use rand::rngs::StdRng;
use rand::{RngCore, SeedableRng};
use {ff as _, hex as _, once_cell as _};
use super::*;
use crate::CallTree;
#[test]
fn check_withdrawal_nullifiers_matching_count_passes() {
let rng = &mut StdRng::seed_from_u64(0xbeef);
let note_sk = dusk_core::signatures::schnorr::SecretKey::random(rng);
let note_pk = dusk_core::signatures::schnorr::PublicKey::from(¬e_sk);
let address =
dusk_core::transfer::phoenix::StealthAddress::from_raw_unchecked(
*note_pk.as_ref(),
note_pk,
);
let nullifiers = vec![BlsScalar::from(1), BlsScalar::from(2)];
let withdraw = dusk_core::transfer::withdraw::Withdraw::new(
rng,
¬e_sk,
TRANSFER_CONTRACT,
100,
dusk_core::transfer::withdraw::WithdrawReceiver::Phoenix(address),
dusk_core::transfer::withdraw::WithdrawReplayToken::Phoenix(
nullifiers.clone(),
),
);
let args =
rkyv::to_bytes::<_, 4096>(&withdraw).expect("should serialize");
assert!(
check_withdrawal_nullifiers(
&TRANSFER_CONTRACT,
"withdraw",
&args,
nullifiers.len(),
)
.is_ok()
);
}
#[test]
fn check_withdrawal_nullifiers_mismatched_count_rejects() {
let rng = &mut StdRng::seed_from_u64(0xbeef);
let note_sk = dusk_core::signatures::schnorr::SecretKey::random(rng);
let note_pk = dusk_core::signatures::schnorr::PublicKey::from(¬e_sk);
let address =
dusk_core::transfer::phoenix::StealthAddress::from_raw_unchecked(
*note_pk.as_ref(),
note_pk,
);
let nullifiers = vec![BlsScalar::from(1), BlsScalar::from(2)];
let withdraw = dusk_core::transfer::withdraw::Withdraw::new(
rng,
¬e_sk,
TRANSFER_CONTRACT,
100,
dusk_core::transfer::withdraw::WithdrawReceiver::Phoenix(address),
dusk_core::transfer::withdraw::WithdrawReplayToken::Phoenix(
nullifiers,
),
);
let args =
rkyv::to_bytes::<_, 4096>(&withdraw).expect("should serialize");
let err = check_withdrawal_nullifiers(
&TRANSFER_CONTRACT,
"withdraw",
&args,
3,
)
.unwrap_err();
assert!(
err.contains("nullifier count mismatch"),
"expected mismatch message, got: {err}"
);
assert!(err.contains("2") && err.contains("3"));
}
#[test]
fn check_withdrawal_nullifiers_ignores_non_withdraw_calls() {
assert!(
check_withdrawal_nullifiers(&TRANSFER_CONTRACT, "refund", &[], 5,)
.is_ok()
);
assert!(
check_withdrawal_nullifiers(
&ContractId::from_bytes([0xAA; 32]),
"withdraw",
&[],
5,
)
.is_ok()
);
}
#[test]
fn check_withdrawal_nullifiers_rejects_garbage_args() {
let err = check_withdrawal_nullifiers(
&TRANSFER_CONTRACT,
"withdraw",
&[0xDE, 0xAD, 0xBE, 0xEF],
2,
)
.unwrap_err();
assert!(err.contains("deserialize"));
check_withdrawal_nullifiers(&TRANSFER_CONTRACT, "withdraw", &[], 1)
.unwrap_err();
}
#[test]
fn check_withdrawal_nullifiers_ignores_moonlight_token() {
let rng = &mut StdRng::seed_from_u64(0xdead);
let moonlight_sk = dusk_core::signatures::bls::SecretKey::random(rng);
let moonlight_pk =
dusk_core::signatures::bls::PublicKey::from(&moonlight_sk);
let withdraw = dusk_core::transfer::withdraw::Withdraw::new(
rng,
&moonlight_sk,
TRANSFER_CONTRACT,
100,
dusk_core::transfer::withdraw::WithdrawReceiver::Moonlight(
moonlight_pk,
),
dusk_core::transfer::withdraw::WithdrawReplayToken::Moonlight(42),
);
let args =
rkyv::to_bytes::<_, 4096>(&withdraw).expect("should serialize");
assert!(
check_withdrawal_nullifiers(
&TRANSFER_CONTRACT,
"withdraw",
&args,
999,
)
.is_ok()
);
}
#[test]
fn test_gen_contract_id() {
let mut rng = StdRng::seed_from_u64(42);
let mut bytes = vec![0; 1000];
rng.fill_bytes(&mut bytes);
let nonce = rng.next_u64();
let mut owner = vec![0, 100];
rng.fill_bytes(&mut owner);
let contract_id =
gen_contract_id(bytes.as_slice(), nonce, owner.as_slice());
assert_eq!(
contract_id.as_bytes(),
[
45, 168, 182, 39, 119, 137, 168, 140, 114, 21, 120, 158, 34,
126, 244, 221, 151, 72, 109, 178, 82, 229, 84, 128, 92, 123,
135, 74, 23, 224, 119, 133
]
);
}
#[test]
fn deploy_gas_check_matches_prefork_and_boreas_rules() {
for (gas_limit, gas_spent, deploy_charge, boreas, expected) in [
(10_000_000, 3_000_000, 5_000_000, false, false),
(10_000_000, 3_000_000, 5_000_000, true, true),
(7_000_000, 3_000_000, 5_000_000, false, false),
(7_000_000, 3_000_000, 5_000_000, true, false),
(u64::MAX, u64::MAX, 1, false, false),
] {
assert_eq!(
is_deploy_gas_sufficient(
gas_limit,
gas_spent,
deploy_charge,
boreas,
),
expected,
);
}
}
#[test]
fn deploy_bytecode_reference_types_are_height_gated() {
const EMPTY_MODULE: &[u8] = b"\0asm\x01\0\0\0";
const FUNC_WITH_EXTERNREF_MODULE: &[u8] = &[
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60, 0x01, 0x6f, 0x00, ];
const TABLE_WITH_EXTERNREF_MODULE: &[u8] = &[
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x04, 0x04, 0x01, 0x6f, 0x00, 0x01, ];
validate_deploy_bytecode_features(EMPTY_MODULE, false)
.expect("MVP bytecode should validate without reference-types");
let err = validate_deploy_bytecode_features(
FUNC_WITH_EXTERNREF_MODULE,
false,
)
.expect_err("reference-types bytecode should fail before activation");
assert_eq!(err, DEPLOY_FEATURE_VALIDATION_ERROR);
let err = validate_deploy_bytecode_features(
TABLE_WITH_EXTERNREF_MODULE,
false,
)
.expect_err("reference-types table should fail before activation");
assert_eq!(err, DEPLOY_FEATURE_VALIDATION_ERROR);
Validator::new_with_features(
pre_reference_types_deploy_features()
.union(WasmFeatures::REFERENCE_TYPES),
)
.validate_all(FUNC_WITH_EXTERNREF_MODULE)
.expect("reference-types bytecode should validate when enabled");
}
#[test]
fn pre_reference_types_deploy_features_are_pinned() {
let features = pre_reference_types_deploy_features();
assert!(!features.contains(WasmFeatures::REFERENCE_TYPES));
assert!(!features.contains(WasmFeatures::FUNCTION_REFERENCES));
assert!(!features.contains(WasmFeatures::GC));
assert!(!features.contains(WasmFeatures::THREADS));
assert!(!features.contains(WasmFeatures::TAIL_CALL));
assert!(features.contains(WasmFeatures::BULK_MEMORY));
assert!(features.contains(WasmFeatures::MULTI_VALUE));
assert!(features.contains(WasmFeatures::SIMD));
assert!(features.contains(WasmFeatures::RELAXED_SIMD));
assert!(features.contains(WasmFeatures::MULTI_MEMORY));
assert!(features.contains(WasmFeatures::MEMORY64));
assert!(!features.contains(WasmFeatures::EXCEPTIONS));
assert!(!features.contains(WasmFeatures::EXTENDED_CONST));
}
#[test]
fn deploy_init_events_are_preserved_before_and_after_boreas() {
let init_event = Event {
source: ContractId::from_bytes([7; 32]),
topic: "runtime_update".into(),
data: vec![1, 2, 3, 4],
reverted: false,
};
let build_init_receipt = || CallReceipt {
gas_spent: 123,
gas_limit: 999,
events: vec![init_event.clone()],
call_tree: CallTree::default(),
data: Vec::new(),
};
let mut prefork_receipt = CallReceipt {
gas_spent: 10,
gas_limit: 1000,
events: vec![],
call_tree: CallTree::default(),
data: Ok(Vec::new()),
};
apply_deploy_init_receipt(
&mut prefork_receipt,
Some(build_init_receipt()),
false,
);
assert_eq!(prefork_receipt.gas_spent, 10);
assert_eq!(prefork_receipt.events, vec![init_event.clone()]);
let mut boreas_receipt = CallReceipt {
gas_spent: 10,
gas_limit: 1000,
events: vec![],
call_tree: CallTree::default(),
data: Ok(Vec::new()),
};
apply_deploy_init_receipt(
&mut boreas_receipt,
Some(build_init_receipt()),
true,
);
assert_eq!(boreas_receipt.gas_spent, 133);
assert_eq!(boreas_receipt.events, vec![init_event]);
}
}