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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/telos-security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,13 @@ jobs:
# GHSA-frvp-7c67-39w9 is a Windows-only serve-static traversal in the nested
# @modelcontextprotocol/sdk Hono adapter. Vocs does not import or bundle that adapter,
# and this Linux static build serves no files through it. No patched 1.x release exists.
bun audit --ignore GHSA-frvp-7c67-39w9
# GHSA-w3rx-r6r6-pgpr and GHSA-5p2g-fcmc-qvqq affect optional image-size decoders.
# Vocs 2.6.1 pins the latest release, which has no patched successor; the controlled
# documentation build does not process ICNS, JXL, or HEIF input.
bun audit \
--ignore GHSA-frvp-7c67-39w9 \
--ignore GHSA-w3rx-r6r6-pgpr \
--ignore GHSA-5p2g-fcmc-qvqq

security-success:
name: telos / security success
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

14 changes: 13 additions & 1 deletion crates/telos/bin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use reth_ethereum_cli::ExtendedCommand;
use reth_node_telos::{
rpc_policy::{
enforce_exact_auth_rpc_surface, enforce_exact_public_rpc_surface, enforce_telos_rpc_policy,
validate_telos_transaction_count_block, REPLAY_UNSAFE_RPC_METHODS,
telos_pubsub_module, validate_telos_transaction_count_block, REPLAY_UNSAFE_RPC_METHODS,
TELOS_FORWARDER_REQUIRED_RPC_METHODS, TELOS_UNSUPPORTED_AUTH_METHODS,
TELOS_UNSUPPORTED_RPC_METHODS,
},
Expand Down Expand Up @@ -304,6 +304,18 @@ fn main() {
"removed RPC methods that require a native Telos forwarder"
);
}
if ctx
.modules
.module_config()
.contains_ws(&RethRpcModule::Eth)
{
let module = telos_pubsub_module(
ctx.registry.eth_handlers().pubsub.clone(),
ctx.registry.tasks().clone(),
)?;
ctx.modules.add_or_replace_ws(module)?;
info!(target: "reth::cli", "installed restricted Telos WebSocket subscriptions");
}
enforce_exact_public_rpc_surface(ctx.modules, forwarder_enabled)?;
enforce_exact_auth_rpc_surface(ctx.auth_module.module_mut())?;
Ok(())
Expand Down
1 change: 1 addition & 0 deletions crates/telos/node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ reth-rpc-server-types.workspace = true
reth-rpc-builder.workspace = true
reth-stages.workspace = true
reth-stages-types.workspace = true
reth-tasks.workspace = true

# telos
reth-telos-rpc.workspace = true
Expand Down
16 changes: 16 additions & 0 deletions crates/telos/node/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ pub struct TelosArgs {
/// Seconds to cache the on-chain gas price.
#[arg(long = "telos.gas-cache-seconds", default_value_t = 8)]
pub gas_cache_seconds: u32,

/// Ask the native Telos API to retry submitted transactions until they enter a block.
#[arg(long = "telos.transaction-retry", default_value_t = false)]
pub transaction_retry: bool,
}

impl Default for TelosArgs {
Expand All @@ -46,6 +50,7 @@ impl Default for TelosArgs {
signer_permission: None,
signer_key_file: None,
gas_cache_seconds: 8,
transaction_retry: false,
}
}
}
Expand Down Expand Up @@ -123,6 +128,7 @@ impl From<TelosArgs> for TelosClientArgs {
signer_permission: args.signer_permission,
signer_key_file: args.signer_key_file,
gas_cache_seconds: Some(args.gas_cache_seconds),
transaction_retry: args.transaction_retry,
}
}
}
Expand All @@ -148,6 +154,16 @@ mod tests {
assert!(args.validate().is_ok());
}

#[test]
fn transaction_retry_is_explicitly_enabled() {
let defaults = CommandParser::<TelosArgs>::parse_from(["reth"]).args;
assert!(!defaults.transaction_retry);

let enabled =
CommandParser::<TelosArgs>::parse_from(["reth", "--telos.transaction-retry"]).args;
assert!(enabled.transaction_retry);
}

