Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

High Sequence Number Gas DOS #597

Closed
wants to merge 21 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
33 changes: 28 additions & 5 deletions .github/workflows/checks-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@ jobs:
CELESTIA_LOG_LEVEL: FATAL # adjust the log level while debugging
run: |
nix develop --command bash -c "just suzuka-full-node native build.setup.eth-local.celestia-local.test -t=false"
nix develop --command bash -c "just suzuka-full-node native build.setup.eth-local.celestia-local.test -t=false"

suzuka-full-node-malicious:
if: github.event.label.name == 'cicd:suzuka-full-node-malicious' || github.ref == 'refs/heads/main'

suzuka-multi-node-local:
if: github.event.label.name == 'cicd:suzuka-multi-node-local' || github.ref == 'refs/heads/main'
strategy:
matrix:
include:
Expand All @@ -122,10 +124,31 @@ jobs:
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main

- uses: cachix/cachix-action@v15
- name: Run Suzuka Full Node Gas DoS Test
env:
CELESTIA_LOG_LEVEL: FATAL # adjust the log level while debugging
run: |
nix develop --command bash -c "just suzuka-full-node native build.setup.eth-local.celestia-local.gas-dos -t=false"

suzuka-multi-node-local:
if: github.event.label.name == 'cicd:suzuka-multi-node-local' || github.ref == 'refs/heads/main'
strategy:
matrix:
include:
- os: ubuntu-22.04
arch: x86_64
runs-on: buildjet-16vcpu-ubuntu-2204

runs-on: ${{ matrix.runs-on }}

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
name: movementlabs
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
submodules: true

- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main

- name: Run Suzuka Full Node Tests Against Local ETH and Local Celestia
env:
Expand Down
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
gcc
rust
postgresql
tesseract4
ansible
];

Expand Down
2 changes: 2 additions & 0 deletions networks/suzuka/setup/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ async fn main() -> Result<(), anyhow::Error> {
}
});

info!("Starting Suzuka Full Node Setup");

// get the config file
let dot_movement = dot_movement::DotMovement::try_from_env()?;

Expand Down
6 changes: 5 additions & 1 deletion networks/suzuka/suzuka-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ rust-version = { workspace = true }
### define here all scenarios execution binaries
[[bin]]
name = "demo_scenario"
path = "bin/demo_scenario.rs"
path = "src/bin/e2e/demo_scenario.rs"
#[[bin]]
# name = "basic_alice_bob"
# path = "bin/basic_alice_bob.rs"
Expand All @@ -23,6 +23,10 @@ path = "bin/demo_scenario.rs"
name = "suzuka-client-e2e-simple-interaction"
path = "src/bin/e2e/simple_interaction.rs"

[[bin]]
name = "suzuka-client-gas-dos"
path = "src/bin/e2e/gas_dos.rs"

[[bin]]
name = "suzuka-client-e2e-followers-consistent"
path = "src/bin/e2e/followers_consistent.rs"
Expand Down
271 changes: 271 additions & 0 deletions networks/suzuka/suzuka-client/src/bin/e2e/gas_dos.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
use anyhow::Context;
use bcs::to_bytes;
use once_cell::sync::Lazy;
use std::str::FromStr;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use suzuka_client::{
coin_client::CoinClient,
move_types::{
identifier::Identifier,
language_storage::{ModuleId, TypeTag},
},
rest_client::{Client, FaucetClient},
transaction_builder::TransactionBuilder,
types::transaction::{EntryFunction, SignedTransaction, TransactionPayload},
types::{account_address::AccountAddress, chain_id::ChainId, LocalAccount},
};
use url::Url;

static SUZUKA_CONFIG: Lazy<suzuka_config::Config> = Lazy::new(|| {
let dot_movement = dot_movement::DotMovement::try_from_env().unwrap();
let config = dot_movement.try_get_config_from_json::<suzuka_config::Config>().unwrap();
config
});

