From 2fdeb42bbbed9d5dffb66fa15a5006206661b9c6 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 13 Aug 2026 21:01:43 +0700 Subject: [PATCH 1/6] feat(rs-scripts): register funded identities via a ChainLock asset lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a register-identity bin that funds and registers a Platform identity from a Core faucet wallet using a ChainLock asset-lock proof. The InstantSend proof path does not work against rs-dapi: the proof stream is filtered on the one-time asset-lock address, which lives only in the asset-lock special-transaction payload that matches_transaction never inspects, so the lock is never forwarded and the wait times out after the funding tx is already on-chain — burning the funds every run. ChainLock avoids the stream entirely: poll Core until the funding tx is chain-locked, wait for Platform's core-chain-locked height to reach it, then build the proof directly. Key material (the one-time asset-lock WIF and the identity keys) is written to the credentials file before broadcast so a downstream failure stays recoverable; only the identity id is printed to stdout. Verified end-to-end against devnet-moutai (protocol 14): 1 DASH and 25 DASH identities both registered via ChainLock and resolved on-chain. Co-Authored-By: Claude Opus 4.8 --- packages/rs-scripts/Cargo.toml | 2 +- .../rs-scripts/src/bin/register_identity.rs | 554 ++++++++++++++++++ 2 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 packages/rs-scripts/src/bin/register_identity.rs diff --git a/packages/rs-scripts/Cargo.toml b/packages/rs-scripts/Cargo.toml index 9d1a105140..6a1965fbb4 100644 --- a/packages/rs-scripts/Cargo.toml +++ b/packages/rs-scripts/Cargo.toml @@ -27,7 +27,7 @@ platform-version = { path = "../rs-platform-version" } dash-sdk = { path = "../rs-sdk" } rs-dapi-client = { path = "../rs-dapi-client", default-features = false } rs-sdk-trusted-context-provider = { path = "../rs-sdk-trusted-context-provider" } -simple-signer = { path = "../simple-signer" } +simple-signer = { path = "../simple-signer", features = ["state-transitions"] } base64 = "0.22" chrono = "0.4" hex = "0.4" diff --git a/packages/rs-scripts/src/bin/register_identity.rs b/packages/rs-scripts/src/bin/register_identity.rs new file mode 100644 index 0000000000..5add05b474 --- /dev/null +++ b/packages/rs-scripts/src/bin/register_identity.rs @@ -0,0 +1,554 @@ +//! Bootstrap a funded Platform identity from a Dash Core faucet wallet, +//! using a **ChainLock** asset-lock proof. +//! +//! It generates a Platform identity plus a one-time asset-lock key, +//! builds an asset-lock funding transaction from the faucet wallet's +//! UTXOs, has Core sign and broadcast it, obtains a ChainLock-based +//! asset-lock proof, then registers the identity and reports its id. +//! +//! ## This supersedes an earlier InstantSend-based approach — and why that one loses money +//! +//! A previous version of this tool proved the asset lock with an InstantSend +//! lock instead of a ChainLock. **That does not work against rs-dapi and burns +//! the funds on every run.** The InstantSend proof is obtained by subscribing to +//! DAPI's transaction stream filtered on the one-time asset-lock address — but +//! that address exists only inside the asset-lock special-transaction payload, +//! which DAPI's stream matcher (`matches_transaction`) never inspects. So the +//! lock is never forwarded, the wait times out **after the funding transaction +//! is already broadcast and on-chain**, and the DASH is stranded on a one-time +//! key that is then discarded. Do not reintroduce the InstantSend path here; +//! getting this wrong is a silent, unrecoverable loss of funds. The mechanism +//! and the ChainLock alternative are detailed below. +//! +//! # Why ChainLock and not InstantSend +//! +//! An asset lock can be proven to Platform two ways: an InstantSend lock +//! on the funding transaction, or a ChainLock over the block that +//! contains it. This tool uses ChainLock, on purpose. +//! +//! The InstantSend path relies on subscribing to DAPI's transaction +//! stream filtered on the one-time asset-lock address. That address +//! exists only in the asset-lock *special-transaction payload* +//! (`credit_outputs`), and DAPI's stream bloom-matcher +//! (`matches_transaction`) inspects only the transaction's regular +//! inputs, output scripts, and txid — never the special-tx payload. So +//! the InstantSend lock for an asset-lock funding transaction is never +//! forwarded to the subscriber. The wait then blocks until it times out, +//! **after the funding transaction is already on-chain** — the DASH is +//! locked to a one-time key the caller then discards, and it is gone. +//! This is not hypothetical: it is the confirmed failure mode of the +//! older InstantSend-based funding bin against current rs-dapi, and it +//! burns the funds on every run. +//! +//! ChainLock sidesteps the stream entirely: after broadcast we poll Core +//! (`getrawtransaction`) until the funding tx is chain-locked, wait for +//! Platform's `coreChainLockedHeight` to reach that height, and build a +//! `ChainAssetLockProof` directly. On any network that chain-locks +//! promptly (devnets and testnet do, every block) this costs a couple of +//! minutes and cannot silently drop the proof. +//! +//! # Recoverability +//! +//! An asset lock is irreversible once broadcast. So all key material — +//! the one-time asset-lock WIF and the identity's private keys — is +//! written to the `--out-env` file **before** the transaction is +//! broadcast. If any later step fails, the locked funds are still +//! recoverable via the one-time key (retry the identity-create or +//! top-up with the same outpoint). Only the final identity id is ever +//! printed to stdout; key material never touches stdout or the logs. +//! +//! # Endpoints +//! +//! It needs a Platform DAPI endpoint and a Dash Core RPC endpoint whose +//! wallet holds the faucet coins (reachable, e.g. over an SSH tunnel). +//! The SDK is built `with_core` so it fetches quorum public keys for +//! proof verification from that same Core RPC. The Core RPC password is +//! read from `CORE_RPC_PASSWORD` (falling back to `--core-rpc-password`) +//! so it never appears in the process arguments. + +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::process::ExitCode; +use std::time::{Duration, Instant}; + +use clap::Parser; +use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; +use dash_sdk::platform::transition::put_identity::PutIdentity; +use dash_sdk::platform::types::epoch::Epoch; +use dash_sdk::{Sdk, SdkBuilder}; +use dpp::dashcore::consensus::encode::{deserialize, serialize}; +use dpp::dashcore::secp256k1::Secp256k1; +use dpp::dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; +use dpp::dashcore::transaction::special_transaction::TransactionPayload; +use dpp::dashcore::{Address, Network, OutPoint, PrivateKey, ScriptBuf, Transaction, TxIn, TxOut}; +use dpp::dashcore_rpc::{Auth, Client, RpcApi}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::string_encoding::Encoding; +use dpp::prelude::AssetLockProof; +use platform_version::version::PlatformVersion; +use rand::rngs::StdRng; +use rand::SeedableRng; +use rs_dapi_client::{Address as DapiAddress, AddressList}; +use simple_signer::signer::SimpleSigner; + +/// Duffs per DASH. +const DUFFS_PER_DASH: u64 = 100_000_000; +/// Flat L1 fee for the asset-lock transaction (0.001 DASH — generous +/// for a handful of inputs on devnet/testnet). +const ASSET_LOCK_FEE_DUFFS: u64 = 100_000; +/// Minimum change worth returning; below this, fold into the fee. +const DUST_DUFFS: u64 = 10_000; +/// How long to wait for the asset-lock tx to be ChainLocked and for +/// platform to reach that core height before giving up. +const PROOF_TIMEOUT: Duration = Duration::from_secs(300); + +#[derive(Parser, Debug)] +#[command( + name = "register-identity", + about = "Register a funded Platform identity using a ChainLock asset lock funded by a Core faucet wallet." +)] +struct Args { + /// Comma-separated DAPI address(es), e.g. https://1.2.3.4:1443. + #[arg(short = 'a', long = "address")] + address: String, + + /// Network: mainnet | testnet | devnet | regtest. + #[arg(short = 'n', long = "network", default_value = "testnet")] + network: String, + + /// Core RPC host (reachable, e.g. over an SSH tunnel). + #[arg(long = "core-host", default_value = "127.0.0.1")] + core_host: String, + + /// Core RPC port. + #[arg(long = "core-port", default_value = "20002")] + core_port: u16, + + /// Core RPC username. + #[arg(long = "core-rpc-user", default_value = "dashrpc")] + core_rpc_user: String, + + /// Core RPC password. Prefer the CORE_RPC_PASSWORD env var. + #[arg(long = "core-rpc-password")] + core_rpc_password: Option, + + /// Name of the Core wallet holding the faucet coins, e.g. + /// dashd-wallet-1-faucet. + #[arg(long = "faucet-wallet")] + faucet_wallet: String, + + /// Amount of DASH to lock into the new identity's credits. + #[arg(long = "fund-dash", default_value = "25")] + fund_dash: f64, + + /// Number of identity keys to generate (>= 3 gives MASTER + + /// CRITICAL + HIGH authentication keys). + #[arg(long = "key-count", default_value = "3")] + key_count: u32, + + /// Path to the mode-600 credentials file. Key material is appended + /// here (never printed to stdout). + #[arg(long = "out-env")] + out_env: String, +} + +fn parse_network(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "mainnet" | "main" => Ok(Network::Mainnet), + "testnet" | "test" => Ok(Network::Testnet), + "devnet" | "dev" => Ok(Network::Devnet), + "regtest" => Ok(Network::Regtest), + other => Err(format!( + "unknown network '{other}' (expected mainnet | testnet | devnet | regtest)" + )), + } +} + +/// Append `KEY=value` lines to the mode-600 credentials file. Creates +/// the file if absent; never truncates. +fn append_creds(path: &str, lines: &[(String, String)]) -> Result<(), String> { + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .open(path) + .map_err(|e| format!("failed to open creds file {path}: {e}"))?; + for (k, v) in lines { + writeln!(f, "{k}={v}").map_err(|e| format!("failed to write creds file: {e}"))?; + } + Ok(()) +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> ExitCode { + match run().await { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("Error: {e}"); + ExitCode::FAILURE + } + } +} + +async fn run() -> Result<(), String> { + let args = Args::parse(); + let network = parse_network(&args.network)?; + let platform_version = PlatformVersion::latest(); + + if args.key_count < 3 { + return Err("--key-count must be >= 3 (need MASTER + CRITICAL + HIGH keys)".to_string()); + } + + let core_password = std::env::var("CORE_RPC_PASSWORD") + .ok() + .or(args.core_rpc_password.clone()) + .ok_or_else(|| { + "Core RPC password required: set CORE_RPC_PASSWORD or pass --core-rpc-password" + .to_string() + })?; + + let amount_duffs = (args.fund_dash * DUFFS_PER_DASH as f64) as u64; + if amount_duffs == 0 { + return Err("--fund-dash must be > 0".to_string()); + } + + // --- Platform SDK (DAPI + Core for quorum public keys) --- + let addresses = args + .address + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| { + s.parse::() + .map_err(|e| format!("failed to parse address '{s}': {e}")) + }) + .collect::, String>>()?; + let address_list = AddressList::from_iter(addresses); + + // No HTTP quorum endpoint exists for this devnet, so the SDK fetches + // quorum public keys (for proof verification) from Core RPC. + let sdk: Sdk = SdkBuilder::new(address_list) + .with_network(network) + .with_core( + &args.core_host, + args.core_port, + &args.core_rpc_user, + &core_password, + ) + .build() + .map_err(|e| format!("failed to build SDK: {e}"))?; + + // --- Core RPC (faucet wallet) for listunspent / sign / send --- + let wallet_url = format!( + "http://{}:{}/wallet/{}", + args.core_host, args.core_port, args.faucet_wallet + ); + let core = Client::new( + &wallet_url, + Auth::UserPass(args.core_rpc_user.clone(), core_password.clone()), + ) + .map_err(|e| format!("failed to connect to Core RPC: {e}"))?; + + // --- Generate the identity (with private keys) and a one-time + // asset-lock key --- + let mut rng = StdRng::from_entropy(); + let (identity, key_material): (Identity, Vec<(IdentityPublicKey, [u8; 32])>) = + Identity::random_identity_with_main_keys_with_private_key( + args.key_count, + &mut rng, + platform_version, + ) + .map_err(|e| format!("failed to generate identity: {e}"))?; + + let secp = Secp256k1::new(); + let mut secp_rng = dpp::dashcore::secp256k1::rand::thread_rng(); + let one_time_secret = dpp::dashcore::secp256k1::SecretKey::new(&mut secp_rng); + let one_time_private_key = PrivateKey::new(one_time_secret, network); + let one_time_public_key = one_time_private_key.public_key(&secp); + let one_time_key_hash = one_time_public_key.pubkey_hash(); + let one_time_address = Address::p2pkh(&one_time_public_key, network); + + // --- Build the asset-lock funding transaction --- + let tx = build_asset_lock_transaction(&core, amount_duffs, &one_time_key_hash)?; + let unsigned_hex = hex::encode(serialize(&tx)); + + eprintln!( + "Signing asset-lock tx ({} inputs, locking {} DASH) via Core wallet...", + tx.input.len(), + args.fund_dash + ); + let signed = core + .sign_raw_transaction_with_wallet(unsigned_hex.as_str(), None, None) + .map_err(|e| format!("signrawtransactionwithwallet failed: {e}"))?; + if !signed.complete { + return Err( + "Core could not fully sign the asset-lock tx (signrawtransactionwithwallet returned \ + incomplete — is the faucet wallet loaded and are the inputs spendable?)" + .to_string(), + ); + } + let signed_hex = hex::encode(&signed.hex); + let signed_tx: Transaction = + deserialize(&signed.hex).map_err(|e| format!("failed to parse signed tx: {e}"))?; + let txid = signed_tx.txid(); + + // --- Capture recovery credentials BEFORE broadcast --- + // Once the asset lock is on-chain, the locked funds are recoverable + // only via the one-time key + tx outpoint (retry identity-create or + // top-up). Persist everything needed for that before we spend. + let mut pre_creds: Vec<(String, String)> = vec![ + ("ASSET_LOCK_TXID".to_string(), txid.to_string()), + ( + "ASSET_LOCK_ONE_TIME_WIF".to_string(), + one_time_private_key.to_wif(), + ), + ( + "ASSET_LOCK_ONE_TIME_ADDRESS".to_string(), + one_time_address.to_string(), + ), + ( + "ASSET_LOCK_FUND_DASH".to_string(), + args.fund_dash.to_string(), + ), + ]; + for (pk, secret) in &key_material { + let wif = PrivateKey::from_byte_array(secret, network) + .map(|k| k.to_wif()) + .map_err(|e| format!("failed to encode identity key {} as WIF: {e}", pk.id()))?; + pre_creds.push((format!("IDENTITY_KEY_{}_WIF", pk.id()), wif)); + } + if let Some((critical_key, critical_secret)) = find_critical_auth_key(&identity, &key_material) + { + let wif = PrivateKey::from_byte_array(&critical_secret, network) + .map(|k| k.to_wif()) + .map_err(|e| format!("failed to encode critical key as WIF: {e}"))?; + pre_creds.push(( + "IDENTITY_CRITICAL_AUTH_KEY_ID".to_string(), + critical_key.id().to_string(), + )); + pre_creds.push(("IDENTITY_CRITICAL_AUTH_KEY_WIF".to_string(), wif)); + } + append_creds(&args.out_env, &pre_creds)?; + eprintln!( + "Recovery credentials written to {} before broadcast.", + args.out_env + ); + + // --- Broadcast --- + let broadcast_txid = core + .send_raw_transaction(signed_hex.as_str()) + .map_err(|e| format!("sendrawtransaction failed: {e}"))?; + if broadcast_txid != txid { + return Err(format!( + "broadcast txid {broadcast_txid} does not match signed txid {txid}" + )); + } + eprintln!("Broadcast asset-lock tx {txid}; waiting for ChainLock proof..."); + + // --- ChainLock asset-lock proof (no DAPI stream) --- + let started = Instant::now(); + // 1. Wait for Core to report the tx as ChainLocked and give its height. + let core_chain_locked_height: u32 = loop { + if started.elapsed() > PROOF_TIMEOUT { + return Err( + "timed out waiting for the asset-lock tx to be ChainLocked (the ChainLock proof \ + path did not complete — do not retry on this path)" + .to_string(), + ); + } + let info = core + .get_raw_transaction_info(&txid, None) + .map_err(|e| format!("getrawtransaction failed: {e}"))?; + if info.chainlock { + let h = info + .height + .ok_or("ChainLocked tx has no height in getrawtransaction result")?; + break u32::try_from(h).map_err(|_| format!("invalid tx height {h}"))?; + } + eprintln!("asset-lock tx not yet ChainLocked; retrying in 2s..."); + tokio::time::sleep(Duration::from_secs(2)).await; + }; + eprintln!( + "asset-lock tx ChainLocked at core height {core_chain_locked_height}; waiting for platform to reach it..." + ); + + // 2. Wait until platform's core-chain-locked height has caught up, so + // drive will accept the proof. + loop { + if started.elapsed() > PROOF_TIMEOUT { + return Err( + "timed out waiting for platform to reach the ChainLocked core height (do not retry \ + on this path)" + .to_string(), + ); + } + let (_epoch, metadata) = Epoch::fetch_current_with_metadata(&sdk) + .await + .map_err(|e| format!("failed to fetch platform metadata: {e}"))?; + if metadata.core_chain_locked_height >= core_chain_locked_height { + break; + } + eprintln!( + "platform core-chain-locked height {} < {}; retrying in 2s...", + metadata.core_chain_locked_height, core_chain_locked_height + ); + tokio::time::sleep(Duration::from_secs(2)).await; + } + + let asset_lock_proof = AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height, + out_point: OutPoint { txid, vout: 0 }, + }); + let proof_elapsed = started.elapsed(); + eprintln!( + "Got ChainLock asset-lock proof in {:.1}s. Registering identity...", + proof_elapsed.as_secs_f64() + ); + + // --- Register the identity --- + let mut signer = SimpleSigner::default(); + signer.add_identity_public_keys(key_material.iter().cloned()); + + let reg_started = Instant::now(); + let registered = identity + .put_to_platform_and_wait_for_response_with_private_key( + &sdk, + asset_lock_proof, + &one_time_private_key, + &signer, + None, + ) + .await + .map_err(|e| format!("failed to register identity: {e}"))?; + let reg_elapsed = reg_started.elapsed(); + + let identity_id = registered.id(); + let identity_id_b58 = identity_id.to_string(Encoding::Base58); + let balance = registered.balance(); + let total_elapsed = started.elapsed(); + + // Persist final identity facts (key material already written pre-broadcast). + append_creds( + &args.out_env, + &[ + ("IDENTITY_ID".to_string(), identity_id_b58.clone()), + ("IDENTITY_BALANCE_CREDITS".to_string(), balance.to_string()), + ( + "IDENTITY_ASSET_LOCK_PROOF_TYPE".to_string(), + "ChainLock".to_string(), + ), + ( + "IDENTITY_REGISTER_ELAPSED_SEC".to_string(), + format!("{:.1}", total_elapsed.as_secs_f64()), + ), + ], + )?; + + eprintln!( + "REGISTERED id={identity_id_b58} proof=ChainLock credits={balance} \ + proof_wait={:.1}s register={:.1}s total={:.1}s", + proof_elapsed.as_secs_f64(), + reg_elapsed.as_secs_f64(), + total_elapsed.as_secs_f64() + ); + // stdout: identity id ONLY (no key material, ever). + println!("{identity_id_b58}"); + + Ok(()) +} + +/// Build an unsigned asset-lock funding transaction: select faucet +/// UTXOs to cover `amount_duffs` + fee, burn `amount_duffs` via an +/// OP_RETURN output, return change, and credit `amount_duffs` to the +/// one-time key hash in the asset-lock payload. +fn build_asset_lock_transaction( + core: &Client, + amount_duffs: u64, + one_time_key_hash: &dpp::dashcore::PubkeyHash, +) -> Result { + let target = amount_duffs + .checked_add(ASSET_LOCK_FEE_DUFFS) + .ok_or("amount overflow")?; + + let mut utxos = core + .list_unspent(Some(1), None, None, None, None) + .map_err(|e| format!("listunspent failed: {e}"))?; + // Largest first, so we cover the target with as few inputs as possible. + utxos.sort_by(|a, b| b.amount.to_sat().cmp(&a.amount.to_sat())); + + let mut inputs = Vec::new(); + let mut change_script: Option = None; + let mut selected: u64 = 0; + for entry in utxos { + if change_script.is_none() { + change_script = Some(entry.script_pub_key.clone()); + } + inputs.push(TxIn { + previous_output: OutPoint::new(entry.txid, entry.vout), + script_sig: ScriptBuf::new(), + sequence: 0xFFFF_FFFF, + witness: Default::default(), + }); + selected = selected.saturating_add(entry.amount.to_sat()); + if selected >= target { + break; + } + } + if selected < target { + return Err(format!( + "faucet wallet has insufficient spendable funds: need {target} duffs, have {selected}" + )); + } + let change_script = change_script.expect("at least one input selected"); + + // Regular outputs: burn (locks the value on L1) + change. + let mut outputs = vec![TxOut { + value: amount_duffs, + script_pubkey: ScriptBuf::new_op_return(&[]), + }]; + let change = selected - target; + if change > DUST_DUFFS { + outputs.push(TxOut { + value: change, + script_pubkey: change_script, + }); + } + + // Asset-lock payload: credit the burned value to the one-time key. + let payload = TransactionPayload::AssetLockPayloadType(AssetLockPayload { + version: AssetLockPayload::CURRENT_VERSION, + credit_outputs: vec![TxOut { + value: amount_duffs, + script_pubkey: ScriptBuf::new_p2pkh(one_time_key_hash), + }], + }); + + Ok(Transaction { + version: 3, + lock_time: 0, + input: inputs, + output: outputs, + special_transaction_payload: Some(payload), + }) +} + +/// Find the identity's CRITICAL AUTHENTICATION ECDSA key and its +/// matching private key from the generated key material. +fn find_critical_auth_key( + identity: &Identity, + key_material: &[(IdentityPublicKey, [u8; 32])], +) -> Option<(IdentityPublicKey, [u8; 32])> { + let public_key = identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::CRITICAL].into_iter().collect(), + [KeyType::ECDSA_SECP256K1].into_iter().collect(), + false, + )?; + key_material + .iter() + .find(|(pk, _)| pk.id() == public_key.id()) + .map(|(pk, secret)| (pk.clone(), *secret)) +} From e8d2b14b5f53b1861203df660a471f45b9db3c8f Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 14 Aug 2026 01:32:28 +0700 Subject: [PATCH 2/6] feat(rs-scripts): add resume mode and make the proof timeout non-stranding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slow platform ChainLock ingestion can exceed the proof wait and leave the tool erroring while the funds sit locked in a broadcast, chainlocked asset lock — the same stranded-funds failure class as the InstantSend bug this bin replaces. Three changes: - Raise PROOF_TIMEOUT to 600s so the wait outlasts a normal ingestion lag. - On timeout, print a resume recipe instead of a bare error: the asset lock is on-chain, the one-time WIF is saved at the --out-env path, and the exact --resume-txid command finishes registration against the existing lock, spending nothing new. - Add --resume-txid: reuse an already-broadcast asset lock (one-time WIF read from --out-env, identity keys generated fresh since the id derives from the asset-lock outpoint) and register the identity against it. Verified on devnet-moutai: recovered a real stranded 25-DASH lock into a ~2.5T-credit identity via --resume-txid, no new funds spent. Co-Authored-By: Claude Opus 4.8 --- .../rs-scripts/src/bin/register_identity.rs | 253 ++++++++++++------ 1 file changed, 167 insertions(+), 86 deletions(-) diff --git a/packages/rs-scripts/src/bin/register_identity.rs b/packages/rs-scripts/src/bin/register_identity.rs index 5add05b474..478925ab36 100644 --- a/packages/rs-scripts/src/bin/register_identity.rs +++ b/packages/rs-scripts/src/bin/register_identity.rs @@ -53,10 +53,19 @@ //! the one-time asset-lock WIF and the identity's private keys — is //! written to the `--out-env` file **before** the transaction is //! broadcast. If any later step fails, the locked funds are still -//! recoverable via the one-time key (retry the identity-create or -//! top-up with the same outpoint). Only the final identity id is ever +//! recoverable via the one-time key. Only the final identity id is ever //! printed to stdout; key material never touches stdout or the logs. //! +//! The ChainLock wait can legitimately take minutes (platform ingests a +//! fresh core ChainLock with some lag), so the proof timeout is generous. +//! If it is nonetheless exceeded, the tool does NOT strand the funds: it +//! reports that the asset lock is broadcast and on-chain, points at the +//! saved one-time WIF, and prints the exact command to finish the +//! registration against the existing lock — re-run with `--resume-txid +//! `, which reuses the lock and spends nothing new. Re-running +//! without `--resume-txid` would broadcast a second asset lock and strand +//! the first, so the resume path exists precisely to avoid that. +//! //! # Endpoints //! //! It needs a Platform DAPI endpoint and a Dash Core RPC endpoint whose @@ -69,6 +78,7 @@ use std::io::Write; use std::os::unix::fs::OpenOptionsExt; use std::process::ExitCode; +use std::str::FromStr; use std::time::{Duration, Instant}; use clap::Parser; @@ -80,7 +90,9 @@ use dpp::dashcore::consensus::encode::{deserialize, serialize}; use dpp::dashcore::secp256k1::Secp256k1; use dpp::dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; use dpp::dashcore::transaction::special_transaction::TransactionPayload; -use dpp::dashcore::{Address, Network, OutPoint, PrivateKey, ScriptBuf, Transaction, TxIn, TxOut}; +use dpp::dashcore::{ + Address, Network, OutPoint, PrivateKey, ScriptBuf, Transaction, TxIn, TxOut, Txid, +}; use dpp::dashcore_rpc::{Auth, Client, RpcApi}; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -102,8 +114,12 @@ const ASSET_LOCK_FEE_DUFFS: u64 = 100_000; /// Minimum change worth returning; below this, fold into the fee. const DUST_DUFFS: u64 = 10_000; /// How long to wait for the asset-lock tx to be ChainLocked and for -/// platform to reach that core height before giving up. -const PROOF_TIMEOUT: Duration = Duration::from_secs(300); +/// platform to reach that core height before giving up. Generous, because +/// platform's ingestion of a fresh core ChainLock can lag by minutes; a +/// short budget here risks the tool giving up on a lock that is about to +/// become usable. On timeout the tool still prints a resume recipe rather +/// than stranding the funds (see the timeout handling in `run`). +const PROOF_TIMEOUT: Duration = Duration::from_secs(600); #[derive(Parser, Debug)] #[command( @@ -153,6 +169,14 @@ struct Args { /// here (never printed to stdout). #[arg(long = "out-env")] out_env: String, + + /// Resume a previously-broadcast asset lock instead of creating a new + /// one. Pass the asset-lock txid; the one-time WIF is read from the + /// `--out-env` file (`ASSET_LOCK_ONE_TIME_WIF`). Registers the identity + /// against the existing chainlocked asset lock — NO new funds are spent. + /// Use this to recover after a proof-wait timeout stranded a lock. + #[arg(long = "resume-txid")] + resume_txid: Option, } fn parse_network(s: &str) -> Result { @@ -182,6 +206,34 @@ fn append_creds(path: &str, lines: &[(String, String)]) -> Result<(), String> { Ok(()) } +/// Read the last value for `key` from a `KEY=value` credentials file. +/// Last-wins, matching how the file is sourced by a shell. +fn read_creds_value(path: &str, key: &str) -> Result, String> { + let contents = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read creds file {path}: {e}"))?; + let prefix = format!("{key}="); + Ok(contents + .lines() + .filter_map(|l| l.strip_prefix(&prefix)) + .next_back() + .map(str::to_string)) +} + +/// Message for a proof-wait timeout that makes the failure NON-stranding: +/// the asset lock is already broadcast and on-chain, so the funds are not +/// lost — they are pending. It names the recoverable one-time key and the +/// exact command to resume without spending new funds. +fn strand_safe_timeout(txid: &Txid, out_env: &str, waiting_for: &str) -> String { + format!( + "timed out waiting for {waiting_for}.\n\ + The asset lock IS broadcast and on-chain — the funds are NOT lost, only pending.\n\ + The one-time key that owns it is saved in {out_env} (ASSET_LOCK_ONE_TIME_WIF).\n\ + Resume WITHOUT spending new funds by re-running the same command plus:\n\ + \x20 --resume-txid {txid}\n\ + Do NOT re-run without --resume-txid — that broadcasts a second asset lock and strands this one." + ) +} + #[tokio::main(flavor = "multi_thread")] async fn main() -> ExitCode { match run().await { @@ -252,8 +304,10 @@ async fn run() -> Result<(), String> { ) .map_err(|e| format!("failed to connect to Core RPC: {e}"))?; - // --- Generate the identity (with private keys) and a one-time - // asset-lock key --- + // --- Generate the identity keys --- + // The identity id derives from the asset-lock outpoint, not from these + // keys, so the key set is generated the same way in both the fresh and + // resume paths. let mut rng = StdRng::from_entropy(); let (identity, key_material): (Identity, Vec<(IdentityPublicKey, [u8; 32])>) = Identity::random_identity_with_main_keys_with_private_key( @@ -263,101 +317,128 @@ async fn run() -> Result<(), String> { ) .map_err(|e| format!("failed to generate identity: {e}"))?; - let secp = Secp256k1::new(); - let mut secp_rng = dpp::dashcore::secp256k1::rand::thread_rng(); - let one_time_secret = dpp::dashcore::secp256k1::SecretKey::new(&mut secp_rng); - let one_time_private_key = PrivateKey::new(one_time_secret, network); - let one_time_public_key = one_time_private_key.public_key(&secp); - let one_time_key_hash = one_time_public_key.pubkey_hash(); - let one_time_address = Address::p2pkh(&one_time_public_key, network); - - // --- Build the asset-lock funding transaction --- - let tx = build_asset_lock_transaction(&core, amount_duffs, &one_time_key_hash)?; - let unsigned_hex = hex::encode(serialize(&tx)); - - eprintln!( - "Signing asset-lock tx ({} inputs, locking {} DASH) via Core wallet...", - tx.input.len(), - args.fund_dash - ); - let signed = core - .sign_raw_transaction_with_wallet(unsigned_hex.as_str(), None, None) - .map_err(|e| format!("signrawtransactionwithwallet failed: {e}"))?; - if !signed.complete { - return Err( - "Core could not fully sign the asset-lock tx (signrawtransactionwithwallet returned \ - incomplete — is the faucet wallet loaded and are the inputs spendable?)" - .to_string(), - ); - } - let signed_hex = hex::encode(&signed.hex); - let signed_tx: Transaction = - deserialize(&signed.hex).map_err(|e| format!("failed to parse signed tx: {e}"))?; - let txid = signed_tx.txid(); - - // --- Capture recovery credentials BEFORE broadcast --- - // Once the asset lock is on-chain, the locked funds are recoverable - // only via the one-time key + tx outpoint (retry identity-create or - // top-up). Persist everything needed for that before we spend. - let mut pre_creds: Vec<(String, String)> = vec![ - ("ASSET_LOCK_TXID".to_string(), txid.to_string()), - ( - "ASSET_LOCK_ONE_TIME_WIF".to_string(), - one_time_private_key.to_wif(), - ), - ( - "ASSET_LOCK_ONE_TIME_ADDRESS".to_string(), - one_time_address.to_string(), - ), - ( - "ASSET_LOCK_FUND_DASH".to_string(), - args.fund_dash.to_string(), - ), - ]; + // Encode the identity key material for the credentials file. + let mut identity_creds: Vec<(String, String)> = Vec::new(); for (pk, secret) in &key_material { let wif = PrivateKey::from_byte_array(secret, network) .map(|k| k.to_wif()) .map_err(|e| format!("failed to encode identity key {} as WIF: {e}", pk.id()))?; - pre_creds.push((format!("IDENTITY_KEY_{}_WIF", pk.id()), wif)); + identity_creds.push((format!("IDENTITY_KEY_{}_WIF", pk.id()), wif)); } if let Some((critical_key, critical_secret)) = find_critical_auth_key(&identity, &key_material) { let wif = PrivateKey::from_byte_array(&critical_secret, network) .map(|k| k.to_wif()) .map_err(|e| format!("failed to encode critical key as WIF: {e}"))?; - pre_creds.push(( + identity_creds.push(( "IDENTITY_CRITICAL_AUTH_KEY_ID".to_string(), critical_key.id().to_string(), )); - pre_creds.push(("IDENTITY_CRITICAL_AUTH_KEY_WIF".to_string(), wif)); + identity_creds.push(("IDENTITY_CRITICAL_AUTH_KEY_WIF".to_string(), wif)); } - append_creds(&args.out_env, &pre_creds)?; - eprintln!( - "Recovery credentials written to {} before broadcast.", - args.out_env - ); - // --- Broadcast --- - let broadcast_txid = core - .send_raw_transaction(signed_hex.as_str()) - .map_err(|e| format!("sendrawtransaction failed: {e}"))?; - if broadcast_txid != txid { - return Err(format!( - "broadcast txid {broadcast_txid} does not match signed txid {txid}" - )); - } - eprintln!("Broadcast asset-lock tx {txid}; waiting for ChainLock proof..."); + // --- Obtain the asset lock: resume an existing one, or create+broadcast --- + let (txid, one_time_private_key): (Txid, PrivateKey) = if let Some(resume) = &args.resume_txid { + // RESUME: reuse an already-broadcast asset lock — NO new funds spent. + // The one-time key that owns the lock is read from the credentials + // file written before the original broadcast; the freshly generated + // identity keys are recorded now so the resumed identity is + // recoverable too. + let txid = + Txid::from_str(resume.trim()).map_err(|e| format!("invalid --resume-txid: {e}"))?; + let wif = read_creds_value(&args.out_env, "ASSET_LOCK_ONE_TIME_WIF")?.ok_or_else(|| { + format!( + "resume needs ASSET_LOCK_ONE_TIME_WIF in {} (the one-time key that owns the asset lock)", + args.out_env + ) + })?; + let one_time_private_key = PrivateKey::from_wif(wif.trim()) + .map_err(|e| format!("failed to parse ASSET_LOCK_ONE_TIME_WIF: {e}"))?; + append_creds(&args.out_env, &identity_creds)?; + eprintln!( + "Resuming existing asset-lock tx {txid} (no new broadcast; no new funds spent)..." + ); + (txid, one_time_private_key) + } else { + // FRESH: generate a one-time key, build + Core-sign + broadcast the + // asset lock, capturing all recovery credentials BEFORE broadcast. + let secp = Secp256k1::new(); + let mut secp_rng = dpp::dashcore::secp256k1::rand::thread_rng(); + let one_time_secret = dpp::dashcore::secp256k1::SecretKey::new(&mut secp_rng); + let one_time_private_key = PrivateKey::new(one_time_secret, network); + let one_time_public_key = one_time_private_key.public_key(&secp); + let one_time_key_hash = one_time_public_key.pubkey_hash(); + let one_time_address = Address::p2pkh(&one_time_public_key, network); + + let tx = build_asset_lock_transaction(&core, amount_duffs, &one_time_key_hash)?; + let unsigned_hex = hex::encode(serialize(&tx)); + + eprintln!( + "Signing asset-lock tx ({} inputs, locking {} DASH) via Core wallet...", + tx.input.len(), + args.fund_dash + ); + let signed = core + .sign_raw_transaction_with_wallet(unsigned_hex.as_str(), None, None) + .map_err(|e| format!("signrawtransactionwithwallet failed: {e}"))?; + if !signed.complete { + return Err( + "Core could not fully sign the asset-lock tx (signrawtransactionwithwallet \ + returned incomplete — is the faucet wallet loaded and are the inputs spendable?)" + .to_string(), + ); + } + let signed_hex = hex::encode(&signed.hex); + let signed_tx: Transaction = + deserialize(&signed.hex).map_err(|e| format!("failed to parse signed tx: {e}"))?; + let txid = signed_tx.txid(); + + // Capture recovery credentials BEFORE broadcast: once on-chain, the + // locked funds are recoverable only via the one-time key + outpoint. + let mut pre_creds: Vec<(String, String)> = vec![ + ("ASSET_LOCK_TXID".to_string(), txid.to_string()), + ( + "ASSET_LOCK_ONE_TIME_WIF".to_string(), + one_time_private_key.to_wif(), + ), + ( + "ASSET_LOCK_ONE_TIME_ADDRESS".to_string(), + one_time_address.to_string(), + ), + ( + "ASSET_LOCK_FUND_DASH".to_string(), + args.fund_dash.to_string(), + ), + ]; + pre_creds.extend(identity_creds.iter().cloned()); + append_creds(&args.out_env, &pre_creds)?; + eprintln!( + "Recovery credentials written to {} before broadcast.", + args.out_env + ); + + let broadcast_txid = core + .send_raw_transaction(signed_hex.as_str()) + .map_err(|e| format!("sendrawtransaction failed: {e}"))?; + if broadcast_txid != txid { + return Err(format!( + "broadcast txid {broadcast_txid} does not match signed txid {txid}" + )); + } + eprintln!("Broadcast asset-lock tx {txid}; waiting for ChainLock proof..."); + (txid, one_time_private_key) + }; // --- ChainLock asset-lock proof (no DAPI stream) --- let started = Instant::now(); // 1. Wait for Core to report the tx as ChainLocked and give its height. let core_chain_locked_height: u32 = loop { if started.elapsed() > PROOF_TIMEOUT { - return Err( - "timed out waiting for the asset-lock tx to be ChainLocked (the ChainLock proof \ - path did not complete — do not retry on this path)" - .to_string(), - ); + return Err(strand_safe_timeout( + &txid, + &args.out_env, + "the asset-lock tx to be ChainLocked", + )); } let info = core .get_raw_transaction_info(&txid, None) @@ -379,11 +460,11 @@ async fn run() -> Result<(), String> { // drive will accept the proof. loop { if started.elapsed() > PROOF_TIMEOUT { - return Err( - "timed out waiting for platform to reach the ChainLocked core height (do not retry \ - on this path)" - .to_string(), - ); + return Err(strand_safe_timeout( + &txid, + &args.out_env, + "platform to reach the ChainLocked core height", + )); } let (_epoch, metadata) = Epoch::fetch_current_with_metadata(&sdk) .await From 14c9edaa18e2850deb3d62cbaaf78c06250bc549 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 14 Aug 2026 12:22:18 +0700 Subject: [PATCH 3/6] test(rs-scripts): unit tests for register_identity helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the pure, network-independent pieces: network parsing; the credentials last-value-wins roundtrip and mode-600 creation (so a resumed identity uses the live key, not a stale one); the non-stranding timeout message (names the txid, creds path, and --resume-txid); and CRITICAL authentication key selection returning the matching private-key material. The network-dependent core (asset-lock build/sign/broadcast, ChainLock proof, identity registration) is validated end-to-end against devnet-moutai rather than by unit tests — it requires a live node and faucet. Co-Authored-By: Claude Opus 4.8 --- .../rs-scripts/src/bin/register_identity.rs | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/packages/rs-scripts/src/bin/register_identity.rs b/packages/rs-scripts/src/bin/register_identity.rs index 478925ab36..8e9419f900 100644 --- a/packages/rs-scripts/src/bin/register_identity.rs +++ b/packages/rs-scripts/src/bin/register_identity.rs @@ -633,3 +633,93 @@ fn find_critical_auth_key( .find(|(pk, _)| pk.id() == public_key.id()) .map(|(pk, secret)| (pk.clone(), *secret)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_network_accepts_known_and_rejects_unknown() { + assert!(matches!(parse_network("devnet"), Ok(Network::Devnet))); + assert!(matches!(parse_network("DevNet"), Ok(Network::Devnet))); + assert!(matches!(parse_network("testnet"), Ok(Network::Testnet))); + assert!(matches!(parse_network("mainnet"), Ok(Network::Mainnet))); + assert!(matches!(parse_network("regtest"), Ok(Network::Regtest))); + assert!(parse_network("bogus").is_err()); + } + + #[test] + fn creds_roundtrip_returns_last_value_and_file_is_mode_600() { + // A resume run appends fresh identity keys after an earlier attempt's, + // so read_creds_value MUST return the LAST value — otherwise the caller + // would sign with a stale key that does not match the live identity. + use std::os::unix::fs::PermissionsExt; + let path = + std::env::temp_dir().join(format!("register_identity_test_{}.env", std::process::id())); + let p = path.to_str().unwrap(); + let _ = std::fs::remove_file(p); + + append_creds(p, &[("K".to_string(), "first".to_string())]).unwrap(); + append_creds( + p, + &[ + ("OTHER".to_string(), "x".to_string()), + ("K".to_string(), "second".to_string()), + ], + ) + .unwrap(); + + assert_eq!(read_creds_value(p, "K").unwrap().as_deref(), Some("second")); + assert_eq!(read_creds_value(p, "OTHER").unwrap().as_deref(), Some("x")); + assert_eq!(read_creds_value(p, "MISSING").unwrap(), None); + + let mode = std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "credentials file must be created mode 600"); + let _ = std::fs::remove_file(p); + } + + #[test] + fn strand_message_is_non_stranding_and_actionable() { + let txid = + Txid::from_str("c913da3655688c10c79e0d8b8e059c94625b939cfa99e848f0f24dc48ec4f685") + .unwrap(); + let msg = strand_safe_timeout(&txid, "/tmp/creds.env", "the tx to be ChainLocked"); + // Must name the txid, the creds path, and the exact resume flag, and must + // reassure the funds are not lost — that is the whole point of the change. + assert!(msg.contains("c913da3655688c10c79e0d8b8e059c94625b939cfa99e848f0f24dc48ec4f685")); + assert!(msg.contains("/tmp/creds.env")); + assert!(msg.contains("--resume-txid")); + assert!(msg.to_lowercase().contains("not lost")); + } + + #[test] + fn finds_the_critical_authentication_signing_key_and_its_secret() { + // The whole tool is useless if it cannot hand back a CRITICAL-level + // authentication key (the one that can sign both contracts and + // documents) together with the private key that matches it. + let platform_version = PlatformVersion::latest(); + let mut rng = StdRng::seed_from_u64(1); + let (identity, key_material): (Identity, Vec<(IdentityPublicKey, [u8; 32])>) = + Identity::random_identity_with_main_keys_with_private_key( + 3, + &mut rng, + platform_version, + ) + .expect("generate identity"); + + let (pk, secret) = find_critical_auth_key(&identity, &key_material) + .expect("a 3-key identity must expose a CRITICAL AUTHENTICATION ECDSA key"); + + assert_eq!(pk.purpose(), Purpose::AUTHENTICATION); + assert_eq!(pk.security_level(), SecurityLevel::CRITICAL); + assert_eq!(pk.key_type(), KeyType::ECDSA_SECP256K1); + + // The returned secret is really THIS key's material, not some other key's. + let expected = key_material + .iter() + .find(|(k, _)| k.id() == pk.id()) + .map(|(_, s)| *s) + .unwrap(); + assert_eq!(secret, expected); + } +} From 93ee7c6e2311e66181597aca8eac29894a58a152 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 14 Aug 2026 13:31:04 +0700 Subject: [PATCH 4/6] fix(rs-scripts): harden register_identity preconditions and secret handling Work the blocking and suggested review threads on the ChainLock funding bin; several are the same fund-stranding class as the islock bug it replaces: - Enforce mode 600 on the credentials file even when it already exists. - Reject funding below the versioned identity-create minimum before locking (asset-lock floor + base + per-key create fee), so a too-small lock is never broadcast and then stranded. - Reject a funding tx above max_asset_lock_transaction_inputs before signing (an over-input lock's proof is unusable on Platform). - Bound --key-count by max_public_keys_in_creation before any funding work. - Scope recovery credentials to the asset-lock txid; resume reads the txid-scoped one-time WIF and cannot pair the requested txid with a different lock's key. Identity keys are written only on a key-matching success, so the file never advertises keys that do not control the registered identity (handles the AlreadyExists fetch case). - Treat an empty CORE_RPC_PASSWORD as unset. - Retry transient Core/DAPI polling errors after broadcast until the timeout. - Make the proof-timeout message reflect ChainLock state, and surface a resume recipe on an ambiguous broadcast error. Unit tests added for the pure logic; network core validated e2e on devnet-moutai. Co-Authored-By: Claude Opus 4.8 --- .../rs-scripts/src/bin/register_identity.rs | 317 ++++++++++++++---- 1 file changed, 259 insertions(+), 58 deletions(-) diff --git a/packages/rs-scripts/src/bin/register_identity.rs b/packages/rs-scripts/src/bin/register_identity.rs index 8e9419f900..6052d5cfbe 100644 --- a/packages/rs-scripts/src/bin/register_identity.rs +++ b/packages/rs-scripts/src/bin/register_identity.rs @@ -86,6 +86,7 @@ use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::types::epoch::Epoch; use dash_sdk::{Sdk, SdkBuilder}; +use dpp::balances::credits::CREDITS_PER_DUFF; use dpp::dashcore::consensus::encode::{deserialize, serialize}; use dpp::dashcore::secp256k1::Secp256k1; use dpp::dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; @@ -194,12 +195,19 @@ fn parse_network(s: &str) -> Result { /// Append `KEY=value` lines to the mode-600 credentials file. Creates /// the file if absent; never truncates. fn append_creds(path: &str, lines: &[(String, String)]) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; let mut f = std::fs::OpenOptions::new() .create(true) .append(true) .mode(0o600) .open(path) .map_err(|e| format!("failed to open creds file {path}: {e}"))?; + // `.mode(0o600)` only applies to a freshly created inode. If the file + // already existed with looser permissions, tighten it to 0600 BEFORE + // writing any secret, so a pre-existing world-readable file cannot + // silently keep exposing the keys we are about to append. + f.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("failed to enforce mode 600 on {path}: {e}"))?; for (k, v) in lines { writeln!(f, "{k}={v}").map_err(|e| format!("failed to write creds file: {e}"))?; } @@ -219,15 +227,60 @@ fn read_creds_value(path: &str, key: &str) -> Result, String> { .map(str::to_string)) } -/// Message for a proof-wait timeout that makes the failure NON-stranding: -/// the asset lock is already broadcast and on-chain, so the funds are not -/// lost — they are pending. It names the recoverable one-time key and the -/// exact command to resume without spending new funds. -fn strand_safe_timeout(txid: &Txid, out_env: &str, waiting_for: &str) -> String { +/// Minimum asset-lock funding, in duffs, that an identity-create with +/// `key_count` keys needs to clear its required-balance validation. Mirrors +/// `IdentityCreateTransition::calculate_min_required_fee` (versioned): the +/// asset-lock processing-start floor plus, on fee-calc v1+, the base +/// identity-create cost and the per-key creation cost. Locking below this +/// broadcasts and mines a transaction whose identity-create then fails — +/// stranding the funds, the exact failure this tool exists to avoid. +fn min_asset_lock_duffs(key_count: u32, pv: &PlatformVersion) -> u64 { + let identities = &pv.dpp.state_transitions.identities; + let floor_credits = identities + .asset_locks + .required_asset_lock_duff_balance_for_processing_start_for_identity_create + .saturating_mul(CREDITS_PER_DUFF); + let required_credits = match identities.calculate_min_required_fee_on_identity_create_transition + { + 0 => floor_credits, + _ => { + let min_fees = &pv.fee_version.state_transition_min_fees; + min_fees.identity_create_base_cost.saturating_add( + floor_credits.saturating_add( + min_fees + .identity_key_in_creation_cost + .saturating_mul(key_count as u64), + ), + ) + } + }; + // Convert the required credits back to the minimum asset-lock duffs + // (ceil, so rounding never lands us a duff short). + required_credits.div_ceil(CREDITS_PER_DUFF) +} + +/// Message for a proof-wait timeout that makes the failure NON-stranding. +/// The funds are recoverable via the saved one-time key either way, but the +/// finality claim must match what Core actually reported: `chainlocked=false` +/// means the tx may still be unconfirmed/in the mempool (verify in Core +/// first), while `chainlocked=true` means only Platform's catch-up timed out. +fn strand_safe_timeout(txid: &Txid, out_env: &str, chainlocked: bool) -> String { + let state = if chainlocked { + "The asset lock IS broadcast and ChainLocked on L1 — only Platform's catch-up to that \ + core height timed out. The funds are NOT lost; re-running will finish once Platform \ + has caught up." + .to_string() + } else { + format!( + "The asset lock was broadcast but Core has NOT yet reported it as ChainLocked — it may \ + still be unconfirmed or in the mempool. The funds are NOT lost. First verify in Core \ + (`getrawtransaction {txid} 1` → chainlock:true); resume only once it is chainlocked." + ) + }; format!( - "timed out waiting for {waiting_for}.\n\ - The asset lock IS broadcast and on-chain — the funds are NOT lost, only pending.\n\ - The one-time key that owns it is saved in {out_env} (ASSET_LOCK_ONE_TIME_WIF).\n\ + "timed out waiting for the ChainLock proof.\n\ + {state}\n\ + The one-time key that owns the lock is saved in {out_env} (ASSET_LOCK_ONE_TIME_WIF__{txid}).\n\ Resume WITHOUT spending new funds by re-running the same command plus:\n\ \x20 --resume-txid {txid}\n\ Do NOT re-run without --resume-txid — that broadcasts a second asset lock and strands this one." @@ -250,12 +303,31 @@ async fn run() -> Result<(), String> { let network = parse_network(&args.network)?; let platform_version = PlatformVersion::latest(); + // Key count: lower bound (need MASTER + HIGH + CRITICAL) and Platform's + // versioned upper bound. Exceeding the max would sign and broadcast the + // irreversible funding tx before the SDK rejects the over-large transition. + let max_key_count = platform_version + .dpp + .state_transitions + .identities + .max_public_keys_in_creation; if args.key_count < 3 { return Err("--key-count must be >= 3 (need MASTER + CRITICAL + HIGH keys)".to_string()); } + if args.key_count > max_key_count as u32 { + return Err(format!( + "--key-count {} exceeds Platform's identity-create limit of {} \ + (max_public_keys_in_creation); the transition would be rejected AFTER the funding \ + transaction is broadcast", + args.key_count, max_key_count + )); + } let core_password = std::env::var("CORE_RPC_PASSWORD") .ok() + // An exported-but-empty CORE_RPC_PASSWORD must NOT shadow + // --core-rpc-password; treat it as unset. + .filter(|p| !p.is_empty()) .or(args.core_rpc_password.clone()) .ok_or_else(|| { "Core RPC password required: set CORE_RPC_PASSWORD or pass --core-rpc-password" @@ -266,6 +338,21 @@ async fn run() -> Result<(), String> { if amount_duffs == 0 { return Err("--fund-dash must be > 0".to_string()); } + // Reject an asset lock below the identity-create minimum BEFORE locking + // funds: a smaller lock is broadcast and mined, then its identity-create + // fails required-balance validation and the funds are stranded. Only the + // fresh path spends; a resume reuses an existing lock. + if args.resume_txid.is_none() { + let min_duffs = min_asset_lock_duffs(args.key_count, platform_version); + if amount_duffs < min_duffs { + return Err(format!( + "--fund-dash {} = {} duffs is below the identity-create minimum of {} duffs for {} \ + keys (asset-lock floor + base + per-key create fee); a smaller lock would be \ + broadcast and then stranded when registration fails its required-balance check", + args.fund_dash, amount_duffs, min_duffs, args.key_count + )); + } + } // --- Platform SDK (DAPI + Core for quorum public keys) --- let addresses = args @@ -340,21 +427,22 @@ async fn run() -> Result<(), String> { // --- Obtain the asset lock: resume an existing one, or create+broadcast --- let (txid, one_time_private_key): (Txid, PrivateKey) = if let Some(resume) = &args.resume_txid { // RESUME: reuse an already-broadcast asset lock — NO new funds spent. - // The one-time key that owns the lock is read from the credentials - // file written before the original broadcast; the freshly generated - // identity keys are recorded now so the resumed identity is - // recoverable too. + // Read the one-time key SCOPED TO THIS txid, so a credentials file that + // accumulated several asset locks cannot pair the requested txid with a + // different lock's one-time key. Identity keys are written on success + // below, not here. let txid = Txid::from_str(resume.trim()).map_err(|e| format!("invalid --resume-txid: {e}"))?; - let wif = read_creds_value(&args.out_env, "ASSET_LOCK_ONE_TIME_WIF")?.ok_or_else(|| { + let wif_key = format!("ASSET_LOCK_ONE_TIME_WIF__{txid}"); + let wif = read_creds_value(&args.out_env, &wif_key)?.ok_or_else(|| { format!( - "resume needs ASSET_LOCK_ONE_TIME_WIF in {} (the one-time key that owns the asset lock)", + "resume found no {wif_key} in {} — the one-time key for txid {txid} is not recorded \ + there; point --out-env at the credentials file written when this lock was created", args.out_env ) })?; let one_time_private_key = PrivateKey::from_wif(wif.trim()) - .map_err(|e| format!("failed to parse ASSET_LOCK_ONE_TIME_WIF: {e}"))?; - append_creds(&args.out_env, &identity_creds)?; + .map_err(|e| format!("failed to parse {wif_key}: {e}"))?; eprintln!( "Resuming existing asset-lock tx {txid} (no new broadcast; no new funds spent)..." ); @@ -370,7 +458,17 @@ async fn run() -> Result<(), String> { let one_time_key_hash = one_time_public_key.pubkey_hash(); let one_time_address = Address::p2pkh(&one_time_public_key, network); - let tx = build_asset_lock_transaction(&core, amount_duffs, &one_time_key_hash)?; + let tx = build_asset_lock_transaction( + &core, + amount_duffs, + &one_time_key_hash, + platform_version + .dpp + .state_transitions + .identities + .asset_locks + .max_asset_lock_transaction_inputs, + )?; let unsigned_hex = hex::encode(serialize(&tx)); eprintln!( @@ -393,33 +491,59 @@ async fn run() -> Result<(), String> { deserialize(&signed.hex).map_err(|e| format!("failed to parse signed tx: {e}"))?; let txid = signed_tx.txid(); - // Capture recovery credentials BEFORE broadcast: once on-chain, the - // locked funds are recoverable only via the one-time key + outpoint. - let mut pre_creds: Vec<(String, String)> = vec![ - ("ASSET_LOCK_TXID".to_string(), txid.to_string()), - ( - "ASSET_LOCK_ONE_TIME_WIF".to_string(), - one_time_private_key.to_wif(), - ), - ( - "ASSET_LOCK_ONE_TIME_ADDRESS".to_string(), - one_time_address.to_string(), - ), - ( - "ASSET_LOCK_FUND_DASH".to_string(), - args.fund_dash.to_string(), - ), - ]; - pre_creds.extend(identity_creds.iter().cloned()); - append_creds(&args.out_env, &pre_creds)?; + // Capture recovery credentials BEFORE broadcast, SCOPED TO THIS txid so + // multiple asset locks in one file never cross-contaminate. Identity + // keys are written only on success (below), so the file never advertises + // keys that do not control the registered identity. + append_creds( + &args.out_env, + &[ + ("ASSET_LOCK_TXID".to_string(), txid.to_string()), + ( + format!("ASSET_LOCK_ONE_TIME_WIF__{txid}"), + one_time_private_key.to_wif(), + ), + ( + format!("ASSET_LOCK_ONE_TIME_ADDRESS__{txid}"), + one_time_address.to_string(), + ), + ( + format!("ASSET_LOCK_FUND_DASH__{txid}"), + args.fund_dash.to_string(), + ), + ], + )?; eprintln!( - "Recovery credentials written to {} before broadcast.", + "Recovery credentials (scoped to {txid}) written to {} before broadcast.", args.out_env ); - let broadcast_txid = core - .send_raw_transaction(signed_hex.as_str()) - .map_err(|e| format!("sendrawtransaction failed: {e}"))?; + // sendrawtransaction can return a transport error AFTER Core already + // accepted the tx. Do not treat that as a clean failure: check Core, + // proceed if the tx is there, otherwise report the ambiguity and the + // resume path rather than inviting a blind re-run (a second lock). + let broadcast_txid = match core.send_raw_transaction(signed_hex.as_str()) { + Ok(t) => t, + Err(e) => match core.get_raw_transaction_info(&txid, None) { + Ok(_) => { + eprintln!( + "sendrawtransaction returned an error ({e}) but tx {txid} is present in \ + Core — treating as broadcast." + ); + txid + } + Err(_) => { + return Err(format!( + "sendrawtransaction failed ({e}) and tx {txid} is NOT yet visible in Core \ + — acceptance is ambiguous. Recovery credentials (scoped to {txid}) are \ + saved in {}. Check Core: `getrawtransaction {txid} 1`. If it appears, \ + resume with --resume-txid {txid}; do NOT blindly re-run, which could \ + broadcast a second asset lock.", + args.out_env + )); + } + }, + }; if broadcast_txid != txid { return Err(format!( "broadcast txid {broadcast_txid} does not match signed txid {txid}" @@ -434,15 +558,20 @@ async fn run() -> Result<(), String> { // 1. Wait for Core to report the tx as ChainLocked and give its height. let core_chain_locked_height: u32 = loop { if started.elapsed() > PROOF_TIMEOUT { - return Err(strand_safe_timeout( - &txid, - &args.out_env, - "the asset-lock tx to be ChainLocked", - )); + // Not yet chainlocked when we gave up. + return Err(strand_safe_timeout(&txid, &args.out_env, false)); } - let info = core - .get_raw_transaction_info(&txid, None) - .map_err(|e| format!("getrawtransaction failed: {e}"))?; + // A transient Core RPC error after broadcast is retryable — the asset + // lock is already on-chain, so keep polling until PROOF_TIMEOUT rather + // than forcing manual recovery over a brief transport blip. + let info = match core.get_raw_transaction_info(&txid, None) { + Ok(info) => info, + Err(e) => { + eprintln!("transient: getrawtransaction failed ({e}); retrying in 2s..."); + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + }; if info.chainlock { let h = info .height @@ -460,15 +589,20 @@ async fn run() -> Result<(), String> { // drive will accept the proof. loop { if started.elapsed() > PROOF_TIMEOUT { - return Err(strand_safe_timeout( - &txid, - &args.out_env, - "platform to reach the ChainLocked core height", - )); + // Chainlocked on L1; only Platform's catch-up timed out. + return Err(strand_safe_timeout(&txid, &args.out_env, true)); } - let (_epoch, metadata) = Epoch::fetch_current_with_metadata(&sdk) - .await - .map_err(|e| format!("failed to fetch platform metadata: {e}"))?; + // Transient DAPI metadata-fetch errors are retryable for the same + // reason: the lock is chainlocked and recoverable, so retry until the + // timeout instead of bailing to manual recovery. + let metadata = match Epoch::fetch_current_with_metadata(&sdk).await { + Ok((_epoch, metadata)) => metadata, + Err(e) => { + eprintln!("transient: platform metadata fetch failed ({e}); retrying in 2s..."); + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + }; if metadata.core_chain_locked_height >= core_chain_locked_height { break; } @@ -511,7 +645,33 @@ async fn run() -> Result<(), String> { let balance = registered.balance(); let total_elapsed = started.elapsed(); - // Persist final identity facts (key material already written pre-broadcast). + // Write the identity key material ONLY now that registration succeeded, and + // ONLY if the on-chain identity actually carries the keys we generated. If + // the asset lock had already produced an identity in a prior run, + // put_to_platform resolves the AlreadyExists response by fetching that + // identity — whose keys are NOT ours. Advertising ours would hand out keys + // that cannot sign for it. + let our_key_data: std::collections::BTreeSet> = key_material + .iter() + .map(|(pk, _)| pk.data().as_slice().to_vec()) + .collect(); + let onchain_key_data: std::collections::BTreeSet> = registered + .public_keys() + .values() + .map(|pk| pk.data().as_slice().to_vec()) + .collect(); + if our_key_data == onchain_key_data { + append_creds(&args.out_env, &identity_creds)?; + } else { + eprintln!( + "WARNING: identity {identity_id_b58} already existed with different keys (from a prior \ + run); this run's freshly generated keys do NOT control it and were NOT written to {}. \ + The controlling keys are whatever the original run saved.", + args.out_env + ); + } + + // Persist final identity facts. append_creds( &args.out_env, &[ @@ -549,6 +709,7 @@ fn build_asset_lock_transaction( core: &Client, amount_duffs: u64, one_time_key_hash: &dpp::dashcore::PubkeyHash, + max_inputs: u16, ) -> Result { let target = amount_duffs .checked_add(ASSET_LOCK_FEE_DUFFS) @@ -583,6 +744,19 @@ fn build_asset_lock_transaction( "faucet wallet has insufficient spendable funds: need {target} duffs, have {selected}" )); } + // Platform rejects an asset-lock proof whose transaction has more than + // `max_asset_lock_transaction_inputs` inputs. Core would still sign, mine, + // and chainlock such a tx, but every Platform use of its proof would be + // invalid — the locked output permanently unusable. Refuse before signing. + if inputs.len() > max_inputs as usize { + return Err(format!( + "funding this amount needs {} inputs, but Platform's asset-lock cap is {} \ + (max_asset_lock_transaction_inputs); the lock would be unusable. Consolidate the \ + faucet wallet into fewer, larger UTXOs or lock a smaller amount", + inputs.len(), + max_inputs + )); + } let change_script = change_script.expect("at least one input selected"); // Regular outputs: burn (locks the value on L1) + change. @@ -683,7 +857,12 @@ mod tests { let txid = Txid::from_str("c913da3655688c10c79e0d8b8e059c94625b939cfa99e848f0f24dc48ec4f685") .unwrap(); - let msg = strand_safe_timeout(&txid, "/tmp/creds.env", "the tx to be ChainLocked"); + // Post-ChainLock timeout message. + let msg = strand_safe_timeout(&txid, "/tmp/creds.env", true); + // Pre-ChainLock message must NOT assert L1 finality it hasn't established. + let pre = strand_safe_timeout(&txid, "/tmp/creds.env", false); + assert!(pre.contains("--resume-txid") && pre.to_lowercase().contains("not lost")); + assert!(msg.contains("ChainLocked on L1")); // Must name the txid, the creds path, and the exact resume flag, and must // reassure the funds are not lost — that is the whole point of the change. assert!(msg.contains("c913da3655688c10c79e0d8b8e059c94625b939cfa99e848f0f24dc48ec4f685")); @@ -692,6 +871,28 @@ mod tests { assert!(msg.to_lowercase().contains("not lost")); } + #[test] + fn min_asset_lock_clears_the_floor_and_grows_with_keys() { + // The minimum guard exists so a lock below the identity-create + // requirement is never broadcast (which would strand it): the computed + // minimum must be at least the versioned processing-start floor and must + // increase as more keys are added. + let pv = PlatformVersion::latest(); + let floor = pv + .dpp + .state_transitions + .identities + .asset_locks + .required_asset_lock_duff_balance_for_processing_start_for_identity_create; + let min3 = min_asset_lock_duffs(3, pv); + let min6 = min_asset_lock_duffs(6, pv); + assert!( + min3 >= floor, + "minimum must clear the processing-start floor" + ); + assert!(min6 > min3, "more keys must require a larger minimum lock"); + } + #[test] fn finds_the_critical_authentication_signing_key_and_its_secret() { // The whole tool is useless if it cannot hand back a CRITICAL-level From 8b77f03b2d501b6aafe9b3833ffe12da8bf04657 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 14 Aug 2026 15:52:57 +0700 Subject: [PATCH 5/6] fix(rs-scripts): reject an empty --address list before any funding work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty endpoint vector (from --address '' or ',,') passed AddressList and SdkBuilder::build unchecked, so the asset lock was signed and broadcast and only the first Platform request then failed — stranding the funds. Same spend-before-validate class as the identity-create-minimum and input-cap guards: validation that protects a spend must run before the spend. Extracted parse_dapi_addresses() with the empty-list guard, called before SDK construction, plus a unit test. Co-Authored-By: Claude Opus 4.8 --- .../rs-scripts/src/bin/register_identity.rs | 52 +++++++++++++++---- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/packages/rs-scripts/src/bin/register_identity.rs b/packages/rs-scripts/src/bin/register_identity.rs index 6052d5cfbe..de1e9a1c03 100644 --- a/packages/rs-scripts/src/bin/register_identity.rs +++ b/packages/rs-scripts/src/bin/register_identity.rs @@ -192,6 +192,32 @@ fn parse_network(s: &str) -> Result { } } +/// Parse the comma-separated `--address` list into DAPI endpoints, rejecting +/// an EMPTY result (e.g. `""` or `",,"`). An empty endpoint list otherwise +/// flows through `AddressList::from_iter` and `SdkBuilder::build` unchecked and +/// fails only on the first Platform request — AFTER the asset-lock funding tx +/// is signed and broadcast, stranding the funds. This validation guards a +/// spend, so it must run before any funding work. +fn parse_dapi_addresses(arg: &str) -> Result, String> { + let addresses = arg + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| { + s.parse::() + .map_err(|e| format!("failed to parse address '{s}': {e}")) + }) + .collect::, String>>()?; + if addresses.is_empty() { + return Err( + "--address resolved to an empty endpoint list; provide at least one DAPI address \ + such as https://1.2.3.4:1443. Refusing before any funding work." + .to_string(), + ); + } + Ok(addresses) +} + /// Append `KEY=value` lines to the mode-600 credentials file. Creates /// the file if absent; never truncates. fn append_creds(path: &str, lines: &[(String, String)]) -> Result<(), String> { @@ -355,17 +381,10 @@ async fn run() -> Result<(), String> { } // --- Platform SDK (DAPI + Core for quorum public keys) --- - let addresses = args - .address - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| { - s.parse::() - .map_err(|e| format!("failed to parse address '{s}': {e}")) - }) - .collect::, String>>()?; - let address_list = AddressList::from_iter(addresses); + // Reject an empty endpoint list BEFORE building the SDK or doing any + // funding work (an empty list otherwise reaches the fresh path and the + // asset lock is broadcast before the first Platform request fails). + let address_list = AddressList::from_iter(parse_dapi_addresses(&args.address)?); // No HTTP quorum endpoint exists for this devnet, so the SDK fetches // quorum public keys (for proof verification) from Core RPC. @@ -822,6 +841,17 @@ mod tests { assert!(parse_network("bogus").is_err()); } + #[test] + fn dapi_addresses_reject_empty_before_any_spend() { + // An empty endpoint list must be a hard error — otherwise it reaches the + // funding path and the asset lock is broadcast before the SDK fails. + assert!(parse_dapi_addresses("").is_err()); + assert!(parse_dapi_addresses(",,").is_err()); + assert!(parse_dapi_addresses(" , , ").is_err()); + let ok = parse_dapi_addresses("https://1.2.3.4:1443,https://5.6.7.8:1443").unwrap(); + assert_eq!(ok.len(), 2); + } + #[test] fn creds_roundtrip_returns_last_value_and_file_is_mode_600() { // A resume run appends fresh identity keys after an earlier attempt's, From a03f1e2930fdeeb2950147c20c13adc52b63ac1f Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 14 Aug 2026 16:48:39 +0700 Subject: [PATCH 6/6] fix(rs-scripts): generate only signer-usable identity key types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With --key-count > 3 the shared fixture generator labels the extra authentication keys with a random type, which can be BIP13_SCRIPT_HASH. Identity-create SUCCEEDS with such a key (BIP13 needs no creation witness), but SimpleSigner::sign rejects it — the registered identity would silently carry an authentication key that can never authorize a later transition, discovered only on use. Constrain generation at the call site (the fixture is shared, so its behaviour is left unchanged for other callers) to key types the signer can actually use — ECDSA_SECP256K1, BLS12_381, ECDSA_HASH160 — regenerating any draw that includes another type. Unit tests for the predicate and for a key_count=6 identity ending up all-signable. Co-Authored-By: Claude Opus 4.8 --- .../rs-scripts/src/bin/register_identity.rs | 100 ++++++++++++++++-- 1 file changed, 93 insertions(+), 7 deletions(-) diff --git a/packages/rs-scripts/src/bin/register_identity.rs b/packages/rs-scripts/src/bin/register_identity.rs index de1e9a1c03..0932cce2f7 100644 --- a/packages/rs-scripts/src/bin/register_identity.rs +++ b/packages/rs-scripts/src/bin/register_identity.rs @@ -121,6 +121,24 @@ const DUST_DUFFS: u64 = 10_000; /// become usable. On timeout the tool still prints a resume recipe rather /// than stranding the funds (see the timeout handling in `run`). const PROOF_TIMEOUT: Duration = Duration::from_secs(600); +/// Safety bound on the key-generation retry loop (see `key_type_is_signable`). +/// Each draw has a high chance of being all-signable, so this is only a +/// runaway backstop, never reached in practice. +const MAX_KEY_GEN_ATTEMPTS: usize = 1000; + +/// Key types that `SimpleSigner` can actually sign identity transitions with — +/// a strict subset of what identity-create will *accept*. In particular it +/// EXCLUDES `BIP13_SCRIPT_HASH`, which identity-create accepts (no creation +/// witness) but the signer rejects, and `EDDSA_25519_HASH160`, which the signer +/// can technically sign but which we do not generate. Restricting generation to +/// this set guarantees every key on the identity can authorize later +/// transitions. +fn key_type_is_signable(kt: KeyType) -> bool { + matches!( + kt, + KeyType::ECDSA_SECP256K1 | KeyType::BLS12_381 | KeyType::ECDSA_HASH160 + ) +} #[derive(Parser, Debug)] #[command( @@ -414,14 +432,42 @@ async fn run() -> Result<(), String> { // The identity id derives from the asset-lock outpoint, not from these // keys, so the key set is generated the same way in both the fresh and // resume paths. + // + // Constrain the key TYPES at the call site (the shared fixture generator is + // used by other callers, so we do not change its behaviour): for + // --key-count > 3 it labels the extra authentication keys with a random + // type, which can be BIP13_SCRIPT_HASH. Identity-create SUCCEEDS with such a + // key (BIP13 needs no creation witness), but SimpleSigner::sign REJECTS it — + // the identity would silently carry an authentication key that can never + // authorize a later transition. Regenerate any draw that includes a key + // type the signer cannot use, so the identity we register is fully usable. let mut rng = StdRng::from_entropy(); - let (identity, key_material): (Identity, Vec<(IdentityPublicKey, [u8; 32])>) = - Identity::random_identity_with_main_keys_with_private_key( - args.key_count, - &mut rng, - platform_version, - ) - .map_err(|e| format!("failed to generate identity: {e}"))?; + let mut attempts = 0usize; + let (identity, key_material): (Identity, Vec<(IdentityPublicKey, [u8; 32])>) = loop { + attempts += 1; + let generated: (Identity, Vec<(IdentityPublicKey, [u8; 32])>) = + Identity::random_identity_with_main_keys_with_private_key( + args.key_count, + &mut rng, + platform_version, + ) + .map_err(|e| format!("failed to generate identity: {e}"))?; + if generated + .0 + .public_keys() + .values() + .all(|pk| key_type_is_signable(pk.key_type())) + { + break generated; + } + if attempts >= MAX_KEY_GEN_ATTEMPTS { + return Err(format!( + "could not generate an identity whose {} keys are all signer-usable after {} \ + attempts (unexpected — check the key-type constraint)", + args.key_count, attempts + )); + } + }; // Encode the identity key material for the credentials file. let mut identity_creds: Vec<(String, String)> = Vec::new(); @@ -852,6 +898,46 @@ mod tests { assert_eq!(ok.len(), 2); } + #[test] + fn only_signer_usable_key_types_are_accepted() { + // BIP13_SCRIPT_HASH is the trap — identity-create accepts it but + // SimpleSigner::sign rejects it. It (and EDDSA, which we do not + // generate) must be excluded from the constraint. + assert!(key_type_is_signable(KeyType::ECDSA_SECP256K1)); + assert!(key_type_is_signable(KeyType::BLS12_381)); + assert!(key_type_is_signable(KeyType::ECDSA_HASH160)); + assert!(!key_type_is_signable(KeyType::BIP13_SCRIPT_HASH)); + assert!(!key_type_is_signable(KeyType::EDDSA_25519_HASH160)); + } + + #[test] + fn max_key_count_identity_ends_up_all_signable() { + // Mirror the call-site regenerate loop at the max key count (where the + // fixture adds random-typed extra keys) and confirm the accepted + // identity holds only signer-usable keys. + let pv = PlatformVersion::latest(); + let mut rng = StdRng::seed_from_u64(7); + let mut attempts = 0; + let identity = loop { + attempts += 1; + let (id, _km): (Identity, Vec<(IdentityPublicKey, [u8; 32])>) = + Identity::random_identity_with_main_keys_with_private_key(6, &mut rng, pv).unwrap(); + if id + .public_keys() + .values() + .all(|pk| key_type_is_signable(pk.key_type())) + { + break id; + } + assert!(attempts < MAX_KEY_GEN_ATTEMPTS, "loop should converge"); + }; + assert!(identity.public_keys().len() >= 6); + assert!(identity + .public_keys() + .values() + .all(|pk| key_type_is_signable(pk.key_type()))); + } + #[test] fn creds_roundtrip_returns_last_value_and_file_is_mode_600() { // A resume run appends fresh identity keys after an earlier attempt's,