use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::json;
use crate::config::{self, NetworkConfig};
use crate::near::{AccessKeyPerm, ContractCaller, NearClient};
#[derive(Debug, Deserialize)]
pub struct VaultStateView {
pub parent: String,
pub keystore_dao: String,
pub mpc_contract: String,
#[serde(default)]
pub initial_tee_key: Option<String>,
pub registered_tee_keys: Vec<String>,
pub recovery: Option<RecoveryStateView>,
pub unlocked: bool,
pub unilateral_exit_window_secs: u64,
}
#[derive(Debug, Deserialize)]
pub struct RecoveryStateView {
pub initiated_at: u64,
pub finalize_after: u64,
pub finalize_before: u64,
pub trigger: RecoveryTriggerView,
}
#[derive(Debug, Deserialize)]
pub enum RecoveryTriggerView {
Cessation,
Unilateral,
}
const VAULT_INITIAL_NEAR: u128 = 1_000_000_000_000_000_000_000_000;
const VAULT_PARENT_BUDGET_NEAR: u128 = VAULT_INITIAL_NEAR + 100_000_000_000_000_000_000_000;
const VAULT_NEW_GAS: u64 = 30_000_000_000_000;
pub async fn init(
network: &NetworkConfig,
name: &str,
parent: Option<String>,
exit_window: &str,
webhook_url: Option<&str>,
) -> Result<()> {
let exit_window_secs = parse_exit_window(exit_window)?;
if name.is_empty() || name.contains('.') {
anyhow::bail!(
"--name must be a single sub-account label (got '{name}'). \
E.g. 'vault' produces 'vault.<your-account>.near'"
);
}
let creds = config::load_credentials(network)
.with_context(|| "`outlayer vault init` requires a logged-in NEAR account")?;
if creds.is_wallet_key() {
anyhow::bail!(
"`outlayer vault init` requires a NEAR full-access key \
(custody-wallet auth not supported — the parent NEAR account itself signs \
the atomic deploy). Re-login with `outlayer login {}`.",
network.network_id
);
}
let parent_account = match parent {
Some(p) if p != creds.account_id => {
anyhow::bail!(
"--parent {} does not match the logged-in account {}. \
The parent is immutable post-deploy and is the ONLY account that \
can recover this vault — a typo here is unrecoverable. \
If you really mean to deploy with a different parent, log in as \
that account first (so its private key signs the deploy and \
controls recovery).",
p, creds.account_id
);
}
Some(p) => p,
None => creds.account_id.clone(),
};
let vault_account_id = format!("{name}.{parent_account}");
eprintln!("Vault deploy plan:");
eprintln!(" parent: {parent_account}");
eprintln!(" vault account: {vault_account_id}");
eprintln!(" exit window: {} ({}s)", format_seconds_human(exit_window_secs), exit_window_secs);
eprintln!(
" initial: {} yoctoNEAR (~{:.2} NEAR)",
VAULT_INITIAL_NEAR,
VAULT_INITIAL_NEAR as f64 / 1e24,
);
eprintln!();
let near = NearClient::new(network);
let api = crate::api::ApiClient::new(network);
let existing = near
.view_account_info(&vault_account_id)
.await
.with_context(|| format!("failed to probe {vault_account_id}"))?;
if existing.exists {
anyhow::bail!(
"Account {vault_account_id} already exists (balance {} yoctoNEAR). \
If a previous init crashed before /customer/register completed, \
use `outlayer vault resume {vault_account_id}` to finish registration. \
Otherwise pick a different --name.",
existing.amount_yocto
);
}
let parent_info = near
.view_account_info(&parent_account)
.await
.with_context(|| format!("failed to probe parent account {parent_account}"))?;
if !parent_info.exists {
anyhow::bail!(
"Parent account {parent_account} does not exist on {} — \
cannot deploy a sub-account from a non-existent parent.",
network.network_id
);
}
if parent_info.amount_yocto < VAULT_PARENT_BUDGET_NEAR {
anyhow::bail!(
"Parent account {parent_account} has only {} yoctoNEAR (~{:.3} NEAR); \
vault deploy needs at least {} yoctoNEAR (~{:.3} NEAR) — \
{} for vault.transfer + ~0.1 NEAR gas headroom. \
Top up the parent and retry.",
parent_info.amount_yocto,
parent_info.amount_yocto as f64 / 1e24,
VAULT_PARENT_BUDGET_NEAR,
VAULT_PARENT_BUDGET_NEAR as f64 / 1e24,
VAULT_INITIAL_NEAR,
);
}
eprintln!("[1/6] Resolving vault code hash from {}...", network.keystore_dao_id);
let code_hash_b58 = resolve_vault_code_hash(&near, &network.keystore_dao_id).await?;
eprintln!(" Using approved vault code hash: {code_hash_b58}");
eprintln!("[2/6] Fetching TEE function-call pubkey for {vault_account_id}...");
let tee_pubkey_str = api
.derive_vault_tee_key(&vault_account_id)
.await
.context("Failed to derive TEE pubkey from coordinator")?;
eprintln!(" TEE pubkey: {tee_pubkey_str}");
let tee_pubkey: near_crypto::PublicKey = tee_pubkey_str
.parse()
.with_context(|| format!("coordinator returned malformed TEE pubkey '{tee_pubkey_str}'"))?;
eprintln!("[3/6] Building atomic-deploy tx (5 actions, UseGlobalContract by hash)...");
let signer = match ContractCaller::from_credentials(&creds, network)? {
ContractCaller::Local(s) => s,
ContractCaller::Wallet { .. } => unreachable!("wallet auth refused above"),
};
let vault_account: near_primitives::types::AccountId = vault_account_id
.parse()
.with_context(|| format!("'{vault_account_id}' is not a valid NEAR account id"))?;
let mpc_contract: near_primitives::types::AccountId = network
.mpc_contract_id
.parse()
.with_context(|| format!("invalid mpc_contract_id '{}'", network.mpc_contract_id))?;
let new_args = json!({
"parent": parent_account,
"keystore_dao": network.keystore_dao_id,
"mpc_contract": network.mpc_contract_id,
"initial_tee_pubkey": tee_pubkey.to_string(),
"initial_exit_window": exit_window_secs,
});
let vault_code_hash = base58_decode_32(&code_hash_b58)?;
eprintln!(" Broadcasting (CreateAccount + Transfer + UseGlobalContract + new() + AddKey atomically)...");
let outcome = signer
.atomic_deploy_vault(
&vault_account,
VAULT_INITIAL_NEAR,
vault_code_hash,
new_args,
VAULT_NEW_GAS,
tee_pubkey,
&mpc_contract,
)
.await
.context("atomic-deploy transaction failed")?;
let tx_id = outcome.transaction_outcome.id.to_string();
eprintln!(" Tx hash: {tx_id}");
if let near_primitives::views::FinalExecutionStatus::Failure(err) = &outcome.status {
anyhow::bail!(
"atomic deploy reverted (tx: {tx_id}): {:?} — vault account state is rolled back, retry safely.",
err
);
}
await_vault_visible(&near, &vault_account_id, &code_hash_b58).await?;
eprintln!("[4/5] Triggering keystore re-verification (mark_vault_verified)...");
let verify_resp = match api.sign_vault_verification(&vault_account_id).await {
Ok(v) => v,
Err(e) => {
anyhow::bail!(
"verification call failed — the vault account is deployed but \
NOT yet verified on chain. Retry with:\n\n \
outlayer vault resume {}\n\n\
(it picks up at step 4 idempotently). Underlying error: {:#}",
vault_account_id, e
);
}
};
if verify_resp.already_verified {
eprintln!(" Vault was already verified on chain (idempotent re-run).");
} else if let Some(h) = &verify_resp.tx_hash {
eprintln!(" mark_vault_verified tx: {h}");
}
if webhook_url.is_some() {
eprintln!();
eprintln!("Note: --webhook-url is ignored without a custody wallet bound to the vault.");
eprintln!(" Pass it when minting an API key instead (POST /register).");
}
eprintln!("[5/5] Done.");
eprintln!();
eprintln!("Vault deployed and verified:");
eprintln!(" vault: {vault_account_id}");
eprintln!();
eprintln!("To mint a custody API key for this vault (run any number of times \
for separate wallets, e.g. one per agent):");
eprintln!();
eprintln!(
" curl -s -X POST {}/register \\\n -H 'Content-Type: application/json' \\\n -d '{{\"vault_id\":\"{vault_account_id}\"}}'",
network.api_base_url,
);
eprintln!();
eprintln!("Inspect: outlayer vault status {vault_account_id}");
eprintln!("Verify: outlayer vault verify {vault_account_id}");
Ok(())
}
pub async fn resume(network: &NetworkConfig, account: &str) -> Result<()> {
let near = NearClient::new(network);
let api = crate::api::ApiClient::new(network);
eprintln!("Resuming vault init for: {account}");
let info = near
.view_account_info(account)
.await
.with_context(|| format!("failed to probe {account}"))?;
if !info.exists {
anyhow::bail!(
"Cannot resume — vault account {account} does NOT exist. \
A `vault init` whose atomic-deploy tx never landed leaves no on-chain \
state, so just re-run `outlayer vault init` from scratch."
);
}
eprintln!("[1/1] Triggering keystore re-verification (mark_vault_verified)...");
let verify_resp = api
.sign_vault_verification(account)
.await
.with_context(|| "verification call failed during resume")?;
if verify_resp.already_verified {
eprintln!(" Vault was already verified on chain (idempotent re-run).");
} else if let Some(h) = &verify_resp.tx_hash {
eprintln!(" mark_vault_verified tx: {h}");
}
eprintln!();
eprintln!("Vault: {account}");
eprintln!();
eprintln!("To mint a custody API key for this vault:");
eprintln!();
eprintln!(
" curl -s -X POST {}/register \\\n -H 'Content-Type: application/json' \\\n -d '{{\"vault_id\":\"{account}\"}}'",
network.api_base_url,
);
Ok(())
}
#[derive(Debug, Deserialize)]
struct VaultVersionInfo {
label: String,
deprecated: bool,
approved_at: u64,
}
async fn resolve_vault_code_hash(near: &NearClient, dao_id: &str) -> Result<String> {
let versions: Vec<(String, VaultVersionInfo)> = near
.view_call_on(dao_id, "list_approved_vault_versions", json!({}))
.await
.with_context(|| {
format!("list_approved_vault_versions view-call failed on {dao_id}")
})?;
let mut candidate: Option<(String, VaultVersionInfo)> = None;
for (hash, info) in versions {
if info.deprecated {
continue;
}
if candidate
.as_ref()
.map_or(true, |(_, prev)| info.approved_at > prev.approved_at)
{
candidate = Some((hash, info));
}
}
let (hash, info) = candidate.ok_or_else(|| {
anyhow::anyhow!(
"{dao_id} has no non-deprecated approved vault code hash. \
Operator must publish a vault WASM as a global contract \
(`near contract deploy-as-global ... as-global-hash`) and \
approve the resulting hash via `approve_vault_version`."
)
})?;
eprintln!(
" → label=\"{}\", approved_at_ns={}",
info.label, info.approved_at
);
Ok(hash)
}
fn base58_decode_32(b58: &str) -> Result<[u8; 32]> {
let raw = bs58::decode(b58)
.into_vec()
.with_context(|| format!("vault code hash '{b58}' is not valid base58"))?;
if raw.len() != 32 {
anyhow::bail!(
"vault code hash '{b58}' decoded to {} bytes; expected 32 (sha256)",
raw.len()
);
}
let mut out = [0u8; 32];
out.copy_from_slice(&raw);
Ok(out)
}
async fn await_vault_visible(
near: &NearClient,
vault_account_id: &str,
expected_global_hash: &str,
) -> Result<()> {
use std::time::{Duration, Instant};
let started = Instant::now();
let timeout = Duration::from_secs(30);
let interval = Duration::from_secs(1);
#[allow(unused_assignments)]
let mut last_err: String = "no probe ran".into();
eprintln!(" Waiting for vault to be visible at final finality...");
loop {
match near.view_account_info(vault_account_id).await {
Ok(info) if info.exists => {
match info.effective_code_hash() {
Some(h) if h == expected_global_hash => return Ok(()),
Some(h) => {
anyhow::bail!(
"vault account {vault_account_id} is on chain but its \
code hash {h} ≠ requested {expected_global_hash}. \
This shouldn't happen with a clean atomic deploy; \
inspect the tx receipts before continuing."
);
}
None => {
last_err = "account exists but no contract attached yet".into();
}
}
}
Ok(_) => {
last_err = "account not yet visible".into();
}
Err(e) => {
last_err = format!("{e:#}");
}
}
if started.elapsed() >= timeout {
anyhow::bail!(
"vault account {vault_account_id} did not reach final finality \
within {}s; the atomic deploy may still be propagating. \
Resume idempotently with:\n\n \
outlayer vault resume {vault_account_id}\n\n\
(last probe: {last_err})",
timeout.as_secs(),
);
}
tokio::time::sleep(interval).await;
}
}
pub async fn status(network: &NetworkConfig, account: &str) -> Result<()> {
let near = NearClient::new(network);
let state = fetch_vault_state(&near, account).await?;
println!("Vault: {account}");
println!("Parent: {}", state.parent);
println!("Keystore-DAO: {}", state.keystore_dao);
println!("MPC contract: {}", state.mpc_contract);
println!(
"Status: {}",
if state.unlocked { "UNLOCKED (recovered)" } else { "locked (TEE-controlled)" },
);
println!(
"Exit window: {}",
format_seconds_human(state.unilateral_exit_window_secs),
);
match &state.initial_tee_key {
Some(k) => println!("Initial TEE key: {k}"),
None => println!("Initial TEE key: (none — pre-launch test vault)"),
}
println!(
"Registered TEE keys (DAO-rotated): {}",
state.registered_tee_keys.len()
);
for k in &state.registered_tee_keys {
println!(" • {k}");
}
if let Some(rec) = &state.recovery {
let trigger = match rec.trigger {
RecoveryTriggerView::Cessation => "cessation",
RecoveryTriggerView::Unilateral => "unilateral",
};
println!();
println!("Recovery in progress ({trigger}):");
println!(" initiated_at {} (ns)", rec.initiated_at);
println!(" finalize_after {} (ns)", rec.finalize_after);
println!(" finalize_before {} (ns)", rec.finalize_before);
} else {
println!("Recovery: none in progress");
}
Ok(())
}
pub async fn verify(network: &NetworkConfig, account: &str) -> Result<()> {
let near = NearClient::new(network);
println!("Verifying vault: {account}");
println!("(network: {})", network.network_id);
println!();
let mut warnings: Vec<String> = Vec::new();
let info = near
.view_account_info(account)
.await
.with_context(|| format!("failed to probe {account}"))?;
if !info.exists {
println!("Vault account {account} does NOT exist on {}.", network.network_id);
println!();
println!("Result: NOT VERIFIED — no contract deployed.");
return Ok(());
}
let is_verified: bool = near
.view_call_on(
&network.keystore_dao_id,
"is_vault_verified",
json!({ "vault_id": account }),
)
.await
.with_context(|| {
format!(
"is_vault_verified view-call failed on {} — vault may not exist",
network.keystore_dao_id
)
})?;
println!(
"[1/5] keystore-dao.is_vault_verified : {}",
if is_verified { "TRUE (verified)" } else { "FALSE (not verified — do NOT trust)" },
);
if !is_verified {
warnings.push("vault is NOT in keystore-dao.verified_vaults".into());
}
let acct = near
.view_account_info(account)
.await
.with_context(|| format!("failed to fetch account info for {account}"))?;
match acct.effective_code_hash() {
None => {
warnings.push(format!("no contract deployed at {account}"));
println!("[2/5] code hash : NONE (no contract deployed)");
}
Some(hash) => {
let kind = if acct.global_contract_hash.is_some() {
"global"
} else {
"local"
};
let approved: bool = near
.view_call_on(
&network.keystore_dao_id,
"is_vault_code_approved",
json!({ "hash": hash }),
)
.await
.with_context(|| "is_vault_code_approved view-call failed".to_string())?;
println!(
"[2/5] vault code hash ({kind}) : {hash} {}",
if approved { "APPROVED" } else { "NOT APPROVED" }
);
if !approved {
warnings.push(format!(
"vault code hash {hash} is not in keystore-dao approved set"
));
}
}
}
let state = match fetch_vault_state(&near, account).await {
Ok(s) => s,
Err(e) => {
println!("[3/5] vault.get_state() : FAILED ({e})");
warnings.push(format!("get_state failed: {e}"));
print_warnings(&warnings, is_verified, false);
return Ok(());
}
};
println!(
"[3/5] vault.get_state() : OK (parent={}, keystore_dao={}, mpc={})",
state.parent, state.keystore_dao, state.mpc_contract
);
if state.keystore_dao != network.keystore_dao_id {
warnings.push(format!(
"vault.keystore_dao = {} ≠ network.keystore_dao_id = {}",
state.keystore_dao, network.keystore_dao_id
));
}
if state.mpc_contract != network.mpc_contract_id {
warnings.push(format!(
"vault.mpc_contract = {} ≠ network.mpc_contract_id = {}",
state.mpc_contract, network.mpc_contract_id
));
}
if state.unlocked {
warnings
.push("vault is UNLOCKED — parent has post-recovery key authority".into());
}
if state.registered_tee_keys.is_empty() && state.unlocked {
warnings.push("vault is unlocked but has no registered TEE keys".into());
}
if let Some(rec) = &state.recovery {
warnings.push(format!(
"recovery in progress ({:?}, finalize_after={} ns)",
rec.trigger, rec.finalize_after
));
}
println!(
" exit window : {}",
format_seconds_human(state.unilateral_exit_window_secs)
);
println!(
" initial_tee_key : {}",
state.initial_tee_key.as_deref().unwrap_or("(none — legacy vault)")
);
println!(" registered_tee_keys (rotated) : {}", state.registered_tee_keys.len());
println!(
" unlocked / recovery : {} / {}",
state.unlocked,
state.recovery.is_some()
);
let keys = near
.view_access_key_list(account)
.await
.with_context(|| format!("failed to view_access_key_list({account})"))?;
let mut bad_keys = 0usize;
for k in &keys {
match &k.permission {
AccessKeyPerm::FullAccess => {
bad_keys += 1;
warnings
.push(format!("vault has a FULL-ACCESS key {} — must not exist", k.public_key));
}
AccessKeyPerm::FunctionCall {
receiver_id,
method_names,
allowance,
} => {
let initial_tee_scope = receiver_id == account
&& method_names.len() == 1
&& method_names[0] == "request_master";
let propose_tee_scope = receiver_id == &state.mpc_contract
&& method_names.len() == 1
&& method_names[0] == "request_app_private_key";
let unlocked_self_call = state.unlocked && receiver_id == account;
let tee_scope_ok = initial_tee_scope || propose_tee_scope;
if !tee_scope_ok && !unlocked_self_call {
bad_keys += 1;
warnings.push(format!(
"access key {} has unexpected scope: receiver={}, methods={:?}",
k.public_key, receiver_id, method_names
));
}
if tee_scope_ok && allowance.is_some() {
warnings.push(format!(
"TEE access key {} has limited allowance ({:?}); \
expected Unlimited (None)",
k.public_key, allowance
));
}
}
}
}
println!(
"[4/5] access keys ({}) : {}",
keys.len(),
if bad_keys == 0 { "OK".to_string() } else { format!("{bad_keys} unexpected key(s)") }
);
let on_chain: std::collections::HashSet<&str> =
keys.iter().map(|k| k.public_key.as_str()).collect();
let missing: Vec<&String> = state
.registered_tee_keys
.iter()
.filter(|k| !on_chain.contains(k.as_str()))
.collect();
if missing.is_empty() {
println!("[5/5] registered_tee_keys ⊆ access keys: OK");
} else {
println!(
"[5/5] registered_tee_keys ⊆ access keys: {} missing",
missing.len()
);
for k in &missing {
warnings.push(format!(
"registered TEE key {k} not present on account access-key list"
));
}
}
let safe = is_verified && bad_keys == 0 && !state.unlocked;
print_warnings(&warnings, is_verified, safe);
Ok(())
}
async fn fetch_vault_state(near: &NearClient, account: &str) -> Result<VaultStateView> {
near.view_call_on(account, "get_state", json!({}))
.await
.with_context(|| format!("failed to call {account}.get_state()"))
}
fn print_warnings(warnings: &[String], is_verified: bool, safe: bool) {
println!();
if warnings.is_empty() && safe {
println!("Result: PASS — vault verified, all defense-in-depth checks OK");
return;
}
let headline = if !safe {
"NOT SAFE — defense-in-depth checks failed (do not deposit funds)"
} else if !is_verified {
"NOT VERIFIED — do not deposit funds"
} else {
"VERIFIED (with warnings — review below)"
};
println!("Result: {headline}");
for w in warnings {
println!(" ⚠ {w}");
}
}
fn format_seconds_human(secs: u64) -> String {
if secs % 86_400 == 0 && secs >= 86_400 {
format!("{} day(s) ({secs}s)", secs / 86_400)
} else if secs % 3600 == 0 && secs >= 3600 {
format!("{} hour(s) ({secs}s)", secs / 3600)
} else {
format!("{secs}s")
}
}
const GAS_VAULT_RECOVERY: u64 = 100_000_000_000_000; const GAS_VAULT_ADD_KEY: u64 = 100_000_000_000_000;
pub async fn initiate_recovery(network: &NetworkConfig, account: &str) -> Result<()> {
let caller = parent_caller(network, account, "initiate-recovery").await?;
eprintln!("Calling {account}.initiate_recovery() — DAO cessation gate is checked in the callback...");
let result = caller
.call_contract_at(account, "initiate_recovery", json!({}), GAS_VAULT_RECOVERY, 0)
.await
.context("initiate_recovery failed")?;
print_tx_hash(&result.tx_hash);
eprintln!(
"If the DAO has declared cessation, the 7-day timer is now running. \
Track progress with `outlayer vault status {account}`."
);
Ok(())
}
pub async fn initiate_unilateral_recovery(network: &NetworkConfig, account: &str) -> Result<()> {
let caller = parent_caller(network, account, "initiate-unilateral-recovery").await?;
eprintln!(
"Calling {account}.unilateral_initiate_recovery() — exit window is captured at this call."
);
let result = caller
.call_contract_at(
account,
"unilateral_initiate_recovery",
json!({}),
GAS_VAULT_RECOVERY,
0,
)
.await
.context("unilateral_initiate_recovery failed")?;
print_tx_hash(&result.tx_hash);
eprintln!(
"Unilateral recovery initiated. Run `outlayer vault status {account}` to see \
finalize_after / finalize_before timestamps."
);
Ok(())
}
pub async fn finalize_recovery(
network: &NetworkConfig,
account: &str,
new_parent_pubkey: &str,
) -> Result<()> {
let parsed_pubkey: near_crypto::PublicKey = new_parent_pubkey
.parse()
.with_context(|| format!(
"invalid new_parent_pubkey '{}' — expected ed25519:<base58> or secp256k1:<base58>",
new_parent_pubkey
))?;
let pubkey_str = parsed_pubkey.to_string();
let near = NearClient::new(network);
let state = fetch_vault_state(&near, account).await?;
if let Some(ref initial) = state.initial_tee_key {
if initial == &pubkey_str {
anyhow::bail!(
"new_parent_pubkey {pubkey_str} is the same as this vault's initial TEE key. \
The contract would try to delete and re-add the same key, which fails on chain. \
Generate a FRESH keypair (e.g. `customer-recovery generate-key`)."
);
}
}
if state.registered_tee_keys.iter().any(|k| k == &pubkey_str) {
anyhow::bail!(
"new_parent_pubkey {pubkey_str} is already a registered TEE key on this vault. \
The contract would delete it during the swap and fail to add it back. \
Generate a FRESH keypair."
);
}
let existing_keys = near
.view_access_key_list(account)
.await
.with_context(|| format!("failed to read access keys for {account}"))?;
if existing_keys.iter().any(|k| k.public_key == pubkey_str) {
anyhow::bail!(
"new_parent_pubkey {pubkey_str} is already an access key on {account} \
(probably from a prior `unlocked_add_key` or rotation). The atomic swap \
would AddKey it again and panic with AccessKeyAlreadyExists. Generate a \
FRESH keypair."
);
}
let caller = parent_caller(network, account, "finalize-recovery").await?;
eprintln!(
"Calling {account}.finalize_recovery({}) — routes by recovery.trigger.",
parsed_pubkey
);
eprintln!(
" On success the vault will atomically:\n \
- delete all TEE access keys (no more OutLayer signing)\n \
- add your pubkey as FullAccess (you own the vault)"
);
let result = caller
.call_contract_at(
account,
"finalize_recovery",
json!({ "new_parent_pubkey": parsed_pubkey.to_string() }),
GAS_VAULT_RECOVERY,
0,
)
.await
.context("finalize_recovery failed")?;
print_tx_hash(&result.tx_hash);
if result.value.as_ref().and_then(|v| v.as_bool()) == Some(false) {
eprintln!(
"Result: finalize did NOT unlock (DAO revoked cessation or window expired)"
);
} else {
eprintln!(
"Result: vault is now UNLOCKED and your key is installed.\n \
Recover the per-vault master locally with `customer-recovery` \
(see scripts/customer-recovery/README.md)."
);
}
eprintln!("Run `outlayer vault status {account}` to confirm.");
Ok(())
}
pub async fn set_exit_window(
network: &NetworkConfig,
account: &str,
window: &str,
) -> Result<()> {
let secs = parse_exit_window(window)?;
let caller = parent_caller(network, account, "set-exit-window").await?;
eprintln!(
"Calling {account}.set_exit_window({secs}) (= {})...",
format_seconds_human(secs)
);
let result = caller
.call_contract_at(
account,
"set_exit_window",
json!({ "new_window_secs": secs }),
GAS_VAULT_RECOVERY,
0,
)
.await
.context("set_exit_window failed")?;
print_tx_hash(&result.tx_hash);
eprintln!(
"Exit window updated. Future `unilateral-initiate-recovery` calls will use {}.",
format_seconds_human(secs)
);
Ok(())
}
pub async fn unlocked_add_key(
network: &NetworkConfig,
account: &str,
pubkey: &str,
full_access: bool,
) -> Result<()> {
pubkey.parse::<near_crypto::PublicKey>().with_context(|| {
format!("'{pubkey}' is not a valid NEAR public key (expected 'ed25519:...' or 'secp256k1:...')")
})?;
let caller = parent_caller(network, account, "unlocked-add-key").await?;
eprintln!(
"Calling {account}.unlocked_add_key({pubkey}, full_access={full_access})..."
);
let result = caller
.call_contract_at(
account,
"unlocked_add_key",
json!({
"public_key": pubkey,
"full_access": full_access,
"allowance": null,
}),
GAS_VAULT_ADD_KEY,
0,
)
.await
.context("unlocked_add_key failed")?;
print_tx_hash(&result.tx_hash);
eprintln!(
"Key added. Type: {}",
if full_access { "FULL ACCESS" } else { "function-call (1 NEAR allowance, all vault methods)" }
);
Ok(())
}
async fn parent_caller(
network: &NetworkConfig,
account: &str,
cmd: &str,
) -> Result<ContractCaller> {
let creds = config::load_credentials(network)
.with_context(|| format!("`outlayer vault {cmd}` requires a logged-in NEAR account"))?;
if creds.is_wallet_key() {
anyhow::bail!(
"`outlayer vault {cmd}` requires a NEAR full-access key (custody-wallet auth not supported). \
Vault recovery must be signed by the parent account directly. \
Re-login with `outlayer login {}`.",
network.network_id,
);
}
let near = NearClient::new(network);
let state = fetch_vault_state(&near, account).await.with_context(|| {
format!(
"could not read vault state for {account} — does it exist on {}?",
network.network_id
)
})?;
if state.parent != creds.account_id {
anyhow::bail!(
"`outlayer vault {cmd}` must be signed by the vault's parent account.\n \
logged-in: {}\n vault.parent: {}\n\
Re-login as {} or pass a different vault id.",
creds.account_id,
state.parent,
state.parent,
);
}
eprintln!(
"Acting as: {} → {}",
creds.account_id, account,
);
ContractCaller::from_credentials(&creds, network)
.with_context(|| "Failed to load NEAR signer".to_string())
}
fn print_tx_hash(tx_hash: &Option<String>) {
if let Some(h) = tx_hash {
eprintln!("Tx hash: {h}");
}
}
pub fn parse_exit_window(input: &str) -> Result<u64> {
let trimmed = input.trim();
if trimmed.is_empty() {
anyhow::bail!("exit window cannot be empty (use '24h', '7d', or '30d')");
}
let (num_part, unit) = trimmed.split_at(
trimmed
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(trimmed.len()),
);
if num_part.is_empty() {
anyhow::bail!(
"exit window must start with a number (e.g. '24h', '7d', '30d'); got '{}'",
input
);
}
let n: u64 = num_part
.parse()
.map_err(|e| anyhow::anyhow!("invalid number in exit window '{}': {}", input, e))?;
let secs = match unit {
"s" | "S" => Some(n),
"m" | "M" => n.checked_mul(60),
"h" | "H" => n.checked_mul(3600),
"d" | "D" => n.checked_mul(86400),
"" => anyhow::bail!(
"exit window missing unit suffix; use 's', 'm', 'h', or 'd', e.g. '180s', '3m', '24h', '7d'"
),
other => anyhow::bail!(
"unknown exit window unit '{}'; use 's', 'm', 'h', or 'd', e.g. '180s', '3m', '24h', '7d'",
other
),
}
.ok_or_else(|| anyhow::anyhow!("exit window '{}' overflows u64 seconds", input))?;
Ok(secs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_exit_window_basic() {
assert_eq!(parse_exit_window("24h").unwrap(), 86_400);
assert_eq!(parse_exit_window("7d").unwrap(), 604_800);
assert_eq!(parse_exit_window("30d").unwrap(), 2_592_000);
assert_eq!(parse_exit_window("1H").unwrap(), 3600);
}
#[test]
fn parse_exit_window_rejects_bad() {
assert!(parse_exit_window("").is_err());
assert!(parse_exit_window("h").is_err());
assert!(parse_exit_window("24").is_err());
assert!(parse_exit_window("24x").is_err());
assert!(parse_exit_window("abc").is_err());
}
}