#[test]
fn partial_forwarder_configuration_is_rejected() {
let args = CommandParser::<TelosArgs>::parse_from([
Expand Down
19 changes: 9 additions & 10 deletions crates/telos/node/src/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ impl TelosFrame {
return return_result(result)
}

// Native chain-ID-3 transactions from the zero address represent withdrawals. The value
// Native chain-ID-3 transactions from the zero address represent deposits. The value
// transfer above is authoritative, but executing target bytecode would fabricate a second
// state transition that does not exist in the native runtime.
if stops_after_native_zero_caller_transfer(context) {
Expand Down Expand Up @@ -303,10 +303,9 @@ fn apply_call_value<DB: Database>(
inputs.target_address.is_zero() &&
!inputs.caller.is_zero() =>
{
// A native deposit to address zero is a burn. Debit and journal the source while only
// touching the destination; crediting the zero account would mint an EVM-only balance.
// The legacy transfer still checked whether the temporary destination credit would
// overflow before undoing it, so preserve both error precedence and that edge case.
// A native withdrawal to address zero is a burn. Debit and journal the source while
// only touching the destination; crediting the zero account would create an EVM-only
// balance. Preserve the legacy temporary-credit overflow check and its error order.
let source_balance = {
let source = journal.load_account_mut(inputs.caller)?.data;
*source.balance()
Expand Down Expand Up @@ -335,9 +334,9 @@ fn apply_call_value<DB: Database>(
inputs.target_address.is_zero() &&
inputs.caller.is_zero() =>
{
// The legacy burn guard explicitly excludes the zero sender. A zero-to-zero transfer
// therefore behaves as a checked self-transfer: it touches the account but leaves the
// balance unchanged before the native zero-caller early stop.
// The withdrawal burn guard explicitly excludes the zero sender. A zero-to-zero
// transfer therefore behaves as a checked self-transfer: it touches the account but
// leaves the balance unchanged before the native zero-caller early stop.
let mut account = journal.load_account_mut(Address::ZERO)?.data;
if value > *account.balance() {
return Ok(Some(InstructionResult::OutOfFunds))
Expand Down Expand Up @@ -577,7 +576,7 @@ mod tests {
}

#[test]
fn chain_three_transfer_to_zero_burns_without_crediting_zero() {
fn chain_three_withdrawal_to_zero_burns_without_crediting_zero() {
let caller = Address::repeat_byte(0x11);
let mut context = context_with_accounts(
native_tx(caller, 3, 1),
Expand Down Expand Up @@ -643,7 +642,7 @@ mod tests {
}

#[test]
fn chain_three_burn_preserves_legacy_destination_overflow_error() {
fn chain_three_withdrawal_preserves_destination_overflow_error() {
let caller = Address::repeat_byte(0x11);
let mut context = context_with_accounts(
native_tx(caller, 3, 1),
Expand Down
22 changes: 21 additions & 1 deletion crates/telos/node/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ mod tests {
}

#[test]
fn chain_three_deposit_to_zero_burns_value() {
fn chain_three_withdrawal_to_zero_burns_value() {
let caller = Address::repeat_byte(0x11);
let mut evm = evm_with_caller(caller, 0);
evm.components_mut().0.insert_account_info(
Expand Down Expand Up @@ -451,6 +451,26 @@ mod tests {
assert_eq!(output.state[&caller].info.nonce, 5);
}

#[test]
fn chain_three_stale_nonce_withdrawal_burns_and_preserves_state_nonce() {
let caller = Address::repeat_byte(0x11);
let mut evm = evm_with_caller(caller, 12);
evm.components_mut().0.insert_account_info(
Address::ZERO,
AccountInfo { balance: U256::from(7), ..Default::default() },
);
let mut tx = transaction(caller, 0, true);
tx.inner.kind = TxKind::Call(Address::ZERO);
tx.inner.value = U256::from(25);

let output = evm.transact_raw(tx).unwrap();
let gas_used = output.result.gas().tx_gas_used();

assert_eq!(output.state[&caller].info.balance, U256::from(10_000_000 - gas_used - 25));
assert_eq!(output.state[&caller].info.nonce, 12);
assert_eq!(output.state[&Address::ZERO].info.balance, U256::from(7));
}

#[test]
fn chain_three_stale_nonce_to_nonzero_advances_existing_nonce() {
let caller = Address::repeat_byte(0x11);
Expand Down
83 changes: 81 additions & 2 deletions crates/telos/node/src/rpc_policy.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
//! Telos RPC surface policy.

use alloy_eips::BlockId;
use alloy_rpc_types_eth::pubsub::{Params, SubscriptionKind};
use jsonrpsee::{types::ErrorObjectOwned, RpcModule};
use reth_node_core::args::RpcServerArgs;
use reth_rpc::EthPubSub;
use reth_rpc_builder::{RethRpcModule, TransportRpcModules};
use reth_rpc_eth_api::helpers::EthSubscriptions;
use reth_tasks::Runtime;
use std::collections::BTreeSet;

/// Complete authenticated API exposed to the trusted companion.
Expand Down Expand Up @@ -61,6 +66,9 @@ pub const TELOS_PUBLIC_ETH_RPC_ALLOWLIST: &[&str] = &[
"eth_uninstallFilter",
];

/// WebSocket-only Ethereum methods qualified for Telos canonical-chain notifications.
pub const TELOS_PUBLIC_ETH_WS_RPC_ALLOWLIST: &[&str] = &["eth_subscribe", "eth_unsubscribe"];

/// Exact public network methods qualified for the follower's no-peer network implementation.
pub const TELOS_PUBLIC_NET_RPC_ALLOWLIST: &[&str] = &["net_peerCount", "net_version"];

Expand Down Expand Up @@ -118,6 +126,60 @@ pub fn validate_telos_transaction_count_block(block: Option<BlockId>) -> Result<
Ok(())
}

/// Builds the restricted Telos subscription surface.
///
/// Canonical heads, logs, and receipts are sourced from the follower's canonical-state stream.
/// Pending transactions and syncing are intentionally rejected because Reth's local pool and
/// peer-network status do not represent nodeos transaction propagation or companion catch-up.
pub fn telos_pubsub_module<Eth>(
pubsub: EthPubSub<Eth>,
tasks: Runtime,
) -> eyre::Result<RpcModule<()>>
where
Eth: EthSubscriptions + 'static,
{
let mut module = RpcModule::new(());
module.register_subscription(
"eth_subscribe",
"eth_subscription",
"eth_unsubscribe",
move |params, pending, _ctx, _ext| {
let pubsub = pubsub.clone();
let tasks = tasks.clone();
async move {
let mut params = params.sequence();
let kind: SubscriptionKind = params.next()?;
let subscription_params: Option<Params> = params.optional_next()?;

if !is_telos_subscription_kind_supported(&kind) {
pending
.reject(ErrorObjectOwned::owned(
-32602,
"unsupported Telos subscription: use newHeads, logs, or transactionReceipts",
None::<()>,
))
.await;
return Ok(())
}

let sink = pending.accept().await?;
tasks.spawn_task(async move {
let _ = pubsub.handle_accepted(sink, kind, subscription_params).await;
});
Ok(())
}
},
)?;
Ok(module)
}

const fn is_telos_subscription_kind_supported(kind: &SubscriptionKind) -> bool {
matches!(
kind,
SubscriptionKind::NewHeads | SubscriptionKind::Logs | SubscriptionKind::TransactionReceipts
)
}

/// Authenticated methods outside the minimal Telos companion protocol.
pub const TELOS_UNSUPPORTED_AUTH_METHODS: [&str; 17] = [
"engine_getClientVersionV1",
Expand Down Expand Up @@ -238,6 +300,9 @@ pub fn enforce_exact_public_rpc_surface(
expected.extend(TELOS_PUBLIC_ETH_RPC_ALLOWLIST.iter().copied().filter(|method| {
forwarder_enabled || !TELOS_FORWARDER_REQUIRED_RPC_METHODS.contains(method)
}));
if transport == "WebSocket" {
expected.extend(TELOS_PUBLIC_ETH_WS_RPC_ALLOWLIST.iter().copied());
}
}
if net {
expected.extend(TELOS_PUBLIC_NET_RPC_ALLOWLIST.iter().copied());
Expand Down Expand Up @@ -310,6 +375,15 @@ mod tests {
assert!(validate_telos_transaction_count_block(Some(BlockId::pending())).is_err());
}

#[test]
fn subscriptions_expose_only_canonical_chain_events() {
assert!(is_telos_subscription_kind_supported(&SubscriptionKind::NewHeads));
assert!(is_telos_subscription_kind_supported(&SubscriptionKind::Logs));
assert!(is_telos_subscription_kind_supported(&SubscriptionKind::TransactionReceipts));
assert!(!is_telos_subscription_kind_supported(&SubscriptionKind::NewPendingTransactions));
assert!(!is_telos_subscription_kind_supported(&SubscriptionKind::Syncing));
}

#[test]
fn closed_gate_disables_regular_ipc_but_not_auth_ipc() {
let mut rpc = RpcServerArgs { auth_ipc: true, ..Default::default() };
Expand Down Expand Up @@ -424,7 +498,7 @@ mod tests {
fn public_surface_is_exact_and_namespace_aware() {
let config = TransportRpcModuleConfig::default()
.with_http([RethRpcModule::Eth, RethRpcModule::Net])
.with_ws([RethRpcModule::Web3]);
.with_ws([RethRpcModule::Eth, RethRpcModule::Web3]);
let mut http = RpcModule::new(());
for method in TELOS_PUBLIC_ETH_RPC_ALLOWLIST
.iter()
Expand All @@ -434,7 +508,12 @@ mod tests {
http.register_method(method, |_, _, _| "ok").unwrap();
}
let mut ws = RpcModule::new(());
for method in TELOS_PUBLIC_WEB3_RPC_ALLOWLIST.iter().copied() {
for method in TELOS_PUBLIC_ETH_RPC_ALLOWLIST
.iter()
.copied()
.chain(TELOS_PUBLIC_ETH_WS_RPC_ALLOWLIST.iter().copied())
.chain(TELOS_PUBLIC_WEB3_RPC_ALLOWLIST.iter().copied())
{
ws.register_method(method, |_, _, _| "ok").unwrap();
}
let modules =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,13 @@
"crates/revm/src/journaled_state.rs"
],
"legacy_semantics": "A nonzero chain-3 caller transferring to address zero is debited without crediting zero, while zero-to-zero remains neutral and the destination overflow check still occurs.",
"port": "Telos frame value application preserves the legacy burn guard, touch, self-transfer, and overflow ordering.",
"port": "Telos frame value application preserves the legacy withdrawal burn, touch, self-transfer, and overflow ordering.",
"tests": [
{ "path": "src/frame.rs", "function": "chain_three_transfer_to_zero_burns_without_crediting_zero" },
{ "path": "src/frame.rs", "function": "chain_three_withdrawal_to_zero_burns_without_crediting_zero" },
{ "path": "src/frame.rs", "function": "chain_three_zero_caller_to_zero_self_transfer_is_balance_neutral" },
{ "path": "src/frame.rs", "function": "chain_three_burn_preserves_legacy_destination_overflow_error" },
{ "path": "src/frame.rs", "function": "unauthenticated_transfer_to_zero_uses_ethereum_credit" }
{ "path": "src/frame.rs", "function": "chain_three_withdrawal_preserves_destination_overflow_error" },
{ "path": "src/frame.rs", "function": "unauthenticated_transfer_to_zero_uses_ethereum_credit" },
{ "path": "src/handler.rs", "function": "chain_three_stale_nonce_withdrawal_burns_and_preserves_state_nonce" }
]
},
{
Expand Down
Loading
Loading