Skip to content

Commit

Permalink
add opt in reentrancy to soroban
Browse files Browse the repository at this point in the history
  • Loading branch information
heytdep committed Nov 6, 2024
1 parent 1cd8b8d commit 43a3ed8
Show file tree
Hide file tree
Showing 16 changed files with 567 additions and 199 deletions.
42 changes: 42 additions & 0 deletions soroban-env-common/env.json
Original file line number Diff line number Diff line change
Expand Up @@ -1560,6 +1560,48 @@
],
"return": "Val",
"docs": "Calls a function in another contract with arguments contained in vector `args`, returning either the result of the called function or an `Error` if the called function failed. The returned error is either a custom `ContractError` that the called contract returns explicitly, or an error with type `Context` and code `InvalidAction` in case of any other error in the called contract (such as a host function failure that caused a trap). `try_call` might trap in a few scenarios where the error can't be meaningfully recovered from, such as running out of budget."
},
{
"export": "1",
"name": "call_reentrant",
"args": [
{
"name": "contract",
"type": "AddressObject"
},
{
"name": "func",
"type": "Symbol"
},
{
"name": "args",
"type": "VecObject"
}
],
"return": "Val",
"docs": "Calls a function in another contract with arguments contained in vector `args`. If the call is successful, returns the result of the called function. Traps otherwise. This functions enables re-entrancy in the immediate cross-contract call.",
"min_supported_protocol": 21
},
{
"export": "2",
"name": "try_call_reentrant",
"args": [
{
"name": "contract",
"type": "AddressObject"
},
{
"name": "func",
"type": "Symbol"
},
{
"name": "args",
"type": "VecObject"
}
],
"return": "Val",
"docs": "Calls a function in another contract with arguments contained in vector `args`, returning either the result of the called function or an `Error` if the called function failed. The returned error is either a custom `ContractError` that the called contract returns explicitly, or an error with type `Context` and code `InvalidAction` in case of any other error in the called contract (such as a host function failure that caused a trap). `try_call` might trap in a few scenarios where the error can't be meaningfully recovered from, such as running out of budget. This functions enables re-entrancy in the immediate cross-contract call.",
"min_supported_protocol": 21
}
]
},
Expand Down
91 changes: 26 additions & 65 deletions soroban-env-host/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2369,23 +2369,8 @@ impl VmCallerEnv for Host {
func: Symbol,
args: VecObject,
) -> Result<Val, HostError> {
let argvec = self.call_args_from_obj(args)?;
// this is the recommended path of calling a contract, with `reentry`
// always set `ContractReentryMode::Prohibited`
let res = self.call_n_internal(
&self.contract_id_from_address(contract_address)?,
func,
argvec.as_slice(),
CallParams::default_external_call(),
);
if let Err(e) = &res {
self.error(
e.error,
"contract call failed",
&[func.to_val(), args.to_val()],
);
}
res
let call_params = CallParams::default_external_call();
self.call_with_params(contract_address, func, args, call_params)
}

// Notes on metering: covered by the components.
Expand All @@ -2396,54 +2381,30 @@ impl VmCallerEnv for Host {
func: Symbol,
args: VecObject,
) -> Result<Val, HostError> {
let argvec = self.call_args_from_obj(args)?;
// this is the "loosened" path of calling a contract.
// TODO: A `reentry` flag will be passed from `try_call` into here.
// For now, we are passing in `ContractReentryMode::Prohibited` to disable
// reentry.
let res = self.call_n_internal(
&self.contract_id_from_address(contract_address)?,
func,
argvec.as_slice(),
CallParams::default_external_call(),
);
match res {
Ok(rv) => Ok(rv),
Err(e) => {
self.error(
e.error,
"contract try_call failed",
&[func.to_val(), args.to_val()],
);
// Only allow to gracefully handle the recoverable errors.
// Non-recoverable errors should still cause guest to panic and
// abort execution.
if e.is_recoverable() {
// Pass contract error _codes_ through, while switching
// from Err(ce) to Ok(ce), i.e. recovering.
if e.error.is_type(ScErrorType::Contract) {
Ok(e.error.to_val())
} else {
// Narrow all the remaining host errors down to a single
// error type. We don't want to expose the granular host
// errors to the guest, consistently with how every
// other host function works. This reduces the risk of
// implementation being 'locked' into specific error
// codes due to them being exposed to the guest and
// hashed into blockchain.
// The granular error codes are still observable with
// diagnostic events.
Ok(Error::from_type_and_code(
ScErrorType::Context,
ScErrorCode::InvalidAction,
)
.to_val())
}
} else {
Err(e)
}
}
}
let call_params = CallParams::default_external_call();
self.try_call_with_params(contract_address, func, args, call_params)
}

