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
95 changes: 32 additions & 63 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,97 +21,66 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v6

- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.90"

- name: Cache cargo
uses: Swatinem/rust-cache@v2

- name: Install Aptos CLI
run: |
curl -fsSL "https://aptos.dev/scripts/install_cli.py" | python3
echo "$HOME/.local/bin" >> $GITHUB_PATH

- name: Verify Aptos CLI installation
run: aptos --version

- name: Start localnet
run: |
aptos node run-localnet --with-faucet --force-restart &
sleep 30
aptos node run-localnet --with-faucet --force-restart > "${{ runner.temp }}/localnet.log" 2>&1 &
echo "LOCALNET_PID=$!" >> $GITHUB_ENV
# Wait for localnet to be ready
for i in {1..60}; do
if curl -s http://127.0.0.1:8080/v1 > /dev/null; then
echo "Localnet is ready"
for i in $(seq 1 90); do
if curl -s http://127.0.0.1:8080/v1 > /dev/null 2>&1; then
echo "Localnet is ready after ~$((i * 2))s"
break
fi
echo "Waiting for localnet... ($i)"
if [ "$i" -eq 90 ]; then
echo "Localnet failed to start within 180s"
cat "${{ runner.temp }}/localnet.log"
exit 1
fi
sleep 2
done
# Verify faucet is also ready
for i in {1..30}; do
# Wait for faucet to be ready
for i in $(seq 1 30); do
if curl -s http://127.0.0.1:8081/health > /dev/null 2>&1; then
echo "Faucet is ready"
break
fi
echo "Waiting for faucet... ($i)"
if [ "$i" -eq 30 ]; then
echo "Faucet failed to start within 60s"
cat "${{ runner.temp }}/localnet.log"
exit 1
fi
sleep 2
done

- name: Run E2E tests
run: cargo test -p aptos-sdk --features "e2e,full"
env:
APTOS_LOCAL_FAUCET_URL: http://127.0.0.1:8081
APTOS_LOCAL_NODE_URL: http://127.0.0.1:8080/v1

- name: Stop localnet
if: always()
run: pkill -f "aptos node" || true
run: cargo test -p aptos-sdk --features "e2e,full" -- --ignored

# Spec tests that require network connectivity
# TODO: Re-enable once specification tests are stabilized
# spec-tests-network:
# name: Spec Tests (Network Required)
# runs-on: ubuntu-latest
# timeout-minutes: 30
# steps:
# - uses: actions/checkout@v6
#
# - name: Install Rust
# uses: dtolnay/rust-toolchain@stable
#
# - name: Cache cargo
# uses: Swatinem/rust-cache@v2
#
# - name: Install Aptos CLI
# run: |
# curl -fsSL "https://aptos.dev/scripts/install_cli.py" | python3
# echo "$HOME/.local/bin" >> $GITHUB_PATH
#
# - name: Start localnet
# run: |
# aptos node run-localnet --with-faucet --force-restart &
# sleep 30
# for i in {1..60}; do
# if curl -s http://127.0.0.1:8080/v1 > /dev/null; then
# echo "Localnet is ready"
# break
# fi
# echo "Waiting for localnet... ($i)"
# sleep 2
# done
#
# - name: Run spec tests
# run: cargo test --test specs
# working-directory: specifications/tests/rust
# env:
# APTOS_LOCAL_FAUCET_URL: http://127.0.0.1:8081
# APTOS_LOCAL_NODE_URL: http://127.0.0.1:8080/v1
#
# - name: Stop localnet
# if: always()
# run: pkill -f "aptos node" || true
- name: Print localnet logs on failure
if: failure()
run: cat "${{ runner.temp }}/localnet.log"

- name: Stop localnet
if: always()
run: |
kill "$LOCALNET_PID" 2>/dev/null || true
pkill -f "aptos node" || true
99 changes: 91 additions & 8 deletions crates/aptos-sdk/src/aptos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::transaction::{
RawTransaction, SignedTransaction, TransactionBuilder, TransactionPayload,
};
use crate::types::{AccountAddress, ChainId};
use std::sync::Arc;
use std::sync::{Arc, RwLock};
use std::time::Duration;