// :!:>section_1c
static NODE_URL: Lazy<Url> = Lazy::new(|| {
let node_connection_address = SUZUKA_CONFIG
.execution_config
.maptos_config
.client
.maptos_rest_connection_hostname
.clone();
let node_connection_port = SUZUKA_CONFIG
.execution_config
.maptos_config
.client
.maptos_rest_connection_port
.clone();

let node_connection_url =
format!("http://{}:{}", node_connection_address, node_connection_port);

Url::from_str(node_connection_url.as_str()).unwrap()
});

static FAUCET_URL: Lazy<Url> = Lazy::new(|| {
let faucet_listen_address = SUZUKA_CONFIG
.execution_config
.maptos_config
.client
.maptos_faucet_rest_connection_hostname
.clone();
let faucet_listen_port = SUZUKA_CONFIG
.execution_config
.maptos_config
.client
.maptos_faucet_rest_connection_port
.clone();

let faucet_listen_url = format!("http://{}:{}", faucet_listen_address, faucet_listen_port);

Url::from_str(faucet_listen_url.as_str()).unwrap()
});
// <:!:section_1c

pub async fn create_fake_signed_transaction(
chain_id: u8,
from_account: &LocalAccount,
to_account: AccountAddress,
amount: u64,
sequence_number: u64,
) -> Result<SignedTransaction, anyhow::Error> {
let coin_type = "0x1::aptos_coin::AptosCoin";
let timeout_secs = 600; // 10 minutes
let max_gas_amount = 5_000;
let gas_unit_price = 100;

let expiration_time =
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + timeout_secs;

let transaction_builder = TransactionBuilder::new(
TransactionPayload::EntryFunction(EntryFunction::new(
ModuleId::new(AccountAddress::ONE, Identifier::new("coin").unwrap()),
Identifier::new("transfer")?,
vec![TypeTag::from_str(coin_type)?],
vec![to_bytes(&to_account)?, to_bytes(&amount)?],
)),
expiration_time,
ChainId::new(chain_id),
);

let raw_transaction = transaction_builder
.sender(from_account.address())
.sequence_number(sequence_number)
.max_gas_amount(max_gas_amount)
.gas_unit_price(gas_unit_price)
.expiration_timestamp_secs(expiration_time)
.chain_id(ChainId::new(chain_id))
.build();

let signed_transaction = from_account.sign_transaction(raw_transaction);

Ok(signed_transaction)
}