fn call_reentrant(
&self,
_vmcaller: &mut VmCaller<Host>,
contract_address: AddressObject,
func: Symbol,
args: VecObject,
) -> Result<Val, Self::Error> {
let call_params = CallParams::reentrant_external_call();
self.call_with_params(contract_address, func, args, call_params)
}

fn try_call_reentrant(
&self,
_vmcaller: &mut VmCaller<Host>,
contract_address: AddressObject,
func: Symbol,
args: VecObject,
) -> Result<Val, Self::Error> {
let call_params = CallParams::reentrant_external_call();
self.try_call_with_params(contract_address, func, args, call_params)
}

// endregion: "call" module functions
Expand Down
94 changes: 94 additions & 0 deletions soroban-env-host/src/host/frame.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use soroban_env_common::VecObject;

use crate::{
auth::AuthorizationManagerSnapshot,
budget::AsBudget,
Expand Down Expand Up @@ -111,6 +113,14 @@ impl CallParams {
}
}

pub(crate) fn reentrant_external_call() -> Self {
Self {
reentry_mode: ContractReentryMode::Allowed,
internal_host_call: false,
treat_missing_function_as_noop: false,
}
}

#[allow(unused)]
pub(crate) fn default_internal_call() -> Self {
Self {
Expand Down Expand Up @@ -814,6 +824,90 @@ impl Host {
}
}

pub(crate) fn call_with_params(
&self,
contract_address: AddressObject,
func: Symbol,
args: VecObject,
call_params: CallParams,
) -> Result<Val, HostError> {
let argvec = self.call_args_from_obj(args)?;
// this is the recommended path of calling a contract, with `reentry`
// always set `ContractReentryMode::Prohibited` unless the reentrant
// flag is enabled.
let res = self.call_n_internal(
&self.contract_id_from_address(contract_address)?,
func,
argvec.as_slice(),
call_params,
);
if let Err(e) = &res {
self.error(
e.error,
"contract call failed",
&[func.to_val(), args.to_val()],
);
}
res
}

pub(crate) fn try_call_with_params(
&self,
contract_address: AddressObject,
func: Symbol,
args: VecObject,
call_params: CallParams,
) -> Result<Val, HostError> {
let argvec = self.call_args_from_obj(args)?;
// this is the "loosened" path of calling a contract.
// TODO: A `reentry` flag will be passed from `try_call` into here.
// Default behaviour is to pass in `ContractReentryMode::Prohibited` to disable
// reentry, but it is the `call_data` parameter that controls this mode.
let res = self.call_n_internal(
&self.contract_id_from_address(contract_address)?,
func,
argvec.as_slice(),
call_params,
);
match res {
Ok(rv) => Ok(rv),
Err(e) => {
self.error(
e.error,
"contract try_call failed",
&[func.to_val(), args.to_val()],
);
// Only allow to gracefully handle the recoverable errors.
// Non-recoverable errors should still cause guest to panic and
// abort execution.
if e.is_recoverable() {
// Pass contract error _codes_ through, while switching
// from Err(ce) to Ok(ce), i.e. recovering.
if e.error.is_type(ScErrorType::Contract) {
Ok(e.error.to_val())
} else {
// Narrow all the remaining host errors down to a single
// error type. We don't want to expose the granular host
// errors to the guest, consistently with how every
// other host function works. This reduces the risk of
// implementation being 'locked' into specific error
// codes due to them being exposed to the guest and
// hashed into blockchain.
// The granular error codes are still observable with
// diagnostic events.
Ok(Error::from_type_and_code(
ScErrorType::Context,
ScErrorCode::InvalidAction,
)
.to_val())
}
} else {
Err(e)
}
}
}
}

// Notes on metering: this is covered by the called components.
pub(crate) fn call_n_internal(
&self,
Expand Down
58 changes: 58 additions & 0 deletions soroban-env-host/src/test/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2396,3 +2396,61 @@ mod cap_58_constructor {
}
}
}

