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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,32 @@ address, so a transaction built for someone else's key cannot be signed as
answers NEAR requests too, using a local ML-DSA-65 hot wallet, for end-to-end
testing without a device.

#### Using the NEAR commands with a cold wallet

A cold wallet file holds only an SS58 address, which is a hash of the key and
cannot be turned back into the 1952-byte ML-DSA-65 public key NEAR needs.
Import the key once from the QR the cold wallet app shows (account → Show
public key → NEAR); after that every `quantus near` command accepts the cold
wallet, signing over QR where a hot wallet would sign locally.

```bash
# Scan the key export QR (or pass it as text with --key ml-dsa-65:<base58>)
quantus near import-cold-key --wallet my_cold

# Now the same commands as for a hot wallet, no password involved
quantus near show-key --wallet my_cold
quantus near create-account --new-account vault.alice.testnet --wallet my_cold \
--parent-credentials ~/.near-credentials/testnet/alice.testnet.json
quantus near send --wallet my_cold --account vault.alice.testnet --to bob.testnet --amount 1.5
```

The import is refused unless the exported key hashes to the wallet's stored
address, so a QR from another device cannot be attached to `my_cold`. The
key is re-checked against the address every time it is read from the wallet
file. For testing without a device, `quantus developer cold-sign-sim --wallet
<hot65> --export-near-key --response-file export.ur` writes the same QR
payload a device would show.

---

### Sending Tokens
Expand Down
32 changes: 31 additions & 1 deletion src/cli/cold_signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,9 +531,15 @@ pub async fn handle_cold_sign_sim(
wallet: String,
request_file: Option<String>,
response_file: Option<String>,
export_near_key: bool,
password: Option<String>,
password_file: Option<String>,
) -> Result<()> {
if export_near_key {
let export = near_key_export_as_device(&wallet, password, password_file)?;
return write_sim_response(&export.encode(), response_file.as_deref());
}

// 1. Read the request UR (polling the file allows scripted pipelines).
let request_source = match &request_file {
Some(path) => UrSource::File(PathBuf::from(path)),
Expand Down Expand Up @@ -601,6 +607,30 @@ pub async fn handle_cold_sign_sim(
write_sim_response(&response_bytes, response_file.as_deref())
}

/// The simulator's answer to "Show public key": the wallet's ML-DSA-65 key in
/// NEAR text form, tied to its SS58 address, as the cold wallet app exports it.
fn near_key_export_as_device(
wallet: &str,
password: Option<String>,
password_file: Option<String>,
) -> Result<crate::qr::NearPublicKeyExport> {
let keypair = crate::wallet::load_keypair_from_wallet(wallet, password, password_file)?;
if keypair.scheme != crate::wallet::DilithiumScheme::MlDsa65 {
return Err(QuantusError::Generic(format!(
"wallet '{wallet}' is {:?}; only ML-DSA-65 keys are used on NEAR",
keypair.scheme
)));
}
let key = crate::near::protocol::PublicKey::from_ml_dsa_65_bytes(&keypair.public_key)?;
let export = crate::qr::NearPublicKeyExport::new(
keypair.try_to_account_id_ss58check()?,
key.to_near_string(),
)?;
log_print!("🔑 NEAR key export for {}", export.address.bright_cyan());
log_print!(" {}", export.near_public_key);
Ok(export)
}

/// The NEAR half of the simulator, mirroring what the cold wallet app will do
/// with a v2 request: decode the borsh transaction, refuse it unless the
/// transaction's declared key is this wallet's ML-DSA-65 key, and sign the
Expand Down Expand Up @@ -661,7 +691,7 @@ fn write_sim_response(response_bytes: &[u8], response_file: Option<&str>) -> Res
let tmp = format!("{path}.tmp");
std::fs::write(&tmp, parts.join("\n") + "\n")?;
std::fs::rename(&tmp, path)?;
log_print!("📤 Signature response ({} UR parts) written to {}", parts.len(), path);
log_print!("📤 Response ({} UR parts) written to {}", parts.len(), path);
},
None =>
for part in &parts {
Expand Down
7 changes: 7 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,11 @@ pub enum DeveloperCommands {
#[arg(long)]
response_file: Option<String>,

/// Instead of signing a request, emit the wallet's NEAR public key
/// export UR, as the cold wallet app's "Show public key" does
#[arg(long)]
export_near_key: bool,

/// Password for the wallet
#[arg(short, long)]
password: Option<String>,
Expand Down Expand Up @@ -672,13 +677,15 @@ pub async fn handle_developer_command(command: DeveloperCommands) -> crate::erro
wallet,
request_file,
response_file,
export_near_key,
password,
password_file,
} =>
cold_signing::handle_cold_sign_sim(
wallet,
request_file,
response_file,
export_near_key,
password,
password_file,
)
Expand Down
Loading
Loading