pub async fn test_sending_failed_transaction() -> Result<(), anyhow::Error> {
let rest_client = Client::new(NODE_URL.clone());
let faucet_client = FaucetClient::new(FAUCET_URL.clone(), NODE_URL.clone());
let coin_client = CoinClient::new(&rest_client);

let alice = LocalAccount::generate(&mut rand::rngs::OsRng);
let bob = LocalAccount::generate(&mut rand::rngs::OsRng);

println!("=== Addresses ===");
println!("Alice: {}", alice.address().to_hex_literal());
println!("Bob: {}", bob.address().to_hex_literal());

faucet_client
.fund(alice.address(), 100_000_000)
.await
.context("Failed to fund Alice's account")?;

faucet_client
.create_account(bob.address())
.await
.context("Failed to fund Bob's account")?;

let chain_id = rest_client
.get_index()
.await
.context("Failed to get chain ID")?
.inner()
.chain_id;
println!("\n=== Initial Balance ===");
let initial_balance = coin_client
.get_account_balance(&alice.address())
.await
.context("Failed to get Alice's account balance")?;
println!("Alice: {:?}", initial_balance);

// TEST 1: Sending a transaction trying to transfer more coins than Alice has (including gas fees)
let transaction = create_fake_signed_transaction(
chain_id,
&alice,
bob.address(),
100_000_000,
alice.sequence_number(),
)
.await?;

let _transaction_will_fail = rest_client
.submit(&transaction)
.await
.context("Failed when waiting for the transaction")?
.into_inner();
match rest_client.wait_for_signed_transaction(&transaction).await {
Ok(_) => panic!("Transaction should have failed"),
Err(e) => {
println!("Transaction failed as expected: {:?}", e);
}
}

// assert gas fee charged
let failed_balance = coin_client
.get_account_balance(&alice.address())
.await
.context("Failed to get Alice's account balance")?;
println!("\n=== After Failed Tx#1 ===");
println!("Alice: {:?}", failed_balance);
assert!(initial_balance > failed_balance);

// TEST 2: Sending a transaction with a high sequence number
let too_high_sequence_number = alice.sequence_number() + 32 + 2;
println!("Alice's sequence number: {}", alice.sequence_number());
println!("Too high sequence number: {}", too_high_sequence_number);
let mut last_balance = failed_balance;
let transaction = create_fake_signed_transaction(
chain_id,
&alice,
bob.address(),
100,
too_high_sequence_number, // too new tolerance is 32
)
.await?;

match rest_client.submit(&transaction).await {
Ok(_) => panic!("Transaction should have failed with high sequence number"),
Err(e) => match e {
suzuka_client::rest_client::error::RestError::Api(aptos_error) => {
println!("Transaction failed as expected: {:?}", aptos_error);
assert_eq!(aptos_error.error.error_code as u32, 402); // 402 is used for too old and too new
}
_ => panic!("Unexpected error: {:?}", e),
},
}

// assert that no gas fee charged because the transaction never entered the mempool
let failed_balance = coin_client
.get_account_balance(&alice.address())
.await
.context("Failed to get Alice's account balance")?;
println!("\n=== After Failed Tx#2 ===");
println!("Alice: {:?}", failed_balance);
assert!(last_balance == failed_balance);

// TEST 3: Sending a transaction with a sequence number that won't be accepted by the VM, but would be accepted by the mempool (sequence number cannot be reused)
let attack_sequence_number = alice.sequence_number() + 5;
let transaction = create_fake_signed_transaction(
chain_id,
&alice,
bob.address(),
100,
attack_sequence_number,
)
.await?;

// transaction should fail in the vm not on the submission
let _transaction_will_fail = rest_client
.submit(&transaction)
.await
.context("Failed when waiting for the transaction")?
.into_inner();
match rest_client.wait_for_signed_transaction(&transaction).await {
Ok(_) => panic!("Transaction should have failed"),
Err(e) => {
println!("Transaction failed as expected: {:?}", e);
}
}

// assert gas fee not charged
let failed_balance = coin_client
.get_account_balance(&alice.address())
.await
.context("Failed to get Alice's account balance")?;
println!("\n=== After Failed Tx#3 ===");
println!("Alice: {:?}", failed_balance);
assert!(last_balance == failed_balance);

// transaction using the same sequence number should fail to submit
let transaction = create_fake_signed_transaction(
chain_id,
&alice,
bob.address(),
100,
attack_sequence_number,
)
.await?;

match rest_client.submit(&transaction).await {
Ok(res) => panic!(
"Transaction should have failed with high sequence number. Instead got: {:?}",
res
),
Err(e) => match e {
suzuka_client::rest_client::error::RestError::Api(aptos_error) => {
println!("Transaction failed as expected: {:?}", aptos_error);
assert_eq!(aptos_error.error.error_code as u32, 402); // 402 is used for too old and too new
}
_ => panic!("Unexpected error: {:?}", e),
},
}

Ok(())
}

#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
test_sending_failed_transaction().await?;
Ok(())
}
16 changes: 16 additions & 0 deletions process-compose/suzuka-full-node/process-compose.gas-dos.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
version: "3"

environment:

processes:

gas-dos-test:
command: |
cargo run --bin suzuka-client-gas-dos
depends_on:
suzuka-full-node:
condition: process_healthy
suzuka-faucet:
condition: process_healthy
availability:
exit_on_end: true
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ impl Executor {
&node_config,
Arc::clone(&self.transactions_in_flight),
maptos_config.load_shedding.max_transactions_in_flight,
self.config.mempool.sequence_number_ttl_ms,
self.config.mempool.gc_slot_duration_ms,
);

let cx = Context::new(
Expand Down
Loading