#[allow(unused_imports)]
mod cap_xx_opt_in_reentry {
use crate::{Host, HostError, MeteredOrdMap};
use soroban_env_common::{AddressObject, Env, Symbol, TryFromVal, TryIntoVal, Val, VecObject};
use soroban_test_wasms::{SIMPLE_NO_REENTRY_CONTRACT_B, SIMPLE_REENTRY_CONTRACT_A, SIMPLE_REENTRY_CONTRACT_B};
use stellar_xdr::curr::{ContractEvent, ContractEventBody, ContractEventType, ContractEventV0, ExtensionPoint, Hash, ScSymbol, ScVal};

#[test]
fn test_reentry_enabled() {
let host = Host::test_host_with_recording_footprint();
let contract_id_a = host.register_test_contract_wasm(SIMPLE_REENTRY_CONTRACT_A);
let contract_id_b = host.register_test_contract_wasm(SIMPLE_REENTRY_CONTRACT_B);
host.enable_debug().unwrap();
let args = test_vec![
&host,
contract_id_b
].into();
call_contract(&host, contract_id_a, args);

let event_body = ContractEventBody::V0(ContractEventV0 {
topics: host.map_err(vec![ScVal::Symbol(ScSymbol("first_soroban_reentry".try_into().unwrap()))].try_into()).unwrap(),
data: ScVal::Void
}
);
let events = host.get_events().unwrap().0;
match events.iter().find(|he| he.event.type_ == ContractEventType::Contract) {
Some(he) if he.event.type_ == ContractEventType::Contract => {
assert_eq!(he.event.body, event_body)
}
_ => panic!("missing contract event"),
}
}

#[test]
#[should_panic]
fn test_reentry_disabled() {
let host = Host::test_host_with_recording_footprint();
let contract_id_a = host.register_test_contract_wasm(SIMPLE_REENTRY_CONTRACT_A);
let contract_id_b = host.register_test_contract_wasm(SIMPLE_NO_REENTRY_CONTRACT_B);
host.enable_debug().unwrap();
let args = test_vec![
&host,
contract_id_b
].into();
call_contract(&host, contract_id_a, args);
}

fn call_contract(host: &Host, called: AddressObject, args: VecObject) {
let fname = Symbol::try_from_val(host, &"test_reentry").unwrap();
host.call(
called,
fname,
args
)
.unwrap();
}
}
11 changes: 11 additions & 0 deletions soroban-test-wasms/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,14 @@ pub const CONSTRUCTOR_WITH_RESULT: &[u8] =
include_bytes!("../wasm-workspace/opt/22/test_constructor_with_result.wasm").as_slice();
pub const CUSTOM_ACCOUNT_CONTEXT_TEST_CONTRACT: &[u8] =
include_bytes!("../wasm-workspace/opt/22/test_custom_account_context.wasm").as_slice();


// Protocol 23 Wasms.
pub const SIMPLE_REENTRY_CONTRACT_A: &[u8] =
include_bytes!("../wasm-workspace/opt/23/example_reentry_a.wasm").as_slice();

pub const SIMPLE_REENTRY_CONTRACT_B: &[u8] =
include_bytes!("../wasm-workspace/opt/23/example_reentry_b.wasm").as_slice();

pub const SIMPLE_NO_REENTRY_CONTRACT_B: &[u8] =
include_bytes!("../wasm-workspace/opt/23/example_no_reentry_b.wasm").as_slice();
Loading

0 comments on commit 43a3ed8

Please sign in to comment.