#[cfg(feature = "ed25519")]
Expand Down Expand Up @@ -52,6 +52,9 @@ use crate::api::IndexerClient;
pub struct Aptos {
config: AptosConfig,
fullnode: Arc<FullnodeClient>,
/// Resolved chain ID. Initialized from config; lazily fetched from node
/// for custom networks where the chain ID is unknown (0).
chain_id: RwLock<ChainId>,
#[cfg(feature = "faucet")]
faucet: Option<FaucetClient>,
#[cfg(feature = "indexer")]
Expand All @@ -73,9 +76,12 @@ impl Aptos {
#[cfg(feature = "indexer")]
let indexer = IndexerClient::new(&config).ok();

let chain_id = RwLock::new(config.chain_id());

Ok(Self {
config,
fullnode,
chain_id,
#[cfg(feature = "faucet")]
faucet,
#[cfg(feature = "indexer")]
Expand Down Expand Up @@ -145,18 +151,76 @@ impl Aptos {

/// Gets the current ledger information.
///
/// As a side effect, this also resolves the chain ID if it was unknown
/// (e.g., for custom network configurations).
///
/// # Errors
///
/// Returns an error if the HTTP request fails, the API returns an error status code,
/// or the response cannot be parsed.
///
/// # Panics
///
/// Panics if the internal `chain_id` lock is poisoned (only possible if another thread
/// panicked while holding the lock).
pub async fn ledger_info(&self) -> AptosResult<crate::api::response::LedgerInfo> {
let response = self.fullnode.get_ledger_info().await?;
Ok(response.into_inner())
let info = response.into_inner();

// Update chain_id if it was unknown (custom network)
if self.chain_id.read().expect("chain_id lock poisoned").id() == 0 && info.chain_id > 0 {
*self.chain_id.write().expect("chain_id lock poisoned") = ChainId::new(info.chain_id);
}

Ok(info)
}

/// Returns the current chain ID.
///
/// For known networks (mainnet, testnet, devnet, local), this returns the
/// well-known chain ID immediately. For custom networks, this returns
/// `ChainId(0)` until the chain ID is resolved via [`ensure_chain_id`](Self::ensure_chain_id)
/// or any method that makes a request to the node (e.g., [`build_transaction`](Self::build_transaction),
/// [`ledger_info`](Self::ledger_info)).
///
/// # Panics
///
/// Panics if the internal `chain_id` lock is poisoned.
pub fn chain_id(&self) -> ChainId {
self.config.chain_id()
*self.chain_id.read().expect("chain_id lock poisoned")
}

/// Resolves the chain ID from the node if it is unknown.
///
/// For known networks, this returns the chain ID immediately without
/// making a network request. For custom networks (chain ID 0), this
/// fetches the ledger info from the node to discover the actual chain ID
/// and caches it for future use.
///
/// This is called automatically by [`build_transaction`](Self::build_transaction)
/// and other transaction methods, so you typically don't need to call it
/// directly unless you need the chain ID before building a transaction.
///
/// # Errors
///
/// Returns an error if the HTTP request to fetch ledger info fails.
///
/// # Panics
///
/// Panics if the internal `chain_id` lock is poisoned.
pub async fn ensure_chain_id(&self) -> AptosResult<ChainId> {
{
let chain_id = self.chain_id.read().expect("chain_id lock poisoned");
if chain_id.id() > 0 {
return Ok(*chain_id);
}
}
// Chain ID is unknown; fetch from node
let response = self.fullnode.get_ledger_info().await?;
let info = response.into_inner();
let new_chain_id = ChainId::new(info.chain_id);
*self.chain_id.write().expect("chain_id lock poisoned") = new_chain_id;
Ok(new_chain_id)
}

// === Account ===
Expand Down Expand Up @@ -213,20 +277,22 @@ impl Aptos {
sender: &A,
payload: TransactionPayload,
) -> AptosResult<RawTransaction> {
// Fetch sequence number and gas price in parallel - they're independent
let (sequence_number, gas_estimation) = tokio::join!(
// Fetch sequence number, gas price, and chain ID in parallel
let (sequence_number, gas_estimation, chain_id) = tokio::join!(
self.get_sequence_number(sender.address()),
self.fullnode.estimate_gas_price()
self.fullnode.estimate_gas_price(),
self.ensure_chain_id()
);
let sequence_number = sequence_number?;
let gas_estimation = gas_estimation?;
let chain_id = chain_id?;

TransactionBuilder::new()
.sender(sender.address())
.sequence_number(sequence_number)
.payload(payload)
.gas_unit_price(gas_estimation.data.recommended())
.chain_id(self.chain_id())
.chain_id(chain_id)
.expiration_from_now(600)
.build()
}
Expand Down Expand Up @@ -679,7 +745,7 @@ impl Aptos {
/// let results = aptos.batch().submit_and_wait(&sender, payloads, None).await?;
/// ```
pub fn batch(&self) -> crate::transaction::BatchOperations<'_> {
crate::transaction::BatchOperations::new(&self.fullnode, &self.config)
crate::transaction::BatchOperations::new(&self.fullnode, &self.chain_id)
}

/// Submits multiple transactions in parallel.
Expand Down Expand Up @@ -930,6 +996,23 @@ mod tests {
.mount(&server)
.await;

// Mock for ledger info (needed for chain_id resolution on custom networks)
Mock::given(method("GET"))
.and(path("/v1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"chain_id": 4,
"epoch": "1",
"ledger_version": "100",
"oldest_ledger_version": "0",
"ledger_timestamp": "1000000",
"node_role": "full_node",
"oldest_block_height": "0",
"block_height": "50"
})))
.expect(1)
.mount(&server)
.await;

let aptos = create_mock_aptos(&server);
let account = crate::account::Ed25519Account::generate();
let recipient = AccountAddress::from_hex("0x123").unwrap();
Expand Down
37 changes: 28 additions & 9 deletions crates/aptos-sdk/src/transaction/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

use crate::account::Account;
use crate::api::FullnodeClient;
use crate::config::AptosConfig;

use crate::error::{AptosError, AptosResult};
use crate::transaction::{
RawTransaction, SignedTransaction, TransactionBuilder, TransactionPayload,
Expand Down Expand Up @@ -529,18 +529,35 @@ impl BatchSummary {
#[allow(missing_debug_implementations)] // Contains references that may not implement Debug
pub struct BatchOperations<'a> {
client: &'a FullnodeClient,
config: &'a AptosConfig,
chain_id: &'a std::sync::RwLock<ChainId>,
}

impl<'a> BatchOperations<'a> {
/// Creates a new batch operations helper.
pub fn new(client: &'a FullnodeClient, config: &'a AptosConfig) -> Self {
Self { client, config }
pub fn new(client: &'a FullnodeClient, chain_id: &'a std::sync::RwLock<ChainId>) -> Self {
Self { client, chain_id }
}

/// Resolves the chain ID, fetching from the node if unknown.
async fn resolve_chain_id(&self) -> AptosResult<ChainId> {
{
let chain_id = self.chain_id.read().expect("chain_id lock poisoned");
if chain_id.id() > 0 {
return Ok(*chain_id);
}
}
// Chain ID is unknown; fetch from node
let response = self.client.get_ledger_info().await?;
let info = response.into_inner();
let new_chain_id = ChainId::new(info.chain_id);
*self.chain_id.write().expect("chain_id lock poisoned") = new_chain_id;
Ok(new_chain_id)
}

/// Builds a batch of transactions for an account.
///
/// This automatically fetches the current sequence number and gas price.
/// This automatically fetches the current sequence number, gas price,
/// and chain ID (if unknown).
///
/// # Errors
///
Expand All @@ -550,18 +567,20 @@ impl<'a> BatchOperations<'a> {
account: &A,
payloads: Vec<TransactionPayload>,
) -> AptosResult<SignedTransactionBatch> {
// Fetch sequence number and gas price in parallel - they're independent
let (sequence_number, gas_estimation) = tokio::join!(
// Fetch sequence number, gas price, and chain ID in parallel
let (sequence_number, gas_estimation, chain_id) = tokio::join!(
self.client.get_sequence_number(account.address()),
self.client.estimate_gas_price()
self.client.estimate_gas_price(),
self.resolve_chain_id()
);
let sequence_number = sequence_number?;
let gas_estimation = gas_estimation?;
let chain_id = chain_id?;

let batch = TransactionBatchBuilder::new()
.sender(account.address())
.starting_sequence_number(sequence_number)
.chain_id(self.config.chain_id())
.chain_id(chain_id)
.gas_unit_price(gas_estimation.data.recommended())
.add_payloads(payloads)
.build_and_sign(account)?;
Expand Down
Loading
Loading