Skip to content
Open
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
1 change: 1 addition & 0 deletions applications/minotari_console_wallet/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ pub use cli::{
MakeItRainArgs,
SendMinotariArgs,
SetBaseNodeArgs,
SignOneSidedTransactionArgs,
WhoisArgs,
};
use init::{WalletBoot, change_password, init_wallet, start_wallet, tari_splash_screen};
Expand Down
7 changes: 7 additions & 0 deletions integration_tests/src/world.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ pub struct TariWorld {
pub last_imported_tx_ids: Vec<u64>,
// We need to store this for the merge mining proxy steps. The checks are get and check are done on separate steps.
pub last_merge_miner_response: Value,
// Used for offline signing integration test — stores prepared and signed transaction JSON between steps.
pub offline_signing_prepared: Option<String>,
pub offline_signing_signed: Option<String>,
Comment on lines +102 to +104
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Debug implementation for TariWorld (lines 117-138) should be updated to include the new offline_signing_prepared and offline_signing_signed fields. This ensures that the state of offline signing is visible when the world is printed for debugging purposes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this

pub key_manager: KeyManager,
// This will be used for all one-sided coinbase payments
pub wallet_private_key: PrivateKey,
Expand Down Expand Up @@ -131,6 +134,8 @@ impl Debug for TariWorld {
.field("errors", &self.errors)
.field("last_imported_tx_ids", &self.last_imported_tx_ids)
.field("last_merge_miner_response", &self.last_merge_miner_response)
.field("offline_signing_prepared", &self.offline_signing_prepared)
.field("offline_signing_signed", &self.offline_signing_signed)
.finish()
}
}
Expand Down Expand Up @@ -172,6 +177,8 @@ impl TariWorld {
errors: Default::default(),
last_imported_tx_ids: vec![],
last_merge_miner_response: Default::default(),
offline_signing_prepared: None,
offline_signing_signed: None,
key_manager: KeyManager::new_random().unwrap(),
wallet_private_key,
default_payment_address,
Expand Down
34 changes: 34 additions & 0 deletions integration_tests/tests/features/OfflineSigning.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Copyright 2025 The Tari Project
# SPDX-License-Identifier: BSD-3-Clause

@offline-signing @wallet
Feature: Offline One-Sided Transaction Signing

@critical
Scenario: Full offline signing flow via gRPC
# Set up infrastructure
Given I have a seed node NODE
When I have wallet WALLET_SENDER connected to all seed nodes
When I have wallet WALLET_RECEIVER connected to all seed nodes
When I have SHA3X mining node MINER connected to base node NODE and wallet WALLET_SENDER
# Fund the sender wallet
When mining node MINER mines 4 blocks
Then all nodes are at height 4
When I wait for wallet WALLET_SENDER to have at least 1002000 uT
# Export sender keys so we can create a view-only wallet and sign offline
Then I export wallet WALLET_SENDER view and spend keys as SENDER_KEYS
# Create view-only wallet from exported keys
Then I create view wallet VIEW_WALLET from view and spend keys SENDER_KEYS on node NODE
# Wait for view wallet to discover UTXOs via chain scan
When I wait for wallet VIEW_WALLET to have at least 1002000 uT
# Step 1: View-only wallet prepares transaction for offline signing via gRPC
When I prepare an offline one-sided transaction of 100000 uT from wallet VIEW_WALLET to wallet WALLET_RECEIVER at fee 20
# Step 2: Sign the prepared transaction using the full spend wallet via its CLI
Then I sign the prepared transaction using wallet WALLET_SENDER
# Step 3: Broadcast the signed transaction back via the view-only wallet
When I broadcast the signed transaction via wallet VIEW_WALLET
# Mine blocks to confirm
When mining node MINER mines 5 blocks
Then all nodes are at height 9
# Step 4: Verify receiving wallet has confirmed funds
When I wait for wallet WALLET_RECEIVER to have at least 100000 uT
1 change: 1 addition & 0 deletions integration_tests/tests/steps/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ use tari_integration_tests::TariWorld;
pub mod merge_mining_steps;
pub mod mining_steps;
pub mod node_steps;
pub mod offline_signing_steps;
pub mod wallet_cli_steps;
pub mod wallet_ffi_steps;
pub mod wallet_steps;
Expand Down
186 changes: 186 additions & 0 deletions integration_tests/tests/steps/offline_signing_steps.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Copyright 2025. The Tari Project
//
// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
// following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
// disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
// following disclaimer in the documentation and/or other materials provided with the distribution.
//
// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
// products derived from this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

use std::time::Duration;

use cucumber::{then, when};
use minotari_app_grpc::tari_rpc as grpc;
use grpc::{PaymentRecipient, payment_recipient::PaymentType};
use minotari_console_wallet::{CliCommands, SignOneSidedTransactionArgs};
use tari_integration_tests::{
TariWorld,
wallet_process::{create_wallet_client, get_default_cli, spawn_wallet},
};
use tari_transaction_components::transaction_components::memo_field::{MemoField, TxType};

#[when(expr = "I prepare an offline one-sided transaction of {int} uT from wallet {word} to wallet {word} at fee {int}")]
async fn prepare_offline_transaction(
world: &mut TariWorld,
amount: u64,
sender: String,
receiver: String,
fee: u64,
) {
let mut sender_client = create_wallet_client(world, sender.clone()).await.unwrap();
let receiver_wallet_address = world.get_wallet_address(&receiver).await.unwrap();

let payment_id = MemoField::new_open_from_string(
&format!("Offline one-sided {} uT from {} to {}", amount, sender, receiver),
TxType::PaymentToOther,
)
.unwrap();

let recipient = PaymentRecipient {
address: receiver_wallet_address,
amount,
fee_per_gram: fee,
payment_type: PaymentType::OneSidedToStealthAddress as i32,
raw_payment_id: payment_id.to_bytes(),
user_payment_id: None,
};

let request = grpc::PrepareOneSidedTransactionForSigningRequest {
recipient: Some(recipient),
};

let response = sender_client
.prepare_one_sided_transaction_for_signing(request)
.await
.unwrap()
.into_inner();

assert!(
response.is_success,
"PrepareOneSidedTransactionForSigning failed: {}",
response.failure_message
);
assert!(!response.result.is_empty(), "Prepared transaction result is empty");

println!("Prepared offline transaction: {} bytes of JSON", response.result.len());
world.offline_signing_prepared = Some(response.result);
}

#[then(expr = "I sign the prepared transaction using wallet {word}")]
async fn sign_prepared_transaction_using_wallet(world: &mut TariWorld, wallet_name: String) {
let prepared_json = world
.offline_signing_prepared
.as_ref()
.expect("No prepared transaction found — run the prepare step first")
.clone();

// Materialise the prepared transaction as a file under the signing
// wallet's temp dir. The console wallet CLI's `SignOneSidedTransaction`
// subcommand reads the request from `input_file` and writes the signed
// result to `output_file`.
let (input_file, output_file, base_node_name, peer_seeds) = {
let wallet_ps = world
.wallets
.get_mut(&wallet_name)
.unwrap_or_else(|| panic!("Wallet '{wallet_name}' not found"));
let input = wallet_ps.temp_dir_path.join("offline_signing_input.json");
let output = wallet_ps.temp_dir_path.join("offline_signing_output.json");
// Ensure any stale output from a previous run doesn't cause a false-
// positive read below.
let _ = std::fs::remove_file(&output);
std::fs::write(&input, &prepared_json).expect("Failed to write prepared transaction file");

wallet_ps.kill();
(
input,
output,
wallet_ps.base_node_name.clone(),
wallet_ps.peer_seeds.clone(),
)
};

// Give the killed wallet a moment to fully release its db/port lock.
tokio::time::sleep(Duration::from_secs(5)).await;

// Respawn the spend wallet with `SignOneSidedTransaction` as the boot-time
// command so the CLI signs the prepared transaction using the wallet's
// own in-database spend key — exercising the full offline signing cycle
// through the real wallet binary rather than reconstructing a key manager
// from file-exported keys in-process.
let mut cli = get_default_cli();
cli.command2 = Some(CliCommands::SignOneSidedTransaction(SignOneSidedTransactionArgs {
input_file: input_file.clone(),
output_file: output_file.clone(),
}));

spawn_wallet(world, wallet_name.clone(), base_node_name, peer_seeds, None, Some(cli)).await;

// Poll for the signed output file to appear. The CLI writes it once the
// command has finished, which happens after wallet startup + unlock.
let poll_interval = Duration::from_millis(500);
let timeout = Duration::from_secs(60);
let mut waited = Duration::ZERO;
while waited < timeout && !output_file.exists() {
tokio::time::sleep(poll_interval).await;
waited += poll_interval;
}
assert!(
output_file.exists(),
"Signed transaction file never appeared at {output_file:?} within {timeout:?}"
);

let signed_json = std::fs::read_to_string(&output_file)
.unwrap_or_else(|e| panic!("Failed to read signed transaction file: {e}"));
assert!(!signed_json.is_empty(), "Signed transaction file is empty");

println!(
"Transaction signed via wallet CLI: {} bytes of JSON",
signed_json.len()
);
world.offline_signing_signed = Some(signed_json);
}

#[when(expr = "I broadcast the signed transaction via wallet {word}")]
async fn broadcast_signed_transaction(world: &mut TariWorld, wallet_name: String) {
let signed_json = world
.offline_signing_signed
.as_ref()
.expect("No signed transaction found — run the sign step first")
.clone();

let mut client = create_wallet_client(world, wallet_name.clone()).await.unwrap();

let request = grpc::BroadcastSignedOneSidedTransactionRequest {
request: signed_json,
};

let response = client
.broadcast_signed_one_sided_transaction(request)
.await
.unwrap()
.into_inner();

assert!(
response.is_success,
"BroadcastSignedOneSidedTransaction failed: {}",
response.failure_message
);

println!(
"Signed transaction broadcast successfully, tx_id: {}",
response.transaction_id
);
}